Skip to main content

obz-neo-sdk

Make an externally hosted agent appear inside Neo GenAI Studio as a first-class Remote Agent — with live Traces and Conversations — without restructuring the service.

Three things, and nothing else: registration, tracing, conversation capture.

The agent runs wherever it runs. Nothing in Studio connects to it: every wire is outbound. A few HTTPS calls go out at first use to register, and after that the only traffic is OTLP to the collector.

uv sync --extra studiotelemetry     # see "Installing" below -- uv, not pip
export NEO_HOST=https://botbuilder.your-company.com
export NEO_M2M_API_KEY=<m2m-key>
export STUDIOTELEMETRY_COLLECTOR_ENDPOINT=http://localhost:4318
from neo_sdk import StudioSDK

studio = StudioSDK(name="it-helpdesk-agent")

class HelpdeskAgent:
    async def handle(self, query: str, conversation_id: str) -> str:
        with studio.turn(session=conversation_id, input=query) as turn:
            answer = await my_graph.ainvoke(query)
            turn.output(answer)
            return answer

StudioSDK.init(...) is the same thing without keeping a handle, for code that uses the ambient API — one call at startup, and the exporter is up before the first request rather than during it:

from neo_sdk import StudioSDK, trace_wrapper

@trace_wrapper(type="tool")          # decorated at import, before init() runs
def search_kb(query: str) -> list[str]:
    ...

StudioSDK.init(name="it-helpdesk-agent")     # or json_file_path="deploy/agent.json"

init returns the instance too, and StudioSDK.active() gives it back later. The constructor stays lazy on purpose — it initialises OTEL on the first turn, so importing a module costs nothing; init (like warmup=True) moves that to startup, where it belongs.

Bringing the exporter up early needs the collector endpoint to be known when init runs — from STUDIOTELEMETRY_COLLECTOR_ENDPOINT or collector_endpoint=. When CONNECT is the thing that issues it, there is nothing to initialise yet and the first turn still does it; init deliberately does not try, because a failed initialisation is latched and would leave the process exporting nothing at all. Either way the instrumentation is the same: the vendored distro when it is installed — which is what auto-instruments Bedrock, Anthropic, LangChain and the rest — and plain OpenTelemetry with an OTLP exporter when it is not.

Your endpoint stays yours — your framework, your schema, your auth:

@app.post("/run")                      # FastAPI, Flask, Django, Lambda — no opinion
async def run(req: MyRequestModel):
    conv_id = req.conversation_id or str(uuid4())
    answer = await agent.handle(req.query, conv_id)
    return {"answer": answer, "conversation_id": conv_id}   # ← echo it back

That is the whole integration: one client, one with block.


The two signals

One handler call produces two independently routed telemetry streams. Both leave the process as OTLP to the collector; the collector decides where each belongs. Understanding the split explains nearly every field issue.

flowchart LR
  RUN["with studio.turn(…)<br/>one turn scope"] --> W["workflow + LLM + tool spans"]
  RUN --> C["conversation.ingest<br/><i>new root span</i>"]
  W --> COL["StudioTelemetry<br/>collector"]
  C --> COL
  COL -->|"no studio.span.category"| LF["Langfuse"] --> T["Studio → Traces"]
  COL -->|"studio.span.category<br/>= conversation"| AS["agents_service ingest"] --> CV["Studio → Conversations"]

The SDK never POSTs a conversation to agents_service. It speaks OTLP and tags the span; the collector routes it — to ingest only, filtered out of the Langfuse pipeline.

The two signals also fail independently, which is why a green Traces tab tells you nothing about Conversations.


The whole API

studio = StudioSDK(name=..., description=..., url=...)   # declare; no network I/O
StudioSDK.init(name=...)                              # declare + start tracing now
StudioSDK.from_json("deploy/agent.json")               # declare from a card on disk

with studio.turn(session=..., input=...) as turn:     # the turn — both signals
    turn.output(answer)                               # required

with studio.trace("rerank", type="task"): ...         # any span, any type
with studio.tool("search_kb"): ...                    # shorthand for type="tool"
with studio.step("rerank"): ...                       # shorthand for type="task"

