@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.
Files changed (50) hide show
  1. package/.env.example +7 -2
  2. package/VERSION +1 -1
  3. package/bin/meridian +0 -0
  4. package/bin/meridian-tray +0 -0
  5. package/package.json +1 -1
  6. package/scripts/install-openobserve-daemon.sh +30 -14
  7. package/services/agents/mlx_classifier.py +28 -1
  8. package/services/agents/observability.py +284 -73
  9. package/services/agents/prompts/activity_report.py +37 -53
  10. package/services/agents/routes/activity.py +74 -78
  11. package/services/agents/routes/chat.py +60 -18
  12. package/services/agents/routes/classify.py +245 -0
  13. package/services/agents/routes/distill.py +9 -5
  14. package/services/agents/routes/generate.py +314 -0
  15. package/services/agents/routes/summarise.py +18 -54
  16. package/services/agents/routes/worklog.py +70 -18
  17. package/services/agents/schemas.py +66 -0
  18. package/services/agents/server.py +17 -4
  19. package/services/agents/session_distiller.py +29 -6
  20. package/services/agents/structured.py +188 -0
  21. package/services/agents/tests/test_classifier.py +86 -0
  22. package/services/agents/tests/test_classify_helpers.py +38 -0
  23. package/services/agents/tests/test_generate_helpers.py +36 -0
  24. package/services/agents/tests/test_schemas.py +82 -0
  25. package/services/agents/tests/test_worklog_models.py +42 -0
  26. package/services/agents/thinking.py +265 -0
  27. package/services/agents/time_utils.py +56 -0
  28. package/services/agents/worklog_pipeline/classifier.py +209 -0
  29. package/services/agents/worklog_pipeline/db.py +171 -26
  30. package/services/agents/worklog_pipeline/generation.py +119 -0
  31. package/services/agents/worklog_pipeline/models.py +46 -57
  32. package/services/agents/worklog_pipeline/pipeline.py +365 -285
  33. package/services/agents/worklog_pipeline/prompts/{match_tasks.py → classify_tasks.py} +19 -7
  34. package/services/agents/worklog_pipeline/prompts/propose_ticket.py +85 -11
  35. package/services/agents/worklog_pipeline/prompts/worklog.py +58 -13
  36. package/services/agents/worklog_pipeline/workflow.py +53 -100
  37. package/services/observability/dashboards/activity-summary.json +455 -0
  38. package/services/observability/dashboards/pm-worklog-hour.json +194 -0
  39. package/services/observability/dashboards/session-distiller.json +504 -0
  40. package/services/pyproject.toml +14 -2
  41. package/services/pyrightconfig.json +9 -0
  42. package/services/skills/coding-agent/session-summary/SKILL.md +13 -5
  43. package/services/tests/fixtures/classify/classify_tier1_eval.json +14 -0
  44. package/services/tests/fixtures/classify/classify_tier1_today_s_confirmed_plan.json +20 -0
  45. package/services/tests/fixtures/classify/classify_tier2_wider_backlog_batch.json +38 -0
  46. package/services/tests/fixtures/classify/eval_cases.json +25 -0
  47. package/services/uv.lock +502 -3
  48. package/services/agents/worklog_pipeline/agent_io.py +0 -75
  49. package/services/agents/worklog_pipeline/match.py +0 -145
  50. 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. The vars below are
49
- # optional overrides (e.g. a self-hosted app or a non-default redirect port).
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.67.0
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.67.0",
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
- existing = set()
270
+ id_map = {} # title -> dashboard_id
271
271
  for d in json.loads(body).get("dashboards", []):
272
- for v in ("v1", "v2", "v3", "v4", "v5", "v6"):
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
- existing.add(d[v]["title"])
275
+ id_map[d[v]["title"]] = did
276
+ break
275
277
 
