Skip to main content
Use agent as a context manager when you want to add tracing to code that already exists without restructuring it. Drop a with block around the body you want to trace — no function extraction required.
This pattern is Python-specific. For TypeScript, use the callable wrapper pattern in Async agent or Sync agent.

Async agent

from uselemma_tracing import register_otel, agent

register_otel()


async def handle_request(user_message: str) -> str:
    async with agent("my-agent", input=user_message) as run:
        response = await call_llm(user_message)
        run.complete(response)  # sets ai.agent.output; span closes on block exit
    return response

Sync agent

from uselemma_tracing import register_otel, agent

register_otel()


def handle_request(user_message: str) -> str:
    with agent("my-agent", input=user_message) as run:
        response = call_llm(user_message)
        run.complete(response)
    return response

Accessing run_id after the block

The as run variable stays in scope after the block exits:
async with agent("my-agent", input=user_message) as run:
    response = await call_llm(user_message)
    run.complete(response)

# Both available here:
print(run.run_id)
print(run.span)

Tool-calling loop

For multi-turn loops, put the entire loop inside the block. The span stays open for all turns and closes when the block exits.
import json
from opentelemetry import trace
from uselemma_tracing import register_otel, agent

register_otel()

tracer = trace.get_tracer("my-agent")


async def handle_request(user_message: str) -> str:
    async with agent("my-agent", input=user_message) as run:
        history = [{"role": "user", "content": user_message}]

        while True:
            response = await call_model(history)

            if response.stop_reason == "end_turn":
                run.complete(response.text)
                return response.text

            for tool_call in response.tool_calls:
                with tracer.start_as_current_span("tool.call") as span:
                    span.set_attribute("tool.name", tool_call.name)
                    span.set_attribute("tool.args", json.dumps(tool_call.args))
                    result = await run_tool(tool_call.name, tool_call.args)
                    span.set_attribute("tool.result", str(result))
                history.append({"role": "tool", "content": result})

Error handling

Unhandled exceptions are recorded on the span automatically — no try/except needed for the common case:
async with agent("my-agent", input=user_message) as run:
    response = await call_llm(user_message)  # if this raises, error is recorded automatically
    run.complete(response)
To record an error without re-raising, call run.fail() manually:
async with agent("my-agent", input=user_message) as run:
    try:
        response = await call_llm(user_message)
        run.complete(response)
    except SomeRecoverableError as err:
        run.fail(err)
        run.complete("fallback response")

What happens if complete() is not called

The span ends cleanly when the block exits, but ai.agent.output is not set. The run appears in the dashboard without an output value. Call run.complete(result) before the block exits to record the output.

Callable wrapper vs context manager

Callable wrapperContext manager
Syntaxagent("name", fn)agent("name", input=...)
Requires extracting a functionYesNo
Output auto-captured from return valueYesNo — call run.complete()
Reusable wrapped callableYesNo — inline only
Async and sync supportYesYes
run_id accessFrom return tuple (result, run_id, span)From run.run_id after block