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 +54 -0
- etaoi-0.0.2/README.md +20 -0
- etaoi-0.0.2/pyproject.toml +76 -0
- etaoi-0.0.2/src/etaoi/_LIVEKIT_WIRING.md +45 -0
- etaoi-0.0.2/src/etaoi/__init__.py +25 -0
- etaoi-0.0.2/src/etaoi/__main__.py +11 -0
- etaoi-0.0.2/src/etaoi/_active.py +40 -0
- etaoi-0.0.2/src/etaoi/_disabled.py +52 -0
- etaoi-0.0.2/src/etaoi/_version.py +1 -0
- etaoi-0.0.2/src/etaoi/_voice_context.py +67 -0
- etaoi-0.0.2/src/etaoi/attach.py +249 -0
- etaoi-0.0.2/src/etaoi/cli/__init__.py +59 -0
- etaoi-0.0.2/src/etaoi/cli/__main__.py +12 -0
- etaoi-0.0.2/src/etaoi/cli/doctor.py +1327 -0
- etaoi-0.0.2/src/etaoi/context.py +295 -0
- etaoi-0.0.2/src/etaoi/handler.py +1136 -0
- etaoi-0.0.2/src/etaoi/livekit/__init__.py +20 -0
- etaoi-0.0.2/src/etaoi/livekit/attach.py +277 -0
- etaoi-0.0.2/src/etaoi/livekit/handler.py +1679 -0
- etaoi-0.0.2/src/etaoi/livekit_context.py +218 -0
- etaoi-0.0.2/src/etaoi/probe.py +491 -0
- etaoi-0.0.2/src/etaoi/sinks.py +139 -0
- etaoi-0.0.2/src/etaoi/transport/__init__.py +35 -0
- etaoi-0.0.2/src/etaoi/transport/batcher.py +131 -0
- etaoi-0.0.2/src/etaoi/transport/codec.py +126 -0
- etaoi-0.0.2/src/etaoi/transport/sender.py +399 -0
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"]
|