etaoi 0.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
etaoi-0.0.2/PKG-INFO ADDED
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.3
2
+ Name: etaoi
3
+ Version: 0.0.2
4
+ Summary: Agent-native trace SDK for LangChain / LangGraph / deepagents (skeleton)
5
+ Keywords: langchain,langgraph,deepagents,tracing,observability,agents
6
+ Author: AAIRA / VIZ contributors
7
+ License: MIT
8
+ Classifier: Development Status :: 1 - Planning
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Typing :: Typed
17
+ Requires-Dist: etaoi-schema>=0.3,<1
18
+ Requires-Dist: langchain-core>=1.0,<2
19
+ Requires-Dist: anyio>=4
20
+ Requires-Dist: msgpack>=1.1.2
21
+ Requires-Dist: tenacity>=9
22
+ Requires-Dist: websockets>=13,<17
23
+ Requires-Dist: livekit-agents>=1.2,<2 ; extra == 'livekit'
24
+ Requires-Dist: deepagents>=0.6.1,<0.7 ; extra == 'test'
25
+ Requires-Dist: langgraph>=1.1.0,<2 ; extra == 'test'
26
+ Requires-Dist: pytest>=8 ; extra == 'test'
27
+ Requires-Dist: pytest-asyncio>=0.24 ; extra == 'test'
28
+ Requires-Python: >=3.11
29
+ Project-URL: Homepage, https://github.com/etaoi/etaoi
30
+ Project-URL: Repository, https://github.com/etaoi/etaoi
31
+ Provides-Extra: livekit
32
+ Provides-Extra: test
33
+ Description-Content-Type: text/markdown
34
+
35
+ # etaoi
36
+
37
+ Python SDK for [VIZ](../../README.md) — an agent-native trace visualizer for the LangChain
38
+ ecosystem and LiveKit voice agents.
39
+
40
+ > **Status:** Skeleton. This package exists so CI is green from day one; the real
41
+ > implementation (LangChain `BaseCallbackHandler` adapter, WebSocket transport,
42
+ > MessagePack serialization, background flush) lands in Phase 2 of the
43
+ > [delivery roadmap](../../.planning/ROADMAP.md).
44
+
45
+ When complete, a developer will be able to:
46
+
47
+ ```python
48
+ import etaoi
49
+
50
+ etaoi.attach(my_agent) # streams telemetry to a local VIZ server
51
+ ```
52
+
53
+ For the wire schema this package depends on, see
54
+ [`etaoi-schema`](../etaoi-schema/python/README.md).
etaoi-0.0.2/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # etaoi
2
+
3
+ Python SDK for [VIZ](../../README.md) — an agent-native trace visualizer for the LangChain
4
+ ecosystem and LiveKit voice agents.
5
+
6
+ > **Status:** Skeleton. This package exists so CI is green from day one; the real
7
+ > implementation (LangChain `BaseCallbackHandler` adapter, WebSocket transport,
8
+ > MessagePack serialization, background flush) lands in Phase 2 of the
9
+ > [delivery roadmap](../../.planning/ROADMAP.md).
10
+
11
+ When complete, a developer will be able to:
12
+
13
+ ```python
14
+ import etaoi
15
+
16
+ etaoi.attach(my_agent) # streams telemetry to a local VIZ server
17
+ ```
18
+
19
+ For the wire schema this package depends on, see
20
+ [`etaoi-schema`](../etaoi-schema/python/README.md).
@@ -0,0 +1,76 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.4,<0.11"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "etaoi"
7
+ version = "0.0.2"
8
+ description = "Agent-native trace SDK for LangChain / LangGraph / deepagents (skeleton)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "AAIRA / VIZ contributors" }]
13
+ keywords = ["langchain", "langgraph", "deepagents", "tracing", "observability", "agents"]
14
+ classifiers = [
15
+ "Development Status :: 1 - Planning",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "etaoi-schema>=0.3,<1",
27
+ "langchain-core>=1.0,<2",
28
+ "anyio>=4",
29
+ "msgpack>=1.1.2",
30
+ "tenacity>=9",
31
+ "websockets>=13,<17",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ livekit = [
36
+ "livekit-agents>=1.2,<2",
37
+ ]
38
+ test = [
39
+ "deepagents>=0.6.1,<0.7",
40
+ "langgraph>=1.1.0,<2",
41
+ "pytest>=8",
42
+ "pytest-asyncio>=0.24",
43
+ ]
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/etaoi/etaoi"
47
+ Repository = "https://github.com/etaoi/etaoi"
48
+
49
+ [project.scripts]
50
+ etaoi = "etaoi.cli:main"
51
+
52
+ [tool.uv.sources]
53
+ etaoi-schema = { workspace = true }
54
+
55
+ [tool.ruff]
56
+ line-length = 100
57
+ target-version = "py311"
58
+
59
+ [tool.ruff.lint]
60
+ select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"]
61
+
62
+ [tool.mypy]
63
+ strict = true
64
+ python_version = "3.11"
65
+ packages = ["etaoi"]
66
+
67
+ [[tool.mypy.overrides]]
68
+ module = ["msgpack", "msgpack.*"]
69
+ ignore_missing_imports = true
70
+
71
+ [tool.pytest.ini_options]
72
+ testpaths = ["tests"]
73
+ asyncio_mode = "auto"
74
+ markers = [
75
+ "benchmark: opt-in SDK overhead benchmark (Plan 02-08); run via `pytest -m benchmark`",
76
+ ]
@@ -0,0 +1,45 @@
1
+ # LiveKit livekit/handler.py — agent_context wiring (deferred from Phase 06)
2
+
3
+ After merging this branch into main, paste the emit call into the
4
+ `LiveKitVoiceHandler.bootstrap()` (or equivalent entry-point invoked from
5
+ `Agent.on_enter`). The exact insertion point is the handler method that
6
+ runs once per attach, before any voice_session events go out.
7
+
8
+ ## Diff to apply
9
+
10
+ ```python
11
+ # At the top of packages/etaoi/src/etaoi/livekit/handler.py
12
+ from etaoi.livekit_context import build_voice_agent_context
13
+
14
+ # Inside the bootstrap path (Agent.on_enter or LiveKitVoiceHandler.bind/attach):
15
+ def _bootstrap_agent_context(self, agent: Any, session: Any) -> None:
16
+ """Emit one agent_context span and cache its id for trace linkage."""
17
+ try:
18
+ ctx = build_voice_agent_context(agent, session)
19
+ self.emit_agent_context(ctx)
20
+ except Exception:
21
+ # Bootstrap failure must never block the voice pipeline.
22
+ return
23
+ ```
24
+
25
+ `emit_agent_context` already exists on `AgentVizHandler` (the LangChain
26
+ handler base) — if `LiveKitVoiceHandler` doesn't inherit from it, copy the
27
+ method or thread a reference. The contract: cache the bootstrap span_id
28
+ and stamp `metadata[AGENT_CONTEXT_ID]` on every subsequent voice_session /
29
+ voice_turn / llm span.
30
+
31
+ ## Hook location
32
+
33
+ In livekit-agents 1.5, the right place is the `Agent.on_enter` lifecycle
34
+ hook — fires once when the agent enters its session. Either:
35
+ - Subclass `Agent` and override `on_enter` (user-side), then call our
36
+ `_bootstrap_agent_context` from there, or
37
+ - Patch the handler to attach an `on_enter` listener at bind time.
38
+
39
+ ## Test plan
40
+
41
+ The existing `test_livekit_context.py` covers the snapshot shape. After
42
+ wiring, add one test in `test_livekit_handler.py` that asserts:
43
+ 1. Exactly one `agent_context` span is emitted per attach.
44
+ 2. Subsequent voice_session events carry `metadata[AGENT_CONTEXT_ID]`
45
+ equal to the bootstrap span_id.
@@ -0,0 +1,25 @@
1
+ """etaoi - Python SDK for VIZ."""
2
+
3
+ from etaoi._version import __version__
4
+ from etaoi.attach import AttachedHandle, attach
5
+ from etaoi.handler import AgentVizHandler
6
+ from etaoi.probe import instrument
7
+
8
+ # LiveKit voice integration is gated by the optional ``[livekit]`` extra.
9
+ # Re-exported lazily so users without livekit-agents installed can still
10
+ # ``from etaoi import attach`` without paying an ImportError.
11
+ try:
12
+ from etaoi.livekit.attach import AttachedSessionHandle, attach_session
13
+
14
+ _livekit_exports: tuple[str, ...] = ("AttachedSessionHandle", "attach_session")
15
+ except Exception: # pragma: no cover — only triggers when livekit subpackage breaks
16
+ _livekit_exports = ()
17
+
18
+ __all__ = [
19
+ "AgentVizHandler",
20
+ "AttachedHandle",
21
+ "__version__",
22
+ "attach",
23
+ "instrument",
24
+ *_livekit_exports,
25
+ ]
@@ -0,0 +1,11 @@
1
+ """Allow ``python -m etaoi <subcommand>`` as an alternative to the
2
+ console_script. Useful in containers and CI where the entry-point script
3
+ may not be on PATH but the package is importable.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from etaoi.cli import main
9
+
10
+ if __name__ == "__main__":
11
+ raise SystemExit(main())
@@ -0,0 +1,40 @@
1
+ """Process-global access to the currently-attached ``AgentVizHandler``.
2
+
3
+ LangChain v1 does not fire callbacks for ``AgentMiddleware.wrap_model_call``
4
+ methods — they're invoked as plain Python function calls inside the model
5
+ node. The probe wrappers in :mod:`etaoi.probe` need to find the live
6
+ handler to emit per-middleware snapshot spans; we keep that pointer in a
7
+ ``ContextVar`` so it works correctly under asyncio + threadpool execution.
8
+
9
+ Set by :meth:`AgentVizHandler.__init__` and cleared by the handler when the
10
+ top-level run closes. Probes call :func:`active_handler` and emit if a
11
+ handler is bound, otherwise pass through with no overhead.
12
+
13
+ Also exposes a per-call "current run id" — the most recently-opened
14
+ LangChain span. Probes use it to parent their synthetic spans correctly.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from contextvars import ContextVar
20
+ from typing import TYPE_CHECKING
21
+ from uuid import UUID
22
+
23
+ if TYPE_CHECKING:
24
+ from etaoi.handler import AgentVizHandler
25
+
26
+ ACTIVE_HANDLER: ContextVar["AgentVizHandler | None"] = ContextVar(
27
+ "agentviz_active_handler", default=None
28
+ )
29
+
30
+ ACTIVE_RUN_ID: ContextVar[UUID | None] = ContextVar(
31
+ "agentviz_active_run_id", default=None
32
+ )
33
+
34
+
35
+ def active_handler() -> "AgentVizHandler | None":
36
+ return ACTIVE_HANDLER.get()
37
+
38
+
39
+ def active_run_id() -> UUID | None:
40
+ return ACTIVE_RUN_ID.get()
@@ -0,0 +1,52 @@
1
+ """``AGENTVIZ_DISABLED=1`` short-circuit (SDK-08).
2
+
3
+ When the env var is set, constructing :class:`etaoi.handler.AgentVizHandler`
4
+ returns a module-level :class:`NoopHandler` singleton instead of building a
5
+ real handler — zero allocation beyond the singleton itself, every callback is
6
+ a no-op. This is the SDK's escape hatch for operators who want to keep the
7
+ ``attach()`` call in their code path but suppress all instrumentation at run
8
+ time (CI, profile-of-prod parity runs, debugging).
9
+
10
+ Accepted truthy values (case-insensitive): ``"1"``, ``"true"``, ``"yes"``.
11
+ Anything else (empty string, ``"0"``, ``"false"``) leaves instrumentation on.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+
18
+ from langchain_core.callbacks import BaseCallbackHandler
19
+
20
+ _TRUTHY = frozenset({"1", "true", "yes"})
21
+
22
+
23
+ def is_disabled() -> bool:
24
+ """Return ``True`` iff ``AGENTVIZ_DISABLED`` is set to a truthy value.
25
+
26
+ The env var is read on every call (no caching) so tests can flip it
27
+ between cases without juggling module state.
28
+ """
29
+ return os.environ.get("AGENTVIZ_DISABLED", "").strip().lower() in _TRUTHY
30
+
31
+
32
+ class NoopHandler(BaseCallbackHandler):
33
+ """Callback handler that does nothing.
34
+
35
+ Subclasses :class:`BaseCallbackHandler` so it satisfies type checks at
36
+ the ``langchain`` boundary (``config={"callbacks": [...]}``) without
37
+ overriding any methods — every ``on_*`` falls through to the base
38
+ class's no-op implementation.
39
+ """
40
+
41
+ __slots__ = ()
42
+
43
+
44
+ _NOOP_HANDLER = NoopHandler()
45
+
46
+
47
+ def noop_singleton() -> NoopHandler:
48
+ """Return the process-wide :class:`NoopHandler` instance."""
49
+ return _NOOP_HANDLER
50
+
51
+
52
+ __all__ = ["NoopHandler", "is_disabled", "noop_singleton"]
@@ -0,0 +1 @@
1
+ __version__ = "0.0.2"
@@ -0,0 +1,67 @@
1
+ """Cross-handler contextvar bridge for stitching LangChain spans to a LiveKit
2
+ voice turn.
3
+
4
+ When a LiveKit voice turn opens, the LiveKitVoiceHandler binds the active
5
+ ``speech_id`` (and optional ``agent_id`` / ``agent_label``) here. If the
6
+ session's LLM is a ``livekit-plugins-langchain`` ``LLMAdapter`` wrapping a
7
+ LangGraph / deepagents / ``create_agent`` graph, every LangChain callback the
8
+ internal graph fires runs on the same task — so the AgentVizHandler
9
+ (LangChain) can read ``current_voice_context()`` and tag its spans with the
10
+ LK speech_id, producing a single stitched trace across both handlers.
11
+
12
+ This module is import-light on purpose: pure stdlib, zero dependencies, so
13
+ the LiveKit and LangChain SDK extras can both pull it in without leaking
14
+ their own deps on each other.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from contextvars import ContextVar
20
+ from dataclasses import dataclass
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class VoiceContext:
25
+ """Snapshot of the currently-active LiveKit voice turn for stitching.
26
+
27
+ All fields are optional individually so partial activation (turn open
28
+ before agent identity is known, agent active outside a turn) is
29
+ representable.
30
+ """
31
+
32
+ speech_id: str | None = None
33
+ agent_id: str | None = None
34
+ agent_label: str | None = None
35
+ session_id: str | None = None
36
+
37
+
38
+ _CURRENT: ContextVar[VoiceContext | None] = ContextVar(
39
+ "agentviz_voice_context", default=None
40
+ )
41
+
42
+
43
+ def current_voice_context() -> VoiceContext | None:
44
+ """Return the active VoiceContext, or None if no LK turn is in flight."""
45
+ return _CURRENT.get()
46
+
47
+
48
+ def set_voice_context(ctx: VoiceContext | None) -> object:
49
+ """Bind ``ctx`` as the active context. Returns a token for ``reset()``."""
50
+ return _CURRENT.set(ctx)
51
+
52
+
53
+ def reset_voice_context(token: object) -> None:
54
+ """Restore the previous context. Safe to call with a stale token."""
55
+ try:
56
+ _CURRENT.reset(token) # type: ignore[arg-type]
57
+ except (ValueError, LookupError):
58
+ # Stale token (different task / already reset). Best-effort clear.
59
+ _CURRENT.set(None)
60
+
61
+
62
+ __all__ = [
63
+ "VoiceContext",
64
+ "current_voice_context",
65
+ "reset_voice_context",
66
+ "set_voice_context",
67
+ ]
@@ -0,0 +1,249 @@
1
+ """Public ``attach()`` entry point — one-line agent instrumentation (Plan 02-05).
2
+
3
+ ``from etaoi import attach``
4
+ ``handle = attach(my_agent)`` — returns AttachedHandle with ``.detach()`` and ``.handler`` attrs.
5
+
6
+ Honors ``AGENTVIZ_DISABLED=1`` short-circuit: returns a noop handle with no Sender allocated.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import atexit
13
+ import contextlib
14
+ import logging
15
+ import threading
16
+ from typing import Any
17
+ from uuid import UUID
18
+
19
+ import anyio
20
+
21
+ from etaoi._disabled import is_disabled
22
+ from etaoi.context import build_agent_context
23
+ from etaoi.handler import AgentVizHandler
24
+ from etaoi.sinks import MsgpackDropSink, NoopSink, Sink, WebSocketSink
25
+ from etaoi.transport.sender import Sender
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class AttachedHandle:
31
+ """Returned by ``attach()``. Provides ``.detach()`` and ``.handler``."""
32
+
33
+ def __init__(
34
+ self,
35
+ handler: AgentVizHandler,
36
+ sender: Sender | None,
37
+ thread: threading.Thread | None,
38
+ cancel_scope: anyio.CancelScope | None,
39
+ agent: Any = None,
40
+ loop: asyncio.AbstractEventLoop | None = None,
41
+ ) -> None:
42
+ self.handler = handler
43
+ self._sender = sender
44
+ self._thread = thread
45
+ self._cancel_scope = cancel_scope
46
+ self._loop = loop
47
+ self._agent = agent
48
+ self._installed_on_config = False
49
+ self._detached = False
50
+ atexit.register(self._safe_detach)
51
+
52
+ @property
53
+ def user_id(self) -> str | None:
54
+ """The user_id pinned on the underlying handler (or ``None``)."""
55
+ return getattr(self.handler, "_user_id", None)
56
+
57
+ @property
58
+ def session_id(self) -> UUID | None:
59
+ """The session_id pinned on the underlying handler (or ``None``)."""
60
+ return getattr(self.handler, "_session_id", None)
61
+
62
+ def detach(self) -> None:
63
+ if self._detached:
64
+ return
65
+ self._detached = True
66
+ # Remove the handler from the agent config if we installed it.
67
+ if self._installed_on_config and self._agent is not None:
68
+ with contextlib.suppress(Exception):
69
+ cfg = getattr(self._agent, "config", None)
70
+ if isinstance(cfg, dict):
71
+ callbacks = cfg.get("callbacks")
72
+ if isinstance(callbacks, list) and self.handler in callbacks:
73
+ callbacks.remove(self.handler)
74
+ # Synthesize span_ends for runs that LangChain/deepagents never closed
75
+ # (terminal callbacks skipped on short flows → "empty card" in UI).
76
+ with contextlib.suppress(Exception):
77
+ self.handler.flush_open_spans()
78
+ # Graceful shutdown: schedule aclose() on the sender's background loop
79
+ # so the 75 ms batch buffer drains BEFORE we tear the task down.
80
+ # Hard-cancelling the scope (the original behavior) discarded any
81
+ # envelopes still sitting in the batch window — that's how short
82
+ # agent runs lost their final LLM `span_end`.
83
+ if self._sender is not None and self._loop is not None and not self._loop.is_closed():
84
+ with contextlib.suppress(Exception):
85
+ fut = asyncio.run_coroutine_threadsafe(self._sender.aclose(), self._loop)
86
+ with contextlib.suppress(Exception):
87
+ fut.result(timeout=2.5)
88
+ if self._cancel_scope is not None:
89
+ with contextlib.suppress(Exception):
90
+ self._cancel_scope.cancel()
91
+ if self._thread is not None and self._thread.is_alive():
92
+ self._thread.join(timeout=2.0)
93
+
94
+ def _safe_detach(self) -> None:
95
+ with contextlib.suppress(Exception):
96
+ self.detach()
97
+
98
+
99
+ class _DisabledHandle:
100
+ """No-op handle returned when AGENTVIZ_DISABLED=1."""
101
+
102
+ def __init__(self) -> None:
103
+ self.handler = AgentVizHandler(sink=NoopSink())
104
+
105
+ @property
106
+ def user_id(self) -> str | None:
107
+ return None
108
+
109
+ @property
110
+ def session_id(self) -> UUID | None:
111
+ return None
112
+
113
+ def detach(self) -> None:
114
+ return
115
+
116
+
117
+ def _resolve_sink(sink: str | Sink | None, server_url: str) -> tuple[Sink, Sender | None]:
118
+ if sink is None or sink == "ws":
119
+ sender = Sender(server_url)
120
+ return WebSocketSink(sender), sender
121
+ if sink == "noop":
122
+ return NoopSink(), None
123
+ if sink == "msgpack-drop":
124
+ return MsgpackDropSink(), None
125
+ if isinstance(sink, str):
126
+ raise ValueError(f"unknown sink mode: {sink!r}")
127
+ return sink, None
128
+
129
+
130
+ def _install_handler_on_agent(agent: Any, handler: AgentVizHandler) -> bool:
131
+ """Best-effort install the handler into ``agent.config['callbacks']``.
132
+
133
+ Returns True iff the handler was successfully appended (so ``detach``
134
+ knows to remove it). Failures are silent — ``attach()`` MUST NOT raise
135
+ on a malformed agent argument; the returned handle still carries the
136
+ handler so callers can wire it manually via ``config={"callbacks": ...}``.
137
+
138
+ LangGraph's ``CompiledStateGraph`` exposes a mutable ``config`` dict that
139
+ flows through ``invoke()`` / ``ainvoke()``. Appending to its
140
+ ``"callbacks"`` list is the lowest-friction wiring: the agent picks
141
+ the handler up on every subsequent invocation without the caller
142
+ having to thread it through manually. Plan 02-08's overhead benchmark
143
+ relies on this so the test body can call ``agent.invoke(...)`` without
144
+ re-passing the handler each turn.
145
+ """
146
+ cfg = getattr(agent, "config", None)
147
+ if not isinstance(cfg, dict):
148
+ return False
149
+ callbacks = cfg.get("callbacks")
150
+ if callbacks is None:
151
+ cfg["callbacks"] = [handler]
152
+ return True
153
+ if isinstance(callbacks, list):
154
+ callbacks.append(handler)
155
+ return True
156
+ # Some LangChain primitives accept a BaseCallbackManager here; we don't
157
+ # want to swap it out from under the user. Fall back to manual wiring.
158
+ return False
159
+
160
+
161
+ def attach(
162
+ agent: Any,
163
+ *,
164
+ server_url: str = "ws://127.0.0.1:8787/ingest",
165
+ sink: str | Sink | None = None,
166
+ user_id: str | None = None,
167
+ session_id: str | UUID | None = None,
168
+ ) -> AttachedHandle | _DisabledHandle:
169
+ """Attach AgentViz to an agent.
170
+
171
+ Args:
172
+ agent: The agent object (LangChain Runnable / LangGraph compiled
173
+ graph / deepagent). The handler is best-effort appended to
174
+ ``agent.config['callbacks']`` so subsequent ``invoke()`` calls
175
+ pick it up automatically.
176
+ server_url: WS ingest URL (only consulted when ``sink`` defaults to
177
+ ``"ws"``).
178
+ sink: ``"ws"`` (default), ``"noop"``, ``"msgpack-drop"``, or a
179
+ custom :class:`Sink` instance.
180
+ user_id: Optional user identifier stamped on every event emitted by
181
+ this handle. Omitted → server buckets as anonymous.
182
+ session_id: Optional session id (UUID or string). Omitted → uuid4.
183
+ Strings that don't parse as UUIDs are coerced via uuid5 so the
184
+ same string maps to the same session across attach() calls.
185
+
186
+ Top-level ``invoke()`` calls under this handle each get a fresh
187
+ auto-allocated ``trace_id``, so one ``attach(...)`` can cover many
188
+ invocations within a single session.
189
+ """
190
+ if is_disabled():
191
+ return _DisabledHandle()
192
+
193
+ resolved_sink, sender = _resolve_sink(sink, server_url)
194
+ handler = AgentVizHandler(
195
+ sink=resolved_sink,
196
+ user_id=user_id,
197
+ session_id=session_id,
198
+ )
199
+
200
+ cancel_scope: anyio.CancelScope | None = None
201
+ thread: threading.Thread | None = None
202
+
203
+ if sender is not None:
204
+ # Run the Sender on its own event loop in a background thread so the user's
205
+ # agent (which may be sync or async) does not need to know about it.
206
+ ready = threading.Event()
207
+ loop_holder: dict[str, Any] = {}
208
+ bg_sender = sender
209
+
210
+ def _run() -> None:
211
+ async def _main() -> None:
212
+ nonlocal_scope = anyio.CancelScope()
213
+ loop_holder["scope"] = nonlocal_scope
214
+ with contextlib.suppress(RuntimeError):
215
+ loop_holder["loop"] = asyncio.get_running_loop()
216
+ ready.set()
217
+ with nonlocal_scope:
218
+ try:
219
+ await bg_sender.run()
220
+ except Exception as e:
221
+ logger.warning("etaoi sender exited: %r", e)
222
+
223
+ with contextlib.suppress(Exception):
224
+ anyio.run(_main)
225
+
226
+ thread = threading.Thread(target=_run, name="etaoi-sender", daemon=True)
227
+ thread.start()
228
+ ready.wait(timeout=2.0)
229
+ cancel_scope = loop_holder.get("scope")
230
+ bg_loop = loop_holder.get("loop")
231
+ else:
232
+ bg_loop = None
233
+
234
+ installed = False
235
+ with contextlib.suppress(Exception):
236
+ installed = _install_handler_on_agent(agent, handler)
237
+
238
+ # Bootstrap-once agent_context span. Best-effort: introspection failure
239
+ # must never block attach (the host agent always wins).
240
+ with contextlib.suppress(Exception):
241
+ ctx = build_agent_context(agent)
242
+ handler.emit_agent_context(ctx)
243
+
244
+ h = AttachedHandle(handler, sender, thread, cancel_scope, agent=agent, loop=bg_loop)
245
+ h._installed_on_config = installed
246
+ return h
247
+
248
+
249
+ __all__ = ["AttachedHandle", "attach"]