studio.complete(session_id)                           # end of conversation → KPIs
studio.status()                                       # what was actually recorded

Both with and async with work. Plain with is correct in async code, because nothing on enter or exit blocks.

complete() cannot be a scope: a conversation ends later, often in a different request, sometimes never — there is no block whose body is the conversation.

turn.output is optional when a model call is instrumented

A turn needs three things: a session, the user's text and the reply. Only the session has to come from you — the SDK never sees your request, so it cannot know which conversation a call belongs to. The other two are already on the model call's span, written there by the auto-instrumentations, so the SDK reads them:

with studio.turn(session=conversation_id):     # no input=, no turn.output(...)
    answer = await my_graph.ainvoke(query)

gen_ai.prompt.* becomes the user's text and gen_ai.completion.* the reply. Across an agent loop the first question and the last non-empty completion win — a loop feeds tool results back as further user messages, and a completion with no content is a tool call, not an answer.

The bundled instrumentations do not agree on one spelling, so all of the shapes they emit are read:

Shape Written by
prompt.{i}.role + .content the chat APIs
prompt.{i}.user — the suffix is the role Bedrock, Anthropic, Vertex AI, Groq, Haystack, OpenAI completions
completion.role + .content, unindexed LlamaIndex predict
completion.{i} — the text directly studiotelemetry-sdk's report_response

A prompt with no roles at all falls back to its last message. A prompt whose roles are stated but include no user message yields nothing rather than guessing: a system prompt is not a question, and it would put your own instructions in the Conversations tab.

Gaps only. turn(input=...) and turn.output(...) are statements of what you meant and always win; this is an inference, and an inference never overwrites a statement. An explicit turn.output("") stays empty on purpose.

Only the values are read, never the keys: a conversation span carrying gen_ai.* matches Langfuse's routing rules and would never reach the Conversations tab.

Turn it off with StudioSDK(harvest_conversation=False) or NEO_HARVEST_CONVERSATION=0 — worth knowing if prompt text becoming conversation content is not something you want. With it off, and no turn.output(...), you get the one-time warning about a question with no answer instead.

Instrumentation

Automatic, from the vendored distro — active for whichever library is installed, and needing no code:

Model providers Anthropic, Bedrock, Cohere, Google GenAI, Groq, Ollama, OpenAI, SageMaker, Together, Transformers, Vertex AI
Agent frameworks CrewAI, LangChain / LangGraph, LlamaIndex, Haystack, MCP
Vector stores Chroma, Pinecone, Qdrant, Weaviate

Everything lands on ocbc.span.kind, the same workflow | task | agent | tool vocabulary the SDK's own trace(type=...) uses — so an auto-instrumented agent span and a hand-written one are the same kind of thing.

Agent spans specifically. CrewAI and LlamaIndex emit agent themselves; LangChain and LangGraph now do too, for an AgentExecutor or a LangGraph node named agent (the name create_react_agent gives it). The graph itself stays workflow, and a node named anything else stays task rather than being guessed at.

Strands traces itself — strands.telemetry.Tracer already opens a span per agent run, cycle, model call and tool call, into whichever provider is global, which after StudioSDK is ours. So the SDK tags those spans with the kind rather than instrumenting Strands a second time, which would have doubled every span. Nothing to install or enable.

By hand, for a framework with no coverage — agent_trace joins llm_trace, memory_trace and tool_trace:

from neo_sdk import agent_trace

with agent_trace("plan", agent="planner", model="claude-opus-5",
                 tools=[search, summarise], max_iterations=8) as run:
    answer = my_own_loop(query)
    run.finished(iterations=3, stop_reason="answered")

stop_reason is the difference between an agent that answered and one that hit its iteration cap — identical on the span until it is recorded. Also usable as a decorator, like the other typed spans.

The low level: turn, trace, trace_wrapper

Only turn means something. It owns the conversation envelope, the session and the nesting rules, and it is the one construct whose contract is easy to get invisibly wrong. Everything else an agent does inside it is a span of some type, and that is one construct offered twice — as a scope and as a decorator:

One construct, three shapes, each in a bound form on the StudioSDK and an ambient form at module level for code that holds no handle:

