@meridiona/meridian-darwin-arm64 1.67.0 → 1.69.0
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.
- package/.env.example +7 -2
- package/VERSION +1 -1
- package/bin/meridian +0 -0
- package/bin/meridian-tray +0 -0
- package/package.json +1 -1
- package/scripts/install-openobserve-daemon.sh +30 -14
- package/services/agents/mlx_classifier.py +28 -1
- package/services/agents/observability.py +284 -73
- package/services/agents/prompts/activity_report.py +37 -53
- package/services/agents/routes/activity.py +74 -78
- package/services/agents/routes/chat.py +60 -18
- package/services/agents/routes/classify.py +245 -0
- package/services/agents/routes/distill.py +9 -5
- package/services/agents/routes/generate.py +314 -0
- package/services/agents/routes/summarise.py +18 -54
- package/services/agents/routes/worklog.py +70 -18
- package/services/agents/schemas.py +66 -0
- package/services/agents/server.py +17 -4
- package/services/agents/session_distiller.py +29 -6
- package/services/agents/structured.py +188 -0
- package/services/agents/tests/test_classifier.py +86 -0
- package/services/agents/tests/test_classify_helpers.py +38 -0
- package/services/agents/tests/test_generate_helpers.py +36 -0
- package/services/agents/tests/test_schemas.py +82 -0
- package/services/agents/tests/test_worklog_models.py +42 -0
- package/services/agents/thinking.py +265 -0
- package/services/agents/time_utils.py +56 -0
- package/services/agents/worklog_pipeline/classifier.py +209 -0
- package/services/agents/worklog_pipeline/db.py +171 -26
- package/services/agents/worklog_pipeline/generation.py +119 -0
- package/services/agents/worklog_pipeline/models.py +46 -57
- package/services/agents/worklog_pipeline/pipeline.py +365 -285
- package/services/agents/worklog_pipeline/prompts/{match_tasks.py → classify_tasks.py} +19 -7
- package/services/agents/worklog_pipeline/prompts/propose_ticket.py +85 -11
- package/services/agents/worklog_pipeline/prompts/worklog.py +58 -13
- package/services/agents/worklog_pipeline/workflow.py +53 -100
- package/services/observability/dashboards/activity-summary.json +455 -0
- package/services/observability/dashboards/pm-worklog-hour.json +194 -0
- package/services/observability/dashboards/session-distiller.json +504 -0
- package/services/pyproject.toml +14 -2
- package/services/pyrightconfig.json +9 -0
- package/services/skills/coding-agent/session-summary/SKILL.md +13 -5
- package/services/tests/fixtures/classify/classify_tier1_eval.json +14 -0
- package/services/tests/fixtures/classify/classify_tier1_today_s_confirmed_plan.json +20 -0
- package/services/tests/fixtures/classify/classify_tier2_wider_backlog_batch.json +38 -0
- package/services/tests/fixtures/classify/eval_cases.json +25 -0
- package/services/uv.lock +502 -3
- package/services/agents/worklog_pipeline/agent_io.py +0 -75
- package/services/agents/worklog_pipeline/match.py +0 -145
- package/services/agents/worklog_pipeline/worklog.py +0 -41
package/.env.example
CHANGED
|
@@ -45,9 +45,14 @@
|
|
|
45
45
|
# If both are present, OAuth wins. JIRA_PROJECT_KEYS applies to either.
|
|
46
46
|
# ---------------------------------------------------------------------------
|
|
47
47
|
|
|
48
|
-
# (A) OAuth needs NO config — Meridian ships a public client id
|
|
49
|
-
#
|
|
48
|
+
# (A) OAuth in a RELEASE build needs NO config — Meridian ships a public client id
|
|
49
|
+
# and bakes the matching client secret in at build time (CI-only; never
|
|
50
|
+
# committed). A LOCAL SOURCE BUILD (cargo run / npm run tauri dev) has no
|
|
51
|
+
# baked-in secret, so browser OAuth login always fails there with "Jira
|
|
52
|
+
# OAuth requires a client secret..." until you set JIRA_OAUTH_CLIENT_SECRET
|
|
53
|
+
# below (ask a Meridiona team member for it) — or just use (B) instead.
|
|
50
54
|
# JIRA_OAUTH_CLIENT_ID=your-atlassian-app-client-id # override the baked-in client id
|
|
55
|
+
# JIRA_OAUTH_CLIENT_SECRET=your-atlassian-app-client-secret # REQUIRED for OAuth on a source build
|
|
51
56
|
# JIRA_OAUTH_REDIRECT_PORT=9123 # must match the app's registered redirect
|
|
52
57
|
# http://127.0.0.1:<port>/callback
|
|
53
58
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.69.0
|
package/bin/meridian
CHANGED
|
Binary file
|
package/bin/meridian-tray
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meridiona/meridian-darwin-arm64",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.69.0",
|
|
4
4
|
"description": "Prebuilt Meridian app for macOS arm64 (daemon binary + dashboard + Python services). Installed via @meridiona/meridian.",
|
|
5
5
|
"homepage": "https://github.com/Meridiona/meridian",
|
|
6
6
|
"repository": {
|
|
@@ -267,13 +267,15 @@ st, body = call("GET", f"/api/{org}/dashboards")
|
|
|
267
267
|
if st != 200:
|
|
268
268
|
print(f" ⚠ could not list dashboards ({st}) — skipping dashboard import")
|
|
269
269
|
sys.exit(0)
|
|
270
|
-
|
|
270
|
+
id_map = {} # title -> dashboard_id
|
|
271
271
|
for d in json.loads(body).get("dashboards", []):
|
|
272
|
-
|
|
272
|
+
did = d.get("dashboard_id", "")
|
|
273
|
+
for v in ("v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8"):
|
|
273
274
|
if d.get(v) and d[v].get("title"):
|
|
274
|
-
|
|
275
|
+
id_map[d[v]["title"]] = did
|
|
276
|
+
break
|
|
275
277
|
|
|
276
|
-
created =
|
|
278
|
+
created = updated = failed = 0
|
|
277
279
|
for path in sorted(glob.glob(os.path.join(dash_dir, "*.json"))):
|
|
278
280
|
try:
|
|
279
281
|
dash = json.load(open(path))
|
|
@@ -282,18 +284,32 @@ for path in sorted(glob.glob(os.path.join(dash_dir, "*.json"))):
|
|
|
282
284
|
failed += 1
|
|
283
285
|
continue
|
|
284
286
|
title = dash.get("title", "")
|
|
285
|
-
if title in
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
287
|
+
if title in id_map:
|
|
288
|
+
# OO PUT requires an internal hash that can't be round-tripped reliably.
|
|
289
|
+
# Delete + re-create is the safe upsert path.
|
|
290
|
+
did = id_map[title]
|
|
291
|
+
st_d, _ = call("DELETE", f"/api/{org}/dashboards/{did}")
|
|
292
|
+
if st_d not in (200, 204):
|
|
293
|
+
failed += 1
|
|
294
|
+
print(f" ⚠ delete failed {title} ({st_d}) — skipping")
|
|
295
|
+
continue
|
|
296
|
+
st, resp = call("POST", f"/api/{org}/dashboards?folder=default", dash)
|
|
297
|
+
if st in (200, 201):
|
|
298
|
+
updated += 1
|
|
299
|
+
print(f" ✓ updated dashboard: {title}")
|
|
300
|
+
else:
|
|
301
|
+
failed += 1
|
|
302
|
+
print(f" ⚠ failed to re-create {os.path.basename(path)} ({st}): {resp[:200].decode(errors='replace')}")
|
|
292
303
|
else:
|
|
293
|
-
|
|
294
|
-
|
|
304
|
+
st, resp = call("POST", f"/api/{org}/dashboards?folder=default", dash)
|
|
305
|
+
if st in (200, 201):
|
|
306
|
+
created += 1
|
|
307
|
+
print(f" ✓ imported dashboard: {title}")
|
|
308
|
+
else:
|
|
309
|
+
failed += 1
|
|
310
|
+
print(f" ⚠ failed to import {os.path.basename(path)} ({st}): {resp[:200].decode(errors='replace')}")
|
|
295
311
|
|
|
296
|
-
print(f"→ dashboards: {created} imported, {
|
|
312
|
+
print(f"→ dashboards: {created} imported, {updated} updated, {failed} failed")
|
|
297
313
|
PYEOF
|
|
298
314
|
return 0
|
|
299
315
|
}
|
|
@@ -18,7 +18,7 @@ from typing import Any, Iterator
|
|
|
18
18
|
from agents import model_registry, observability
|
|
19
19
|
|
|
20
20
|
log = logging.getLogger("agents.mlx_classifier")
|
|
21
|
-
tracer = observability.setup("meridian-mlx-
|
|
21
|
+
tracer = observability.setup("meridian-mlx-server")
|
|
22
22
|
|
|
23
23
|
# Generative/classifier checkpoint — resolved from the model registry (the single
|
|
24
24
|
# source of truth for all three pipeline models), env-overridable via MERIDIAN_LLM_ID.
|
|
@@ -45,6 +45,31 @@ _in_flight = 0
|
|
|
45
45
|
_last_used = time.monotonic()
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
def _invalidate_dependent_caches() -> None:
|
|
49
|
+
"""Drop caches in other modules keyed off the (about-to-be-freed) model object.
|
|
50
|
+
|
|
51
|
+
``agents.structured`` caches an outlines MLXLM wrapper + compiled FSM
|
|
52
|
+
Generator(s) per ``id(bundle.model)`` — without this call those caches
|
|
53
|
+
only get cleared lazily, the next time a structured-generation call
|
|
54
|
+
happens to see a new model id. That left the "evicted" model (and its
|
|
55
|
+
outlines_core Index/Guide/Vocabulary, which carry Python-side reference
|
|
56
|
+
cycles) fully resident for the whole gap: it both defeated this module's
|
|
57
|
+
single-slot residency guarantee (two generative models briefly alive at
|
|
58
|
+
once) and, since eviction fires here often — every reranker↔classifier
|
|
59
|
+
swap — let that stale generation pile up across many reload cycles
|
|
60
|
+
before Python's own GC schedule swept it, producing multi-GB/hour
|
|
61
|
+
malloc-zone growth invisible to ``mx.get_active_memory()`` (that call
|
|
62
|
+
only accounts Metal/unified GPU memory, not this CPU-side object graph).
|
|
63
|
+
Lazy import: ``agents.structured`` is not a dependency this module needs
|
|
64
|
+
at import time, only at eviction time.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
from agents import structured
|
|
68
|
+
structured.invalidate()
|
|
69
|
+
except Exception: # noqa: BLE001 — cache invalidation is best-effort
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
48
73
|
def _get_model() -> _ModelBundle:
|
|
49
74
|
"""Return the loaded model bundle, loading from disk on the first call.
|
|
50
75
|
|
|
@@ -132,6 +157,7 @@ def maybe_evict_idle(idle_s: float | None = None) -> float | None:
|
|
|
132
157
|
mx, before = None, 0
|
|
133
158
|
_model_cache.clear()
|
|
134
159
|
_tokenizer_cache.clear()
|
|
160
|
+
_invalidate_dependent_caches()
|
|
135
161
|
gc.collect()
|
|
136
162
|
freed = 0.0
|
|
137
163
|
if mx is not None:
|
|
@@ -167,6 +193,7 @@ def evict_resident_model() -> float | None:
|
|
|
167
193
|
mx, before = None, 0
|
|
168
194
|
_model_cache.clear()
|
|
169
195
|
_tokenizer_cache.clear()
|
|
196
|
+
_invalidate_dependent_caches()
|
|
170
197
|
gc.collect()
|
|
171
198
|
freed = 0.0
|
|
172
199
|
if mx is not None:
|
|
@@ -3,39 +3,54 @@
|
|
|
3
3
|
A single `setup(agent_name)` call wires up:
|
|
4
4
|
|
|
5
5
|
* an OTel `TracerProvider` with `service.name=agent_name`
|
|
6
|
-
*
|
|
7
|
-
telemetry
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
* export ALWAYS goes through the durable disk spool
|
|
7
|
+
(`~/.meridian/telemetry/pending/`), never a live HTTP call from this
|
|
8
|
+
process — see "Spool-only export" below
|
|
9
|
+
* a `LoggerProvider` + matching log handler so every `logging.LogRecord`
|
|
10
|
+
is correlated to the active span via trace_id/span_id
|
|
11
11
|
* W3C `TraceContextTextMapPropagator` as the global propagator so each
|
|
12
12
|
agent can pick up the Rust daemon's `traceparent` and continue the trace
|
|
13
13
|
* `LoggingInstrumentor` so every `logging.LogRecord` carries
|
|
14
14
|
`otelTraceID` / `otelSpanID` attributes for OpenObserve correlation
|
|
15
15
|
* a JSON formatter (`python-json-logger`) writing daily-rotated JSONL files
|
|
16
|
-
under `~/.meridian/logs/{agent_name}.jsonl` plus stderr
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
under `~/.meridian/logs/{agent_name}.jsonl` plus stderr
|
|
17
|
+
|
|
18
|
+
Export gate: `~/.meridian/settings.json` `otlp_enabled` toggle. When on, every
|
|
19
|
+
span/log batch is written to the spool (`_write_spool`, an atomic
|
|
20
|
+
tmp-then-rename into `~/.meridian/telemetry/pending/`) — the exact same
|
|
21
|
+
`<signal>-<unix_micros>-<seq>.otlp` layout `src/telemetry_spool/writer.rs`
|
|
22
|
+
produces. Generation (this process) and delivery are fully decoupled:
|
|
23
|
+
|
|
24
|
+
* The Rust daemon's `telemetry_spool::shipper` background task drains
|
|
25
|
+
`pending/` into OpenObserve whenever it's reachable, independent of
|
|
26
|
+
whether this process is even still running — `ship_one` in
|
|
27
|
+
`telemetry_spool/mod.rs` classifies failures as Terminal (payload is bad,
|
|
28
|
+
quarantined) or Retryable (network/5xx/429, retried next tick), so a
|
|
29
|
+
down or flaky OpenObserve never loses or corrupts anything, it just
|
|
30
|
+
backs up on disk until OO comes back.
|
|
31
|
+
* `meridian telemetry export`/`import` (`telemetry_spool/cli.rs`) lets a
|
|
32
|
+
user hand someone else the pending spool directly (or import one) —
|
|
33
|
+
the "give a customer's log bundle to support, load it into our own
|
|
34
|
+
OpenObserve" path this architecture exists for.
|
|
35
|
+
|
|
36
|
+
This process NEVER opens a live connection to OpenObserve itself. Earlier
|
|
37
|
+
revisions shipped directly via `OTLPSpanExporter`/`OTLPLogExporter` when OO
|
|
38
|
+
credentials were configured in settings.json (skipping the Rust daemon) —
|
|
39
|
+
that meant a long-running process (the MLX server) held live HTTP
|
|
40
|
+
export/retry state against OpenObserve, and when OO went down for hours,
|
|
41
|
+
continuous failed-export retries correlated with the process's memory
|
|
42
|
+
growing from ~100MB to 20+GB. Spool-only export removes that whole failure
|
|
43
|
+
class: writing to disk can't hang, retry-loop, or accumulate connection
|
|
44
|
+
state, and the already-hardened Rust shipper (atomic writes, terminal/
|
|
45
|
+
retryable classification, quarantine) is the only thing that ever talks to
|
|
46
|
+
OpenObserve.
|
|
24
47
|
|
|
25
48
|
`extract_parent_context(traceparent)` is the helper agents use to continue
|
|
26
49
|
a span emitted by another process — typically the Rust ETL or another
|
|
27
50
|
agent stage.
|
|
28
51
|
|
|
29
52
|
Idempotent: calling `setup` twice is a no-op for the second call (returns
|
|
30
|
-
the existing tracer).
|
|
31
|
-
single-shot CLI paths funnel through the same module.
|
|
32
|
-
|
|
33
|
-
Spool durability: when `otlp_enabled` is true (regardless of whether
|
|
34
|
-
credentials are present), spans and logs are written atomically to
|
|
35
|
-
`~/.meridian/telemetry/pending/<signal>-<unix_micros>-<seq>.otlp` via
|
|
36
|
-
`SpoolSpanExporter` and `SpoolLogExporter`. The Rust daemon's shipper
|
|
37
|
-
task drains that shared directory to OpenObserve — Python does NOT need
|
|
38
|
-
its own shipper, and credential resolution lives there, not here.
|
|
53
|
+
the existing tracer).
|
|
39
54
|
"""
|
|
40
55
|
from __future__ import annotations
|
|
41
56
|
|
|
@@ -55,10 +70,10 @@ from opentelemetry.context import Context
|
|
|
55
70
|
from opentelemetry.instrumentation.logging import LoggingInstrumentor
|
|
56
71
|
from opentelemetry.propagate import set_global_textmap
|
|
57
72
|
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
|
|
58
|
-
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, LogExportResult
|
|
73
|
+
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, SimpleLogRecordProcessor, LogExportResult
|
|
59
74
|
from opentelemetry.sdk.resources import Resource
|
|
60
75
|
from opentelemetry.sdk.trace import TracerProvider
|
|
61
|
-
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExportResult
|
|
76
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SimpleSpanProcessor, SpanExportResult
|
|
62
77
|
from opentelemetry.trace.propagation.tracecontext import (
|
|
63
78
|
TraceContextTextMapPropagator,
|
|
64
79
|
)
|
|
@@ -200,11 +215,9 @@ class SpoolLogExporter:
|
|
|
200
215
|
|
|
201
216
|
# ──────────────────────── Config ───────────────────────────────────────────────
|
|
202
217
|
DEFAULT_LOG_DIR = Path.home() / ".meridian" / "logs"
|
|
203
|
-
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
# picks them up. This process only reads `otlp_enabled` to decide whether to
|
|
207
|
-
# spool — it never delivers, so it needs no endpoint/credentials of its own.
|
|
218
|
+
# Same settings.json the Rust daemon reads — only the otlp_enabled toggle
|
|
219
|
+
# matters here now; oo_email/oo_password are read by the Rust shipper, not
|
|
220
|
+
# this process (see the module docstring's "Spool-only export" section).
|
|
208
221
|
_SETTINGS_PATH = Path(
|
|
209
222
|
os.environ.get("MERIDIAN_SETTINGS_PATH")
|
|
210
223
|
or (Path.home() / ".meridian" / "settings.json")
|
|
@@ -230,13 +243,7 @@ def _load_settings() -> dict[str, object]:
|
|
|
230
243
|
|
|
231
244
|
|
|
232
245
|
def _is_otlp_enabled() -> bool:
|
|
233
|
-
"""Return True when the otlp_enabled toggle is on and tracing is not disabled.
|
|
234
|
-
|
|
235
|
-
Deliberately does NOT check credentials — used to gate the spool exporters
|
|
236
|
-
so telemetry is captured even when OO credentials are absent (the Rust
|
|
237
|
-
shipper delivers when they are provided later, and warns once if a user
|
|
238
|
-
leaves the toggle on with no credentials).
|
|
239
|
-
"""
|
|
246
|
+
"""Return True when the otlp_enabled toggle is on and tracing is not disabled."""
|
|
240
247
|
if os.environ.get("MERIDIAN_TRACING_DISABLED", "").lower() in ("1", "true", "yes"):
|
|
241
248
|
return False
|
|
242
249
|
return bool(_load_settings().get("otlp_enabled"))
|
|
@@ -280,22 +287,16 @@ def setup(agent_name: str) -> trace.Tracer:
|
|
|
280
287
|
|
|
281
288
|
|
|
282
289
|
def shutdown() -> None:
|
|
283
|
-
"""
|
|
290
|
+
"""Shut down the global TracerProvider and log provider.
|
|
284
291
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
explicitly flushed.
|
|
292
|
+
BatchSpanProcessor/BatchLogRecordProcessor queue spans asynchronously;
|
|
293
|
+
calling shutdown() flushes the queue before releasing resources.
|
|
288
294
|
"""
|
|
289
295
|
provider = trace.get_tracer_provider()
|
|
290
|
-
if hasattr(provider, "force_flush"):
|
|
291
|
-
provider.force_flush(timeout_millis=5_000)
|
|
292
296
|
if hasattr(provider, "shutdown"):
|
|
293
297
|
provider.shutdown()
|
|
294
298
|
|
|
295
|
-
# Flush queued log records too — BatchLogRecordProcessor drops them on
|
|
296
|
-
# interpreter exit otherwise, the same hazard as spans.
|
|
297
299
|
if _LOGGER_PROVIDER is not None:
|
|
298
|
-
_LOGGER_PROVIDER.force_flush(timeout_millis=5_000)
|
|
299
300
|
_LOGGER_PROVIDER.shutdown()
|
|
300
301
|
|
|
301
302
|
|
|
@@ -312,32 +313,36 @@ def extract_parent_context(traceparent: Optional[str]) -> Optional[Context]:
|
|
|
312
313
|
|
|
313
314
|
|
|
314
315
|
# ──────────────────────── Tracing setup ────────────────────────────────────────
|
|
316
|
+
# Spool-only by design — see the module docstring's "Spool-only export"
|
|
317
|
+
# section for why this process never opens a live connection to OpenObserve.
|
|
318
|
+
# `src/telemetry_spool/shipper.rs` (Rust daemon, runs independently) is the
|
|
319
|
+
# only thing that ever ships these bytes to OO; `meridian telemetry export/
|
|
320
|
+
# import` is the manual escape hatch when the daemon isn't running at all.
|
|
321
|
+
|
|
322
|
+
|
|
315
323
|
def _configure_tracing(agent_name: str) -> None:
|
|
316
|
-
|
|
317
|
-
|
|
324
|
+
# The W3C propagator is always installed so traceparent round-trips work.
|
|
325
|
+
set_global_textmap(TraceContextTextMapPropagator())
|
|
318
326
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if _is_otlp_enabled():
|
|
322
|
-
provider.add_span_processor(BatchSpanProcessor(SpoolSpanExporter()))
|
|
327
|
+
if not _is_otlp_enabled():
|
|
328
|
+
return
|
|
323
329
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
#
|
|
330
|
+
resource = Resource.create({"service.name": agent_name})
|
|
331
|
+
provider = TracerProvider(resource=resource)
|
|
332
|
+
# BatchSpanProcessor avoids blocking inference threads on each span end;
|
|
333
|
+
# SpoolSpanExporter itself is a synchronous local file write, so this can
|
|
334
|
+
# never hang, retry-loop, or accumulate connection state regardless of
|
|
335
|
+
# whether OpenObserve is reachable.
|
|
336
|
+
provider.add_span_processor(BatchSpanProcessor(SpoolSpanExporter()))
|
|
327
337
|
trace.set_tracer_provider(provider)
|
|
328
|
-
set_global_textmap(TraceContextTextMapPropagator())
|
|
329
338
|
|
|
330
339
|
|
|
331
340
|
def _configure_log_export(agent_name: str) -> Optional[logging.Handler]:
|
|
332
|
-
"""Build
|
|
333
|
-
correlated to the active span by trace_id/span_id
|
|
334
|
-
of the Rust daemon's `OpenTelemetryTracingBridge`.
|
|
335
|
-
|
|
336
|
-
Returns the handler (caller attaches it to root) or `None` when export is
|
|
337
|
-
disabled, in which case logs still go to the JSONL file + stdout/stderr.
|
|
341
|
+
"""Build an OTel log handler so every ``log.*`` record reaches OpenObserve,
|
|
342
|
+
correlated to the active span by trace_id/span_id.
|
|
338
343
|
|
|
339
|
-
|
|
340
|
-
|
|
344
|
+
Returns the handler (caller attaches it to root) or ``None`` when export is
|
|
345
|
+
disabled — logs still go to the JSONL file + stdout/stderr regardless.
|
|
341
346
|
"""
|
|
342
347
|
global _LOGGER_PROVIDER
|
|
343
348
|
|
|
@@ -346,6 +351,8 @@ def _configure_log_export(agent_name: str) -> Optional[logging.Handler]:
|
|
|
346
351
|
|
|
347
352
|
resource = Resource.create({"service.name": agent_name})
|
|
348
353
|
provider = LoggerProvider(resource=resource)
|
|
354
|
+
# BatchLogRecordProcessor so log export never blocks callers — see
|
|
355
|
+
# _configure_tracing above for why SpoolLogExporter is always used.
|
|
349
356
|
provider.add_log_record_processor(BatchLogRecordProcessor(SpoolLogExporter()))
|
|
350
357
|
set_logger_provider(provider)
|
|
351
358
|
_LOGGER_PROVIDER = provider
|
|
@@ -456,19 +463,106 @@ def current_traceparent() -> Optional[str]:
|
|
|
456
463
|
return carrier.get("traceparent")
|
|
457
464
|
|
|
458
465
|
|
|
459
|
-
|
|
460
|
-
|
|
466
|
+
# Process-level handle so the agno TracerProvider isn't garbage-collected.
|
|
467
|
+
_AGNO_TRACER_PROVIDER = None
|
|
461
468
|
|
|
462
|
-
|
|
463
|
-
|
|
469
|
+
|
|
470
|
+
def _build_agno_db_exporter(db, workflow_id: str, agent_id: str):
|
|
471
|
+
"""agno ``DatabaseSpanExporter`` subclass that stamps the pipeline identity
|
|
472
|
+
onto each trace's ROOT span.
|
|
473
|
+
|
|
474
|
+
AgentOS hides any trace whose ``workflow_id`` AND ``agent_id`` are both
|
|
475
|
+
null. With OpenObserve off, the meridian wrapper span (``worklog.hour``) is
|
|
476
|
+
non-recording, so agno's worklog Workflow runs as its OWN root trace; its
|
|
477
|
+
root ``*.run`` span is parentless here and gets the ids, surfacing the trace
|
|
478
|
+
in the dashboard. Child spans are written unchanged.
|
|
479
|
+
"""
|
|
480
|
+
from collections import defaultdict
|
|
481
|
+
|
|
482
|
+
from agno.tracing.exporter import DatabaseSpanExporter
|
|
483
|
+
from agno.tracing.schemas import Span
|
|
484
|
+
from opentelemetry.sdk.trace.export import SpanExportResult
|
|
485
|
+
|
|
486
|
+
class _AgnoDbExporter(DatabaseSpanExporter):
|
|
487
|
+
def export(self, spans): # type: ignore[override]
|
|
488
|
+
if self._shutdown:
|
|
489
|
+
return SpanExportResult.FAILURE
|
|
490
|
+
if not spans:
|
|
491
|
+
return SpanExportResult.SUCCESS
|
|
492
|
+
converted = []
|
|
493
|
+
for s in spans:
|
|
494
|
+
try:
|
|
495
|
+
cs = Span.from_otel_span(s)
|
|
496
|
+
except Exception: # noqa: BLE001 — skip a span we can't convert
|
|
497
|
+
continue
|
|
498
|
+
if not cs.parent_span_id:
|
|
499
|
+
attrs = dict(cs.attributes or {})
|
|
500
|
+
attrs.setdefault("workflow_id", workflow_id)
|
|
501
|
+
attrs.setdefault("agent_id", agent_id)
|
|
502
|
+
# Group hour-runs into one AgentOS session. agno normally
|
|
503
|
+
# stamps session_id when run(session_id=...) is passed; this
|
|
504
|
+
# is a fallback that derives the day-level session from the
|
|
505
|
+
# run_id ("wl-<day>T<hh>" → "wl-<day>") so the trace always
|
|
506
|
+
# surfaces under a session in the dashboard.
|
|
507
|
+
if not (attrs.get("session_id") or attrs.get("agno.session.id")):
|
|
508
|
+
rid = attrs.get("run_id") or attrs.get("agno.run.id") or ""
|
|
509
|
+
if isinstance(rid, str) and "T" in rid:
|
|
510
|
+
attrs["session_id"] = rid.rsplit("T", 1)[0]
|
|
511
|
+
cs.attributes = attrs
|
|
512
|
+
converted.append(cs)
|
|
513
|
+
if not converted:
|
|
514
|
+
return SpanExportResult.SUCCESS
|
|
515
|
+
by_trace: dict = defaultdict(list)
|
|
516
|
+
for cs in converted:
|
|
517
|
+
by_trace[cs.trace_id].append(cs)
|
|
518
|
+
try:
|
|
519
|
+
self._export_sync(by_trace) # SqliteDb is synchronous
|
|
520
|
+
except Exception as e: # noqa: BLE001
|
|
521
|
+
logging.getLogger(__name__).warning("agno trace export failed: %s", e)
|
|
522
|
+
return SpanExportResult.FAILURE
|
|
523
|
+
return SpanExportResult.SUCCESS
|
|
524
|
+
|
|
525
|
+
return _AgnoDbExporter(db=db)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def setup_agno_tracing(
|
|
529
|
+
db_file: Optional[str] = None,
|
|
530
|
+
workflow_id: str = "worklog-hour",
|
|
531
|
+
agent_id: str = "meridian-worklog-pipeline",
|
|
532
|
+
):
|
|
533
|
+
"""Route agno's native (openinference) spans — and ONLY those — to a SqliteDb
|
|
534
|
+
the AgentOS viewer reads.
|
|
535
|
+
|
|
536
|
+
The TracerProvider here is EXPLICIT, not global: meridian's own manual spans
|
|
537
|
+
live on the no-op global provider and never reach this exporter, so the
|
|
538
|
+
dashboard shows agno's ``tracing=True`` output alone. Idempotent per process.
|
|
539
|
+
Returns the provider (or ``None`` if agno/openinference aren't installed).
|
|
464
540
|
"""
|
|
541
|
+
global _AGNO_TRACER_PROVIDER
|
|
542
|
+
if _AGNO_TRACER_PROVIDER is not None:
|
|
543
|
+
return _AGNO_TRACER_PROVIDER
|
|
465
544
|
try:
|
|
466
|
-
from
|
|
467
|
-
AgnoInstrumentor
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
545
|
+
from agno.db.sqlite import SqliteDb
|
|
546
|
+
from openinference.instrumentation.agno import AgnoInstrumentor
|
|
547
|
+
from opentelemetry.sdk.trace import TracerProvider as _TP
|
|
548
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
549
|
+
except ImportError as e:
|
|
550
|
+
logging.getLogger(__name__).warning(
|
|
551
|
+
"setup_agno_tracing: dependencies missing (%s); agno tracing disabled", e
|
|
471
552
|
)
|
|
553
|
+
return None
|
|
554
|
+
|
|
555
|
+
path = db_file or os.environ.get("AGNO_TRACE_DB") or str(
|
|
556
|
+
Path("~/.meridian/agno_traces.db").expanduser()
|
|
557
|
+
)
|
|
558
|
+
path = str(Path(path).expanduser())
|
|
559
|
+
exporter = _build_agno_db_exporter(SqliteDb(db_file=path), workflow_id, agent_id)
|
|
560
|
+
provider = _TP()
|
|
561
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
562
|
+
AgnoInstrumentor().instrument(tracer_provider=provider)
|
|
563
|
+
_AGNO_TRACER_PROVIDER = provider
|
|
564
|
+
logging.getLogger(__name__).info("setup_agno_tracing: agno spans -> %s", path)
|
|
565
|
+
return provider
|
|
472
566
|
|
|
473
567
|
|
|
474
568
|
def preview(text: Optional[str], max_chars: int = 200) -> str:
|
|
@@ -478,4 +572,121 @@ def preview(text: Optional[str], max_chars: int = 200) -> str:
|
|
|
478
572
|
return text[:max_chars] + ("…" if len(text) > max_chars else "")
|
|
479
573
|
|
|
480
574
|
|
|
481
|
-
|
|
575
|
+
def record_gen_params(
|
|
576
|
+
span,
|
|
577
|
+
*,
|
|
578
|
+
temp: float,
|
|
579
|
+
max_tokens: int,
|
|
580
|
+
thinking_budget: int,
|
|
581
|
+
budget_forced: bool,
|
|
582
|
+
enable_thinking: bool = True,
|
|
583
|
+
model: str = "",
|
|
584
|
+
) -> None:
|
|
585
|
+
"""Stamp the generation parameters used for ONE LLM call onto its span.
|
|
586
|
+
|
|
587
|
+
Records the per-call sampling settings — the variable ``temp`` plus the
|
|
588
|
+
shared constants from :mod:`agents.thinking` (top_p / top_k / presence /
|
|
589
|
+
repetition penalty) — together with the thinking-budget config and whether
|
|
590
|
+
the hard cap actually fired (``budget_forced``). Call inside the call's main
|
|
591
|
+
span so every LLM span in the worklog trace shows exactly which parameters
|
|
592
|
+
produced its output. ``model`` is set only when non-empty (some callers set
|
|
593
|
+
it earlier on the span themselves).
|
|
594
|
+
"""
|
|
595
|
+
from agents.thinking import (
|
|
596
|
+
DEFAULT_TOP_P, DEFAULT_TOP_K, DEFAULT_PRESENCE_PENALTY,
|
|
597
|
+
DEFAULT_REPETITION_PENALTY,
|
|
598
|
+
)
|
|
599
|
+
if model:
|
|
600
|
+
span.set_attribute("model", model)
|
|
601
|
+
span.set_attribute("temp", temp)
|
|
602
|
+
span.set_attribute("top_p", DEFAULT_TOP_P)
|
|
603
|
+
span.set_attribute("top_k", DEFAULT_TOP_K)
|
|
604
|
+
span.set_attribute("presence_penalty", DEFAULT_PRESENCE_PENALTY)
|
|
605
|
+
span.set_attribute("repetition_penalty", DEFAULT_REPETITION_PENALTY)
|
|
606
|
+
span.set_attribute("thinking_budget", thinking_budget)
|
|
607
|
+
span.set_attribute("enable_thinking", enable_thinking)
|
|
608
|
+
span.set_attribute("max_tokens", max_tokens)
|
|
609
|
+
span.set_attribute("budget_forced", budget_forced)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def record_fsm_params(
|
|
613
|
+
span,
|
|
614
|
+
*,
|
|
615
|
+
temp: float,
|
|
616
|
+
max_tokens: int,
|
|
617
|
+
schema: str,
|
|
618
|
+
model: str = "",
|
|
619
|
+
) -> None:
|
|
620
|
+
"""Stamp the ACTUAL decoding config for one FSM (grammar-constrained) JSON call.
|
|
621
|
+
|
|
622
|
+
The FSM path (:mod:`agents.structured`) decodes against an outlines logits
|
|
623
|
+
processor compiled from ``schema`` and samples among the grammar-legal tokens with
|
|
624
|
+
``temp`` / ``top_p`` / ``top_k`` (via ``make_sampler``). It runs with thinking OFF
|
|
625
|
+
and — unlike the thinking path — applies NO presence/repetition penalty (outlines
|
|
626
|
+
owns ``logits_processors``). This recorder reflects only what is genuinely applied,
|
|
627
|
+
so the trace never advertises phantom penalties or a thinking budget that don't
|
|
628
|
+
exist on these calls. Use it (not :func:`record_gen_params`) for the FSM endpoints.
|
|
629
|
+
"""
|
|
630
|
+
from agents.thinking import DEFAULT_TOP_P, DEFAULT_TOP_K
|
|
631
|
+
|
|
632
|
+
if model:
|
|
633
|
+
span.set_attribute("model", model)
|
|
634
|
+
span.set_attribute("decoding", "fsm") # grammar-constrained (outlines)
|
|
635
|
+
span.set_attribute("grammar_constrained", True)
|
|
636
|
+
span.set_attribute("fsm_schema", schema) # the Pydantic output schema enforced
|
|
637
|
+
span.set_attribute("enable_thinking", False) # thinking is off for FSM JSON calls
|
|
638
|
+
span.set_attribute("temp", temp)
|
|
639
|
+
span.set_attribute("top_p", DEFAULT_TOP_P)
|
|
640
|
+
span.set_attribute("top_k", DEFAULT_TOP_K)
|
|
641
|
+
span.set_attribute("max_tokens", max_tokens)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def record_llm_io(
|
|
645
|
+
tracer,
|
|
646
|
+
prefix: str,
|
|
647
|
+
*,
|
|
648
|
+
system_prompt: str,
|
|
649
|
+
llm_input: str,
|
|
650
|
+
llm_output: str,
|
|
651
|
+
input_tokens: Optional[int] = None,
|
|
652
|
+
output_tokens: Optional[int] = None,
|
|
653
|
+
think_tokens: Optional[int] = None,
|
|
654
|
+
max_input_chars: int = 8000,
|
|
655
|
+
max_output_chars: int = 8000,
|
|
656
|
+
) -> None:
|
|
657
|
+
"""Emit the three `<prefix>.prompt` / `.input` / `.output` child spans that
|
|
658
|
+
OpenObserve renders as dedicated Prompt / Input / Output panels.
|
|
659
|
+
|
|
660
|
+
This is the same shape the ``activity_report`` endpoint uses, so every LLM
|
|
661
|
+
call in the worklog trace (classify / propose / generate) is debuggable the
|
|
662
|
+
same way: the exact system prompt, the exact user content, and the raw model
|
|
663
|
+
output — plus token counts on the output span. Call this INSIDE the call's
|
|
664
|
+
main span so the three land as its children.
|
|
665
|
+
"""
|
|
666
|
+
with tracer.start_as_current_span(f"{prefix}.prompt") as sp:
|
|
667
|
+
sp.set_attribute("total_chars", len(system_prompt or ""))
|
|
668
|
+
sp.set_attribute("llm_input", preview(system_prompt, max_chars=max_input_chars))
|
|
669
|
+
with tracer.start_as_current_span(f"{prefix}.input") as sp:
|
|
670
|
+
sp.set_attribute("total_chars", len(llm_input or ""))
|
|
671
|
+
sp.set_attribute("llm_input", preview(llm_input, max_chars=max_input_chars))
|
|
672
|
+
with tracer.start_as_current_span(f"{prefix}.output") as sp:
|
|
673
|
+
sp.set_attribute("total_chars", len(llm_output or ""))
|
|
674
|
+
sp.set_attribute("llm_output", preview(llm_output, max_chars=max_output_chars))
|
|
675
|
+
if input_tokens is not None:
|
|
676
|
+
sp.set_attribute("input_tokens", input_tokens)
|
|
677
|
+
if output_tokens is not None:
|
|
678
|
+
sp.set_attribute("output_tokens", output_tokens)
|
|
679
|
+
if think_tokens is not None:
|
|
680
|
+
sp.set_attribute("think_tokens", think_tokens)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
__all__ = [
|
|
684
|
+
"setup",
|
|
685
|
+
"extract_parent_context",
|
|
686
|
+
"setup_agno_tracing",
|
|
687
|
+
"current_traceparent",
|
|
688
|
+
"preview",
|
|
689
|
+
"record_gen_params",
|
|
690
|
+
"record_fsm_params",
|
|
691
|
+
"record_llm_io",
|
|
692
|
+
]
|