Metadata-Version: 2.4
Name: lumenova-beacon
Version: 2.12.1
Summary: Lumenova Beacon SDK - A Python SDK for observability tracing with OpenTelemetry-compatible span export
Project-URL: Homepage, https://lumenova.ai
Author-email: Lumenova AI <support@lumenova.ai>
Maintainer-email: Lumenova AI <support@lumenova.ai>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: ai,langchain,llm,monitoring,observability,opentelemetry,sdk,tracing
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Monitoring
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.25.0
Requires-Dist: tenacity>=8.1.0
Provides-Extra: aws
Requires-Dist: boto3>=1.20.0; extra == 'aws'
Provides-Extra: crewai
Requires-Dist: crewai>=0.28.0; extra == 'crewai'
Requires-Dist: opentelemetry-api<2.0,>=1.38.0; extra == 'crewai'
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2.0,>=1.38.0; extra == 'crewai'
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2.0,>=1.38.0; extra == 'crewai'
Requires-Dist: opentelemetry-sdk<2.0,>=1.38.0; extra == 'crewai'
Provides-Extra: dev
Requires-Dist: ipython>=8.0.0; extra == 'dev'
Requires-Dist: mypy>=1.0.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
Requires-Dist: pytest>=7.0.0; extra == 'dev'
Requires-Dist: ruff<0.16,>=0.15; extra == 'dev'
Provides-Extra: examples
Requires-Dist: anthropic>=0.40.0; extra == 'examples'
Requires-Dist: boto3>=1.20.0; extra == 'examples'
Requires-Dist: crewai>=0.28.0; extra == 'examples'
Requires-Dist: faiss-cpu>=1.13.1; extra == 'examples'
Requires-Dist: fastapi>=0.115.0; extra == 'examples'
Requires-Dist: fastmcp<4,>=3.0; extra == 'examples'
Requires-Dist: google-genai>=1.0.0; extra == 'examples'
Requires-Dist: langchain-anthropic>=0.3.0; extra == 'examples'
Requires-Dist: langchain-community>=0.4.1; extra == 'examples'
Requires-Dist: langchain-core>=0.3.0; extra == 'examples'
Requires-Dist: langchain-openai>=1.1.5; extra == 'examples'
Requires-Dist: langchain>=1.0.0; extra == 'examples'
Requires-Dist: langgraph>=1.0.5; extra == 'examples'
Requires-Dist: litellm>=1.50.0; extra == 'examples'
Requires-Dist: llama-index-core>=0.12.3; extra == 'examples'
Requires-Dist: llama-index-embeddings-azure-openai>=0.3.0; extra == 'examples'
Requires-Dist: llama-index-llms-azure-openai>=0.3.0; extra == 'examples'
Requires-Dist: openai>=1.0.0; extra == 'examples'
Requires-Dist: openinference-instrumentation-google-genai>=0.1.0; extra == 'examples'
Requires-Dist: openinference-instrumentation-llama-index>=4.3.8; extra == 'examples'
Requires-Dist: opentelemetry-api<2.0,>=1.38.0; extra == 'examples'
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2.0,>=1.38.0; extra == 'examples'
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2.0,>=1.38.0; extra == 'examples'
Requires-Dist: opentelemetry-instrumentation-anthropic>=0.1.0; extra == 'examples'
Requires-Dist: opentelemetry-instrumentation-fastapi<1.0,>=0.59b0; extra == 'examples'
Requires-Dist: opentelemetry-instrumentation-httpx<1.0,>=0.59b0; extra == 'examples'
Requires-Dist: opentelemetry-instrumentation-openai>=0.1.0; extra == 'examples'
Requires-Dist: opentelemetry-instrumentation-redis<1.0,>=0.59b0; extra == 'examples'
Requires-Dist: opentelemetry-instrumentation-requests<1.0,>=0.59b0; extra == 'examples'
Requires-Dist: opentelemetry-instrumentation<1.0,>=0.59b0; extra == 'examples'
Requires-Dist: opentelemetry-sdk<2.0,>=1.38.0; extra == 'examples'
Requires-Dist: python-dotenv>=1.0.0; extra == 'examples'
Requires-Dist: redis>=5.0.0; extra == 'examples'
Requires-Dist: requests>=2.31.0; extra == 'examples'
Requires-Dist: strands-agents-tools>=0.2.0; extra == 'examples'
Requires-Dist: strands-agents>=1.15.0; extra == 'examples'
Provides-Extra: langchain
Requires-Dist: langchain-core>=0.3.0; extra == 'langchain'
Requires-Dist: opentelemetry-api<2.0,>=1.38.0; extra == 'langchain'
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2.0,>=1.38.0; extra == 'langchain'
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2.0,>=1.38.0; extra == 'langchain'
Requires-Dist: opentelemetry-sdk<2.0,>=1.38.0; extra == 'langchain'
Provides-Extra: litellm
Requires-Dist: litellm>=1.50.0; extra == 'litellm'
Requires-Dist: opentelemetry-api<2.0,>=1.38.0; extra == 'litellm'
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2.0,>=1.38.0; extra == 'litellm'
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2.0,>=1.38.0; extra == 'litellm'
Requires-Dist: opentelemetry-sdk<2.0,>=1.38.0; extra == 'litellm'
Provides-Extra: mcp
Requires-Dist: fastmcp<4,>=3.0; extra == 'mcp'
Requires-Dist: opentelemetry-api<2.0,>=1.38.0; extra == 'mcp'
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2.0,>=1.38.0; extra == 'mcp'
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2.0,>=1.38.0; extra == 'mcp'
Requires-Dist: opentelemetry-sdk<2.0,>=1.38.0; extra == 'mcp'
Provides-Extra: opentelemetry
Requires-Dist: opentelemetry-api<2.0,>=1.38.0; extra == 'opentelemetry'
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2.0,>=1.38.0; extra == 'opentelemetry'
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2.0,>=1.38.0; extra == 'opentelemetry'
Requires-Dist: opentelemetry-sdk<2.0,>=1.38.0; extra == 'opentelemetry'
Provides-Extra: strands
Requires-Dist: opentelemetry-api<2.0,>=1.38.0; extra == 'strands'
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2.0,>=1.38.0; extra == 'strands'
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2.0,>=1.38.0; extra == 'strands'
Requires-Dist: opentelemetry-sdk<2.0,>=1.38.0; extra == 'strands'
Requires-Dist: strands-agents>=1.15.0; extra == 'strands'
Provides-Extra: test
Requires-Dist: boto3>=1.20.0; extra == 'test'
Requires-Dist: crewai>=0.28.0; extra == 'test'
Requires-Dist: fastmcp<4,>=3.0; extra == 'test'
Requires-Dist: ipython>=8.0.0; extra == 'test'
Requires-Dist: langchain-core>=0.3.0; extra == 'test'
Requires-Dist: mypy>=1.0.0; extra == 'test'
Requires-Dist: opentelemetry-api<2.0,>=1.38.0; extra == 'test'
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2.0,>=1.38.0; extra == 'test'
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2.0,>=1.38.0; extra == 'test'
Requires-Dist: opentelemetry-sdk<2.0,>=1.38.0; extra == 'test'
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'test'
Requires-Dist: pytest>=7.0.0; extra == 'test'
Requires-Dist: respx>=0.20.0; extra == 'test'
Requires-Dist: ruff<0.16,>=0.15; extra == 'test'
Requires-Dist: strands-agents>=1.15.0; extra == 'test'
Description-Content-Type: text/markdown

# Lumenova Beacon SDK