276
- created = skipped = failed = 0
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 existing:
286
- skipped += 1
287
- continue
288
- st, resp = call("POST", f"/api/{org}/dashboards?folder=default", dash)
289
- if st in (200, 201):
290
- created += 1
291
- print(f" ✓ imported dashboard: {title}")
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
- failed += 1
294
- print(f" ⚠ failed to import {os.path.basename(path)} ({st}): {resp[:200].decode(errors='replace')}")
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, {skipped} already present, {failed} failed")
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-classifier")
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
- * a `BatchSpanProcessor` writing OTLP/HTTP-protobuf spans to the durable
7
- telemetry spool (the Rust daemon's shipper drains it to OpenObserve)
8
- * a `LoggerProvider` + spool log handler so every `logging.LogRecord` is also
9
- spooled (correlated to the active span), mirroring the Rust daemon's
10
- `OpenTelemetryTracingBridge`
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 — both ingestable
17
- by OpenObserve's log pipeline without further parsing.
18
-
19
- Export is gated by the SAME `~/.meridian/settings.json` the Rust daemon reads —
20
- the `otlp_enabled` toggleso the dashboard Settings page is the single source
21
- of truth for both processes. Delivery (endpoint + Basic-auth credentials) is
22
- owned entirely by the Rust shipper; Python only ever writes to the spool. The
23
- legacy `MERIDIAN_OO_AUTH` env credential is deprecated and ignored.
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). This matters because both the daemon and the
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
- # Single source of truth for the export TOGGLE the SAME file the Rust daemon
204
- # reads (see `src/observability.rs::resolve_otlp_target`). Delivery credentials
205
- # live there too: the dashboard Settings page writes here and the Rust shipper
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 readsonly 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
- """Flush and shut down the global TracerProvider.
290
+ """Shut down the global TracerProvider and log provider.
284
291
 
285
- Must be called before a short-lived process exits — BatchSpanProcessor
286
- queues spans asynchronously and drops them on interpreter shutdown unless
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
- resource = Resource.create({"service.name": agent_name})
317
- provider = TracerProvider(resource=resource)
324
+ # The W3C propagator is always installed so traceparent round-trips work.
325
+ set_global_textmap(TraceContextTextMapPropagator())
318
326
 
319
- # Wire the spool exporter when otlp_enabled is true (even without creds).
320
- # The Rust shipper drains the shared spool dir when a target is available.
321
- if _is_otlp_enabled():
322
- provider.add_span_processor(BatchSpanProcessor(SpoolSpanExporter()))
327
+ if not _is_otlp_enabled():
328
+ return
323
329
 
324
- # Set as the global provider. OTel's `set_tracer_provider` warns if
325
- # someone already configured a provider in-process; we accept that and
326
- # overwrite the agent process is the authority on its own telemetry.
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 a spool-logs handler so every `log.*` record reaches OpenObserve,
333
- correlated to the active span by trace_id/span_id — the Python counterpart
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
- When `otlp_enabled` is true the spool log exporter is always wired (even
340
- without credentials) — the Rust shipper handles delivery.
344
+ Returns the handler (caller attaches it to root) or ``None`` when export is
345
+ disabledlogs 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
- def instrument_agno() -> None:
460
- """Instrument the agno framework for OpenTelemetry tracing.
466
+ # Process-level handle so the agno TracerProvider isn't garbage-collected.
467
+ _AGNO_TRACER_PROVIDER = None
461
468
 
462
- No-op when openinference-instrumentation-agno is not installed; the package
463
- is optional and the server must start cleanly without it.
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 opentelemetry.instrumentation.agno import AgnoInstrumentor # type: ignore[import]
467
- AgnoInstrumentor().instrument()
468
- except ImportError:
469
- logging.getLogger(__name__).debug(
470
- "opentelemetry-instrumentation-agno not installed; agno spans will not be exported"
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
- __all__ = ["setup", "extract_parent_context", "instrument_agno", "current_traceparent", "preview"]
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
+ ]