@meridiona/meridian-darwin-arm64 1.68.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/services/agents/mlx_classifier.py +27 -0
- package/services/agents/observability.py +159 -78
- package/services/agents/prompts/activity_report.py +5 -2
- package/services/agents/routes/activity.py +26 -120
- package/services/agents/routes/chat.py +60 -18
- package/services/agents/routes/classify.py +245 -0
- package/services/agents/routes/distill.py +7 -4
- package/services/agents/routes/generate.py +314 -0
- package/services/agents/routes/summarise.py +18 -54
- package/services/agents/routes/worklog.py +62 -13
- package/services/agents/schemas.py +66 -0
- package/services/agents/server.py +14 -5
- 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/worklog_pipeline/classifier.py +209 -0
- package/services/agents/worklog_pipeline/db.py +118 -18
- package/services/agents/worklog_pipeline/generation.py +119 -0
- package/services/agents/worklog_pipeline/models.py +43 -58
- package/services/agents/worklog_pipeline/pipeline.py +300 -304
- package/services/agents/worklog_pipeline/prompts/{match_tasks.py → classify_tasks.py} +19 -7
- package/services/agents/worklog_pipeline/prompts/propose_ticket.py +87 -15
- package/services/agents/worklog_pipeline/prompts/worklog.py +58 -13
- package/services/agents/worklog_pipeline/workflow.py +49 -99
- package/services/observability/dashboards/pm-worklog-hour.json +10 -8
- package/services/pyproject.toml +12 -2
- package/services/pyrightconfig.json +9 -0
- 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 +85 -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 -42
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": {
|
|
@@ -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,10 +3,9 @@
|
|
|
3
3
|
A single `setup(agent_name)` call wires up:
|
|
4
4
|
|
|
5
5
|
* an OTel `TracerProvider` with `service.name=agent_name`
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
credentials are absent; the Rust daemon drains that directory to OO
|
|
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
|
|
10
9
|
* a `LoggerProvider` + matching log handler so every `logging.LogRecord`
|
|
11
10
|
is correlated to the active span via trace_id/span_id
|
|
12
11
|
* W3C `TraceContextTextMapPropagator` as the global propagator so each
|
|
@@ -16,10 +15,35 @@ A single `setup(agent_name)` call wires up:
|
|
|
16
15
|
* a JSON formatter (`python-json-logger`) writing daily-rotated JSONL files
|
|
17
16
|
under `~/.meridian/logs/{agent_name}.jsonl` plus stderr
|
|
18
17
|
|
|
19
|
-
Export gate: `~/.meridian/settings.json` `otlp_enabled` toggle
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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.
|
|
23
47
|
|
|
24
48
|
`extract_parent_context(traceparent)` is the helper agents use to continue
|
|
25
49
|
a span emitted by another process — typically the Rust ETL or another
|
|
@@ -191,8 +215,9 @@ class SpoolLogExporter:
|
|
|
191
215
|
|
|
192
216
|
# ──────────────────────── Config ───────────────────────────────────────────────
|
|
193
217
|
DEFAULT_LOG_DIR = Path.home() / ".meridian" / "logs"
|
|
194
|
-
# Same settings.json the Rust daemon reads
|
|
195
|
-
# oo_email/oo_password are
|
|
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).
|
|
196
221
|
_SETTINGS_PATH = Path(
|
|
197
222
|
os.environ.get("MERIDIAN_SETTINGS_PATH")
|
|
198
223
|
or (Path.home() / ".meridian" / "settings.json")
|
|
@@ -288,27 +313,11 @@ def extract_parent_context(traceparent: Optional[str]) -> Optional[Context]:
|
|
|
288
313
|
|
|
289
314
|
|
|
290
315
|
# ──────────────────────── Tracing setup ────────────────────────────────────────
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
passwd = s.get("oo_password") or ""
|
|
297
|
-
if not email or not passwd:
|
|
298
|
-
return {}
|
|
299
|
-
token = base64.b64encode(f"{email}:{passwd}".encode()).decode()
|
|
300
|
-
return {"Authorization": f"Basic {token}"}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def _oo_otlp_base_url() -> str:
|
|
304
|
-
"""Return the OO OTLP base URL (without /v1/traces or /v1/logs suffix)."""
|
|
305
|
-
s = _load_settings()
|
|
306
|
-
endpoint = s.get("otlp_endpoint") or "http://localhost:5080/api/default/v1/traces"
|
|
307
|
-
# Strip the /v1/traces suffix to get the org-level base used by both signals.
|
|
308
|
-
for suffix in ("/v1/traces", "/v1/logs"):
|
|
309
|
-
if endpoint.endswith(suffix):
|
|
310
|
-
return endpoint[: -len(suffix)]
|
|
311
|
-
return endpoint.rstrip("/")
|
|
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.
|
|
312
321
|
|
|
313
322
|
|
|
314
323
|
def _configure_tracing(agent_name: str) -> None:
|
|
@@ -320,22 +329,11 @@ def _configure_tracing(agent_name: str) -> None:
|
|
|
320
329
|
|
|
321
330
|
resource = Resource.create({"service.name": agent_name})
|
|
322
331
|
provider = TracerProvider(resource=resource)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
329
|
-
exporter = OTLPSpanExporter(
|
|
330
|
-
endpoint=f"{_oo_otlp_base_url()}/v1/traces",
|
|
331
|
-
headers=headers,
|
|
332
|
-
)
|
|
333
|
-
provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
334
|
-
else:
|
|
335
|
-
# Credentials absent — spool for the Rust daemon to deliver later.
|
|
336
|
-
# BatchSpanProcessor avoids blocking inference threads on each span end.
|
|
337
|
-
provider.add_span_processor(BatchSpanProcessor(SpoolSpanExporter()))
|
|
338
|
-
|
|
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()))
|
|
339
337
|
trace.set_tracer_provider(provider)
|
|
340
338
|
|
|
341
339
|
|
|
@@ -351,23 +349,11 @@ def _configure_log_export(agent_name: str) -> Optional[logging.Handler]:
|
|
|
351
349
|
if not _is_otlp_enabled():
|
|
352
350
|
return None
|
|
353
351
|
|
|
354
|
-
headers = _oo_otlp_headers()
|
|
355
352
|
resource = Resource.create({"service.name": agent_name})
|
|
356
353
|
provider = LoggerProvider(resource=resource)
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
|
|
361
|
-
log_exporter = OTLPLogExporter(
|
|
362
|
-
endpoint=f"{_oo_otlp_base_url()}/v1/logs",
|
|
363
|
-
headers=headers,
|
|
364
|
-
)
|
|
365
|
-
else:
|
|
366
|
-
# Credentials absent — spool for the Rust daemon to deliver later.
|
|
367
|
-
log_exporter = SpoolLogExporter() # type: ignore[assignment]
|
|
368
|
-
|
|
369
|
-
# BatchLogRecordProcessor for both paths so log export never blocks callers.
|
|
370
|
-
provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter))
|
|
354
|
+
# BatchLogRecordProcessor so log export never blocks callers — see
|
|
355
|
+
# _configure_tracing above for why SpoolLogExporter is always used.
|
|
356
|
+
provider.add_log_record_processor(BatchLogRecordProcessor(SpoolLogExporter()))
|
|
371
357
|
set_logger_provider(provider)
|
|
372
358
|
_LOGGER_PROVIDER = provider
|
|
373
359
|
return LoggingHandler(level=logging.NOTSET, logger_provider=provider)
|
|
@@ -477,21 +463,6 @@ def current_traceparent() -> Optional[str]:
|
|
|
477
463
|
return carrier.get("traceparent")
|
|
478
464
|
|
|
479
465
|
|
|
480
|
-
def instrument_agno() -> None:
|
|
481
|
-
"""Instrument the agno framework for OpenTelemetry tracing.
|
|
482
|
-
|
|
483
|
-
No-op when openinference-instrumentation-agno is not installed; the package
|
|
484
|
-
is optional and the server must start cleanly without it.
|
|
485
|
-
"""
|
|
486
|
-
try:
|
|
487
|
-
from opentelemetry.instrumentation.agno import AgnoInstrumentor # type: ignore[import]
|
|
488
|
-
AgnoInstrumentor().instrument()
|
|
489
|
-
except ImportError:
|
|
490
|
-
logging.getLogger(__name__).debug(
|
|
491
|
-
"opentelemetry-instrumentation-agno not installed; agno spans will not be exported"
|
|
492
|
-
)
|
|
493
|
-
|
|
494
|
-
|
|
495
466
|
# Process-level handle so the agno TracerProvider isn't garbage-collected.
|
|
496
467
|
_AGNO_TRACER_PROVIDER = None
|
|
497
468
|
|
|
@@ -601,11 +572,121 @@ def preview(text: Optional[str], max_chars: int = 200) -> str:
|
|
|
601
572
|
return text[:max_chars] + ("…" if len(text) > max_chars else "")
|
|
602
573
|
|
|
603
574
|
|
|
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
|
+
|
|
604
683
|
__all__ = [
|
|
605
684
|
"setup",
|
|
606
685
|
"extract_parent_context",
|
|
607
|
-
"instrument_agno",
|
|
608
686
|
"setup_agno_tracing",
|
|
609
687
|
"current_traceparent",
|
|
610
688
|
"preview",
|
|
689
|
+
"record_gen_params",
|
|
690
|
+
"record_fsm_params",
|
|
691
|
+
"record_llm_io",
|
|
611
692
|
]
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""System prompt for the activity reporter.
|
|
2
2
|
|
|
3
|
-
Produces
|
|
4
|
-
|
|
3
|
+
Produces ONE consolidated activity report from the hour's distilled screen-capture
|
|
4
|
+
data AND any coding-agent session summaries (woven into the same story, not a
|
|
5
|
+
separate section). Format: TLDR + Core Tasks + Decisions + Resources.
|
|
5
6
|
"""
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
@@ -39,6 +40,8 @@ List documentation pages, repos, articles, videos, dashboards, or other material
|
|
|
39
40
|
---
|
|
40
41
|
|
|
41
42
|
RULES
|
|
43
|
+
- CONSOLIDATE everything into ONE story. Coding-agent sessions are NOT a separate task or section — weave them into the same work threads as the screen activity. If a coding-agent session and the screen capture describe the same work (same files, same feature, same ticket), MERGE them into a single thread; do not double-count or list "coding agent work" on its own.
|
|
44
|
+
- PRESERVE concrete identifiers verbatim — ticket keys (e.g. KAN-241), file paths, function names, PR numbers, branch-independent specifics. A downstream matcher needs these to bind the work to a ticket; never genericize or drop them.
|
|
42
45
|
- Infer the PURPOSE, not just the activity. If the screen shows edits to a prompt file + model test runs, say what the developer was trying to improve and why — not just "edited prompt file and ran tests."
|
|
43
46
|
- Extract identifiable specifics: system names, service names, model names, tool names — anything that helps a matcher connect this to a ticket.
|
|
44
47
|
- Do not make up facts, numbers, or names not present in the input.
|
|
@@ -11,11 +11,12 @@ import logging
|
|
|
11
11
|
|
|
12
12
|
from fastapi import APIRouter, HTTPException
|
|
13
13
|
from opentelemetry import trace
|
|
14
|
-
from opentelemetry.
|
|
14
|
+
from opentelemetry.context import context as _otel_context
|
|
15
15
|
from pydantic import BaseModel
|
|
16
16
|
|
|
17
17
|
from agents import observability
|
|
18
18
|
from agents._state import app_state, model_sem
|
|
19
|
+
from agents.thinking import generate_thinking, DEFAULT_PROSE_TEMP, DEFAULT_THINKING_BUDGET
|
|
19
20
|
|
|
20
21
|
log = logging.getLogger("agents.server")
|
|
21
22
|
|
|
@@ -25,11 +26,11 @@ _ACTIVITY_REPORT_MAX_TOKENS = 16384 # budget for <think> block + answer
|
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
class _ActivityReportRequest(BaseModel):
|
|
28
|
-
body: str
|
|
29
|
+
body: str # the full consolidated input (distilled OCR + coding sessions)
|
|
29
30
|
label: str
|
|
30
|
-
db_path: str | None = None
|
|
31
31
|
max_tokens: int = _ACTIVITY_REPORT_MAX_TOKENS
|
|
32
32
|
traceparent: str | None = None
|
|
33
|
+
enable_thinking: bool = True # set False to disable thinking mode for comparison
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
class _ActivityReportResponse(BaseModel):
|
|
@@ -40,63 +41,15 @@ class _ActivityReportResponse(BaseModel):
|
|
|
40
41
|
elapsed_s: float
|
|
41
42
|
|
|
42
43
|
|
|
43
|
-
def _fetch_coding_summaries(db_path: str, label: str) -> str:
|
|
44
|
-
"""Return a formatted block of coding-agent session summaries for the given local hour label.
|
|
45
|
-
|
|
46
|
-
Queries app_sessions for Claude Code / Codex / Copilot / Cursor rows whose
|
|
47
|
-
started_at or ended_at overlaps the local hour, using the same UTC-range
|
|
48
|
-
conversion as session_distiller so the label always means local time.
|
|
49
|
-
Returns an empty string if none found or db_path is None.
|
|
50
|
-
"""
|
|
51
|
-
import sqlite3
|
|
52
|
-
from datetime import datetime, timedelta, timezone
|
|
53
|
-
|
|
54
|
-
try:
|
|
55
|
-
local_tz = datetime.now().astimezone().tzinfo
|
|
56
|
-
local_start = datetime.strptime(label, "%Y-%m-%dT%H").replace(tzinfo=local_tz)
|
|
57
|
-
utc_start = local_start.astimezone(timezone.utc)
|
|
58
|
-
utc_end = (local_start + timedelta(hours=1)).astimezone(timezone.utc)
|
|
59
|
-
utc_start_s = utc_start.strftime("%Y-%m-%dT%H:%M:%S")
|
|
60
|
-
utc_end_s = utc_end.strftime("%Y-%m-%dT%H:%M:%S")
|
|
61
|
-
|
|
62
|
-
conn = sqlite3.connect(db_path)
|
|
63
|
-
rows = conn.execute("""
|
|
64
|
-
SELECT app_name, started_at, ended_at, session_summary
|
|
65
|
-
FROM app_sessions
|
|
66
|
-
WHERE app_name IN ('Claude Code', 'Codex', 'GitHub Copilot', 'Cursor Agent')
|
|
67
|
-
AND task_method = 'summarised'
|
|
68
|
-
AND session_summary IS NOT NULL
|
|
69
|
-
AND (
|
|
70
|
-
(started_at >= ? AND started_at < ?)
|
|
71
|
-
OR (ended_at >= ? AND ended_at < ?)
|
|
72
|
-
)
|
|
73
|
-
ORDER BY started_at
|
|
74
|
-
""", (utc_start_s, utc_end_s, utc_start_s, utc_end_s)).fetchall()
|
|
75
|
-
conn.close()
|
|
76
|
-
except Exception as exc: # noqa: BLE001
|
|
77
|
-
log.warning("activity_report: could not fetch coding summaries: %s", exc)
|
|
78
|
-
return ""
|
|
79
|
-
|
|
80
|
-
if not rows:
|
|
81
|
-
return ""
|
|
82
|
-
|
|
83
|
-
lines = [f"\n---\n\n## Coding Agent Sessions ({len(rows)} session{'s' if len(rows) != 1 else ''})\n"]
|
|
84
|
-
for app_name, started_at, ended_at, summary in rows:
|
|
85
|
-
lines.append(f"### [{app_name}] {started_at[:16]} – {ended_at[:16]}")
|
|
86
|
-
lines.append(summary.strip())
|
|
87
|
-
lines.append("")
|
|
88
|
-
return "\n".join(lines)
|
|
89
|
-
|
|
90
|
-
|
|
91
44
|
@router.post("/activity_report", response_model=_ActivityReportResponse)
|
|
92
45
|
async def activity_report(req: _ActivityReportRequest) -> _ActivityReportResponse:
|
|
93
46
|
"""Human-readable worklog entry from distilled session body.
|
|
94
47
|
|
|
95
|
-
Uses
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
48
|
+
Uses the shared agents.thinking.generate_thinking (unified sampling +
|
|
49
|
+
thinking-budget enforcement). The thinking-budget processor caps the <think>
|
|
50
|
+
block so it can't consume the whole max_tokens window; the block is stripped
|
|
51
|
+
and only the final markdown answer is returned. req.enable_thinking=False
|
|
52
|
+
disables reasoning entirely (for comparison). Token counts are the model's own.
|
|
100
53
|
"""
|
|
101
54
|
from fastapi.concurrency import run_in_threadpool
|
|
102
55
|
import time as _time
|
|
@@ -111,75 +64,18 @@ async def activity_report(req: _ActivityReportRequest) -> _ActivityReportRespons
|
|
|
111
64
|
|
|
112
65
|
from agents.prompts.activity_report import SYSTEM as _AR_SYSTEM
|
|
113
66
|
|
|
67
|
+
# The caller (worklog_pipeline.stage_report) sends the FULL consolidated input —
|
|
68
|
+
# distilled OCR + labeled coding-agent sessions — so this endpoint just runs it.
|
|
114
69
|
body = req.body
|
|
115
|
-
if req.db_path:
|
|
116
|
-
coding_block = _fetch_coding_summaries(req.db_path, req.label)
|
|
117
|
-
if coding_block:
|
|
118
|
-
body = body + coding_block
|
|
119
|
-
log.info("activity_report: appended %d coding-summary chars", len(coding_block))
|
|
120
|
-
|
|
121
70
|
messages = [
|
|
122
71
|
{"role": "system", "content": _AR_SYSTEM},
|
|
123
72
|
{"role": "user", "content": body},
|
|
124
73
|
]
|
|
125
74
|
|
|
126
|
-
|
|
127
|
-
""
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
m._get_tokenizer() is the HF tokenizer with Qwen3.5 chat template).
|
|
131
|
-
enable_thinking=True requires the HF tokenizer's apply_chat_template, not
|
|
132
|
-
outlines' Chat() shim, so we call mlx_lm.generate directly.
|
|
133
|
-
"""
|
|
134
|
-
from mlx_lm import generate
|
|
135
|
-
from mlx_lm.sample_utils import make_sampler, make_logits_processors
|
|
136
|
-
|
|
137
|
-
sampler = make_sampler(temp=1.0, top_p=0.95, top_k=20)
|
|
138
|
-
logits_processors = make_logits_processors(
|
|
139
|
-
repetition_penalty=1.1,
|
|
140
|
-
repetition_context_size=64,
|
|
141
|
-
presence_penalty=1.5,
|
|
142
|
-
)
|
|
143
|
-
hf_tokenizer = m._get_tokenizer()
|
|
144
|
-
prompt_ids = hf_tokenizer.apply_chat_template(
|
|
145
|
-
messages,
|
|
146
|
-
add_generation_prompt=True,
|
|
147
|
-
enable_thinking=True,
|
|
148
|
-
thinking_budget=8192,
|
|
149
|
-
)
|
|
150
|
-
# A fresh HF tokenizer returns a BatchEncoding ({input_ids, attention_mask});
|
|
151
|
-
# the mlx_lm wrapper returns a plain token list. generate() needs the list.
|
|
152
|
-
if hasattr(prompt_ids, "keys") and "input_ids" in prompt_ids:
|
|
153
|
-
prompt_ids = prompt_ids["input_ids"]
|
|
154
|
-
input_tokens = len(prompt_ids)
|
|
155
|
-
|
|
156
|
-
with m.model_session() as model:
|
|
157
|
-
raw = generate(
|
|
158
|
-
model.model, hf_tokenizer,
|
|
159
|
-
prompt=prompt_ids,
|
|
160
|
-
max_tokens=req.max_tokens,
|
|
161
|
-
sampler=sampler,
|
|
162
|
-
logits_processors=logits_processors,
|
|
163
|
-
verbose=False,
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
# Strip <think>…</think> block; count its tokens
|
|
167
|
-
think_tokens = 0
|
|
168
|
-
if "</think>" in raw:
|
|
169
|
-
think_part, answer = raw.split("</think>", 1)
|
|
170
|
-
think_tokens = len(hf_tokenizer.encode(think_part + "</think>"))
|
|
171
|
-
raw = answer.strip()
|
|
172
|
-
|
|
173
|
-
output_tokens = len(hf_tokenizer.encode(raw))
|
|
174
|
-
return raw, input_tokens, output_tokens, think_tokens
|
|
175
|
-
|
|
176
|
-
_links = []
|
|
177
|
-
if _parent_ctx is not None:
|
|
178
|
-
_parent_span_ctx = trace.get_current_span(_parent_ctx).get_span_context()
|
|
179
|
-
if _parent_span_ctx and _parent_span_ctx.is_valid:
|
|
180
|
-
_links = [Link(_parent_span_ctx)]
|
|
181
|
-
|
|
182
|
-
with tracer.start_as_current_span("activity_report", links=_links) as span:
|
|
75
|
+
with tracer.start_as_current_span(
|
|
76
|
+
"activity_report",
|
|
77
|
+
context=_parent_ctx if _parent_ctx is not None else _otel_context.Context(),
|
|
78
|
+
) as span:
|
|
183
79
|
span.set_attribute("gen_ai.operation.name", "chat")
|
|
184
80
|
span.set_attribute("gen_ai.system", "mlx")
|
|
185
81
|
span.set_attribute("distil_label", req.label)
|
|
@@ -204,7 +100,13 @@ async def activity_report(req: _ActivityReportRequest) -> _ActivityReportRespons
|
|
|
204
100
|
|
|
205
101
|
try:
|
|
206
102
|
async with model_sem():
|
|
207
|
-
|
|
103
|
+
_res = await run_in_threadpool(
|
|
104
|
+
generate_thinking, m, messages,
|
|
105
|
+
max_tokens=req.max_tokens, enable_thinking=req.enable_thinking,
|
|
106
|
+
json_mode=False, temp=DEFAULT_PROSE_TEMP)
|
|
107
|
+
report = _res.text
|
|
108
|
+
input_tokens, output_tokens, think_tokens = (
|
|
109
|
+
_res.input_tokens, _res.output_tokens, _res.think_tokens)
|
|
208
110
|
except Exception as exc: # noqa: BLE001
|
|
209
111
|
span.set_status(trace.StatusCode.ERROR, str(exc))
|
|
210
112
|
log.error(
|
|
@@ -220,6 +122,10 @@ async def activity_report(req: _ActivityReportRequest) -> _ActivityReportRespons
|
|
|
220
122
|
span.set_attribute("think_tokens", think_tokens)
|
|
221
123
|
span.set_attribute("output_chars", len(report))
|
|
222
124
|
span.set_attribute("elapsed_s", elapsed)
|
|
125
|
+
observability.record_gen_params(
|
|
126
|
+
span, temp=DEFAULT_PROSE_TEMP, max_tokens=req.max_tokens,
|
|
127
|
+
thinking_budget=DEFAULT_THINKING_BUDGET, budget_forced=_res.budget_forced,
|
|
128
|
+
enable_thinking=req.enable_thinking)
|
|
223
129
|
span.set_attribute("is_error", False)
|
|
224
130
|
|
|
225
131
|
# Span 3: model output — llm_output → OO renders as dedicated "Output" panel
|