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 asfrom 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_traceputs 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
TracerWrapperis a process-wide singleton bound to the first endpoint it sees. Two different collector endpoints cannot be tested in one process, which is whytest_wire.pypins itself to the plain-OTEL backend — legitimate because the two are required to be byte-identical on the wire, whichtest_backend.pyasserts. trace.set_tracer_provideris latched by aOnce, 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f087407fbfbbcb4621ab3ed5ba80f3a22cfcf8195107e8739a0895ab7e17820d
|
|
| MD5 |
49fa99bd45f4c1e37f825cbe25f03027
|
|
| BLAKE2b-256 |
65804d00d495c24690311b6131805fb0bb9ce5380ae253a1ab116b04beb20bfc
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
374774f9814e8895631187f219b712fabad979eba4839f330ec5cc0b3bd3e6e1
|
|
| MD5 |
71287f1a822cf6847aff5fa0a73fa6f5
|
|
| BLAKE2b-256 |
022c9f2e4386f63f10e54f550cef890c85dc419b7c7cbd94efeb0156c95c9f77
|