Shape Bound Ambient
block with studio.trace("rerank", type="task") with trace(...)
decorator @studio.trace_wrapper(type="tool") @trace_wrapper(...)
call studio.trace_call(fn, "search_kb", type="tool") trace_call(...)
from neo_sdk import trace, trace_call, trace_wrapper

@trace_wrapper(type="tool")                 # sync, async, generator, async gen
def search_kb(query: str) -> list[str]:
    ...

with trace("compose", type="llm", input=prompt) as scope:
    scope.output(answer)

best = trace_call(lambda: rank(hits), "pick_best", type="task")
hits = trace_call(search_kb, "search_kb", type="tool", args=(query,))

The ambient forms are applied or written at import, before StudioSDK(...) has been constructed, so they look up their runtime when they run — and stay transparent until one exists, warning once rather than vanishing. Pass runtime= (or use the bound form) when a process holds more than one StudioSDK.

trace_call is trace_wrapper without the @: a coroutine function gives you an awaitable, a generator function a generator, and the span closes when that finishes. Its name is positional and required — the wrapper can borrow a name from a def, but a lambda has none, and <lambda> is not a span anyone can find.

If your module also does from opentelemetry import trace, import ours as from neo_sdk import trace as studio_trace. Only the ambient name collides; studio.trace(...) never does.

type is any string. A value in the ocbc.span.kind vocabulary — workflow, task, agent, tool, unknown — travels on that attribute; anything else is recorded as task there and kept verbatim on studio.trace.type, because an unrecognised kind renders as nothing downstream, and a span you cannot see is the failure mode this SDK exists to avoid.

capture_io=True records arguments and return value on the span, bounded. It is off by default: arguments are user data, so recording them is a decision.

The high level: typed spans

The layer above. Each name is one kind of work an agent does, and exists to carry the attributes that kind of work has — a model and a token count, a store and a hit. Without those it would be an alias for trace(type=…) and would not earn a name.

from neo_sdk import conversation, llm_trace, memory_trace, tool_trace

@conversation(session="session_id", input="query")     # the turn, as a decorator
def handle(query: str, session_id: str) -> str:
    with memory_trace("recall", store="redis", scope="user") as mem:
        mem.hit(True, entries=2)

    with tool_trace("search_kb", call_id="call_01"):
        hits = index.search(query)

    with llm_trace("compose", model="claude-opus-5", provider="anthropic") as llm:
        answer = model.complete(prompt)
        llm.usage(input_tokens=412, output_tokens=58)
        llm.response(answer, finish_reason="stop")

    return answer                                       # becomes turn.output(...)
Name Records
llm_trace gen_ai.* — model, provider, temperature, token usage, finish reason
memory_trace operation, store, scope, key, hit, entry count
tool_trace tool name, call id, description
conversation the turn: session and input from named arguments, reply from the return

Four names, deliberately. A type earns one by bringing a vocabulary, not by renaming a type string — anything else is still trace(type="retrieval"), which routes correctly and carries the type verbatim.

llm_trace, memory_trace and tool_trace are each both a block and a decorator — one name, both shapes:

@llm_trace(model="claude-opus-5")
def compose(prompt): ...

conversation is the exception: decorator-only. Its session= and input= name parameters of the decorated function, and a block form where they would carry values already exists — it is called studio.turn(...). One name meaning both would be a trap.

Everything the low level guarantees is inherited rather than reimplemented: these subclass Trace, so fail-open, re-raise, nesting, identity stamping and provenance cannot drift per type. Anything without a name here is still trace(type="whatever").

gen_ai.* is a Langfuse routing prefix — correct on a trace span, forbidden on a conversation or health span. llm_trace puts them where they belong; a test asserts the conversation envelope stays clean when one runs inside a turn.


What the SDK absorbs

The SDK handles So you never touch
Provenance on every span it emits studio.source, telemetry.distro.*, scope versions
Workspace login from the M2M key workspace_id, project_id
Agent registration / reuse the Studio custom-agent UUID
Instance registration instance_id
OTEL init TracerProvider, exporters, processors
Turn scoping span creation, nesting, propagation
Conversation capture conversation.ingest, turn events, attribute names
OTLP export, batching, retry collector endpoints, auth headers

