Context Manager: Full Control, Including Degrades
The context manager is the mode for agents whose lifecycle doesn't fit one function — multi-phase orchestration, loops, conditional runs. It also gives you the most complete narrative, because every primitive is right there on ctx. This walkthrough narrates a real ops agent end to end, including the part most tutorials skip: what to record when things go wrong.
The agent
An SRE agent that reads the observability pipeline's health, classifies each leg, and explains the likeliest cause when something is down.
import waxell_observe as waxell
waxell.init(agent_name="otlp-debugger")
def run(client, llm):
with waxell.context(agent_name="otlp-debugger") as ctx:
# --- phase 1: read -------------------------------------------------
health = client.get("internal/observability/health/")
if health is None:
ctx.record_tool_call(
name="internal/observability/health",
status="error",
error=client.last_error, # "HTTP 403", "no admin token", …
tool_type="api",
)
ctx.set_result({"summary": f"could not read health ({client.last_error})",
"degraded": [f"read:{client.last_error}"]})
return
ctx.record_step("read health", output={"legs": list(health)})
# --- phase 2: classify --------------------------------------------
down = [k for k, v in health.items() if v.get("status") != "healthy"]
ctx.record_reasoning(
step="classify",
thought=f"{len(health)} legs read; {len(down)} not healthy",
evidence=[f"{k}={v.get('status')}" for k, v in health.items()],
conclusion="degraded" if down else "healthy",
)
ctx.record_decision(
name="pipeline_state",
options=["healthy", "degraded"],
chosen="degraded" if down else "healthy",
reasoning=f"down legs: {down or 'none'}",
)
# --- phase 3: explain (only if needed) -----------------------------
cause = ""
if down:
cause, real = llm(f"Legs down: {down}. Likeliest cause + first fix step?")
if not real:
ctx.record_tool_call(name="llm.kimi-k3", status="error",
error="provider unavailable (no key or degraded)")
ctx.record_step("explain", output={"cause": cause})
# --- conclusion ----------------------------------------------------
summary = (f"OTLP pipeline DEGRADED — down: {', '.join(down)}"
if down else "OTLP pipeline healthy (collector/tempo/loki)")
ctx.set_result({"summary": summary, "down": down, "cause": cause})
ctx.record_agent_response(summary)
What each call buys the reader
| Call | What the run shows |
|---|---|
record_step("read health", …) | A named phase on the Timeline, with what it produced |
record_reasoning(...) | The agent's thinking — thought, evidence, conclusion — so a reviewer can check the logic, not just the answer |
record_decision(...) | A discrete choice with options + rationale; shows up as a decision point |
record_tool_call(status="error") | A red span for the failed read or unavailable model, with the reason |
set_result({...}) | The structured conclusion attached to the run — what dashboards and delivery read |
record_agent_response(summary) | The human-facing sentence |
The two failure paths, made visible
This is the part that separates a trace you can debug from one you can't.
A failed read. Without the record_tool_call, a None from client.get() followed by return leaves a run with a single span and no explanation — a reader would have to guess it never got its data. With it, the trace shows a red internal/observability/health span carrying HTTP 403, and the result says so in words.
An unavailable model. If the provider key is missing, llm() may fall back to a stub and the run completes "successfully" with zero tokens — indistinguishable from an agent that simply didn't need the model. Recording llm.<provider> as an error span makes the thin trace self-explanatory.
Every early return should leave a span behind. If the run stopped short, the trace should say why without anyone reading the code.
Phases: give the run a shape
A run that reads as gather → analyze → propose is one a person — or the AI narrator — can follow. A flat list of forty LLM calls is not. waxell.phase() brackets a unit of work as a named container span: everything recorded inside it (LLM calls, tool calls, steps, decisions) nests underneath in the trace tree, and the phase closes with its real duration and status.
import waxell_observe as waxell
with waxell.context(agent_name="ops.sre.otlp-debugger") as ctx:
with waxell.phase("gather", legs=3):
health = read_health() # tool + LLM spans nest under "phase:gather"
with waxell.phase("analyze"):
verdict = judge(health)
with waxell.phase("propose"):
ctx.record_decision(name="propose:raise-threshold", options=["raise", "hold"], chosen="raise", reasoning="…")
ctx.set_result({"summary": verdict})
| What you get | Where it shows |
|---|---|
phase:gather / phase:analyze / phase:propose containers with children | Trace tab, collapsible per phase |
| Real per-phase duration | Trace bars — "analyze took 80% of the run" is one glance |
| A phase that raised is red, with the exception attached | The failing phase is the headline, not the innermost span |
waxell.phase=<name> plus any keyword attributes you pass | Span attributes; the narrator uses them to say which phase went long or blew up |
Outside a context (init()-only code) phase() is a no-op — the body still runs, nothing is recorded — so it is safe to leave in library code that is sometimes called un-observed.
Pick three to five names and reuse them across agents (gather, analyze, propose, deliver). The narrator's baseline comparison and the fleet's desk rollups get sharper when every agent's runs have the same shape.
Reading it in the UI
Open the run. The header carries the result summary; the Timeline tab lists the three phases; the Behavior tab shows the decision; a red span marks any degrade. The AI narrator will produce something like:
Read observability health (collector, tempo, loki — all healthy), classified the pipeline as healthy, and reported "OTLP pipeline healthy." No model call was needed.
That sentence is only possible because the agent told its own story.