Metadata-Version: 2.4
Name: obz-neo-sdk
Version: 0.1.0
Summary: Registration, tracing and conversation capture for externally hosted Neo GenAI Studio agents
Author: OneByZero
License: Proprietary
Project-URL: Homepage, https://github.com/OwlsAtWork/obz-neo-sdk
Keywords: genai,observability,opentelemetry,agents,neo
Requires-Python: <3.13,>=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.28.0
Requires-Dist: urllib3>=2.0.0
Requires-Dist: pydantic>=2.0.0
Requires-Dist: opentelemetry-api<2,>=1.28.0
Requires-Dist: opentelemetry-sdk<2,>=1.28.0
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.28.0
Provides-Extra: studiotelemetry
Requires-Dist: studiotelemetry-sdk; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-urllib3>=0.55b1; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-anthropic; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-bedrock; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-cohere; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-crewai; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-google-generativeai; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-groq; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-haystack; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-langchain; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-llamaindex; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-mcp; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-ollama; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-openai; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-pinecone; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-qdrant; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-sagemaker; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-together; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-transformers; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-vertexai; extra == "studiotelemetry"
Requires-Dist: opentelemetry-instrumentation-weaviate; extra == "studiotelemetry"
Requires-Dist: opentelemetry-semantic-conventions-ai; extra == "studiotelemetry"
Provides-Extra: fleet
Requires-Dist: fastapi>=0.110; extra == "fleet"
Requires-Dist: uvicorn>=0.29; extra == "fleet"
Requires-Dist: httpx>=0.27; extra == "fleet"
Provides-Extra: anthropic
Requires-Dist: anthropic>=0.40; extra == "anthropic"
Provides-Extra: openai
Requires-Dist: openai>=1.40; extra == "openai"
Provides-Extra: bedrock
Requires-Dist: boto3>=1.34; extra == "bedrock"
Provides-Extra: langgraph
Requires-Dist: langgraph>=0.2; extra == "langgraph"
Requires-Dist: langchain-aws>=0.2; extra == "langgraph"
Requires-Dist: langchain-core>=0.3; extra == "langgraph"
Provides-Extra: strands
Requires-Dist: strands-agents>=1.0; extra == "strands"
Requires-Dist: strands-agents-tools>=0.2; extra == "strands"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: mypy>=1.11; extra == "dev"
Requires-Dist: types-requests; extra == "dev"
Dynamic: license-file

<!--
Copyright © Amazon.com and Affiliates: This deliverable is considered Developed Content as defined in the AWS Service Terms and the SOW between the parties dated [7/28/2026].
-->

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

```bash
uv sync --extra studiotelemetry     # see "Installing" below -- uv, not pip
```

```bash
export NEO_HOST=https://botbuilder.your-company.com
export NEO_M2M_API_KEY=<m2m-key>
export STUDIOTELEMETRY_COLLECTOR_ENDPOINT=http://localhost:4318
```

```python
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:

```python
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:

```python
@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.

```mermaid
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

```python
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:

```python
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`:

```python
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(...)` |

```python
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.

```python
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:

```python
@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:

```mermaid
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:

```python
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.

```python
>>> 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.

```mermaid
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:

```json
{
  "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:

```python
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

```python
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:

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

which builds the same `LoggerManager` you can build yourself:

```python
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.

```bash
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

```bash
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.

```python
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](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

```bash
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](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.