The four guarantees

The SDK runs inside a request path you are on call for.

Guarantee What it means
Fail-open Every construct catches its own exceptions. A telemetry bug never surfaces as an application error.
Never block No synchronous network I/O on entering or exiting a turn. Registration resolves in the background.
Non-swallowing __exit__ marks the turn failed and re-raises. A with block is not a try/except.
No double-emit A nested turn (a sub-agent) contributes spans but not a second conversation turn.

Missing configuration costs you observability, never availability. Every rung of the ladder still answers your endpoint:

flowchart TB
  A["✅ credentials + collector + turn opened<br/><b>everything works</b>"] --> B
  B["🚨 studio.turn() never opened<br/>chat works · agent registers · <b>zero telemetry, no error</b>"] --> C
  C["⚠️ no collector<br/>chat works · no Traces, no Conversations"] --> D
  D["⚠️ no NEO_HOST / M2M<br/>chat works · no registration<br/>telemetry cannot map into Studio"]

What you own — and it all fails silently

Three values reach the SDK, and all three are arguments the agent passes. There is no signature introspection and no return-value inference — the SDK never sees your request, so it cannot infer any of them:

with studio.turn(session=..., input=...) as turn:   # who, and what they said
    turn.output(answer)                             # what you replied
You own If you skip it
Opening studio.turn() at all Agent registers, chats, shows nothing. No error anywhere.
Passing input= Conversations show an answer with no question.
Calling turn.output(reply) Conversations show a question with no answer.
Echoing the session id back so the next turn can continue it Every turn becomes its own one-turn conversation.
Calling complete(session_id) Conversations appear, KPIs stay empty.

None of these raises. That is inherent to a model where the SDK does not own the request path, so it compensates with feedback: a one-time warning at each failure point, and status() for what was actually recorded.

>>> studio.status()
{'agent_name': 'it-helpdesk-agent',
 'agent_id': '3f2a…', 'workspace_id': 'ws_01H8…', 'instance_id': 'inst_7f3c9a',
 'registered': True, 'registration_error': None,
 'collector': 'http://localhost:4318',
 'turns_observed': 12, 'sessions_seen': 4, 'sessions_completed': 3,
 'conversations_emitted': 12, 'conversations_buffered': 0, 'conversations_dropped': 0}

Registration

Two phases, deliberately separate — a pod restart is not a new agent.

flowchart TB
  subgraph P1 ["REGISTER — “what agent am I?”"]
    A1["agent_name (+ description, url)"] --> A2["Studio finds or creates the row"] --> A3["agent_id (UUID)"]
  end
  subgraph P2 ["CONNECT — “which running copy am I?”"]
    B1["instance_id + version + runtime"] --> B2["Studio records the instance"] --> B3["OTLP config returned"]
  end
  P1 --> P2

REGISTER is idempotent per name, so restarts and rollouts reuse the same UUID. CONNECT runs once per process, giving Studio a live fleet view — and its response may carry OTLP endpoint, token and sampling, which makes rotating any of those a platform-side operation instead of a customer redeploy. Environment variables still win, for air-gapped installs.

Resolution starts when StudioSDK(...) is constructed, on a background thread — the constructor never waits for it. An agent therefore appears in Studio because it was declared, not because it was called, so a process that has served no traffic yet is still visible. Conversations recorded before the UUID lands are buffered with their original timestamps, then emitted — the first conversation of a cold process is not lost.

NEO_AGENT_ID pins an existing row and skips the create, for immutable deploys.

The agent card

agent.json is parsed into an A2A AgentCard model and sent whole on the create, as agent_card — so one file serves an A2A peer and Studio:

{
  "protocolVersion": "0.3.0",
  "name": "it-helpdesk-agent",
  "description": "Resolves IT tickets: VPN, access, hardware",
  "url": "https://helpdesk.example.com/a2a",
  "version": "2.4.0",
  "preferredTransport": "JSONRPC",
  "provider": {"organization": "OCBC", "url": "https://ocbc.example.com"},
  "capabilities": {"streaming": true, "pushNotifications": false},
  "defaultInputModes": ["text/plain"],
  "defaultOutputModes": ["text/plain"],
  "skills": [{"id": "vpn", "name": "VPN triage", "description": "...", "tags": ["network"]}]
}

