Skip to content

Commit 748ec94

Browse files
committed
release: 2.6.0 — TruLens observability
Add TruLens recording for swarm runs and agent calls, telemetry config keys, optional devsper[trulens] extra, and devsper observe dashboard CLI. Bump version and update changelog. Made-with: Cursor
1 parent faae87b commit 748ec94

File tree

10 files changed

+466
-3
lines changed

10 files changed

+466
-3
lines changed

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.6.0] — 2026-04-06
11+
12+
### Added
13+
14+
- **TruLens observability integration** — Added first-class TruLens instrumentation for swarm runs and agent calls, with configurable runtime toggles in `[telemetry]` (`trulens_enabled`, `trulens_database_url`) and recorder/session exports in `devsper.telemetry`.
15+
- **CLI observability dashboard command** — Added `devsper observe` to launch the TruLens dashboard locally with optional `--port` and `--db` overrides.
16+
- **Optional `trulens` extra** — Added `devsper[trulens]` dependency group for straightforward TruLens installation.
17+
18+
### Changed
19+
20+
- **Swarm execution path**`Swarm.run` now records via a TruLens custom app wrapper when enabled, with safe fallback to the standard runtime path if TruLens initialization or recorder setup fails.
21+
- **Agent call capture**`Agent.run` is instrumented for per-call telemetry capture when TruLens is installed.
22+
- **Telemetry defaults and examples** — Updated default config template to include TruLens options and local database guidance.
23+
1024
## [2.5.0] — 2026-04-06
1125

1226
### Added

devsper.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ enabled = true
2424
top_k = 12
2525

2626
[telemetry]
27+
trulens_enabled = true
28+
trulens_database_url = "" # empty = sqlite:///.devsper/trulens.sqlite
2729
otel_enabled = true
2830
otel_endpoint = ""
2931
cost_tracking = true

devsper/agents/agent.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
ClarificationField,
1616
ClarificationRequest,
1717
)
18+
from devsper.telemetry.trulens import instrument as _tru_instrument
1819

1920
log = logging.getLogger(__name__)
2021

@@ -765,6 +766,7 @@ def _auto_web_collection_answers(
765766
answers[f.question] = self._default_answer_for_field(f)
766767
return answers
767768

769+
@_tru_instrument
768770
def run(self, request: AgentRequest) -> AgentResponse:
769771
"""Stateless run: all context in AgentRequest, all output in AgentResponse."""
770772
import time

devsper/cli/main.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2068,6 +2068,33 @@ def _run_analytics() -> int:
20682068
return 0
20692069

20702070

2071+
def _run_observe(port: int = 8501, db: str = "") -> int:
2072+
"""Launch the TruLens observability dashboard."""
2073+
try:
2074+
from devsper.telemetry.trulens import init_trulens, get_session
2075+
except ImportError:
2076+
print("TruLens is not installed. Run: uv pip install 'devsper[trulens]'")
2077+
return 1
2078+
2079+
session = get_session() or init_trulens(database_url=db)
2080+
if session is None:
2081+
print(
2082+
"TruLens is not installed or failed to initialize.\n"
2083+
"Run: uv pip install 'devsper[trulens]'"
2084+
)
2085+
return 1
2086+
2087+
db_url = getattr(session, "connector", None)
2088+
db_label = str(db or ".devsper/trulens.sqlite")
2089+
print(f"Opening TruLens dashboard — db: {db_label} port: {port}")
2090+
print("Press Ctrl-C to stop.")
2091+
try:
2092+
session.run_dashboard(port=port)
2093+
except KeyboardInterrupt:
2094+
pass
2095+
return 0
2096+
2097+
20712098
def _run_tools(args: object) -> int:
20722099
"""List tools with reliability scores, or reset score history."""
20732100
from rich.console import Console
@@ -4032,6 +4059,29 @@ def main() -> int:
40324059
)
40334060
version_parser.set_defaults(func=_run_version)
40344061

