Skip to main content

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​

CallWhat 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.

The rule

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 getWhere it shows
phase:gather / phase:analyze / phase:propose containers with childrenTrace tab, collapsible per phase
Real per-phase durationTrace bars — "analyze took 80% of the run" is one glance
A phase that raised is red, with the exception attachedThe failing phase is the headline, not the innermost span
waxell.phase=<name> plus any keyword attributes you passSpan 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.

Phase names are a vocabulary

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.