A2A is camelCase on the wire; Python reads it as snake_case (card.protocol_version, card.default_input_modes). Either spelling parses.

The create body is exactly the four fields the service accepts — name, description, url and agent_card. Everything the card describes travels inside it, because CustomAgentCreateModel declares only those four and ignores the rest: a flattened version, or a camelCase agentCard, is accepted with a 200 and silently discarded.

NEO_AGENT_CARD_PATH locates it — the file, or the directory holding it — falling back to agent.json in the working directory. An explicit path matters in a container, where the card is mounted somewhere the process was not launched from.

StudioSDK.from_json(...) names the card in code instead, for when the path is known to the program rather than to the environment:

from neo_sdk import StudioSDK

studio = StudioSDK.from_json("deploy/agent.json", warmup=True)

The card supplies name, url, description, display name, version and environment; keyword arguments still win over it, and the environment still fills what neither supplies. The argument may be the file or the directory holding it. Unlike discovery, a missing or unparseable card here raises ConfigurationError — the path was named explicitly, so falling back to a card found elsewhere would hide the typo.

Keys the SDK does not know are preserved, not dropped: the card is written by the agent's author and extended by the platform. A missing card is normal (StudioSDK(name=...) is enough) and a malformed one warns rather than raising — startup does not depend on a description.


Serverless

studio = StudioSDK(flush="on_turn_exit")     # or NEO_FLUSH=on_turn_exit

Lambda freezes the environment the instant the handler returns, so a batched exporter never runs again and spans are silently discarded. This trades the never-block guarantee for telemetry existing at all — correct there, wrong on a server, so it is never a default. The SDK warns once if it detects a serverless environment without it.


Environment reference

Variable Role Required
NEO_HOST Builder URL — auth + workspace from the M2M key for registration
NEO_M2M_API_KEY M2M key; its workspace becomes the workspace for registration
STUDIOTELEMETRY_COLLECTOR_ENDPOINT OTLP/HTTP collector for telemetry
STUDIOTELEMETRY_COLLECTOR_AUTH_TOKEN Collector auth, scheme included conditional
NEO_AGENTS_HOST agents_service base URL optional
NEO_AGENT_ID Pin an existing UUID; skips the create optional
NEO_AGENT_NAME / NEO_AGENT_URL Declaration fallbacks optional
NEO_FLUSH on_turn_exit for serverless optional
NEO_TELEMETRY_DISABLED Turn telemetry off entirely optional
NEO_BUFFER_LIMIT Conversation buffer cap (default 512) optional

There is deliberately no AGENT_CONTROL_CENTER_BASE_URL, username or password here. The agent emits a span; the collector — which already holds those credentials — is what calls the control center.

Logging

The SDK logs through logging.getLogger(__name__) in each module, so every logger sits under neo_sdk.registry.*, neo_sdk.observability.* or and a level set on either prefix covers it. By default it configures nothing — a library that rewrites its host's logging format on import has taken a decision that belongs to the host.

When the agent is the process, ask for it:

studio = StudioSDK(name="it-helpdesk-agent", log_level="INFO")

which builds the same LoggerManager you can build yourself:

from neo_sdk.common.logger import (
    ApplicationLoggerConfiguration, LoggerConfiguration, LoggerManager,
)

LoggerManager(LoggerConfiguration(
    application_logger=ApplicationLoggerConfiguration(
        log_level="INFO", service_name="it-helpdesk-agent",
    )
))

That sets a console format and level, and holds the noisy libraries at WARNING beneath it — botocore, httpx, langchain, litellm, langsmith and the rest. This matters more than it sounds: an agent on Bedrock through LangChain at DEBUG produces so much library output that the SDK's own once-only warnings — the only signal for several silent failures — scroll past unread. Set log_level="DEBUG" and the libraries come with you, since that is when you want them. | NEO_TELEMETRY_DISABLED | Skip OTEL entirely | optional |

You never set workspace_id, project_id, agent_id, instance_id, trace_id or span_id. agent.json in the working directory supplies name, description, url and version if you prefer a file.


Installing

