@meridiona/meridian-darwin-arm64 1.55.0 → 1.57.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/VERSION CHANGED
@@ -1 +1 @@
1
- 1.55.0
1
+ 1.57.0
package/bin/meridian CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meridiona/meridian-darwin-arm64",
3
- "version": "1.55.0",
3
+ "version": "1.57.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": {
@@ -3,10 +3,11 @@
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` exporting OTLP/HTTP-protobuf spans to OpenObserve
7
- * a `LoggerProvider` + OTLP-logs handler so every `logging.LogRecord` is also
8
- shipped to OpenObserve (correlated to the active span), mirroring the Rust
9
- daemon's `OpenTelemetryTracingBridge`
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`
10
11
  * W3C `TraceContextTextMapPropagator` as the global propagator so each
11
12
  agent can pick up the Rust daemon's `traceparent` and continue the trace
12
13
  * `LoggingInstrumentor` so every `logging.LogRecord` carries
@@ -15,11 +16,11 @@ A single `setup(agent_name)` call wires up:
15
16
  under `~/.meridian/logs/{agent_name}.jsonl` plus stderr — both ingestable
16
17
  by OpenObserve's log pipeline without further parsing.
17
18
 
18
- Export config (endpoint + Basic-auth credentials) is resolved from the SAME
19
- `~/.meridian/settings.json` the Rust daemon reads `otlp_enabled`,
20
- `otlp_endpoint`, `oo_email`, `oo_password` so the dashboard Settings page is
21
- the single source of truth for both processes. The legacy `MERIDIAN_OO_AUTH`
22
- env credential is deprecated and ignored, matching the daemon.
19
+ Export is gated by the SAME `~/.meridian/settings.json` the Rust daemon reads —
20
+ the `otlp_enabled` toggle — so 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.
23
24
 
24
25
  `extract_parent_context(traceparent)` is the helper agents use to continue
25
26
  a span emitted by another process — typically the Rust ETL or another
@@ -28,46 +29,182 @@ agent stage.
28
29
  Idempotent: calling `setup` twice is a no-op for the second call (returns
29
30
  the existing tracer). This matters because both the daemon and the
30
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.
31
39
  """
32
40
  from __future__ import annotations
33
41
 
34
- import base64
35
42
  import json
36
43
  import logging
37
44
  import logging.handlers
38
45
  import os
39
46
  import sys
47
+ import threading
48
+ import time
40
49
  from pathlib import Path
41
- from typing import NamedTuple, Optional
50
+ from typing import Optional
42
51
 
43
52
  from opentelemetry import trace
44
53
  from opentelemetry._logs import set_logger_provider
45
54
  from opentelemetry.context import Context
46
- from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
47
- from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
48
- OTLPSpanExporter,
49
- )
50
55
  from opentelemetry.instrumentation.logging import LoggingInstrumentor
51
56
  from opentelemetry.propagate import set_global_textmap
52
57
  from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
53
- from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
58
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, LogExportResult
54
59
  from opentelemetry.sdk.resources import Resource
55
60
  from opentelemetry.sdk.trace import TracerProvider
56
- from opentelemetry.sdk.trace.export import BatchSpanProcessor
61
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExportResult
57
62
  from opentelemetry.trace.propagation.tracecontext import (
58
63
  TraceContextTextMapPropagator,
59
64
  )
60
65
  from pythonjsonlogger import jsonlogger
61
66
 
62
67
 
68
+ # ──────────────────────── Spool exporters ──────────────────────────────────────
69
+
70
+ _spool_seq_lock = threading.Lock()
71
+ _spool_seq = 0
72
+
73
+
74
+ def _next_spool_seq() -> int:
75
+ global _spool_seq
76
+ with _spool_seq_lock:
77
+ val = _spool_seq
78
+ _spool_seq += 1
79
+ return val
80
+
81
+
82
+ def _resolve_telemetry_dir() -> Path:
83
+ """Mirror of the Rust writer's resolve_telemetry_dir().
84
+
85
+ Precedence: MERIDIAN_TELEMETRY_DIR env → ~/.meridian/telemetry.
86
+ """
87
+ env = os.environ.get("MERIDIAN_TELEMETRY_DIR", "").strip()
88
+ if env:
89
+ return Path(env).expanduser()
90
+ home = Path.home()
91
+ return home / ".meridian" / "telemetry"
92
+
93
+
94
+ def _write_spool(signal: str, payload: bytes) -> None:
95
+ """Atomically write payload to ~/.meridian/telemetry/pending/.
96
+
97
+ Filename: <signal>-<unix_micros>-<seq>.otlp
98
+ Write via <name>.tmp then rename so the Rust shipper never sees partial files.
99
+ """
100
+ base = _resolve_telemetry_dir()
101
+ pending = base / "pending"
102
+ pending.mkdir(parents=True, exist_ok=True)
103
+
104
+ micros = int(time.time() * 1_000_000)
105
+ seq = _next_spool_seq()
106
+ filename = f"{signal}-{micros}-{seq}.otlp"
107
+ final_path = pending / filename
108
+ tmp_path = pending / f"{filename}.tmp"
109
+
110
+ try:
111
+ # fsync the tmp file before the rename so a power loss can't leave a
112
+ # rename (metadata) durable while the data blocks are still in page
113
+ # cache — that would surface a truncated .otlp the shipper POSTs (→ a
114
+ # 400). Mirrors the Rust writer's sync_all() + dir fsync.
115
+ with open(tmp_path, "wb") as fh:
116
+ fh.write(payload)
117
+ fh.flush()
118
+ os.fsync(fh.fileno())
119
+ tmp_path.rename(final_path)
120
+ try:
121
+ dir_fd = os.open(str(pending), os.O_RDONLY)
122
+ try:
123
+ os.fsync(dir_fd)
124
+ finally:
125
+ os.close(dir_fd)
126
+ except OSError:
127
+ # Directory fsync is best-effort (not all FS/platforms support it);
128
+ # the tmp-file fsync already guarantees the data is on disk.
129
+ pass
130
+ except Exception as exc:
131
+ logging.getLogger(__name__).warning(
132
+ "telemetry spool write failed — payload dropped",
133
+ extra={"signal": signal, "error": str(exc)},
134
+ )
135
+ # Best-effort cleanup so a failed write never strands a .tmp orphan
136
+ # (the Rust shipper sweeps these, but don't rely on it).
137
+ try:
138
+ tmp_path.unlink(missing_ok=True)
139
+ except OSError as cleanup_exc:
140
+ # Cleanup failure is non-fatal: write already failed and we avoid
141
+ # raising secondary errors from best-effort orphan removal.
142
+ logging.getLogger(__name__).debug(
143
+ "telemetry spool tmp cleanup failed",
144
+ extra={"tmp_path": str(tmp_path), "error": str(cleanup_exc)},
145
+ )
146
+
147
+
148
+ class SpoolSpanExporter:
149
+ """Span exporter that writes serialised OTLP payloads to the spool dir.
150
+
151
+ Wraps the SDK's encode_spans() to produce the same wire bytes the real
152
+ OTLPSpanExporter would POST. The Rust shipper drains pending/ to OO.
153
+ """
154
+
155
+ def export(self, spans): # type: ignore[override]
156
+ try:
157
+ from opentelemetry.exporter.otlp.proto.common.trace_encoder import (
158
+ encode_spans,
159
+ )
160
+ payload = encode_spans(spans).SerializeToString()
161
+ _write_spool("traces", payload)
162
+ except Exception as exc:
163
+ logging.getLogger(__name__).warning(
164
+ "SpoolSpanExporter.export failed", extra={"error": str(exc)}
165
+ )
166
+ return SpanExportResult.SUCCESS
167
+
168
+ def shutdown(self) -> None:
169
+ pass
170
+
171
+ def force_flush(self, timeout_millis: int = 30_000) -> bool:
172
+ return True
173
+
174
+
175
+ class SpoolLogExporter:
176
+ """Log exporter that writes serialised OTLP payloads to the spool dir.
177
+
178
+ Mirrors SpoolSpanExporter for the log signal.
179
+ """
180
+
181
+ def export(self, log_data): # type: ignore[override]
182
+ try:
183
+ from opentelemetry.exporter.otlp.proto.common._log_encoder import (
184
+ encode_logs,
185
+ )
186
+ payload = encode_logs(log_data).SerializeToString()
187
+ _write_spool("logs", payload)
188
+ except Exception as exc:
189
+ logging.getLogger(__name__).warning(
190
+ "SpoolLogExporter.export failed", extra={"error": str(exc)}
191
+ )
192
+ return LogExportResult.SUCCESS
193
+
194
+ def shutdown(self) -> None:
195
+ pass
196
+
197
+ def force_flush(self, timeout_millis: int = 30_000) -> bool:
198
+ return True
199
+
200
+
63
201
  # ──────────────────────── Config ───────────────────────────────────────────────
64
- DEFAULT_TRACES_ENDPOINT = "http://localhost:5080/api/default/v1/traces"
65
- DEFAULT_LOGS_ENDPOINT = "http://localhost:5080/api/default/v1/logs"
66
- DEFAULT_LOG_DIR = Path.home() / ".meridian" / "logs"
67
- # Single source of truth for OpenObserve export config the SAME file the Rust
68
- # daemon reads (see `src/observability.rs::resolve_otlp_target`). Keeps the two
69
- # processes credential-aligned: the dashboard Settings page writes here and both
70
- # the daemon and this MLX server pick it up with no env plumbing.
202
+ 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.
71
208
  _SETTINGS_PATH = Path(
72
209
  os.environ.get("MERIDIAN_SETTINGS_PATH")
73
210
  or (Path.home() / ".meridian" / "settings.json")
@@ -80,18 +217,6 @@ _INITIALISED: dict[str, trace.Tracer] = {}
80
217
  _PROCESS_SERVICE_NAME: str | None = None
81
218
  # Held so shutdown() can flush log records the same way it flushes spans.
82
219
  _LOGGER_PROVIDER: LoggerProvider | None = None
83
- # One-time guard so an export misconfiguration (enabled-but-no-creds, or a
84
- # schemeless endpoint) warns once per process instead of on every resolve.
85
- _WARNED_EXPORT_MISCONFIG: bool = False
86
-
87
-
88
- # ──────────────────────── OTLP target resolution ───────────────────────────────
89
- class _OtlpTarget(NamedTuple):
90
- """Resolved OTLP export target: signal endpoints + Basic-auth header value."""
91
-
92
- traces_endpoint: str
93
- logs_endpoint: str
94
- headers: dict[str, str]
95
220
 
96
221
 
97
222
  def _load_settings() -> dict[str, object]:
@@ -104,85 +229,17 @@ def _load_settings() -> dict[str, object]:
104
229
  return {}
105
230
 
106
231
 
107
- def _resolve_otlp_target() -> Optional[_OtlpTarget]:
108
- """Mirror of the Rust daemon's `resolve_otlp_target()`.
232
+ def _is_otlp_enabled() -> bool:
233
+ """Return True when the otlp_enabled toggle is on and tracing is not disabled.
109
234
 
110
- Returns `None` (→ export disabled) when the toggle is off or credentials
111
- are missing. Endpoint precedence: settings.json `otlp_endpoint` the
112
- `MERIDIAN_OTLP_TRACES_ENDPOINT`/`MERIDIAN_OTLP_ENDPOINT` env override
113
- the localhost default. Auth is `base64(oo_email:oo_password)` — settings.json
114
- only; the legacy `MERIDIAN_OO_AUTH` env path is deprecated and ignored, the
115
- same decision the daemon made.
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).
116
239
  """
117
- global _WARNED_EXPORT_MISCONFIG
118
-
119
240
  if os.environ.get("MERIDIAN_TRACING_DISABLED", "").lower() in ("1", "true", "yes"):
120
- return None
121
-
122
- settings = _load_settings()
123
- if not settings.get("otlp_enabled"):
124
- return None
125
-
126
- # Resolve the endpoint up front so we can warn (not silently disable) when
127
- # export is enabled but unusable. Precedence: settings → env → localhost.
128
- configured = str(settings.get("otlp_endpoint") or "").strip()
129
- env_endpoint = (
130
- os.environ.get("MERIDIAN_OTLP_TRACES_ENDPOINT", "").strip()
131
- or os.environ.get("MERIDIAN_OTLP_ENDPOINT", "").strip()
132
- )
133
- traces_endpoint = configured or env_endpoint or DEFAULT_TRACES_ENDPOINT
134
-
135
- def _warn_once(msg: str, *args: object) -> None:
136
- global _WARNED_EXPORT_MISCONFIG
137
- if not _WARNED_EXPORT_MISCONFIG:
138
- _WARNED_EXPORT_MISCONFIG = True
139
- logging.getLogger(__name__).warning(msg, *args)
140
-
141
- email = str(settings.get("oo_email") or "")
142
- password = str(settings.get("oo_password") or "")
143
- if not email or not password:
144
- # otlp_enabled but no usable credentials → export OFF. Warn once so an
145
- # env-only (MERIDIAN_OO_AUTH) install that predates the settings.json
146
- # credential move doesn't go dark silently — mirrors the daemon, which
147
- # also warns. MERIDIAN_OO_AUTH is no longer read here.
148
- _warn_once(
149
- "OpenObserve export enabled but oo_email/oo_password missing in %s — "
150
- "traces+logs export DISABLED. Set credentials in the dashboard Settings "
151
- "(the MERIDIAN_OO_AUTH env var is no longer used).",
152
- _SETTINGS_PATH,
153
- )
154
- return None
155
- # Guard against HTTP header injection / malformed user:password splits —
156
- # matches the daemon's same-named check.
157
- if any(c in email for c in "\r\n:") or any(c in password for c in "\r\n"):
158
- return None
159
- auth = base64.standard_b64encode(f"{email}:{password}".encode()).decode()
160
-
161
- # Validate scheme — only http/https are valid OTLP transports. The daemon
162
- # disables export + warns on a schemeless endpoint; mirror that exactly so the
163
- # two processes don't disagree on whether export is on.
164
- if not (traces_endpoint.startswith("http://") or traces_endpoint.startswith("https://")):
165
- _warn_once(
166
- "OTLP endpoint %r has no http/https scheme — export DISABLED.",
167
- traces_endpoint,
168
- )
169
- return None
170
-
171
- # OpenObserve serves logs at the sibling `…/v1/logs` path. Derive it from the
172
- # traces endpoint by swapping the trailing signal segment so a custom host or
173
- # base (incl. a trailing slash, e.g. `…/v1/traces/`) carries to BOTH signals —
174
- # never silently fall back to localhost for logs while traces go remote.
175
- t = traces_endpoint.rstrip("/")
176
- if t.endswith("/v1/traces"):
177
- logs_endpoint = t[: -len("/v1/traces")] + "/v1/logs"
178
- elif t.endswith("/traces"):
179
- logs_endpoint = t[: -len("/traces")] + "/logs"
180
- elif "traces" in t:
181
- logs_endpoint = t.rsplit("traces", 1)[0] + "logs"
182
- else:
183
- logs_endpoint = t + "/v1/logs"
184
-
185
- return _OtlpTarget(traces_endpoint, logs_endpoint, {"Authorization": f"Basic {auth}"})
241
+ return False
242
+ return bool(_load_settings().get("otlp_enabled"))
186
243
 
187
244
 
188
245
  # ──────────────────────── Public API ───────────────────────────────────────────
@@ -207,13 +264,8 @@ def setup(agent_name: str) -> trace.Tracer:
207
264
 
208
265
  if _PROCESS_SERVICE_NAME is None:
209
266
  _PROCESS_SERVICE_NAME = agent_name
210
- # Resolve the export target ONCE and pass it to both configurers — a
211
- # second read could see a settings.json the dashboard rewrote mid-setup
212
- # (TOCTOU), leaving traces enabled while logs resolve disabled (or with
213
- # different creds/endpoint) in the same process.
214
- target = _resolve_otlp_target()
215
- _configure_tracing(agent_name, target)
216
- _configure_logging(agent_name, target)
267
+ _configure_tracing(agent_name)
268
+ _configure_logging(agent_name)
217
269
  logging.getLogger(agent_name).info(
218
270
  "observability initialised",
219
271
  extra={"service.name": agent_name},
@@ -257,15 +309,14 @@ def extract_parent_context(traceparent: Optional[str]) -> Optional[Context]:
257
309
 
258
310
 
259
311
  # ──────────────────────── Tracing setup ────────────────────────────────────────
260
- def _configure_tracing(agent_name: str, target: Optional[_OtlpTarget]) -> None:
312
+ def _configure_tracing(agent_name: str) -> None:
261
313
  resource = Resource.create({"service.name": agent_name})
262
314
  provider = TracerProvider(resource=resource)
263
315
 
264
- if target is not None:
265
- exporter = OTLPSpanExporter(
266
- endpoint=target.traces_endpoint, headers=target.headers
267
- )
268
- provider.add_span_processor(BatchSpanProcessor(exporter))
316
+ # Wire the spool exporter when otlp_enabled is true (even without creds).
317
+ # The Rust shipper drains the shared spool dir when a target is available.
318
+ if _is_otlp_enabled():
319
+ provider.add_span_processor(BatchSpanProcessor(SpoolSpanExporter()))
269
320
 
270
321
  # Set as the global provider. OTel's `set_tracer_provider` warns if
271
322
  # someone already configured a provider in-process; we accept that and
@@ -274,32 +325,32 @@ def _configure_tracing(agent_name: str, target: Optional[_OtlpTarget]) -> None:
274
325
  set_global_textmap(TraceContextTextMapPropagator())
275
326
 
276
327
 
277
- def _configure_log_export(
278
- agent_name: str, target: Optional[_OtlpTarget]
279
- ) -> Optional[logging.Handler]:
280
- """Build an OTLP-logs handler so every `log.*` record reaches OpenObserve,
328
+ def _configure_log_export(agent_name: str) -> Optional[logging.Handler]:
329
+ """Build a spool-logs handler so every `log.*` record reaches OpenObserve,
281
330
  correlated to the active span by trace_id/span_id — the Python counterpart
282
331
  of the Rust daemon's `OpenTelemetryTracingBridge`.
283
332
 
284
333
  Returns the handler (caller attaches it to root) or `None` when export is
285
334
  disabled, in which case logs still go to the JSONL file + stdout/stderr.
335
+
336
+ When `otlp_enabled` is true the spool log exporter is always wired (even
337
+ without credentials) — the Rust shipper handles delivery.
286
338
  """
287
339
  global _LOGGER_PROVIDER
288
340
 
289
- if target is None:
341
+ if not _is_otlp_enabled():
290
342
  return None
291
343
 
292
344
  resource = Resource.create({"service.name": agent_name})
293
345
  provider = LoggerProvider(resource=resource)
294
- exporter = OTLPLogExporter(endpoint=target.logs_endpoint, headers=target.headers)
295
- provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
346
+ provider.add_log_record_processor(BatchLogRecordProcessor(SpoolLogExporter()))
296
347
  set_logger_provider(provider)
297
348
  _LOGGER_PROVIDER = provider
298
349
  return LoggingHandler(level=logging.NOTSET, logger_provider=provider)
299
350
 
300
351
 
301
352
  # ──────────────────────── Logging setup ────────────────────────────────────────
302
- def _configure_logging(agent_name: str, target: Optional[_OtlpTarget]) -> None:
353
+ def _configure_logging(agent_name: str) -> None:
303
354
  log_dir = Path(os.environ.get("MERIDIAN_LOG_DIR") or DEFAULT_LOG_DIR)
304
355
  log_dir.mkdir(parents=True, exist_ok=True)
305
356
  log_path = log_dir / f"{agent_name}.jsonl"
@@ -359,17 +410,17 @@ def _configure_logging(agent_name: str, target: Optional[_OtlpTarget]) -> None:
359
410
  root.addHandler(file_h)
360
411
  root.addHandler(stdout_h)
361
412
  root.addHandler(stderr_h)
362
- # Ship every record to OpenObserve via OTLP/HTTP logs too, when export is
363
- # configured. The OTel LoggingHandler reads the active span context, so each
413
+ # Spool every record for OpenObserve via OTLP/HTTP logs too, when export is
414
+ # enabled. The OTel LoggingHandler reads the active span context, so each
364
415
  # OO log row carries the trace_id/span_id that ties it to the classifier's
365
416
  # span waterfall. No-op (None) when OTLP is disabled.
366
- # The OTLP handler already carries service.name via the OTel Resource, so it
417
+ # The spool handler already carries service.name via the OTel Resource, so it
367
418
  # needs no _ServiceFilter (that would duplicate the attribute on each record).
368
- otlp_log_h = _configure_log_export(agent_name, target)
419
+ otlp_log_h = _configure_log_export(agent_name)
369
420
  if otlp_log_h is not None:
370
- # Do NOT feed the OTLP exporter's OWN transport logs back into OTLP
371
- # export: on export failure httpx/urllib3/opentelemetry emit WARNING+
372
- # records which this root handler would try to export → more failures (a
421
+ # Do NOT feed the spool handler's OWN transport/encoder logs back into
422
+ # the spool: on a hiccup httpx/urllib3/opentelemetry emit WARNING+
423
+ # records which this root handler would try to spool → more failures (a
373
424
  # log→export→log loop). Drop those from THIS handler only — they still
374
425
  # reach the file/stderr handlers.
375
426
  _otlp_excluded = ("httpx", "httpcore", "urllib3", "grpc", "opentelemetry")
@@ -62,7 +62,8 @@ def build_synth_agent(
62
62
  debug_level: 1 or 2 (verbose).
63
63
  """
64
64
  from agno.agent import Agent
65
- from agno.guardrails import PIIDetectionGuardrail
65
+ # PII guardrail disabled for now — not needed.
66
+ # from agno.guardrails import PIIDetectionGuardrail
66
67
  from agno.skills import LocalSkills, Skills
67
68
 
68
69
  return Agent(
@@ -98,11 +99,18 @@ def build_synth_agent(
98
99
  SessionBundleSizeGuard(max_tokens=80_000),
99
100
  ],
100
101
  post_hooks=[
101
- PIIDetectionGuardrail(),
102
+ # PIIDetectionGuardrail disabled — it's an input guardrail and was
103
+ # wrongly wired into post_hooks (post-hooks get run_output, not
104
+ # run_input → TypeError). Not needed for now.
102
105
  time_spent_sanity_check,
103
106
  ],
104
107
  output_schema=JiraUpdate,
105
- use_json_mode=True,
108
+ # use_json_mode=False → agno sends the full JiraUpdate json_schema as the
109
+ # request's response_format (not a bare {"type":"json_object"}). The MLX
110
+ # /v1/chat/completions handler reads that schema and FSM-constrains
111
+ # decoding with outlines, so the reasoning model physically cannot leak
112
+ # chain-of-thought prose instead of the JSON object.
113
+ use_json_mode=False,
106
114
  add_datetime_to_context=True,
107
115
  add_history_to_context=False,
108
116
  markdown=False,