[![PyPI version](https://img.shields.io/pypi/v/lumenova-beacon.svg)](https://pypi.org/project/lumenova-beacon/)
[![Python Versions](https://img.shields.io/pypi/pyversions/lumenova-beacon.svg)](https://pypi.org/project/lumenova-beacon/)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)

> A Python observability SDK for AI/LLM applications — trace agentic frameworks (LangChain, LangGraph, CrewAI, Strands), LLM calls, and custom code with OpenTelemetry-compatible spans.

## Features

- **LangChain/LangGraph Integration** - Automatic tracing for chains, agents, tools, retrievers, with interrupt/resume and agent handoff support
- **Strands Agents Integration** - Hook provider (recommended) or legacy callback handler for AWS Strands agent tracing
- **CrewAI Integration** - Event listener for CrewAI crew tracing
- **MCP Server Integration** - FastMCP middleware that traces an MCP server (tools, resources, prompts) independently of any agent
- **LiteLLM Integration** - Callback logger for LiteLLM proxy tracing
- **Agentic Governance** - Real-time policy enforcement for AI agent tool calls and LLM invocations
- **System Probes** - Run autonomous AI agents that probe your HTTP system locally (private APIs, custom auth) and produce scored markdown reports
- **OpenTelemetry Integration** - Automatic instrumentation for Anthropic, OpenAI, FastAPI, Redis, HTTPX, and more
- **Manual & Decorator Tracing** - Create spans manually or use `@trace` decorator
- **Trace Querying & Export** - Query, search, and filter the traces you sent to Beacon — and download complete traces back out for archiving or offline analysis
- **Agent Registry & Insights** - Register the agents traces are attributed to, read Beacon's AI-generated insights and recommendations, and trigger/monitor analysis runs
- **Dataset Management** - ActiveRecord-style API for managing test datasets
- **Prompt Management** - Version-controlled prompt templates with labels (staging, production)
- **Experiment & Evaluation Management** - Run experiments over datasets and evaluate results
- **Human Annotations** - Enqueue traces/spans/sessions for human review, push external feedback, read annotation summaries back
- **Data Masking** - Built-in PII detection and redaction via Beacon Guardrails
- **Flexible Transport** - HTTP or file-based span export
- **Full Async Support** - Async/await throughout

## Requirements

- Python 3.10+

## Installation

```bash
# Base installation
pip install lumenova-beacon

# With OpenTelemetry support
pip install lumenova-beacon[opentelemetry]

# With LangChain/LangGraph support
pip install lumenova-beacon[langchain]

# With LiteLLM support
pip install lumenova-beacon[litellm]

# With Strands Agents support
pip install lumenova-beacon[strands]

# With CrewAI support
pip install lumenova-beacon[crewai]

# With MCP server support (FastMCP)
pip install lumenova-beacon[mcp]

# With AWS Secrets Manager support
pip install lumenova-beacon[aws]
```

## Quick Start

### LangChain / LangGraph

```python
from lumenova_beacon import BeaconClient, BeaconLangGraphHandler
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

# Initialize client
client = BeaconClient(
    endpoint="https://your-beacon-endpoint.lumenova.ai",
    api_key="your-api-key",
)

# Create a tracing handler
handler = BeaconLangGraphHandler(session_id="session-123")

# All LangChain operations are now traced automatically
llm = ChatOpenAI(model="gpt-4")
prompt = ChatPromptTemplate.from_template("Tell me about {topic}")
chain = prompt | llm

response = chain.invoke(
    {"topic": "AI agents"},
    config={"callbacks": [handler]}
)
```

### Basic Tracing

```python
from lumenova_beacon import BeaconClient, trace

client = BeaconClient(
    endpoint="https://your-beacon-endpoint.lumenova.ai",
    api_key="your-api-key",
    session_id="my-session"
)

@trace
def my_function(x, y):
    return x + y

result = my_function(10, 20)  # Automatically traced
```

> No endpoint? Pass `file_directory='./traces'` instead of `endpoint` to write spans as JSON locally — useful for development and tests. See [File Transport](#file-transport).

## Configuration

### Environment Variables

All environment variables work as fallback — constructor parameters override them:

| Variable | Purpose | Default |
|----------|---------|---------|
| `BEACON_ENDPOINT` | API base URL for OTLP export | (required unless using `file_directory`) |
| `BEACON_API_KEY` | Authentication token | |
| `BEACON_SESSION_ID` | Default session ID for spans | |
| `BEACON_SERVICE_NAME` | Service name for OTEL resource (fallback: `OTEL_SERVICE_NAME`) | |
| `BEACON_ENVIRONMENT` | Deployment environment (e.g., "production", "staging") | |
| `BEACON_USER_EMAIL` | Default user email for attribution | |
| `BEACON_USER_NAME` | Default user display name for attribution | |
| `BEACON_USER_ID` | Default user ID for attribution | |
| `BEACON_VERIFY` | TLS verification for all SDK HTTP calls. `true`/`false` or a CA bundle path. | `true` |
| `BEACON_CLIENT_CERT` | mTLS client cert path (combined PEM, or cert file paired with `BEACON_CLIENT_KEY`). | |
| `BEACON_CLIENT_KEY` | mTLS client key path, paired with `BEACON_CLIENT_CERT`. | |
| `BEACON_PROXIES` | HTTP(S) proxy for all SDK calls. URL string or JSON dict (`{"http":..., "https":...}`). Bypass via `NO_PROXY`. Use empty-string values (`{"http":"","https":""}`) to **disable** proxies for those schemes and override any `http_proxy` / `https_proxy` env vars; `null` is rejected. | |
| `BEACON_EAGER_EXPORT` | Export spans eagerly on end | `true` |
| `BEACON_ISOLATED` | Use a private TracerProvider when another OTEL exporter is already running | `false` |
| `BEACON_EXTRA_OTLP_ENDPOINTS` | Additional OTLP endpoints (comma-separated). Port `4318` is HTTP; others are gRPC. | |
| `BEACON_AWS_SECRET_NAME` | AWS Secrets Manager secret name (resolves API key) | |
| `BEACON_AWS_SECRET_KEY` | Key path within AWS secret | `api_key` |
| `BEACON_AWS_REGION` | AWS region for Secrets Manager | (boto3 default) |

`BEACON_VERIFY`, `BEACON_CLIENT_CERT`/`_KEY`, and `BEACON_PROXIES` apply to the HTTP OTLP exporter and REST API calls. For gRPC OTLP extras, use `grpc_proxy`/`https_proxy` and `OTEL_EXPORTER_OTLP_CERTIFICATE`/`_CLIENT_CERTIFICATE`/`_CLIENT_KEY`.

```bash
# Bash/Linux/macOS
export BEACON_ENDPOINT="https://your-beacon-endpoint.lumenova.ai"
export BEACON_API_KEY="your-api-key"
export BEACON_SESSION_ID="my-session"
```

```powershell
# PowerShell
$env:BEACON_ENDPOINT = "https://your-beacon-endpoint.lumenova.ai"
$env:BEACON_API_KEY = "your-api-key"
$env:BEACON_SESSION_ID = "my-session"
```

### Configuration Options

```python
from lumenova_beacon import BeaconClient

client = BeaconClient(
    # Connection
    endpoint="https://your-beacon-endpoint.lumenova.ai",
    api_key="your-api-key",
    verify=True,                            # Or a CA bundle path, e.g. "/path/to/corp-ca.pem"
    # proxies="http://proxy.example.com:8080",  # Or a dict: {"http": "...", "https": "..."}. Default: no proxy.
    # cert=("/path/to/client.pem", "/path/to/client.key"),  # mTLS; or a single combined PEM path. Validated at construction — point at real files before uncommenting.

    # Span Configuration
    session_id="my-session",
    service_name="my-service",
    environment="production",

    # User Identity
    user_email="user@example.com",
    user_name="Alice",
    user_id="user-123",

    # OpenTelemetry
    auto_instrument_opentelemetry=True,   # Auto-configure OTEL (default: True)
    isolated=False,                        # Use private TracerProvider (default: False)
    auto_instrument_litellm=False,         # Auto-configure LiteLLM (default: False)

    # Data Masking
    masking_function=None,                 # Custom masking function (optional)

    # General
    eager_export=True,
    record_stacktrace=False,               # Capture stack traces on exceptions (default: False)

    # Additional options passed via **kwargs
    # enabled=True,                        # Enable/disable tracing
    # headers={"Custom-Header": "value"},  # Custom HTTP headers
)
```

### File Transport

For local development or testing, use `file_directory` instead of `endpoint`:

```python
from lumenova_beacon import BeaconClient

client = BeaconClient(
    file_directory="./traces",
)
```

## Integrations

### 1. LangChain/LangGraph

Automatically trace all LangChain and LangGraph operations — chains, agents, tools, retrievers, and LLM calls.

**First, set your credentials.** Every handler and config below relies on a `BeaconClient` that knows your **endpoint** and **API key**. Provide them in one of two ways — environment variables are preferred so credentials stay out of your code:

```powershell
# Preferred: environment variables (BeaconClient() then needs no arguments)
$env:BEACON_ENDPOINT = "https://your-beacon-endpoint.lumenova.ai"
$env:BEACON_API_KEY = "your-api-key"
```

```python
# Or pass them explicitly when constructing the client (overrides env vars)
client = BeaconClient(endpoint="https://your-beacon-endpoint.lumenova.ai", api_key="your-api-key")
```

**Two entry points, same traces — pick based on whether your graph is checkpointed:**

| | `BeaconLangGraphHandler` | `BeaconLangGraphConfig` |
|---|---|---|
| What it is | A callback handler you attach via `config={"callbacks": [...]}` | A config **builder** that owns a handler and returns a ready-made `RunnableConfig` |
| Best for | Chains, RAG, one-shot agent runs — no checkpointer | Checkpointed LangGraph agents that **interrupt and resume** |
| Interrupt/resume continuity | Manual (`mark_interrupt()` + your own ID tracking) | **Automatic** — persists Beacon trace IDs into the checkpoint, continues the same trace on resume |
| Governance | Attach a separate `BeaconLangGraphGovernanceHandler` | Pass `governance=GovernanceConfig(...)` in one step |

Use the **handler** for stateless chains and simple agent calls; use **`BeaconLangGraphConfig`** when your graph is compiled with a checkpointer and uses interrupt/resume, needs handoff detection, or needs governance.

#### Option A — `BeaconLangGraphHandler` (chains and one-shot agents)

```python
from lumenova_beacon import BeaconClient, BeaconLangGraphHandler
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

client = BeaconClient()  # endpoint + api_key from env (or pass them explicitly)
handler = BeaconLangGraphHandler(
    session_id="session-123",
    agent_name="planner",          # Emitted as gen_ai.agent.name (optional)
    agent_id="agent-uuid",         # Emitted as gen_ai.agent.id (optional)
    agent_version="2025-05-01",    # Emitted as gen_ai.agent.version (optional)
)

# Use with request-time callbacks (recommended)
llm = ChatOpenAI(model="gpt-4")
response = llm.invoke(
    "What is the capital of France?",
    config={"callbacks": [handler]}
)

# Works with chains
prompt = ChatPromptTemplate.from_template("Tell me about {topic}")
chain = prompt | llm
response = chain.invoke(
    {"topic": "AI"},
    config={"callbacks": [handler]}
)

# Traces agents, tools, retrievers, and more
from langchain.agents import create_react_agent, AgentExecutor

agent = create_react_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools)
result = executor.invoke(
    {"input": "What's the weather?"},
    config={"callbacks": [handler]}
)
```

#### Per-invocation Identity Override

A single `BeaconLangGraphHandler` can serve many conversations or agents without mutating instance state — pass the reserved keys `session_id`, `agent_id`, `agent_version` via `config={"metadata": {...}}` and they flow into `gen_ai.conversation.id` / `gen_ai.agent.id` / `gen_ai.agent.version` for that one invocation:

```python
response = chain.invoke(
    {"topic": "AI"},
    config={
        "callbacks": [handler],
        "metadata": {
            "session_id": "conversation-456",
            "agent_id": "planner",
            "agent_version": "2025-05-01",
        },
    },
)
```

#### Option B — `BeaconLangGraphConfig` (checkpointed agents, interrupt/resume)

For LangGraph agents compiled **with a checkpointer**, use `BeaconLangGraphConfig` to automatically continue traces across interrupt/resume cycles. It owns a `BeaconLangGraphHandler` internally and persists the Beacon trace/root-span IDs into the LangGraph checkpoint, so a resumed run (even in a new process) continues the *same* trace instead of starting a new one. Pass the result of `get_config()` straight to `invoke`/`ainvoke` — don't add your own `callbacks`:

```python
from lumenova_beacon import BeaconClient, BeaconLangGraphConfig

client = BeaconClient()  # endpoint + api_key from env (or pass them explicitly)

beacon = BeaconLangGraphConfig(
    graph=agent.graph,           # must be compiled WITH a checkpointer
    thread_id=thread_id,
    agent_name="planner",
    agent_id="agent-uuid",       # Emitted as gen_ai.agent.id (optional)
    agent_version="2025-05-01",  # Emitted as gen_ai.agent.version (optional)
    session_id="session-123",
    autodetect_handoffs=True,    # Auto-detect agent-to-agent handoffs
)
config = beacon.get_config()
result = await agent.graph.ainvoke(state, config)

# For async checkpointers (e.g., AsyncPostgresSaver):
config = await beacon.aget_config()
```

To resume, build the config again for the same `thread_id` — it re-reads the checkpoint, detects the interrupt, and continues the original trace (`beacon.is_resume` is then `True`). To add policy enforcement, pass `governance=GovernanceConfig(...)` — see [Integrated Tracing + Governance with BeaconLangGraphConfig](#integrated-tracing--governance-with-beaconlanggraphconfig).

### 2. LiteLLM

Trace all LiteLLM operations across multiple LLM providers:

```python
from lumenova_beacon import BeaconClient, BeaconLiteLLMLogger
import litellm

# Option 1: Auto-instrumentation
client = BeaconClient(auto_instrument_litellm=True)

# Option 2: Manual registration
client = BeaconClient()
litellm.callbacks = [BeaconLiteLLMLogger()]

# All LiteLLM calls are now traced
response = litellm.completion(
    model="gpt-4",
    messages=[{"role": "user", "content": "Hello"}],
    metadata={
        "generation_name": "greeting",
        "session_id": "user-123",
    }
)
```

### 3. Strands Agents

Trace AWS Strands Agent executions with automatic span hierarchy. Two integrations are
available — the **hook provider is recommended**; the callback handler is legacy.

**`BeaconStrandsHooks` (recommended)** — implements the Strands `HookProvider` protocol,
registered via `hooks=[...]`. The agent span is always finalized (even when the model hits
`max_tokens` and Strands raises out of `agent()`), and per-request identity can be passed via
`invocation_state` so one long-lived handler serves many users. Requires `strands-agents>=1.38.0`.

```python
from lumenova_beacon import BeaconClient, BeaconStrandsHooks
from strands import Agent

client = BeaconClient()
hooks = BeaconStrandsHooks(
    session_id="my-session",
    agent_name="My Agent",
    agent_id="agent-uuid",       # Emitted as gen_ai.agent.id (optional)
    agent_version="2025-05-01",  # Emitted as gen_ai.agent.version (optional)
)

agent = Agent(model=model, tools=[...], hooks=[hooks])
# Per-request identity (optional) — overrides constructor defaults for this call:
result = agent("Hello, world!", invocation_state={"beacon": {"user_email": "alice@corp.com"}})
print(hooks.trace_id)  # Link to Beacon trace
```

**`BeaconStrandsHandler` (legacy)** — the original callback handler, registered via
`callback_handler=`. It additionally captures streaming time-to-first-token, which the hook
API cannot, but it does not finalize the agent span when Strands raises out of `agent()`
(e.g. `max_tokens`) — call `handler.finalize()` in a `finally` to close the span. Supports
`strands-agents>=1.15.0`.

```python
from lumenova_beacon import BeaconClient, BeaconStrandsHandler
from strands import Agent

client = BeaconClient()
handler = BeaconStrandsHandler(session_id="my-session", agent_name="My Agent")

agent = Agent(model=model, callback_handler=handler)
try:
    result = agent("Hello, world!")
finally:
    handler.finalize()  # closes the agent span even on the max_tokens raise path
print(handler.trace_id)  # Link to Beacon trace
```

### 4. CrewAI

Trace CrewAI Crew executions via the event listener:

```python
from lumenova_beacon import BeaconClient, BeaconCrewAIListener
from crewai import Agent, Crew, Task

client = BeaconClient()

# Auto-registers with CrewAI event bus
listener = BeaconCrewAIListener(
    session_id="my-session",
    crew_name="My Research Crew",
    crew_id="crew-uuid",         # Emitted as gen_ai.agent.id (optional)
    agent_version="2025-05-01",  # Emitted as gen_ai.agent.version (optional)
)

crew = Crew(agents=[...], tasks=[...])
result = crew.kickoff()
print(listener.trace_id)  # Link to Beacon trace
```

### 5. MCP Server

Trace an MCP (Model Context Protocol) server **independently of any agent**.
`BeaconMCPMiddleware` is a [FastMCP](https://gofastmcp.com) server middleware that
intercepts the server's request dispatch and emits one server-side Beacon span per
request — so the server is observable regardless of which client or agent called it.

```python
from fastmcp import FastMCP
from lumenova_beacon import BeaconClient, BeaconMCPMiddleware

BeaconClient()  # configure once (BEACON_ENDPOINT/BEACON_API_KEY, or file_directory=...)

mcp = FastMCP("my-server")
mcp.add_middleware(BeaconMCPMiddleware(server_name="my-server"))

@mcp.tool
def add(a: int, b: int) -> int:
    return a + b

mcp.run()  # each tools/call now produces a SERVER-kind Beacon span
```

A single `on_request` dispatcher produces **one span per request method** (`SpanKind.SERVER`
for inbound, `CLIENT` for server-initiated). Every method is covered — `mcp.method.name`
is always set; unknown methods get a generic `REQUEST` span:

| MCP method                         | Span type    | `gen_ai.operation.name` |
|------------------------------------|--------------|--------------------------|
| `tools/call`                       | `TOOL`       | `execute_tool` (OTel spec) |
| `resources/read`                   | `RETRIEVAL`  | `retrieval`              |
| `resources/subscribe`/`unsubscribe`| `REQUEST`    | `subscribe_resource` / … |
| `prompts/get`                      | `REQUEST`    | `get_prompt`             |
| `tools/list`, `resources/list`, …  | `REQUEST`    | `list_*`                 |
| `initialize`, `completion/complete`| `REQUEST`    | `initialize` / `complete`|
| `sampling/createMessage`           | `GENERATION` | `create_message`         |

**Trace topology** — each request starts its own root trace; if the caller propagated a
W3C `traceparent` in the request `_meta`, the span continues that distributed trace
instead. **Per-request identity** — pass a reserved `"beacon"` key inside the request
`_meta` (`{"beacon": {"user_email": "...", "session_id": "..."}}`) to override the
constructor defaults for a single call; the bag also accepts `user_name`, `user_id`, and
a `metadata` dict (merged over the constructor `metadata`). **Noise control** — the low-signal methods are off
by default: discovery lists (`capture_list_operations`), `ping` (`capture_ping`), and
notifications (`capture_notifications`). Instrumentation never breaks the server: if span
setup fails, the request proceeds untraced.

### 6. OpenTelemetry

Beacon automatically configures OpenTelemetry to export spans:

```python
from lumenova_beacon import BeaconClient
from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor
from opentelemetry.instrumentation.openai import OpenAIInstrumentor

# Initialize (auto-configures OpenTelemetry)
client = BeaconClient(
    endpoint="https://your-beacon-endpoint.lumenova.ai",
    api_key="your-api-key",
    auto_instrument_opentelemetry=True  # Default
)

# Instrument libraries
AnthropicInstrumentor().instrument()
OpenAIInstrumentor().instrument()

# Now all API calls are automatically traced!
from anthropic import Anthropic
anthropic = Anthropic()
response = anthropic.messages.create(
    model="claude-3-5-sonnet-20241022",
    messages=[{"role": "user", "content": "Hello!"}]
)  # Automatically traced with proper span hierarchy
```

#### Supported Instrumentors

Install additional instrumentors as needed:

```bash
pip install opentelemetry-instrumentation-anthropic
pip install opentelemetry-instrumentation-openai
pip install opentelemetry-instrumentation-fastapi
pip install opentelemetry-instrumentation-redis
pip install opentelemetry-instrumentation-httpx
pip install opentelemetry-instrumentation-requests
```

## Tracing

### Decorator Tracing

The `@trace` decorator automatically captures function execution:

```python
from lumenova_beacon import trace

# Simple usage
@trace
def process_data(data):
    return data.upper()

# With custom name
@trace(name="custom_operation")
def another_function():
    pass

# Capture inputs and outputs
@trace(capture_args=True, capture_result=True)
def calculate(x, y):
    return x + y

# Works with async functions
@trace
async def async_operation():
    await some_async_call()
```

### Manual Tracing

For more control, use context managers:

```python
from lumenova_beacon import BeaconClient
from lumenova_beacon.types import SpanKind, StatusCode

client = BeaconClient()

# Context manager
with client.trace("operation_name") as span:
    span.set_attribute("user_id", "123")
    span.set_input({"query": "search term"})

    try:
        result = do_work()
        span.set_output(result)
        span.set_status(StatusCode.OK)
    except Exception as e:
        span.record_exception(e)
        span.set_status(StatusCode.ERROR, str(e))
        raise

# Async context manager
async with client.trace("async_operation") as span:
    result = await async_work()
    span.set_output(result)

# Direct span creation
span = client.create_span(
    name="manual_span",
    kind=SpanKind.CLIENT,
)
span.start()
# ... do work ...
span.end()
```

### Sessions

A `session_id` groups related traces — a multi-turn conversation, a batch run, a single user journey — so they can be filtered together in Beacon. Set it once on the client (or via `BEACON_SESSION_ID`) and it's inherited by every span created through the client. You can also override it per integration handler (`BeaconLangGraphHandler(session_id=...)`, `BeaconStrandsHandler(session_id=...)`, etc.), per LangChain invocation via `config={"metadata": {"session_id": ...}}`, or per span via `span.set_attribute("session_id", "...")`.

## Agentic Governance

Enforce governance policies on AI agent actions in real time. The governance system evaluates tool calls and LLM invocations against policy stacks before and after execution, blocking actions that violate your rules.

Key capabilities:
- **Pre & post execution hooks** — evaluate actions before they run (prevent violations) and after (audit outputs)
- **Fail-open resilience** — defaults to allowing actions when the governance API is unreachable
- **Violation strategies** — raise immediately, stop gracefully after current action, or escalate after N violations
- **Content reinjection** — policies that emit a top-level `output` string (e.g. PII-masked tool output) can have that replacement applied automatically via `@governance`, `handler.wrap(...)` on a `BeaconLangGraphGovernanceHandler` instance (see [Wrapping for content reinjection](#wrapping-for-content-reinjection) below), or the LangGraph helpers `wrap_tool_node` / `wrap_chat_model`. Callback-only attachment observes but does not substitute.

### Decorator

```python
from lumenova_beacon import governance

# Bare decorator — requires GovernanceConfig on the active BeaconClient
@governance
def search_web(query: str) -> str:
    return web_search(query)

# With policy stack IDs
@governance(stack_ids=["my-policy-stack"])
def send_email(to: str, body: str) -> bool:
    return email_client.send(to, body)

# Tag-based policy discovery with strict mode
@governance(tags=["env:prod", "team:security"], fail_open=False)
def delete_record(record_id: str) -> None:
    db.delete(record_id)

# Works with async functions
@governance(stack_ids=["my-policy-stack"])
async def run_query(sql: str) -> list[dict]:
    return await db.execute(sql)
```

### LangChain/LangGraph Callback Handler

```python
from lumenova_beacon import BeaconLangGraphHandler, BeaconLangGraphGovernanceHandler
from langchain_openai import ChatOpenAI

handler = BeaconLangGraphHandler(session_id="session-123")
governance_handler = BeaconLangGraphGovernanceHandler(
    stack_ids=["my-policy-stack"],
    fail_open=True,
    on_violation="raise",
)

llm = ChatOpenAI(model="gpt-4")
response = llm.invoke(
    "What is the capital of France?",
    config={"callbacks": [handler, governance_handler]}
)
```

### Wrapping for content reinjection

Use `handler.wrap(...)` (instead of callback-only attachment) when a policy returns a top-level `output` field that should actually replace the tool arguments, tool return value, LLM prompt, or LLM response. Callbacks can only observe and block; `wrap` rebinds the inner callable so the substitution takes effect.

`wrap` is an instance method — construct a handler first, then call `.wrap(...)` on the instance.

```python
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from lumenova_beacon import BeaconLangGraphGovernanceHandler

@tool
def read_file(path: str) -> str:
    """Read a file."""
    with open(path) as f:
        return f.read()

# 1. Construct the handler — it owns shared violation state.
handler = BeaconLangGraphGovernanceHandler(
    stack_ids=["pii-redaction-stack"],
    fail_open=True,
    on_violation="raise",
)

# 2. Wrap on the INSTANCE. Mutates in place; returns the same target.
wrapped_tool = handler.wrap(read_file)                          # BaseTool
wrapped_tools = handler.wrap([read_file])                       # list[BaseTool]
wrapped_llm = handler.wrap(ChatOpenAI(model="gpt-4o-mini"))     # BaseChatModel

# 3. All wrapped targets share handler.violation_count, the stopped flag,
#    and the invocation chain via closure.
print(handler.violation_count)
```

Notes:

- `wrap` accepts a `BaseTool`, a `Sequence[BaseTool]`, or a `BaseChatModel`.
- The input is mutated in place: `BaseTool.func`/`coroutine` and `BaseChatModel.invoke`/`ainvoke` are rebound. Keep a reference before wrapping if you need an unwrapped copy.
- Idempotent: wrapping an already-wrapped target is a no-op.
- `stream` / `astream` on a wrapped chat model bypass governance — call `invoke` / `ainvoke` instead.
- Tools that override `_run`/`_arun` directly (no `func`/`coroutine` attribute) cannot be wrapped — apply `@governance` to the inner function before building the tool, or expose `func`/`coroutine`.

A full LangGraph `StateGraph` example using this pattern lives in the SDK source tree at `examples/llm_integrations/langchain/langchain_governance_wrap.py`.

### BeaconLangGraphAgent — Wrapper with Violation Dispatch

For LangGraph agents, `BeaconLangGraphAgent` is the highest-level governance entry point: it owns a `BeaconLangGraphConfig` internally, exposes `invoke` / `stream` / `stream_events` (plus async variants), and lets you pick how `GovernanceViolationError` is surfaced via `violation_mode`:

- `'raise'` (default) — re-raise to the caller, same as the bare config.
- `'state'` — append phase-appropriate messages via the checkpointer and return / yield cleanly. LLM-phase blocks become an `AIMessage`; tool-phase blocks become a `ToolMessage` with sibling reconciliation so OpenAI's "tool_calls must be answered" invariant holds.
- `'emit'` — yield a synthesised `on_custom_event` from the streaming entry points; falls back to `'state'` for `invoke` / `ainvoke` which have no event stream.

```python
from lumenova_beacon import BeaconClient, BeaconLangGraphAgent, GovernanceConfig

client = BeaconClient()

agent = BeaconLangGraphAgent(
    graph=compiled_graph,
    thread_id="thread-123",
    governance=GovernanceConfig(stack_ids=["my-policy-stack"], fail_open=True),
    violation_mode="state",
    agent_name="planner",
    agent_id="agent-uuid",       # Emitted as gen_ai.agent.id (optional)
    agent_version="2025-05-01",  # Emitted as gen_ai.agent.version (optional)
    session_id="session-123",
)

# Same surface as graph.invoke / graph.stream — no config= plumbing needed.
result = agent.invoke({"messages": [("user", "delete production")]})

# Streaming with state-mode reconciliation:
for chunk in agent.stream({"messages": [...]}):
    ...

# Inspect the last violation (e.g. when emit-mode falls back to a WARN-only path):
if agent.last_violation:
    print(agent.last_violation.phase, agent.last_violation.policies)
```

The wrapper requires a graph compiled with a checkpointer (same as `BeaconLangGraphConfig`) and a `GovernanceConfig` — without one it has nothing to enforce and raises at construction. Reconciliation writes are persisted with `callbacks` / `tags` stripped from the config so the checkpointer touch is invisible to the tracer (no stray second trace).

### Integrated Tracing + Governance with BeaconLangGraphConfig

For LangGraph agents, pass a `GovernanceConfig` to `BeaconLangGraphConfig` to get both tracing and governance in a single setup:

```python
from lumenova_beacon import BeaconLangGraphConfig, GovernanceConfig

beacon = BeaconLangGraphConfig(
    graph=agent.graph,
    thread_id=thread_id,
    agent_name="planner",
    governance=GovernanceConfig(
        stack_ids=["my-policy-stack"],
        fail_open=True,
        on_violation="raise",
        max_violations=3,
    ),
)
config = beacon.get_config()
result = await agent.graph.ainvoke(state, config)
```

### GovernanceConfig Options

```python
from lumenova_beacon import GovernanceConfig

config = GovernanceConfig(
    stack_ids=["stack-1", "stack-2"],       # Policy stack IDs (at least one of stack_ids/tags required)
    tags=["env:prod"],                       # Tag-based policy discovery
    fail_open=True,                          # Allow actions when API is unreachable (default: True)
    enabled_hooks={"pre_tool", "post_tool"}, # Which hooks to run (default: all four)
    on_violation="raise",                    # "raise" (immediate) or "stop" (graceful shutdown)
    max_violations=5,                        # Escalate to stop mode after N violations
    timeout=2.0,                             # API call timeout in seconds
    debug=False,                             # Enable debug logging
)
```

Available hooks: `pre_tool`, `post_tool`, `pre_llm`, `post_llm`.

### Handling Violations

```python
from lumenova_beacon.exceptions import GovernanceViolationError

try:
    result = agent.invoke(state, config)
except GovernanceViolationError as e:
    print(e.message)       # Human-readable reason
    print(e.policies)      # List of rules that were evaluated
    print(e.latency_ms)    # Evaluation latency
```

## System Probes

Run autonomous AI agents that probe your HTTP system, classify each outcome, and produce a scored markdown report. The agent (LangGraph + LLM + scoring) runs on Beacon; the SDK only claims the run, polls for HTTP requests the agent wants executed, runs each one against your target with your local credentials, and PATCHes the response back. **In SDK mode, Beacon never sees your target's URL or credentials.**

Use a probe when you want broad coverage of an HTTP API without writing tests by hand, when you want adversarial probing (e.g. OWASP LLM Top 10) against an LLM-backed endpoint, or when your API lives behind a VPN / private VPC where Beacon can't reach it directly.

Configure the run from the Beacon UI (target system → probe evaluator → click *Run probe*), then run it locally:

### Path A — built-in HTTP dispatcher

Pass auth as a kwarg matching the target's `auth_scheme_hint`. The SDK fires `httpx` itself and injects the auth header on every outbound request.

```python
from lumenova_beacon import Probe

# Bearer auth (auth_scheme_hint='bearer')
Probe.run("<run-id-from-the-ui>", bearer_token="<your-bearer-token>")

# Custom header auth (e.g. auth_scheme_hint='custom_headers')
Probe.run("<run-id-from-the-ui>", headers={"X-API-Key": "<your-api-key>"})

# No auth (auth_scheme_hint='none')
Probe.run("<run-id-from-the-ui>")
```

`Probe.run` blocks until the run reaches a terminal status. Use `Probe.arun(...)` for the async variant.

### Path B — custom callable

Own the connection yourself — for custom auth flows, non-HTTP transports, or mocks.

```python
import time
from lumenova_beacon import Probe, ProbeHttpRequest, ProbeHttpSuccessResponse

def my_dispatcher(req: ProbeHttpRequest) -> ProbeHttpSuccessResponse:
    started = time.monotonic()
    response = my_internal_client.send(
        req.method,
        req.url,
        headers=req.request_headers,
        body=req.request_body,
    )
    return ProbeHttpSuccessResponse(
        duration_ms=int((time.monotonic() - started) * 1000),
        status_code=response.status_code,
        response_headers=dict(response.headers),
        response_body=response.text,
    )

Probe.run("<run-id-from-the-ui>", target_callable=my_dispatcher)
```

Mixing `target_callable` with an auth kwarg raises immediately at `Probe.run` entry — pick one.

The SDK uses the same `BEACON_ENDPOINT` + `BEACON_API_KEY` env vars (or `BeaconClient` configuration) as the rest of the SDK to reach Beacon.

## Dataset Management

Manage test datasets with an ActiveRecord-style API. All methods have async variants with an `a` prefix (e.g., `acreate`, `aget`, `alist`).

```python
from lumenova_beacon import BeaconClient
from lumenova_beacon.datasets import Dataset, DatasetRecord

client = BeaconClient()

# Create dataset
dataset = Dataset.create(
    name="qa-evaluation",
    description="Question answering test cases"
)

# Add records with flexible column-based data
dataset.create_record(
    data={
        "prompt": "What is AI?",
        "expected_answer": "Artificial Intelligence is...",
        "difficulty": "easy",
        "category": "definitions"
    }
)

# Bulk create records
dataset.bulk_create_records([
    {"data": {"question": "What is ML?", "expected_answer": "Machine Learning..."}},
    {"data": {"question": "What is DL?", "expected_answer": "Deep Learning..."}},
])

# List, get, update, delete
datasets, pagination = Dataset.list(page=1, page_size=20, search="qa")
dataset = Dataset.get(dataset_id="dataset-uuid", include_records=True)
dataset.update(name="updated-name", description="New description")
dataset.delete()
```

## Prompt Management

Version-controlled prompt templates with labels. All methods have async variants with an `a` prefix.

```python
from lumenova_beacon import BeaconClient
from lumenova_beacon.prompts import Prompt

client = BeaconClient()

# Create text prompt
prompt = Prompt.create(
    name="greeting",
    template="Hello {{name}}! Welcome to {{company}}.",
    description="Customer greeting template",
    tags=["customer-support", "greeting"]
)

# Create chat prompt
prompt = Prompt.create(
    name="support-bot",
    messages=[
        {"role": "system", "content": "You are a helpful assistant for {{product}}."},
        {"role": "user", "content": "{{question}}"}
    ],
)

# Get latest version (sync)
prompt = Prompt.get(name="greeting")

# Get specific version (async)
prompt = await Prompt.aget(name="greeting", version=2)

# Get labeled version (sync)
prompt = Prompt.get(name="greeting", label="production")

# Get by ID (async)
prompt = await Prompt.aget(prompt_id="prompt-uuid")

# Format prompt with variables
message = prompt.format(name="Alice", company="Acme Corp")
# Result: "Hello Alice! Welcome to Acme Corp."

# Versioning and labels
new_version = prompt.publish(
    template="Hi {{name}}! Welcome to {{company}}. We're excited to have you!",
    message="Added enthusiastic tone"
)
prompt.set_label("production", version=2)

# Introspect labels: list them, or look one up by name. Each PromptLabel
# references its version by id (prompt_version_id), not by number.
labels = prompt.list_labels()
for lbl in labels:
    print(lbl.label, "->", lbl.prompt_version_id)
prod = prompt.get_label("production")  # raises PromptNotFoundError if absent

# Convert to LangChain template
lc_prompt = prompt.to_langchain()  # Returns PromptTemplate or ChatPromptTemplate
```

#### LangChain Conversion

```python
from langchain_core.prompts import PromptTemplate, ChatPromptTemplate

# Convert text prompt to LangChain (sync)
prompt = Prompt.get(name="greeting", label="production")
lc_prompt = prompt.to_langchain()  # Returns PromptTemplate
result = lc_prompt.format(name="Bob", company="TechCorp")

# Convert chat prompt to LangChain (async)
chat_prompt = await Prompt.aget(name="support-bot", label="production")
lc_chat = chat_prompt.to_langchain()  # Returns ChatPromptTemplate
messages = lc_chat.format_messages(product="DataHub", question="Reset password?")

# Use in chain
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4")
chain = lc_chat | llm
response = await chain.ainvoke({"product": "CloudSync", "question": "Why sync failing?"})
```

#### List and Search

```python
# List all prompts (sync) — returns (prompts, pagination metadata)
prompts, pagination = Prompt.list(page=1, page_size=20)
print(pagination.total, pagination.total_pages)

# Filter by tags (async). Passing several tags matches prompts carrying ANY of
# them, not all — use Prompt.list_tags() to discover the tags in use.
support_prompts, _ = await Prompt.alist(tags=["customer-support"])
support_or_billing, _ = await Prompt.alist(tags=["customer-support", "billing"])

# Combine AND/OR with any_of()/all_of() (nestable up to 4 levels, 32 tags):
# prompts tagged customer-support AND (production OR staging)
from lumenova_beacon import all_of, any_of

filtered, _ = Prompt.list(tags=all_of("customer-support", any_of("production", "staging")))

# Search by text (sync)
results, _ = Prompt.list(search="greeting")

# Filter by type (sync)
chat_prompts, _ = Prompt.list(prompt_type=PromptType.CHAT)

# Filter by category (sync). Pass Prompt.UNCATEGORIZED to list only prompts
# that have no category.
support, _ = Prompt.list(category="customer-support")
uncategorized, _ = Prompt.list(category=Prompt.UNCATEGORIZED)

# Sort (sync). sort_by: 'created_at' | 'updated_at' | 'name';
# sort_order: 'asc' | 'desc'. Omitted params fall back to the server
# defaults ('created_at', 'desc').
newest, _ = Prompt.list(sort_by="updated_at", sort_order="desc")

# Iterate over all pages (sync)
prompts, page = Prompt.list()
while page.page < page.total_pages:
    more, page = Prompt.list(page=page.page + 1)
    prompts.extend(more)
```

#### Categories

```python
# List the distinct category names in use across the project (sync)
categories = Prompt.categories()  # -> ['customer-support', 'definitions', ...]

# Async
categories = await Prompt.acategories()
```

> `categories()` lists only categories that are actually assigned to a prompt;
> prompts with no category are not represented. Use `Prompt.UNCATEGORIZED` with
> `list()` or `bulk_get()` to fetch those.

#### Tags

```python
# List the distinct tags in use across the project, sorted (sync)
tags = Prompt.list_tags()  # -> ['billing', 'customer-support', 'production', ...]

# Async
tags = await Prompt.alist_tags()

# Feed them straight back into a filter
prompts, _ = Prompt.list(tags=tags[:2])
```

> The discovery method is `list_tags()`, not `tags()` — `prompt.tags` is already
> the tag list of an individual prompt. Filtering with `list(tags=[...])` matches
> prompts carrying **any** of the given tags; for AND/OR combinations pass an
> `any_of()`/`all_of()` expression instead of a plain list (see List and Search).

#### Bulk Fetch

Fetch many prompts in one call, by IDs or by category. Each returned prompt
carries its latest version, so it's immediately usable with `.format()`.
Pass **exactly one** of `prompt_ids` or `category`.

```python
# Fetch several prompts by ID (sync). Unknown or inaccessible IDs are skipped
# and a warning naming the misses is emitted — valid prompts are still returned.
prompts = Prompt.bulk_get(prompt_ids=["id-1", "id-2", "id-3"])

# Fetch every prompt in a category (async)
support_prompts = await Prompt.abulk_get(category="customer-support")

# Fetch prompts that have no category, using the UNCATEGORIZED sentinel
uncategorized = Prompt.bulk_get(category=Prompt.UNCATEGORIZED)

for prompt in prompts:
    print(prompt.name, prompt.format(name="Alice"))
```

> Passing both `prompt_ids` and `category` (or neither) raises
> `PromptValidationError`.

#### Duplicate Names

Prompt names are not unique within a project. When you fetch by name and more
than one prompt matches, `Prompt.get(name=...)` returns the **first match**
(project-owned, then oldest) and emits a warning instead of failing:

```python
import warnings

with warnings.catch_warnings():
    warnings.simplefilter("always")
    prompt = Prompt.get(name="greeting")  # warns: 'Multiple prompts named ...'
```

Fetch by `prompt_id` to target a specific prompt unambiguously.

## Experiment Management

Experiments run a pipeline of steps (`PROMPT`, `LLM`, `EVALUATION`, `PYTHON`, `EXTERNAL_AGENT`, `GUARDRAIL`, `EXTERNAL_SYSTEM`) over a dataset. Provide either a flat `steps=` list (single-stage) or a macro graph of `stages=` (multi-stage). The two are mutually exclusive. The server orchestrates the run and stores results.

### Macro-graph stages

Multi-stage experiments wire `ExperimentStage` nodes with `StageInputBinding` ports. The default graph is **Read Dataset → Pipeline → Write Dataset** (matching the UI). Read Dataset is not a `StageKind` — it serializes as `from_dataset_id` on the Pipeline input. Write Dataset (`StageKind.WRITE_DATASET`) is the primary output (`is_output=True`). Every `steps` / `write_dataset` stage needs exactly one bound input. Stages are displayed in list order — `position` is auto-assigned from the list index (positions must be unique), so set `position=` only to override the order. Pass `stage_id=` to `list_records()` to scope results to one stage's materialized dataset.

```python
from lumenova_beacon.experiments import (
    Experiment, ExperimentStage, ExperimentStep, ExperimentStepType,
    StageInputBinding, StageKind,
)

experiment = Experiment.create(
    name="macro-graph",
    dataset_id="dataset-uuid",
    stages=[
        ExperimentStage(
            key="pipeline",
            name="Pipeline",
            kind=StageKind.STEPS,
            is_output=False,
            inputs=[StageInputBinding(port="input", from_dataset_id="dataset-uuid")],
            steps=[
                ExperimentStep(
                    step_type=ExperimentStepType.LLM,
                    output_column="response",
                    config={
                        "model_config_id": "config-uuid",
                        "variable_mappings": {"input": "text_column"},
                    },
                ),
            ],
        ),
        ExperimentStage(
            key="write",
            name="Write Dataset",
            kind=StageKind.WRITE_DATASET,
            is_output=True,
            inputs=[StageInputBinding(port="input", from_stage_key="pipeline")],
            config={"mode": "create", "name": "Macro Results"},
        ),
    ],
)

stage = experiment.stages[0]
records, _ = experiment.list_records(stage_id=stage.id)
```

### External Agents

Use an `EXTERNAL_AGENT` step to plug your own code — a LangGraph graph, a RAG pipeline, anything callable — into a pipeline step. The server runs every other step it can, then transitions the experiment to `WAITING_FOR_EXTERNAL`; the SDK polls for pending inputs, dispatches each one to your callable, and writes the result back.

```python
from lumenova_beacon.experiments import (
    Experiment, ExperimentStep, ExperimentStepType, ExperimentStatus,
)

experiment = Experiment.create(
    name="agent-benchmark",
    dataset_id="dataset-uuid",
    steps=[
        ExperimentStep(
            step_type=ExperimentStepType.EXTERNAL_AGENT,
            output_column="agent_output",
            config={
                "agent_name": "My Agent",
                "variable_mappings": {"input": "question"},
            },
        ),
    ],
)

# Blocks until every pending external run has been dispatched.
# `my_agent_fn` receives the resolved input dict and returns the value
# to write into `output_column`.
experiment.start(external_agents={"My Agent": my_agent_fn})

# Resume a paused experiment from the same callable.
if experiment.status == ExperimentStatus.WAITING_FOR_EXTERNAL:
    experiment.continue_experiment(external_agents={"My Agent": my_agent_fn})
```

## Evaluation Management

Evaluations score outputs using one of three evaluator types — LLM-as-judge, code-based, or agent-as-judge — and come in two flavors selected by which source you pass:

- **Trace-based** (`filter_rules=...`): scores spans matching a filter. Set `active=True` and the evaluator runs continuously on new traces.
- **Dataset-based** (`dataset_id=...`): scores rows in a dataset on demand.

```python
from lumenova_beacon.evaluations import Evaluation, Evaluator

# Reuse an existing evaluator configured in the Beacon UI
evaluator = Evaluator.get(evaluator_id="evaluator-uuid")

# Trace-based: auto-runs on new spans matching the filter
evaluation = Evaluation.create(
    name="prod-quality-monitor",
    evaluator_id=evaluator.id,
    variable_mappings={
        "question": {"data_mode": "reduced", "reduced_fields": ["input"]},
        "answer":   {"data_mode": "reduced", "reduced_fields": ["output"]},
    },
    filter_rules={"logic": "AND", "rules": [
        {"column": "span_name", "operator": "contains", "value": "chat"},
    ]},
    active=True,
)

# Dataset-based: scores rows in a dataset (run on demand)
evaluation = Evaluation.create(
    name="qa-accuracy",
    evaluator_id=evaluator.id,
    variable_mappings={
        "question": {"data_mode": "reduced", "column": "input"},
        "answer":   {"data_mode": "reduced", "column": "output"},
    },
    dataset_id="dataset-uuid",
)
```

`filter_rules` also accepts the same typed `FilterRule` list the trace query API uses (combined with AND and validated client-side), and trace-based evaluations can sample production traffic via `sample_rate`:

```python
from lumenova_beacon.evaluations import Evaluation, FilterOperator, FilterRule

evaluation = Evaluation.create(
    name="sampled-quality-monitor",
    evaluator_id=evaluator.id,
    variable_mappings={...},
    filter_rules=[FilterRule("agent_name", FilterOperator.EQUALS, "MathAgent")],
    active=True,
    sample_rate=0.25,   # auto-evaluate 25% of matching traffic, in (0, 1]
)
```

### Running Evaluations

Run on a single target, in bulk on selected targets, or on everything matching the evaluation's source — and block until results land when you need them synchronously:

```python
# Single target: exactly one of trace_id / session_id / dataset_record_id
run = evaluation.run(trace_id="trace-123")
run = evaluation.run(session_id="sess-456")     # session-based evaluations

# Wait for one run (refreshes in place; FAILED does not raise — check status)
run.wait_for_completion(timeout=300)
print(run.status, run.scores_data)

# Bulk: up to 1000 selected traces/spans/sessions in one call
result = evaluation.bulk_run(
    trace_ids=["t-1", "t-2"],
    session_ids=["sess-1"],
    items=[{"trace_id": "t-3", "span_id": "s-9"}],  # span-level refinement
)
print(len(result.created), result.skipped_existing)

# Everything matching filter_rules / all dataset records
evaluation.execute()
evaluation.wait_for_runs(timeout=600)   # block until no PENDING/RUNNING runs

# Rerun terminal runs in place (same run IDs, results cleared; max 500)
rerun = evaluation.rerun_runs([r.id for r in failed_runs])
print(rerun.reset_run_ids, rerun.skipped)
```

### Extraction Engines for Variable Mappings

Each variable mapping picks an extraction engine for the field expression. Three are available:

- **JSONPath** — straightforward path access (`$.input.messages[0].content`). Fast and familiar; right for a single key or index.
- **JSONata** — structural transforms (filter by predicate, join, aggregate, reshape).
- **Python** — anything that needs general-purpose code (regex, multi-step helpers, NumPy). The expression must define a top-level `def extract(data):` function.

Engines can be chained as a pipeline (e.g. JSONata → Python) when a single expression isn't enough. Caps mirrored client-side: each `expr` is at most 64 KiB and a pipeline is at most 32 steps.

```python
from lumenova_beacon import VariableMapping, ProcessorStep, ExtractionEngine, ProcessorKind

# JSONPath (legacy `json_path` field — also accepted as a raw dict shape).
question = VariableMapping.jsonpath("$.messages[0].content")

# JSONata for filter/join/aggregate/reshape transforms.
answer = VariableMapping.jsonata("$join(messages[role='assistant'].content, ' ')")

# Python for general-purpose transforms.
summary = VariableMapping.python(
    "def extract(data):\n"
    "    return data['output'].strip().lower()\n"
)

# Multi-step pipeline (JSONata → Python).
chained = VariableMapping(
    data_mode="raw",
    pipeline=[
        ProcessorStep(engine=ExtractionEngine.JSONATA, expr="messages.content"),
        ProcessorStep(
            engine=ExtractionEngine.PYTHON,
            kind=ProcessorKind.RESHAPE,
            expr="def extract(data):\n    return ' '.join(data)\n",
        ),
    ],
)

evaluation = Evaluation.create(
    name="multi-engine",
    evaluator_id=evaluator.id,
    variable_mappings={"question": question, "answer": answer, "summary": summary},
    filter_rules={"logic": "AND", "rules": []},
)
```

The legacy `dict[str, Any]` shape (`{"question": {"data_mode": "raw", "json_path": "$.x"}}`) keeps working unchanged. Validation runs at construction time: incompatible engine/kind pairs (e.g. `jsonpath` + `filter`), oversize expressions, and the `pipeline`-with-`json_path` anti-pattern raise `ValueError` before the request hits the wire. Requires the Beacon backend version that ships the Python extraction engine (lumenova-guardrails MR !337) for the `python` and pipeline shapes; older backends accept JSONPath only.

### Querying Evaluation Runs

`EvaluationRun.list()` supports rich server-side filtering: by evaluation, trace, session, dataset record, status, agent attribution, run scope, score values, cluster membership, and creation date range.

```python
from datetime import datetime, timezone
from lumenova_beacon.evaluations import EvaluationRun

# Runs attributed to an agent within a date window
runs, page = EvaluationRun.list(
    agent_name="MathAgent",
    scope="trace",                # 'session' | 'trace' | 'span'
    start_time=datetime(2026, 7, 1, tzinfo=timezone.utc),
    end_time=datetime(2026, 7, 15, tzinfo=timezone.utc),
)

# Runs whose 'accuracy' score fell below 0.5
runs, page = EvaluationRun.list(score_name="accuracy", score_max=0.5)

# Per-run results: status, timing, and per-score values
for run in runs:
    print(run.status, run.completed_at, run.scores_data)
```

### Evaluation Statistics

```python
from lumenova_beacon.evaluations import Evaluation, EvaluationRun

# Project-level rollup (typed EvaluationStats)
stats = Evaluation.stats()
print(stats.total, stats.active, stats.failed)

# Per-evaluation run statistics (raw dict, includes avg/median/p95 per score)
evaluation = Evaluation.get("evaluation-uuid", include_statistics=True)
print(evaluation.statistics)

# Per-agent rollup (typed AgentEvaluationStats): overall pass rate plus a
# per-evaluator breakdown with avg/stdev/median/p95/min/max/distribution
agent_stats = EvaluationRun.agent_stats(agent_name="MathAgent")
print(agent_stats.overall.pass_rate)
for bucket in agent_stats.per_evaluator:
    for score in bucket.scores:
        print(bucket.evaluator_name, score.name, score.avg, score.median, score.p95)
```

### Evaluator Versions

Evaluators are versioned; each version snapshots the prompt template, code, and score configuration (rubric). Evaluations can pin a version via `evaluator_version_id` / `evaluator_version_mode`.

```python
from lumenova_beacon.evaluations import Evaluator

evaluator = Evaluator.get("evaluator-uuid")
print(evaluator.current_version, evaluator.version_count)

for version in evaluator.list_versions():       # newest first
    print(version.version, version.commit_message, version.created_at)

v2 = evaluator.get_version(2)
print(v2.prompt_template, v2.scores_config)
```

### Exporting Evaluation Results

Results can leave the evaluation as an XLSX workbook, flat JSON records, or dataset records (e.g. eval failures become the next test set). The `include_*` flags select field groups: extracted inputs, evaluation output, scores, and run metadata.

```python
evaluation = Evaluation.get("evaluation-uuid")

# XLSX workbook (Statistics + Results sheets, COMPLETED runs)
evaluation.export_results_xlsx(output_path="results.xlsx")  # write to disk
workbook_bytes = evaluation.export_results_xlsx()           # or get raw bytes

# Flat JSON records
export = evaluation.export_results(include_metadata=True)
print(export.records_total, export.records_skipped)
for record in export.records:
    print(record)

# Into a dataset: create a new one...
result = evaluation.export_to_dataset(dataset_name="failure-set")
print(result.dataset_id, result.records_added, result.records_skipped)

# ...or append to an existing one (column schema is extended, never trimmed)
evaluation.export_to_dataset(dataset_id="dataset-uuid")
```

### Definition Export / Import (Environment Promotion)

An evaluation's *definition* (variable mappings, filter rules, evaluator reference with inline snapshot, named refs for datasets/LLM configs/target systems) exports as a portable JSON envelope that can be imported into another project or environment — e.g. promoting from staging to production in CI:

```python
# Source environment
envelope = evaluation.export()                      # dict
evaluation.export(output_path="evaluation.json")    # or write to a file

# Destination environment: dry-run first
preview = Evaluation.preview_import(envelope)
print(preview.evaluator_resolution.status)   # auto_matched / name_missing / ...
print(preview.unresolved_refs)               # refs needing manual resolution

# Commit: bind to an existing evaluator, or create it from the envelope
imported = Evaluation.import_definition(envelope, evaluator_id="evaluator-uuid")
imported = Evaluation.import_definition(envelope, create_evaluator_from_payload=True)
```

### Evaluator Dev Loop

Iterate on evaluators programmatically — dry-run an agent-as-judge evaluator against a real trace (nothing persisted), preview variable-mapping pipelines against sample payloads, and parse-validate Python extraction expressions:

```python
from lumenova_beacon.evaluations import Evaluator

evaluator = Evaluator.get("evaluator-uuid")

# Agent-as-judge dry run; progress streams on the Beacon UI's
# evaluation_run:{dry_run_id} WebSocket channel
dry_run_id = evaluator.try_on_trace("trace-123", agent_config={"max_steps": 5})

# Run a pipeline server-side (including sandboxed Python steps)
preview = Evaluator.preview_pipeline(
    [{"engine": "jsonata", "expr": "messages[-1].content", "kind": "extract_path"}],
    data={"messages": [{"content": "hello"}]},
)
if preview.ok:
    print(preview.result)
else:
    print(preview.error.step_index, preview.error.kind, preview.error.message)

# Same parse rules as the runtime (syntax, AST budget, def extract(data) required)
validation = Evaluator.validate_python_expr("def extract(data):\n    return data")
print(validation.ok, validation.errors)
```

### Evaluation Clusters

Cluster an evaluation's COMPLETED runs by input to map the input landscape. `EvaluationRun.list(cluster_id=...)` filters runs by cluster membership:

```python
cluster_run = evaluation.generate_clusters()       # background pass (409 if active)

clusters = evaluation.get_clusters()
print(clusters.run.status, clusters.is_stale, clusters.new_completed_runs)
for cluster in clusters.clusters:                  # populated once completed
    print(cluster.title, cluster.run_count, cluster.share_pct, cluster.keywords)

evaluation.cancel_cluster_run(cluster_run.id)      # cooperative cancel
```

## Querying and Exporting Traces

The tracing side of the SDK (above) sends spans *into* Beacon; this API is the opposite direction — query, search, and download the traces and spans your application exported back *out* of the platform. `Trace` and `TraceSpan` are read-only models with the same sync/async API shape as the other management classes (`list`/`alist`, `get`/`aget`).

```python
from lumenova_beacon.traces import FilterOperator, FilterRule, Trace, TraceSpan

# List recent traces (free-text search, sorting, date bounds)
traces, page = Trace.list(search="checkout", sort_by="duration_ms", page_size=50)

# Structured filtering — typed rules are validated client-side and serialized
# to the backend's {"logic": "AND", "rules": [...]} format
traces, page = Trace.list(
    filter_rules=[
        FilterRule("duration_ms", FilterOperator.GREATER_THAN, 1000),
        FilterRule("status", "equals", "ERROR"),
    ],
    # Only traces containing at least one span matching these rules
    span_filter_rules=[FilterRule("model_name", "contains", "gpt")],
)

# Cursor pagination for deep result sets
while page.has_more:
    more, page = Trace.list(page_size=100, cursor=page.next_cursor)
    traces.extend(more)

# Get one trace with its span tree — or hydrate a listed trace in place
trace = traces[0].fetch()  # same as Trace.get(traces[0].trace_id)
for span in trace.spans:            # partial TraceSpan objects (no attributes)
    print(span.name, span.type, span.duration_ms)
    full_span = span.fetch()        # -> full TraceSpan (with attributes)

# Span-level querying, including filters on the parent trace
spans, page = TraceSpan.list(
    filter_rules=[FilterRule("type", "equals", "generation")],
    trace_filter_rules=[FilterRule("agent_name", "equals", "support-bot")],
)

# Full input/output — resolves blob-offloaded large values transparently
span = TraceSpan.get(spans[0].span_id)
full_input = span.full_input()
full_output = span.full_output()

# Any attribute, offload-aware; or the complete attribute dict as JSON
model = span.get_attribute("gen_ai.request.model")
import json; print(json.dumps(span.attributes, indent=2, default=str))
```

A raw dict is accepted anywhere a filter is (`filter_rules={"logic": "AND", "rules": [...]}`). Filter columns are validated by the backend (`TraceValidationError` on an unknown column); common ones include `name`, `duration_ms`, `total_tokens`, `status`, `session_id`, `agent_name`, `environment`, `user_id` for traces and `span_name`, `type`, `model_name`, `input`, `output`, `langgraph_node` for spans. Free-text `search` needs at least 3 characters, or pass a hex trace/span ID for an exact lookup.

`list()` returns lightweight rows; call `get()` (or `trace.fetch()`) for the full record. `trace.spans` holds partial `TraceSpan` objects (structural/summary fields, no `attributes`; call `span.fetch()` for the full record), and is `None` until the trace is hydrated — reading it before then logs a warning pointing you to `.fetch()`. Large `input`/`output` values are truncated in responses and offloaded to blob storage — `span.full_input()` / `span.full_output()` return the complete value, downloading it only when needed (`span.offloaded_attributes` lists what was offloaded, and `span.fetch_attribute(name)` fetches a raw blob directly). A trace only carries `input_snippet` / `output_snippet`; for its full input/output, read them from the root span (`TraceSpan.get(trace.root_span_id).full_input()`).

### Exporting a trace to a file

`Trace.export()` returns the complete trace in one call — the same self-contained JSON document as the Beacon UI's **Download** button: trace metadata plus the full span hierarchy with complete attributes, no per-span fetching. Blob-offloaded attribute values are restored by the backend; anything it could not restore is reported explicitly instead of silently missing.

```python
export = Trace.export("0123456789abcdef0123456789abcdef")
export.save(f"trace_{export.trace_id}.json")   # write the document as JSON

export.raw                     # the exact server document (dict) — json.dump-ready
export.root_span["children"]   # full span hierarchy with attributes
if not export.is_complete:     # offloaded values the backend could not restore
    print(export.unresolved_blob_attributes)   # [(span_id, attribute_key, reason, size)]

# Batch export — up to 50 traces per call; IDs the backend dropped are explicit
exports, missing = Trace.export_batch([trace_id_1, trace_id_2])
```

Async variants: `await Trace.aexport(...)` / `await Trace.aexport_batch(...)`. For selective reads (one span's attributes, one offloaded input) prefer the query API above — `span.fetch()`, `span.full_input()` — instead of exporting whole traces.

## Agents & Insights

Register the agents your traces are attributed to, then read the AI-generated insights and recommendations Beacon produces for them — and drive the analysis runs that generate them. The agents endpoints are project-scoped: the SDK resolves your API key's bound project automatically and caches it; pass `project_id=` to override (required for non-project master keys).

```python
from lumenova_beacon.agents import Agent, AgentMatchRule

# Discover agent identities seen in recent traces, then register one
found, total = Agent.discovered(days=7)
agent = Agent.create(
    name="support-bot",
    match_rule=AgentMatchRule(names=["support-bot"]),   # or a raw dict
    description="Customer support agent",
)

# Registry reads and updates
agents, page = Agent.list()
agent = Agent.get(agent.id)
agent.update(description="Handles tier-1 support chats")

# Per-agent usage rollups over a window (users/sessions/traces/tokens/cost)
report = Agent.usage(window_days=30)
for item in report.items:
    print(item.agent_name, item.traces, item.cost)
```

### Insights and recommendations (the findings ledger)

Findings are produced by Beacon's analysis runs; each is either an **insight** (kind, severity, frequency, confidence) or a **recommendation** (category, priority, suggested before/after), with evidence references back to the traces, sessions, and evaluations that support it.

```python
# Read an agent's insights and recommendations
findings, page = agent.findings(severities=["warning", "critical"])
recs, _ = agent.findings(target_type="recommendation", sort="last_observed")
for f in findings:
    print(f.severity, f.title, f.times_seen, [e.type for e in f.evidence])

# Act on a finding
finding = findings[0]
finding.add_feedback("Also happens on weekend traffic")  # feeds the next analysis
observations = finding.history()   # observation journal; also refreshes the finding
finding.dismiss()                  # hide from default listings
finding.undismiss()

# Trigger and monitor analysis
run = agent.analyze(effort="deep")   # 'fast' | 'normal' | 'deep'
state = agent.analysis_state()       # slim poll target; None until first analyzed
if state and state.is_stale:
    agent.analyze()                  # incremental by default; mode="window" re-analyzes a window
runs, _ = agent.runs()               # analysis run log, newest first
agent.cancel_analysis()

# Project-level fleet rollup (per-agent counts + cross-agent themes)
overview = Agent.overview()
print(overview.totals, len(overview.agents), len(overview.themes))
```

Async variants follow the usual `a` prefix (`await Agent.alist()`, `await agent.aanalyze()`, ...). When an analysis cannot start, `analyze()` raises `AgentConflictError` whose `.reason` carries the backend's structured cause: `'no_completed_analysis'` (run a full analysis first), `'insufficient_new_data'` (nothing new — pass `force=True` to override), or `'finding_regeneration_in_flight'`.

## Human Annotations

Route traces, spans, or sessions to human reviewers and read their feedback back. Annotation queues (and their score definitions) are created in the Beacon UI; the SDK covers the application-side workflows with the same sync/async API shape as the other management classes.

```python
from lumenova_beacon.annotations import Annotation, AnnotationQueue

# List queues and inspect their score definitions
queues, page = AnnotationQueue.list(search="review")
queue = AnnotationQueue.get(queues[0].id)
for score in queue.scores_config:
    print(score.name, score.type, score.required)   # e.g. accuracy percent True

# Enqueue work for human review — traces, sessions, or individual spans
# (must match the queue's target_type). Adding is idempotent.
result = queue.add_items(trace_ids=[trace_id])
result = queue.add_items(session_ids=["session-1"])
result = queue.add_items(spans=[(trace_id, span_id)])
print(len(result.created), result.skipped_existing)

# List items and push feedback collected in your own app (e.g. end-user
# thumbs up/down). Scores are a plain {name: value} mapping, validated
# server-side against the queue's score definitions.
items, item_page = queue.items(status="pending")
items[0].complete({"accuracy": 0.9, "notes": "solid answer"})

# Optimistic concurrency: raise AnnotationConflictError instead of
# overwriting another annotator's concurrent edit
item = queue.get_item(items[0].id)
item.complete({"accuracy": 1.0}, expected_last_modified_at=item.last_modified_at)

# Read annotation summaries back — one per queue item referencing the target
summaries = Annotation.for_trace(trace_id)      # or Annotation.for_session(...)
summaries = Trace.get(trace_id).annotations()   # convenience on Trace
for s in summaries:
    print(s.queue_name, s.status, {sc.name: sc.value for sc in s.scores})
```

Score types are `percent` (float in 0–1), `numeric` (respecting min/max bounds), `categorical` (one of the configured values), and `text`. A completion may fill a subset of scores — the item's status (`pending` / `partially_completed` / `completed`) is derived from which required scores are filled. Queue create/update/delete, import/export, and calibration are UI/admin workflows and intentionally not exposed. See [examples/management/annotations_usage.py](examples/management/annotations_usage.py) for a full walkthrough.

## LLM Config Management

```python
from lumenova_beacon.llm_configs import LLMConfig

config = LLMConfig.get(config_id="config-uuid")
configs, pagination = LLMConfig.list(page=1, page_size=20)
```

## Data Masking

Automatically mask sensitive data (PII) before spans are exported:

```python
from lumenova_beacon import BeaconClient
from lumenova_beacon.masking.integrations.beacon_guardrails import (
    create_beacon_masking_function,
    MaskingMode,
    PIIType,
)

# Create a masking function backed by Beacon Guardrails API
masking_fn = create_beacon_masking_function(
    pii_types=[PIIType.PERSON, PIIType.EMAIL_ADDRESS, PIIType.US_SSN],
    mode=MaskingMode.REDACT,
)

# Pass it to the client - all span data is masked before export
client = BeaconClient(
    endpoint="https://your-beacon-endpoint.lumenova.ai",
    api_key="your-api-key",
    masking_function=masking_fn,
)
```

You can also provide a custom masking function:

```python
def my_masking_fn(text: str) -> str:
    return text.replace("secret", "***")

client = BeaconClient(masking_function=my_masking_fn)
```

## Guardrails

Apply content guardrail policies via the Beacon API:

```python
from lumenova_beacon.guardrails import Guardrail

guardrail = Guardrail(guardrail_id="guardrail-uuid")

# Sync
result = guardrail.apply("some user input")

# Async
result = await guardrail.aapply("some user input")
```

### Optional metadata

Pass an optional `metadata` dict to enable retrieval/grounding-aware policies:

```python
result = guardrail.apply(
    "Paris is the capital of France.",
    metadata={
        "query": "What is the capital of France?",
        "context": [
            "Paris is the capital and largest city of France.",
            "France is a country in Western Europe.",
        ],
    },
)
```

- `metadata` is optional — omit it entirely if you only need to validate `text`.
  Both `query` and `context` are also individually optional within `metadata`.
- `query` is the user's original question; it is forwarded to evaluation policies
  that score relevance/grounding against the model output.
- `context` is the list of retrieved document **texts** the model used to answer
  (typical RAG flow). Pass the actual content of each chunk, not links to it. Each
  entry is sanitized alongside the main `text` and round-trips into the response
  (`outputs[].input.metadata.context`, `outputs[].output.metadata.context`).

## API Reference

### Main Exports

```python
from lumenova_beacon import (
    BeaconClient,           # Main client
    BeaconConfig,           # Configuration class
    get_client,             # Get current client singleton
    trace,                  # Tracing decorator
    # Integrations (lazy-loaded)
    BeaconLangGraphHandler,  # LangChain/LangGraph
    BeaconLangGraphConfig,  # LangGraph configuration (adds support for interruptions)
    BeaconStrandsHooks,     # Strands Agents (hook provider, recommended)
    BeaconStrandsHandler,   # Strands Agents (callback handler, legacy)
    BeaconCrewAIListener,   # CrewAI
    BeaconMCPMiddleware,    # MCP server (FastMCP middleware)
    BeaconLiteLLMLogger,    # LiteLLM
    # Governance (lazy-loaded)
    BeaconLangGraphAgent,              # LangGraph wrapper with violation_mode dispatch
    BeaconLangGraphGovernanceHandler,  # LangChain/LangGraph governance callback handler
    GovernanceConfig,                  # Governance configuration
)

from lumenova_beacon.governance import governance  # Function decorator

from lumenova_beacon.datasets import Dataset, DatasetRecord
from lumenova_beacon.prompts import Prompt
from lumenova_beacon.experiments import Experiment
from lumenova_beacon.evaluations import (
    Evaluation,
    EvaluationRun,
    Evaluator,
    EvaluatorVersion,        # Immutable evaluator version snapshot
    EvaluationStats,         # Project-level evaluation rollup
    AgentEvaluationStats,    # Per-agent stats (overall + per-evaluator scores)
)
from lumenova_beacon.agents import Agent, AgentFinding  # Agent registry + insights
from lumenova_beacon.annotations import Annotation, AnnotationQueue
from lumenova_beacon.llm_configs import LLMConfig
from lumenova_beacon.guardrails import Guardrail
from lumenova_beacon.types import SpanKind, StatusCode, SpanType
```

### BeaconClient

```python
client = BeaconClient(
    endpoint: str | None = None,
    api_key: str | None = None,
    file_directory: str | None = None,
    session_id: str | None = None,
    user_email: str | None = None,
    user_name: str | None = None,
    user_id: str | None = None,
    service_name: str | None = None,
    environment: str | None = None,
    auto_instrument_opentelemetry: bool = True,
    isolated: bool = False,
    auto_instrument_litellm: bool = False,
    masking_function: Callable | None = None,
    verify: bool | str | None = None,
    proxies: dict[str, str] | str | None = None,
    cert: str | tuple[str, str] | None = None,
    eager_export: bool | None = None,
    record_stacktrace: bool = False,
)

# Methods
span = client.create_span(name, kind, span_type, session_id)
ctx = client.trace(name, kind, span_type)  # Context manager (sync & async)
client.export_span(span)     # Export a single span
client.export_spans(spans)   # Export multiple spans
client.flush()               # Flush pending spans
```

### Dataset

```python
# Class methods (sync - simple names)
dataset = Dataset.create(name: str, description: str | None = None, column_schema: list[dict[str, Any]] | None = None)
dataset = Dataset.get(dataset_id: str, include_records: bool = False)
datasets, pagination = Dataset.list(page=1, page_size=20, search=None)

# Class methods (async - 'a' prefix)
dataset = await Dataset.acreate(...)
dataset = await Dataset.aget(...)
datasets, pagination = await Dataset.alist(...)

# Instance methods (sync - simple names)
dataset.save()
dataset.update(name=None, description=None)
dataset.delete()
record = dataset.create_record(data: dict[str, Any])
dataset.bulk_create_records(records: list[dict])
records, pagination = dataset.list_records(page=1, page_size=50)

# Instance methods (async - 'a' prefix)
await dataset.asave()
await dataset.aupdate(...)
await dataset.adelete()
record = await dataset.acreate_record(...)
await dataset.abulk_create_records(...)
records, pagination = await dataset.alist_records(...)

# Properties
dataset.id
dataset.name
dataset.description
dataset.record_count
dataset.created_at
dataset.updated_at
dataset.column_schema
```

### DatasetRecord

```python
# Class methods (sync - simple names)
record = DatasetRecord.get(dataset_id: str, record_id: str)
records, pagination = DatasetRecord.list(dataset_id: str, page=1, page_size=50)

# Class methods (async - 'a' prefix)
record = await DatasetRecord.aget(...)
records, pagination = await DatasetRecord.alist(...)

# Instance methods (sync - simple names)
record.save()
record.update(data: dict[str, Any] | None = None)
record.delete()

# Instance methods (async - 'a' prefix)
await record.asave()
await record.aupdate(...)
await record.adelete()

# Properties
record.id
record.dataset_id
record.data  # dict[str, Any] - flexible column data
record.created_at
record.updated_at
```

### Prompt

```python
# Class methods (sync - simple names)
prompt = Prompt.create(name, *, template=None, messages=None, description=None, tags=None, category=None, message=None)
prompt = Prompt.get(prompt_id=None, *, name=None, label="latest", version=None)
prompts, pagination = Prompt.list(  # returns (list[Prompt], PaginatedResponse)
    page=1, page_size=50,
    tags=None,          # list[str] (matches prompts having ANY of these tags),
                        # or an any_of()/all_of() expression for AND/OR combos
                        # e.g. all_of("support", any_of("prod", "staging"))
    search=None, prompt_type=None,
    category=None,      # str, or Prompt.UNCATEGORIZED for prompts with no category
    sort_by=None,       # 'created_at' | 'updated_at' | 'name' (server default: 'created_at')
    sort_order=None,    # 'asc' | 'desc' (server default: 'desc')
)
prompts = Prompt.bulk_get(prompt_ids=None, category=None)  # one of; category may be Prompt.UNCATEGORIZED
categories = Prompt.categories()  # -> list[str] of category names in use
tags = Prompt.list_tags()  # -> list[str] of tags in use (sorted)

# Class methods (async - 'a' prefix)
prompt = await Prompt.acreate(...)
prompt = await Prompt.aget(...)
prompts, pagination = await Prompt.alist(...)
prompts = await Prompt.abulk_get(...)
categories = await Prompt.acategories()
tags = await Prompt.alist_tags()

# Instance methods (sync - simple names)
prompt.update(name=None, description=None, tags=None, category=None)
prompt.delete()
new_version = prompt.publish(template=None, messages=None, message="")
prompt.set_label(label: str, version: int | None = None)
labels = prompt.list_labels()          # -> list[PromptLabel]
label = prompt.get_label(name: str)    # -> PromptLabel (raises PromptNotFoundError if absent)

# Instance methods (async - 'a' prefix)
await prompt.aupdate(...)
await prompt.adelete()
new_version = await prompt.apublish(...)
await prompt.aset_label(...)
labels = await prompt.alist_labels()
label = await prompt.aget_label(name)

# Rendering (always sync)
result = prompt.format(**kwargs)
result = prompt.compile(variables: dict)
template = prompt.to_template()  # Convert to Python f-string format
lc_prompt = prompt.to_langchain()  # Convert to LangChain template

# Properties
prompt.id
prompt.name
prompt.type  # "text" or "chat"
prompt.version
prompt.template  # For TEXT prompts
prompt.messages  # For CHAT prompts
prompt.labels  # list[str]
prompt.tags  # list[str]
```

### Span

```python
span = Span(name, kind, span_type)

# Lifecycle
span.start()
span.end(status_code=StatusCode.OK)

# Status
span.set_status(StatusCode.ERROR, "description")
span.record_exception(exc: Exception)

# Attributes
span.set_attribute("key", value)
span.set_attributes({"k1": "v1", "k2": "v2"})
span.set_input(data: dict)
span.set_output(data: dict)
span.set_metadata("key", value)

# Properties
span.trace_id
span.span_id
span.parent_id
span.name
span.kind
span.span_type
```

### Type Enums

```python
from lumenova_beacon.types import SpanKind, StatusCode, SpanType

# SpanKind
SpanKind.INTERNAL
SpanKind.SERVER
SpanKind.CLIENT
SpanKind.PRODUCER
SpanKind.CONSUMER

# StatusCode
StatusCode.UNSET
StatusCode.OK
StatusCode.ERROR

# SpanType
SpanType.SPAN
SpanType.GENERATION
SpanType.CHAIN
SpanType.TOOL
SpanType.RETRIEVAL
SpanType.AGENT
SpanType.FUNCTION
SpanType.REQUEST
SpanType.SERVER
SpanType.TASK
SpanType.CACHE
SpanType.EMBEDDING
SpanType.HANDOFF
SpanType.CONDITIONAL
```

## Error Handling

All exceptions inherit from `BeaconError`. Key exception types:

- `ConfigurationError` — invalid configuration
- `TransportError` (`HTTPTransportError`, `FileTransportError`) — export failures
- `GovernanceViolationError` — policy blocked an action (includes `message`, `policies`, `latency_ms`)
- `DatasetError`, `PromptError`, `ExperimentError`, `EvaluationError`, `TraceError`, `AnnotationError`, `AgentError` — resource-specific errors (each with `NotFound` and `Validation` variants; 409s raise `AnnotationConflictError` / `AgentConflictError`, the latter carrying a structured `.reason`)

### Retry Logic

All HTTP operations automatically retry up to 3 times with exponential backoff:

```python
from lumenova_beacon.exceptions import PromptNetworkError

try:
    prompt = Prompt.get(name="my-prompt")
except PromptNetworkError as e:
    # Failed after 3 automatic retries
    print(f"Network error: {e}")
except PromptNotFoundError as e:
    # Prompt doesn't exist
    print(f"Not found: {e}")
```

### Graceful Degradation

```python
from lumenova_beacon import BeaconClient

# Disable tracing in development
client = BeaconClient(enabled=False)

# Tracing becomes no-op when disabled
@trace
def my_function():
    return "result"  # No tracing overhead
```

## License

This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