The Studio OTEL distro is vendored under src/neo_sdk/studiotelemetry/, so a zip of the SDK includes it — 23 sub-projects: the SDK, its semantic conventions, and the auto-instrumentations for OpenAI, Anthropic, Bedrock, LangChain, LlamaIndex, CrewAI, MCP, the vector stores and the rest.

make install          # uv sync --extra dev --extra studiotelemetry

Use uv sync, not pip install

This matters more than it looks. [tool.uv.sources] is what pins each vendored package to its local path, and uv pip install ignores it — only uv sync applies it. Get that wrong and the packages resolve from PyPI under the same names and the same version numbers, with no error:

opentelemetry-semantic-conventions-ai==0.4.9     ← vendored fork: ocbc.*
opentelemetry-semantic-conventions-ai==0.4.9     ← public PyPI:  traceloop.*

Identical version, different span attribute prefix. Every attribute Studio attributes traces by would quietly change name. tests/test_backend.py asserts the runtime prefix matches, so a mis-resolved install fails a test instead of failing in production.

For the same reason uv.lock is committed, and every vendored package is listed explicitly in the studiotelemetry extra rather than left to resolve transitively — leaving them transitive let seven of them, including the semantic-conventions package, come from PyPI.

Without the distro

make install-nodistro

Tracing still works: neo_sdk.observability falls back to plain opentelemetry-sdk and produces byte-identical spans — same resource attributes, same ocbc.association.properties.* prefix, same OTLP path. What you lose is auto-instrumentation, so LLM calls no longer appear as child spans on their own. CI builds both ways.

First-turn cost

The distro loads ~30 auto-instrumentors when it initialises, which is about 250–300 ms of CPU — paid once, on whichever turn triggers it, and by default that is somebody's first request. Steady-state overhead after that is ~0.08 ms per turn.

studio = StudioSDK(name="it-helpdesk-agent", warmup=True)

warmup=True does that work in a background thread at construction, so a long-running server pays it at boot. It is off by default because init installs a global TracerProvider, and an app that configures its own should decide when that happens.

Python version

>=3.11,<3.13, matching the distro's own pin.


Layout

Flat, as in Neo SDK — four packages, one distribution:

studiotelemetry-tests/  that distro's pytest suites and VCR cassettes;
                        kept out of the shipped tree

src/neo_sdk/                    the one top-level package
  studiotelemetry/        vendored Studio OTEL distro — 23 sub-projects
                          (not a Python subpackage; not linted, not
                          type-checked, not in the wheel)
  studio.py               StudioSDK — the public surface an author touches
  module.py               NeoApplicationModule: composition, the identity
                          seam, and registration lifecycle
  configuration_parser.py NeoConfigurationParser — reads the env, once
  model.py                NeoConfiguration
  client.py               NeoSDK, at Neo SDK's import path
  constants.py            association property + span names
  exceptions.py           NeoError, AuthenticationError, HTTPClientError
  telemetry.py            Telemetry

  common/               what both halves need — plane-agnostic
    logger/               taken verbatim from Neo-SDK ocbc-sdk-poc
      base.py               BaseLogger, the ABC
      model.py              LoggerType + the two configuration models
      manager.py            LoggerManager
      application_logger/   console format, level, library-noise control
    diagnostics.py        DiagnosticLogger: report each key once, count the rest
    model.py              BaseModel: the pydantic settings, in one place
    exceptions.py         NeoError, the root of both hierarchies
    utils.py              Clock

  registry/             control plane — no OpenTelemetry import anywhere
    client.py             NeoSDK: login · ensure_agent · connect_instance
    configuration_parser.py  RegistryConfigurationParser
    agent_json.py         AgentJson — the agent declaration
    constants.py          env var names, row statuses
    utils.py              AgentPayload — tolerant payload reading
    exceptions.py         RegistryError and friends
    model.py              RegistryConfiguration · Agent · WorkspaceIdentity
                          TelemetryTransport · InstanceRegistration
                          AgentDeclaration
    _internal/            BuilderRest · AgentsRest · _HTTPClient · Logger

  observability/        data plane — no registry import anywhere
    manager.py            Observability: turns, spans, conversations
    configuration_parser.py  ObservabilityConfigurationParser
    telemetry.py          TelemetryBackend
    utils.py              AssociationProperties
    constants.py          span/attr names, the routing switch
    protocols.py          IdentityResolver — the seam
    exceptions.py         ObservabilityError and friends
    model.py              ObservabilityConfiguration · RuntimeIdentity
                          ResolvedIdentity · ConversationTurn
    runtime/conversation.py  ConversationEnvelope · ConversationEmitter
    runtime/turn.py       Turn
    _internal/logger.py   Logger · WarningKey

    manager.py            Health: reported state, register, heartbeat
    configuration_parser.py  HealthConfigurationParser
    constants.py          span/attr names; aliases the routing switch
    utils.py              scrub · clip · describe_exception
    exceptions.py         HealthError and friends
    model.py              HealthConfiguration · AgentState · AgentCard
                          HeartbeatStats
    runtime/envelope.py   HealthEnvelope · HealthEmitter
    runtime/heartbeat.py  Heartbeat — one daemon thread
    _internal/logger.py   Logger · WarningKey