4062+
observe_parser = subparsers.add_parser(
4063+
"observe",
4064+
help="Launch TruLens observability dashboard",
4065+
description="Open the TruLens dashboard for browsing run records, traces, and feedback.",
4066+
epilog="""
4067+
Examples:
4068+
devsper observe
4069+
devsper observe --port 8502
4070+
devsper observe --db sqlite:///custom.sqlite
4071+
""",
4072+
formatter_class=argparse.RawDescriptionHelpFormatter,
4073+
)
4074+
observe_parser.add_argument(
4075+
"--port", type=int, default=8501, help="Dashboard port (default: 8501)"
4076+
)
4077+
observe_parser.add_argument(
4078+
"--db",
4079+
default="",
4080+
metavar="URL",
4081+
help="TruLens database URL (default: sqlite:///.devsper/trulens.sqlite)",
4082+
)
4083+
observe_parser.set_defaults(func=lambda a: _run_observe(a.port, a.db))
4084+
40354085
health_parser = subparsers.add_parser(
40364086
"health",
40374087
help="Health and readiness check",

devsper/config/schema.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ class TelemetryConfig(BaseModel):
109109
otel_endpoint: str = ""
110110
otel_headers: dict[str, str] = Field(default_factory=dict)
111111
cost_tracking: bool = True
112+
trulens_enabled: bool = True
113+
trulens_database_url: str = "" # empty = SQLite default (~/.trulens/)
112114

113115

114116
class BudgetConfig(BaseModel):

devsper/swarm/swarm.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@
1111

1212
import asyncio
1313
import json
14+
import logging
1415
import os
1516
import threading
1617
from pathlib import Path
1718
from datetime import datetime, timezone
1819

20+
log = logging.getLogger(__name__)
21+
1922
from devsper.types.task import Task
2023
from devsper.types.event import Event, events
2124
from devsper.utils.event_logger import EventLog
@@ -28,6 +31,12 @@
2831
from devsper.agents.registry import AgentRegistry
2932
from devsper.budget import BudgetManager
3033
from devsper.telemetry import instrument_swarm_run, record_exception
34+
from devsper.telemetry.trulens import (
35+
init_trulens,
36+
get_session,
37+
make_recorder,
38+
instrument as _tru_instrument,
39+
)
3140

3241

3342
def _fake_config():
@@ -79,6 +88,22 @@ def _persist_dag(scheduler: Scheduler, event_log: EventLog, execution_graph: obj
7988
json.dump({"nodes": nodes, "edges": edges}, f, indent=0)
8089

8190

91+
class _TruSwarmApp:
92+
"""Thin TruLens-instrumented wrapper for swarm execution.
93+
94+
TruLens @instrument marks this method so every call is captured as a record
95+
when a TruCustomApp recorder is active. The Swarm delegates to this wrapper
96+
only when TruLens is enabled; otherwise _run_core() is called directly.
97+
"""
98+
99+
def __init__(self, swarm: "Swarm") -> None:
100+
self._swarm = swarm
101+
102+
@_tru_instrument
103+
def execute(self, user_task: str, hitl_resolver: object = None) -> dict:
104+
return self._swarm._run_core(user_task, hitl_resolver)
105+
106+
82107
class RunResult(dict):
83108
"""Backward-compatible run result map with optional metadata."""
84109

@@ -200,6 +225,18 @@ def __init__(
200225
self._last_reasoning_store = None
201226
self._pause_event = threading.Event()
202227
self._pause_event.set()
228+
# Initialize TruLens session if enabled in config
229+
_tele = getattr(cfg, "telemetry", None) if cfg is not None else None
230+
if getattr(_tele, "trulens_enabled", True):
231+
try:
232+
from devsper import __version__
233+
234+
init_trulens(
235+
database_url=str(getattr(_tele, "trulens_database_url", "") or ""),
236+
app_version=__version__,
237+
)
238+
except Exception as _tlu_exc:
239+
log.warning("TruLens init skipped: %s", _tlu_exc)
203240

204241
def pause(self) -> None:
205242
"""Pause the executor: currently-running tasks finish, no new tasks start."""
@@ -209,11 +246,35 @@ def resume(self) -> None:
209246
"""Resume the executor so new tasks can be picked."""
210247
self._pause_event.set()
211248

249+
def _trulens_enabled(self) -> bool:
250+
"""Return True if TruLens recording is configured and a session exists."""
251+
tele = getattr(self._config, "telemetry", None)
252+
enabled = bool(getattr(tele, "trulens_enabled", True)) if tele else True
253+
return enabled and get_session() is not None
254+
212255
def run(self, user_task: str, hitl_resolver: object = None) -> dict[str, str]:
213256
"""
214257
Create root task → plan subtasks → add to scheduler → run executor → return task_id → result.
215258
hitl_resolver: optional (approval, policy) -> bool for in-process approval prompts.
259+
260+
When TruLens is enabled the run is recorded via TruCustomApp so inputs,
261+
outputs, and per-agent calls are stored in the TruLens database.
216262
"""
263+
if self._trulens_enabled():
264+
try:
265+
from devsper import __version__
266+
267+
_app = _TruSwarmApp(self)
268+
recorder = make_recorder(_app, app_name="devsper", app_version=__version__)
269+
if recorder is not None:
270+
with recorder as _recording:
271+
return _app.execute(user_task, hitl_resolver)
272+
except Exception as _tru_exc:
273+
log.warning("TruLens recording setup failed, falling back: %s", _tru_exc)
274+
return self._run_core(user_task, hitl_resolver)
275+
276+
def _run_core(self, user_task: str, hitl_resolver: object = None) -> dict[str, str]:
277+
"""Internal run implementation (called directly or via TruLens wrapper)."""
217278
run_id = getattr(self.event_log, "run_id", "") or ""
218279
with instrument_swarm_run(run_id, user_task) as span:
219280
if span is not None:

devsper/telemetry/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,21 @@
77
record_exception,
88
)
99
from devsper.telemetry.pricing import PRICING, estimate_cost_usd
10+
from devsper.telemetry.trulens import (
11+
get_session as get_trulens_session,
12+
init_trulens,
13+
make_recorder as make_trulens_recorder,
14+
)
1015

1116
__all__ = [
1217
"PRICING",
1318
"annotate_span",
1419
"estimate_cost_usd",
1520
"get_tracer",
21+
"get_trulens_session",
22+
"init_trulens",
1623
"instrument_swarm_run",
24+
"make_trulens_recorder",
1725
"record_exception",
1826
]
1927

devsper/telemetry/trulens.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""TruLens observability — default for devsper 2.6.0+.
2+
3+
TruLens records every swarm run (input task → output results) and every agent
4+
call (prompt → completion) into a local SQLite database at
5+
``.devsper/trulens.sqlite`` (same directory as memory.db and tool_analytics.db).
6+
7+
The TruLens dashboard can be launched with::
8+
9+
from devsper.telemetry import get_trulens_session
10+
get_trulens_session().run_dashboard()
11+
12+
OTEL spans are still emitted alongside TruLens records — they are complementary.
13+
TruLens is the new *default* export layer; OTEL remains available for
14+
infrastructure-level tracing (Grafana, Jaeger, etc.).
15+
16+
Configuration (devsper.toml):
17+
18+
[telemetry]
19+
trulens_enabled = true
20+
trulens_database_url = "" # empty = sqlite:///.devsper/trulens.sqlite
21+
22+
Install::
23+
24+
uv pip install "devsper[trulens]"
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import logging
30+
import os
31+
from typing import Any
32+
33+
log = logging.getLogger(__name__)
34+
35+
# Default SQLite path — mirrors memory.db / tool_analytics.db location.
36+
_DEFAULT_DB_PATH = ".devsper/trulens.sqlite"
37+
_DEFAULT_DB_URL = f"sqlite:///{_DEFAULT_DB_PATH}"
38+
39+
try:
40+
from trulens.core import TruSession
41+
from trulens.apps.custom import TruCustomApp, instrument
42+
43+
_TRULENS_AVAILABLE = True
44+
except ImportError:
45+
_TRULENS_AVAILABLE = False
46+
TruSession = None # type: ignore[assignment,misc]
47+
TruCustomApp = None # type: ignore[assignment,misc]
48+
49+
def instrument(fn): # type: ignore[misc] # noqa: E306
50+
"""No-op fallback when trulens-core is not installed."""
51+
return fn
52+
53+
54+
_session: Any = None # TruSession | None
55+
56+
57+
def init_trulens(
58+
*,
59+
database_url: str = "",
60+
app_name: str = "devsper",
61+
app_version: str = "",
62+
) -> Any:
63+
"""Initialize global TruLens session (idempotent).
64+
65+
Defaults to ``sqlite:///.devsper/trulens.sqlite`` — the same ``.devsper/``
66+
directory used by ``memory.db`` and ``tool_analytics.db``.
67+
68+
Returns the session on success, or None if trulens-core is not installed
69+
or initialization fails.
70+
"""
71+
global _session
72+
if not _TRULENS_AVAILABLE:
73+
log.debug("trulens-core not installed; TruLens observability skipped")
74+
return None
75+
if _session is not None:
76+
return _session
77+
url = database_url.strip() if database_url else _DEFAULT_DB_URL
78+
# Ensure the .devsper/ directory exists for the default SQLite path.
79+
if url == _DEFAULT_DB_URL:
80+
os.makedirs(os.path.dirname(_DEFAULT_DB_PATH), exist_ok=True)
81+
try:
82+
_session = TruSession(database_url=url)
83+
log.info(
84+
"TruLens session initialized (app=%s v%s db=%s)",
85+
app_name,
86+
app_version or "?",
87+
url,
88+
)
89+
except Exception as exc:
90+
log.warning("TruLens init failed: %s", exc)
91+
return _session
92+
93+
94+
def get_session() -> Any:
95+
"""Return the active TruLens session, or None."""
96+
return _session
97+
98+
99+
def make_recorder(
100+
app: Any,
101+
*,
102+
app_name: str = "devsper",
103+
app_version: str = "",
104+
) -> Any:
105+
"""Wrap an instrumented app with TruCustomApp for recording.
106+
107+
Returns a TruCustomApp recorder, or None if TruLens is unavailable /
108+
the session has not been initialized.
109+
"""
110+
if not _TRULENS_AVAILABLE or _session is None or TruCustomApp is None:
111+
return None
112+
try:
113+
kwargs: dict[str, Any] = {"app_name": app_name}
114+
if app_version:
115+
kwargs["app_version"] = app_version
116+
return TruCustomApp(app, **kwargs)
117+
except Exception as exc:
118+
log.warning("TruLens make_recorder failed: %s", exc)
119+
return None

pyproject.toml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ packages = ["devsper"]
77

88
[project]
99
name = "devsper"
10-
version = "2.5.0"
10+
version = "2.6.0"
1111
description = "Orchestrate distributed swarms of AI agents that collaboratively solve complex tasks."
1212
readme = "README.md"
1313
license = "GPL-3.0-or-later"
@@ -70,6 +70,9 @@ distributed = [
7070
"uvicorn>=0.29.0",
7171
"hiredis>=2.3.0",
7272
]
73+
# TruLens observability (default since 2.6.0).
74+
# trulens-core pins rich<14; we override that constraint via [tool.uv.override-dependencies].
75+
trulens = ["trulens-core>=1.0"]
7376
# Pin LangGraph explicitly for integrators (also pulled in via langchain).
7477
langgraph = ["langgraph>=1.0.0,<2"]
7578
worker = [] # distributed worker is a separate Rust binary (devsper-worker)
@@ -88,3 +91,9 @@ dev = [
8891
"respx>=0.22.0",
8992
"ruff>=0.15.5",
9093
]
94+
95+
[tool.uv]
96+
# trulens-core pins rich<14; textual (and devsper's TUI) requires rich>=14.
97+
# Override so uv resolves to the version we actually need — rich 14.x is
98+
# backward-compatible with the Console/Table APIs trulens uses.
99+
override-dependencies = ["rich>=14.3.3"]

0 commit comments

Comments
 (0)