Advanced: Context Manager
For most agents, the decorator pattern is simpler and covers 90% of use cases. Use WaxellContext only when you need explicit lifecycle control -- batch loops, multi-step orchestration across functions, conditional context creation, or multiple runs in a single function.
See Decorator vs Context Manager for a decision guide.
WaxellContext is a context manager that gives you explicit control over run lifecycle, LLM call recording, step tracking, and mid-execution policy checks.
It works as both async with (for async code) and plain with (for sync code).
Async Usage
from waxell_observe import WaxellContext
async with WaxellContext(agent_name="research-agent") as ctx:
result = await run_research(query)
ctx.record_llm_call(model="gpt-4o", tokens_in=300, tokens_out=150)
ctx.record_step("research", output={"sources": 5})
ctx.set_result({"answer": result})
Sync Usage
from waxell_observe import WaxellContext
with WaxellContext(agent_name="batch-processor") as ctx:
result = process_data(input_data)
ctx.record_llm_call(model="gpt-4o", tokens_in=300, tokens_out=150)
ctx.record_step("process", output={"items": 42})
ctx.set_result({"output": result})
The sync path uses native __enter__ / __exit__ with synchronous HTTP calls — ContextVars are set in the calling thread, so auto-instrumentation works correctly.
Use with (sync) for batch processing scripts, CLI tools, ETL pipelines, and any code that doesn't use async/await. Use async with for async web servers, async agent frameworks, and code that's already async.
Convenience Aliases
waxell.context and waxell.session are re-exports for the cleanest one-liner usage:
import waxell_observe as waxell
# `waxell.context(...)` is an alias for `WaxellContext(...)`
with waxell.context(agent_name="my-agent") as ctx:
...
# `waxell.session(...)` sets session_id / user_id for any run opened inside the
# block — useful for thread-less frameworks (pydantic-ai, smolagents, raw SDK
# loops) where the framework has no native thread concept.
with waxell.session(session_id=thread_id, user_id=email):
agent.run_sync(user_message) # auto-instrumented run inherits session/user
You can also generate a random session id with waxell.generate_session_id() (returns sess_<16 hex chars>).
Lifecycle
On entering the context:
- Policies are checked (if
enforce_policy=True) - A new execution run is started on the control plane
On exiting the context:
- Buffered LLM calls are flushed to the control plane
- Buffered steps are flushed to the control plane
- The run is completed with success or error status
Enhanced Context Options
Session and User Tracking
Group related runs into sessions and track end-user identity:
with WaxellContext(
agent_name="my-chatbot",
session_id="session-abc-123", # Group related runs
user_id="user-456", # Track end-user
) as ctx:
# Your LLM calls here
response = call_llm(prompt)
Tags and Metadata
Add structured metadata to runs for filtering and analysis:
with WaxellContext(agent_name="my-agent") as ctx:
ctx.set_tag("environment", "production")
ctx.set_tag("pipeline", "rag-v2")
ctx.set_metadata("retrieval_count", 5)
ctx.set_metadata("model_version", "gpt-4-turbo")
# Your LLM calls here
Recording Scores
Capture quality metrics and user feedback:
with WaxellContext(agent_name="my-agent") as ctx:
response = call_llm(prompt)
# Numeric score (0-1 range)
ctx.record_score(
name="relevance",
value=0.92,
data_type="numeric",
comment="Highly relevant to the query"
)
# Boolean score
ctx.record_score(
name="contains_hallucination",
value=False,
data_type="boolean"
)
# Categorical score
ctx.record_score(
name="tone",
value="professional",
data_type="categorical"
)
Recording Steps
Track sub-operations within a run:
with WaxellContext(agent_name="rag-pipeline") as ctx:
# Step 1: Retrieval
docs = retrieve_documents(query)
ctx.record_step("retrieval", output={"doc_count": len(docs)})
# Step 2: Generation
response = generate_response(query, docs)
ctx.record_step("generation", output={"response_length": len(response)})
Constructor Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
agent_name | str | (required) | Name for this agent in the control plane |
workflow_name | str | "default" | Workflow name for grouping runs |
inputs | dict | None | None | Input data to record with the run |
metadata | dict | None | None | Arbitrary metadata to attach to the run |
client | WaxellObserveClient | None | None | Pre-configured client. If None, creates a new one using current configuration |
enforce_policy | bool | True | Check policies on context entry |
session_id | str | "" | Session ID for grouping related runs |
user_id | str | "" | End-user ID for per-user tracking and analytics |
user_group | str | "" | User group for authorization policies (e.g., "enterprise", "free") |
end_user_id | str | "" | Sub-user identity for end-user budget / rate-limit / suspension policy handlers. Wired into metadata["tenant_sub_user_id"] |
interaction_mode | str | None | None | One of "auto", "interactive", "autonomous". None/"auto" is inferred at run start: a session_id (chat turn / multi-turn thread) means interactive; no session means autonomous |
mid_execution_governance | bool | None | None | Flush data and check governance after each governance-bearing record_* call. None resolves to True unless WAXELL_DISABLE_MID_EXECUTION_GOVERNANCE=1 is set |
auto_grounding | bool | False | Auto-bridge retrieval scores to grounding governance |
on_policy_block | Callable | None | None | Callback for policy blocks. Receives PolicyViolationError, returns ApprovalDecision. Built-in: prompt_approval, auto_approve, auto_deny |
Recording Methods
record_llm_call
Record an LLM API call. All parameters are keyword-only.
ctx.record_llm_call(
model="gpt-4o",
tokens_in=500,
tokens_out=200,
cost=0.0, # Optional: auto-estimated if 0.0
task="summarize", # Optional: label for this call
prompt_preview="...", # Optional: first N chars of prompt
response_preview="...", # Optional: first N chars of response
duration_ms=350, # Optional: call duration in milliseconds
provider="openai", # Optional: inferred from model name if empty
)
| Parameter | Type | Default | Description |
|---|---|---|---|
model | str | (required) | Model name (e.g., "gpt-4o", "claude-sonnet-4") |
tokens_in | int | (required) | Input/prompt token count |
tokens_out | int | (required) | Output/completion token count |
cost | float | 0.0 | Cost in USD. If 0.0, automatically estimated using built-in model pricing |
task | str | "" | A label describing this LLM call's purpose |
prompt_preview | str | "" | Preview of the prompt text |
response_preview | str | "" | Preview of the response text |
duration_ms | int | None | None | LLM call duration in milliseconds |
provider | str | "" | Provider name (e.g., "openai", "anthropic"). If empty, inferred from model name |
LLM calls are buffered in memory and flushed to the control plane when the context exits.
record_step
Record a named execution step.
ctx.record_step("extract_entities", output={"count": 12})
| Parameter | Type | Default | Description |
|---|---|---|---|
step_name | str | (required) | Name identifying this step |
output | dict | None | None | Optional output data for the step |
Steps are automatically numbered in order of recording. Like LLM calls, they are buffered and flushed on context exit.
set_result
Set the final result for the run.
ctx.set_result({"answer": "The capital of France is Paris.", "confidence": 0.95})
| Parameter | Type | Default | Description |
|---|---|---|---|
result | dict | (required) | Result data to include when the run is completed |
Call this before the context exits. If not called, the run completes with an empty result.
check_policy / check_policy_sync
Perform a mid-execution policy check. This is useful for long-running agents that should re-validate policies between steps.
# Async
policy = await ctx.check_policy()
# Sync
policy = ctx.check_policy_sync()
if policy.blocked:
print(f"Blocked: {policy.reason}")
# Handle the block (e.g., stop processing)
elif policy.action == "warn":
print(f"Warning: {policy.reason}")
# Continue but log the warning
Returns a PolicyCheckResult with:
action-- one of"allow","block","warn","throttle"reason-- human-readable explanationmetadata-- additional policy dataallowed-- property,Trueif action is"allow"or"warn"blocked-- property,Trueif action is"block"or"throttle"
record_score
Record a quality score or feedback metric for the current run.
ctx.record_score(
name="relevance",
value=0.92,
data_type="numeric",
comment="Highly relevant to the query",
)
| Parameter | Type | Default | Description |
|---|---|---|---|
name | str | (required) | Score name (e.g., "relevance", "accuracy", "thumbs_up") |
value | float | str | bool | (required) | Score value. Type depends on data_type |
data_type | str | "numeric" | One of "numeric", "categorical", "boolean" |
comment | str | "" | Optional free-text comment |
Scores are buffered and flushed to the control plane when the context exits.
set_tag
Set a searchable tag on the current run. Tags become OTel span attributes and are queryable in Grafana TraceQL.
ctx.set_tag("environment", "production")
ctx.set_tag("pipeline", "rag-v2")
| Parameter | Type | Default | Description |
|---|---|---|---|
key | str | (required) | Tag name (alphanumeric, underscores, hyphens) |
value | str | (required) | Tag value (string only) |
set_metadata
Set arbitrary metadata on the current run. Complex values are JSON-serialized.
ctx.set_metadata("retrieval_count", 5)
ctx.set_metadata("model_version", "gpt-4-turbo")
| Parameter | Type | Default | Description |
|---|---|---|---|
key | str | (required) | Metadata key |
value | Any | (required) | Any JSON-serializable value |
Behavior Tracking
Track agent behaviors beyond LLM calls and steps. These methods buffer data as spans and flush on context exit.
record_tool_call
Record a tool or function call.
ctx.record_tool_call(
name="web_search",
input={"query": "latest news"},
output={"results": [...]},
duration_ms=250,
status="ok",
tool_type="api",
)
| Parameter | Type | Default | Description |
|---|---|---|---|
name | str | (required) | Tool name (e.g., "web_search", "database_query") |
input | dict | str | "" | Tool input parameters |
output | dict | str | "" | Tool output/result |
duration_ms | int | None | None | Execution time in milliseconds |
status | str | "ok" | "ok" or "error" |
tool_type | str | "function" | Classification: "function", "api", "database", "retriever" |
error | str | "" | Error message if status is "error" |
record_retrieval
Record a RAG document retrieval.
ctx.record_retrieval(
query="How does the billing system work?",
documents=[{"id": "doc1", "title": "Billing FAQ", "score": 0.92}],
source="pinecone",
duration_ms=120,
top_k=5,
scores=[0.92, 0.87, 0.81],
)
| Parameter | Type | Default | Description |
|---|---|---|---|
query | str | (required) | The retrieval query string |
documents | list[dict] | (required) | Retrieved documents (e.g., [{id, title, score, snippet}]) |
source | str | "" | Data source name (e.g., "pinecone", "elasticsearch") |
duration_ms | int | None | None | Retrieval time in milliseconds |
top_k | int | None | None | Number of documents requested |
scores | list[float] | None | None | Relevance scores for each retrieved document |
record_decision
Record a decision or routing point.
ctx.record_decision(
name="route_to_agent",
options=["billing", "technical", "general"],
chosen="billing",
reasoning="User mentioned invoice and payment",
confidence=0.95,
)
| Parameter | Type | Default | Description |
|---|---|---|---|
name | str | (required) | Decision name (e.g., "route_to_agent", "select_model") |
options | list[str] | (required) | Available choices |
chosen | str | (required) | The selected option |
reasoning | str | "" | Why this option was chosen |
confidence | float | None | None | Confidence score (0.0-1.0) |
metadata | dict | None | None | Additional context |
instrumentation_type | str | "manual" | How this decision was captured: "manual", "decorator", or "auto" |
record_reasoning
Record a reasoning or chain-of-thought step.
ctx.record_reasoning(
step="evaluate_sources",
thought="Source A is more recent but Source B has higher authority",
evidence=["Source A: 2024", "Source B: cited 500 times"],
conclusion="Use Source B as primary, Source A as supplement",
)
| Parameter | Type | Default | Description |
|---|---|---|---|
step | str | (required) | Reasoning step name |
thought | str | (required) | The reasoning text/thought process |
evidence | list[str] | None | None | Supporting evidence or references |
conclusion | str | "" | Conclusion reached at this step |
record_retry
Record a retry or fallback event.
ctx.record_retry(
attempt=2,
reason="Rate limited by OpenAI",
strategy="fallback",
original_error="429 Too Many Requests",
fallback_to="claude-sonnet-4",
max_attempts=3,
)
| Parameter | Type | Default | Description |
|---|---|---|---|
attempt | int | (required) | Current attempt number (1-based) |
reason | str | (required) | Why a retry/fallback occurred |
strategy | str | "retry" | "retry", "fallback", or "circuit_break" |
original_error | str | "" | The error that triggered the retry |
fallback_to | str | "" | Name of fallback target (model, agent, tool) |
max_attempts | int | None | None | Maximum attempts configured |
record_policy_check
Record a policy evaluation result as a governance span.
ctx.record_policy_check(
policy_name="budget-limit",
action="warn",
category="budget",
reason="Approaching 80% of daily budget",
phase="mid_execution",
)
| Parameter | Type | Default | Description |
|---|---|---|---|
policy_name | str | (required) | Name of the policy evaluated |
action | str | (required) | Evaluation result: "allow", "warn", "block", etc. |
category | str | "" | Policy category (e.g., "budget", "rate-limit") |
reason | str | "" | Reason for the action (empty for allow) |
duration_ms | float | 0 | Evaluation time in milliseconds |
phase | str | "pre_execution" | "pre_execution", "mid_execution", or "post_execution" |
priority | int | 100 | Policy priority (lower = evaluated first) |
Conversation & Human Interaction
For interactive agents (chat, REPL, ticketing) — make user input and agent output visible in the trace alongside LLM calls. These also bump conversation counters, so context-management / recursion-bound policies fire mid-run.
record_user_message
Record an inbound user message. Always lands at position=0.
ctx.record_user_message("What's the weather?")
| Parameter | Type | Default | Description |
|---|---|---|---|
content | str | "" | The user's message text |
message | str | "" | Alias for content; content wins if both set |
metadata | dict | None | None | Optional extra context (channel, user_id, ...) |
record_agent_response
Record an outbound agent response (distinct from the raw LLM call payload).
ctx.record_agent_response("It's sunny in Paris today.")
| Parameter | Type | Default | Description |
|---|---|---|---|
content | str | (required) | The agent's response text shown to the user |
metadata | dict | None | None | Optional extra context (citations, confidence, ...) |
record_human_interaction
Record a completed human-in-the-loop interaction as a single IO span. Use this
when you already have the prompt, response, and timing — for the streaming /
context-manager variants use ctx.input(...) or ctx.human_turn(...) (below).
ctx.record_human_interaction(
prompt="Approve refund?",
response="yes",
channel="slack",
action="approval",
elapsed_ms=12500,
)
| Parameter | Type | Default | Description |
|---|---|---|---|
prompt | str | "" | What was shown to the human |
response | str | "" | What the human replied |
channel | str | "terminal" | Where it happened ("terminal", "slack", "ui", "webhook", ...) |
action | str | "" | Interaction kind ("confirmation", "input", "approval", ...) |
elapsed_ms | float | int | None | None | How long the human took to respond |
metadata | dict | None | None | Arbitrary extra context |
ctx.input
Drop-in for the built-in input() — auto-captures prompt + response + wait time
as a human_turn span. Strips ANSI escape codes before recording.
answer = ctx.input("Approve? (y/n): ")
ctx.human_turn
Context manager for non-terminal channels (Slack, webhooks, UI dialogs) where the wait shape varies:
with ctx.human_turn(prompt="Review PR", channel="github") as turn:
result = wait_for_webhook()
turn.set_response(result)
record_communication
Record an outbound communication (Slack, email, SMS, etc.) for communication governance policies (allowed channels, message limits, disclaimer rules).
ctx.record_communication(
channel="slack",
recipient="#general",
body="Deploy completed",
)
| Parameter | Type | Default | Description |
|---|---|---|---|
channel | str | (required) | "slack", "email", "sms", ... |
recipient | str | "" | Target ("#general", "user@acme.com", ...) |
body | str | "" | Message body |
subject | str | "" | Subject line (email, etc.) |
metadata | dict | None | None | Extra context |
HITL Pause
ctx.pause
Mark a human-in-the-loop pause — wrap any blocking wait so the trace renders a ⏸ pause marker with wait duration + reason. Framework-agnostic.
with ctx.pause(reason="awaiting_approval"):
decision = wait_for_human() # blocks
reason mirrors runtime PausedReason values: awaiting_user,
awaiting_approval, awaiting_signal, awaiting_timer, awaiting_child.
ctx.mark_resumed
Mark that this run resumed after a cross-process pause (e.g. a LangGraph
interrupt() answered by a separate resume invocation). Emits a post_resume
phase marker.
ctx.mark_resumed(from_run_id="run_abc123")
Memory
Record agent memory reads and writes — emits kind=memory spans and feeds
memory governance + the run's Memory tab + memory-state replay.
remember_episodic
Write an episodic memory (named-slot KV).
ctx.remember_episodic(slot_name="deal_findings", data={"company": "Acme"})
| Parameter | Type | Default | Description |
|---|---|---|---|
slot_name | str | (required) | Named slot (e.g. "deal_findings") |
data | Any | (required) | Value to store (dict / list / scalar) |
scope_key | str | None | None | Defaults to agent:session |
ttl_seconds | int | 86400 | TTL on the Memory tab write |
max_items | int | None | None | Cap on items in the slot |
memory_type | str | None | None | Optional sub-classification |
remember_semantic
Write a semantic fact (free-text, embedded for similarity search).
ctx.remember_semantic(
slot_name="customer_preferences",
content="Acme prefers monthly invoicing in EUR.",
)
| Parameter | Type | Default | Description |
|---|---|---|---|
slot_name | str | (required) | Named slot |
content | str | (required) | Fact text |
scope_key | str | None | None | Defaults to agent:session |
importance | float | 0.7 | 0–1 weight |
tags | list | None | None | Searchable tags |
fact_key | str | None | None | Stable de-dup key |
source_tool | str | "" | Where the fact came from |
content_embedding | list | None | None | Pre-computed embedding (falls back to OpenAI text-embedding-3-small if OPENAI_API_KEY is set) |
record_memory_recall
Record a memory read (search/lookup) — the read side of the memory signal, analogous to grounding. Surfaces hit-rate + relevance per turn.
ctx.record_memory_recall(
query="What did Acme say about pricing?",
tier="episodic",
store="mem0",
results_count=3,
top_score=0.84,
results=hits,
)
| Parameter | Type | Default | Description |
|---|---|---|---|
query | str | "" | Recall query |
tier | str | "episodic" | "episodic" or "semantic" |
store | str | "" | Memory store name ("mem0", "zep", "letta", ...) |
results_count | int | 0 | 0 ⇒ miss; >0 ⇒ hit |
top_score | float | None | None | Best match relevance (0–1) |
slot_name | str | "" | Named slot if applicable |
results | list | None | None | Recalled items — bounded-captured into the memory snapshot for replay |
Prompt Lineage
record_prompt_use
Stamp the run with the registry prompt name + version it used. This is the
lineage link that ties eval + replay to the exact prompt version. get_prompt
calls this automatically; use it directly when you fetch a prompt some other way.
ctx.record_prompt_use(name="customer_intake", version=7, label="prod")
| Parameter | Type | Default | Description |
|---|---|---|---|
name | str | (required) | Registry prompt name |
version | int | 0 | Pinned version (0 means latest at fetch time) |
label | str | "" | Label resolved against ("prod", "staging") |
content_hash | str | "" | Content hash of the rendered prompt |
Approval Lifecycle
record_approval_request
Record that an approval workflow was initiated after a policy block. Call
after catching PolicyViolationError when the policy metadata indicates
approval is required.
ctx.record_approval_request(
action_type="delete",
approvers=["security@acme.com"],
timeout_minutes=30,
reason="Mass deletion above 100 records",
)
| Parameter | Type | Default | Description |
|---|---|---|---|
action_type | str | (required) | Action awaiting approval ("delete", "refund", ...) |
approvers | list | None | None | Emails / group names |
timeout_minutes | float | None | None | Approval window |
reason | str | "" | Why approval is needed |
metadata | dict | None | None | Extra context |
record_approval_response
Record the outcome of an approval request. Call after the human decides or the timeout expires.
ctx.record_approval_response(
action_type="delete",
decision="approved",
approver="security@acme.com",
elapsed_seconds=420,
)
| Parameter | Type | Default | Description |
|---|---|---|---|
action_type | str | (required) | Action that was awaiting approval |
decision | str | (required) | "approved", "denied", or "timeout" |
approver | str | "" | Who decided |
elapsed_seconds | float | None | None | Time from block to decision |
metadata | dict | None | None | Extra context |
Governance Signals
Buffer state the controlplane's policy handlers read from conversation_state.
All are additive — call as the agent works.
record_data_access
ctx.record_data_access(source="postgres", operation="read", records=42)
| Parameter | Type | Default | Description |
|---|---|---|---|
source | str | (required) | Data source ("postgres", "s3", "redis", ...) |
operation | str | "read" | "read" or "write" |
records | int | 0 | Number of records accessed |
record_network_request
ctx.record_network_request(url="https://api.stripe.com/v1/charges")
| Parameter | Type | Default | Description |
|---|---|---|---|
url | str | (required) | URL or domain accessed |
record_scope_impact
Running totals of the run's blast radius. Each call increments.
ctx.record_scope_impact(records_modified=10, transaction_total=499.99)
| Parameter | Type | Default | Description |
|---|---|---|---|
records_modified | int | 0 | Records updated |
records_deleted | int | 0 | Records deleted |
files_changed | int | 0 | Files written |
transaction_total | float | 0.0 | Dollar value of transactions |
api_writes | int | 0 | External API write operations |
Incremental Flush
For long-running contexts (REPLs, chat sessions, batch loops) where you want buffered data visible in the UI — and governance evaluated — before the context exits.
# async
await ctx.flush()
# sync
ctx.flush_sync()
Each call sends only data buffered since the last flush. Safe to call any
number of times. Both raise PolicyViolationError if a governance-checked
send (LLM calls, steps) trips a block policy mid-run.
Properties
| Property | Type | Description |
|---|---|---|
run_id | str | The run ID from the control plane, or "" if the run has not started |
Error Handling
If an exception occurs inside the context, the run is automatically completed with status="error" and the error message. The exception is not suppressed -- it propagates normally:
# Async
try:
async with WaxellContext(agent_name="my-agent") as ctx:
raise ValueError("Something went wrong")
except ValueError:
pass # Run was completed with status="error"
# Sync
try:
with WaxellContext(agent_name="my-agent") as ctx:
raise ValueError("Something went wrong")
except ValueError:
pass # Run was completed with status="error"
If flushing telemetry to the control plane fails (e.g., network error), the failure is logged as a warning but does not interfere with your agent's execution.
Policy Enforcement on Entry
When enforce_policy=True, policies are checked before the run starts. If the policy result is block or throttle, a PolicyViolationError is raised and no run is created:
# Canonical import — also exported from waxell_observe.errors
from waxell_observe import PolicyViolationError
# Works identically with both async and sync context managers
try:
with WaxellContext(
agent_name="my-agent",
enforce_policy=True,
) as ctx:
...
except PolicyViolationError as e:
print(f"Blocked: {e}")
print(f"Action: {e.policy_result.action}")
When to Use Context Manager vs Decorator
Choose WaxellContext over @observe when you need:
- Multi-step orchestration -- wrap complex logic that spans multiple functions
- Mid-execution policy checks -- re-validate policies between steps
- Explicit input/metadata control -- pass structured inputs and metadata at context creation
- Multiple runs in one function -- start and complete several runs in sequence
- Conditional observability -- only create a context under certain conditions
- Synchronous code -- batch scripts, CLI tools, and ETL pipelines that don't use async
Example of multiple runs (sync -- natural fit for batch processing):
def batch_process(items: list[str]):
for item in items:
with WaxellContext(
agent_name="batch-processor",
inputs={"item": item},
) as ctx:
result = process_item(item)
ctx.record_llm_call(model="gpt-4o-mini", tokens_in=50, tokens_out=30)
ctx.set_result({"output": result})
The same pattern works with async with for async code:
async def batch_process(items: list[str]):
for item in items:
async with WaxellContext(
agent_name="batch-processor",
inputs={"item": item},
) as ctx:
result = await process_item(item)
ctx.record_llm_call(model="gpt-4o-mini", tokens_in=50, tokens_out=30)
ctx.set_result({"output": result})
Full Example (Async)
from waxell_observe import WaxellObserveClient, WaxellContext
WaxellObserveClient.configure(
api_url="https://acme.waxell.dev",
api_key="wax_sk_...",
)
async def run_pipeline(query: str) -> dict:
async with WaxellContext(
agent_name="research-pipeline",
workflow_name="deep-research",
inputs={"query": query},
metadata={"version": "2.1"},
enforce_policy=True,
) as ctx:
# Step 1: Search
sources = await search(query)
ctx.record_step("search", output={"source_count": len(sources)})
# Step 2: Synthesize
synthesis = await synthesize(query, sources)
ctx.record_llm_call(
model="claude-sonnet-4",
tokens_in=2000,
tokens_out=500,
task="synthesize",
)
ctx.record_step("synthesize", output={"length": len(synthesis)})
# Mid-execution policy check
policy = await ctx.check_policy()
if policy.blocked:
ctx.set_result({"error": "Policy blocked continuation"})
return {"error": policy.reason}
# Step 3: Refine
final = await refine(synthesis)
ctx.record_llm_call(
model="gpt-4o",
tokens_in=800,
tokens_out=300,
task="refine",
)
ctx.record_step("refine")
result = {"answer": final, "sources": len(sources)}
ctx.set_result(result)
return result
Full Example (Sync)
from waxell_observe import WaxellObserveClient, WaxellContext
WaxellObserveClient.configure(
api_url="https://acme.waxell.dev",
api_key="wax_sk_...",
)
def process_tickets(tickets: list[dict]) -> list[dict]:
results = []
for ticket in tickets:
with WaxellContext(
agent_name="ticket-processor",
workflow_name="support-pipeline",
inputs={"ticket_id": ticket["id"], "subject": ticket["subject"]},
enforce_policy=True,
) as ctx:
ctx.set_tag("priority", ticket["priority"])
# Step 1: Classify
category = classify_ticket(ticket)
ctx.record_llm_call(model="gpt-4o-mini", tokens_in=200, tokens_out=10, task="classify")
ctx.record_step("classify", output={"category": category})
# Step 2: Generate response
response = generate_response(ticket, category)
ctx.record_llm_call(model="gpt-4o", tokens_in=500, tokens_out=200, task="respond")
ctx.record_step("respond", output={"length": len(response)})
# Mid-execution policy check (sync variant)
policy = ctx.check_policy_sync()
if policy.blocked:
ctx.set_result({"error": policy.reason})
results.append({"ticket_id": ticket["id"], "error": policy.reason})
continue
ctx.record_score("response_quality", 0.9)
result = {"ticket_id": ticket["id"], "category": category, "response": response}
ctx.set_result(result)
results.append(result)
return results
Conversation State
WaxellContext automatically tracks conversation metrics from LLM calls:
ctx.conversation_turns— number of user turns in the conversationctx.context_utilization— context window usage as a percentage (0-100%)ctx.message_count— total messages in the LLM context
These properties are read-only and updated automatically when auto-instrumentation records LLM calls.
Manual Recording
For agents not using auto-instrumented LLM providers, drive the counters
explicitly with ctx.record_user_message(...) / ctx.record_agent_response(...)
(see Conversation & Human Interaction above).
These methods create IO spans that appear in the trace timeline alongside LLM
calls and tool invocations. See Conversation Tracking
for full details.
Module-level Helpers
Every method above has a module-level shorthand that operates on the currently
active WaxellContext (resolved via ContextVar) — no-op outside a run. Use
these from deeply-nested tool bodies, callbacks, or third-party adapters that
the decorator can't pass ctx to.
import waxell_observe as waxell
# Access the active context (returns None outside a run)
ctx = waxell.get_current_context() # or: waxell.get_context()
# Recording — no ctx argument needed
waxell.score("relevance", 0.92)
waxell.tag("environment", "prod")
waxell.metadata("retrieval_count", 5)
waxell.step("classify", output={"category": "billing"})
waxell.decide("route", chosen="research", options=["direct", "research"])
waxell.reason("evaluate", thought="Source B has higher authority")
waxell.retrieve(query=q, documents=docs, source="pinecone")
waxell.retry(attempt=2, reason="Rate limited", strategy="fallback")
# Memory + prompt lineage
waxell.remember_episodic("deal_findings", data={"company": "Acme"})
waxell.remember_semantic("preferences", "Acme prefers monthly invoicing.")
waxell.recall(query=q, tier="episodic", results_count=3, top_score=0.84)
waxell.prompt_use("customer_intake", version=7, label="prod")
# Conversation + HITL
waxell.user_message("What's the weather?")
waxell.agent_response("Sunny in Paris.")
waxell.communication(channel="slack", recipient="#ops", body="deploy ok")
answer = waxell.input("Approve? (y/n): ") # drop-in for input()
with waxell.human_turn(prompt="Review PR", channel="github") as turn:
turn.set_response(wait_for_webhook())
with waxell.pause(reason="awaiting_approval"):
decision = wait_for_human()
# Approval lifecycle
waxell.approval_request(action_type="delete", approvers=["sec@acme.com"])
waxell.approval_response(action_type="delete", decision="approved")
# Incremental flush
await waxell.flush() # async
waxell.flush_sync() # sync
Span Cap (long-running agents)
A long-running or autonomous agent can emit unboundedly many spans. To prevent
the in-process buffer from ballooning, each run is capped at 50 000 spans
(root + first 49 999 — the tail is dropped, a WARNING is logged, and the
flushed run carries a _span_cap_hit summary so truncation is visible).
Tune via env var:
export WAXELL_MAX_SPANS_PER_RUN=100000 # raise the cap
export WAXELL_MAX_SPANS_PER_RUN=0 # disable the cap entirely
Typical batch loops won't hit this. If you do, prefer wrapping each iteration
in its own WaxellContext so each batch item is a discrete run with its own
budget — see the batch examples
above.
Next Steps
- Decorator Pattern -- Simpler alternative for single-function agents
- LLM Call Tracking -- Details on captured LLM data
- Conversation Tracking -- Auto-captured conversation data
- Policy & Governance -- Policy actions and enforcement
- Sessions -- Group related runs
- User Tracking -- Track end-user identity
- Scoring -- Quality metrics