registry/ and observability/ are independent of each other. A future plane is not independent of either: it is attributed by the control plane's identity and carried by the data plane's exporter, so it composes both rather than sitting beside them. That is why it imports from observability and the other two import from neither.

Every model lives in that package's model.py, separate from the code that uses it: parsers read the environment, managers hold behaviour, model.py holds shape. And there are no module-level functions anywhere in the shipped packages — every helper is a classmethod or staticmethod on the class it belongs to, so there is one obvious place to look for any given piece of behaviour.

Configuration objects and response models are pydantic, as in Neo SDK, with extra="allow" throughout: agents_service adds fields, and a model that rejected them would turn a harmless server-side addition into a client crash.

studio.py is deliberately thin. module.py holds the composition, the lifecycle and the IdentityResolver implementation — it is the only code in the repo that knows both halves exist, which is what the isolation test pins down.

One top-level package, so installing this never puts a bare common or registry into site-packages where it could shadow the host application's own module. The subpackages carry no redundant prefix.

neo_sdk.observability holds no import of neo_sdk.registry and none of the facade; it receives identity through a Protocol that src/neo_sdk/module.py implements. That is enforced by tests rather than promised in prose — including one that performs the extraction into a temp directory and imports the result with no neo package present at all. See EXTRACTING_OBSERVABILITY.md.

Both halves import neo_sdk.common, which is the deliberate cost of not keeping two copies of the logger and two unrelated exception roots. It travels with whichever half gets extracted, so it is held to standard-library-only and tested for it.

Registration is a control-plane decision; transport is a data-plane job. Merging them would let a malformed span mint a phantom agent row.


Troubleshooting

Symptom Cause
Agent registers, chat works, Studio shows nothing studio.turn() never opened. Check status().
Every conversation is one turn long A fresh session id per turn. The agent may mint it — that is supported — but it has to be reused across the turns of one conversation: return turn.session_id and accept it back. status() shows sessions_seen next to single_turn_sessions.
Traces ✅, Conversations ❌ Ingest returned 404 and the collector dropped the span. Three causes, in order of likelihood: (1) the agent_id is a deleted agent; (2) the session_id already belongs to a different agent — including the previous UUID of an agent that was deleted and re-registered, which any redeploy produces; (3) a workspace mismatch. Start a fresh session_id after re-registering.
Posting straight at ingest gives invalid OTLP JSON POST {agents_service}/api/v1/otel/v1/traces accepts OTLP JSON, while the SDK exports protobuf. That is correct and not a mismatch: the collector converts. Export to the collector, never to ingest.
Traces populate, Conversations have no replies turn.output() never called.
Agent missing from Remote Agents No name and no NEO_AGENT_ID; or NEO_HOST/M2M missing; or registration failed — check the one-time warning. Registration starts at StudioSDK(...) construction, so a process that never opened a turn should still appear — if it does not, the credentials were missing when the constructor ran.
Short-lived process registers nothing It exited before the background registration finished. Call studio.shutdown(), which waits briefly for it.
Long-running service: traces appear, some conversations missing The process was killed without studio.shutdown(). Conversations recorded before the agent id resolved sit in a bounded buffer and are emitted when it lands; a service that never shuts down cleanly never drains them. Wire it to your framework's shutdown hook — for FastAPI, a lifespan handler.
KPIs empty complete(session_id) never called.
Deleted the agent in Studio, then restarted New UUID. Old sessions are bound to the old row — start a fresh session_id.
Serverless: nothing exported flush="on_turn_exit" not set.
POST /v1/metrics 404 The collector is traces-only. Ignore it.

None of the top rows raises an error.


Development

make install      # uv sync, with the vendored distro
make check        # ruff + pytest

How it is tested

No Studio and no collector are needed. 112 tests in five layers, each answering a different question:

Layer File Answers
Wire test_wire.py What would the collector actually receive? Runs a real agent against a real OTLP/HTTP receiver, decodes the protobuf, and applies the collector's routing rule to it.
Behaviour test_turn.py Does one turn emit both signals, with the right shape? Uses an in-memory exporter, so assertions are on real span attributes rather than mocks of our own code.
Contract test_registry.py Which URL, which body, which field wins on reconcile? A fake transport; the point is the HTTP contract, not that requests works.
Failure test_resilience.py Does a broken backend, resolver or redactor still let the agent serve? Includes a timing check that nothing blocks the request path.
Structure test_isolation.py Can observability still be extracted? Performs the extraction into a temp directory and imports the result.

Plus test_backend.py for the two interchangeable OTEL backends and test_studio.py for registration at construction.

The fleet

make fleet runs twenty-five agents, each as its own process, against a fake Studio control plane and a real OTLP collector — eight correct shapes, five on real frameworks (LangGraph, CrewAI, Strands) against Bedrock, five making direct LLM calls, two awkward runtimes, two failure paths, and three that reproduce a silent failure on purpose. Each declares what it should produce, so the runner reports a verdict rather than an exit code.

make fleet-up instead brings the LLM agents up as HTTP services against a real GenAI Studio, registers them, and drives traffic so they appear under Remote Agents with live Traces and Conversations. See fleet/README.md.

Two things worth knowing if you extend the suite:

  • The Studio distro's TracerWrapper is a process-wide singleton bound to the first endpoint it sees. Two different collector endpoints cannot be tested in one process, which is why test_wire.py pins itself to the plain-OTEL backend — legitimate because the two are required to be byte-identical on the wire, which test_backend.py asserts.
  • trace.set_tracer_provider is latched by a Once, so it runs at most once per process. A test that needs a specific provider installs it directly rather than calling that.

The suite runs with or without the distro — the three tests that assert on it skip cleanly when it is absent, and CI builds both ways.

make clean leaves src/neo_sdk/studiotelemetry/ alone; it is vendored source, not a build artifact.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

obz_neo_sdk-0.1.0.tar.gz (175.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

obz_neo_sdk-0.1.0-py3-none-any.whl (116.9 kB view details)

Uploaded Python 3

File details

Details for the file obz_neo_sdk-0.1.0.tar.gz.

File metadata

  • Download URL: obz_neo_sdk-0.1.0.tar.gz
  • Upload date:
  • Size: 175.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.13

File hashes

Hashes for obz_neo_sdk-0.1.0.tar.gz
Algorithm Hash digest
SHA256 f087407fbfbbcb4621ab3ed5ba80f3a22cfcf8195107e8739a0895ab7e17820d
MD5 49fa99bd45f4c1e37f825cbe25f03027
BLAKE2b-256 65804d00d495c24690311b6131805fb0bb9ce5380ae253a1ab116b04beb20bfc

See more details on using hashes here.

File details

Details for the file obz_neo_sdk-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: obz_neo_sdk-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 116.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.13

File hashes

Hashes for obz_neo_sdk-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 374774f9814e8895631187f219b712fabad979eba4839f330ec5cc0b3bd3e6e1
MD5 71287f1a822cf6847aff5fa0a73fa6f5
BLAKE2b-256 022c9f2e4386f63f10e54f550cef890c85dc419b7c7cbd94efeb0156c95c9f77

See more details on using hashes here.

Release history Release notifications | RSS feed

0.1.9

1 file

0.1.8

2 files

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page