From 396976db34fb6a49f5491b54c5e31bbe04649f34 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:57:07 -0400 Subject: [PATCH 01/65] Connection Exponential Backoff (#758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(db): add connection retry, adaptive deriver polling, and pool metrics Add resilience and visibility for DB connection handling under transaction- pooler (Supavisor) saturation, where client-connection limits get exhausted across many tenants. - get_db/tracked_db now force an eager pool checkout with bounded exponential backoff (tenacity), retrying SQLAlchemy TimeoutError + OperationalError so transient pooler rejections degrade gracefully instead of 500ing. Toggle via DB_CONNECTION_RETRY_ENABLED (+ delay/backoff knobs); ~10s default budget. - Deriver polling backs off when idle or erroring (base -> max, x2 each cycle) and snaps back to base on claimed work, cutting steady-state query load. Toggle via DERIVER_POLLING_BACKOFF_ENABLED (+ max/multiplier). - Add scrape-time db_pool_connections Prometheus gauge (checked_out/checked_in/ size/overflow, labeled api|deriver), registered in both the API lifespan and the deriver metrics server. - Make SqlalchemyIntegration explicit in both Sentry inits; wrap connection acquisition in a db.pool.acquire span and capture live pool stats on retry-exhaustion. * feat(db): add acquisition counter and in-flight query gauge Build on the pool-connection metrics with two signals that turn detection into diagnosis under transaction-pooler saturation: - db_connection_acquisitions{outcome=ok|retried|exhausted}: counts how often connection checkout retries through pooler rejection — the alertable early warning before requests start failing. - db_queries_in_flight: statements actually executing on the wire (via SQLAlchemy cursor-execute events, drift-proof across query errors). Pairs with checked_out: the gap reveals connections held but parked (the "idle in transaction during an external call" antipattern). Labeled namespace + instance_type only; gated on METRICS.ENABLED for zero overhead when off. Add DB-free unit tests for retry outcomes, polling backoff, and in-flight gauge drift handling. * fix: address CodeRabbit review on PR #758 - db: roll back the session on a retryable checkout failure before retrying — a failed autobegin can leave it pending-rollback, making the next db.connection() raise instead of re-checking-out cleanly. Cheap Python-side cleanup when no connection was bound. - metrics: guard DBPoolCollector.collect() so a pool-read/import hiccup can't raise and abort the whole /metrics scrape (Prometheus drops ALL metrics if any collector raises) — log and fall back to empty. * fix(db): lazy retrying session + review fixes for connection backoff Address Codex/CodeRabbit review on PR #758. - Replace eager checkout with HonchoAsyncSession: a lazy AsyncSession that checks out its connection (with retry) on the first DB-touching call, not at construction. Request handlers doing non-DB work (embedding/file/LLM) before their first query no longer pin a connection across it, while the API path still gets checkout retry. Only the checkout is retried — the statement runs once via super(), so writes are never duplicated. Tracing's set_config moves into the same lazy acquire hook. - Roll the session back on a retryable checkout failure before retrying, so a failed autobegin can't leave it pending-rollback. - Lower default POOL_TIMEOUT to 5s and validate it stays under the retry budget for pooled (non-null) POOL_CLASS; update config.toml.example and v2/v3 docs. - Clamp pool overflow gauge to >= 0 (was negative before the pool fills). - Remove double-sleep in the deriver idle poll (true backoff cap, not 2x); make in-flight instrumentation registration idempotent. - Tests: HonchoAsyncSession lazy/idempotent acquire, statement-runs-once, tracing, commit/rollback flag reset, get_db no-acquire-at-entry, polling-loop single-sleep, and the POOL_TIMEOUT/retry-budget validator. * fix(db): cover all DB-touching session methods; clear flag on close/reset Address Codex follow-up review on PR #758 (polish, no behavior-critical bug). - HonchoAsyncSession: wrap get/get_one/stream/stream_scalars/delete in addition to execute/scalar/scalars/flush/merge/refresh/commit, so the "lazy checkout with retry on first DB use" guarantee has no holes. connection() stays unwrapped (acquire_connection_with_retry calls it — wrapping would recurse). - Reset the acquired flag on close()/reset() too, so a session reused after close/reset re-acquires (and re-wraps retry) on its next DB use. - Fix stale comments: connection retry now applies lazily to the request path via HonchoAsyncSession (config.py), and the FakeSession helper note. - Tests: close/reset flag reset, and get/delete route through acquisition. --- config.toml.example | 9 +- docs/v2/contributing/configuration.mdx | 2 +- docs/v3/contributing/configuration.mdx | 8 +- src/config.py | 59 +++- src/db.py | 314 ++++++++++++++++++++- src/dependencies.py | 34 +-- src/deriver/__main__.py | 11 +- src/deriver/queue_manager.py | 45 ++- src/main.py | 11 +- src/telemetry/__init__.py | 7 +- src/telemetry/prometheus/__init__.py | 2 + src/telemetry/prometheus/metrics.py | 79 ++++++ tests/test_db_resilience.py | 370 +++++++++++++++++++++++++ tests/test_dependencies.py | 33 +-- 14 files changed, 924 insertions(+), 60 deletions(-) create mode 100644 tests/test_db_resilience.py diff --git a/config.toml.example b/config.toml.example index 0f4b92c8..258172a7 100644 --- a/config.toml.example +++ b/config.toml.example @@ -26,11 +26,18 @@ POOL_CLASS = "default" POOL_PRE_PING = true POOL_SIZE = 10 MAX_OVERFLOW = 20 -POOL_TIMEOUT = 30 # seconds +POOL_TIMEOUT = 5 # seconds; must stay under CONNECTION_RETRY_MAX_DELAY_SECONDS POOL_RECYCLE = 300 # seconds POOL_USE_LIFO = true SQL_DEBUG = false TRACING = false +# Bounded retry around connection checkout (used by background/tracked_db and +# the lazy request session). For a pooled (non-null) POOL_CLASS, POOL_TIMEOUT +# must be < CONNECTION_RETRY_MAX_DELAY_SECONDS or startup validation fails. +CONNECTION_RETRY_ENABLED = true +CONNECTION_RETRY_MAX_DELAY_SECONDS = 10.0 +CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS = 0.1 +CONNECTION_RETRY_BACKOFF_MAX_SECONDS = 2.0 # Authentication settings [auth] diff --git a/docs/v2/contributing/configuration.mdx b/docs/v2/contributing/configuration.mdx index c172369c..f2548f94 100644 --- a/docs/v2/contributing/configuration.mdx +++ b/docs/v2/contributing/configuration.mdx @@ -161,7 +161,7 @@ DB_CONNECTION_URI=postgresql+psycopg://honcho_user:secure_password@db.example.co DB_SCHEMA=public DB_POOL_SIZE=10 DB_MAX_OVERFLOW=20 -DB_POOL_TIMEOUT=30 +DB_POOL_TIMEOUT=5 DB_POOL_RECYCLE=300 DB_POOL_PRE_PING=true DB_SQL_DEBUG=false diff --git a/docs/v3/contributing/configuration.mdx b/docs/v3/contributing/configuration.mdx index 582bde3a..d7a71b6c 100644 --- a/docs/v3/contributing/configuration.mdx +++ b/docs/v3/contributing/configuration.mdx @@ -459,10 +459,16 @@ DB_SCHEMA=public DB_POOL_PRE_PING=true DB_POOL_SIZE=10 DB_MAX_OVERFLOW=20 -DB_POOL_TIMEOUT=30 +DB_POOL_TIMEOUT=5 DB_POOL_RECYCLE=300 DB_POOL_USE_LIFO=true DB_SQL_DEBUG=false +# Bounded retry around connection checkout. For a pooled (non-null) DB_POOL_CLASS, +# DB_POOL_TIMEOUT must be < DB_CONNECTION_RETRY_MAX_DELAY_SECONDS or startup fails. +DB_CONNECTION_RETRY_ENABLED=true +DB_CONNECTION_RETRY_MAX_DELAY_SECONDS=10.0 +DB_CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS=0.1 +DB_CONNECTION_RETRY_BACKOFF_MAX_SECONDS=2.0 ``` ### Authentication diff --git a/src/config.py b/src/config.py index 5ae93fa4..35d72d3e 100644 --- a/src/config.py +++ b/src/config.py @@ -612,8 +612,9 @@ class DBSettings(HonchoSettings): POOL_PRE_PING: bool = True POOL_SIZE: Annotated[int, Field(default=10, gt=0, le=1000)] = 10 MAX_OVERFLOW: Annotated[int, Field(default=20, ge=0, le=1000)] = 20 - POOL_TIMEOUT: Annotated[int, Field(default=30, gt=0, le=300)] = ( - 30 # seconds (max 5 minutes) + POOL_TIMEOUT: Annotated[int, Field(default=5, gt=0, le=300)] = ( + 5 # seconds; kept under CONNECTION_RETRY_MAX_DELAY_SECONDS so a pool + # checkout fails fast enough to allow a retry within the budget ) POOL_RECYCLE: Annotated[int, Field(default=300, gt=0, le=7200)] = ( 300 # seconds (max 2 hours) @@ -622,6 +623,48 @@ class DBSettings(HonchoSettings): SQL_DEBUG: bool = False TRACING: bool = False + # Bounded exponential-backoff retry around connection acquisition. Applied + # lazily on the first DB use of any session (HonchoAsyncSession) — both the + # request path and background/tracked_db scopes — without forcing an eager + # checkout. Guards against transient transaction-pooler saturation (e.g. + # Supavisor rejecting with "too many clients") by retrying the checkout + # instead of failing immediately. CONNECTION_RETRY_MAX_DELAY_SECONDS is the + # TOTAL retry + # budget; with a real QueuePool, a single checkout can block up to + # POOL_TIMEOUT, so POOL_TIMEOUT must stay below the budget for a retry to be + # possible (enforced below). With NullPool (the transaction-pooler setup) + # there is no local queue wait, so saturation is a fast OperationalError and + # POOL_TIMEOUT does not apply. + CONNECTION_RETRY_ENABLED: bool = True + CONNECTION_RETRY_MAX_DELAY_SECONDS: Annotated[ + float, Field(default=10.0, gt=0.0, le=120.0) + ] = 10.0 + CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS: Annotated[ + float, Field(default=0.1, gt=0.0, le=10.0) + ] = 0.1 + CONNECTION_RETRY_BACKOFF_MAX_SECONDS: Annotated[ + float, Field(default=2.0, gt=0.0, le=30.0) + ] = 2.0 + + @model_validator(mode="after") + def _validate_retry_budget_vs_pool_timeout(self) -> "DBSettings": + # Only meaningful for a real local pool: NullPool has no queue wait, so + # POOL_TIMEOUT is unused there. For a QueuePool, a checkout can block up + # to POOL_TIMEOUT, so it must be < the total retry budget or the first + # attempt consumes the whole budget and no retry ever happens. + if ( + self.POOL_CLASS != "null" + and self.CONNECTION_RETRY_ENABLED + and self.POOL_TIMEOUT >= self.CONNECTION_RETRY_MAX_DELAY_SECONDS + ): + raise ValueError( + f"DB_POOL_TIMEOUT ({self.POOL_TIMEOUT}s) must be less than " + + "DB_CONNECTION_RETRY_MAX_DELAY_SECONDS " + + f"({self.CONNECTION_RETRY_MAX_DELAY_SECONDS}s) so a pooled checkout " + + "can fail fast enough to be retried within the budget." + ) + return self + class AuthSettings(HonchoSettings): model_config = SettingsConfigDict(env_prefix="AUTH_", extra="ignore") # pyright: ignore @@ -737,6 +780,18 @@ class DeriverSettings(HonchoSettings): POLLING_SLEEP_INTERVAL_SECONDS: Annotated[ float, Field(default=1.0, gt=0.0, le=60.0) ] = 1.0 + # Adaptive polling: when the queue is idle (or the loop is erroring) the + # sleep interval grows from POLLING_SLEEP_INTERVAL_SECONDS toward + # POLLING_SLEEP_MAX_INTERVAL_SECONDS by POLLING_BACKOFF_MULTIPLIER each + # cycle, then snaps back to the base interval as soon as work is found. + # Reduces steady-state query load against the (shared) DB/pooler. + POLLING_BACKOFF_ENABLED: bool = True + POLLING_SLEEP_MAX_INTERVAL_SECONDS: Annotated[ + float, Field(default=30.0, gt=0.0, le=300.0) + ] = 30.0 + POLLING_BACKOFF_MULTIPLIER: Annotated[ + float, Field(default=2.0, ge=1.0, le=10.0) + ] = 2.0 STALE_SESSION_TIMEOUT_MINUTES: Annotated[int, Field(default=5, gt=0, le=1440)] = 5 # Retention window (seconds) for keeping errored items in the queue diff --git a/src/db.py b/src/db.py index 4b656175..0a133c05 100644 --- a/src/db.py +++ b/src/db.py @@ -1,11 +1,32 @@ import contextvars +import logging +from typing import Any -from sqlalchemy import MetaData, text -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +import sentry_sdk +from sqlalchemy import MetaData, event, text +from sqlalchemy.exc import OperationalError +from sqlalchemy.exc import TimeoutError as SQLAlchemyTimeoutError +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) from sqlalchemy.orm import declarative_base -from sqlalchemy.pool import NullPool +from sqlalchemy.pool import NullPool, QueuePool +from tenacity import ( + AsyncRetrying, + retry_if_exception_type, + stop_after_delay, + wait_exponential_jitter, +) from src.config import settings +from src.telemetry.prometheus.metrics import ( + db_queries_in_flight_gauge, + prometheus_metrics, +) + +logger = logging.getLogger(__name__) connect_args = {"prepare_threshold": None} @@ -38,13 +59,300 @@ engine = create_async_engine( **engine_kwargs, ) +# NOTE: SessionLocal is defined further down, after HonchoAsyncSession (its +# session class) and acquire_connection_with_retry (which that class calls). + +# Errors worth retrying when acquiring a pooled connection: SQLAlchemy's local +# pool-checkout timeout, and OperationalError (how a saturated transaction +# pooler surfaces "too many clients" / connection refusals). +RETRYABLE_DB_CONNECTION_ERRORS = (SQLAlchemyTimeoutError, OperationalError) + +# Identifies this process ("api" | "deriver") on DB metrics. Set once at startup +# by register_db_query_instrumentation; stays "unknown" if metrics are disabled. +_db_instance_type: str = "unknown" + + +def _record_acquisition_outcome(outcome: str) -> None: + """Record a connection-acquisition outcome (no-op when metrics disabled).""" + if settings.METRICS.ENABLED: + prometheus_metrics.record_db_connection_acquisition( + instance_type=_db_instance_type, outcome=outcome + ) + + +def get_pool_stats() -> dict[str, int]: + """Return live connection-pool stats for this process. + + ``engine.pool`` is the AsyncEngine's pool (the same object as + ``engine.sync_engine.pool``); its stat methods are synchronous counter + reads with no I/O, so they are safe to call without ``await``. Returns + zeros for pools that do not track connections (e.g. ``NullPool``). + """ + zeros = {"checked_out": 0, "checked_in": 0, "size": 0, "overflow": 0} + pool = engine.pool + # Only QueuePool (and its AsyncAdaptedQueuePool subclass) tracks connection + # counts; NullPool and others have no meaningful stats. + if not isinstance(pool, QueuePool): + return zeros + try: + # overflow() is negative until the base pool fills (it starts at + # -pool_size); clamp to the count of overflow connections actually open. + return { + "checked_out": pool.checkedout(), + "checked_in": pool.checkedin(), + "size": pool.size(), + "overflow": max(0, pool.overflow()), + } + except Exception: + return zeros + + +async def acquire_connection_with_retry(db: AsyncSession, context: str) -> None: + """Force pool checkout (which ``SessionLocal()`` defers) with bounded backoff. + + ``SessionLocal()`` is lazy: the pool checkout — and any pooler rejection — + happens on the first query. We force it here inside a retry block so that + transient saturation of the transaction pooler is retried with exponential + backoff rather than surfacing as an immediate error. The checkout is wrapped + in a Sentry span so wait time is visible in traces; on budget exhaustion the + original error is reraised after capturing live pool stats to Sentry. + + Each attempt rolls the session back on a retryable failure before retrying: + a failed checkout can leave the autobegun transaction in a pending-rollback + state, which would make the next ``db.connection()`` raise instead of + re-checking-out cleanly. The rollback is pure Python-side state cleanup when + no connection was bound, so it is cheap and safe. + """ + with sentry_sdk.start_span(op="db.pool.acquire", name=context): + if not settings.DB.CONNECTION_RETRY_ENABLED: + await db.connection() + return + attempts = 0 + try: + async for attempt in AsyncRetrying( + wait=wait_exponential_jitter( + initial=settings.DB.CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS, + max=settings.DB.CONNECTION_RETRY_BACKOFF_MAX_SECONDS, + ), + stop=stop_after_delay(settings.DB.CONNECTION_RETRY_MAX_DELAY_SECONDS), + retry=retry_if_exception_type(RETRYABLE_DB_CONNECTION_ERRORS), + reraise=True, + ): + with attempt: + attempts += 1 + try: + await db.connection() + except RETRYABLE_DB_CONNECTION_ERRORS: + # Reset session state so the next attempt starts clean. + try: + await db.rollback() + except Exception: + logger.debug( + "rollback after failed checkout failed", + exc_info=True, + ) + raise + except RETRYABLE_DB_CONNECTION_ERRORS as e: + _record_acquisition_outcome("exhausted") + if settings.SENTRY.ENABLED: + sentry_sdk.set_context("db_pool", get_pool_stats()) + sentry_sdk.capture_exception(e) + raise + # "ok" on first try, "retried" if backoff was needed before success. + _record_acquisition_outcome("ok" if attempts <= 1 else "retried") + + +class HonchoAsyncSession(AsyncSession): + """AsyncSession that lazily checks out its connection, with retry. + + The pool checkout — and any pooler-rejection retry via + ``acquire_connection_with_retry`` — happens on the FIRST DB-touching call, + not at construction. So a request handler that does non-DB work (embedding, + file processing, an LLM call) before its first query does NOT pin a + connection across that work, while still getting checkout retry on the + request path. + + Only the checkout is retried; the SQL statement itself runs exactly once + (we never retry ``super().execute`` after a broad OperationalError), so + writes are never duplicated. The context for tracing is read from the + ``request_context`` ContextVar, which the request/task scope has already set. + """ + + # Class-level default; per-instance assignment shadows it (the subclass has + # a __dict__ even though AsyncSession declares __slots__). + _honcho_acquired: bool = False + + async def _ensure_acquired(self) -> None: + if self._honcho_acquired: + return + context = request_context.get() or "unknown" + await acquire_connection_with_retry(self, context) + self._honcho_acquired = True + if settings.DB.TRACING: + # Forced checkout already happened above; this rides the same + # connection. super() to avoid re-entering _ensure_acquired. + await super().execute( + text("SELECT set_config('application_name', :name, false)"), + {"name": context}, + ) + + # The overrides below are thin: ensure the connection is checked out (once, + # with retry) before delegating to AsyncSession. They cover every public + # DB-touching async method so the "lazy retry on first DB use" guarantee has + # no holes. Signatures are widened to *args/**kwargs because we only forward; + # call sites are typed against the AsyncSession base, so this does not weaken + # type-checking elsewhere. (connection() is intentionally NOT wrapped — + # acquire_connection_with_retry calls it, so wrapping would recurse.) + async def execute(self, *args: Any, **kwargs: Any) -> Any: + await self._ensure_acquired() + return await super().execute(*args, **kwargs) + + async def scalar(self, *args: Any, **kwargs: Any) -> Any: + await self._ensure_acquired() + return await super().scalar(*args, **kwargs) + + async def scalars(self, *args: Any, **kwargs: Any) -> Any: + await self._ensure_acquired() + return await super().scalars(*args, **kwargs) + + async def get(self, *args: Any, **kwargs: Any) -> Any: + await self._ensure_acquired() + return await super().get(*args, **kwargs) + + async def get_one(self, *args: Any, **kwargs: Any) -> Any: + await self._ensure_acquired() + return await super().get_one(*args, **kwargs) + + async def stream(self, *args: Any, **kwargs: Any) -> Any: + await self._ensure_acquired() + return await super().stream(*args, **kwargs) + + async def stream_scalars(self, *args: Any, **kwargs: Any) -> Any: + await self._ensure_acquired() + return await super().stream_scalars(*args, **kwargs) + + async def flush(self, *args: Any, **kwargs: Any) -> None: + await self._ensure_acquired() + await super().flush(*args, **kwargs) + + async def merge(self, *args: Any, **kwargs: Any) -> Any: + await self._ensure_acquired() + return await super().merge(*args, **kwargs) + + async def refresh(self, *args: Any, **kwargs: Any) -> None: + await self._ensure_acquired() + await super().refresh(*args, **kwargs) + + async def delete(self, *args: Any, **kwargs: Any) -> None: + await self._ensure_acquired() + await super().delete(*args, **kwargs) + + async def commit(self) -> None: + # Ensures the add()->commit() path (autoflush on commit) also retries. + await self._ensure_acquired() + try: + await super().commit() + finally: + # Transaction ended; a later op must re-acquire (and re-wrap retry). + self._honcho_acquired = False + + async def rollback(self) -> None: + try: + await super().rollback() + finally: + self._honcho_acquired = False + + async def close(self) -> None: + try: + await super().close() + finally: + # The connection is released; a reused session must re-acquire. + self._honcho_acquired = False + + async def reset(self) -> None: + try: + await super().reset() + finally: + self._honcho_acquired = False + + SessionLocal = async_sessionmaker( autocommit=False, autoflush=False, expire_on_commit=False, bind=engine, + class_=HonchoAsyncSession, ) + +class DBQueryInflightTracker: + """Tracks statements executing on the wire via SQLAlchemy cursor events. + + Drift-proof: marks ``Connection.info`` when a statement starts and clears it + on completion OR error, so the gauge can't leak upward (an errored statement + skips ``after_cursor_execute``) or go negative (a connect-time error has no + matching start). Bound to a pre-resolved labeled gauge child so the + per-statement hot path does no label resolution. + """ + + # Marker on Connection.info recording that we incremented for the current + # statement, so we decrement exactly once on completion or error. + INFLIGHT_KEY: str = "_honcho_inflight" + + def __init__(self, gauge_child: Any) -> None: + self._child: Any = gauge_child + + def on_before(self, conn: Any, *_: Any) -> None: + try: + conn.info[self.INFLIGHT_KEY] = True + self._child.inc() + except Exception: + logger.debug("in-flight gauge inc failed", exc_info=True) + + def on_after(self, conn: Any, *_: Any) -> None: + try: + if conn.info.pop(self.INFLIGHT_KEY, False): + self._child.dec() + except Exception: + logger.debug("in-flight gauge dec failed", exc_info=True) + + def on_error(self, exception_context: Any) -> None: + try: + conn = exception_context.connection + if conn is not None and conn.info.pop(self.INFLIGHT_KEY, False): + self._child.dec() + except Exception: + logger.debug("in-flight gauge error-path dec failed", exc_info=True) + + +# Process-wide tracker, created at registration (None until then / if metrics off). +_inflight_tracker: DBQueryInflightTracker | None = None + + +_db_query_instrumentation_registered = False + + +def register_db_query_instrumentation(instance_type: str) -> None: + """Attach per-statement in-flight tracking to the engine (no-op if off). + + Gated on METRICS.ENABLED so there is zero overhead — not even attached event + listeners — when metrics are disabled. Idempotent: repeated calls (e.g. a + re-run lifespan or test startup) won't attach duplicate listeners, which + would double-count in-flight statements. + """ + global _db_instance_type, _inflight_tracker, _db_query_instrumentation_registered + _db_instance_type = instance_type + if not settings.METRICS.ENABLED or _db_query_instrumentation_registered: + return + child = db_queries_in_flight_gauge.labels(instance_type=instance_type) + _inflight_tracker = DBQueryInflightTracker(child) + sync_engine = engine.sync_engine + event.listen(sync_engine, "before_cursor_execute", _inflight_tracker.on_before) + event.listen(sync_engine, "after_cursor_execute", _inflight_tracker.on_after) + event.listen(sync_engine, "handle_error", _inflight_tracker.on_error) + _db_query_instrumentation_registered = True + + # Define your naming convention convention = { "ix": "ix_%(table_name)s_%(column_0_N_name)s", # Index - supports multi-column diff --git a/src/dependencies.py b/src/dependencies.py index 66b1931e..cd7007e0 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -2,25 +2,22 @@ import uuid from contextlib import asynccontextmanager from fastapi import Depends -from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession -from src.config import settings from src.db import SessionLocal, request_context async def get_db(): - """FastAPI Dependency Generator for Database""" - - context = request_context.get() or "unknown" + """FastAPI Dependency Generator for Database. + The session is lazy: it does NOT check out a pooled connection here. + HonchoAsyncSession acquires it (with retry) on the first DB-touching call, + so a handler doing non-DB work (embedding/file/LLM) before its first query + does not pin a connection across it. Tracing's application_name and the + checkout retry both live in the session, keyed off request_context. + """ db: AsyncSession = SessionLocal() try: - if settings.DB.TRACING: - await db.execute( - text("SELECT set_config('application_name', :name, false)"), - {"name": context}, - ) yield db except Exception: await db.rollback() @@ -30,14 +27,19 @@ async def get_db(): # is closed before the TCP connection drops. Supavisor v2 does NOT # clean up orphaned transactions on client disconnect in transaction- # pooling mode, so relying on `in_transaction()` (Python-side state) - # can leave the backend pinned with an open BEGIN. + # can leave the backend pinned with an open BEGIN. (Cheap no-op if the + # lazy session never checked out a connection.) await db.rollback() await db.close() @asynccontextmanager async def tracked_db(operation_name: str | None = None): - """Context manager for tracked database sessions""" + """Context manager for tracked database sessions. + + Sets a task-scoped request_context so the lazy session picks it up for + tracing/attribution, then yields a lazy session (see get_db). + """ # Get request ID if available, or create operation-specific one context = request_context.get() token = None @@ -46,16 +48,8 @@ async def tracked_db(operation_name: str | None = None): context = f"task:{operation_name}:{str(uuid.uuid4())[:8]}" token = request_context.set(context) - # Create session with tracking info db = SessionLocal() - try: - if settings.DB.TRACING: - await db.execute( - text("SELECT set_config('application_name', :name, false)"), - {"name": context or f"task:{operation_name}"}, - ) - yield db except Exception: await db.rollback() diff --git a/src/deriver/__main__.py b/src/deriver/__main__.py index 20ffbb66..c56ed6a0 100644 --- a/src/deriver/__main__.py +++ b/src/deriver/__main__.py @@ -6,9 +6,13 @@ import uvloop from prometheus_client import start_http_server from src.config import settings -from src.db import engine +from src.db import engine, register_db_query_instrumentation from src.startup import validate_embedding_schema -from src.telemetry import initialize_telemetry_async, shutdown_telemetry +from src.telemetry import ( + initialize_telemetry_async, + register_db_pool_collector, + shutdown_telemetry, +) from .queue_manager import main @@ -18,6 +22,9 @@ logger = logging.getLogger(__name__) def start_metrics_server() -> None: """Start the Prometheus metrics HTTP server on port 9090.""" start_http_server(9090) + # Expose DB connection-pool stats for this deriver instance. + register_db_pool_collector("deriver") + register_db_query_instrumentation("deriver") logger.info("Prometheus metrics server started on port 9090") diff --git a/src/deriver/queue_manager.py b/src/deriver/queue_manager.py index 8c2b5850..22716cba 100644 --- a/src/deriver/queue_manager.py +++ b/src/deriver/queue_manager.py @@ -11,6 +11,7 @@ import sentry_sdk from dotenv import load_dotenv from nanoid import generate as generate_nanoid from sentry_sdk.integrations.asyncio import AsyncioIntegration +from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sqlalchemy import and_, delete, or_, select, update from sqlalchemy.dialects.postgresql import insert from sqlalchemy.engine import CursorResult @@ -125,6 +126,12 @@ class QueueManager: self.worker_ownership: dict[str, WorkerOwnership] = {} self.queue_empty_flag: asyncio.Event = asyncio.Event() + # Current adaptive polling interval; grows while idle/erroring and + # resets to the base interval as soon as work is claimed. + self._current_poll_interval: float = ( + settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS + ) + # Initialize from settings self.workers: int = settings.DERIVER.WORKERS self.semaphore: asyncio.Semaphore = asyncio.Semaphore(self.workers) @@ -147,7 +154,9 @@ class QueueManager: # Initialize Sentry if enabled, using settings if settings.SENTRY.ENABLED: - initialize_sentry(integrations=[AsyncioIntegration()]) + initialize_sentry( + integrations=[AsyncioIntegration(), SqlalchemyIntegration()] + ) def add_task(self, task: asyncio.Task[None]) -> None: """Track a new task""" @@ -378,18 +387,36 @@ class QueueManager: ) return claimed_mapping + def _reset_poll_interval(self) -> None: + """Snap the polling interval back to the base after finding work.""" + self._current_poll_interval = settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS + + def _advance_poll_interval(self) -> float: + """Return the current idle/backoff sleep, then grow it toward the cap.""" + interval = self._current_poll_interval + if settings.DERIVER.POLLING_BACKOFF_ENABLED: + self._current_poll_interval = min( + self._current_poll_interval + * settings.DERIVER.POLLING_BACKOFF_MULTIPLIER, + settings.DERIVER.POLLING_SLEEP_MAX_INTERVAL_SECONDS, + ) + return interval + async def polling_loop(self) -> None: """Main polling loop to find and process new work units""" logger.debug("Starting polling loop") try: while not self.shutdown_event.is_set(): if self.queue_empty_flag.is_set(): - # logger.debug("Queue empty flag set, waiting") - await asyncio.sleep(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS) + # The empty-poll branch below already slept this cycle's + # interval; just clear the flag and re-query (no second + # sleep — that would double the effective idle interval). self.queue_empty_flag.clear() continue - # Check if we have capacity before querying + # Check if we have capacity before querying. There is work to do + # (workers are busy), so keep the base interval for fast pickup + # when capacity frees rather than backing off. if self.semaphore.locked(): # logger.debug("All workers busy, waiting") await asyncio.sleep(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS) @@ -399,6 +426,7 @@ class QueueManager: await self.cleanup_stale_work_units() claimed_work_units = await self.get_and_claim_work_units() if claimed_work_units: + self._reset_poll_interval() for work_unit_key, aqs_id in claimed_work_units.items(): # Create a new task for processing this work unit if not self.shutdown_event.is_set(): @@ -414,15 +442,14 @@ class QueueManager: self.add_task(task) else: self.queue_empty_flag.set() - await asyncio.sleep( - settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS - ) + await asyncio.sleep(self._advance_poll_interval()) except Exception as e: logger.exception("Error in polling loop") if settings.SENTRY.ENABLED: sentry_sdk.capture_exception(e) - # Note: rollback is handled by tracked_db dependency - await asyncio.sleep(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS) + # Note: rollback is handled by tracked_db dependency. + # Back off so a down/saturated DB isn't hammered every cycle. + await asyncio.sleep(self._advance_poll_interval()) finally: logger.info("Polling loop stopped") diff --git a/src/main.py b/src/main.py index d08d1164..6edd9d4c 100644 --- a/src/main.py +++ b/src/main.py @@ -13,12 +13,13 @@ from fastapi.responses import JSONResponse from fastapi_pagination import add_pagination from pydantic import ValidationError from sentry_sdk.integrations.fastapi import FastApiIntegration +from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.starlette import StarletteIntegration from src._version import HONCHO_VERSION from src.cache.client import close_cache, init_cache from src.config import settings -from src.db import engine, request_context +from src.db import engine, register_db_query_instrumentation, request_context from src.exceptions import HonchoException from src.routers import ( conclusions, @@ -34,6 +35,7 @@ from src.telemetry import ( initialize_telemetry_async, metrics_endpoint, prometheus_metrics, + register_db_pool_collector, shutdown_telemetry, ) from src.telemetry.logging import get_route_template @@ -117,6 +119,9 @@ if SENTRY_ENABLED: FastApiIntegration( transaction_style="endpoint", ), + # Explicit so DB-query spans (and our db.pool.acquire span) are not + # reliant on auto-enabling. + SqlalchemyIntegration(), ], before_send=before_send, ) @@ -127,6 +132,10 @@ async def lifespan(_: FastAPI): # Initialize CloudEvents telemetry await initialize_telemetry_async() + # Expose DB connection-pool stats for this API instance (no-op if metrics off) + register_db_pool_collector("api") + register_db_query_instrumentation("api") + # Validate embedding schema before serving any traffic. Fails closed: if # the configured EMBEDDING_VECTOR_DIMENSIONS does not match the physical # pgvector columns, the process refuses to start rather than silently diff --git a/src/telemetry/__init__.py b/src/telemetry/__init__.py index 401be523..abbc5df0 100644 --- a/src/telemetry/__init__.py +++ b/src/telemetry/__init__.py @@ -12,13 +12,18 @@ This module consolidates all telemetry, metrics, and observability functionality """ from src.telemetry.events import emit -from src.telemetry.prometheus import metrics_endpoint, prometheus_metrics +from src.telemetry.prometheus import ( + metrics_endpoint, + prometheus_metrics, + register_db_pool_collector, +) __all__ = [ "emit", "initialize_telemetry_async", "metrics_endpoint", "prometheus_metrics", + "register_db_pool_collector", "shutdown_telemetry", ] diff --git a/src/telemetry/prometheus/__init__.py b/src/telemetry/prometheus/__init__.py index ab0a2616..876ea110 100644 --- a/src/telemetry/prometheus/__init__.py +++ b/src/telemetry/prometheus/__init__.py @@ -14,6 +14,7 @@ from src.telemetry.prometheus.metrics import ( TokenTypes, metrics_endpoint, prometheus_metrics, + register_db_pool_collector, ) __all__ = [ @@ -23,4 +24,5 @@ __all__ = [ "TokenTypes", "metrics_endpoint", "prometheus_metrics", + "register_db_pool_collector", ] diff --git a/src/telemetry/prometheus/metrics.py b/src/telemetry/prometheus/metrics.py index 90082b85..1a79c634 100644 --- a/src/telemetry/prometheus/metrics.py +++ b/src/telemetry/prometheus/metrics.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from collections.abc import Iterator from enum import Enum from typing import cast, final @@ -14,6 +15,7 @@ from prometheus_client import ( disable_created_metrics, generate_latest, ) +from prometheus_client.core import GaugeMetricFamily from starlette.requests import Request from starlette.responses import Response @@ -125,6 +127,23 @@ telemetry_buffer_size_gauge = NamespacedGauge( ["namespace"], ) +# DB connection-pool health. The acquisitions counter measures how often we hit +# (and retry through) transaction-pooler saturation; the in-flight gauge counts +# statements actually executing on the wire, so checked_out minus in_flight +# reveals connections held but parked (the "idle in transaction during an +# external call" antipattern). +db_connection_acquisitions_counter = NamespacedCounter( + "db_connection_acquisitions", + "DB connection acquisitions by outcome (ok=first try, retried, exhausted)", + ["namespace", "instance_type", "outcome"], +) + +db_queries_in_flight_gauge = NamespacedGauge( + "db_queries_in_flight", + "DB statements currently executing on a connection for this instance", + ["namespace", "instance_type"], +) + @final class PrometheusMetrics: @@ -275,10 +294,70 @@ class PrometheusMetrics: except Exception as e: self._handle_metric_error("set_telemetry_buffer_size", e) + def record_db_connection_acquisition( + self, *, instance_type: str, outcome: str + ) -> None: + # outcome is one of "ok" | "retried" | "exhausted". + try: + db_connection_acquisitions_counter.labels( + instance_type=instance_type, + outcome=outcome, + ).inc() + except Exception as e: + self._handle_metric_error("record_db_connection_acquisition", e) + prometheus_metrics = PrometheusMetrics() +class DBPoolCollector: + """Scrape-time collector for SQLAlchemy connection-pool stats. + + Computed live on each /metrics scrape from the async engine's pool, so it + is always current with no background task or sampling lag. One instance is + registered per process (the API server or a deriver worker). + """ + + def __init__(self, instance_type: str) -> None: + # instance_type: "api" | "deriver" + self.instance_type: str = instance_type + + def collect(self) -> Iterator[GaugeMetricFamily]: + namespace = settings.METRICS.NAMESPACE or "" + gauge = GaugeMetricFamily( + "db_pool_connections", + "DB connections held by this instance, by pool state", + labels=["namespace", "instance_type", "state"], + ) + # Fail soft: Prometheus aborts the entire scrape (dropping ALL metrics) + # if any collector raises, so never let a pool/import hiccup here sink + # the whole /metrics response. + try: + # Lazy import to avoid an import cycle at module load (db imports + # config, telemetry is imported widely). Reads engine.pool directly. + from src.db import get_pool_stats + + stats = get_pool_stats() + except Exception: + logger.warning("Failed to collect DB pool stats", exc_info=True) + stats = {} + for state, value in stats.items(): + gauge.add_metric([namespace, self.instance_type, state], value) + yield gauge + + +_db_pool_collector_registered = False + + +def register_db_pool_collector(instance_type: str) -> None: + """Register the DB pool collector once per process (no-op if metrics off).""" + global _db_pool_collector_registered + if _db_pool_collector_registered or not settings.METRICS.ENABLED: + return + REGISTRY.register(DBPoolCollector(instance_type)) + _db_pool_collector_registered = True + + async def metrics_endpoint(_request: Request) -> Response: if not settings.METRICS.ENABLED: return Response("Metrics are disabled", status_code=404) diff --git a/tests/test_db_resilience.py b/tests/test_db_resilience.py new file mode 100644 index 00000000..a82f307a --- /dev/null +++ b/tests/test_db_resilience.py @@ -0,0 +1,370 @@ +"""Unit tests for DB connection resilience + observability. + +These are DB-free: they exercise the retry helper against a fake session, the +deriver polling backoff math, and the in-flight gauge listeners directly. +""" + +from types import SimpleNamespace +from typing import Any + +import pytest +from sqlalchemy.exc import OperationalError +from sqlalchemy.ext.asyncio import AsyncSession + +import src.db as db_module +from src.config import settings +from src.db import DBQueryInflightTracker, acquire_connection_with_retry +from src.telemetry.prometheus.metrics import ( + db_connection_acquisitions_counter, + db_queries_in_flight_gauge, +) + + +def _make_operational_error() -> OperationalError: + """A stand-in for how a saturated pooler surfaces ('too many clients').""" + return OperationalError("SELECT 1", {}, Exception("too many clients")) + + +class _FlakyConnSession: + """Fake AsyncSession whose connection() fails N times then succeeds.""" + + def __init__(self, fail_times: int, *, always_fail: bool = False) -> None: + self.fail_times: int = fail_times + self.always_fail: bool = always_fail + self.calls: int = 0 + self.rollback_calls: int = 0 + + async def connection(self) -> None: + self.calls += 1 + if self.always_fail or self.calls <= self.fail_times: + raise _make_operational_error() + + async def rollback(self) -> None: + self.rollback_calls += 1 + + +def _acq_count(outcome: str) -> float: + child = db_connection_acquisitions_counter.labels( + instance_type="api", outcome=outcome + ) + return float(child._value.get()) # pyright: ignore[reportPrivateUsage, reportUnknownArgumentType] + + +@pytest.fixture +def metrics_on(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(settings.METRICS, "ENABLED", True) + monkeypatch.setattr(settings.METRICS, "NAMESPACE", "test") + monkeypatch.setattr(db_module, "_db_instance_type", "api") + # Fast, deterministic backoff so retry tests don't sleep. + monkeypatch.setattr(settings.DB, "CONNECTION_RETRY_ENABLED", True) + monkeypatch.setattr(settings.DB, "CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS", 0.001) + monkeypatch.setattr(settings.DB, "CONNECTION_RETRY_BACKOFF_MAX_SECONDS", 0.01) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("metrics_on") +async def test_acquire_succeeds_first_try_records_ok() -> None: + before = _acq_count("ok") + session = _FlakyConnSession(fail_times=0) + await acquire_connection_with_retry(session, "request:test") # pyright: ignore[reportArgumentType] + assert session.calls == 1 + assert _acq_count("ok") == before + 1 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("metrics_on") +async def test_acquire_retries_then_succeeds_records_retried() -> None: + before = _acq_count("retried") + session = _FlakyConnSession(fail_times=2) + await acquire_connection_with_retry(session, "request:test") # pyright: ignore[reportArgumentType] + assert session.calls == 3 # two failures then success + # Session is reset after each failed checkout before the next attempt. + assert session.rollback_calls == 2 + assert _acq_count("retried") == before + 1 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("metrics_on") +async def test_acquire_exhausts_budget_reraises_and_records( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Tiny budget so the loop gives up quickly under sustained failure. + monkeypatch.setattr(settings.DB, "CONNECTION_RETRY_MAX_DELAY_SECONDS", 0.05) + before = _acq_count("exhausted") + session = _FlakyConnSession(fail_times=0, always_fail=True) + with pytest.raises(OperationalError): + await acquire_connection_with_retry(session, "request:test") # pyright: ignore[reportArgumentType] + assert session.calls >= 1 + assert _acq_count("exhausted") == before + 1 + + +@pytest.mark.asyncio +async def test_acquire_disabled_calls_once(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings.DB, "CONNECTION_RETRY_ENABLED", False) + session = _FlakyConnSession(fail_times=0) + await acquire_connection_with_retry(session, "request:test") # pyright: ignore[reportArgumentType] + assert session.calls == 1 + + +# --- HonchoAsyncSession: lazy checkout with retry on first DB use ------------ + + +@pytest.mark.asyncio +async def test_session_lazy_acquires_on_first_db_use( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No checkout at construction; acquisition (with retry) happens once on the + first execute, and the statement itself runs exactly once.""" + monkeypatch.setattr(settings.DB, "TRACING", False) + acquired: list[str] = [] + executed: list[Any] = [] + + async def fake_acquire(_session: Any, context: str) -> None: + acquired.append(context) + + async def fake_super_execute(_self: Any, *args: Any, **_kw: Any) -> str: + executed.append(args[0] if args else None) + return "result" + + monkeypatch.setattr(db_module, "acquire_connection_with_retry", fake_acquire) + monkeypatch.setattr(AsyncSession, "execute", fake_super_execute) + + session = db_module.SessionLocal() + assert session._honcho_acquired is False # pyright: ignore[reportPrivateUsage] + assert acquired == [] # constructing the session does NOT check out + + result = await session.execute("SELECT 1") + assert result == "result" + assert acquired == ["unknown"] # acquired exactly once, on first use + assert session._honcho_acquired is True # pyright: ignore[reportPrivateUsage] + assert executed == ["SELECT 1"] # statement ran once (not retried) + + await session.execute("SELECT 2") + assert acquired == ["unknown"] # still once — no re-acquire on later use + + +@pytest.mark.asyncio +async def test_session_tracing_sets_application_name_on_acquire( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings.DB, "TRACING", True) + calls: list[tuple[Any, ...]] = [] + + async def fake_acquire(_session: Any, _context: str) -> None: + return None + + async def fake_super_execute(_self: Any, *args: Any, **_kw: Any) -> None: + calls.append(args) + + monkeypatch.setattr(db_module, "acquire_connection_with_retry", fake_acquire) + monkeypatch.setattr(AsyncSession, "execute", fake_super_execute) + + token = db_module.request_context.set("request:trace-ctx") + try: + session = db_module.SessionLocal() + await session.execute("SELECT 1") + finally: + db_module.request_context.reset(token) + + # First super().execute is the set_config, then the real statement. + assert any("set_config" in str(c[0]) for c in calls) + set_config_call = next(c for c in calls if "set_config" in str(c[0])) + assert set_config_call[1] == {"name": "request:trace-ctx"} + + +@pytest.mark.asyncio +async def test_session_lifecycle_methods_reset_acquired_flag( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_acquire(_session: Any, _context: str) -> None: + return None + + async def noop(_self: Any) -> None: + return None + + monkeypatch.setattr(db_module, "acquire_connection_with_retry", fake_acquire) + for method in ("commit", "rollback", "close", "reset"): + monkeypatch.setattr(AsyncSession, method, noop) + + session = db_module.SessionLocal() + await session.commit() # ensures acquired, commits, then resets + assert session._honcho_acquired is False # pyright: ignore[reportPrivateUsage] + + # rollback/close/reset must each clear the flag so a reused session + # re-acquires (and re-wraps retry) on its next DB use. + for method in ("rollback", "close", "reset"): + session._honcho_acquired = True # pyright: ignore[reportPrivateUsage] + await getattr(session, method)() + assert session._honcho_acquired is False # pyright: ignore[reportPrivateUsage] + + +@pytest.mark.asyncio +async def test_session_get_and_delete_also_acquire( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The lazy-acquire guarantee covers get/delete, not just execute.""" + monkeypatch.setattr(settings.DB, "TRACING", False) + acquired: list[str] = [] + + async def fake_acquire(_session: Any, context: str) -> None: + acquired.append(context) + + async def fake_get(_self: Any, *_a: Any, **_k: Any) -> str: + return "row" + + async def fake_delete(_self: Any, *_a: Any, **_k: Any) -> None: + return None + + monkeypatch.setattr(db_module, "acquire_connection_with_retry", fake_acquire) + monkeypatch.setattr(AsyncSession, "get", fake_get) + monkeypatch.setattr(AsyncSession, "delete", fake_delete) + + session = db_module.SessionLocal() + await session.get(object, 1) + await session.delete(object()) + assert acquired == ["unknown"] # acquired once on the first DB-touching call + + +@pytest.mark.asyncio +async def test_get_db_does_not_acquire_at_dependency_entry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from src.dependencies import get_db as real_get_db + + acquired: list[str] = [] + + async def fake_acquire(_session: Any, context: str) -> None: + acquired.append(context) + + monkeypatch.setattr(db_module, "acquire_connection_with_retry", fake_acquire) + + dep_gen = real_get_db() + await anext(dep_gen) + assert acquired == [] # yielding the session must not check out + await dep_gen.aclose() + assert acquired == [] # finally rollback/close must not check out either + + +def test_polling_backoff_sequence_and_reset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_ENABLED", True) + monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_INTERVAL_SECONDS", 1.0) + monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_MULTIPLIER", 2.0) + monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_MAX_INTERVAL_SECONDS", 30.0) + + from src.deriver.queue_manager import QueueManager + + qm = QueueManager() + seq = [qm._advance_poll_interval() for _ in range(8)] # pyright: ignore[reportPrivateUsage] + assert seq == [1.0, 2.0, 4.0, 8.0, 16.0, 30.0, 30.0, 30.0] # caps at max + + qm._reset_poll_interval() # pyright: ignore[reportPrivateUsage] + assert qm._advance_poll_interval() == 1.0 # pyright: ignore[reportPrivateUsage] + + +def test_polling_backoff_disabled_stays_constant( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_ENABLED", False) + monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_INTERVAL_SECONDS", 1.0) + + from src.deriver.queue_manager import QueueManager + + qm = QueueManager() + assert [qm._advance_poll_interval() for _ in range(3)] == [1.0, 1.0, 1.0] # pyright: ignore[reportPrivateUsage] + + +def test_pool_timeout_must_be_under_retry_budget() -> None: + """A pooled QueuePool checkout that can block past the retry budget is a + contradiction (no retry ever happens) and must fail config validation.""" + from src.config import DBSettings + + # Contradiction: pooled + retry on, but POOL_TIMEOUT >= budget. + with pytest.raises(ValueError, match="DB_POOL_TIMEOUT"): + DBSettings( + POOL_CLASS="default", + POOL_TIMEOUT=30, + CONNECTION_RETRY_ENABLED=True, + CONNECTION_RETRY_MAX_DELAY_SECONDS=10.0, + ) + + # NullPool has no queue wait, so POOL_TIMEOUT is irrelevant -> allowed. + DBSettings( + POOL_CLASS="null", + POOL_TIMEOUT=30, + CONNECTION_RETRY_ENABLED=True, + CONNECTION_RETRY_MAX_DELAY_SECONDS=10.0, + ) + + +@pytest.mark.asyncio +async def test_polling_loop_idle_sleeps_once_per_cycle( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Drive the real loop on an empty queue: exactly one (growing, capped) + sleep per empty poll — no double-sleep from the queue_empty_flag branch.""" + monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_ENABLED", True) + monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_INTERVAL_SECONDS", 1.0) + monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_MULTIPLIER", 2.0) + monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_MAX_INTERVAL_SECONDS", 8.0) + + import asyncio + + from src.deriver import queue_manager as qm_mod + + qm = qm_mod.QueueManager() + sleeps: list[float] = [] + polls = {"n": 0} + + async def fake_cleanup() -> None: + return None + + async def fake_claim() -> dict[str, str]: + polls["n"] += 1 + if polls["n"] >= 5: + qm.shutdown_event.set() # stop after 5 empty polls + return {} + + async def fake_sleep(seconds: float) -> None: + sleeps.append(seconds) + + monkeypatch.setattr(qm, "cleanup_stale_work_units", fake_cleanup) + monkeypatch.setattr(qm, "get_and_claim_work_units", fake_claim) + # queue_manager calls asyncio.sleep on the stdlib module; patch it there. + monkeypatch.setattr(asyncio, "sleep", fake_sleep) + + await qm.polling_loop() + + # One sleep per empty poll, growing 1->2->4->8 then capped at 8 (not doubled). + assert sleeps == [1.0, 2.0, 4.0, 8.0, 8.0] + + +def test_inflight_gauge_no_drift(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings.METRICS, "NAMESPACE", "test") + child: Any = db_queries_in_flight_gauge.labels(instance_type="api") + tracker = DBQueryInflightTracker(child) + key = DBQueryInflightTracker.INFLIGHT_KEY + + def value() -> float: + return float(child._value.get()) + + start = value() + conn = SimpleNamespace(info={}) + + # Normal execute: before -> after returns to baseline. + tracker.on_before(conn) + assert value() == start + 1 + assert conn.info[key] is True + tracker.on_after(conn) + assert value() == start + assert key not in conn.info + + # Errored execute: before -> on_error decrements (after never fires). + tracker.on_before(conn) + assert value() == start + 1 + tracker.on_error(SimpleNamespace(connection=conn)) + assert value() == start + + # on_error without a matching before (e.g. connect error) must not push the + # gauge negative. + tracker.on_error(SimpleNamespace(connection=SimpleNamespace(info={}))) + assert value() == start diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index c90643ec..2996cddc 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -16,6 +16,12 @@ class FakeSession: self.execute_calls: list[tuple[Any, ...]] = [] self.rollback_calls: int = 0 self.close_calls: int = 0 + self.connection_calls: int = 0 + + async def connection(self) -> None: + # Tracks checkout attempts so tests can assert get_db/tracked_db stay + # lazy (they should never force a checkout themselves). + self.connection_calls += 1 async def execute(self, statement: Any, params: Any = None) -> None: self.execute_calls.append((statement, params)) @@ -31,26 +37,24 @@ class FakeSession: @pytest.mark.asyncio -async def test_get_db_sets_application_name_when_tracing_enabled( +async def test_get_db_yields_lazily_without_checkout_or_tracing( monkeypatch: pytest.MonkeyPatch, ) -> None: + # get_db must NOT touch the connection or run set_config itself — those now + # happen lazily inside HonchoAsyncSession on first DB use, so a handler doing + # non-DB work before its first query never pins a connection. fake_db = FakeSession() monkeypatch.setattr(dependencies_module, "SessionLocal", lambda: fake_db) - monkeypatch.setattr(settings.DB, "TRACING", True) + monkeypatch.setattr(settings.DB, "TRACING", True) # still no set_config here - context_token = request_context.set("request:test-ctx") dep_gen = real_get_db() - try: db = await anext(dep_gen) assert db is fake_db - assert len(fake_db.execute_calls) == 1 - stmt, params = fake_db.execute_calls[0] - assert "set_config" in str(stmt) - assert params == {"name": "request:test-ctx"} + assert fake_db.connection_calls == 0 # no eager checkout + assert fake_db.execute_calls == [] # no set_config in get_db finally: await dep_gen.aclose() - request_context.reset(context_token) assert fake_db.rollback_calls == 1 # unconditional rollback in finally assert fake_db.close_calls == 1 @@ -80,7 +84,6 @@ async def test_tracked_db_creates_and_resets_task_context( ) -> None: fake_db = FakeSession() monkeypatch.setattr(dependencies_module, "SessionLocal", lambda: fake_db) - monkeypatch.setattr(settings.DB, "TRACING", True) monkeypatch.setattr( uuid, "uuid4", @@ -90,15 +93,12 @@ async def test_tracked_db_creates_and_resets_task_context( clear_token = request_context.set(None) try: async with real_tracked_db("cleanup_job"): + # tracked_db sets the task context so the lazy session can read it. assert request_context.get() == "task:cleanup_job:12345678" finally: request_context.reset(clear_token) assert request_context.get() is None - assert len(fake_db.execute_calls) == 1 - stmt, params = fake_db.execute_calls[0] - assert "set_config" in str(stmt) - assert params == {"name": "task:cleanup_job:12345678"} assert fake_db.rollback_calls == 1 # unconditional rollback in finally assert fake_db.close_calls == 1 @@ -109,7 +109,6 @@ async def test_tracked_db_preserves_existing_request_context( ) -> None: fake_db = FakeSession() monkeypatch.setattr(dependencies_module, "SessionLocal", lambda: fake_db) - monkeypatch.setattr(settings.DB, "TRACING", True) context_token = request_context.set("request:existing") try: @@ -118,10 +117,6 @@ async def test_tracked_db_preserves_existing_request_context( finally: request_context.reset(context_token) - assert len(fake_db.execute_calls) == 1 - stmt, params = fake_db.execute_calls[0] - assert "set_config" in str(stmt) - assert params == {"name": "request:existing"} assert fake_db.rollback_calls == 1 # unconditional rollback in finally assert fake_db.close_calls == 1 From bb6dad9157b7aae07de802ebca84d340261016eb Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:37:05 -0400 Subject: [PATCH 02/65] v3.0.8 Release Candidate (#763) * chore(docs): Update changelogs for v3.0.8 * chore: update configuration docs --- .env.template | 13 ++++++++++- CHANGELOG.md | 28 ++++++++++++++++++++++++ config.toml.example | 7 ++++++ docs/changelog/compatibility-guide.mdx | 3 ++- docs/changelog/introduction.mdx | 30 +++++++++++++++++++++++++- docs/docs.json | 2 +- docs/v3/contributing/configuration.mdx | 6 ++++++ pyproject.toml | 2 +- uv.lock | 4 ++-- 9 files changed, 88 insertions(+), 7 deletions(-) diff --git a/.env.template b/.env.template index c13040cb..a4ae6c96 100644 --- a/.env.template +++ b/.env.template @@ -44,12 +44,18 @@ DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres # DB_POOL_CLASS=default # DB_POOL_SIZE=10 # DB_MAX_OVERFLOW=20 -# DB_POOL_TIMEOUT=30 +# DB_POOL_TIMEOUT=5 # seconds; must stay under DB_CONNECTION_RETRY_MAX_DELAY_SECONDS for a pooled (non-null) DB_POOL_CLASS # DB_POOL_RECYCLE=300 # DB_POOL_PRE_PING=true # DB_POOL_USE_LIFO=true # DB_SQL_DEBUG=false # DB_TRACING=false +# Bounded exponential-backoff retry around connection checkout (guards against +# transient transaction-pooler saturation). Applied lazily on first DB use. +# DB_CONNECTION_RETRY_ENABLED=true +# DB_CONNECTION_RETRY_MAX_DELAY_SECONDS=10.0 +# DB_CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS=0.1 +# DB_CONNECTION_RETRY_BACKOFF_MAX_SECONDS=2.0 # ============================================================================= # Authentication Settings @@ -104,6 +110,11 @@ LLM_OPENAI_API_KEY=your-api-key-here # DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1 # DERIVER_WORKERS=1 # DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0 +# Adaptive polling: grows the idle/error sleep from the base toward the max by +# the multiplier each cycle, snapping back to base when work is found. +# DERIVER_POLLING_BACKOFF_ENABLED=true +# DERIVER_POLLING_SLEEP_MAX_INTERVAL_SECONDS=30.0 +# DERIVER_POLLING_BACKOFF_MULTIPLIER=2.0 # DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5 # DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000 # 30 days # DERIVER_MODEL_CONFIG__TEMPERATURE= diff --git a/CHANGELOG.md b/CHANGELOG.md index 60085e5e..b4552f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [3.0.8] - 2026-06-01 + +### Added + +- Connection-checkout retry with bounded exponential backoff (tenacity) on `get_db`/`tracked_db`: transient transaction-pooler (Supavisor) rejections — SQLAlchemy `TimeoutError` and `OperationalError` — now retry with backoff instead of surfacing as 500s under client-connection saturation. Gated by + `DB_CONNECTION_RETRY_ENABLED` with configurable delay/backoff knobs; ~10s default budget (#758) +- `HonchoAsyncSession` — a lazy `AsyncSession` that checks out its pooled connection (with retry) on the first DB-touching call rather than at construction. Request handlers doing non-DB work (embedding, file, LLM) before their first query no longer pin a pooler connection across it. Only the checkout is retried; + the statement still runs exactly once, so writes are never duplicated (#758) +- Adaptive deriver queue polling: the poll interval backs off when the queue is idle or erroring (base → max, doubling each cycle) and snaps back to base the moment work is claimed, cutting steady-state query load against the DB. Gated by `DERIVER_POLLING_BACKOFF_ENABLED` with configurable max/multiplier (#758) +- New Prometheus `db_pool_connections` gauge (checked_out / checked_in / size / overflow), labeled `api`|`deriver`, registered in both the API lifespan and the deriver metrics server (#758) +- New Prometheus `db_connection_acquisitions{outcome=ok|retried|exhausted}` counter — the alertable early-warning signal that connection checkouts are retrying through pooler rejection, before requests start failing (#758) +- New Prometheus `db_queries_in_flight` gauge — statements actually executing on the wire (via SQLAlchemy cursor-execute events). Paired with `checked_out`, the gap reveals connections held but parked (the "idle in transaction during an external call" antipattern). Gated on `METRICS.ENABLED` for zero overhead when + off (#758) +- Explicit `SqlalchemyIntegration` in both the API and deriver Sentry inits; connection acquisition wrapped in a `db.pool.acquire` span with live pool stats captured on retry exhaustion (#758) + +### Changed + +- Default `POOL_TIMEOUT` lowered to 5s, with validation that it stays under the connection-retry budget when a pooled (non-null) `POOL_CLASS` is configured; `config.toml.example` and the v2/v3 configuration docs updated to match (#758) +- `HonchoAsyncSession` wraps every DB-touching session method (execute / scalar / scalars / flush / merge / refresh / commit / get / get_one / stream / stream_scalars / delete) so the lazy-checkout-with-retry guarantee has no holes; the acquired flag resets on `close()`/`reset()` so a reused session re-acquires on + next use (#758) + +### Fixed + +- Roll the session back on a retryable checkout failure before retrying — a failed autobegin could otherwise leave it pending-rollback, making the next connection attempt raise instead of cleanly re-checking-out (#758) +- Guard `DBPoolCollector.collect()` so a pool-read/import hiccup can't raise and abort the entire `/metrics` scrape (Prometheus drops all metrics if any collector raises) (#758) +- Clamp the pool overflow gauge to ≥ 0 (it could report negative before the pool fills) (#758) +- Removed a double-sleep in the deriver idle poll so the backoff cap is a true cap rather than 2× (#758) + ## [3.0.7] - 2026-05-21 ### Added diff --git a/config.toml.example b/config.toml.example index 258172a7..0ddf4c23 100644 --- a/config.toml.example +++ b/config.toml.example @@ -88,6 +88,13 @@ model = "text-embedding-3-small" ENABLED = true WORKERS = 1 POLLING_SLEEP_INTERVAL_SECONDS = 1.0 +# Adaptive polling: when idle/erroring, the sleep interval grows from +# POLLING_SLEEP_INTERVAL_SECONDS toward POLLING_SLEEP_MAX_INTERVAL_SECONDS by +# POLLING_BACKOFF_MULTIPLIER each cycle, then snaps back to base when work is +# found. Cuts steady-state query load against the shared DB/pooler. +POLLING_BACKOFF_ENABLED = true +POLLING_SLEEP_MAX_INTERVAL_SECONDS = 30.0 +POLLING_BACKOFF_MULTIPLIER = 2.0 STALE_SESSION_TIMEOUT_MINUTES = 5 # QUEUE_ERROR_RETENTION_SECONDS = 2592000 # 30 days DEDUPLICATE = true diff --git a/docs/changelog/compatibility-guide.mdx b/docs/changelog/compatibility-guide.mdx index f12d3cee..dfd40be1 100644 --- a/docs/changelog/compatibility-guide.mdx +++ b/docs/changelog/compatibility-guide.mdx @@ -30,7 +30,8 @@ This guide helps you match the right SDK version to your Honcho API version. New | Honcho API Version | TypeScript SDK | Python SDK | |-------------------|---------------|------------| -| v3.0.7 (Current) | v2.1.2 | v2.1.2 | +| v3.0.8 (Current) | v2.1.2 | v2.1.2 | +| v3.0.7 | v2.1.2 | v2.1.2 | | v3.0.6 | v2.1.1 | v2.1.1 | | v3.0.5 | v2.1.0 | v2.1.0 | | v3.0.4 | v2.1.0 | v2.1.0 | diff --git a/docs/changelog/introduction.mdx b/docs/changelog/introduction.mdx index 29c15fdf..8e2febcb 100644 --- a/docs/changelog/introduction.mdx +++ b/docs/changelog/introduction.mdx @@ -27,7 +27,35 @@ Welcome to the Honcho changelog! This section documents all notable changes to t ### Honcho API and SDK Changelogs - + + ### Added + + - Connection-checkout retry with bounded exponential backoff (tenacity) on `get_db`/`tracked_db`: transient transaction-pooler (Supavisor) rejections — SQLAlchemy `TimeoutError` and `OperationalError` — now retry with backoff instead of surfacing as 500s under client-connection saturation. Gated by + `DB_CONNECTION_RETRY_ENABLED` with configurable delay/backoff knobs; ~10s default budget (#758) + - `HonchoAsyncSession` — a lazy `AsyncSession` that checks out its pooled connection (with retry) on the first DB-touching call rather than at construction. Request handlers doing non-DB work (embedding, file, LLM) before their first query no longer pin a pooler connection across it. Only the checkout is retried; + the statement still runs exactly once, so writes are never duplicated (#758) + - Adaptive deriver queue polling: the poll interval backs off when the queue is idle or erroring (base → max, doubling each cycle) and snaps back to base the moment work is claimed, cutting steady-state query load against the DB. Gated by `DERIVER_POLLING_BACKOFF_ENABLED` with configurable max/multiplier (#758) + - New Prometheus `db_pool_connections` gauge (checked_out / checked_in / size / overflow), labeled `api`|`deriver`, registered in both the API lifespan and the deriver metrics server (#758) + - New Prometheus `db_connection_acquisitions{outcome=ok|retried|exhausted}` counter — the alertable early-warning signal that connection checkouts are retrying through pooler rejection, before requests start failing (#758) + - New Prometheus `db_queries_in_flight` gauge — statements actually executing on the wire (via SQLAlchemy cursor-execute events). Paired with `checked_out`, the gap reveals connections held but parked (the "idle in transaction during an external call" antipattern). Gated on `METRICS.ENABLED` for zero overhead when + off (#758) + - Explicit `SqlalchemyIntegration` in both the API and deriver Sentry inits; connection acquisition wrapped in a `db.pool.acquire` span with live pool stats captured on retry exhaustion (#758) + + ### Changed + + - Default `POOL_TIMEOUT` lowered to 5s, with validation that it stays under the connection-retry budget when a pooled (non-null) `POOL_CLASS` is configured; `config.toml.example` and the v2/v3 configuration docs updated to match (#758) + - `HonchoAsyncSession` wraps every DB-touching session method (execute / scalar / scalars / flush / merge / refresh / commit / get / get_one / stream / stream_scalars / delete) so the lazy-checkout-with-retry guarantee has no holes; the acquired flag resets on `close()`/`reset()` so a reused session re-acquires on + next use (#758) + + ### Fixed + + - Roll the session back on a retryable checkout failure before retrying — a failed autobegin could otherwise leave it pending-rollback, making the next connection attempt raise instead of cleanly re-checking-out (#758) + - Guard `DBPoolCollector.collect()` so a pool-read/import hiccup can't raise and abort the entire `/metrics` scrape (Prometheus drops all metrics if any collector raises) (#758) + - Clamp the pool overflow gauge to ≥ 0 (it could report negative before the pool fills) (#758) + - Removed a double-sleep in the deriver idle poll so the backoff cap is a true cap rather than 2× (#758) + + + ### Added - New `src/llm/` package as the single owner of provider runtime: clients, backends, history adapters, tool loop, request builder, credentials, and caching policy (#459) diff --git a/docs/docs.json b/docs/docs.json index 64b56d86..47d5089f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -24,7 +24,7 @@ "navigation": { "versions": [ { - "version": "v3.0.7", + "version": "v3.0.8", "api": { "openapi": ["v3/openapi.json"] }, diff --git a/docs/v3/contributing/configuration.mdx b/docs/v3/contributing/configuration.mdx index d7a71b6c..04224e4b 100644 --- a/docs/v3/contributing/configuration.mdx +++ b/docs/v3/contributing/configuration.mdx @@ -354,6 +354,12 @@ DERIVER_MAX_CUSTOM_INSTRUCTIONS_TOKENS=2000 # Worker settings DERIVER_WORKERS=1 # Increase for higher throughput DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0 +# Adaptive polling: when idle/erroring, the sleep interval grows from the base +# toward DERIVER_POLLING_SLEEP_MAX_INTERVAL_SECONDS by the multiplier each cycle, +# then snaps back to base when work is found. Cuts steady-state query load. +DERIVER_POLLING_BACKOFF_ENABLED=true +DERIVER_POLLING_SLEEP_MAX_INTERVAL_SECONDS=30.0 +DERIVER_POLLING_BACKOFF_MULTIPLIER=2.0 DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5 # Queue management diff --git a/pyproject.toml b/pyproject.toml index 31581efb..1079b9eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho" -version = "3.0.7" +version = "3.0.8" description = "Honcho Server" authors = [ {name = "Plastic Labs", email = "hello@plasticlabs.ai"}, diff --git a/uv.lock b/uv.lock index d577957f..a2dac3b8 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-05-16T17:58:57.678125Z" +exclude-newer = "2026-05-27T18:30:19.790621Z" exclude-newer-span = "P5D" [manifest] @@ -1159,7 +1159,7 @@ wheels = [ [[package]] name = "honcho" -version = "3.0.7" +version = "3.0.8" source = { virtual = "." } dependencies = [ { name = "alembic" }, From 9f26fdd2ea2952904fc938f69c414ca95fc59ad2 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:48:36 -0400 Subject: [PATCH 03/65] Deriver Jitter (#765) * fix(deriver): Remove connection retry logic and add jitter to polling interval * chore(docs): Update changelog and document new configurations * chore: increment version numbers --- .env.template | 16 +- CHANGELOG.md | 14 ++ README.md | 2 +- config.toml.example | 18 +- docs/changelog/compatibility-guide.mdx | 3 +- docs/changelog/introduction.mdx | 16 +- docs/docs.json | 2 +- docs/v3/contributing/configuration.mdx | 14 +- pyproject.toml | 2 +- src/config.py | 62 ++--- src/db.py | 257 ++++----------------- src/dependencies.py | 9 +- src/deriver/queue_manager.py | 38 +++- src/main.py | 3 +- src/telemetry/prometheus/metrics.py | 26 +-- tests/deriver/test_queue_processing.py | 60 +++++ tests/test_db_resilience.py | 298 ++++++------------------- tests/test_dependencies.py | 4 +- uv.lock | 4 +- 19 files changed, 298 insertions(+), 550 deletions(-) diff --git a/.env.template b/.env.template index a4ae6c96..f27c81f5 100644 --- a/.env.template +++ b/.env.template @@ -44,18 +44,15 @@ DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres # DB_POOL_CLASS=default # DB_POOL_SIZE=10 # DB_MAX_OVERFLOW=20 -# DB_POOL_TIMEOUT=5 # seconds; must stay under DB_CONNECTION_RETRY_MAX_DELAY_SECONDS for a pooled (non-null) DB_POOL_CLASS +# DB_POOL_TIMEOUT=5 # seconds a pooled checkout waits for a free connection (QueuePool only) # DB_POOL_RECYCLE=300 # DB_POOL_PRE_PING=true # DB_POOL_USE_LIFO=true # DB_SQL_DEBUG=false # DB_TRACING=false -# Bounded exponential-backoff retry around connection checkout (guards against -# transient transaction-pooler saturation). Applied lazily on first DB use. -# DB_CONNECTION_RETRY_ENABLED=true -# DB_CONNECTION_RETRY_MAX_DELAY_SECONDS=10.0 -# DB_CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS=0.1 -# DB_CONNECTION_RETRY_BACKOFF_MAX_SECONDS=2.0 +# Per-connection establish timeout (seconds) so a single connection attempt +# fails fast instead of hanging when the server/pooler is unreachable. +# DB_CONNECT_TIMEOUT_SECONDS=2 # ============================================================================= # Authentication Settings @@ -115,6 +112,11 @@ LLM_OPENAI_API_KEY=your-api-key-here # DERIVER_POLLING_BACKOFF_ENABLED=true # DERIVER_POLLING_SLEEP_MAX_INTERVAL_SECONDS=30.0 # DERIVER_POLLING_BACKOFF_MULTIPLIER=2.0 +# Jitter so instances that start together don't poll in lockstep. Startup: sleep +# a random delay in [0, value] before the first poll (0.0 disables). Per-cycle: +# multiply every poll sleep by a random factor in [1-ratio, 1+ratio] (0.0 disables). +# DERIVER_POLLING_STARTUP_JITTER_SECONDS=30.0 +# DERIVER_POLLING_JITTER_RATIO=0.5 # DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5 # DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000 # 30 days # DERIVER_MODEL_CONFIG__TEMPERATURE= diff --git a/CHANGELOG.md b/CHANGELOG.md index b4552f14..cff3c273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [3.0.9] - 2026-06-02 + +### Changed + +- Connection acquisition is now a single attempt with no server-side retry, on a vanilla `AsyncSession`. A new `DB_CONNECT_TIMEOUT_SECONDS` (default 2s) bounds the attempt so a saturated or unreachable pooler fails fast instead of holding a client connection open to re-knock. A saturated DB now surfaces to the caller — the API returns an error and the deriver backs off and retries on a later poll — which lets the pooler drain rather than amplifying saturation. + +### Added + +- Deriver poll jitter so instances that start together don't poll in lockstep: `DERIVER_POLLING_STARTUP_JITTER_SECONDS` (random delay before the first poll, default 30s) and `DERIVER_POLLING_JITTER_RATIO` (±fraction applied to every poll sleep, default 0.5). Both disable at `0.0`; the underlying backoff schedule is unchanged. + +### Removed + +- Reverted the connection-checkout retry and `HonchoAsyncSession` custom session introduced in 3.0.8. Removed the `DB_CONNECTION_RETRY_ENABLED` / `DB_CONNECTION_RETRY_MAX_DELAY_SECONDS` / `DB_CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS` / `DB_CONNECTION_RETRY_BACKOFF_MAX_SECONDS` settings, the `db_connection_acquisitions{outcome=...}` Prometheus counter, and the `db.pool.acquire` Sentry span. Alerting built on `db_connection_acquisitions` should migrate to `db_pool_connections` / `db_queries_in_flight`. + ## [3.0.8] - 2026-06-01 ### Added diff --git a/README.md b/README.md index 3aa3810a..acf20123 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ --- -![Static Badge](https://img.shields.io/badge/Server-3.0.7-blue) +![Static Badge](https://img.shields.io/badge/Server-3.0.9-blue) [![PyPI version](https://img.shields.io/pypi/v/honcho-ai.svg)](https://pypi.org/project/honcho-ai/) [![NPM version](https://img.shields.io/npm/v/@honcho-ai/sdk.svg)](https://npmjs.org/package/@honcho-ai/sdk) [![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/honcho) diff --git a/config.toml.example b/config.toml.example index 0ddf4c23..dbaf7e3b 100644 --- a/config.toml.example +++ b/config.toml.example @@ -26,18 +26,14 @@ POOL_CLASS = "default" POOL_PRE_PING = true POOL_SIZE = 10 MAX_OVERFLOW = 20 -POOL_TIMEOUT = 5 # seconds; must stay under CONNECTION_RETRY_MAX_DELAY_SECONDS +POOL_TIMEOUT = 5 # seconds a pooled checkout waits for a free connection (QueuePool only) POOL_RECYCLE = 300 # seconds POOL_USE_LIFO = true SQL_DEBUG = false TRACING = false -# Bounded retry around connection checkout (used by background/tracked_db and -# the lazy request session). For a pooled (non-null) POOL_CLASS, POOL_TIMEOUT -# must be < CONNECTION_RETRY_MAX_DELAY_SECONDS or startup validation fails. -CONNECTION_RETRY_ENABLED = true -CONNECTION_RETRY_MAX_DELAY_SECONDS = 10.0 -CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS = 0.1 -CONNECTION_RETRY_BACKOFF_MAX_SECONDS = 2.0 +# Per-connection establish timeout (seconds) so a single connection attempt +# fails fast instead of hanging when the server/pooler is unreachable. +CONNECT_TIMEOUT_SECONDS = 2 # Authentication settings [auth] @@ -95,6 +91,12 @@ POLLING_SLEEP_INTERVAL_SECONDS = 1.0 POLLING_BACKOFF_ENABLED = true POLLING_SLEEP_MAX_INTERVAL_SECONDS = 30.0 POLLING_BACKOFF_MULTIPLIER = 2.0 +# Jitter so instances that start together don't poll in lockstep. Startup: +# sleep a random delay in [0, POLLING_STARTUP_JITTER_SECONDS] before the first +# poll (0.0 disables). Per-cycle: multiply every poll sleep by a random factor +# in [1 - ratio, 1 + ratio] (0.5 -> [0.5x, 1.5x]; 0.0 disables). +POLLING_STARTUP_JITTER_SECONDS = 30.0 +POLLING_JITTER_RATIO = 0.5 STALE_SESSION_TIMEOUT_MINUTES = 5 # QUEUE_ERROR_RETENTION_SECONDS = 2592000 # 30 days DEDUPLICATE = true diff --git a/docs/changelog/compatibility-guide.mdx b/docs/changelog/compatibility-guide.mdx index dfd40be1..d1e225b9 100644 --- a/docs/changelog/compatibility-guide.mdx +++ b/docs/changelog/compatibility-guide.mdx @@ -30,7 +30,8 @@ This guide helps you match the right SDK version to your Honcho API version. New | Honcho API Version | TypeScript SDK | Python SDK | |-------------------|---------------|------------| -| v3.0.8 (Current) | v2.1.2 | v2.1.2 | +| v3.0.9 (Current) | v2.1.2 | v2.1.2 | +| v3.0.8 | v2.1.2 | v2.1.2 | | v3.0.7 | v2.1.2 | v2.1.2 | | v3.0.6 | v2.1.1 | v2.1.1 | | v3.0.5 | v2.1.0 | v2.1.0 | diff --git a/docs/changelog/introduction.mdx b/docs/changelog/introduction.mdx index 8e2febcb..a4778f76 100644 --- a/docs/changelog/introduction.mdx +++ b/docs/changelog/introduction.mdx @@ -27,7 +27,21 @@ Welcome to the Honcho changelog! This section documents all notable changes to t ### Honcho API and SDK Changelogs - + + ### Changed + + - Connection acquisition is now a single attempt with no server-side retry, on a vanilla `AsyncSession`. A new `DB_CONNECT_TIMEOUT_SECONDS` (default 2s) bounds the attempt so a saturated or unreachable pooler fails fast instead of holding a client connection open to re-knock. A saturated DB now surfaces to the caller — the API returns an error and the deriver backs off and retries on a later poll — which lets the pooler drain rather than amplifying saturation. + + ### Added + + - Deriver poll jitter so instances that start together don't poll in lockstep: `DERIVER_POLLING_STARTUP_JITTER_SECONDS` (random delay before the first poll, default 30s) and `DERIVER_POLLING_JITTER_RATIO` (±fraction applied to every poll sleep, default 0.5). Both disable at `0.0`; the underlying backoff schedule is unchanged. + + ### Removed + + - Reverted the connection-checkout retry and `HonchoAsyncSession` custom session introduced in 3.0.8. Removed the `DB_CONNECTION_RETRY_ENABLED` / `DB_CONNECTION_RETRY_MAX_DELAY_SECONDS` / `DB_CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS` / `DB_CONNECTION_RETRY_BACKOFF_MAX_SECONDS` settings, the `db_connection_acquisitions{outcome=...}` Prometheus counter, and the `db.pool.acquire` Sentry span. Alerting built on `db_connection_acquisitions` should migrate to `db_pool_connections` / `db_queries_in_flight`. + + + ### Added - Connection-checkout retry with bounded exponential backoff (tenacity) on `get_db`/`tracked_db`: transient transaction-pooler (Supavisor) rejections — SQLAlchemy `TimeoutError` and `OperationalError` — now retry with backoff instead of surfacing as 500s under client-connection saturation. Gated by diff --git a/docs/docs.json b/docs/docs.json index 47d5089f..4c240e50 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -24,7 +24,7 @@ "navigation": { "versions": [ { - "version": "v3.0.8", + "version": "v3.0.9", "api": { "openapi": ["v3/openapi.json"] }, diff --git a/docs/v3/contributing/configuration.mdx b/docs/v3/contributing/configuration.mdx index 04224e4b..267e6814 100644 --- a/docs/v3/contributing/configuration.mdx +++ b/docs/v3/contributing/configuration.mdx @@ -360,6 +360,11 @@ DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0 DERIVER_POLLING_BACKOFF_ENABLED=true DERIVER_POLLING_SLEEP_MAX_INTERVAL_SECONDS=30.0 DERIVER_POLLING_BACKOFF_MULTIPLIER=2.0 +# Jitter so instances that start together don't poll in lockstep. Startup: sleep +# a random delay in [0, value] before the first poll (0.0 disables). Per-cycle: +# multiply every poll sleep by a random factor in [1-ratio, 1+ratio] (0.0 disables). +DERIVER_POLLING_STARTUP_JITTER_SECONDS=30.0 +DERIVER_POLLING_JITTER_RATIO=0.5 DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5 # Queue management @@ -469,12 +474,9 @@ DB_POOL_TIMEOUT=5 DB_POOL_RECYCLE=300 DB_POOL_USE_LIFO=true DB_SQL_DEBUG=false -# Bounded retry around connection checkout. For a pooled (non-null) DB_POOL_CLASS, -# DB_POOL_TIMEOUT must be < DB_CONNECTION_RETRY_MAX_DELAY_SECONDS or startup fails. -DB_CONNECTION_RETRY_ENABLED=true -DB_CONNECTION_RETRY_MAX_DELAY_SECONDS=10.0 -DB_CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS=0.1 -DB_CONNECTION_RETRY_BACKOFF_MAX_SECONDS=2.0 +# Per-connection establish timeout (seconds) so a single connection attempt +# fails fast instead of hanging when the server/pooler is unreachable. +DB_CONNECT_TIMEOUT_SECONDS=2 ``` ### Authentication diff --git a/pyproject.toml b/pyproject.toml index 1079b9eb..4856619e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho" -version = "3.0.8" +version = "3.0.9" description = "Honcho Server" authors = [ {name = "Plastic Labs", email = "hello@plasticlabs.ai"}, diff --git a/src/config.py b/src/config.py index 35d72d3e..b435daa0 100644 --- a/src/config.py +++ b/src/config.py @@ -613,8 +613,8 @@ class DBSettings(HonchoSettings): POOL_SIZE: Annotated[int, Field(default=10, gt=0, le=1000)] = 10 MAX_OVERFLOW: Annotated[int, Field(default=20, ge=0, le=1000)] = 20 POOL_TIMEOUT: Annotated[int, Field(default=5, gt=0, le=300)] = ( - 5 # seconds; kept under CONNECTION_RETRY_MAX_DELAY_SECONDS so a pool - # checkout fails fast enough to allow a retry within the budget + 5 # seconds a pooled checkout may wait for a free connection (QueuePool + # only; NullPool has no local queue wait) ) POOL_RECYCLE: Annotated[int, Field(default=300, gt=0, le=7200)] = ( 300 # seconds (max 2 hours) @@ -623,47 +623,12 @@ class DBSettings(HonchoSettings): SQL_DEBUG: bool = False TRACING: bool = False - # Bounded exponential-backoff retry around connection acquisition. Applied - # lazily on the first DB use of any session (HonchoAsyncSession) — both the - # request path and background/tracked_db scopes — without forcing an eager - # checkout. Guards against transient transaction-pooler saturation (e.g. - # Supavisor rejecting with "too many clients") by retrying the checkout - # instead of failing immediately. CONNECTION_RETRY_MAX_DELAY_SECONDS is the - # TOTAL retry - # budget; with a real QueuePool, a single checkout can block up to - # POOL_TIMEOUT, so POOL_TIMEOUT must stay below the budget for a retry to be - # possible (enforced below). With NullPool (the transaction-pooler setup) - # there is no local queue wait, so saturation is a fast OperationalError and - # POOL_TIMEOUT does not apply. - CONNECTION_RETRY_ENABLED: bool = True - CONNECTION_RETRY_MAX_DELAY_SECONDS: Annotated[ - float, Field(default=10.0, gt=0.0, le=120.0) - ] = 10.0 - CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS: Annotated[ - float, Field(default=0.1, gt=0.0, le=10.0) - ] = 0.1 - CONNECTION_RETRY_BACKOFF_MAX_SECONDS: Annotated[ - float, Field(default=2.0, gt=0.0, le=30.0) - ] = 2.0 - - @model_validator(mode="after") - def _validate_retry_budget_vs_pool_timeout(self) -> "DBSettings": - # Only meaningful for a real local pool: NullPool has no queue wait, so - # POOL_TIMEOUT is unused there. For a QueuePool, a checkout can block up - # to POOL_TIMEOUT, so it must be < the total retry budget or the first - # attempt consumes the whole budget and no retry ever happens. - if ( - self.POOL_CLASS != "null" - and self.CONNECTION_RETRY_ENABLED - and self.POOL_TIMEOUT >= self.CONNECTION_RETRY_MAX_DELAY_SECONDS - ): - raise ValueError( - f"DB_POOL_TIMEOUT ({self.POOL_TIMEOUT}s) must be less than " - + "DB_CONNECTION_RETRY_MAX_DELAY_SECONDS " - + f"({self.CONNECTION_RETRY_MAX_DELAY_SECONDS}s) so a pooled checkout " - + "can fail fast enough to be retried within the budget." - ) - return self + # Per-connection establish timeout (seconds) passed to the driver, so a + # single connection attempt fails fast instead of hanging when the server or + # pooler is unreachable or stalled. Connection acquisition is a single + # attempt with no retry; callers handle failure (the API surfaces it, the + # deriver backs off and retries on a later poll). + CONNECT_TIMEOUT_SECONDS: Annotated[int, Field(default=2, gt=0, le=60)] = 2 class AuthSettings(HonchoSettings): @@ -792,6 +757,17 @@ class DeriverSettings(HonchoSettings): POLLING_BACKOFF_MULTIPLIER: Annotated[ float, Field(default=2.0, ge=1.0, le=10.0) ] = 2.0 + # Sleep a uniform-random delay in [0, POLLING_STARTUP_JITTER_SECONDS] before + # the first poll so instances that start together don't poll in lockstep. + # Set to 0.0 to disable. + POLLING_STARTUP_JITTER_SECONDS: Annotated[ + float, Field(default=30.0, ge=0.0, le=300.0) + ] = 30.0 + # Multiply every poll sleep by a random factor in [1 - ratio, 1 + ratio] + # (0.5 -> [0.5x, 1.5x]) so poll loops don't re-converge over time. The + # backoff schedule is unchanged; only the returned sleep is scattered. Set + # to 0.0 to disable. + POLLING_JITTER_RATIO: Annotated[float, Field(default=0.5, ge=0.0, le=1.0)] = 0.5 STALE_SESSION_TIMEOUT_MINUTES: Annotated[int, Field(default=5, gt=0, le=1440)] = 5 # Retention window (seconds) for keeping errored items in the queue diff --git a/src/db.py b/src/db.py index 0a133c05..dc9d7c67 100644 --- a/src/db.py +++ b/src/db.py @@ -2,10 +2,7 @@ import contextvars import logging from typing import Any -import sentry_sdk from sqlalchemy import MetaData, event, text -from sqlalchemy.exc import OperationalError -from sqlalchemy.exc import TimeoutError as SQLAlchemyTimeoutError from sqlalchemy.ext.asyncio import ( AsyncSession, async_sessionmaker, @@ -13,22 +10,18 @@ from sqlalchemy.ext.asyncio import ( ) from sqlalchemy.orm import declarative_base from sqlalchemy.pool import NullPool, QueuePool -from tenacity import ( - AsyncRetrying, - retry_if_exception_type, - stop_after_delay, - wait_exponential_jitter, -) from src.config import settings -from src.telemetry.prometheus.metrics import ( - db_queries_in_flight_gauge, - prometheus_metrics, -) +from src.telemetry.prometheus.metrics import db_queries_in_flight_gauge logger = logging.getLogger(__name__) -connect_args = {"prepare_threshold": None} +connect_args = { + "prepare_threshold": None, + # Bound a single connection attempt so it fails fast instead of hanging when + # the server/pooler is unreachable or stalled (psycopg, seconds). + "connect_timeout": settings.DB.CONNECT_TIMEOUT_SECONDS, +} # Context variable to store request context request_context: contextvars.ContextVar[str | None] = contextvars.ContextVar( @@ -59,25 +52,46 @@ engine = create_async_engine( **engine_kwargs, ) -# NOTE: SessionLocal is defined further down, after HonchoAsyncSession (its -# session class) and acquire_connection_with_retry (which that class calls). - -# Errors worth retrying when acquiring a pooled connection: SQLAlchemy's local -# pool-checkout timeout, and OperationalError (how a saturated transaction -# pooler surfaces "too many clients" / connection refusals). -RETRYABLE_DB_CONNECTION_ERRORS = (SQLAlchemyTimeoutError, OperationalError) - -# Identifies this process ("api" | "deriver") on DB metrics. Set once at startup -# by register_db_query_instrumentation; stays "unknown" if metrics are disabled. -_db_instance_type: str = "unknown" +# A vanilla AsyncSession is lazy: it checks out a pooled connection on the first +# DB-touching call (not at construction) and couples the checkout to the +# statement, so a handler doing non-DB work (embedding/file/LLM) before its +# first query does not pin a connection across it. Connection acquisition is a +# single attempt with no retry — callers handle a saturated/unreachable DB (the +# API surfaces the error; the deriver backs off and retries on a later poll). +SessionLocal = async_sessionmaker( + autocommit=False, + autoflush=False, + expire_on_commit=False, + bind=engine, + class_=AsyncSession, +) -def _record_acquisition_outcome(outcome: str) -> None: - """Record a connection-acquisition outcome (no-op when metrics disabled).""" - if settings.METRICS.ENABLED: - prometheus_metrics.record_db_connection_acquisition( - instance_type=_db_instance_type, outcome=outcome - ) +def _set_application_name_on_checkout( + dbapi_connection: Any, _connection_record: Any, _connection_proxy: Any +) -> None: + """Tag each checked-out connection with the current request context. + + Registered only when ``DB.TRACING`` is on. Fires on every pool checkout (so a + reused pooled connection is re-tagged for the new caller), reading the + per-task ``request_context`` the request/task scope has already set. + Best-effort: a failure here must never break the checkout. + """ + context = request_context.get() or "unknown" + try: + cursor = dbapi_connection.cursor() + try: + cursor.execute( + "SELECT set_config('application_name', %s, false)", (context,) + ) + finally: + cursor.close() + except Exception: + logger.debug("setting application_name on checkout failed", exc_info=True) + + +if settings.DB.TRACING: + event.listen(engine.sync_engine, "checkout", _set_application_name_on_checkout) def get_pool_stats() -> dict[str, int]: @@ -107,184 +121,6 @@ def get_pool_stats() -> dict[str, int]: return zeros -async def acquire_connection_with_retry(db: AsyncSession, context: str) -> None: - """Force pool checkout (which ``SessionLocal()`` defers) with bounded backoff. - - ``SessionLocal()`` is lazy: the pool checkout — and any pooler rejection — - happens on the first query. We force it here inside a retry block so that - transient saturation of the transaction pooler is retried with exponential - backoff rather than surfacing as an immediate error. The checkout is wrapped - in a Sentry span so wait time is visible in traces; on budget exhaustion the - original error is reraised after capturing live pool stats to Sentry. - - Each attempt rolls the session back on a retryable failure before retrying: - a failed checkout can leave the autobegun transaction in a pending-rollback - state, which would make the next ``db.connection()`` raise instead of - re-checking-out cleanly. The rollback is pure Python-side state cleanup when - no connection was bound, so it is cheap and safe. - """ - with sentry_sdk.start_span(op="db.pool.acquire", name=context): - if not settings.DB.CONNECTION_RETRY_ENABLED: - await db.connection() - return - attempts = 0 - try: - async for attempt in AsyncRetrying( - wait=wait_exponential_jitter( - initial=settings.DB.CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS, - max=settings.DB.CONNECTION_RETRY_BACKOFF_MAX_SECONDS, - ), - stop=stop_after_delay(settings.DB.CONNECTION_RETRY_MAX_DELAY_SECONDS), - retry=retry_if_exception_type(RETRYABLE_DB_CONNECTION_ERRORS), - reraise=True, - ): - with attempt: - attempts += 1 - try: - await db.connection() - except RETRYABLE_DB_CONNECTION_ERRORS: - # Reset session state so the next attempt starts clean. - try: - await db.rollback() - except Exception: - logger.debug( - "rollback after failed checkout failed", - exc_info=True, - ) - raise - except RETRYABLE_DB_CONNECTION_ERRORS as e: - _record_acquisition_outcome("exhausted") - if settings.SENTRY.ENABLED: - sentry_sdk.set_context("db_pool", get_pool_stats()) - sentry_sdk.capture_exception(e) - raise - # "ok" on first try, "retried" if backoff was needed before success. - _record_acquisition_outcome("ok" if attempts <= 1 else "retried") - - -class HonchoAsyncSession(AsyncSession): - """AsyncSession that lazily checks out its connection, with retry. - - The pool checkout — and any pooler-rejection retry via - ``acquire_connection_with_retry`` — happens on the FIRST DB-touching call, - not at construction. So a request handler that does non-DB work (embedding, - file processing, an LLM call) before its first query does NOT pin a - connection across that work, while still getting checkout retry on the - request path. - - Only the checkout is retried; the SQL statement itself runs exactly once - (we never retry ``super().execute`` after a broad OperationalError), so - writes are never duplicated. The context for tracing is read from the - ``request_context`` ContextVar, which the request/task scope has already set. - """ - - # Class-level default; per-instance assignment shadows it (the subclass has - # a __dict__ even though AsyncSession declares __slots__). - _honcho_acquired: bool = False - - async def _ensure_acquired(self) -> None: - if self._honcho_acquired: - return - context = request_context.get() or "unknown" - await acquire_connection_with_retry(self, context) - self._honcho_acquired = True - if settings.DB.TRACING: - # Forced checkout already happened above; this rides the same - # connection. super() to avoid re-entering _ensure_acquired. - await super().execute( - text("SELECT set_config('application_name', :name, false)"), - {"name": context}, - ) - - # The overrides below are thin: ensure the connection is checked out (once, - # with retry) before delegating to AsyncSession. They cover every public - # DB-touching async method so the "lazy retry on first DB use" guarantee has - # no holes. Signatures are widened to *args/**kwargs because we only forward; - # call sites are typed against the AsyncSession base, so this does not weaken - # type-checking elsewhere. (connection() is intentionally NOT wrapped — - # acquire_connection_with_retry calls it, so wrapping would recurse.) - async def execute(self, *args: Any, **kwargs: Any) -> Any: - await self._ensure_acquired() - return await super().execute(*args, **kwargs) - - async def scalar(self, *args: Any, **kwargs: Any) -> Any: - await self._ensure_acquired() - return await super().scalar(*args, **kwargs) - - async def scalars(self, *args: Any, **kwargs: Any) -> Any: - await self._ensure_acquired() - return await super().scalars(*args, **kwargs) - - async def get(self, *args: Any, **kwargs: Any) -> Any: - await self._ensure_acquired() - return await super().get(*args, **kwargs) - - async def get_one(self, *args: Any, **kwargs: Any) -> Any: - await self._ensure_acquired() - return await super().get_one(*args, **kwargs) - - async def stream(self, *args: Any, **kwargs: Any) -> Any: - await self._ensure_acquired() - return await super().stream(*args, **kwargs) - - async def stream_scalars(self, *args: Any, **kwargs: Any) -> Any: - await self._ensure_acquired() - return await super().stream_scalars(*args, **kwargs) - - async def flush(self, *args: Any, **kwargs: Any) -> None: - await self._ensure_acquired() - await super().flush(*args, **kwargs) - - async def merge(self, *args: Any, **kwargs: Any) -> Any: - await self._ensure_acquired() - return await super().merge(*args, **kwargs) - - async def refresh(self, *args: Any, **kwargs: Any) -> None: - await self._ensure_acquired() - await super().refresh(*args, **kwargs) - - async def delete(self, *args: Any, **kwargs: Any) -> None: - await self._ensure_acquired() - await super().delete(*args, **kwargs) - - async def commit(self) -> None: - # Ensures the add()->commit() path (autoflush on commit) also retries. - await self._ensure_acquired() - try: - await super().commit() - finally: - # Transaction ended; a later op must re-acquire (and re-wrap retry). - self._honcho_acquired = False - - async def rollback(self) -> None: - try: - await super().rollback() - finally: - self._honcho_acquired = False - - async def close(self) -> None: - try: - await super().close() - finally: - # The connection is released; a reused session must re-acquire. - self._honcho_acquired = False - - async def reset(self) -> None: - try: - await super().reset() - finally: - self._honcho_acquired = False - - -SessionLocal = async_sessionmaker( - autocommit=False, - autoflush=False, - expire_on_commit=False, - bind=engine, - class_=HonchoAsyncSession, -) - - class DBQueryInflightTracker: """Tracks statements executing on the wire via SQLAlchemy cursor events. @@ -340,8 +176,7 @@ def register_db_query_instrumentation(instance_type: str) -> None: re-run lifespan or test startup) won't attach duplicate listeners, which would double-count in-flight statements. """ - global _db_instance_type, _inflight_tracker, _db_query_instrumentation_registered - _db_instance_type = instance_type + global _inflight_tracker, _db_query_instrumentation_registered if not settings.METRICS.ENABLED or _db_query_instrumentation_registered: return child = db_queries_in_flight_gauge.labels(instance_type=instance_type) diff --git a/src/dependencies.py b/src/dependencies.py index cd7007e0..31461496 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -10,11 +10,10 @@ from src.db import SessionLocal, request_context async def get_db(): """FastAPI Dependency Generator for Database. - The session is lazy: it does NOT check out a pooled connection here. - HonchoAsyncSession acquires it (with retry) on the first DB-touching call, - so a handler doing non-DB work (embedding/file/LLM) before its first query - does not pin a connection across it. Tracing's application_name and the - checkout retry both live in the session, keyed off request_context. + The session is lazy: it does NOT check out a pooled connection here. The + AsyncSession checks one out on the first DB-touching call, so a handler doing + non-DB work (embedding/file/LLM) before its first query does not pin a + connection across it. """ db: AsyncSession = SessionLocal() try: diff --git a/src/deriver/queue_manager.py b/src/deriver/queue_manager.py index 22716cba..3d131293 100644 --- a/src/deriver/queue_manager.py +++ b/src/deriver/queue_manager.py @@ -1,4 +1,6 @@ import asyncio +import contextlib +import random import signal from asyncio import Task from collections.abc import Sequence @@ -205,6 +207,7 @@ class QueueManager: # Run the polling loop directly in this task logger.debug("Starting polling loop directly") try: + await self._sleep_startup_jitter() await self.polling_loop() finally: await self.cleanup() @@ -391,6 +394,35 @@ class QueueManager: """Snap the polling interval back to the base after finding work.""" self._current_poll_interval = settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS + def _jitter(self, seconds: float) -> float: + """Scatter a sleep by +/- POLLING_JITTER_RATIO to avoid lockstep polling. + + Returns a uniform-random value in [(1-ratio)*seconds, (1+ratio)*seconds]. + Only the returned sleep is scattered; the underlying backoff schedule is + left unchanged. A ratio of 0.0 returns ``seconds`` unchanged. + """ + ratio = settings.DERIVER.POLLING_JITTER_RATIO + if ratio <= 0.0: + return seconds + # Scheduling jitter, not security/crypto — stdlib random is appropriate. + return seconds * random.uniform(1.0 - ratio, 1.0 + ratio) # nosec B311 + + async def _sleep_startup_jitter(self) -> None: + """Sleep a random delay before the first poll so instances that start + together don't poll in lockstep. Interruptible by shutdown so a signal + during the delay exits promptly. No-op when the window is 0.0. + """ + window = settings.DERIVER.POLLING_STARTUP_JITTER_SECONDS + if window <= 0.0: + return + # Scheduling jitter, not security/crypto — stdlib random is appropriate. + delay = random.uniform(0.0, window) # nosec B311 + logger.debug(f"Startup poll jitter: sleeping {delay:.1f}s before first poll") + # Timeout (slept the full delay without a shutdown) is the normal path; + # an early return means shutdown fired and polling_loop will exit at once. + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self.shutdown_event.wait(), timeout=delay) + def _advance_poll_interval(self) -> float: """Return the current idle/backoff sleep, then grow it toward the cap.""" interval = self._current_poll_interval @@ -400,7 +432,7 @@ class QueueManager: * settings.DERIVER.POLLING_BACKOFF_MULTIPLIER, settings.DERIVER.POLLING_SLEEP_MAX_INTERVAL_SECONDS, ) - return interval + return self._jitter(interval) async def polling_loop(self) -> None: """Main polling loop to find and process new work units""" @@ -419,7 +451,9 @@ class QueueManager: # when capacity frees rather than backing off. if self.semaphore.locked(): # logger.debug("All workers busy, waiting") - await asyncio.sleep(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS) + await asyncio.sleep( + self._jitter(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS) + ) continue try: diff --git a/src/main.py b/src/main.py index 6edd9d4c..a5b429ce 100644 --- a/src/main.py +++ b/src/main.py @@ -119,8 +119,7 @@ if SENTRY_ENABLED: FastApiIntegration( transaction_style="endpoint", ), - # Explicit so DB-query spans (and our db.pool.acquire span) are not - # reliant on auto-enabling. + # Explicit so DB-query spans are not reliant on auto-enabling. SqlalchemyIntegration(), ], before_send=before_send, diff --git a/src/telemetry/prometheus/metrics.py b/src/telemetry/prometheus/metrics.py index 1a79c634..d7bfe627 100644 --- a/src/telemetry/prometheus/metrics.py +++ b/src/telemetry/prometheus/metrics.py @@ -127,17 +127,9 @@ telemetry_buffer_size_gauge = NamespacedGauge( ["namespace"], ) -# DB connection-pool health. The acquisitions counter measures how often we hit -# (and retry through) transaction-pooler saturation; the in-flight gauge counts -# statements actually executing on the wire, so checked_out minus in_flight -# reveals connections held but parked (the "idle in transaction during an -# external call" antipattern). -db_connection_acquisitions_counter = NamespacedCounter( - "db_connection_acquisitions", - "DB connection acquisitions by outcome (ok=first try, retried, exhausted)", - ["namespace", "instance_type", "outcome"], -) - +# DB connection-pool health. The in-flight gauge counts statements actually +# executing on the wire, so checked_out minus in_flight reveals connections held +# but parked (the "idle in transaction during an external call" antipattern). db_queries_in_flight_gauge = NamespacedGauge( "db_queries_in_flight", "DB statements currently executing on a connection for this instance", @@ -294,18 +286,6 @@ class PrometheusMetrics: except Exception as e: self._handle_metric_error("set_telemetry_buffer_size", e) - def record_db_connection_acquisition( - self, *, instance_type: str, outcome: str - ) -> None: - # outcome is one of "ok" | "retried" | "exhausted". - try: - db_connection_acquisitions_counter.labels( - instance_type=instance_type, - outcome=outcome, - ).inc() - except Exception as e: - self._handle_metric_error("record_db_connection_acquisition", e) - prometheus_metrics = PrometheusMetrics() diff --git a/tests/deriver/test_queue_processing.py b/tests/deriver/test_queue_processing.py index c293f52c..4c658d97 100644 --- a/tests/deriver/test_queue_processing.py +++ b/tests/deriver/test_queue_processing.py @@ -1,3 +1,4 @@ +import asyncio from collections.abc import Callable from typing import Any from unittest.mock import patch @@ -1583,3 +1584,62 @@ class TestQueueProcessing: claimed = await qm.get_and_claim_work_units() assert work_unit_key is not None assert work_unit_key in claimed + + +class TestPollingJitter: + """Polling jitter: desynchronize poll loops without changing the schedule.""" + + def test_jitter_stays_within_ratio_bounds( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.5) + qm = QueueManager() + samples = [qm._jitter(10.0) for _ in range(1000)] # pyright: ignore[reportPrivateUsage] + assert all(5.0 <= s <= 15.0 for s in samples) + # A 0.5 ratio over 1000 samples should produce real spread, not a constant. + assert max(samples) - min(samples) > 1.0 + + def test_jitter_ratio_zero_is_identity( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.0) + qm = QueueManager() + assert all(qm._jitter(7.5) == 7.5 for _ in range(50)) # pyright: ignore[reportPrivateUsage] + + def test_advance_jitters_return_but_keeps_deterministic_schedule( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.5) + monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_ENABLED", True) + monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_MULTIPLIER", 2.0) + monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_INTERVAL_SECONDS", 1.0) + monkeypatch.setattr( + settings.DERIVER, "POLLING_SLEEP_MAX_INTERVAL_SECONDS", 30.0 + ) + qm = QueueManager() + + # The underlying schedule advances 1 -> 2 -> 4 -> ... -> 30 deterministically; + # each returned sleep is jittered within [0.5x, 1.5x] of the pre-advance step. + expected_schedule = [1.0, 2.0, 4.0, 8.0, 16.0, 30.0, 30.0] + for step in expected_schedule: + returned = qm._advance_poll_interval() # pyright: ignore[reportPrivateUsage] + assert 0.5 * step <= returned <= 1.5 * step + + @pytest.mark.asyncio + async def test_startup_jitter_disabled_returns_immediately( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(settings.DERIVER, "POLLING_STARTUP_JITTER_SECONDS", 0.0) + qm = QueueManager() + # Window 0.0 must not sleep at all. + await qm._sleep_startup_jitter() # pyright: ignore[reportPrivateUsage] + + @pytest.mark.asyncio + async def test_startup_jitter_interrupted_by_shutdown( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(settings.DERIVER, "POLLING_STARTUP_JITTER_SECONDS", 300.0) + qm = QueueManager() + qm.shutdown_event.set() + # A shutdown already signalled must short-circuit the (long) jitter sleep. + await asyncio.wait_for(qm._sleep_startup_jitter(), timeout=1.0) # pyright: ignore[reportPrivateUsage] diff --git a/tests/test_db_resilience.py b/tests/test_db_resilience.py index a82f307a..e4bab0b5 100644 --- a/tests/test_db_resilience.py +++ b/tests/test_db_resilience.py @@ -1,248 +1,96 @@ """Unit tests for DB connection resilience + observability. -These are DB-free: they exercise the retry helper against a fake session, the -deriver polling backoff math, and the in-flight gauge listeners directly. +These are DB-free: they exercise the application_name checkout hook against a +fake DBAPI connection, the deriver polling backoff math, and the in-flight gauge +listeners directly. """ from types import SimpleNamespace from typing import Any import pytest -from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession import src.db as db_module from src.config import settings -from src.db import DBQueryInflightTracker, acquire_connection_with_retry -from src.telemetry.prometheus.metrics import ( - db_connection_acquisitions_counter, - db_queries_in_flight_gauge, -) +from src.db import DBQueryInflightTracker +from src.telemetry.prometheus.metrics import db_queries_in_flight_gauge -def _make_operational_error() -> OperationalError: - """A stand-in for how a saturated pooler surfaces ('too many clients').""" - return OperationalError("SELECT 1", {}, Exception("too many clients")) - - -class _FlakyConnSession: - """Fake AsyncSession whose connection() fails N times then succeeds.""" - - def __init__(self, fail_times: int, *, always_fail: bool = False) -> None: - self.fail_times: int = fail_times - self.always_fail: bool = always_fail - self.calls: int = 0 - self.rollback_calls: int = 0 - - async def connection(self) -> None: - self.calls += 1 - if self.always_fail or self.calls <= self.fail_times: - raise _make_operational_error() - - async def rollback(self) -> None: - self.rollback_calls += 1 - - -def _acq_count(outcome: str) -> float: - child = db_connection_acquisitions_counter.labels( - instance_type="api", outcome=outcome - ) - return float(child._value.get()) # pyright: ignore[reportPrivateUsage, reportUnknownArgumentType] - - -@pytest.fixture -def metrics_on(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(settings.METRICS, "ENABLED", True) - monkeypatch.setattr(settings.METRICS, "NAMESPACE", "test") - monkeypatch.setattr(db_module, "_db_instance_type", "api") - # Fast, deterministic backoff so retry tests don't sleep. - monkeypatch.setattr(settings.DB, "CONNECTION_RETRY_ENABLED", True) - monkeypatch.setattr(settings.DB, "CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS", 0.001) - monkeypatch.setattr(settings.DB, "CONNECTION_RETRY_BACKOFF_MAX_SECONDS", 0.01) - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("metrics_on") -async def test_acquire_succeeds_first_try_records_ok() -> None: - before = _acq_count("ok") - session = _FlakyConnSession(fail_times=0) - await acquire_connection_with_retry(session, "request:test") # pyright: ignore[reportArgumentType] - assert session.calls == 1 - assert _acq_count("ok") == before + 1 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("metrics_on") -async def test_acquire_retries_then_succeeds_records_retried() -> None: - before = _acq_count("retried") - session = _FlakyConnSession(fail_times=2) - await acquire_connection_with_retry(session, "request:test") # pyright: ignore[reportArgumentType] - assert session.calls == 3 # two failures then success - # Session is reset after each failed checkout before the next attempt. - assert session.rollback_calls == 2 - assert _acq_count("retried") == before + 1 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("metrics_on") -async def test_acquire_exhausts_budget_reraises_and_records( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Tiny budget so the loop gives up quickly under sustained failure. - monkeypatch.setattr(settings.DB, "CONNECTION_RETRY_MAX_DELAY_SECONDS", 0.05) - before = _acq_count("exhausted") - session = _FlakyConnSession(fail_times=0, always_fail=True) - with pytest.raises(OperationalError): - await acquire_connection_with_retry(session, "request:test") # pyright: ignore[reportArgumentType] - assert session.calls >= 1 - assert _acq_count("exhausted") == before + 1 - - -@pytest.mark.asyncio -async def test_acquire_disabled_calls_once(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings.DB, "CONNECTION_RETRY_ENABLED", False) - session = _FlakyConnSession(fail_times=0) - await acquire_connection_with_retry(session, "request:test") # pyright: ignore[reportArgumentType] - assert session.calls == 1 - - -# --- HonchoAsyncSession: lazy checkout with retry on first DB use ------------ - - -@pytest.mark.asyncio -async def test_session_lazy_acquires_on_first_db_use( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """No checkout at construction; acquisition (with retry) happens once on the - first execute, and the statement itself runs exactly once.""" - monkeypatch.setattr(settings.DB, "TRACING", False) - acquired: list[str] = [] - executed: list[Any] = [] - - async def fake_acquire(_session: Any, context: str) -> None: - acquired.append(context) - - async def fake_super_execute(_self: Any, *args: Any, **_kw: Any) -> str: - executed.append(args[0] if args else None) - return "result" - - monkeypatch.setattr(db_module, "acquire_connection_with_retry", fake_acquire) - monkeypatch.setattr(AsyncSession, "execute", fake_super_execute) +def test_session_local_uses_vanilla_async_session() -> None: + """Regression guard: no custom session subclass / acquisition logic. + Connection acquisition is a single lazy checkout owned by AsyncSession; there + must be no re-introduced eager-checkout or retry hooks on the session. + """ session = db_module.SessionLocal() - assert session._honcho_acquired is False # pyright: ignore[reportPrivateUsage] - assert acquired == [] # constructing the session does NOT check out - - result = await session.execute("SELECT 1") - assert result == "result" - assert acquired == ["unknown"] # acquired exactly once, on first use - assert session._honcho_acquired is True # pyright: ignore[reportPrivateUsage] - assert executed == ["SELECT 1"] # statement ran once (not retried) - - await session.execute("SELECT 2") - assert acquired == ["unknown"] # still once — no re-acquire on later use + assert type(session) is AsyncSession + assert not hasattr(session, "_ensure_acquired") + assert not hasattr(session, "_honcho_acquired") -@pytest.mark.asyncio -async def test_session_tracing_sets_application_name_on_acquire( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(settings.DB, "TRACING", True) - calls: list[tuple[Any, ...]] = [] +# --- application_name checkout hook ------------------------------------------ - async def fake_acquire(_session: Any, _context: str) -> None: - return None - async def fake_super_execute(_self: Any, *args: Any, **_kw: Any) -> None: - calls.append(args) +class _FakeCursor: + def __init__(self, recorder: list[Any], raise_exc: Exception | None) -> None: + self.recorder: list[Any] = recorder + self.raise_exc: Exception | None = raise_exc + self.closed: bool = False - monkeypatch.setattr(db_module, "acquire_connection_with_retry", fake_acquire) - monkeypatch.setattr(AsyncSession, "execute", fake_super_execute) + def execute(self, sql: str, params: Any = None) -> None: + if self.raise_exc is not None: + raise self.raise_exc + self.recorder.append((sql, params)) + def close(self) -> None: + self.closed = True + + +class _FakeDBAPIConn: + def __init__(self, recorder: list[Any], raise_exc: Exception | None = None) -> None: + self._cursor: _FakeCursor = _FakeCursor(recorder, raise_exc) + + def cursor(self) -> _FakeCursor: + return self._cursor + + +def test_checkout_hook_sets_application_name_from_request_context() -> None: + recorder: list[Any] = [] + conn = _FakeDBAPIConn(recorder) token = db_module.request_context.set("request:trace-ctx") try: - session = db_module.SessionLocal() - await session.execute("SELECT 1") + db_module._set_application_name_on_checkout(conn, None, None) # pyright: ignore[reportPrivateUsage] finally: db_module.request_context.reset(token) - # First super().execute is the set_config, then the real statement. - assert any("set_config" in str(c[0]) for c in calls) - set_config_call = next(c for c in calls if "set_config" in str(c[0])) - assert set_config_call[1] == {"name": "request:trace-ctx"} + assert len(recorder) == 1 + sql, params = recorder[0] + assert "set_config" in sql and "application_name" in sql + assert params == ("request:trace-ctx",) + assert conn._cursor.closed is True # pyright: ignore[reportPrivateUsage] -@pytest.mark.asyncio -async def test_session_lifecycle_methods_reset_acquired_flag( - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def fake_acquire(_session: Any, _context: str) -> None: - return None +def test_checkout_hook_defaults_to_unknown_without_context() -> None: + recorder: list[Any] = [] + conn = _FakeDBAPIConn(recorder) + token = db_module.request_context.set(None) + try: + db_module._set_application_name_on_checkout(conn, None, None) # pyright: ignore[reportPrivateUsage] + finally: + db_module.request_context.reset(token) - async def noop(_self: Any) -> None: - return None - - monkeypatch.setattr(db_module, "acquire_connection_with_retry", fake_acquire) - for method in ("commit", "rollback", "close", "reset"): - monkeypatch.setattr(AsyncSession, method, noop) - - session = db_module.SessionLocal() - await session.commit() # ensures acquired, commits, then resets - assert session._honcho_acquired is False # pyright: ignore[reportPrivateUsage] - - # rollback/close/reset must each clear the flag so a reused session - # re-acquires (and re-wraps retry) on its next DB use. - for method in ("rollback", "close", "reset"): - session._honcho_acquired = True # pyright: ignore[reportPrivateUsage] - await getattr(session, method)() - assert session._honcho_acquired is False # pyright: ignore[reportPrivateUsage] + assert recorder[0][1] == ("unknown",) -@pytest.mark.asyncio -async def test_session_get_and_delete_also_acquire( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The lazy-acquire guarantee covers get/delete, not just execute.""" - monkeypatch.setattr(settings.DB, "TRACING", False) - acquired: list[str] = [] - - async def fake_acquire(_session: Any, context: str) -> None: - acquired.append(context) - - async def fake_get(_self: Any, *_a: Any, **_k: Any) -> str: - return "row" - - async def fake_delete(_self: Any, *_a: Any, **_k: Any) -> None: - return None - - monkeypatch.setattr(db_module, "acquire_connection_with_retry", fake_acquire) - monkeypatch.setattr(AsyncSession, "get", fake_get) - monkeypatch.setattr(AsyncSession, "delete", fake_delete) - - session = db_module.SessionLocal() - await session.get(object, 1) - await session.delete(object()) - assert acquired == ["unknown"] # acquired once on the first DB-touching call +def test_checkout_hook_swallows_errors() -> None: + """A failure tagging the connection must never break the checkout.""" + conn = _FakeDBAPIConn([], raise_exc=RuntimeError("boom")) + # Must not raise. + db_module._set_application_name_on_checkout(conn, None, None) # pyright: ignore[reportPrivateUsage] -@pytest.mark.asyncio -async def test_get_db_does_not_acquire_at_dependency_entry( - monkeypatch: pytest.MonkeyPatch, -) -> None: - from src.dependencies import get_db as real_get_db - - acquired: list[str] = [] - - async def fake_acquire(_session: Any, context: str) -> None: - acquired.append(context) - - monkeypatch.setattr(db_module, "acquire_connection_with_retry", fake_acquire) - - dep_gen = real_get_db() - await anext(dep_gen) - assert acquired == [] # yielding the session must not check out - await dep_gen.aclose() - assert acquired == [] # finally rollback/close must not check out either +# --- deriver polling backoff math -------------------------------------------- def test_polling_backoff_sequence_and_reset(monkeypatch: pytest.MonkeyPatch) -> None: @@ -250,6 +98,9 @@ def test_polling_backoff_sequence_and_reset(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_INTERVAL_SECONDS", 1.0) monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_MULTIPLIER", 2.0) monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_MAX_INTERVAL_SECONDS", 30.0) + # Disable jitter so the schedule is asserted exactly (jitter is tested + # separately in tests/deriver/test_queue_processing.py::TestPollingJitter). + monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.0) from src.deriver.queue_manager import QueueManager @@ -266,6 +117,7 @@ def test_polling_backoff_disabled_stays_constant( ) -> None: monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_ENABLED", False) monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_INTERVAL_SECONDS", 1.0) + monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.0) from src.deriver.queue_manager import QueueManager @@ -273,29 +125,6 @@ def test_polling_backoff_disabled_stays_constant( assert [qm._advance_poll_interval() for _ in range(3)] == [1.0, 1.0, 1.0] # pyright: ignore[reportPrivateUsage] -def test_pool_timeout_must_be_under_retry_budget() -> None: - """A pooled QueuePool checkout that can block past the retry budget is a - contradiction (no retry ever happens) and must fail config validation.""" - from src.config import DBSettings - - # Contradiction: pooled + retry on, but POOL_TIMEOUT >= budget. - with pytest.raises(ValueError, match="DB_POOL_TIMEOUT"): - DBSettings( - POOL_CLASS="default", - POOL_TIMEOUT=30, - CONNECTION_RETRY_ENABLED=True, - CONNECTION_RETRY_MAX_DELAY_SECONDS=10.0, - ) - - # NullPool has no queue wait, so POOL_TIMEOUT is irrelevant -> allowed. - DBSettings( - POOL_CLASS="null", - POOL_TIMEOUT=30, - CONNECTION_RETRY_ENABLED=True, - CONNECTION_RETRY_MAX_DELAY_SECONDS=10.0, - ) - - @pytest.mark.asyncio async def test_polling_loop_idle_sleeps_once_per_cycle( monkeypatch: pytest.MonkeyPatch, @@ -306,6 +135,7 @@ async def test_polling_loop_idle_sleeps_once_per_cycle( monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_INTERVAL_SECONDS", 1.0) monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_MULTIPLIER", 2.0) monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_MAX_INTERVAL_SECONDS", 8.0) + monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.0) import asyncio diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 2996cddc..d5ba3ea1 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -40,8 +40,8 @@ class FakeSession: async def test_get_db_yields_lazily_without_checkout_or_tracing( monkeypatch: pytest.MonkeyPatch, ) -> None: - # get_db must NOT touch the connection or run set_config itself — those now - # happen lazily inside HonchoAsyncSession on first DB use, so a handler doing + # get_db must NOT touch the connection or run set_config itself — checkout + # happens lazily inside the AsyncSession on first DB use, so a handler doing # non-DB work before its first query never pins a connection. fake_db = FakeSession() monkeypatch.setattr(dependencies_module, "SessionLocal", lambda: fake_db) diff --git a/uv.lock b/uv.lock index a2dac3b8..25279af9 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-05-27T18:30:19.790621Z" +exclude-newer = "2026-05-28T15:27:36.945866Z" exclude-newer-span = "P5D" [manifest] @@ -1159,7 +1159,7 @@ wheels = [ [[package]] name = "honcho" -version = "3.0.8" +version = "3.0.9" source = { virtual = "." } dependencies = [ { name = "alembic" }, From 5a3b598cb4ab54ec15f83cc34104ec445a4eabc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Anatol?= Date: Tue, 9 Jun 2026 18:49:46 +0100 Subject: [PATCH 04/65] feat(config): make CORS allowed origins configurable via env (#697) * feat(config): make CORS allowed origins configurable via env Replaces the hardcoded `origins` list in `src/main.py` with a new `CORSSettings` block (env prefix `CORS_`), exposed as `settings.CORS.ORIGINS`. Defaults match the prior hardcoded values, so self-hosted deployments behind custom domains can now whitelist their frontend without editing source. Documented in `.env.template` under a new CORS Settings section. * docs(config): add docstring to CORSSettings * refactor(config): inline CORS_ORIGINS into AppSettings Drop the dedicated CORSSettings nested model and expose CORS_ORIGINS directly on AppSettings. The CORS_ORIGINS env var keeps working as before since AppSettings has no env prefix. --- .env.template | 7 +++++++ src/config.py | 7 +++++++ src/main.py | 8 +------- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.env.template b/.env.template index f27c81f5..cb11ed62 100644 --- a/.env.template +++ b/.env.template @@ -276,6 +276,13 @@ LLM_OPENAI_API_KEY=your-api-key-here # CACHE_DEFAULT_TTL_SECONDS=300 # CACHE_DEFAULT_LOCK_TTL_SECONDS=5 +# ============================================================================= +# CORS Settings +# ============================================================================= +# JSON array of origins allowed by the FastAPI CORSMiddleware. Defaults match +# the previously hardcoded list: localhost, 127.0.0.1:8000 and api.honcho.dev. +# CORS_ORIGINS=["http://localhost","http://127.0.0.1:8000","https://api.honcho.dev"] + # ============================================================================= # Vector Store Settings # ============================================================================= diff --git a/src/config.py b/src/config.py index b435daa0..0fc9b55e 100644 --- a/src/config.py +++ b/src/config.py @@ -1305,6 +1305,13 @@ class AppSettings(HonchoSettings): LANGFUSE_HOST: str | None = None LANGFUSE_PUBLIC_KEY: str | None = None + # Origins allowed by the FastAPI CORSMiddleware + CORS_ORIGINS: list[str] = [ + "http://localhost", + "http://127.0.0.1:8000", + "https://api.honcho.dev", + ] + COLLECT_METRICS_LOCAL: bool = False LOCAL_METRICS_FILE: str = "metrics.jsonl" REASONING_TRACES_FILE: str | None = None # Path to JSONL file for reasoning traces diff --git a/src/main.py b/src/main.py index a5b429ce..f38946df 100644 --- a/src/main.py +++ b/src/main.py @@ -183,15 +183,9 @@ app = FastAPI( }, ) -origins = [ - "http://localhost", - "http://127.0.0.1:8000", - "https://api.honcho.dev", -] - app.add_middleware( CORSMiddleware, - allow_origins=origins, + allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], From f75b336a3ced32b5de57702ae1b843a36f01668a Mon Sep 17 00:00:00 2001 From: Anthony Yuan <1127391+hyuan@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:49:55 -0400 Subject: [PATCH 05/65] feat: add generate_jwt.py script for creating scoped JWTs (#757) * feat: add generate_jwt.py script for creating scoped JWTs Adds a CLI utility script for generating Honcho JWTs without needing to call the /v1/keys API endpoint. Useful for local development and bootstrapping admin tokens. Features: - --admin flag for full-access tokens - --workspace / --peer / --session flags for scoped tokens - --expires flag with human-friendly duration syntax (e.g. 5h, 30d, 1y) - --print-only flag for scripting (outputs bare token) Examples: uv run python scripts/generate_jwt.py --admin uv run python scripts/generate_jwt.py --admin --expires 24h uv run python scripts/generate_jwt.py --workspace my-ws --expires 30d uv run python scripts/generate_jwt.py --workspace my-ws --peer my-peer --expires 1y * docs: document generate_jwt.py in README auth setup section * fix: remove t='' override to preserve utc_now_iso default in JWTParams Per CodeRabbit review: explicitly setting t="" bypasses JWTParams's default utc_now_iso timestamp, causing tokens for the same scope to become byte-identical. Omitting t lets the default apply, ensuring each generated token is unique. * fix: address JWT script review feedback * fix: type, lint --------- Co-authored-by: Rajat Ahuja --- README.md | 20 ++++ scripts/generate_jwt.py | 148 ++++++++++++++++++++++++++++++ tests/conftest.py | 1 + tests/test_generate_jwt_script.py | 23 +++++ 4 files changed, 192 insertions(+) create mode 100644 scripts/generate_jwt.py create mode 100644 tests/test_generate_jwt_script.py diff --git a/README.md b/README.md index acf20123..e62bce37 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,26 @@ the `AUTH_JWT_SECRET` environment variable. This is required for `AUTH_USE_AUTH` AUTH_JWT_SECRET= ``` +Once auth is enabled, use `scripts/generate_jwt.py` to mint tokens for local +development and scripting: + +```bash +# Admin token (full access, no expiry) +uv run python scripts/generate_jwt.py --admin + +# Admin token expiring in 24 hours +uv run python scripts/generate_jwt.py --admin --expires 24h + +# Workspace-scoped token +uv run python scripts/generate_jwt.py --workspace my-workspace --expires 30d + +# Capture a token for use in curl/scripts +TOKEN=$(uv run python scripts/generate_jwt.py --admin --print-only) +curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/v3/workspaces +``` + +Duration units: `s` (seconds), `m` (minutes), `h` (hours), `d` (days), `w` (weeks), `y` (years). + 5. **Run database migrations** With the database set up and environment variables configured, run the migrations diff --git a/scripts/generate_jwt.py b/scripts/generate_jwt.py new file mode 100644 index 00000000..8d96cd67 --- /dev/null +++ b/scripts/generate_jwt.py @@ -0,0 +1,148 @@ +#!/usr/bin/env uv run python +""" +Utility script to generate scoped JWTs for Honcho. + +Examples: + # Admin JWT (no expiry) + uv run python scripts/generate_jwt.py --admin + + # Admin JWT expiring in 24 hours + uv run python scripts/generate_jwt.py --admin --expires 24h + + # Workspace-scoped JWT expiring in 30 days + uv run python scripts/generate_jwt.py --workspace my-workspace --expires 30d + + # Peer-scoped JWT expiring in 1 year + uv run python scripts/generate_jwt.py --workspace my-workspace --peer my-peer --expires 1y + + # Session-scoped JWT + uv run python scripts/generate_jwt.py --workspace my-workspace --session my-session --expires 8h +""" + +import argparse +import datetime +import os +import re +import sys + +# Allow running from repo root without installing +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from src.security import JWTParams, create_jwt +from src.utils.formatting import format_datetime_utc + +DURATION_UNITS = { + "s": datetime.timedelta(seconds=1), + "m": datetime.timedelta(minutes=1), + "h": datetime.timedelta(hours=1), + "d": datetime.timedelta(days=1), + "w": datetime.timedelta(weeks=1), + "y": datetime.timedelta(days=365), +} + + +def parse_duration(value: str) -> datetime.timedelta: + """Parse a duration string like '5h', '1d', '2w', '1y' into a timedelta.""" + match = re.fullmatch(r"(\d+)([smhdwy])", value.strip().lower()) + if not match: + raise argparse.ArgumentTypeError( + f"Invalid duration '{value}'. Use format like: 30s, 5m, 2h, 7d, 2w, 1y" + ) + amount, unit = int(match.group(1)), match.group(2) + return DURATION_UNITS[unit] * amount + + +def main(): + parser = argparse.ArgumentParser( + description="Generate a scoped JWT for Honcho authentication.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument( + "--admin", + action="store_true", + help="Generate an admin JWT (full access)", + ) + parser.add_argument( + "--workspace", + "-w", + metavar="NAME", + help="Scope the JWT to a workspace", + ) + parser.add_argument( + "--peer", + "-p", + metavar="NAME", + help="Scope the JWT to a peer (requires --workspace)", + ) + parser.add_argument( + "--session", + "-s", + metavar="NAME", + help="Scope the JWT to a session (requires --workspace)", + ) + parser.add_argument( + "--expires", + "-e", + metavar="DURATION", + type=parse_duration, + help="Token expiry duration. Units: s=seconds, m=minutes, h=hours, d=days, w=weeks, y=years. E.g. 5h, 30d, 1y", + ) + parser.add_argument( + "--print-only", + action="store_true", + help="Only print the token, no labels", + ) + args = parser.parse_args() + + if not args.admin and not any([args.workspace, args.peer, args.session]): + parser.error( + "Specify --admin or at least one of --workspace, --peer, --session" + ) + + if args.admin and any([args.workspace, args.peer, args.session]): + parser.error( + "--admin cannot be combined with --workspace, --peer, or --session" + ) + + if (args.peer or args.session) and not args.workspace: + parser.error("--peer and --session require --workspace") + + exp_str: str | None = None + if args.expires: + expiry = datetime.datetime.now(datetime.timezone.utc) + args.expires + exp_str = format_datetime_utc(expiry) + + params = JWTParams( + ad=True if args.admin else None, + w=args.workspace, + p=args.peer, + s=args.session, + exp=exp_str, + ) + + token = create_jwt(params) + + if args.print_only: + print(token) + else: + scope_parts: list[str] = [] + if args.admin: + scope_parts.append("admin") + if args.workspace: + scope_parts.append(f"workspace={args.workspace}") + if args.peer: + scope_parts.append(f"peer={args.peer}") + if args.session: + scope_parts.append(f"session={args.session}") + + print(f"Scope: {', '.join(scope_parts)}") + if exp_str: + print(f"Expires: {exp_str}") + else: + print("Expires: never") + print(f"Token: {token}") + + +if __name__ == "__main__": + main() diff --git a/tests/conftest.py b/tests/conftest.py index 06a31ac8..90fc269b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -78,6 +78,7 @@ _RUNTIME_MOCK_TEST_BLOCKLIST_PREFIXES = ( # LLM transport tests mock providers directly and don't need database/runtime setup. "tests/utils/test_length_finish_reason.py", "tests/utils/test_clients.py", + "tests/test_generate_jwt_script.py", ) _LIVE_LLM_MARKER = "live_llm" diff --git a/tests/test_generate_jwt_script.py b/tests/test_generate_jwt_script.py new file mode 100644 index 00000000..04011974 --- /dev/null +++ b/tests/test_generate_jwt_script.py @@ -0,0 +1,23 @@ +import sys + +import pytest + +from scripts import generate_jwt + + +def test_admin_cannot_be_combined_with_scoped_flags(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + sys, + "argv", + [ + "generate_jwt.py", + "--admin", + "--workspace", + "my-workspace", + ], + ) + + with pytest.raises(SystemExit) as exc_info: + generate_jwt.main() + + assert exc_info.value.code == 2 From bf494257b891a456b70a5257975b1b84553da593 Mon Sep 17 00:00:00 2001 From: Rajat Ahuja Date: Wed, 10 Jun 2026 13:28:36 -0400 Subject: [PATCH 06/65] add read db (#773) * feat: implement read DB and fix queue stale cleanup * fix: use read_db in internal methods * fix: mention read db in the CLAUDE.md * fix: make TRACING checkout hook autocommit-safe; sample cleanup-gate jitter once The DB.TRACING checkout hook ran `SELECT set_config(...)` at pool checkout, before the dialect applies the read engine's AUTOCOMMIT isolation level. That statement autobegins a transaction, and psycopg then refuses to switch the connection into AUTOCOMMIT ("can't change 'autocommit' now: connection in transaction status INTRANS"), so every read_only session 500s under TRACING and the INTRANS connection leaks back to poison later write checkouts. Run the hook in autocommit and restore the prior mode so it never leaves an open transaction; set_config(..., is_local=false) is session-scoped and survives the boundary. Add a regression test (fails without the fix) covering read_only + TRACING. Also sample the stale-cleanup gate's jittered interval once per attempt instead of re-rolling it every poll, so the spacing is a fixed deadline per cycle rather than a random walk (and is testable at non-zero jitter ratios). * fix: reset request_context in TRACING checkout-hook test --------- Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> --- CLAUDE.md | 1 + config.toml.example | 4 + src/config.py | 4 + src/crud/document.py | 4 +- src/crud/message.py | 8 +- src/crud/representation.py | 2 +- src/db.py | 42 ++++- src/dependencies.py | 37 ++++- src/deriver/queue_manager.py | 42 ++++- src/dialectic/chat.py | 4 +- src/dialectic/core.py | 2 +- src/routers/conclusions.py | 6 +- src/routers/messages.py | 6 +- src/routers/peers.py | 19 +-- src/routers/sessions.py | 12 +- src/routers/workspaces.py | 6 +- src/utils/agent_tools.py | 16 +- src/utils/search.py | 2 +- tests/conftest.py | 11 +- tests/integration/test_message_embeddings.py | 10 +- tests/sdk_typescript/conftest.py | 9 +- tests/startup/test_embedding_validator.py | 14 +- tests/test_db_resilience.py | 110 +++++++++++++ tests/test_dependencies.py | 158 +++++++++++++++++++ 24 files changed, 469 insertions(+), 60 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fbd05871..87a95c3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,7 @@ cd sdks/typescript && bun run tsc --noEmit - Explicit error handling with appropriate exception types - Docstrings: Use Google style docstrings - **Never hold a DB session during external calls** (LLM, embedding, HTTP). If a function needs both a DB session and an external call result, compute the external result first and pass it as a parameter. This avoids tying up DB connections during slow network I/O. Use `tracked_db` for short-lived, DB-only operations; pass a shared session when multiple DB-only calls can reuse one connection. +- **Never write through a read-only session** (`tracked_db(..., read_only=True)`, `get_read_db`, `ReadSessionLocal`). These run in AUTOCOMMIT mode with no transaction: writes are NOT blocked by the database — they silently commit immediately, and `begin_nested()` savepoints break. There is no runtime guard; this is enforced by convention only. Use `read_only=True` strictly for SELECT-only windows; anything that mutates (including get-or-create paths) must use a regular write session. ### Runtime Architecture diff --git a/config.toml.example b/config.toml.example index dbaf7e3b..3aacfbb7 100644 --- a/config.toml.example +++ b/config.toml.example @@ -98,6 +98,10 @@ POLLING_BACKOFF_MULTIPLIER = 2.0 POLLING_STARTUP_JITTER_SECONDS = 30.0 POLLING_JITTER_RATIO = 0.5 STALE_SESSION_TIMEOUT_MINUTES = 5 +# Minimum (jittered) spacing between stale-work-unit cleanup runs per instance. +# Staleness is a minutes-timescale condition, so cleanup doesn't need to run on +# every seconds-scale poll (0.0 = run every poll, legacy behavior). +STALE_WORK_UNIT_CLEANUP_INTERVAL_SECONDS = 60.0 # QUEUE_ERROR_RETENTION_SECONDS = 2592000 # 30 days DEDUPLICATE = true LOG_OBSERVATIONS = false diff --git a/src/config.py b/src/config.py index 0fc9b55e..834982f4 100644 --- a/src/config.py +++ b/src/config.py @@ -769,6 +769,10 @@ class DeriverSettings(HonchoSettings): # to 0.0 to disable. POLLING_JITTER_RATIO: Annotated[float, Field(default=0.5, ge=0.0, le=1.0)] = 0.5 STALE_SESSION_TIMEOUT_MINUTES: Annotated[int, Field(default=5, gt=0, le=1440)] = 5 + # Minimum (jittered) spacing between stale-work-unit cleanup runs + STALE_WORK_UNIT_CLEANUP_INTERVAL_SECONDS: Annotated[ + float, Field(default=60.0, ge=0.0, le=3600.0) + ] = 60.0 # Retention window (seconds) for keeping errored items in the queue QUEUE_ERROR_RETENTION_SECONDS: Annotated[ diff --git a/src/crud/document.py b/src/crud/document.py index fd068d6a..f8560629 100644 --- a/src/crud/document.py +++ b/src/crud/document.py @@ -369,7 +369,7 @@ async def query_documents( max_distance, top_k, ) - async with tracked_db("query_documents.pgvector") as managed_db: + async with tracked_db("query_documents.pgvector", read_only=True) as managed_db: docs = await _query_documents_pgvector( managed_db, workspace_name, @@ -407,7 +407,7 @@ async def query_documents( document_ids=document_ids, filters=filters, ) - async with tracked_db("query_documents.fetch") as managed_db: + async with tracked_db("query_documents.fetch", read_only=True) as managed_db: docs = await fetch_documents_by_ids( db=managed_db, workspace_name=workspace_name, diff --git a/src/crud/message.py b/src/crud/message.py index ec673bb0..42f18f9a 100644 --- a/src/crud/message.py +++ b/src/crud/message.py @@ -817,7 +817,7 @@ async def _semantic_search_messages( # Pre-fetch peer session scope if needed (short-lived DB session) allowed_session_names: list[str] | None = None if observer and not session_name: - async with tracked_db(f"{operation_name}.peer_scope") as db: + async with tracked_db(f"{operation_name}.peer_scope", read_only=True) as db: allowed_session_names = await get_peer_session_names( db, workspace_name, observer ) @@ -837,7 +837,7 @@ async def _semantic_search_messages( if not message_ids: return [] - async with tracked_db(operation_name) as db: + async with tracked_db(operation_name, read_only=True) as db: matched_messages = ( await _fetch_messages_by_ids( db, @@ -853,7 +853,7 @@ async def _semantic_search_messages( _expunge_snippets(db, snippets) return snippets - async with tracked_db(operation_name) as db: + async with tracked_db(operation_name, read_only=True) as db: snippets = await _search_messages_pgvector( db, workspace_name, @@ -985,7 +985,7 @@ async def grep_messages( List of tuples: (matched_messages, context_messages) Each snippet may contain multiple matches if they were close together. """ - async with tracked_db("message.grep_messages") as db: + async with tracked_db("message.grep_messages", read_only=True) as db: # Pre-fetch peer session scope if needed allowed_session_names = None if observer and not session_name: diff --git a/src/crud/representation.py b/src/crud/representation.py index bb969120..4ade79a4 100644 --- a/src/crud/representation.py +++ b/src/crud/representation.py @@ -276,7 +276,7 @@ class RepresentationManager: ) async with tracked_db( - "representation_manager.get_working_representation" + "representation_manager.get_working_representation", read_only=True ) as new_db: return await self._get_working_representation_internal( new_db, diff --git a/src/db.py b/src/db.py index dc9d7c67..005d52eb 100644 --- a/src/db.py +++ b/src/db.py @@ -66,6 +66,24 @@ SessionLocal = async_sessionmaker( class_=AsyncSession, ) +# Read-only engine: shares `engine`'s pool, but checks connections out in DBAPI +# AUTOCOMMIT mode, so psycopg emits NO BEGIN — a SELECT never autobegins a +# transaction. The backend therefore returns to state 'idle' (not 'idle in +# transaction') the moment a statement completes. +read_engine = engine.execution_options(isolation_level="AUTOCOMMIT") + +# Sessions for SELECT-only work (same lazy-checkout semantics as SessionLocal). +# MUST NOT be used for writes: with no enclosing transaction, begin_nested() +# savepoints (see the crud get-or-create paths) break, and every flush would +# commit immediately. Use SessionLocal for anything that mutates. +ReadSessionLocal = async_sessionmaker( + autocommit=False, + autoflush=False, + expire_on_commit=False, + bind=read_engine, + class_=AsyncSession, +) + def _set_application_name_on_checkout( dbapi_connection: Any, _connection_record: Any, _connection_proxy: Any @@ -76,16 +94,30 @@ def _set_application_name_on_checkout( reused pooled connection is re-tagged for the new caller), reading the per-task ``request_context`` the request/task scope has already set. Best-effort: a failure here must never break the checkout. + + Runs in autocommit so it never leaves the connection 'idle in transaction' + at checkout: this hook fires BEFORE the dialect applies execution-option + isolation levels, and psycopg refuses to switch a connection into AUTOCOMMIT + (which the read engine does) while a transaction opened by this statement is + still in progress. set_config(..., is_local=false) is session-scoped, so it + persists past the autocommit boundary. """ context = request_context.get() or "unknown" try: - cursor = dbapi_connection.cursor() + previous_autocommit = dbapi_connection.autocommit + if not previous_autocommit: + dbapi_connection.autocommit = True try: - cursor.execute( - "SELECT set_config('application_name', %s, false)", (context,) - ) + cursor = dbapi_connection.cursor() + try: + cursor.execute( + "SELECT set_config('application_name', %s, false)", (context,) + ) + finally: + cursor.close() finally: - cursor.close() + if not previous_autocommit: + dbapi_connection.autocommit = False except Exception: logger.debug("setting application_name on checkout failed", exc_info=True) diff --git a/src/dependencies.py b/src/dependencies.py index 31461496..060186b4 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -4,7 +4,7 @@ from contextlib import asynccontextmanager from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession -from src.db import SessionLocal, request_context +from src.db import ReadSessionLocal, SessionLocal, request_context async def get_db(): @@ -32,12 +32,39 @@ async def get_db(): await db.close() +async def get_read_db(): + """FastAPI Dependency Generator for SELECT-only handlers. + + Same lazy-checkout semantics as get_db, but the session is bound to the + AUTOCOMMIT read engine: no BEGIN is ever emitted, so the connection can not + sit 'idle in transaction' between the query and this teardown — a delayed + finally here is harmless (the backend is plain 'idle'). close() is still + required to release the connection itself back to the pool. + + MUST only be used by handlers that never mutate; see ReadSessionLocal. + """ + db: AsyncSession = ReadSessionLocal() + try: + yield db + finally: + # rollback is a wire-level no-op under AUTOCOMMIT; kept to reset any + # Python-side session state before close, mirroring get_db. + await db.rollback() + await db.close() + + @asynccontextmanager -async def tracked_db(operation_name: str | None = None): +async def tracked_db(operation_name: str | None = None, *, read_only: bool = False): """Context manager for tracked database sessions. Sets a task-scoped request_context so the lazy session picks it up for tracing/attribution, then yields a lazy session (see get_db). + + Pass read_only=True for SELECT-only windows: the session is then bound to + the AUTOCOMMIT read engine, so the work inside the block never holds an + open transaction (no idle-in-transaction parking; the pooler can reclaim + the backend between statements). Never use read_only=True on a path that + mutates — see ReadSessionLocal. """ # Get request ID if available, or create operation-specific one context = request_context.get() @@ -47,14 +74,15 @@ async def tracked_db(operation_name: str | None = None): context = f"task:{operation_name}:{str(uuid.uuid4())[:8]}" token = request_context.set(context) - db = SessionLocal() + db = (ReadSessionLocal if read_only else SessionLocal)() try: yield db except Exception: await db.rollback() raise finally: - # Always send ROLLBACK unconditionally — see get_db() comment. + # Always send ROLLBACK unconditionally — see get_db() comment. (Under + # read_only/AUTOCOMMIT it is a wire-level no-op.) await db.rollback() await db.close() if token: # Only reset if we set it @@ -62,3 +90,4 @@ async def tracked_db(operation_name: str | None = None): db: AsyncSession = Depends(get_db) +read_db: AsyncSession = Depends(get_read_db) diff --git a/src/deriver/queue_manager.py b/src/deriver/queue_manager.py index 3d131293..e97e67ed 100644 --- a/src/deriver/queue_manager.py +++ b/src/deriver/queue_manager.py @@ -2,6 +2,7 @@ import asyncio import contextlib import random import signal +import time from asyncio import Task from collections.abc import Sequence from dataclasses import dataclass, field @@ -134,6 +135,16 @@ class QueueManager: settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS ) + # Monotonic timestamp of the last stale-work-unit cleanup ATTEMPT. + # None -> the first poll always runs cleanup (recovers rows left stale + # by a crashed predecessor immediately). + self._last_stale_cleanup_attempt: float | None = None + # Jittered gate width (seconds) sampled ONCE per attempt, so the deadline + # for the next run is fixed when the timestamp is set rather than + # re-rolled on every poll (which would make the effective spacing a + # random walk and untestable at non-zero jitter ratios). + self._stale_cleanup_gate_seconds: float = 0.0 + # Initialize from settings self.workers: int = settings.DERIVER.WORKERS self.semaphore: asyncio.Semaphore = asyncio.Semaphore(self.workers) @@ -258,6 +269,35 @@ class QueueManager: # Polling and Scheduling # ########################## + async def _maybe_cleanup_stale_work_units(self) -> None: + """Run stale-work-unit cleanup at most once per (jittered) interval. + + Staleness is a minutes-timescale condition (STALE_SESSION_TIMEOUT_MINUTES), + but the polling loop fires on a seconds timescale on every deriver + instance — running cleanup unconditionally per poll multiplies into + unnecessary write transactions. Gate it locally: + concurrent cleaners on other instances remain safe via FOR UPDATE SKIP + LOCKED, so no cross-instance coordination is required, and the jittered + gate (sampled once per attempt) keeps instances from re-synchronizing + their cleanup runs. The gate tracks the last ATTEMPT (set before + running), so a failing cleanup waits a full interval instead of retrying + every poll against a DB that is already struggling. An interval of 0 + preserves run-every-poll behavior. + """ + interval = settings.DERIVER.STALE_WORK_UNIT_CLEANUP_INTERVAL_SECONDS + if ( + interval > 0.0 + and self._last_stale_cleanup_attempt is not None + and time.monotonic() - self._last_stale_cleanup_attempt + < self._stale_cleanup_gate_seconds + ): + return + # Record the attempt and fix the next deadline before running, so the + # gate width is stable for this cycle and a failing cleanup still waits. + self._last_stale_cleanup_attempt = time.monotonic() + self._stale_cleanup_gate_seconds = self._jitter(interval) + await self.cleanup_stale_work_units() + async def cleanup_stale_work_units(self) -> None: """Clean up stale work units""" async with tracked_db("cleanup_stale_work_units") as db: @@ -457,7 +497,7 @@ class QueueManager: continue try: - await self.cleanup_stale_work_units() + await self._maybe_cleanup_stale_work_units() claimed_work_units = await self.get_and_claim_work_units() if claimed_work_units: self._reset_poll_interval() diff --git a/src/dialectic/chat.py b/src/dialectic/chat.py index e9659a21..ae6ea290 100644 --- a/src/dialectic/chat.py +++ b/src/dialectic/chat.py @@ -40,7 +40,7 @@ async def agentic_chat( The synthesized answer string """ # Short-lived DB session for validation + config - async with tracked_db("dialectic.preflight") as db: + async with tracked_db("dialectic.preflight", read_only=True) as db: await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observer)) if observer != observed: await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observed)) @@ -101,7 +101,7 @@ async def agentic_chat_stream( Chunks of the response text as they are generated """ # Short-lived DB session for validation + config - async with tracked_db("dialectic.preflight") as db: + async with tracked_db("dialectic.preflight", read_only=True) as db: await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observer)) if observer != observed: await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observed)) diff --git a/src/dialectic/core.py b/src/dialectic/core.py index 5a5f690b..dbbfed35 100644 --- a/src/dialectic/core.py +++ b/src/dialectic/core.py @@ -121,7 +121,7 @@ class DialecticAgent: token_limit=max_tokens, reverse=False, # chronological order ) - async with tracked_db("dialectic.session_history") as db: + async with tracked_db("dialectic.session_history", read_only=True) as db: result = await db.execute(stmt) messages = result.scalars().all() diff --git a/src/routers/conclusions.py b/src/routers/conclusions.py index 20aadb75..3a25a5d7 100644 --- a/src/routers/conclusions.py +++ b/src/routers/conclusions.py @@ -6,7 +6,7 @@ from fastapi_pagination.ext.sqlalchemy import apaginate from sqlalchemy.ext.asyncio import AsyncSession from src import crud, schemas -from src.dependencies import db +from src.dependencies import db, read_db from src.exceptions import ResourceNotFoundException, ValidationException from src.security import require_auth from src.telemetry.events import EmbeddingCallPurpose @@ -67,7 +67,7 @@ async def list_conclusions( False, description="Whether to reverse the order of results", ), - db: AsyncSession = db, + db: AsyncSession = read_db, ): """ List Conclusions using optional filters, ordered by recency unless `reverse` is true. Results are paginated. @@ -97,7 +97,7 @@ async def query_conclusions( ..., description="Semantic search parameters for Conclusions", ), - db: AsyncSession = db, + db: AsyncSession = read_db, ) -> list[schemas.Conclusion]: """ Query Conclusions using semantic search. Use `top_k` to control the number of results returned. diff --git a/src/routers/messages.py b/src/routers/messages.py index 917ca713..b58a3287 100644 --- a/src/routers/messages.py +++ b/src/routers/messages.py @@ -18,7 +18,7 @@ from sqlalchemy.orm.attributes import flag_modified from src import crud, schemas from src.config import settings -from src.dependencies import db +from src.dependencies import db, read_db from src.deriver import enqueue from src.exceptions import FileTooLargeError, ResourceNotFoundException from src.security import require_auth @@ -260,7 +260,7 @@ async def get_messages( reverse: bool | None = Query( False, description="Whether to reverse the order of results" ), - db: AsyncSession = db, + db: AsyncSession = read_db, ): """Get all messages for a Session with optional filters. Results are paginated.""" try: @@ -288,7 +288,7 @@ async def get_message( workspace_id: str = Path(...), session_id: str = Path(...), message_id: str = Path(...), - db: AsyncSession = db, + db: AsyncSession = read_db, ): """Get a single message by ID from a Session.""" honcho_message = await crud.get_message( diff --git a/src/routers/peers.py b/src/routers/peers.py index efa19a78..e1aa45dd 100644 --- a/src/routers/peers.py +++ b/src/routers/peers.py @@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import crud, schemas from src.config import settings -from src.dependencies import db, tracked_db +from src.dependencies import db, read_db, tracked_db from src.dialectic.chat import agentic_chat, agentic_chat_stream from src.embedding_client import embedding_client from src.exceptions import AuthenticationException, ResourceNotFoundException @@ -43,7 +43,7 @@ async def get_peers( None, description="Filtering options for the peers list" ), reverse: bool = Query(False, description="Whether to reverse the order of results"), - db: AsyncSession = db, + db: AsyncSession = read_db, ): """Get all Peers for a Workspace, paginated with optional filters.""" filter_param = None @@ -134,7 +134,7 @@ async def get_sessions_for_peer( None, description="Filtering options for the sessions list" ), reverse: bool = Query(False, description="Whether to reverse the order of results"), - db: AsyncSession = db, + db: AsyncSession = read_db, ): """Get all Sessions for a Peer, paginated with optional filters.""" filter_param = None @@ -318,7 +318,7 @@ async def get_peer_card( None, description="Optional target peer to retrieve a card for, from the observer's perspective. If not provided, returns the observer's own card", ), - db: AsyncSession = db, + db: AsyncSession = read_db, ): """Get a peer card for a specific peer relationship. @@ -412,7 +412,6 @@ async def get_peer_context( le=100, description="Maximum number of conclusions to include in the representation", ), - db: AsyncSession = db, ): """ Get context for a peer, including their representation and peer card. @@ -459,10 +458,12 @@ async def get_peer_context( parent_category="api", ) - # Get the peer card - peer_card = await crud.get_peer_card( - db, workspace_id, observer=peer_id, observed=observed - ) + async with tracked_db( + "peers.get_peer_context.peer_card", read_only=True + ) as card_db: + peer_card = await crud.get_peer_card( + card_db, workspace_id, observer=peer_id, observed=observed + ) response = schemas.PeerContext( peer_id=peer_id, diff --git a/src/routers/sessions.py b/src/routers/sessions.py index 98a8714a..eb8794d7 100644 --- a/src/routers/sessions.py +++ b/src/routers/sessions.py @@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import config, crud, schemas from src.cache.client import safe_cache_delete from src.crud.session import session_cache_key -from src.dependencies import db +from src.dependencies import db, read_db from src.deriver.enqueue import enqueue_deletion from src.embedding_client import embedding_client from src.exceptions import ( @@ -251,7 +251,7 @@ async def get_sessions( None, description="Filtering and pagination options for the sessions list" ), reverse: bool = Query(False, description="Whether to reverse the order of results"), - db: AsyncSession = db, + db: AsyncSession = read_db, ): """Get all Sessions for a Workspace, paginated with optional filters.""" filter_param = None @@ -544,7 +544,7 @@ async def get_peer_config( workspace_id: str = Path(...), session_id: str = Path(...), peer_id: str = Path(...), - db: AsyncSession = db, + db: AsyncSession = read_db, ): """Get the configuration for a Peer in a Session.""" return await crud.get_peer_config( @@ -599,7 +599,7 @@ async def set_peer_config( async def get_session_peers( workspace_id: str = Path(...), session_id: str = Path(...), - db: AsyncSession = db, + db: AsyncSession = read_db, ): """Get all Peers in a Session. Results are paginated.""" try: @@ -622,7 +622,7 @@ async def get_session_peers( async def get_session_context( workspace_id: str = Path(...), session_id: str = Path(...), - db: AsyncSession = db, + db: AsyncSession = read_db, tokens: int | None = Query( None, le=config.settings.GET_CONTEXT_MAX_TOKENS, @@ -814,7 +814,7 @@ async def get_session_context( async def get_session_summaries( workspace_id: str = Path(...), session_id: str = Path(...), - db: AsyncSession = db, + db: AsyncSession = read_db, ) -> schemas.SessionSummaries: """ Get available summaries for a Session. diff --git a/src/routers/workspaces.py b/src/routers/workspaces.py index a2298480..8b19fdfd 100644 --- a/src/routers/workspaces.py +++ b/src/routers/workspaces.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import crud, schemas from src.config import settings -from src.dependencies import db +from src.dependencies import db, read_db from src.deriver.enqueue import enqueue_deletion, enqueue_dream from src.exceptions import AuthenticationException from src.security import JWTParams, require_auth @@ -67,7 +67,7 @@ async def get_all_workspaces( None, description="Filtering and pagination options for the workspaces list" ), reverse: bool = Query(False, description="Whether to reverse the order of results"), - db: AsyncSession = db, + db: AsyncSession = read_db, ): """Get all Workspaces, paginated with optional filters.""" filter_param = None @@ -169,7 +169,7 @@ async def get_queue_status( session_id: str | None = Query( None, description="Optional session ID to filter by" ), - db: AsyncSession = db, + db: AsyncSession = read_db, ): """ Get the processing queue status for a Workspace, optionally scoped to an observer, sender, diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index de5b2b09..dae38577 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -1607,7 +1607,7 @@ async def _handle_get_recent_history( ) -> "str | ToolResult": """Handle get_recent_history tool.""" _ = tool_input - async with tracked_db("tool.get_recent_history") as db: + async with tracked_db("tool.get_recent_history", read_only=True) as db: history: list[models.Message] = await get_recent_history( db, workspace_name=ctx.workspace_name, @@ -1723,7 +1723,7 @@ async def _handle_get_observation_context( ctx: ToolContext, tool_input: dict[str, Any] ) -> "str | ToolResult": """Handle get_observation_context tool.""" - async with tracked_db("tool.get_observation_context") as db: + async with tracked_db("tool.get_observation_context", read_only=True) as db: messages = await get_observation_context( db, workspace_name=ctx.workspace_name, @@ -1862,7 +1862,7 @@ async def _handle_get_messages_by_date_range( if isinstance(before_date, str): return before_date # Error message - async with tracked_db("tool.get_messages_by_date_range") as db: + async with tracked_db("tool.get_messages_by_date_range", read_only=True) as db: messages = await crud.get_messages_by_date_range( db, workspace_name=ctx.workspace_name, @@ -1980,7 +1980,7 @@ async def _handle_get_recent_observations( ) -> str: """Handle get_recent_observations tool.""" session_only = tool_input.get("session_only", False) - async with tracked_db("tool.get_recent_observations") as db: + async with tracked_db("tool.get_recent_observations", read_only=True) as db: documents = await crud.query_documents_recent( db=db, workspace_name=ctx.workspace_name, @@ -2006,7 +2006,7 @@ async def _handle_get_most_derived_observations( ctx: ToolContext, tool_input: dict[str, Any] ) -> str: """Handle get_most_derived_observations tool.""" - async with tracked_db("tool.get_most_derived_observations") as db: + async with tracked_db("tool.get_most_derived_observations", read_only=True) as db: documents = await crud.query_documents_most_derived( db=db, workspace_name=ctx.workspace_name, @@ -2038,7 +2038,7 @@ async def _handle_get_session_summary( if summary_type == "long" else summarizer.SummaryType.SHORT ) - async with tracked_db("tool.get_session_summary") as db: + async with tracked_db("tool.get_session_summary", read_only=True) as db: summary = await summarizer.get_summary( db, ctx.workspace_name, ctx.session_name, st ) @@ -2050,7 +2050,7 @@ async def _handle_get_session_summary( async def _handle_get_peer_card(ctx: ToolContext, tool_input: dict[str, Any]) -> str: """Handle get_peer_card tool.""" _ = tool_input - async with tracked_db("tool.get_peer_card") as db: + async with tracked_db("tool.get_peer_card", read_only=True) as db: peer_card = await crud.get_peer_card( db, workspace_name=ctx.workspace_name, @@ -2209,7 +2209,7 @@ async def _handle_get_reasoning_chain( return f"ERROR: Invalid direction '{direction}'. Must be 'premises', 'conclusions', or 'both'" # Get the observation itself - async with tracked_db("tool.get_reasoning_chain") as db: + async with tracked_db("tool.get_reasoning_chain", read_only=True) as db: docs = await crud.get_documents_by_ids(db, ctx.workspace_name, [observation_id]) if not docs or not docs[0]: return f"ERROR: Observation '{observation_id}' not found" diff --git a/src/utils/search.py b/src/utils/search.py index 15721933..761b63e0 100644 --- a/src/utils/search.py +++ b/src/utils/search.py @@ -448,7 +448,7 @@ async def search( return search_results[0][:limit] return [] - async with tracked_db("search.messages") as managed_db: + async with tracked_db("search.messages", read_only=True) as managed_db: combined_results = await _run_search(managed_db) for message in combined_results: managed_db.expunge(message) diff --git a/tests/conftest.py b/tests/conftest.py index 90fc269b..df2b194d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,7 +32,7 @@ from src import models from src.cache.client import cache from src.config import settings from src.db import Base -from src.dependencies import get_db +from src.dependencies import get_db, get_read_db from src.exceptions import HonchoException from src.main import app from src.models import Peer, Workspace @@ -340,6 +340,10 @@ async def client( yield db_session app.dependency_overrides[get_db] = override_get_db + # Read-only routes use get_read_db (AUTOCOMMIT engine) in production; in + # tests they must see the same per-test database/session as writes, both + # for isolation and so data written by a test is visible to its reads. + app.dependency_overrides[get_read_db] = override_get_db # No-op the startup embedding-schema validator inside the lifespan. The # global `engine` it would inspect points to a DB that isn't migrated in @@ -792,7 +796,10 @@ def mock_tracked_db(request: pytest.FixtureRequest): session_factory = async_sessionmaker(bind=db_engine, expire_on_commit=False) @asynccontextmanager - async def mock_tracked_db_context(_: str | None = None): + async def mock_tracked_db_context(_: str | None = None, *, read_only: bool = False): + # read_only is accepted (and ignored): in tests both engines resolve to + # the same per-test database session. + del read_only async with session_factory() as session: yield session diff --git a/tests/integration/test_message_embeddings.py b/tests/integration/test_message_embeddings.py index 198f2d36..83ae2ae2 100644 --- a/tests/integration/test_message_embeddings.py +++ b/tests/integration/test_message_embeddings.py @@ -474,7 +474,10 @@ async def test_search_messages_external_lookup_happens_before_tracked_db( return [([message], [message])] @asynccontextmanager - async def fake_tracked_db(_operation_name: str | None = None): + async def fake_tracked_db( + _operation_name: str | None = None, *, read_only: bool = False + ): + del read_only call_order.append("enter") yield fake_db call_order.append("exit") @@ -578,7 +581,10 @@ async def test_search_messages_temporal_external_lookup_happens_before_tracked_d return [([message], [message])] @asynccontextmanager - async def fake_tracked_db(_operation_name: str | None = None): + async def fake_tracked_db( + _operation_name: str | None = None, *, read_only: bool = False + ): + del read_only call_order.append("enter") yield fake_db call_order.append("exit") diff --git a/tests/sdk_typescript/conftest.py b/tests/sdk_typescript/conftest.py index 74fe148b..15505a0e 100644 --- a/tests/sdk_typescript/conftest.py +++ b/tests/sdk_typescript/conftest.py @@ -20,7 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from uvicorn.config import Config from uvicorn.server import Server -from src.dependencies import get_db +from src.dependencies import get_db, get_read_db from src.main import app @@ -95,6 +95,9 @@ def ts_test_server( yield session app.dependency_overrides[get_db] = override_get_db + # Read-only routes use get_read_db (AUTOCOMMIT engine) in production; in + # tests they must resolve to the same per-test database. + app.dependency_overrides[get_read_db] = override_get_db # No-op the lifespan's startup embedding-schema validator — same # reasoning as the `client` fixture in tests/conftest.py: the module- @@ -133,7 +136,9 @@ def mock_tracked_db(ts_db_session: async_sessionmaker[AsyncSession]): # Create a tracked_db that uses fresh sessions (not shared) @asynccontextmanager - async def ts_tracked_db(_: str | None = None): + async def ts_tracked_db(_: str | None = None, *, read_only: bool = False): + # read_only accepted (and ignored): tests use one per-test database. + del read_only async with ts_db_session() as session: yield session diff --git a/tests/startup/test_embedding_validator.py b/tests/startup/test_embedding_validator.py index 962e07f0..1d257ba5 100644 --- a/tests/startup/test_embedding_validator.py +++ b/tests/startup/test_embedding_validator.py @@ -14,6 +14,7 @@ from sqlalchemy import text from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncEngine +from src.config import settings from src.startup.embedding_validator import ( StartupValidationError, _assert_pgvector_dims_match, # pyright: ignore[reportPrivateUsage] @@ -120,18 +121,24 @@ async def test_validator_fails_closed_when_introspection_keeps_failing( @pytest.mark.asyncio async def test_validator_passes_against_test_database( db_engine: AsyncEngine, + monkeypatch: pytest.MonkeyPatch, ) -> None: """The test DB is provisioned at the default dim (1536); the validator should accept it without raising.""" + # conftest provisions the test tables in `public`; pin the validator to it + # so a developer's local .env DB_SCHEMA can't point it at another schema. + monkeypatch.setattr(settings.DB, "SCHEMA", "public") await validate_embedding_schema(db_engine) @pytest.mark.asyncio async def test_validator_raises_when_schema_dim_diverges_from_settings( db_engine: AsyncEngine, + monkeypatch: pytest.MonkeyPatch, ) -> None: """ALTER one of the embedding columns to a non-1536 dim and confirm the validator raises with an actionable message.""" + monkeypatch.setattr(settings.DB, "SCHEMA", "public") # see test above async with db_engine.begin() as conn: await conn.execute( text( @@ -181,8 +188,13 @@ def test_non_1536_pgvector_without_migrated_no_longer_raises_at_config_time() -> """The dim-vs-MIGRATED guard has been removed. Constructing AppSettings with non-1536 + default pgvector + MIGRATED=false should now succeed (the runtime schema validator at startup is the safety net).""" + # Minimal env, NOT a copy of os.environ: load_dotenv() in the app mutates + # the parent pytest process's environ, so inheriting it would leak a + # developer's local .env (DB_SCHEMA, VECTOR_STORE_MIGRATED, ...) into the + # child despite PYTHON_DOTENV_DISABLED. The child must see pure defaults + # plus exactly the overrides below. env = { - **os.environ, + "PATH": os.environ.get("PATH", ""), "PYTHON_DOTENV_DISABLED": "1", "EMBEDDING_VECTOR_DIMENSIONS": "768", } diff --git a/tests/test_db_resilience.py b/tests/test_db_resilience.py index e4bab0b5..faffa89a 100644 --- a/tests/test_db_resilience.py +++ b/tests/test_db_resilience.py @@ -50,6 +50,11 @@ class _FakeCursor: class _FakeDBAPIConn: def __init__(self, recorder: list[Any], raise_exc: Exception | None = None) -> None: self._cursor: _FakeCursor = _FakeCursor(recorder, raise_exc) + # Real pooled connections are checked out in non-autocommit mode; the + # hook flips this to True for its statement then restores it so it never + # leaves an open transaction that would block the read engine's + # AUTOCOMMIT switch. + self.autocommit: bool = False def cursor(self) -> _FakeCursor: return self._cursor @@ -69,6 +74,8 @@ def test_checkout_hook_sets_application_name_from_request_context() -> None: assert "set_config" in sql and "application_name" in sql assert params == ("request:trace-ctx",) assert conn._cursor.closed is True # pyright: ignore[reportPrivateUsage] + # The hook restored the original (non-autocommit) mode after its statement. + assert conn.autocommit is False def test_checkout_hook_defaults_to_unknown_without_context() -> None: @@ -198,3 +205,106 @@ def test_inflight_gauge_no_drift(monkeypatch: pytest.MonkeyPatch) -> None: # gauge negative. tracker.on_error(SimpleNamespace(connection=SimpleNamespace(info={}))) assert value() == start + + +@pytest.mark.asyncio +async def test_stale_cleanup_time_gate(monkeypatch: pytest.MonkeyPatch) -> None: + """cleanup_stale_work_units runs at most once per gate interval per + instance (staleness is a minutes-timescale condition; per-poll cleanup + multiplies into needless fleet-wide write transactions). First poll always + runs it so a crashed predecessor's stale rows are recovered immediately.""" + monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.0) + monkeypatch.setattr( + settings.DERIVER, "STALE_WORK_UNIT_CLEANUP_INTERVAL_SECONDS", 60.0 + ) + + from src.deriver import queue_manager as qm_mod + + qm = qm_mod.QueueManager() + runs = {"n": 0} + + async def fake_cleanup() -> None: + runs["n"] += 1 + + monkeypatch.setattr(qm, "cleanup_stale_work_units", fake_cleanup) + + clock = {"now": 1_000.0} + monkeypatch.setattr( + "src.deriver.queue_manager.time.monotonic", lambda: clock["now"] + ) + + # First call runs (no prior attempt recorded). + await qm._maybe_cleanup_stale_work_units() # pyright: ignore[reportPrivateUsage] + assert runs["n"] == 1 + + # Inside the gate window: skipped. + clock["now"] += 10.0 + await qm._maybe_cleanup_stale_work_units() # pyright: ignore[reportPrivateUsage] + assert runs["n"] == 1 + + # Past the gate window: runs again. + clock["now"] += 60.0 + await qm._maybe_cleanup_stale_work_units() # pyright: ignore[reportPrivateUsage] + assert runs["n"] == 2 + + +@pytest.mark.asyncio +async def test_stale_cleanup_gate_failed_attempt_waits_full_interval( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The gate records the ATTEMPT before running, so a failing cleanup is not + retried on every poll against a DB that is already struggling.""" + monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.0) + monkeypatch.setattr( + settings.DERIVER, "STALE_WORK_UNIT_CLEANUP_INTERVAL_SECONDS", 60.0 + ) + + from src.deriver import queue_manager as qm_mod + + qm = qm_mod.QueueManager() + attempts = {"n": 0} + + async def failing_cleanup() -> None: + attempts["n"] += 1 + raise RuntimeError("db unavailable") + + monkeypatch.setattr(qm, "cleanup_stale_work_units", failing_cleanup) + + clock = {"now": 1_000.0} + monkeypatch.setattr( + "src.deriver.queue_manager.time.monotonic", lambda: clock["now"] + ) + + with pytest.raises(RuntimeError): + await qm._maybe_cleanup_stale_work_units() # pyright: ignore[reportPrivateUsage] + assert attempts["n"] == 1 + + # Immediately after the failure: still gated, no hammering. + clock["now"] += 1.0 + await qm._maybe_cleanup_stale_work_units() # pyright: ignore[reportPrivateUsage] + assert attempts["n"] == 1 + + +@pytest.mark.asyncio +async def test_stale_cleanup_gate_zero_interval_runs_every_poll( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Interval 0.0 preserves legacy run-on-every-poll behavior.""" + monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.0) + monkeypatch.setattr( + settings.DERIVER, "STALE_WORK_UNIT_CLEANUP_INTERVAL_SECONDS", 0.0 + ) + + from src.deriver import queue_manager as qm_mod + + qm = qm_mod.QueueManager() + runs = {"n": 0} + + async def fake_cleanup() -> None: + runs["n"] += 1 + + monkeypatch.setattr(qm, "cleanup_stale_work_units", fake_cleanup) + + await qm._maybe_cleanup_stale_work_units() # pyright: ignore[reportPrivateUsage] + await qm._maybe_cleanup_stale_work_units() # pyright: ignore[reportPrivateUsage] + assert runs["n"] == 2 diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index d5ba3ea1..1b5c219b 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -150,3 +150,161 @@ async def test_tracked_db_rolls_back_open_transaction_on_exit( assert fake_db.rollback_calls == 1 assert fake_db.close_calls == 1 + + +@pytest.mark.asyncio +async def test_tracked_db_read_only_uses_read_sessionmaker( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # read_only=True must construct the session from ReadSessionLocal (the + # AUTOCOMMIT engine) — and never touch SessionLocal — while keeping the + # same rollback/close teardown. + read_fake = FakeSession() + monkeypatch.setattr(dependencies_module, "ReadSessionLocal", lambda: read_fake) + monkeypatch.setattr( + dependencies_module, + "SessionLocal", + lambda: pytest.fail("read_only window constructed a write session"), + ) + + async with real_tracked_db("read_op", read_only=True) as db: + assert db is read_fake + + assert read_fake.rollback_calls == 1 + assert read_fake.close_calls == 1 + + +@pytest.mark.asyncio +async def test_get_read_db_rolls_back_and_closes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + read_fake = FakeSession() + monkeypatch.setattr(dependencies_module, "ReadSessionLocal", lambda: read_fake) + + dep_gen = dependencies_module.get_read_db() + try: + db = await anext(dep_gen) + assert db is read_fake + assert read_fake.connection_calls == 0 # still lazy, no eager checkout + finally: + await dep_gen.aclose() + + assert read_fake.rollback_calls == 1 + assert read_fake.close_calls == 1 + + +def test_read_engine_is_autocommit_and_shares_pool() -> None: + # The read engine must differ from the write engine ONLY by isolation + # level: AUTOCOMMIT (so reads never autobegin a transaction) on the same + # underlying pool (no second connection budget). + from src.db import engine, read_engine + + assert ( + read_engine.sync_engine._execution_options.get( # pyright: ignore[reportPrivateUsage] + "isolation_level" + ) + == "AUTOCOMMIT" + ) + assert read_engine.sync_engine.pool is engine.sync_engine.pool + + +@pytest.mark.asyncio +async def test_read_only_session_runs_in_autocommit_on_the_wire() -> None: + # Wire-level guarantee behind the whole read-path fix: a read_only session's + # connection has the DBAPI autocommit flag set, so psycopg emits no BEGIN + # and the backend sits in state 'idle' (not 'idle in transaction') after a + # statement returns. NOTE: get_isolation_level() can NOT verify this — it + # reports the server's transaction_isolation GUC (READ COMMITTED), because + # autocommit is a driver behavior, not a server isolation level. + from sqlalchemy import text + + from src.db import read_engine + + async with real_tracked_db("read_op", read_only=True) as db: + pid = (await db.execute(text("SELECT pg_backend_pid()"))).scalar() + conn = await db.connection() + raw = (await conn.get_raw_connection()).driver_connection + assert raw is not None + assert raw.autocommit is True + + # Definitive check, from a second connection: after the SELECT above, + # the session's backend must be plain 'idle' — an open transaction + # would report 'idle in transaction' and be reapable in production. + async with read_engine.connect() as observer: + state = ( + await observer.execute( + text("SELECT state FROM pg_stat_activity WHERE pid = :p"), + {"p": pid}, + ) + ).scalar() + assert state == "idle" + + +@pytest.mark.asyncio +async def test_write_session_holds_idle_in_transaction_after_select() -> None: + # Contrast guard documenting WHY the read engine exists: the default + # (transactional) session autobegins on the first statement and leaves the + # backend 'idle in transaction' until rollback/close — the state that + # Postgres's idle_in_transaction_session_timeout reaps and that pins a + # transaction-mode pooler backend. + from sqlalchemy import text + + from src.db import read_engine + + async with real_tracked_db("write_op") as db: + pid = (await db.execute(text("SELECT pg_backend_pid()"))).scalar() + async with read_engine.connect() as observer: + state = ( + await observer.execute( + text("SELECT state FROM pg_stat_activity WHERE pid = :p"), + {"p": pid}, + ) + ).scalar() + assert state == "idle in transaction" + + +@pytest.mark.asyncio +async def test_read_only_session_works_with_tracing_checkout_hook() -> None: + # Regression: the DB.TRACING checkout hook runs set_config() at pool + # checkout, BEFORE the dialect applies the read engine's AUTOCOMMIT + # isolation level. If that statement is allowed to autobegin a transaction, + # psycopg then refuses to switch the connection into AUTOCOMMIT + # ("can't change 'autocommit' now: connection in transaction") and every + # read_only session 500s under TRACING. The hook must run in autocommit so + # it leaves the connection idle. This combination is otherwise untested + # because DB.TRACING defaults to false. + from sqlalchemy import event, text + + from src.db import ( + _set_application_name_on_checkout, # pyright: ignore[reportPrivateUsage] + engine, + read_engine, + ) + + context_token = request_context.set("tracing-regression") + event.listen(engine.sync_engine, "checkout", _set_application_name_on_checkout) + try: + async with real_tracked_db("read_op", read_only=True) as db: + pid = (await db.execute(text("SELECT pg_backend_pid()"))).scalar() + app_name = (await db.execute(text("SHOW application_name"))).scalar() + conn = await db.connection() + raw = (await conn.get_raw_connection()).driver_connection + assert raw is not None + # AUTOCOMMIT was applied despite the checkout hook running first. + assert raw.autocommit is True + # The hook still tagged the connection (set_config is session-scoped, + # so it survives the autocommit boundary). + assert app_name == "tracing-regression" + # Backend is idle, not idle-in-transaction: the no-BEGIN guarantee + # holds even with the hook firing. + async with read_engine.connect() as observer: + state = ( + await observer.execute( + text("SELECT state FROM pg_stat_activity WHERE pid = :p"), + {"p": pid}, + ) + ).scalar() + assert state == "idle" + finally: + event.remove(engine.sync_engine, "checkout", _set_application_name_on_checkout) + request_context.reset(context_token) From 6aa6033a16912f842f10c3c04bdcae8806b8de68 Mon Sep 17 00:00:00 2001 From: Rajat Ahuja Date: Thu, 11 Jun 2026 10:31:04 -0400 Subject: [PATCH 07/65] feat: defer embedding messages (#704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: defer embedding messages * fix: rm gauges * feat: embed messages immediately on create with reconciler fallback (#766) Adds embed_messages_now background task so newly created messages are searchable within seconds instead of waiting up to the reconciler interval. Three-phase claim/lease → embed → persist never holds a DB session across the embedding call; the reconciler remains the fallback for failures and stragglers. * fix: harden immediate-embed fast path and cover its error branches Wrap embed_messages_now in a top-level try/except so a failure in the claim or persist phase degrades to "reconciler will retry" instead of escaping into the background-task runner; the rows stay pending+leased and the reconciler heals them. Add tests for the previously-uncovered branches: external-store-unavailable persist path, the file-upload endpoint's embed scheduling, and direct unit tests for the shared compute_chunk_positions / build_message_vector_record helpers. Document the semantic-search eventual-consistency window in search.mdx (keyword matches are immediate; vector matches lag creation by seconds). * fix: don't hold DB session across vector-store upserts * fix: align semantic-search function to filter null rows --------- Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> --- .../features/advanced/search.mdx | 4 + src/config.py | 3 + src/crud/message.py | 183 ++-------- src/embedding_client.py | 107 +++--- src/reconciler/embed_now.py | 343 ++++++++++++++++++ src/reconciler/sync_vectors.py | 213 ++++++++--- src/routers/messages.py | 16 + src/telemetry/events/llm.py | 3 + tests/conftest.py | 65 ++-- tests/deriver/test_embed_now.py | 268 ++++++++++++++ tests/deriver/test_vector_reconciliation.py | 165 +++++++++ tests/integration/test_message_embeddings.py | 138 +++++-- tests/llm/test_embedding_client.py | 105 ++++++ tests/routes/test_messages.py | 88 ++++- 14 files changed, 1397 insertions(+), 304 deletions(-) create mode 100644 src/reconciler/embed_now.py create mode 100644 tests/deriver/test_embed_now.py diff --git a/docs/v3/documentation/features/advanced/search.mdx b/docs/v3/documentation/features/advanced/search.mdx index 90f155a2..a95fff15 100644 --- a/docs/v3/documentation/features/advanced/search.mdx +++ b/docs/v3/documentation/features/advanced/search.mdx @@ -6,6 +6,10 @@ icon: 'magnifying-glass' Honcho's search functionality allows you to find relevant messages and conversations across different scopes - from entire workspaces down to specific peers or sessions. + +Search is hybrid: it combines full-text (keyword) matching with semantic (vector) similarity. Keyword matches are available the instant a message is created. Semantic matches depend on the message's embedding, which is generated in the background, so a freshly created message may take a few seconds to surface in semantic results. If you need to assert on semantic results immediately after writing (for example in tests), wait briefly or poll. + + ## Search Scopes ### Workspace Search diff --git a/src/config.py b/src/config.py index 834982f4..6a7869b6 100644 --- a/src/config.py +++ b/src/config.py @@ -704,6 +704,9 @@ class EmbeddingSettings(HonchoSettings): VECTOR_DIMENSIONS: Annotated[int, Field(default=1536, gt=0)] = 1536 MAX_INPUT_TOKENS: Annotated[int, Field(default=8192, gt=0)] = 8192 MAX_TOKENS_PER_REQUEST: Annotated[int, Field(default=300_000, gt=0)] = 300_000 + # Caps concurrent message-embedding fan-out on the API request path (the + # immediate-embed background task). The reconciler is unaffected. + MAX_CONCURRENT_EMBEDDINGS: Annotated[int, Field(default=10, gt=0, le=100)] = 10 @model_validator(mode="before") @classmethod diff --git a/src/crud/message.py b/src/crud/message.py index 42f18f9a..ddd6df38 100644 --- a/src/crud/message.py +++ b/src/crud/message.py @@ -4,19 +4,18 @@ from logging import getLogger from typing import Any from nanoid import generate as generate_nanoid -from sqlalchemy import ColumnElement, Select, and_, func, or_, select, text, update +from sqlalchemy import ColumnElement, Select, and_, func, or_, select, text from sqlalchemy.ext.asyncio import AsyncSession from src import models, schemas from src.config import settings from src.dependencies import tracked_db from src.embedding_client import embedding_client -from src.exceptions import VectorStoreError from src.telemetry.events import EmbeddingCallPurpose from src.utils.filter import apply_filter from src.utils.formatting import ILIKE_ESCAPE_CHAR, escape_ilike_pattern from src.utils.types import embedding_call_purpose -from src.vector_store import VectorRecord, get_external_vector_store +from src.vector_store import get_external_vector_store from .session import get_or_create_session @@ -276,158 +275,37 @@ async def create_messages( db.add_all(message_objects) - # Commit here to release the advisory lock before generating embeddings - await db.commit() - try: - if settings.EMBED_MESSAGES: - id_resource_dict = { - message.public_id: message.content - for message in message_objects - if message.content and message.content.strip() - } - if id_resource_dict: - with embedding_call_purpose( - EmbeddingCallPurpose.MESSAGE_CREATE.value, - workspace_name=workspace_name, - parent_category="api", - ): - embedding_dict = await embedding_client.batch_embed( - id_resource_dict - ) - else: - embedding_dict = {} - - external_vector_store = get_external_vector_store() - - # Determine if we need to persist embeddings to postgres - # True when: TYPE=pgvector OR still migrating (dual-write to both stores) - store_embeddings_in_postgres = ( - settings.VECTOR_STORE.TYPE == "pgvector" - or not settings.VECTOR_STORE.MIGRATED - ) - - # Create MessageEmbedding entries - embedding_objects: list[models.MessageEmbedding] = [] - # Maps emb index -> (chunk_position, embedding vector) - pending_embedding_data: dict[int, tuple[int, list[float]]] = {} + # If embedding is enabled, locally chunk the content and insert + # one pending MessageEmbedding row per chunk in chunk order. The actual + # embedding work is deferred to the reconciler + if settings.EMBED_MESSAGES: + id_resource_dict = { + message_obj.public_id: message_obj.content + for message_obj in message_objects + if message_obj.content and message_obj.content.strip() + } + if id_resource_dict: + chunks_by_id = embedding_client.prepare_chunks(id_resource_dict) + peer_by_id = {m.public_id: m.peer_name for m in message_objects} + pending_rows: list[models.MessageEmbedding] = [] for message_obj in message_objects: - embeddings = embedding_dict.get(message_obj.public_id, []) - for chunk_position, embedding in enumerate(embeddings): - embedding_obj = models.MessageEmbedding( - content=message_obj.content, - message_id=message_obj.public_id, - workspace_name=workspace_name, - session_name=session_name, - peer_name=message_obj.peer_name, - sync_state="pending", - embedding=embedding if store_embeddings_in_postgres else None, - ) - emb_idx = len(embedding_objects) - pending_embedding_data[emb_idx] = (chunk_position, embedding) - embedding_objects.append(embedding_obj) - - # Always create MessageEmbedding rows so reconciliation can track sync state - # even when embeddings aren't stored in postgres - embedding_ids: list[int] = [] - if embedding_objects: - db.add_all(embedding_objects) - await db.flush() - embedding_ids = [emb.id for emb in embedding_objects] - - await db.commit() - - # If no external vector store (pgvector-only mode), mark as synced immediately - if external_vector_store is None: - if embedding_ids: - await db.execute( - update(models.MessageEmbedding) - .where(models.MessageEmbedding.id.in_(embedding_ids)) - .values( - sync_state="synced", - last_sync_at=func.now(), - sync_attempts=0, + chunks = chunks_by_id.get(message_obj.public_id, []) + for chunk_text in chunks: + pending_rows.append( + models.MessageEmbedding( + content=chunk_text, + message_id=message_obj.public_id, + workspace_name=workspace_name, + session_name=session_name, + peer_name=peer_by_id[message_obj.public_id], + sync_state="pending", + embedding=None, ) ) - await db.commit() - else: - # External vector store - build and upsert vector records - namespace = external_vector_store.get_vector_namespace( - "message", workspace_name - ) + if pending_rows: + db.add_all(pending_rows) - # Build vector records with {message_id}_{chunk_position} as vector ID - vector_records: list[VectorRecord] = [] - for emb_idx, emb in enumerate(embedding_objects): - chunk_position, embedding = pending_embedding_data[emb_idx] - vector_id = f"{emb.message_id}_{chunk_position}" - vector_records.append( - VectorRecord( - id=vector_id, - embedding=list(embedding), - metadata={ - "message_id": emb.message_id, - "session_name": emb.session_name, - "peer_name": emb.peer_name, - }, - ) - ) - - # Upsert to external vector store and update sync state - if vector_records: - try: - await external_vector_store.upsert_many( - namespace, vector_records - ) - # Success: mark as synced if we have DB rows - if embedding_ids: - await db.execute( - update(models.MessageEmbedding) - .where(models.MessageEmbedding.id.in_(embedding_ids)) - .values( - sync_state="synced", - last_sync_at=func.now(), - sync_attempts=0, - ) - ) - await db.commit() - - except VectorStoreError: - logger.warning( - "Vector store unavailable; leaving message vectors unsynced" - ) - if embedding_ids: - await db.execute( - update(models.MessageEmbedding) - .where(models.MessageEmbedding.id.in_(embedding_ids)) - .values( - sync_attempts=models.MessageEmbedding.sync_attempts - + 1, - last_sync_at=func.now(), - ) - ) - await db.commit() - - except Exception: - logger.exception("Unexpected error upserting message vectors") - if embedding_ids: - await db.execute( - update(models.MessageEmbedding) - .where(models.MessageEmbedding.id.in_(embedding_ids)) - .values( - sync_attempts=models.MessageEmbedding.sync_attempts - + 1, - last_sync_at=func.now(), - ) - ) - await db.commit() - - except Exception: - logger.exception( - "Failed to generate message embeddings for %s messages in workspace %s and session %s.", - len(message_objects), - workspace_name, - session_name, - ) + await db.commit() return message_objects @@ -770,6 +648,9 @@ async def _search_messages_pgvector( models.MessageEmbedding, models.Message.public_id == models.MessageEmbedding.message_id, ) + # Exclude pending rows that haven't been embedded yet: their NULL + # distance sorts last and would pad the window with unranked messages. + .where(models.MessageEmbedding.embedding.isnot(None)) .where(models.MessageEmbedding.workspace_name == workspace_name) .order_by(models.MessageEmbedding.embedding.cosine_distance(query_embedding)) .limit(limit * 2) diff --git a/src/embedding_client.py b/src/embedding_client.py index 60516bc5..1efdc3d2 100644 --- a/src/embedding_client.py +++ b/src/embedding_client.py @@ -250,76 +250,61 @@ class _EmbeddingClient: async def simple_batch_embed(self, texts: list[str]) -> list[list[float]]: """ - Simple batch embedding for a list of text strings. + Batch-embed a list of text strings. Each input must already fit within + `max_embedding_tokens`; this method does not sub-chunk oversized inputs. + + Internally goes through the same token-aware batching pipeline as + `batch_embed()` so the per-request token cap is respected. Args: texts: List of text strings to embed Returns: - List of embedding vectors corresponding to input texts + List of embedding vectors, one per input text (in order) Raises: ValueError: If any text exceeds token limits """ - embeddings: list[list[float]] = [] + if not texts: + return [] - for i in range(0, len(texts), self.max_batch_size): - batch = texts[i : i + self.max_batch_size] - - async def _embed_batch(batch: list[str] = batch) -> list[list[float]]: - """One provider call for one batch. Lifted into a closure so - _emit_embedding_call can time + emit + propagate errors.""" - batch_embeddings: list[list[float]] = [] - if isinstance(self.client, genai.Client): - # Type cast needed due to genai type signature complexity - response = await self.client.aio.models.embed_content( - model=self.model, - contents=batch, # pyright: ignore[reportArgumentType] - config={"output_dimensionality": self.vector_dimensions}, - ) - if response.embeddings: - for emb in response.embeddings: - if emb.values: - batch_embeddings.append( - self._validate_embedding_dimensions(emb.values) - ) - else: # openai - openai_kwargs: dict[str, Any] = { - "input": batch, - "model": self.model, - } - if self.send_dimensions: - openai_kwargs["dimensions"] = self.vector_dimensions - response = await self.client.embeddings.create(**openai_kwargs) - batch_embeddings.extend( - [ - self._validate_embedding_dimensions(data.embedding) - for data in response.data - ] - ) - return batch_embeddings - - try: - # Pre-compute the tiktoken estimate ONCE for telemetry; the - # batch contents don't change between attempts. - tokens_estimate = sum(len(self.encoding.encode(t)) for t in batch) - batch_embeddings = await _emit_embedding_call( - provider=self.transport, - model=self.model, - texts=batch, - input_tokens_estimate=tokens_estimate, - fn=_embed_batch, + # Validate per-input token limit and collect token counts for batching + token_counts: list[int] = [] + for idx, text in enumerate(texts): + tokens = len(self.encoding.encode(text)) + if tokens > self.max_embedding_tokens: + raise ValueError( + f"Text at index {idx} exceeds maximum token limit of {self.max_embedding_tokens} tokens (got {tokens} tokens)" ) - embeddings.extend(batch_embeddings) - except Exception as e: - # Check if it's a token limit error and re-raise as ValueError for consistency - if "token" in str(e).lower(): - raise ValueError( - f"Text content exceeds maximum token limit of {self.max_embedding_tokens}." - ) from e - raise + token_counts.append(tokens) - return embeddings + # Use positional indices as text_ids so we can reassemble in input order. + text_chunks: dict[str, list[tuple[str, int]]] = { + str(i): [(text, token_counts[i])] for i, text in enumerate(texts) + } + + batches = self._create_batches(text_chunks) + batch_results = await asyncio.gather( + *[self._process_batch(batch) for batch in batches], + ) + + combined: dict[str, list[list[float]]] = self._accumulate_embeddings( + batch_results + ) + return [combined[str(i)][0] for i in range(len(texts))] + + def prepare_chunks(self, id_resource_dict: dict[str, str]) -> dict[str, list[str]]: + """ + Public helper: tokenize and chunk texts using the same rules as + `batch_embed()`. Returns ordered chunk texts per input id. + + Intended for callers that want to persist embeddable chunks + before later embedding them off the request path. + """ + return { + text_id: [chunk_text for chunk_text, _ in chunks] + for text_id, chunks in self._prepare_chunks(id_resource_dict).items() + } async def batch_embed( self, id_resource_dict: dict[str, str] @@ -623,9 +608,13 @@ class EmbeddingClient: return await self._get_client().embed(query) async def simple_batch_embed(self, texts: list[str]) -> list[list[float]]: - """Simple batch embedding for a list of text strings.""" + """Batch embed a list of text strings (each must fit token limit).""" return await self._get_client().simple_batch_embed(texts) + def prepare_chunks(self, id_resource_dict: dict[str, str]) -> dict[str, list[str]]: + """Chunk texts using the same rules as `batch_embed` (no network).""" + return self._get_client().prepare_chunks(id_resource_dict) + async def batch_embed( self, id_resource_dict: dict[str, str] ) -> dict[str, list[list[float]]]: diff --git a/src/reconciler/embed_now.py b/src/reconciler/embed_now.py new file mode 100644 index 00000000..12c71065 --- /dev/null +++ b/src/reconciler/embed_now.py @@ -0,0 +1,343 @@ +""" +Immediate message-embedding fast path. + +``create_messages`` writes ``MessageEmbedding`` rows as ``sync_state='pending'`` +with no vector and defers embedding to the reconciler, which runs on a fixed +interval. To keep freshly created messages searchable within seconds (not +minutes), the message routers schedule ``embed_messages_now`` as a FastAPI +background task right after the response is sent. The reconciler remains the +fallback for anything this path leaves pending (failures, process restarts, or +rows it could not claim). + +The fast path never holds a DB session across a network call (embedding or +external vector store): it claims and leases rows in one short transaction, +embeds with no session open, then persists in short transactions with any +external-store upserts running between them, not inside them. Running concurrently with the +reconciler is safe because the claim uses ``FOR UPDATE SKIP LOCKED`` and leases +rows by stamping ``last_sync_at``, which the reconciler's backoff filter then +skips. +""" + +import asyncio +import logging +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import and_, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src import models +from src.config import settings +from src.dependencies import tracked_db +from src.embedding_client import embedding_client +from src.exceptions import VectorStoreError +from src.reconciler.sync_vectors import ( + _backoff_eligible, # pyright: ignore[reportPrivateUsage] + build_message_vector_record, + compute_chunk_positions, +) +from src.telemetry.events import EmbeddingCallPurpose +from src.utils.types import embedding_call_purpose +from src.vector_store import VectorRecord, VectorStore, get_external_vector_store + +logger = logging.getLogger(__name__) + +_embed_semaphore: asyncio.Semaphore | None = None + + +def _get_embed_semaphore() -> asyncio.Semaphore: + """Lazily create the embed-concurrency semaphore. + + Built on first use (not at import time) so it binds to the running event + loop rather than whatever loop happened to exist at import. + """ + global _embed_semaphore + if _embed_semaphore is None: + _embed_semaphore = asyncio.Semaphore( + settings.EMBEDDING.MAX_CONCURRENT_EMBEDDINGS + ) + return _embed_semaphore + + +def reset_embed_semaphore() -> None: + """Test hook: drop the cached semaphore so the next call rebuilds it on the + current event loop and current config.""" + global _embed_semaphore + _embed_semaphore = None + + +@dataclass(frozen=True) +class _ClaimedChunk: + """Plain snapshot of a claimed ``MessageEmbedding`` row. + + Captured before the claim transaction commits — after commit the ORM object + is detached and attribute access would lazy-load against a closed session. + """ + + id: int + message_id: str + content: str + workspace_name: str + session_name: str | None + peer_name: str | None + + +async def embed_messages_now(message_ids: list[str]) -> None: + """Embed freshly created messages immediately, leaving the reconciler as the + fallback for anything left pending. + + Args: + message_ids: ``Message.public_id`` values (what + ``MessageEmbedding.message_id`` references). Messages without + embeddable content simply have no pending rows to claim. + """ + if not message_ids: + return + + # Runs as a fire-and-forget background task, so guard the whole flow: an + # unhandled error here would escape into the server's task runner and be + # lost. Any failure just leaves rows pending (claimed rows stay leased), + # and the reconciler heals them on its next cycle. + try: + claimed = await _claim_and_lease(message_ids) + if not claimed: + return + + vectors = await _embed_chunks(claimed) + if vectors is None: + # Embedding failed; rows stay pending + leased, reconciler will retry. + return + + await _persist(message_ids, claimed, vectors) + except Exception: + logger.exception( + "Immediate embed failed for %s message(s); reconciler will retry", + len(message_ids), + ) + + +async def _claim_and_lease(message_ids: list[str]) -> list[_ClaimedChunk]: + """Phase 1 (short txn): claim eligible pending rows with FOR UPDATE SKIP + LOCKED, lease them by stamping ``last_sync_at``, and snapshot their data. + + ``sync_attempts`` is intentionally left untouched: the reconciler owns retry + accounting and the eventual ``sync_state='failed'`` backstop, so a transient + embedding failure on this best-effort path never burns that budget. + """ + async with tracked_db("embed_now_claim") as db: + rows_stmt = ( + select(models.MessageEmbedding) + .where( + and_( + models.MessageEmbedding.message_id.in_(message_ids), + models.MessageEmbedding.sync_state == "pending", + _backoff_eligible(models.MessageEmbedding.last_sync_at), + ) + ) + .order_by(models.MessageEmbedding.message_id, models.MessageEmbedding.id) + .with_for_update(skip_locked=True) + ) + rows = list((await db.execute(rows_stmt)).scalars().all()) + if not rows: + return [] + + claimed = [ + _ClaimedChunk( + id=row.id, + message_id=row.message_id, + content=row.content, + workspace_name=row.workspace_name, + session_name=row.session_name, + peer_name=row.peer_name, + ) + for row in rows + ] + + await db.execute( + update(models.MessageEmbedding) + .where(models.MessageEmbedding.id.in_([c.id for c in claimed])) + .values(last_sync_at=func.now()) + ) + await db.commit() + return claimed + + +async def _embed_chunks(claimed: list[_ClaimedChunk]) -> list[list[float]] | None: + """Phase 2 (no DB session): embed the claimed chunk contents under the + concurrency semaphore. Returns vectors in input order, or None on failure.""" + workspaces = {c.workspace_name for c in claimed} + try: + async with _get_embed_semaphore(): + with embedding_call_purpose( + EmbeddingCallPurpose.MESSAGE_CREATE.value, + workspace_name=workspaces.pop() if len(workspaces) == 1 else None, + parent_category="api", + ): + return await embedding_client.simple_batch_embed( + [c.content for c in claimed] + ) + except Exception: + logger.exception( + "Immediate embedding failed for %s chunk(s); reconciler will retry", + len(claimed), + ) + return None + + +async def _persist( + message_ids: list[str], + claimed: list[_ClaimedChunk], + vectors: list[list[float]], +) -> None: + """Phase 3: persist vectors and mark rows synced. On failure, rows stay + pending (already leased) and the reconciler heals them. + + pgvector mode is one short transaction. External-store mode never holds a + DB session across the vector-store network call: positions are read in one + short transaction, the upserts run with no session open, and the surviving + rows are marked synced in a second short transaction.""" + if len(vectors) != len(claimed): + logger.warning( + "Embedding count %s != claimed chunk count %s; skipping immediate persist, reconciler will heal", + len(vectors), + len(claimed), + ) + return + + vector_by_id = {c.id: vec for c, vec in zip(claimed, vectors, strict=True)} + # True for pgvector OR during migration (dual-write to both stores). + store_in_postgres = ( + settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED + ) + external = get_external_vector_store() + + if external is None: + async with tracked_db("embed_now_persist") as db: + await _persist_pgvector(db, claimed, vector_by_id) + await db.commit() + return + + synced = await _upsert_external(message_ids, claimed, vector_by_id, external) + if not synced: + return + + async with tracked_db("embed_now_persist") as db: + await _mark_synced(db, synced, vector_by_id, store_in_postgres) + await db.commit() + + +async def _persist_pgvector( + db: AsyncSession, + claimed: list[_ClaimedChunk], + vector_by_id: dict[int, list[float]], +) -> None: + """pgvector-only mode: write the vector and mark synced per row. The + ``sync_state='pending'`` guard keeps us idempotent if the reconciler synced + a row in the gap.""" + for c in claimed: + await db.execute( + update(models.MessageEmbedding) + .where( + and_( + models.MessageEmbedding.id == c.id, + models.MessageEmbedding.sync_state == "pending", + ) + ) + .values( + sync_state="synced", + last_sync_at=func.now(), + sync_attempts=0, + embedding=vector_by_id[c.id], + ) + ) + + +async def _upsert_external( + message_ids: list[str], + claimed: list[_ClaimedChunk], + vector_by_id: dict[int, list[float]], + external: VectorStore, +) -> list[_ClaimedChunk]: + """External-store mode: upsert vectors per namespace with no DB session + open, returning the chunks whose namespaces upserted successfully. + + Chunk positions come from the shared helper (full sibling ordering) so vector + ids match whatever the reconciler writes for any chunk we skipped; reading + them is the only DB work here, done in its own short transaction before any + network call.""" + async with tracked_db("embed_now_positions") as db: + chunk_position = await compute_chunk_positions(db, message_ids) + + by_namespace: dict[str, list[_ClaimedChunk]] = {} + for c in claimed: + ns = external.get_vector_namespace("message", c.workspace_name) + by_namespace.setdefault(ns, []).append(c) + + synced: list[_ClaimedChunk] = [] + for namespace, chunks in by_namespace.items(): + records: list[VectorRecord] = [] + synced_chunks: list[_ClaimedChunk] = [] + for c in chunks: + pos = chunk_position.get(c.id) + if pos is None: + continue + records.append( + build_message_vector_record( + message_id=c.message_id, + chunk_position=pos, + session_name=c.session_name, + peer_name=c.peer_name, + embedding=vector_by_id[c.id], + ) + ) + synced_chunks.append(c) + + if not records: + continue + + try: + await external.upsert_many(namespace, records) + except VectorStoreError: + logger.warning( + "Vector store unavailable during immediate embed of namespace %s; reconciler will retry", + namespace, + ) + continue + except Exception: + logger.exception( + "Unexpected error during immediate embed of namespace %s; reconciler will retry", + namespace, + ) + continue + + synced.extend(synced_chunks) + + return synced + + +async def _mark_synced( + db: AsyncSession, + chunks: list[_ClaimedChunk], + vector_by_id: dict[int, list[float]], + store_in_postgres: bool, +) -> None: + """Mark upserted chunks synced (DB-only). The ``sync_state='pending'`` + guard keeps us idempotent if the reconciler synced a row in the gap.""" + for c in chunks: + values: dict[str, Any] = { + "sync_state": "synced", + "last_sync_at": func.now(), + "sync_attempts": 0, + } + if store_in_postgres: + values["embedding"] = vector_by_id[c.id] + await db.execute( + update(models.MessageEmbedding) + .where( + and_( + models.MessageEmbedding.id == c.id, + models.MessageEmbedding.sync_state == "pending", + ) + ) + .values(**values) + ) diff --git a/src/reconciler/sync_vectors.py b/src/reconciler/sync_vectors.py index 0dada25d..00db6e3d 100644 --- a/src/reconciler/sync_vectors.py +++ b/src/reconciler/sync_vectors.py @@ -9,7 +9,7 @@ import datetime import logging import time from dataclasses import dataclass -from typing import cast +from typing import Any, cast from sqlalchemy import and_, delete, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -109,28 +109,55 @@ async def _get_message_embeddings_needing_sync( """ Get pending message embeddings that need to be synced to the vector store. - Returns only pending embeddings (with full data including embedding vectors). - The batch_size limits the number of embeddings returned. + Claims up to `batch_size` distinct message_ids that have at least one + eligible pending row, then loads ALL pending rows for those message_ids. + This guarantees a single message's chunks are always processed together in + one batch, which keeps vector-ID assignment (`{message_id}_{chunk_index}`, + derived from row-id ordering) stable across reconciler cycles. - Uses FOR UPDATE SKIP LOCKED to prevent concurrent processing and - orders by last_sync_at (nulls first) to prioritize never-synced records. + Uses FOR UPDATE SKIP LOCKED on the per-row claim so concurrent reconcilers + don't double-process the same chunks. Note: "synced" = done forever, "failed" = permanent failure (manual intervention) """ - stmt = ( - select(models.MessageEmbedding) + # Step 1: pick distinct message_ids with at least one eligible pending row, + # prioritizing those with the oldest last_sync_at. + msg_id_stmt = ( + select( + models.MessageEmbedding.message_id, + func.min(models.MessageEmbedding.last_sync_at).label("oldest_attempt"), + ) .where( and_( models.MessageEmbedding.sync_state == "pending", _backoff_eligible(models.MessageEmbedding.last_sync_at), ) ) - .order_by(models.MessageEmbedding.last_sync_at.asc().nullsfirst()) + .group_by(models.MessageEmbedding.message_id) + .order_by(func.min(models.MessageEmbedding.last_sync_at).asc().nullsfirst()) .limit(batch_size) + ) + msg_id_rows = (await db.execute(msg_id_stmt)).all() + message_ids = [row[0] for row in msg_id_rows] + if not message_ids: + return [] + + # Step 2: claim all pending rows for those messages. Skip rows another + # reconciler holds; if we can't claim every chunk of a message right now, + # the message will be retried next cycle. + rows_stmt = ( + select(models.MessageEmbedding) + .where( + and_( + models.MessageEmbedding.message_id.in_(message_ids), + models.MessageEmbedding.sync_state == "pending", + _backoff_eligible(models.MessageEmbedding.last_sync_at), + ) + ) + .order_by(models.MessageEmbedding.message_id, models.MessageEmbedding.id) .with_for_update(skip_locked=True) ) - - result = await db.execute(stmt) + result = await db.execute(rows_stmt) return list(result.scalars().all()) @@ -177,6 +204,63 @@ async def _bump_message_embedding_sync_attempts( ) +async def compute_chunk_positions( + db: AsyncSession, message_ids: list[str] +) -> dict[int, int]: + """Map each MessageEmbedding row id to its 0-indexed chunk position within + its message. + + Positions are derived from the full set of sibling rows for each message, + ordered by ``(message_id, id)`` — never from a partial subset — so the + ``{message_id}_{chunk_position}`` vector id stays stable no matter which + rows a given caller claimed. Shared by the reconciler and the immediate + embed path so the two writers always agree on vector ids. + """ + if not message_ids: + return {} + + sibling_stmt = ( + select(models.MessageEmbedding.id, models.MessageEmbedding.message_id) + .where(models.MessageEmbedding.message_id.in_(message_ids)) + .order_by(models.MessageEmbedding.message_id, models.MessageEmbedding.id) + ) + sibling_rows = (await db.execute(sibling_stmt)).all() + + embs_by_message: dict[str, list[int]] = {} + for emb_id, msg_id in sibling_rows: + embs_by_message.setdefault(msg_id, []).append(emb_id) + + chunk_position: dict[int, int] = {} + for emb_ids in embs_by_message.values(): + for pos, emb_id in enumerate(emb_ids): + chunk_position[emb_id] = pos + return chunk_position + + +def build_message_vector_record( + *, + message_id: str, + chunk_position: int, + session_name: str | None, + peer_name: str | None, + embedding: list[float], +) -> VectorRecord: + """Build the external-store record for one message-embedding chunk. + + Single source of the ``{message_id}_{chunk_position}`` vector id and the + metadata shape, shared by the reconciler and the immediate embed path. + """ + return VectorRecord( + id=f"{message_id}_{chunk_position}", + embedding=[float(x) for x in embedding], + metadata={ + "message_id": message_id, + "session_name": session_name, + "peer_name": peer_name, + }, + ) + + async def _sync_documents( db: AsyncSession, documents: list[models.Document], @@ -306,16 +390,20 @@ async def _sync_documents( async def _sync_message_embeddings( db: AsyncSession, embeddings: list[models.MessageEmbedding], - external_vector_store: VectorStore, + external_vector_store: VectorStore | None, ) -> tuple[int, int]: """ - Sync a batch of pending message embeddings to the external vector store. + Sync a batch of pending message embeddings. - Handles three cases for each embedding: + When `external_vector_store` is provided, handles three cases per embedding: 1. Embedding exists in postgres → use it for external upsert 2. Embedding missing + need postgres storage → re-embed, write to both stores 3. Embedding missing + external-only mode → re-embed, write to external only + When `external_vector_store` is None (pgvector-only mode), re-embeds any + pending row missing a vector, writes the vector to postgres, and marks + sync_state='synced'. No external upsert is performed. + Returns (synced_count, failed_count). """ if not embeddings: @@ -338,8 +426,12 @@ async def _sync_message_embeddings( if embs_needing_embed: try: contents = [emb.content for emb in embs_needing_embed] + # MESSAGE_CREATE (not VECTOR_SYNC): these rows come from create_messages + # as pending chunks; document re-embeds stay on VECTOR_SYNC below. + workspaces = {emb.workspace_name for emb in embs_needing_embed} with embedding_call_purpose( - EmbeddingCallPurpose.VECTOR_SYNC.value, + EmbeddingCallPurpose.MESSAGE_CREATE.value, + workspace_name=workspaces.pop() if len(workspaces) == 1 else None, parent_category="reconciliation", ): new_embeddings = await embedding_client.simple_batch_embed(contents) @@ -368,6 +460,32 @@ async def _sync_message_embeddings( await _bump_message_embedding_sync_attempts(db, failed_to_embed) failed_count += len(failed_to_embed) + # pgvector-only mode: no external store to upsert to. Any row that now + # has an embedding (either pre-existing or freshly embedded) is fully + # synced. Write embeddings via per-row UPDATE so the vector is persisted + # alongside sync_state in a single statement (session has autoflush=False, + # so the ORM mutation above isn't enough on its own). + if external_vector_store is None: + embs_done: list[models.MessageEmbedding] = [] + for emb in embeddings: + new_emb = freshly_embedded.get(emb.id) + existing = emb.embedding + if new_emb is None and existing is None: + continue + await db.execute( + update(models.MessageEmbedding) + .where(models.MessageEmbedding.id == emb.id) + .values( + sync_state="synced", + last_sync_at=func.now(), + sync_attempts=0, + **({"embedding": new_emb} if new_emb is not None else {}), + ) + ) + embs_done.append(emb) + synced_count += len(embs_done) + return synced_count, failed_count + # Step 2: Compute chunk positions for vector IDs # Messages can be split into multiple chunks; we need {message_id}_{chunk_position} # @@ -380,21 +498,7 @@ async def _sync_message_embeddings( # 2. Removing MessageEmbedding table entirely if it becomes unnecessary # See: https://github.com/plastic-labs/honcho/issues/XXX message_ids = list({emb.message_id for emb in embeddings}) - sibling_stmt = ( - select(models.MessageEmbedding.id, models.MessageEmbedding.message_id) - .where(models.MessageEmbedding.message_id.in_(message_ids)) - .order_by(models.MessageEmbedding.message_id, models.MessageEmbedding.id) - ) - sibling_rows = (await db.execute(sibling_stmt)).all() - - embs_by_message: dict[str, list[int]] = {} - for emb_id, msg_id in sibling_rows: - embs_by_message.setdefault(msg_id, []).append(emb_id) - - chunk_position: dict[int, int] = {} - for emb_ids in embs_by_message.values(): - for pos, emb_id in enumerate(emb_ids): - chunk_position[emb_id] = pos + chunk_position = await compute_chunk_positions(db, message_ids) # Step 3: Build vector records and upsert to external store (all cases) by_namespace: dict[str, list[models.MessageEmbedding]] = {} @@ -416,14 +520,12 @@ async def _sync_message_embeddings( continue vector_records.append( - VectorRecord( - id=f"{emb.message_id}_{chunk_position[emb.id]}", - embedding=[float(x) for x in embedding], - metadata={ - "message_id": emb.message_id, - "session_name": emb.session_name, - "peer_name": emb.peer_name, - }, + build_message_vector_record( + message_id=emb.message_id, + chunk_position=chunk_position[emb.id], + session_name=emb.session_name, + peer_name=emb.peer_name, + embedding=embedding, ) ) embs_to_sync.append(emb) @@ -433,11 +535,23 @@ async def _sync_message_embeddings( try: await external_vector_store.upsert_many(namespace, vector_records) - await db.execute( - update(models.MessageEmbedding) - .where(models.MessageEmbedding.id.in_([e.id for e in embs_to_sync])) - .values(sync_state="synced", last_sync_at=func.now(), sync_attempts=0) - ) + # Per-row UPDATEs so freshly-embedded rows persist the vector + # alongside sync_state. Session has autoflush=False so the ORM + # mutation above isn't sufficient on its own. + for emb in embs_to_sync: + new_emb = freshly_embedded.get(emb.id) + values: dict[str, Any] = { + "sync_state": "synced", + "last_sync_at": func.now(), + "sync_attempts": 0, + } + if new_emb is not None and store_in_postgres: + values["embedding"] = new_emb + await db.execute( + update(models.MessageEmbedding) + .where(models.MessageEmbedding.id == emb.id) + .values(**values) + ) synced_count += len(embs_to_sync) except VectorStoreError: logger.warning( @@ -512,7 +626,7 @@ async def _reconcile_documents_batch( async def _reconcile_message_embeddings_batch( - external_vector_store: VectorStore, + external_vector_store: VectorStore | None, metrics: ReconciliationMetrics, ) -> bool: """ @@ -592,11 +706,18 @@ async def run_vector_reconciliation_cycle() -> ReconciliationMetrics: external_vector_store = get_external_vector_store() deadline = time.monotonic() + RECONCILIATION_TIME_BUDGET_SECONDS - # If no external vector store (pgvector mode), only clean up soft-deleted documents + # pgvector-only mode: still need to embed pending MessageEmbedding rows + # (create_messages defers embedding to the reconciler), then clean up. if external_vector_store is None: while time.monotonic() < deadline: - did_work = await _cleanup_pgvector_batch(metrics) - if not did_work: + embs_work = await _reconcile_message_embeddings_batch(None, metrics) + + if time.monotonic() >= deadline: + break + + cleanup_work = await _cleanup_pgvector_batch(metrics) + + if not (embs_work or cleanup_work): break logger.info("Vector reconciliation cycle completed (pgvector mode)") return metrics diff --git a/src/routers/messages.py b/src/routers/messages.py index b58a3287..5e10c472 100644 --- a/src/routers/messages.py +++ b/src/routers/messages.py @@ -21,6 +21,7 @@ from src.config import settings from src.dependencies import db, read_db from src.deriver import enqueue from src.exceptions import FileTooLargeError, ResourceNotFoundException +from src.reconciler.embed_now import embed_messages_now from src.security import require_auth from src.telemetry import prometheus_metrics from src.telemetry.events import FileUploadedEvent, MessageCreatedEvent, emit @@ -140,6 +141,13 @@ async def create_messages_for_session( # Enqueue all messages in one call background_tasks.add_task(enqueue, payloads) + # Embed immediately so messages are searchable within seconds; the + # reconciler is the fallback for anything left pending. + if settings.EMBED_MESSAGES and created_messages: + background_tasks.add_task( + embed_messages_now, [m.public_id for m in created_messages] + ) + return created_messages except ValueError as e: logger.warning(f"Failed to create messages for session {session_id}: {str(e)}") @@ -206,6 +214,14 @@ async def create_messages_with_file( ] background_tasks.add_task(enqueue, payloads) + + # Embed immediately so messages are searchable within seconds; the + # reconciler is the fallback for anything left pending. + if settings.EMBED_MESSAGES and created_messages: + background_tasks.add_task( + embed_messages_now, [m.public_id for m in created_messages] + ) + logger.debug( "Batch of %s messages created from file uploads and queued for processing", len(created_messages), diff --git a/src/telemetry/events/llm.py b/src/telemetry/events/llm.py index 9cbe7901..8d74d2f6 100644 --- a/src/telemetry/events/llm.py +++ b/src/telemetry/events/llm.py @@ -164,6 +164,9 @@ class EmbeddingCallPurpose(str, Enum): CREATE_OBSERVATIONS = "create_observations" VECTOR_SYNC = "vector_sync" SUMMARY = "summary" + # Pending MessageEmbedding rows from create_messages; embedding runs in the + # reconciler (not inline on the API path). Distinct from VECTOR_SYNC, which + # covers document re-embeds and other vector-store healing work. MESSAGE_CREATE = "message_create" # Added so previously-unattributed call sites land on a distinct slug # instead of None. Closed taxonomy — coordinate with analytics before diff --git a/tests/conftest.py b/tests/conftest.py index df2b194d..4aeaf56c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -484,6 +484,9 @@ def mock_openai_embeddings(request: pytest.FixtureRequest): patch( "src.embedding_client.embedding_client.simple_batch_embed" ) as mock_simple_batch_embed, + patch( + "src.embedding_client.embedding_client.prepare_chunks" + ) as mock_prepare_chunks, patch("src.embedding_client.embedding_client.batch_embed") as mock_batch_embed, ): # Mock the embed method to return content-dependent embedding @@ -497,6 +500,14 @@ def mock_openai_embeddings(request: pytest.FixtureRequest): mock_simple_batch_embed.side_effect = mock_simple_batch_embed_func + def mock_prepare_chunks_func( + id_resource_dict: dict[str, str], + ) -> dict[str, list[str]]: + # No real tokenizer in mocks: treat each input as a single chunk. + return {text_id: [text] for text_id, text in id_resource_dict.items()} + + mock_prepare_chunks.side_effect = mock_prepare_chunks_func + # Mock the batch_embed method to return content-dependent embeddings async def mock_batch_embed_func( id_resource_dict: dict[str, str], @@ -511,6 +522,7 @@ def mock_openai_embeddings(request: pytest.FixtureRequest): yield { "embed": mock_embed, "simple_batch_embed": mock_simple_batch_embed, + "prepare_chunks": mock_prepare_chunks, "batch_embed": mock_batch_embed, } @@ -790,7 +802,7 @@ def mock_tracked_db(request: pytest.FixtureRequest): yield return - from contextlib import asynccontextmanager + from contextlib import ExitStack, asynccontextmanager db_engine = request.getfixturevalue("db_engine") session_factory = async_sessionmaker(bind=db_engine, expire_on_commit=False) @@ -803,28 +815,35 @@ def mock_tracked_db(request: pytest.FixtureRequest): async with session_factory() as session: yield session - with ( - patch("src.dependencies.tracked_db", mock_tracked_db_context), - patch("src.deriver.queue_manager.tracked_db", mock_tracked_db_context), - patch("src.deriver.consumer.tracked_db", mock_tracked_db_context), - patch("src.deriver.enqueue.tracked_db", mock_tracked_db_context), - patch("src.routers.peers.tracked_db", mock_tracked_db_context), - patch("src.crud.representation.tracked_db", mock_tracked_db_context), - patch("src.dreamer.orchestrator.tracked_db", mock_tracked_db_context), - patch("src.dreamer.dream_scheduler.tracked_db", mock_tracked_db_context), - patch("src.dialectic.chat.tracked_db", mock_tracked_db_context), - patch("src.utils.summarizer.tracked_db", mock_tracked_db_context), - patch("src.webhooks.events.tracked_db", mock_tracked_db_context), - patch("src.webhooks.webhook_delivery.tracked_db", mock_tracked_db_context), - patch("src.utils.agent_tools.tracked_db", mock_tracked_db_context), - patch("src.utils.search.tracked_db", mock_tracked_db_context), - patch("src.crud.document.tracked_db", mock_tracked_db_context), - patch("src.crud.message.tracked_db", mock_tracked_db_context), - patch("src.reconciler.sync_vectors.tracked_db", mock_tracked_db_context), - patch("src.dialectic.core.tracked_db", mock_tracked_db_context), - patch("src.dreamer.specialists.tracked_db", mock_tracked_db_context), - patch("src.dreamer.surprisal.tracked_db", mock_tracked_db_context), - ): + # Each module imports tracked_db by name, so patch every import site. + # Use ExitStack (not a parenthesized `with`) to stay under CPython's + # 20-statically-nested-block limit as this list grows. + tracked_db_targets = [ + "src.dependencies.tracked_db", + "src.deriver.queue_manager.tracked_db", + "src.deriver.consumer.tracked_db", + "src.deriver.enqueue.tracked_db", + "src.routers.peers.tracked_db", + "src.crud.representation.tracked_db", + "src.dreamer.orchestrator.tracked_db", + "src.dreamer.dream_scheduler.tracked_db", + "src.dialectic.chat.tracked_db", + "src.utils.summarizer.tracked_db", + "src.webhooks.events.tracked_db", + "src.webhooks.webhook_delivery.tracked_db", + "src.utils.agent_tools.tracked_db", + "src.utils.search.tracked_db", + "src.crud.document.tracked_db", + "src.crud.message.tracked_db", + "src.reconciler.sync_vectors.tracked_db", + "src.reconciler.embed_now.tracked_db", + "src.dialectic.core.tracked_db", + "src.dreamer.specialists.tracked_db", + "src.dreamer.surprisal.tracked_db", + ] + with ExitStack() as stack: + for target in tracked_db_targets: + stack.enter_context(patch(target, mock_tracked_db_context)) yield diff --git a/tests/deriver/test_embed_now.py b/tests/deriver/test_embed_now.py new file mode 100644 index 00000000..f6acd1c4 --- /dev/null +++ b/tests/deriver/test_embed_now.py @@ -0,0 +1,268 @@ +""" +Tests for the immediate message-embedding fast path (src/reconciler/embed_now.py). + +These exercise embed_messages_now end-to-end against the test database: it opens +its own tracked_db sessions (patched to the test engine in conftest), so each test +creates committed fixture rows and asserts on the result via the provided session. +""" + +from unittest.mock import AsyncMock, patch + +import pytest +from nanoid import generate as generate_nanoid +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from src import models +from src.reconciler.embed_now import embed_messages_now, reset_embed_semaphore +from src.vector_store import VectorStore + + +async def _create_message_with_pending_chunks( + db_session: AsyncSession, + workspace: models.Workspace, + peer: models.Peer, + chunk_contents: list[str], +) -> tuple[str, list[int]]: + """Create a message plus one pending MessageEmbedding row per chunk. + + Returns (message public_id, ordered embedding row ids). + """ + session = models.Session(name=str(generate_nanoid()), workspace_name=workspace.name) + db_session.add(session) + await db_session.commit() + + message_id = str(generate_nanoid()) + message = models.Message( + public_id=message_id, + session_name=session.name, + workspace_name=workspace.name, + peer_name=peer.name, + content=" ".join(chunk_contents), + seq_in_session=1, + ) + db_session.add(message) + await db_session.commit() + + rows = [ + models.MessageEmbedding( + content=chunk, + message_id=message_id, + workspace_name=workspace.name, + session_name=session.name, + peer_name=peer.name, + sync_state="pending", + embedding=None, + ) + for chunk in chunk_contents + ] + db_session.add_all(rows) + await db_session.commit() + for row in rows: + await db_session.refresh(row) + return message_id, [row.id for row in rows] + + +@pytest.fixture(autouse=True) +def reset_semaphore_fixture(): + """Rebuild the module semaphore per test so it binds to the active loop.""" + reset_embed_semaphore() + yield + reset_embed_semaphore() + + +@pytest.mark.asyncio +class TestEmbedMessagesNow: + async def test_pgvector_happy_path_marks_synced( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ) -> None: + """pgvector-only mode: rows get a vector and flip to synced immediately.""" + workspace, peer = sample_data + message_id, emb_ids = await _create_message_with_pending_chunks( + db_session, workspace, peer, ["hello world"] + ) + + await embed_messages_now([message_id]) + + for emb_id in emb_ids: + row = await db_session.get(models.MessageEmbedding, emb_id) + assert row is not None + await db_session.refresh(row) + assert row.sync_state == "synced" + assert row.embedding is not None + assert row.sync_attempts == 0 + + async def test_no_message_ids_is_noop(self) -> None: + """Empty input returns without touching the DB or embedding.""" + with patch( + "src.embedding_client.embedding_client.simple_batch_embed" + ) as mock_embed: + await embed_messages_now([]) + mock_embed.assert_not_called() + + async def test_already_synced_rows_not_reclaimed( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ) -> None: + """A second run finds no pending rows and does not re-embed.""" + workspace, peer = sample_data + message_id, _ = await _create_message_with_pending_chunks( + db_session, workspace, peer, ["first content"] + ) + await embed_messages_now([message_id]) + + with patch( + "src.embedding_client.embedding_client.simple_batch_embed" + ) as mock_embed: + await embed_messages_now([message_id]) + mock_embed.assert_not_called() + + async def test_embed_failure_leaves_rows_pending_and_leased( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ) -> None: + """Embedding failure must leave rows pending + leased, attempts untouched, + so the reconciler owns retry accounting.""" + workspace, peer = sample_data + message_id, emb_ids = await _create_message_with_pending_chunks( + db_session, workspace, peer, ["will fail"] + ) + + with patch( + "src.embedding_client.embedding_client.simple_batch_embed", + new=AsyncMock(side_effect=RuntimeError("provider down")), + ): + await embed_messages_now([message_id]) + + for emb_id in emb_ids: + row = await db_session.get(models.MessageEmbedding, emb_id) + assert row is not None + await db_session.refresh(row) + assert row.sync_state == "pending" + assert row.embedding is None + assert row.sync_attempts == 0 # lease only, no attempt bump + assert row.last_sync_at is not None # leased + + async def test_external_store_upserts_with_chunk_positioned_ids( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + mock_vector_store: VectorStore, + ) -> None: + """External-store mode: upsert each chunk with id {message_id}_{position} + and mark rows synced.""" + workspace, peer = sample_data + message_id, emb_ids = await _create_message_with_pending_chunks( + db_session, workspace, peer, ["chunk a", "chunk b", "chunk c"] + ) + + with patch( + "src.reconciler.embed_now.get_external_vector_store", + return_value=mock_vector_store, + ): + await embed_messages_now([message_id]) + + upsert_mock: AsyncMock = mock_vector_store.upsert_many # pyright: ignore[reportAssignmentType] + upsert_mock.assert_awaited() + upserted_ids = { + record.id for call in upsert_mock.await_args_list for record in call.args[1] + } + assert upserted_ids == { + f"{message_id}_0", + f"{message_id}_1", + f"{message_id}_2", + } + + for emb_id in emb_ids: + row = await db_session.get(models.MessageEmbedding, emb_id) + assert row is not None + await db_session.refresh(row) + assert row.sync_state == "synced" + + async def test_locked_chunk_skipped_keeps_positions_stable( + self, + db_session: AsyncSession, + db_engine: AsyncEngine, + sample_data: tuple[models.Workspace, models.Peer], + mock_vector_store: VectorStore, + ) -> None: + """If a sibling chunk is locked by another txn, SKIP LOCKED skips it but + chunk positions still come from the full sibling ordering — so the claimed + chunks keep their {message_id}_0 / _2 ids (not _0 / _1).""" + workspace, peer = sample_data + message_id, emb_ids = await _create_message_with_pending_chunks( + db_session, workspace, peer, ["chunk a", "chunk b", "chunk c"] + ) + locked_id = emb_ids[1] # middle chunk -> position 1 + + # Hold a row lock on the middle chunk from an independent transaction. + lock_factory = async_sessionmaker(bind=db_engine, expire_on_commit=False) + lock_session = lock_factory() + await lock_session.execute( + select(models.MessageEmbedding) + .where(models.MessageEmbedding.id == locked_id) + .with_for_update() + ) + try: + with patch( + "src.reconciler.embed_now.get_external_vector_store", + return_value=mock_vector_store, + ): + await embed_messages_now([message_id]) + finally: + await lock_session.rollback() + await lock_session.close() + + upsert_mock: AsyncMock = mock_vector_store.upsert_many # pyright: ignore[reportAssignmentType] + upserted_ids = { + record.id for call in upsert_mock.await_args_list for record in call.args[1] + } + assert upserted_ids == {f"{message_id}_0", f"{message_id}_2"} + + # The locked chunk stays pending; the other two are synced. + locked_row = await db_session.get(models.MessageEmbedding, locked_id) + assert locked_row is not None + await db_session.refresh(locked_row) + assert locked_row.sync_state == "pending" + for emb_id in (emb_ids[0], emb_ids[2]): + row = await db_session.get(models.MessageEmbedding, emb_id) + assert row is not None + await db_session.refresh(row) + assert row.sync_state == "synced" + + async def test_external_store_unavailable_leaves_rows_pending( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + mock_vector_store: VectorStore, + ) -> None: + """External-store mode: if upsert_many raises VectorStoreError, rows must + stay pending with no vector and untouched attempts, so the reconciler + heals them. embed_now never bumps sync_attempts.""" + from src.exceptions import VectorStoreError + + workspace, peer = sample_data + message_id, emb_ids = await _create_message_with_pending_chunks( + db_session, workspace, peer, ["chunk a", "chunk b"] + ) + + upsert_mock: AsyncMock = mock_vector_store.upsert_many # pyright: ignore[reportAssignmentType] + upsert_mock.side_effect = VectorStoreError("vector store down") + + with patch( + "src.reconciler.embed_now.get_external_vector_store", + return_value=mock_vector_store, + ): + await embed_messages_now([message_id]) + + for emb_id in emb_ids: + row = await db_session.get(models.MessageEmbedding, emb_id) + assert row is not None + await db_session.refresh(row) + assert row.sync_state == "pending" + assert row.embedding is None + assert row.sync_attempts == 0 # embed_now never bumps attempts diff --git a/tests/deriver/test_vector_reconciliation.py b/tests/deriver/test_vector_reconciliation.py index 748b84df..1c004886 100644 --- a/tests/deriver/test_vector_reconciliation.py +++ b/tests/deriver/test_vector_reconciliation.py @@ -23,6 +23,8 @@ from src.reconciler.sync_vectors import ( _reconcile_message_embeddings_batch, # pyright: ignore[reportPrivateUsage] _sync_documents, # pyright: ignore[reportPrivateUsage] _sync_message_embeddings, # pyright: ignore[reportPrivateUsage] + build_message_vector_record, + compute_chunk_positions, run_vector_reconciliation_cycle, ) from src.vector_store import ( @@ -873,6 +875,82 @@ class TestMessageEmbeddings: assert pending_emb.sync_attempts == 0 assert pending_emb.last_sync_at is None + async def test_pgvector_only_mode_embeds_and_marks_synced( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ) -> None: + """In pgvector-only mode, the reconciler must still embed pending rows.""" + workspace, peer = sample_data + pending_emb = await self._create_pending_message_embedding( + db_session, workspace, peer + ) + + # external_vector_store=None == pgvector-only mode. The reconciler should + # re-embed the pending row, write the vector to postgres, and mark synced. + synced, failed = await _sync_message_embeddings(db_session, [pending_emb], None) + + await db_session.commit() + await db_session.refresh(pending_emb) + + assert synced == 1 + assert failed == 0 + assert pending_emb.sync_state == "synced" + assert pending_emb.sync_attempts == 0 + assert pending_emb.embedding is not None + + async def test_all_chunks_of_a_message_claimed_together( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ) -> None: + """A single message's chunks must always be claimed in one batch. + + Selecting by message_id (not row) keeps `{message_id}_{chunk_index}` + vector IDs stable across reconciler cycles. + """ + workspace, peer = sample_data + + # Create one message with 5 chunks. + session = models.Session( + name=str(generate_nanoid()), workspace_name=workspace.name + ) + db_session.add(session) + await db_session.commit() + + message_id = str(generate_nanoid()) + message = models.Message( + public_id=message_id, + session_name=session.name, + workspace_name=workspace.name, + peer_name=peer.name, + content="full message content", + seq_in_session=1, + ) + db_session.add(message) + await db_session.commit() + + chunk_count = 5 + for i in range(chunk_count): + db_session.add( + models.MessageEmbedding( + content=f"chunk-{i}", + message_id=message_id, + workspace_name=workspace.name, + session_name=session.name, + peer_name=peer.name, + sync_state="pending", + embedding=None, + ) + ) + await db_session.commit() + + # Even with batch_size=1, all 5 chunks for the message should be claimed + # together because the query selects by distinct message_id first. + claimed = await _get_message_embeddings_needing_sync(db_session, batch_size=1) + assert len(claimed) == chunk_count + assert all(emb.message_id == message_id for emb in claimed) + @pytest.mark.asyncio class TestEndToEndReconciliation: @@ -913,3 +991,90 @@ class TestEndToEndReconciliation: mock_reconcile_docs.assert_awaited_once() mock_reconcile_embs.assert_awaited_once() mock_cleanup_docs.assert_awaited_once() + + +def test_build_message_vector_record() -> None: + """The shared vector-id/metadata builder: id is {message_id}_{position}, + embeddings are coerced to float, metadata shape is fixed.""" + record = build_message_vector_record( + message_id="msg_abc", + chunk_position=2, + session_name="sess", + peer_name="peer", + embedding=[1, 2, 3], # ints, must be coerced + ) + assert record.id == "msg_abc_2" + assert record.embedding == [1.0, 2.0, 3.0] + assert all(isinstance(x, float) for x in record.embedding) + assert record.metadata == { + "message_id": "msg_abc", + "session_name": "sess", + "peer_name": "peer", + } + + +@pytest.mark.asyncio +class TestComputeChunkPositions: + """Direct coverage for compute_chunk_positions, the source of truth for + {message_id}_{position} vector ids shared by the reconciler and embed_now.""" + + async def test_empty_input_returns_empty(self, db_session: AsyncSession) -> None: + assert await compute_chunk_positions(db_session, []) == {} + + async def test_positions_are_per_message_zero_indexed( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ) -> None: + """Each message's rows are numbered from 0 in (message_id, id) order, + independent of how rows from other messages interleave.""" + workspace, peer = sample_data + session = models.Session( + name=str(generate_nanoid()), workspace_name=workspace.name + ) + db_session.add(session) + await db_session.commit() + + # msg_a has 2 chunks, msg_b has 1 chunk. + msg_a = str(generate_nanoid()) + msg_b = str(generate_nanoid()) + for seq, mid in enumerate((msg_a, msg_b), start=1): + db_session.add( + models.Message( + public_id=mid, + session_name=session.name, + workspace_name=workspace.name, + peer_name=peer.name, + content="content", + seq_in_session=seq, + ) + ) + await db_session.commit() + + rows = [ + models.MessageEmbedding( + content=content, + message_id=mid, + workspace_name=workspace.name, + session_name=session.name, + peer_name=peer.name, + sync_state="pending", + embedding=None, + ) + for mid, content in ( + (msg_a, "a0"), + (msg_a, "a1"), + (msg_b, "b0"), + ) + ] + db_session.add_all(rows) + await db_session.commit() + for row in rows: + await db_session.refresh(row) + a0, a1, b0 = (row.id for row in rows) + + positions = await compute_chunk_positions(db_session, [msg_a, msg_b]) + + assert positions[a0] == 0 + assert positions[a1] == 1 + assert positions[b0] == 0 diff --git a/tests/integration/test_message_embeddings.py b/tests/integration/test_message_embeddings.py index 83ae2ae2..b2f1b032 100644 --- a/tests/integration/test_message_embeddings.py +++ b/tests/integration/test_message_embeddings.py @@ -18,6 +18,7 @@ from src.config import settings from src.crud import create_messages from src.crud import message as message_crud from src.models import Message, Peer, Workspace +from src.reconciler.sync_vectors import run_vector_reconciliation_cycle from src.schemas import MessageCreate from src.utils.search import search @@ -161,9 +162,12 @@ async def test_blank_messages_are_not_sent_for_embedding( nonblank_content, ] - mock_openai_embeddings["batch_embed"].assert_awaited_once() - batch_arg = mock_openai_embeddings["batch_embed"].await_args.args[0] - assert batch_arg == {created_messages[1].public_id: nonblank_content} + # Inline embedding is gone: create_messages should chunk via prepare_chunks + # (no network) and never call batch_embed. + mock_openai_embeddings["batch_embed"].assert_not_awaited() + mock_openai_embeddings["prepare_chunks"].assert_called_once() + prepare_arg = mock_openai_embeddings["prepare_chunks"].call_args.args[0] + assert prepare_arg == {created_messages[1].public_id: nonblank_content} stmt = select(models.MessageEmbedding).where( models.MessageEmbedding.message_id.in_( @@ -176,6 +180,8 @@ async def test_blank_messages_are_not_sent_for_embedding( assert len(embedding_records) == 1 assert embedding_records[0].message_id == created_messages[1].public_id assert embedding_records[0].content == nonblank_content + assert embedding_records[0].sync_state == "pending" + assert embedding_records[0].embedding is None @pytest.mark.asyncio @@ -327,13 +333,23 @@ async def test_semantic_search_when_embeddings_enabled( assert len(created_messages) == 1 created_message = created_messages[0] - # Verify the embedding was created - stmt = select(models.MessageEmbedding).where( - models.MessageEmbedding.message_id == created_message.public_id + # The pending row exists, but the embedding is generated by the reconciler. + # Drive a reconciliation cycle so the row gets an embedding before search. + await db_session.commit() + await run_vector_reconciliation_cycle() + + # Verify the row was created and reconciled. expire_on_commit=False keeps + # stale cached ORM rows, so use populate_existing() to force a reload from + # the DB (the reconciler wrote in a different session). + stmt = ( + select(models.MessageEmbedding) + .where(models.MessageEmbedding.message_id == created_message.public_id) + .execution_options(populate_existing=True) ) result = await db_session.execute(stmt) embedding_record = result.scalar_one_or_none() assert embedding_record is not None + assert embedding_record.sync_state == "synced" # Now test semantic search without explicitly setting semantic=True # This should use semantic search because EMBED_MESSAGES is True @@ -361,6 +377,77 @@ async def test_semantic_search_when_embeddings_enabled( assert created_message.public_id in found_message_ids +@pytest.mark.asyncio +async def test_pgvector_search_excludes_pending_unembedded_rows( + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], +): + """Pending MessageEmbedding rows (embedding=None, awaiting the immediate + path or reconciler) must not appear in pgvector semantic search results: + their NULL distance sorts last and would pad the window with unranked + messages.""" + test_workspace, test_peer = sample_data + test_session = models.Session( + workspace_name=test_workspace.name, name=str(generate_nanoid()) + ) + db_session.add(test_session) + await db_session.commit() + + embedded_id = str(generate_nanoid()) + pending_id = str(generate_nanoid()) + for seq, (mid, content) in enumerate( + ((embedded_id, "embedded message"), (pending_id, "pending message")), start=1 + ): + db_session.add( + models.Message( + public_id=mid, + session_name=test_session.name, + workspace_name=test_workspace.name, + peer_name=test_peer.name, + content=content, + seq_in_session=seq, + ) + ) + await db_session.commit() + + dims = settings.EMBEDDING.VECTOR_DIMENSIONS + db_session.add_all( + [ + models.MessageEmbedding( + content="embedded message", + message_id=embedded_id, + workspace_name=test_workspace.name, + session_name=test_session.name, + peer_name=test_peer.name, + sync_state="synced", + embedding=[0.1] * dims, + ), + models.MessageEmbedding( + content="pending message", + message_id=pending_id, + workspace_name=test_workspace.name, + session_name=test_session.name, + peer_name=test_peer.name, + sync_state="pending", + embedding=None, + ), + ] + ) + await db_session.commit() + + snippets = await message_crud._search_messages_pgvector( # pyright: ignore[reportPrivateUsage] + db_session, + test_workspace.name, + test_session.name, + query_embedding=[0.1] * dims, + limit=10, + ) + + matched_ids = {msg.public_id for matched, _context in snippets for msg in matched} + assert embedded_id in matched_ids + assert pending_id not in matched_ids + + @pytest.mark.asyncio async def test_build_merged_snippets_batches_context_query_across_sessions(): """Context expansion should not issue one DB query per matched session.""" @@ -638,15 +725,14 @@ async def test_message_chunking_creates_multiple_embeddings( test_message_content = "This is a very long message that should be chunked into multiple pieces because it exceeds the token limit that we set for testing purposes. This message contains many words and should definitely be split into multiple chunks." - def mock_batch_embed_chunked( - id_resource_dict: dict[str, str], - ) -> dict[str, list[list[float]]]: - return { - text_id: [[0.1] * 1536, [0.2] * 1536, [0.3] * 1536] # 3 chunks per message - for text_id in id_resource_dict - } + chunk_texts = ["chunk-a", "chunk-b", "chunk-c"] - mock_openai_embeddings["batch_embed"].side_effect = mock_batch_embed_chunked + def mock_prepare_chunks_chunked( + id_resource_dict: dict[str, str], + ) -> dict[str, list[str]]: + return {text_id: list(chunk_texts) for text_id in id_resource_dict} + + mock_openai_embeddings["prepare_chunks"].side_effect = mock_prepare_chunks_chunked messages = [ MessageCreate( @@ -666,22 +752,26 @@ async def test_message_chunking_creates_multiple_embeddings( assert len(created_messages) == 1 created_message = created_messages[0] - # Query the MessageEmbedding table to verify multiple embeddings were created - stmt = select(models.MessageEmbedding).where( - models.MessageEmbedding.message_id == created_message.public_id + # batch_embed is no longer called inline; embedding is deferred to reconciler. + mock_openai_embeddings["batch_embed"].assert_not_awaited() + + # Query the MessageEmbedding table to verify multiple pending rows were created, + # one per chunk, in chunk order (id ascending). + stmt = ( + select(models.MessageEmbedding) + .where(models.MessageEmbedding.message_id == created_message.public_id) + .order_by(models.MessageEmbedding.id) ) result = await db_session.execute(stmt) embedding_records = list(result.scalars().all()) - # Verify multiple embedding records were created (one per chunk) - # Embedding vectors are now stored externally in the vector store - assert len(embedding_records) == 3 # Should have 3 embeddings for 3 chunks + assert len(embedding_records) == 3 + assert [r.content for r in embedding_records] == chunk_texts - for _, embedding_record in enumerate(embedding_records): + for embedding_record in embedding_records: assert embedding_record.message_id == created_message.public_id - assert ( - embedding_record.content == test_message_content - ) # Full content stored in each assert embedding_record.workspace_name == test_workspace.name assert embedding_record.session_name == test_session.name assert embedding_record.peer_name == test_peer.name + assert embedding_record.sync_state == "pending" + assert embedding_record.embedding is None diff --git a/tests/llm/test_embedding_client.py b/tests/llm/test_embedding_client.py index fcee66d9..a4642159 100644 --- a/tests/llm/test_embedding_client.py +++ b/tests/llm/test_embedding_client.py @@ -339,3 +339,108 @@ def test_resolve_send_dimensions_never_returns_false_regardless( monkeypatch, ) assert s.resolve_send_dimensions() is False + + +@pytest.mark.asyncio +async def test_simple_batch_embed_respects_token_budget_per_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """simple_batch_embed must split inputs across requests so per-request token cap holds.""" + fake_embeddings = FakeOpenAIEmbeddingsAPI([0.5] * 4) + + class FakeOpenAIClient: + def __init__(self, *, api_key: str | None, base_url: str | None) -> None: + self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings + + monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient) + + # max_input_tokens=100 per single input; max_tokens_per_request=120 total, + # so two ~80-token inputs must end up in *separate* requests. + client = _EmbeddingClient( + EmbeddingModelConfig( + transport="openai", + model="text-embedding-3-small", + api_key="test-key", + base_url=None, + ), + vector_dimensions=4, + max_input_tokens=100, + max_tokens_per_request=120, + send_dimensions=False, + ) + + # "word " * 80 produces ~80 tokens with cl100k_base/the model encoding. + long_a = ("alpha " * 80).strip() + long_b = ("beta " * 80).strip() + + out = await client.simple_batch_embed([long_a, long_b]) + assert len(out) == 2 + # Per-request token cap forces two separate requests. + assert len(fake_embeddings.calls) == 2 + + +@pytest.mark.asyncio +async def test_simple_batch_embed_rejects_oversized_input( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Inputs that exceed max_embedding_tokens must raise ValueError immediately.""" + fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 4) + + class FakeOpenAIClient: + def __init__(self, *, api_key: str | None, base_url: str | None) -> None: + self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings + + monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient) + + client = _EmbeddingClient( + EmbeddingModelConfig( + transport="openai", + model="text-embedding-3-small", + api_key="test-key", + base_url=None, + ), + vector_dimensions=4, + max_input_tokens=10, + max_tokens_per_request=1000, + send_dimensions=False, + ) + + too_long = ("word " * 50).strip() + with pytest.raises(ValueError, match="maximum token limit"): + await client.simple_batch_embed([too_long]) + + +def test_prepare_chunks_returns_ordered_chunks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """prepare_chunks must split oversized inputs using the same rules as batch_embed.""" + fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 4) + + class FakeOpenAIClient: + def __init__(self, *, api_key: str | None, base_url: str | None) -> None: + self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings + + monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient) + + client = _EmbeddingClient( + EmbeddingModelConfig( + transport="openai", + model="text-embedding-3-small", + api_key="test-key", + base_url=None, + ), + vector_dimensions=4, + max_input_tokens=10, + max_tokens_per_request=1000, + send_dimensions=False, + ) + + short_text = "hello" + long_text = ("word " * 50).strip() + + out = client.prepare_chunks({"short": short_text, "long": long_text}) + + assert out["short"] == [short_text] + assert len(out["long"]) > 1 + # Order preserved + assert isinstance(out["long"][0], str) diff --git a/tests/routes/test_messages.py b/tests/routes/test_messages.py index a121cfe0..2d3c77e5 100644 --- a/tests/routes/test_messages.py +++ b/tests/routes/test_messages.py @@ -1,5 +1,5 @@ import datetime -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from fastapi.testclient import TestClient @@ -46,6 +46,92 @@ async def test_create_message( assert "id" in message +@pytest.mark.asyncio +async def test_create_message_schedules_immediate_embed( + client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] +): + """Creating messages should schedule the immediate-embed background task with + the created messages' public ids.""" + test_workspace, test_peer = sample_data + test_session = models.Session( + workspace_name=test_workspace.name, name=str(generate_nanoid()) + ) + db_session.add(test_session) + await db_session.commit() + + with ( + patch("src.config.settings.EMBED_MESSAGES", True), + patch( + "src.routers.messages.embed_messages_now", new=AsyncMock() + ) as mock_embed_now, + ): + response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + json={"messages": [{"content": "hello", "peer_id": test_peer.name}]}, + ) + assert response.status_code == 201 + public_id = response.json()[0]["id"] + mock_embed_now.assert_awaited_once_with([public_id]) + + +@pytest.mark.asyncio +async def test_create_message_skips_embed_when_disabled( + client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] +): + """When EMBED_MESSAGES is disabled, the immediate-embed task is not scheduled.""" + test_workspace, test_peer = sample_data + test_session = models.Session( + workspace_name=test_workspace.name, name=str(generate_nanoid()) + ) + db_session.add(test_session) + await db_session.commit() + + with ( + patch("src.config.settings.EMBED_MESSAGES", False), + patch( + "src.routers.messages.embed_messages_now", new=AsyncMock() + ) as mock_embed_now, + ): + response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + json={"messages": [{"content": "hello", "peer_id": test_peer.name}]}, + ) + assert response.status_code == 201 + mock_embed_now.assert_not_called() + + +@pytest.mark.asyncio +async def test_file_upload_schedules_immediate_embed( + client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] +): + """The file-upload path schedules the immediate-embed task with the created + messages' public ids, mirroring the session-message path.""" + import io + + test_workspace, test_peer = sample_data + test_session = models.Session( + workspace_name=test_workspace.name, name=str(generate_nanoid()) + ) + db_session.add(test_session) + await db_session.commit() + + with ( + patch("src.config.settings.EMBED_MESSAGES", True), + patch( + "src.routers.messages.embed_messages_now", new=AsyncMock() + ) as mock_embed_now, + ): + files = {"file": ("note.txt", io.BytesIO(b"hello world"), "text/plain")} + response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/upload", + files=files, + data={"peer_id": test_peer.name}, + ) + assert response.status_code == 201 + expected_ids = [m["id"] for m in response.json()] + mock_embed_now.assert_awaited_once_with(expected_ids) + + @pytest.mark.asyncio async def test_create_batch_messages_with_metadata( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] From 44c85fa3e2101e194b89e1461976536ce9ce8793 Mon Sep 17 00:00:00 2001 From: Rajat Ahuja Date: Thu, 11 Jun 2026 10:35:20 -0400 Subject: [PATCH 08/65] fix: private ip address check for webhook creation (#793) --- src/schemas/api.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/schemas/api.py b/src/schemas/api.py index 237c4e14..4863d99a 100644 --- a/src/schemas/api.py +++ b/src/schemas/api.py @@ -681,10 +681,17 @@ class WebhookEndpointCreate(WebhookEndpointBase): if parsed.hostname: try: ip_address = ipaddress.ip_address(parsed.hostname) - if ip_address.is_private: - raise ValueError("Private IP addresses are not allowed") - except ValueError: # Not an IP address, might be a hostname - pass + except ValueError: # Not an IP literal — a hostname, leave it alone + ip_address = None + if ip_address is not None and ( + ip_address.is_private + or ip_address.is_loopback + or ip_address.is_link_local + or ip_address.is_reserved + or ip_address.is_multicast + or ip_address.is_unspecified + ): + raise ValueError("Private/internal IP addresses are not allowed") return v From f20a13926e34078cdf40ba9d54491b6ca08e596d Mon Sep 17 00:00:00 2001 From: Eri Barrett Date: Thu, 11 Jun 2026 12:37:19 -0400 Subject: [PATCH 09/65] fix(dedup): reinforce times_derived on duplicate detection (#768) * fix(dedup): reinforce times_derived on duplicate detection times_derived was never incremented: the reject-new branch dropped the reinforcement and the new-wins branch reset the count to 1, so the column stayed pinned at 1 for nearly every conclusion. With every value equal, ORDER BY times_derived DESC resolved to arbitrary heap order (oldest rows first), which froze stale conclusions to the front of injected context. - reject-new: increment existing_doc.times_derived - new-wins: carry existing count forward onto the replacement - add created_at DESC tiebreaker to both most_derived queries * test(dedup): guard times_derived reinforcement + recency tiebreak Three regression tests, each fails on pre-fix code: - most-derived ties break toward recency, not insertion order - rejecting a duplicate reinforces the surviving doc - a winning duplicate inherits the replaced doc's count + 1 * fix(dedup): atomic reinforcement increment + deterministic tiebreak --------- Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> --- src/crud/document.py | 29 +++- src/crud/representation.py | 8 +- tests/crud/test_document.py | 191 ++++++++++++++++++++++ tests/crud/test_representation_manager.py | 53 +++++- 4 files changed, 275 insertions(+), 6 deletions(-) diff --git a/src/crud/document.py b/src/crud/document.py index f8560629..018f3411 100644 --- a/src/crud/document.py +++ b/src/crud/document.py @@ -176,7 +176,8 @@ async def query_documents_most_derived( limit: Maximum number of documents to return Returns: - Sequence of documents ordered by times_derived descending + Sequence of documents ordered by times_derived descending, + ties broken by created_at descending (most recent first) """ stmt = ( select(models.Document) @@ -186,7 +187,13 @@ async def query_documents_most_derived( models.Document.observed == observed, models.Document.deleted_at.is_(None), ) - .order_by(models.Document.times_derived.desc()) + .order_by( + models.Document.times_derived.desc(), + models.Document.created_at.desc(), + # created_at is the transaction timestamp, so documents created in + # the same batch share it -- id keeps the order deterministic. + models.Document.id, + ) .limit(limit) ) @@ -980,7 +987,13 @@ async def is_rejected_duplicate( If the document is not a duplicate, returns False. If the document is a duplicate AND the new document is superior, - deletes the existing document and returns False. + deletes the existing document and returns False. In this case + ``doc.times_derived`` is updated in place to carry the replaced + document's reinforcement count forward. + + If the document is a duplicate AND the existing document is superior, + increments the existing document's ``times_derived`` to record the + reinforcement, then returns True. """ # Step 1: Find potential duplicates using cosine similarity similar_docs = await query_documents( @@ -1014,12 +1027,20 @@ async def is_rejected_duplicate( logger.warning( f"[DUPLICATE DETECTION] Deleting existing in favor of new. new='{doc.content}', existing='{existing_doc.content}'." ) + # Carry the reinforcement count forward so replacing a duplicate counts as + # another derivation rather than resetting times_derived to 1. + doc.times_derived = max(doc.times_derived, existing_doc.times_derived + 1) # Soft-delete the existing document - reconciliation will clean up vectors and hard-delete existing_doc.deleted_at = datetime.datetime.now(datetime.timezone.utc) await db.flush() return False # Don't reject the new document - # Existing document has more information, reject the new one + # Existing document has more information, reject the new one but record the + # reinforcement: a semantic duplicate was derived again. Assign a SQL + # expression so the increment is atomic server-side -- concurrent workers + # reinforcing the same document must not lose updates. + existing_doc.times_derived = models.Document.times_derived + 1 + await db.flush() logger.warning( f"[DUPLICATE DETECTION] Rejecting new in favor of existing. new='{doc.content}', existing='{existing_doc.content}'." ) diff --git a/src/crud/representation.py b/src/crud/representation.py index 4ade79a4..558a5cce 100644 --- a/src/crud/representation.py +++ b/src/crud/representation.py @@ -444,7 +444,13 @@ class RepresentationManager: models.Document.observed == self.observed, models.Document.deleted_at.is_(None), ) - .order_by(models.Document.times_derived.desc()) + .order_by( + models.Document.times_derived.desc(), + models.Document.created_at.desc(), + # created_at is the transaction timestamp, so documents created + # in the same batch share it -- id keeps the order deterministic. + models.Document.id, + ) ) result = await db.execute(stmt) diff --git a/tests/crud/test_document.py b/tests/crud/test_document.py index f25b75d2..ccde3dac 100644 --- a/tests/crud/test_document.py +++ b/tests/crud/test_document.py @@ -6,6 +6,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src import crud, models, schemas +from src.crud.document import is_rejected_duplicate from src.exceptions import ResourceNotFoundException @@ -274,6 +275,196 @@ class TestDocumentCRUD: assert len(results) == 1 assert results[0].id == times_derived_map[2] + @pytest.mark.asyncio + async def test_most_derived_orders_by_recency_when_reinforcement_ties( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """Regression: when times_derived ties, most-derived must fall back to + recency, not insertion order. Otherwise stale conclusions stick to the + front of the injected representation (the mid-Jan stickiness bug).""" + test_workspace, test_peer = sample_data + test_peer2, test_session, _ = await self._setup_test_data( + db_session, test_workspace, test_peer + ) + + base = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + # Three conclusions, all reinforced once -- the real-world steady state + # before the fix -- inserted oldest-first. + for i in range(3): + db_session.add( + models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content=f"tie {i}", + session_name=test_session.name, + times_derived=1, + created_at=base + datetime.timedelta(days=i), + ) + ) + # A genuinely reinforced conclusion that is also the oldest of all. + db_session.add( + models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content="hot", + session_name=test_session.name, + times_derived=5, + created_at=base - datetime.timedelta(days=10), + ) + ) + await db_session.flush() + + docs = await crud.query_documents_most_derived( + db_session, + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + limit=10, + ) + contents = [d.content for d in docs] + # Primary sort still wins: the actually-reinforced conclusion leads. + assert contents[0] == "hot" + # Ties break toward most-recent, not oldest-inserted. + assert contents[1:] == ["tie 2", "tie 1", "tie 0"] + + @pytest.mark.asyncio + async def test_duplicate_rejection_reinforces_existing( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """Rejecting a new duplicate must bump the surviving doc's times_derived.""" + test_workspace, test_peer = sample_data + test_peer2, test_session, _ = await self._setup_test_data( + db_session, test_workspace, test_peer + ) + + await crud.create_documents( + db_session, + [ + schemas.DocumentCreate( + content="eri loves cats and dogs and birds and snakes", + embedding=[0.5] * 1536, + session_name=test_session.name, + times_derived=1, + metadata=schemas.DocumentMetadata( + message_ids=[1], + message_created_at="2026-01-01T00:00:00Z", + ), + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + + # Fewer unique tokens -> existing wins -> new doc is rejected. + new_doc = schemas.DocumentCreate( + content="eri loves cats", + embedding=[0.5] * 1536, + session_name=test_session.name, + times_derived=1, + metadata=schemas.DocumentMetadata( + message_ids=[2], + message_created_at="2026-01-02T00:00:00Z", + ), + ) + rejected = await is_rejected_duplicate( + db_session, + new_doc, + test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + + assert rejected is True + surviving = ( + await db_session.execute( + select(models.Document).where( + models.Document.workspace_name == test_workspace.name, + models.Document.observer == test_peer.name, + models.Document.observed == test_peer2.name, + models.Document.deleted_at.is_(None), + ) + ) + ).scalar_one() + assert surviving.times_derived == 2 + + @pytest.mark.asyncio + async def test_duplicate_replacement_carries_count_forward( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """When a new duplicate wins, it must inherit the replaced doc's count + 1 + rather than resetting reinforcement to 1.""" + test_workspace, test_peer = sample_data + test_peer2, test_session, _ = await self._setup_test_data( + db_session, test_workspace, test_peer + ) + + await crud.create_documents( + db_session, + [ + schemas.DocumentCreate( + content="eri loves cats", + embedding=[0.5] * 1536, + session_name=test_session.name, + times_derived=3, + metadata=schemas.DocumentMetadata( + message_ids=[1], + message_created_at="2026-01-01T00:00:00Z", + ), + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + + # More information -> new wins -> existing is soft-deleted. + new_doc = schemas.DocumentCreate( + content="eri loves cats and dogs", + embedding=[0.5] * 1536, + session_name=test_session.name, + times_derived=1, + metadata=schemas.DocumentMetadata( + message_ids=[2], + message_created_at="2026-01-02T00:00:00Z", + ), + ) + rejected = await is_rejected_duplicate( + db_session, + new_doc, + test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + + assert rejected is False + # Count carried forward onto the replacement (3 -> 4), not reset to 1. + assert new_doc.times_derived == 4 + live = ( + ( + await db_session.execute( + select(models.Document).where( + models.Document.workspace_name == test_workspace.name, + models.Document.observer == test_peer.name, + models.Document.observed == test_peer2.name, + models.Document.deleted_at.is_(None), + ) + ) + ) + .scalars() + .all() + ) + # Original is soft-deleted; replacement isn't inserted until create_documents runs. + assert len(live) == 0 + @pytest.mark.asyncio async def test_delete_document_success( self, diff --git a/tests/crud/test_representation_manager.py b/tests/crud/test_representation_manager.py index 141b61c7..7744e763 100644 --- a/tests/crud/test_representation_manager.py +++ b/tests/crud/test_representation_manager.py @@ -1,5 +1,5 @@ from contextlib import asynccontextmanager -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, patch import pytest @@ -180,6 +180,57 @@ class TestRepresentationManagerSoftDelete: assert doc_live.id in result_ids assert doc_deleted.id not in result_ids + @pytest.mark.asyncio + async def test_query_documents_most_derived_ties_break_by_recency( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """Regression: when times_derived ties, the manager's most-derived query + must fall back to recency, not insertion order. Mirrors the equivalent + test on crud.query_documents_most_derived -- the query is duplicated in + both modules and must not drift.""" + test_workspace, test_peer = sample_data + test_peer2, test_session, _, manager = await self._setup( + db_session, test_workspace, test_peer + ) + + base = datetime(2026, 1, 1, tzinfo=timezone.utc) + # Three conclusions, all reinforced once, inserted oldest-first. + for i in range(3): + db_session.add( + models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content=f"tie {i}", + session_name=test_session.name, + times_derived=1, + created_at=base + timedelta(days=i), + ) + ) + # A genuinely reinforced conclusion that is also the oldest of all. + db_session.add( + models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content="hot", + session_name=test_session.name, + times_derived=5, + created_at=base - timedelta(days=10), + ) + ) + await db_session.flush() + + results = await manager._query_documents_most_derived(db_session, top_k=10) # pyright: ignore[reportPrivateUsage] + + contents = [doc.content for doc in results] + # Primary sort still wins: the actually-reinforced conclusion leads. + assert contents[0] == "hot" + # Ties break toward most-recent, not oldest-inserted. + assert contents[1:] == ["tie 2", "tie 1", "tie 0"] + class TestRepresentationManagerSave: @pytest.mark.asyncio From 340175ad5f8b49b73007481eef1885ffe99ac768 Mon Sep 17 00:00:00 2001 From: xianzuyang9-blip Date: Fri, 12 Jun 2026 00:58:11 +0800 Subject: [PATCH 10/65] fix: declare click as honcho-cli dependency (#787) * fix: declare click as honcho-cli dependency * fix: declare click as honcho-cli dependency * fix: declare click as honcho-cli dependency --- honcho-cli/pyproject.toml | 1 + honcho-cli/uv.lock | 2 ++ uv.lock | 2 ++ 3 files changed, 5 insertions(+) diff --git a/honcho-cli/pyproject.toml b/honcho-cli/pyproject.toml index c06f22e0..951a6d58 100644 --- a/honcho-cli/pyproject.toml +++ b/honcho-cli/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ "Topic :: Software Development :: Libraries", ] dependencies = [ + "click>=8.0.0", "typer>=0.15.0", "honcho-ai>=2.0.0", "rich>=13.0.0", diff --git a/honcho-cli/uv.lock b/honcho-cli/uv.lock index dcadb6f3..4dc041ab 100644 --- a/honcho-cli/uv.lock +++ b/honcho-cli/uv.lock @@ -91,6 +91,7 @@ name = "honcho-cli" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "click" }, { name = "honcho-ai" }, { name = "httpx" }, { name = "rich" }, @@ -105,6 +106,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "click", specifier = ">=8.0.0" }, { name = "honcho-ai", specifier = ">=0.1.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, diff --git a/uv.lock b/uv.lock index 25279af9..37fb0cb7 100644 --- a/uv.lock +++ b/uv.lock @@ -1305,6 +1305,7 @@ name = "honcho-cli" version = "0.1.0" source = { editable = "honcho-cli" } dependencies = [ + { name = "click" }, { name = "honcho-ai" }, { name = "httpx" }, { name = "rich" }, @@ -1319,6 +1320,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "click", specifier = ">=8.0.0" }, { name = "honcho-ai", editable = "sdks/python" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, From 40513845f8134f891b5fd3a27e327d018ab53f24 Mon Sep 17 00:00:00 2001 From: adavyas <121313528+adavyas@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:58:02 -0700 Subject: [PATCH 11/65] fix: optimize deriver and dreamer prompt cache prefixes (#806) * Optimize dreamer prompt cache prefix * Optimize deriver prompt prefix caching * fix: specifying target peer * fix: specifying target observee --- src/deriver/prompts.py | 25 ++++++++----- src/dreamer/specialists.py | 47 +++++++++++++++++------- tests/dreamer/test_model_config_usage.py | 20 ++++++++++ 3 files changed, 69 insertions(+), 23 deletions(-) diff --git a/src/deriver/prompts.py b/src/deriver/prompts.py index 683834bf..402bcae7 100644 --- a/src/deriver/prompts.py +++ b/src/deriver/prompts.py @@ -31,6 +31,7 @@ def _custom_instructions_section(custom_instructions: str | None) -> str: return c( f""" CUSTOM INSTRUCTIONS: + These instructions apply to the target peer identified below. {normalized_custom_instructions} """ ) @@ -54,26 +55,32 @@ def minimal_deriver_prompt( custom_instructions_section = _custom_instructions_section(custom_instructions) return c( f""" -Analyze messages from {peer_id} to extract **explicit atomic facts** about them. +Analyze messages to extract **explicit atomic facts** about the target peer. -[EXPLICIT] DEFINITION: Facts about {peer_id} that can be derived directly from their messages. +[EXPLICIT] DEFINITION: Facts about the target peer that can be derived directly from their messages. - Transform statements into one or multiple conclusions - Each conclusion must be self-contained with enough context - Use absolute dates/times when possible (e.g. "June 26, 2025" not "yesterday") RULES: -- Properly attribute observations to the correct subject: if it is about {peer_id}, say so. If {peer_id} is referencing someone or something else, make that clear. -- Observations should make sense on their own. Each observation will be used in the future to better understand {peer_id}. -- Extract ALL observations from {peer_id} messages, using others as context. +- The target peer is the peer identified below under `Target peer:`. +- A peer can be a human user, AI agent, bot, service, or other actor. +- Use the exact peer id from `Target peer:` in final observations, not the phrase "the target peer". +- Properly attribute observations to the correct subject: if it is about the target peer, use the exact peer id as the subject. If the target peer is referencing someone or something else, make that clear. +- Observations should make sense on their own. Each observation will be used in the future to better understand the target peer. +- Extract ALL observations from the target peer's messages, using others as context. - Contextualize each observation sufficiently (e.g. "Ann is nervous about the job interview at the pharmacy" not just "Ann is nervous") -EXAMPLES: -- EXPLICIT: "I just had my 25th birthday last Saturday" → "{peer_id} is 25 years old", "{peer_id}'s birthday is June 21st" -- EXPLICIT: "I took my dog for a walk in NYC" → "{peer_id} has a dog", "{peer_id} lives in NYC" -- EXPLICIT: "{peer_id} attended college" + general knowledge → "{peer_id} completed high school or equivalent" +EXAMPLES (using `alice` as the target peer id): +- EXPLICIT: "I just had my 25th birthday last Saturday" → "alice is 25 years old", "alice's birthday is June 21st" +- EXPLICIT: "I took my dog for a walk in NYC" → "alice has a dog", "alice lives in NYC" +- EXPLICIT: "alice attended college" + general knowledge → "alice completed high school or equivalent" {custom_instructions_section} +Target peer: +{peer_id} + Messages to analyze: {messages} diff --git a/src/dreamer/specialists.py b/src/dreamer/specialists.py index af67b989..bae70eef 100644 --- a/src/dreamer/specialists.py +++ b/src/dreamer/specialists.py @@ -111,12 +111,21 @@ class BaseSpecialist(ABC): @abstractmethod def build_user_prompt( self, + observed: str, hints: list[str] | None, peer_card: list[str] | None = None, ) -> str: """Build the user prompt with optional exploration hints and current peer card.""" ... + def _build_target_observee_context(self, observed: str) -> str: + return f"""Target observee: +{observed} + +The target observee is the peer identified above. When created observations need to name this subject, use the exact observee id above, not the phrase "the target observee". + +""" + def _build_peer_card_context(self, peer_card: list[str] | None) -> str: """Build the peer card context section for user prompts.""" if not peer_card: @@ -226,7 +235,11 @@ If you update it, send the full deduplicated list and remove stale entries. }, { "role": "user", - "content": self.build_user_prompt(hints, current_peer_card), + "content": self.build_user_prompt( + observed=observed, + hints=hints, + peer_card=current_peer_card, + ), }, ] @@ -465,15 +478,16 @@ class DeductionSpecialist(BaseSpecialist): def build_system_prompt( self, observed: str, *, peer_card_enabled: bool = True ) -> str: + _ = observed peer_card_section = "" if peer_card_enabled: - peer_card_section = f""" + peer_card_section = """ ## PEER CARD (REQUIRED) -The peer card is {observed}'s identity store: stable identity markers that distinguish this entity from others and persist across interactions. Behavior, tendencies, transient state, and episodic facts belong in observations, not on the peer card. +The peer card is the target observee's identity store: stable identity markers that distinguish this entity from others and persist across interactions. Behavior, tendencies, transient state, and episodic facts belong in observations, not on the peer card. -A peer can be anything with identity that changes over time — a human, an agent, a codebase, a team, an organization. Do not assume {observed} is human. Do not require any field; empty is the correct output when evidence is absent. +A peer can be anything with identity that changes over time — a human, an agent, a codebase, a team, an organization. Do not assume the target observee is human. Do not require any field; empty is the correct output when evidence is absent. ### Allowed entry kinds @@ -493,16 +507,16 @@ Each entry must start with one of these four prefixes (exact case, followed by a - `RELATIONSHIP: Spouse: Bob` - `RELATIONSHIP: Maintainer: vineeth` - `RELATIONSHIP: Members: vineeth, rajat` -- `INSTRUCTION: ...` — standing rule of engagement that {observed} has explicitly stated (do/don't for the observer). Only when explicit; never inferred from behavior. +- `INSTRUCTION: ...` — standing rule of engagement that the target observee has explicitly stated (do/don't for the observer). Only when explicit; never inferred from behavior. - `INSTRUCTION: Call me Vee` - `INSTRUCTION: Never push to main without review` ### Rules 1. **Stable.** If the value plausibly changes within six months absent a deliberate announcement, it does not belong on the card. Prefer leaving the card empty over filling it with volatile content. -2. **Subject is {observed}.** Every entry must be a fact about {observed}, not about another participant in the session. Never write facts about co-occurring peers into the card, no matter how frequently they appear in the messages. -3. **Evidence-grounded.** Only write what {observed} has explicitly stated, or what another participant has explicitly stated about {observed} with {observed}'s assent. No "general knowledge" inferences (`"co-founder"` does not imply an age; mentioning a colleague does not imply a family relationship). -4. **Type-agnostic.** {observed} may not be human. Do not require name/age/location/family/occupation fields. +2. **Subject is the target observee.** Every entry must be a fact about the target observee, not about another participant in the session. Never write facts about co-occurring peers into the card, no matter how frequently they appear in the messages. +3. **Evidence-grounded.** Only write what the target observee has explicitly stated, or what another participant has explicitly stated about the target observee with the target observee's assent. No "general knowledge" inferences (`"co-founder"` does not imply an age; mentioning a colleague does not imply a family relationship). +4. **Type-agnostic.** The target observee may not be human. Do not require name/age/location/family/occupation fields. 5. **No behavioral content.** TRAITs, behavioral tendencies, patterns, and inferred preferences belong in observations, not on the peer card. Do not write `TRAIT:` entries or behavioral `PREFERENCE:` entries — they will be rejected. 6. **No evidence bundles.** Each entry is one concise fact. No `e.g.` clauses, no parenthetical example lists, no semicolon-separated value dumps. @@ -523,7 +537,7 @@ When in doubt about a specific legacy entry, prefer migrating it (so valid info Call `update_peer_card` with the complete deduplicated list when there is a durable identity update to record, or when the existing card needs migration. Entries that do not start with one of the four allowed prefixes will be rejected. Keep concise (max 40 entries).""" - return f"""You are a deductive reasoning agent analyzing observations about {observed}. + return f"""You are a deductive reasoning agent analyzing observations about the target observee. ## YOUR JOB @@ -583,14 +597,16 @@ Use `create_observations_deductive`. def build_user_prompt( self, + observed: str, hints: list[str] | None, peer_card: list[str] | None = None, ) -> str: + target_observee_context = self._build_target_observee_context(observed) peer_card_context = self._build_peer_card_context(peer_card) if hints: hints_str = "\n".join(f"- {q}" for q in hints[:5]) - return f"""{peer_card_context}Start by exploring recent observations and messages. These topics may be worth investigating: + return f"""{target_observee_context}{peer_card_context}Start by exploring recent observations and messages. These topics may be worth investigating: {hints_str} @@ -598,7 +614,7 @@ But follow the evidence - if you find something more interesting, pursue that in Begin with `get_recent_observations` to see what's there.""" - return f"""{peer_card_context}Explore the observation space and create deductive observations. + return f"""{target_observee_context}{peer_card_context}Explore the observation space and create deductive observations. Start with `get_recent_observations` to see what's been learned recently, then investigate whatever seems most promising. @@ -647,8 +663,9 @@ class InductionSpecialist(BaseSpecialist): def build_system_prompt( self, observed: str, *, peer_card_enabled: bool = True ) -> str: + _ = observed _ = peer_card_enabled - return f"""You are an inductive reasoning agent identifying patterns about {observed}. + return """You are an inductive reasoning agent identifying patterns about the target observee. ## YOUR JOB @@ -711,16 +728,18 @@ Use `create_observations_inductive`. def build_user_prompt( self, + observed: str, hints: list[str] | None, peer_card: list[str] | None = None, ) -> str: + target_observee_context = self._build_target_observee_context(observed) # Induction does not consume peer card context — it produces inductive # observations, not identity-marker updates. _ = peer_card if hints: hints_str = "\n".join(f"- {q}" for q in hints[:5]) - return f"""Explore and find patterns. These areas may be worth investigating: + return f"""{target_observee_context}Explore and find patterns. These areas may be worth investigating: {hints_str} @@ -728,7 +747,7 @@ But follow the evidence - if you find patterns elsewhere, pursue those. Start with `get_recent_observations`.""" - return """Explore the observation space and identify patterns. + return f"""{target_observee_context}Explore the observation space and identify patterns. Remember: patterns need 2+ sources. Look for tendencies, preferences, and behavioral regularities. diff --git a/tests/dreamer/test_model_config_usage.py b/tests/dreamer/test_model_config_usage.py index 90892632..18614043 100644 --- a/tests/dreamer/test_model_config_usage.py +++ b/tests/dreamer/test_model_config_usage.py @@ -32,6 +32,26 @@ def test_deduction_prompt_omits_peer_card_when_disabled() -> None: assert "IDENTITY:" not in prompt +def test_dreamer_system_prompts_delay_observed_observee_for_cache_prefix() -> None: + for specialist in (DeductionSpecialist(), InductionSpecialist()): + prompt = specialist.build_system_prompt("alice", peer_card_enabled=True) + other_prompt = specialist.build_system_prompt("bob", peer_card_enabled=True) + + assert prompt == other_prompt + assert "the target observee" in prompt + + +def test_dreamer_user_prompts_include_target_observee() -> None: + for specialist in (DeductionSpecialist(), InductionSpecialist()): + prompt = specialist.build_user_prompt( + observed="alice", + hints=None, + peer_card=None, + ) + + assert "Target observee:\nalice" in prompt + + def test_induction_prompt_has_no_peer_card_section() -> None: """Induction no longer writes to the peer card; its prompt must not reference it.""" prompt = InductionSpecialist().build_system_prompt("alice", peer_card_enabled=True) From aa993a6dddce7c5c3372d776d591f33a4cc559f7 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:19:51 -0400 Subject: [PATCH 12/65] chore(docs): Release Candidate for v3.0.10 (#813) --- CHANGELOG.md | 19 +++++++++++++++++++ docs/changelog/introduction.mdx | 21 ++++++++++++++++++++- docs/docs.json | 2 +- honcho-cli/CHANGELOG.md | 23 +++++++++++++++++++++++ honcho-cli/pyproject.toml | 2 +- honcho-cli/uv.lock | 2 +- pyproject.toml | 2 +- uv.lock | 6 +++--- 8 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 honcho-cli/CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index cff3c273..3809fa3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [3.0.10] - 2026-06-15 + +### Added + +- Messages are now embedded via a background task rather than blocking API request +- Read-only DB session mode (`get_read_db` / `tracked_db(..., read_only=True)`) so reads don't hold a transaction open across the work +- `CORS_ORIGINS` env var to configure CORS allowed origins without editing source; defaults match the prior hardcoded list, so self-hosted deployments behind custom domains can whitelist their frontend (#697) +- `scripts/generate_jwt.py` — utility for minting scoped or admin Honcho JWTs (`--admin`, `--workspace`/`--peer`/`--session`, `--expires` with human-friendly durations, `--print-only`) without calling the keys API (#757) +- `STALE_WORK_UNIT_CLEANUP_INTERVAL_SECONDS` (default 60s) — minimum jittered spacing between deriver stale-work-unit cleanup runs, so cleanup no longer runs on every seconds-scale poll (`0.0` keeps the legacy every-poll behavior) (#773) + +### Changed + +- Optimized the deriver and dreamer prompt cache prefixes to improve prompt-cache hit rates (#806) + +### Fixed + +- `times_derived` is now properly reinforced when a duplicate conclusion is detected. It had been pinned at 1 for nearly every conclusion (the reject-new branch dropped the increment and the new-wins branch reset the count to 1), so `ORDER BY times_derived DESC` fell back to arbitrary heap order and froze stale conclusions to the front of injected context. Reinforcement is now an atomic increment and both most-derived queries gained a `created_at DESC` recency tiebreaker (#768) +- Webhook creation now correctly rejects private/internal IP addresses (#793) + ## [3.0.9] - 2026-06-02 ### Changed diff --git a/docs/changelog/introduction.mdx b/docs/changelog/introduction.mdx index a4778f76..c5bc129d 100644 --- a/docs/changelog/introduction.mdx +++ b/docs/changelog/introduction.mdx @@ -27,7 +27,26 @@ Welcome to the Honcho changelog! This section documents all notable changes to t ### Honcho API and SDK Changelogs - + + ### Added + + - Messages are now embedded via a background task rather than blocking API request + - Read-only DB session mode (`get_read_db` / `tracked_db(..., read_only=True)`) so reads don't hold a transaction open across the work + - `CORS_ORIGINS` env var to configure CORS allowed origins without editing source; defaults match the prior hardcoded list, so self-hosted deployments behind custom domains can whitelist their frontend (#697) + - `scripts/generate_jwt.py` — utility for minting scoped or admin Honcho JWTs (`--admin`, `--workspace`/`--peer`/`--session`, `--expires` with human-friendly durations, `--print-only`) without calling the keys API (#757) + - `STALE_WORK_UNIT_CLEANUP_INTERVAL_SECONDS` (default 60s) — minimum jittered spacing between deriver stale-work-unit cleanup runs, so cleanup no longer runs on every seconds-scale poll (`0.0` keeps the legacy every-poll behavior) (#773) + + ### Changed + + - Optimized the deriver and dreamer prompt cache prefixes to improve prompt-cache hit rates (#806) + + ### Fixed + + - `times_derived` is now properly reinforced when a duplicate conclusion is detected. It had been pinned at 1 for nearly every conclusion (the reject-new branch dropped the increment and the new-wins branch reset the count to 1), so `ORDER BY times_derived DESC` fell back to arbitrary heap order and froze stale conclusions to the front of injected context. Reinforcement is now an atomic increment and both most-derived queries gained a `created_at DESC` recency tiebreaker (#768) + - Webhook creation now correctly rejects private/internal IP addresses (#793) + + + ### Changed - Connection acquisition is now a single attempt with no server-side retry, on a vanilla `AsyncSession`. A new `DB_CONNECT_TIMEOUT_SECONDS` (default 2s) bounds the attempt so a saturated or unreachable pooler fails fast instead of holding a client connection open to re-knock. A saturated DB now surfaces to the caller — the API returns an error and the deriver backs off and retries on a later poll — which lets the pooler drain rather than amplifying saturation. diff --git a/docs/docs.json b/docs/docs.json index 4c240e50..669faf96 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -24,7 +24,7 @@ "navigation": { "versions": [ { - "version": "v3.0.9", + "version": "v3.0.10", "api": { "openapi": ["v3/openapi.json"] }, diff --git a/honcho-cli/CHANGELOG.md b/honcho-cli/CHANGELOG.md new file mode 100644 index 00000000..7d56f768 --- /dev/null +++ b/honcho-cli/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + +## [0.1.1] - 2026-06-15 + +### Fixed + +- Declare `click` as an explicit dependency. The CLI imported `click` directly but relied on it being pulled in transitively, so installs without it on the path could fail at runtime (#787) + +## [0.1.0] - 2026-04-20 + +### Added + +- Initial release of `honcho-cli` — a terminal for inspecting and managing a Honcho deployment (#424) +- `workspace`, `peer`, `session`, `message`, `conclusion`, and `config` command groups for managing resources against any Honcho server +- `init` onboarding flow that prompts for and persists connection settings, with flag/env-var pre-seeding for non-interactive use +- Per-command flags, environment variables, and a config file for pointing the CLI at different servers (local, self-hosted, or hosted) +- Rich terminal output and an agent-usage mode for scripting against the CLI +- Documentation and an agent skill for the CLI (#589) diff --git a/honcho-cli/pyproject.toml b/honcho-cli/pyproject.toml index 951a6d58..b3293a1a 100644 --- a/honcho-cli/pyproject.toml +++ b/honcho-cli/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho-cli" -version = "0.1.0" +version = "0.1.1" description = "A terminal for Honcho — memory that reasons." readme = "README.md" requires-python = ">=3.11" diff --git a/honcho-cli/uv.lock b/honcho-cli/uv.lock index 4dc041ab..fce3f13a 100644 --- a/honcho-cli/uv.lock +++ b/honcho-cli/uv.lock @@ -88,7 +88,7 @@ wheels = [ [[package]] name = "honcho-cli" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "click" }, diff --git a/pyproject.toml b/pyproject.toml index 4856619e..95a7789c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho" -version = "3.0.9" +version = "3.0.10" description = "Honcho Server" authors = [ {name = "Plastic Labs", email = "hello@plasticlabs.ai"}, diff --git a/uv.lock b/uv.lock index 37fb0cb7..a71283bb 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-05-28T15:27:36.945866Z" +exclude-newer = "2026-06-10T19:45:16.447512Z" exclude-newer-span = "P5D" [manifest] @@ -1159,7 +1159,7 @@ wheels = [ [[package]] name = "honcho" -version = "3.0.9" +version = "3.0.10" source = { virtual = "." } dependencies = [ { name = "alembic" }, @@ -1302,7 +1302,7 @@ dev = [{ name = "ruff", specifier = ">=0.11.13" }] [[package]] name = "honcho-cli" -version = "0.1.0" +version = "0.1.1" source = { editable = "honcho-cli" } dependencies = [ { name = "click" }, From 99cbebe30e6510189e3ccf25224cb2e027a004f8 Mon Sep 17 00:00:00 2001 From: Hafiz Ahmad Ashfaq Date: Thu, 18 Jun 2026 01:55:10 +0500 Subject: [PATCH 13/65] fix(llm): coerce None output_tokens to 0 in completion_result_to_response (#809) Some providers return output_tokens=None on certain completions (observed with Gemini on tool-loop completions). HonchoLLMCallResponse types output_tokens as int, so the None propagates into a Pydantic validation error that aborts the call. In practice this surfaces in the Dreamer: a dream starts, deduction succeeds, then induction crashes before inductive conclusions are persisted. Coerce None -> 0 so token accounting degrades gracefully (under-counts rather than crashing) for providers that omit output token counts. Co-authored-by: Claude Opus 4.8 (1M context) --- src/llm/executor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/llm/executor.py b/src/llm/executor.py index 87db8dd0..99c73c70 100644 --- a/src/llm/executor.py +++ b/src/llm/executor.py @@ -167,7 +167,7 @@ def completion_result_to_response( return HonchoLLMCallResponse( content=result.content, input_tokens=result.input_tokens, - output_tokens=result.output_tokens, + output_tokens=result.output_tokens or 0, cache_creation_input_tokens=result.cache_creation_input_tokens, cache_read_input_tokens=result.cache_read_input_tokens, finish_reasons=[result.finish_reason] if result.finish_reason else [], From a2adeb9f458bb9db226725e5d039aeb135bdf2ad Mon Sep 17 00:00:00 2001 From: Aru Sharma <70081536+staru09@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:19:04 +0530 Subject: [PATCH 14/65] Fix surprisal tree kwarg mismatch (#749) * bug fix * test: add tests for create_tree k-kwarg handling --- src/dreamer/trees/__init__.py | 9 +++++++ tests/dreamer/test_trees.py | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 tests/dreamer/test_trees.py diff --git a/src/dreamer/trees/__init__.py b/src/dreamer/trees/__init__.py index e1ac6acf..eba2ff3d 100644 --- a/src/dreamer/trees/__init__.py +++ b/src/dreamer/trees/__init__.py @@ -29,6 +29,15 @@ def create_tree(tree_type: str, **kwargs: Any) -> SurprisalTree: Raises: ValueError: If tree_type is not recognized """ + # `surprisal.py` calls this factory with a uniform `k=settings.DREAM.SURPRISAL.TREE_K` kwarg for every tree type, + # but `k` is only meaningful for the kNN-based trees (kdtree, balltree, graph). + # The other 4 use different tunables and raise TypeError if `k` is passed. + # Drop it here so the factory accepts a uniform kwargs dict. + + trees_without_k = {"rptree", "covertree", "lsh", "prototype"} + if tree_type in trees_without_k: + kwargs.pop("k", None) + if tree_type == "rptree": return RPTree(**kwargs) elif tree_type == "kdtree": diff --git a/tests/dreamer/test_trees.py b/tests/dreamer/test_trees.py new file mode 100644 index 00000000..5c09a241 --- /dev/null +++ b/tests/dreamer/test_trees.py @@ -0,0 +1,51 @@ +import pytest + +from src.dreamer.trees import ( + CoverTree, + GraphSurprisal, + LSHSurprisal, + PrototypeSurprisal, + RPTree, + SklearnTreeWrapper, + SurprisalTree, + create_tree, +) + +ALL_TREE_TYPES = [ + "kdtree", + "balltree", + "rptree", + "covertree", + "lsh", + "graph", + "prototype", +] + +EXPECTED_CLASS = { + "kdtree": SklearnTreeWrapper, + "balltree": SklearnTreeWrapper, + "rptree": RPTree, + "covertree": CoverTree, + "lsh": LSHSurprisal, + "graph": GraphSurprisal, + "prototype": PrototypeSurprisal, +} + + +@pytest.mark.parametrize("tree_type", ALL_TREE_TYPES) +def test_create_tree_accepts_uniform_k_kwarg(tree_type: str): + tree = create_tree(tree_type=tree_type, k=5) + assert isinstance(tree, SurprisalTree) + assert isinstance(tree, EXPECTED_CLASS[tree_type]) + + +@pytest.mark.parametrize("tree_type", ALL_TREE_TYPES) +def test_create_tree_without_k(tree_type: str): + """The factory should also work when no ``k`` is supplied.""" + tree = create_tree(tree_type=tree_type) + assert isinstance(tree, EXPECTED_CLASS[tree_type]) + + +def test_create_tree_unknown_type_raises(): + with pytest.raises(ValueError, match="Unknown tree type"): + create_tree(tree_type="not_a_tree") From 99312958c5cf480cbb4523b5be42e6c169fb8766 Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Mon, 22 Jun 2026 11:45:34 -0400 Subject: [PATCH 15/65] docs(env): add Dialectic base URL override examples to .env.template (#833) The Dialectic section was the only LLM section in .env.template missing MODEL_CONFIG__OVERRIDES__BASE_URL examples. Without them, users routing to OpenAI-compatible providers (e.g. Siliconflow) weren't aware the per-level override existed and fell back to the default OpenAI endpoint, hitting AuthenticationError. The override already works; this just enumerates it per reasoning level. Fixes #818 Co-authored-by: Claude Opus 4.8 --- .env.template | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.env.template b/.env.template index cb11ed62..0b4a6f99 100644 --- a/.env.template +++ b/.env.template @@ -170,12 +170,17 @@ LLM_OPENAI_API_KEY=your-api-key-here # DIALECTIC_LEVELS__max__MODEL_CONFIG__TRANSPORT=openai # DIALECTIC_LEVELS__max__MODEL_CONFIG__MODEL=gpt-5.4-mini # DIALECTIC_LEVELS__max__MAX_TOOL_ITERATIONS=10 -# Optional overrides: +# Optional overrides (model and OpenAI-compatible base URL are per-level): # DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL=your-model-here +# DIALECTIC_LEVELS__minimal__MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1 # DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL=your-model-here +# DIALECTIC_LEVELS__low__MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1 # DIALECTIC_LEVELS__medium__MODEL_CONFIG__MODEL=your-model-here +# DIALECTIC_LEVELS__medium__MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1 # DIALECTIC_LEVELS__high__MODEL_CONFIG__MODEL=your-model-here +# DIALECTIC_LEVELS__high__MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1 # DIALECTIC_LEVELS__max__MODEL_CONFIG__MODEL=your-model-here +# DIALECTIC_LEVELS__max__MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1 # DIALECTIC_LEVELS__max__MODEL_CONFIG__THINKING_EFFORT=medium # DIALECTIC_LEVELS__max__MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024 # Optional backup per level (must set both or neither): From f8bcfa4aa56622217f0774878e6d99ca40a76231 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:48:59 -0400 Subject: [PATCH 16/65] Forward provider_params to underlying transport (#821) * feat(llm): forward provider_params passthroughs (extra_body/headers/query) across backends * refactor(llm): share provider_params passthrough merge + validate shapes Extract apply_sdk_passthroughs/coerce_passthrough_mapping into request_builder; reject non-mapping passthrough values with a clear ValidationException; cover the Anthropic stream() path; document the keys in configuration.mdx. Co-Authored-By: Claude Opus 4.8 (1M context) * chore: address coderabbit docs nitpicks --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/v3/contributing/configuration.mdx | 27 ++++ src/config.py | 18 ++- src/llm/backends/anthropic.py | 7 + src/llm/backends/gemini.py | 21 +++ src/llm/backends/openai.py | 12 +- src/llm/request_builder.py | 58 +++++++- tests/llm/test_backends/test_anthropic.py | 93 +++++++++++++ tests/llm/test_backends/test_gemini.py | 113 +++++++++++++++ tests/llm/test_backends/test_openai.py | 159 ++++++++++++++++++++++ 9 files changed, 504 insertions(+), 4 deletions(-) diff --git a/docs/v3/contributing/configuration.mdx b/docs/v3/contributing/configuration.mdx index 267e6814..06add0d0 100644 --- a/docs/v3/contributing/configuration.mdx +++ b/docs/v3/contributing/configuration.mdx @@ -185,6 +185,33 @@ Each model config supports an `overrides.provider_params` dict for passing arbit verbosity = "low" ``` +#### Transport passthrough keys + +Three keys inside `provider_params` are recognized as request-level escape hatches and forwarded to the underlying transport. Where a transport actually validates and merges one of these keys, its value must be a mapping — a non-mapping value raises a configuration error (see the per-transport behavior below; a key a transport ignores is not validated): + +- **`extra_body`** — merged into the request body +- **`extra_headers`** — extra HTTP headers +- **`extra_query`** — extra URL query parameters + +How each transport forwards them differs: + +- **OpenAI and Anthropic** forward all three as identically-named SDK kwargs (`extra_body`, `extra_headers`, `extra_query`). +- **Gemini** has no SDK kwargs for these. It merges `extra_body` into the `GenerateContentConfig` dict and folds `extra_headers` into `http_options.headers`; `extra_query` is **unsupported and silently ignored**. + +The merge is shallow and **operator-wins**: if Honcho and your config both set the same top-level key inside `extra_body`, your value replaces Honcho's. You are responsible for choosing a coherent combination — e.g. unset `thinking_budget_tokens` when supplying an `extra_body.thinking` for Anthropic-via-proxy, since Honcho will not translate between the two shapes. + +Because Gemini merges `extra_body` directly into `GenerateContentConfig` (rather than a nested request body), an `extra_body` written for OpenAI/Anthropic generally will not transfer to Gemini unchanged — and a key collision there can overwrite a field Honcho manages (`thinking_config`, `response_schema`, `tools`, …). + +```toml +# Example: route an OpenAI-compatible proxy and tag requests for tracing +[deriver.model_config.overrides.provider_params.extra_headers] +X-Proxy-Route = "vertex" + +[deriver.model_config.overrides.provider_params.extra_body] +# Provider-native body fields the standard config doesn't expose +anthropic_beta = ["context-1m-2025-01-15"] +``` + ### Changing Transport When changing a feature's `transport`, always specify `model` explicitly. Partial overrides that change transport without model will keep the previous model name, which may not be valid for the new provider. diff --git a/src/config.py b/src/config.py index 6a7869b6..267a8154 100644 --- a/src/config.py +++ b/src/config.py @@ -69,7 +69,23 @@ class ModelOverrideSettings(BaseModel): api_key_env: str | None = None base_url: str | None = None - provider_params: dict[str, Any] = Field(default_factory=dict) + provider_params: dict[str, Any] = Field( + default_factory=dict, + description=( + "Operator escape hatch for provider-specific request fields. " + "Three recognized keys: `extra_body` (merged into the request body), " + "`extra_headers` (HTTP headers), `extra_query` (URL query params). " + "OpenAI and Anthropic transports forward these as identically-named " + "SDK kwargs. The Gemini transport merges `extra_body` into the " + "GenerateContentConfig dict and folds `extra_headers` into " + "`http_options.headers`; `extra_query` is unsupported. Shallow merge " + "with operator-wins — if Honcho and the operator both set the same " + "key inside `extra_body`, the operator's value replaces Honcho's. " + "Operators are responsible for picking a coherent combination of " + "this and other config (e.g. unset `thinking_budget_tokens` when " + "supplying an `extra_body.thinking` for Anthropic-via-proxy)." + ), + ) class PromptCachePolicy(BaseModel): diff --git a/src/llm/backends/anthropic.py b/src/llm/backends/anthropic.py index 17138583..673f4d03 100644 --- a/src/llm/backends/anthropic.py +++ b/src/llm/backends/anthropic.py @@ -9,6 +9,7 @@ from anthropic.types import TextBlock, ThinkingBlock, ToolUseBlock from pydantic import BaseModel, ValidationError from src.llm.backend import CompletionResult, StreamChunk, ToolCallResult +from src.llm.request_builder import apply_sdk_passthroughs from src.llm.structured_output import repair_response_model_json @@ -69,6 +70,9 @@ class AnthropicBackend: for key in ("top_p", "top_k"): if key in extra_params: params[key] = extra_params[key] + # Operator escape hatch: forward Anthropic SDK passthrough kwargs + # from ModelConfig.provider_params. Shallow merge with operator-wins. + apply_sdk_passthroughs(params, extra_params) use_json_prefill = ( bool(response_format or self._json_mode(extra_params)) @@ -148,6 +152,9 @@ class AnthropicBackend: for key in ("top_p", "top_k"): if key in extra_params: params[key] = extra_params[key] + # Operator escape hatch: forward Anthropic SDK passthrough kwargs + # from ModelConfig.provider_params. Shallow merge with operator-wins. + apply_sdk_passthroughs(params, extra_params) use_json_prefill = ( bool(response_format or is_json_mode) and not thinking_budget_tokens diff --git a/src/llm/backends/gemini.py b/src/llm/backends/gemini.py index b14cefe4..5c70268a 100644 --- a/src/llm/backends/gemini.py +++ b/src/llm/backends/gemini.py @@ -14,6 +14,7 @@ from src.llm.caching import ( build_cache_key, gemini_cache_store, ) +from src.llm.request_builder import coerce_passthrough_mapping from src.llm.structured_output import repair_response_model_json GEMINI_BLOCKED_FINISH_REASONS = { @@ -246,6 +247,26 @@ class GeminiBackend: for key in ("top_p", "top_k", "frequency_penalty", "presence_penalty", "seed"): if extra_params and key in extra_params: config[key] = extra_params[key] + # Operator escape hatch: forward provider_params into the google-genai + # config dict. The Gemini SDK doesn't expose extra_body/extra_headers + # as kwargs (unlike OpenAI/Anthropic) — body-shaped fields live on + # GenerateContentConfig and headers live under config.http_options. + # extra_query has no SDK-level equivalent and is ignored. Shallow + # merge with operator-wins. Operators are responsible for not setting + # unknown fields that google-genai's validation will reject. + if extra_params: + operator_extra_body = extra_params.get("extra_body") + if operator_extra_body: + config.update( + coerce_passthrough_mapping("extra_body", operator_extra_body) + ) + operator_extra_headers = extra_params.get("extra_headers") + if operator_extra_headers: + http_options = config.setdefault("http_options", {}) + existing_headers = http_options.setdefault("headers", {}) + existing_headers.update( + coerce_passthrough_mapping("extra_headers", operator_extra_headers) + ) return config def _normalize_response( diff --git a/src/llm/backends/openai.py b/src/llm/backends/openai.py index b2d82d91..fe8962d5 100644 --- a/src/llm/backends/openai.py +++ b/src/llm/backends/openai.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, ValidationError from src.exceptions import ValidationException from src.llm.backend import CompletionResult, StreamChunk, ToolCallResult +from src.llm.request_builder import apply_sdk_passthroughs from src.llm.structured_output import ( repair_response_model_json, validate_structured_output, @@ -300,8 +301,10 @@ class OpenAIBackend: # Token-budget style thinking is not part of the native OpenAI API, but # OpenAI-compatible proxies (OpenRouter, etc.) accept a `reasoning` object # on the request body. Pass through via extra_body so it reaches those - # backends; operators on providers that need a different shape (vLLM, - # Fireworks, ...) can override via ModelConfig.provider_params. + # backends. Operators on providers that need a different shape (e.g. + # Anthropic-via-Vertex behind litellm wants `thinking`, not `reasoning`) + # supply that shape via ModelConfig.provider_params.extra_body and unset + # thinking_budget_tokens themselves — Honcho does not try to translate. if thinking_budget_tokens is not None and thinking_budget_tokens > 0: params.setdefault("extra_body", {}).setdefault("reasoning", {})[ "max_tokens" @@ -322,6 +325,11 @@ class OpenAIBackend: ): if key in extra_params: params[key] = extra_params[key] + # Operator escape hatch: forward OpenAI SDK passthrough kwargs from + # ModelConfig.provider_params. Shallow merge with operator-wins — + # if the operator supplies `extra_body.reasoning`, it replaces any + # value Honcho auto-injected above. + apply_sdk_passthroughs(params, extra_params) return params def _normalize_response( diff --git a/src/llm/request_builder.py b/src/llm/request_builder.py index d6be5a22..fd8d69c3 100644 --- a/src/llm/request_builder.py +++ b/src/llm/request_builder.py @@ -7,14 +7,70 @@ src/llm/api.py, src/llm/tool_loop.py, src/llm/runtime.py. from __future__ import annotations from collections.abc import AsyncIterator -from typing import Any +from typing import Any, cast from pydantic import BaseModel from src.config import ModelConfig, PromptCachePolicy +from src.exceptions import ValidationException from .backend import CompletionResult, ProviderBackend, StreamChunk +# Operator escape-hatch keys recognized inside ModelConfig.provider_params. +PASSTHROUGH_KEYS = ("extra_body", "extra_headers", "extra_query") + + +def coerce_passthrough_mapping(key: str, value: Any) -> dict[str, Any]: + """Validate an operator-supplied provider_params passthrough is a mapping. + + ``provider_params`` is typed ``dict[str, Any]`` with no nested schema, so an + operator can supply a non-mapping (e.g. a list or string) for one of the + passthrough keys. Catch that here with a clear error instead of letting a + later ``dict.update()`` raise an opaque ``TypeError`` deep in the transport. + + Args: + key: The passthrough key name, used only for the error message. + value: The operator-supplied value to validate. + + Returns: + The value, narrowed to ``dict[str, Any]``. + + Raises: + ValidationException: If ``value`` is not a mapping. + """ + if not isinstance(value, dict): + raise ValidationException( + f"provider_params.{key} must be a mapping, got {type(value).__name__}" + ) + return cast(dict[str, Any], value) + + +def apply_sdk_passthroughs( + params: dict[str, Any], extra_params: dict[str, Any] +) -> None: + """Forward operator provider_params passthroughs onto an SDK call dict. + + OpenAI and Anthropic both accept ``extra_body`` / ``extra_headers`` / + ``extra_query`` as identically-named SDK kwargs, so they share this merge. + Operator values shallow-merge onto ``params`` in place, winning over any + value Honcho already set under the same top-level key (e.g. an auto-injected + ``extra_body.reasoning``). Gemini handles passthroughs separately because the + google-genai SDK does not expose these as kwargs. + + Args: + params: The SDK call kwargs being assembled; mutated in place. + extra_params: Flattened per-call params (see build_config_extra_params). + + Raises: + ValidationException: If a passthrough value is not a mapping. + """ + for passthrough_key in PASSTHROUGH_KEYS: + operator_value = extra_params.get(passthrough_key) + if not operator_value: + continue + existing = params.setdefault(passthrough_key, {}) + existing.update(coerce_passthrough_mapping(passthrough_key, operator_value)) + def build_config_extra_params(config: ModelConfig) -> dict[str, Any]: """Flatten ModelConfig's optional knobs and provider_params into extra_params. diff --git a/tests/llm/test_backends/test_anthropic.py b/tests/llm/test_backends/test_anthropic.py index 52de0fa2..c7a4bb75 100644 --- a/tests/llm/test_backends/test_anthropic.py +++ b/tests/llm/test_backends/test_anthropic.py @@ -122,6 +122,99 @@ async def test_anthropic_backend_skips_assistant_prefill_for_claude_4_models() - assert call["messages"][0]["content"].startswith("Hello\n\nRespond with valid JSON") +@pytest.mark.asyncio +async def test_anthropic_backend_forwards_provider_params_passthroughs() -> None: + """provider_params.extra_body/extra_headers/extra_query reach the Anthropic + SDK call as kwargs of the same name (the SDK's documented passthrough). + """ + client = Mock() + client.messages.create = AsyncMock( + return_value=SimpleNamespace( + content=[TextBlock(type="text", text="ok")], + usage=SimpleNamespace( + input_tokens=10, + output_tokens=5, + cache_creation_input_tokens=0, + cache_read_input_tokens=0, + ), + stop_reason="end_turn", + ) + ) + + backend = AnthropicBackend(client) + await backend.complete( + model="claude-haiku-4-5", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + extra_params={ + "extra_body": {"anthropic_beta": ["context-1m-2025-01-15"]}, + "extra_headers": {"X-Proxy-Route": "vertex"}, + "extra_query": {"trace_id": "abc123"}, + }, + ) + + await_args = client.messages.create.await_args + if await_args is None: + raise AssertionError("Expected Anthropic client call") + call = await_args.kwargs + assert call["extra_body"] == {"anthropic_beta": ["context-1m-2025-01-15"]} + assert call["extra_headers"] == {"X-Proxy-Route": "vertex"} + assert call["extra_query"] == {"trace_id": "abc123"} + + +@pytest.mark.asyncio +async def test_anthropic_backend_stream_forwards_provider_params_passthroughs() -> None: + """The stream() path forwards provider_params passthroughs to the SDK the + same way complete() does — it has its own merge block, so cover it too. + """ + + class _FakeStream: + async def __aenter__(self) -> "_FakeStream": + return self + + async def __aexit__(self, *_: object) -> bool: + return False + + def __aiter__(self) -> "_FakeStream": + return self + + async def __anext__(self) -> object: + raise StopAsyncIteration + + async def get_final_message(self) -> SimpleNamespace: + return SimpleNamespace( + usage=SimpleNamespace(output_tokens=5), + stop_reason="end_turn", + ) + + client = Mock() + client.messages.stream = Mock(return_value=_FakeStream()) + + backend = AnthropicBackend(client) + chunks = [ + chunk + async for chunk in backend.stream( + model="claude-haiku-4-5", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + extra_params={ + "extra_body": {"anthropic_beta": ["context-1m-2025-01-15"]}, + "extra_headers": {"X-Proxy-Route": "vertex"}, + "extra_query": {"trace_id": "abc123"}, + }, + ) + ] + + assert chunks # the terminal is_done chunk is always emitted + call = client.messages.stream.call_args + if call is None: + raise AssertionError("Expected Anthropic stream call") + kwargs = call.kwargs + assert kwargs["extra_body"] == {"anthropic_beta": ["context-1m-2025-01-15"]} + assert kwargs["extra_headers"] == {"X-Proxy-Route": "vertex"} + assert kwargs["extra_query"] == {"trace_id": "abc123"} + + @pytest.mark.asyncio async def test_anthropic_backend_ignores_thinking_effort() -> None: client = Mock() diff --git a/tests/llm/test_backends/test_gemini.py b/tests/llm/test_backends/test_gemini.py index b327c8e4..ca4fce97 100644 --- a/tests/llm/test_backends/test_gemini.py +++ b/tests/llm/test_backends/test_gemini.py @@ -315,6 +315,119 @@ async def test_gemini_backend_strips_system_and_tools_when_using_cached_content( assert "tool_config" not in call["config"] +@pytest.mark.asyncio +async def test_gemini_backend_forwards_provider_params_extra_body() -> None: + """provider_params.extra_body merges into the GenerateContentConfig dict + (Gemini's body-shaped fields live there, not as an SDK kwarg). + """ + client = Mock() + client.aio.models.generate_content = AsyncMock( + return_value=SimpleNamespace( + candidates=[ + SimpleNamespace( + finish_reason=SimpleNamespace(name="STOP"), + content=SimpleNamespace(parts=[SimpleNamespace(text="ok")]), + ) + ], + usage_metadata=SimpleNamespace( + prompt_token_count=12, + candidates_token_count=6, + ), + parsed=None, + ) + ) + + backend = GeminiBackend(client) + await backend.complete( + model="gemini-2.5-flash", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + extra_params={"extra_body": {"candidate_count": 3, "seed": 42}}, + ) + + await_args = client.aio.models.generate_content.await_args + if await_args is None: + raise AssertionError("Expected Gemini generate_content call") + call = await_args.kwargs + assert call["config"]["candidate_count"] == 3 + assert call["config"]["seed"] == 42 + + +@pytest.mark.asyncio +async def test_gemini_backend_forwards_provider_params_extra_headers() -> None: + """provider_params.extra_headers folds into config.http_options.headers.""" + client = Mock() + client.aio.models.generate_content = AsyncMock( + return_value=SimpleNamespace( + candidates=[ + SimpleNamespace( + finish_reason=SimpleNamespace(name="STOP"), + content=SimpleNamespace(parts=[SimpleNamespace(text="ok")]), + ) + ], + usage_metadata=SimpleNamespace( + prompt_token_count=12, + candidates_token_count=6, + ), + parsed=None, + ) + ) + + backend = GeminiBackend(client) + await backend.complete( + model="gemini-2.5-flash", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + extra_params={"extra_headers": {"X-Trace-Id": "abc123"}}, + ) + + await_args = client.aio.models.generate_content.await_args + if await_args is None: + raise AssertionError("Expected Gemini generate_content call") + call = await_args.kwargs + assert call["config"]["http_options"]["headers"] == {"X-Trace-Id": "abc123"} + + +@pytest.mark.asyncio +async def test_gemini_backend_silently_ignores_extra_query() -> None: + """extra_query has no google-genai SDK equivalent. The backend drops it + rather than crashing or surfacing it somewhere unexpected. + """ + client = Mock() + client.aio.models.generate_content = AsyncMock( + return_value=SimpleNamespace( + candidates=[ + SimpleNamespace( + finish_reason=SimpleNamespace(name="STOP"), + content=SimpleNamespace(parts=[SimpleNamespace(text="ok")]), + ) + ], + usage_metadata=SimpleNamespace( + prompt_token_count=12, + candidates_token_count=6, + ), + parsed=None, + ) + ) + + backend = GeminiBackend(client) + await backend.complete( + model="gemini-2.5-flash", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + extra_params={"extra_query": {"trace_id": "abc123"}}, + ) + + await_args = client.aio.models.generate_content.await_args + if await_args is None: + raise AssertionError("Expected Gemini generate_content call") + call = await_args.kwargs + # extra_query is not surfaced anywhere in the request — neither at the top + # level of the SDK call nor inside config. + assert "extra_query" not in call + assert "extra_query" not in call["config"] + + def test_gemini_sanitize_schema_strips_unsupported_keywords() -> None: """Gemini's function-declarations validator rejects JSON-Schema keywords outside its narrow allowlist (additionalProperties, allOf, if/then, $ref, diff --git a/tests/llm/test_backends/test_openai.py b/tests/llm/test_backends/test_openai.py index 695b12cd..dd0c7808 100644 --- a/tests/llm/test_backends/test_openai.py +++ b/tests/llm/test_backends/test_openai.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, Mock import pytest +from src.exceptions import ValidationException from src.llm.backends.openai import OpenAIBackend @@ -227,6 +228,164 @@ async def test_openai_backend_skips_extra_body_when_thinking_budget_zero() -> No assert "extra_body" not in call +@pytest.mark.asyncio +async def test_openai_backend_forwards_provider_params_extra_body() -> None: + """Operator-supplied extra_body in provider_params reaches the OpenAI SDK call. + + This is the escape hatch for OpenAI-compatible proxies that translate to + other providers (litellm → Vertex AI Anthropic) and need provider-native + body fields (e.g. Anthropic's `thinking`). + """ + client = Mock() + client.chat.completions.create = AsyncMock( + return_value=SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason="stop", + message=SimpleNamespace( + content="ok", + tool_calls=[], + reasoning_details=[], + ), + ) + ], + usage=SimpleNamespace( + prompt_tokens=10, + completion_tokens=5, + prompt_tokens_details=None, + ), + ) + ) + + backend = OpenAIBackend(client) + await backend.complete( + model="claude-haiku-4-5", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + extra_params={ + "extra_body": {"thinking": {"type": "enabled", "budget_tokens": 4096}} + }, + ) + + await_args = client.chat.completions.create.await_args + if await_args is None: + raise AssertionError("Expected OpenAI create call") + call = await_args.kwargs + assert call["extra_body"] == { + "thinking": {"type": "enabled", "budget_tokens": 4096} + } + + +@pytest.mark.asyncio +async def test_openai_backend_forwards_provider_params_extra_headers_and_query() -> ( + None +): + client = Mock() + client.chat.completions.create = AsyncMock( + return_value=SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason="stop", + message=SimpleNamespace( + content="ok", + tool_calls=[], + reasoning_details=[], + ), + ) + ], + usage=SimpleNamespace( + prompt_tokens=10, + completion_tokens=5, + prompt_tokens_details=None, + ), + ) + ) + + backend = OpenAIBackend(client) + await backend.complete( + model="gpt-4.1", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + extra_params={ + "extra_headers": {"X-Proxy-Route": "vertex"}, + "extra_query": {"trace_id": "abc123"}, + }, + ) + + await_args = client.chat.completions.create.await_args + if await_args is None: + raise AssertionError("Expected OpenAI create call") + call = await_args.kwargs + assert call["extra_headers"] == {"X-Proxy-Route": "vertex"} + assert call["extra_query"] == {"trace_id": "abc123"} + + +@pytest.mark.asyncio +async def test_openai_backend_operator_extra_body_wins_over_auto_injection() -> None: + """When the operator supplies extra_body.reasoning, it must replace the + value that thinking_budget_tokens would otherwise auto-inject (operator-wins + shallow merge). + """ + client = Mock() + client.chat.completions.create = AsyncMock( + return_value=SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason="stop", + message=SimpleNamespace( + content="ok", + tool_calls=[], + reasoning_details=[], + ), + ) + ], + usage=SimpleNamespace( + prompt_tokens=10, + completion_tokens=5, + prompt_tokens_details=None, + ), + ) + ) + + backend = OpenAIBackend(client) + await backend.complete( + model="x-ai/grok-4.1-fast", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + thinking_budget_tokens=256, + extra_params={ + "extra_body": {"reasoning": {"effort": "high", "max_tokens": 9999}} + }, + ) + + await_args = client.chat.completions.create.await_args + if await_args is None: + raise AssertionError("Expected OpenAI create call") + call = await_args.kwargs + # Operator's whole `reasoning` dict replaces Honcho's auto-injected one. + assert call["extra_body"] == {"reasoning": {"effort": "high", "max_tokens": 9999}} + + +@pytest.mark.asyncio +async def test_openai_backend_rejects_non_mapping_passthrough() -> None: + """A non-mapping passthrough (operator misconfiguration) raises a clear + ValidationException instead of an opaque TypeError deep in the transport. + """ + client = Mock() + client.chat.completions.create = AsyncMock() + + backend = OpenAIBackend(client) + with pytest.raises(ValidationException, match=r"provider_params\.extra_headers"): + await backend.complete( + model="gpt-4.1", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + extra_params={"extra_headers": [["X-Foo", "bar"]]}, + ) + + client.chat.completions.create.assert_not_awaited() + + @pytest.mark.asyncio async def test_openai_backend_converts_anthropic_style_tools() -> None: client = Mock() From 326a757cdbf1fb08ba98d9b35026ecec137f7703 Mon Sep 17 00:00:00 2001 From: Rajat Ahuja Date: Mon, 22 Jun 2026 16:30:00 -0500 Subject: [PATCH 17/65] Fix scoped JWTs (#679) * Peer- and session-scoped JWTs were effectively workspace-scoped: auth() walked the route's declared scope and fell through to a workspace match, so a {w: ws-a, p: alice} token could act on any peer in ws-a. * feat: peer keys can read sessions they belong to; require workspace on scoped keys * fix: authorize JWTs by narrowest scope and gate member reads Follow-up hardening on the narrowest-claim auth fix: - Scope get_peer_config member-read to the caller's own peer; a session member could previously read a co-member's per-session config. - Enforce session membership on POST /peers/{id}/chat: the session_id arrives in the body (invisible to require_auth), so a peer key could read any session's injected message history. Check is_peer_in_session in the handler before the dialectic runs. - Consolidate the workspace-match check in auth() to a single hoisted guard so no branch can silently re-open cross-workspace access. - Normalize empty-string scope claims to None in verify_jwt so a blank workspace can't satisfy the peer/session token-shape invariant. - Extract scope_requires_workspace(), shared by verify_jwt and the keys API so the creation-time guard and verification invariant can't drift. route requires auth) and CLAUDE.md auth-scoping guidance. - docs: describe narrow-scope key semantics in the platform reference. --------- Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> --- CHANGELOG.md | 11 + CLAUDE.md | 5 + docs/v3/documentation/reference/platform.mdx | 8 +- src/crud/session.py | 32 +++ src/routers/keys.py | 12 + src/routers/messages.py | 54 +++- src/routers/peers.py | 19 +- src/routers/sessions.py | 51 +++- src/routers/webhooks.py | 8 +- src/security.py | 123 ++++++-- tests/conftest.py | 2 + tests/routes/test_auth_route_policy.py | 108 +++++++ tests/routes/test_messages.py | 92 ++++++ tests/routes/test_peers.py | 35 +++ tests/routes/test_scoped_api.py | 8 +- tests/test_security.py | 287 +++++++++++++++++++ 16 files changed, 805 insertions(+), 50 deletions(-) create mode 100644 tests/routes/test_auth_route_policy.py create mode 100644 tests/test_security.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3809fa3b..d31b9d72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] + +### Changed + +- Peer-scoped JWTs now get read-only access to the sessions their peer is an active member of (session context, summaries, peers, their own per-session config, search, and message reads). Session-scoped JWTs remain confined to their session and cannot reach peer routes. + +### Fixed + +- Peer- and session-scoped JWTs were effectively workspace-scoped: authorization walked the route's declared scope and fell through to a workspace match, so a `{w, p: alice}` token could act on any peer in the workspace. JWTs are now authorized by their narrowest claim and never widen to workspace access. +- The keys API now rejects creating a peer- or session-scoped key without a workspace. Such keys were minted successfully but failed verification on every request. + ## [3.0.10] - 2026-06-15 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 87a95c3e..c9862b59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,6 +118,11 @@ cd sdks/typescript && bun run tsc --noEmit - **Never hold a DB session during external calls** (LLM, embedding, HTTP). If a function needs both a DB session and an external call result, compute the external result first and pass it as a parameter. This avoids tying up DB connections during slow network I/O. Use `tracked_db` for short-lived, DB-only operations; pass a shared session when multiple DB-only calls can reuse one connection. - **Never write through a read-only session** (`tracked_db(..., read_only=True)`, `get_read_db`, `ReadSessionLocal`). These run in AUTOCOMMIT mode with no transaction: writes are NOT blocked by the database — they silently commit immediately, and `begin_nested()` savepoints break. There is no runtime guard; this is enforced by convention only. Use `read_only=True` strictly for SELECT-only windows; anything that mutates (including get-or-create paths) must use a regular write session. +#### Auth scoping + +- **`allow_member_read=True` (in `require_auth(...)`) is read-only — NEVER set it on a route that mutates state.** It lets a peer-scoped key reach a session route when its peer is an active member of the session, so on a mutating route it would hand any session member write access (message injection, config mutation, deletion). HTTP method is not a reliable read/write signal here (some read routes use POST for a richer body), so this is enforced by an explicit allowlist in `tests/routes/test_auth_route_policy.py` — adding the flag to a new route fails that test until you consciously add the route to `EXPECTED_MEMBER_READ_ROUTES`, and you must never add a mutating method there. +- **When a member-read route is keyed by another sub-resource** (e.g. `peers/{peer_id}/config`), the handler must additionally confirm a peer-scoped caller only reads its OWN resource (`jwt_params.p == peer_id`, else raise `AuthenticationException`). Membership grants session access, not access to a co-member's data. See `get_peer_config` in `src/routers/sessions.py`. + ### Runtime Architecture Honcho runs as two cooperating processes that share a Postgres database and Redis cache: diff --git a/docs/v3/documentation/reference/platform.mdx b/docs/v3/documentation/reference/platform.mdx index 75fc9bd5..abf3c646 100644 --- a/docs/v3/documentation/reference/platform.mdx +++ b/docs/v3/documentation/reference/platform.mdx @@ -60,7 +60,13 @@ The **Performance** page provides comprehensive monitoring with usage metrics, h ## 3. Manage API Keys -The [API Keys](https://app.honcho.dev/api-keys) page allows you to create and manage authentication tokens for different environments. You can create admin-level keys with full instance access or scope keys to specific `Workspaces`, `Peers`, or `Sessions`. +The [API Keys](https://app.honcho.dev/api-keys) page allows you to create and manage authentication tokens for different environments. You can create admin-level keys with full instance access or scope keys to a specific `Workspace`, `Peer`, or `Session`. + +Scoped keys are authorized by their narrowest claim and never widen to the whole workspace: + +- A **peer-scoped** key acts on its own peer, plus **read-only** access to the sessions its peer is an active member of (context, summaries, peers, its own per-session config, search, and message reads). It cannot write to those sessions or act on other peers. +- A **session-scoped** key is confined to its own session and cannot reach peer routes. +- Peer- and session-scoped keys **must carry their parent workspace** — creating one without a workspace is rejected. API Key Management Dashboard diff --git a/src/crud/session.py b/src/crud/session.py index 712188bc..40cdacac 100644 --- a/src/crud/session.py +++ b/src/crud/session.py @@ -834,6 +834,38 @@ async def get_peers_from_session( ) +async def is_peer_in_session( + db: AsyncSession, + workspace_name: str, + session_name: str, + peer_name: str, +) -> bool: + """Return whether a peer is an active member of a session. + + Active membership means a `SessionPeer` row exists with `left_at IS NULL`. + Used by the auth layer to grant a peer-scoped key read access to the + sessions that peer belongs to. + + Args: + db: Database session + workspace_name: Name of the workspace + session_name: Name of the session + peer_name: Name of the peer + + Returns: + True if the peer is currently a member of the session. + """ + result = await db.scalar( + select(models.SessionPeer.peer_name) + .where(models.SessionPeer.workspace_name == workspace_name) + .where(models.SessionPeer.session_name == session_name) + .where(models.SessionPeer.peer_name == peer_name) + .where(models.SessionPeer.left_at.is_(None)) + .limit(1) + ) + return result is not None + + async def get_session_peer_configuration( workspace_name: str, session_name: str, diff --git a/src/routers/keys.py b/src/routers/keys.py index f4a50375..0051db90 100644 --- a/src/routers/keys.py +++ b/src/routers/keys.py @@ -9,6 +9,7 @@ from src.security import ( JWTParams, create_jwt, require_auth, + scope_requires_workspace, ) from src.utils.formatting import format_datetime_utc @@ -42,6 +43,17 @@ async def create_key( "At least one of workspace_id, peer_id, or session_id must be provided" ) + # A peer- or session-scoped key must carry its parent workspace, otherwise + # verify_jwt rejects it on every request (the workspace is required to rule + # out cross-workspace use). Shares the predicate with verify_jwt so the + # creation-time guard and the verification-time invariant cannot drift. + if scope_requires_workspace( + peer=peer_id, session=session_id, workspace=workspace_id + ): + raise ValidationException( + "workspace_id is required when scoping a key to a peer or session" + ) + key_str = create_jwt( JWTParams( exp=format_datetime_utc(expires_at) if expires_at else None, diff --git a/src/routers/messages.py b/src/routers/messages.py index 5e10c472..9ace70cc 100644 --- a/src/routers/messages.py +++ b/src/routers/messages.py @@ -32,9 +32,19 @@ logger = logging.getLogger(__name__) router = APIRouter( prefix="/workspaces/{workspace_id}/sessions/{session_id}/messages", tags=["messages"], - dependencies=[ - Depends(require_auth(workspace_name="workspace_id", session_name="session_id")) - ], +) + +# Read routes additionally allow a peer-scoped key whose peer is a member of the +# session; write routes stay session-scoped only. Applied per-route rather than +# on the router so the two policies can differ. +require_session_read = require_auth( + workspace_name="workspace_id", + session_name="session_id", + allow_member_read=True, +) +require_session_write = require_auth( + workspace_name="workspace_id", + session_name="session_id", ) @@ -82,9 +92,18 @@ async def parse_upload_form( ) -@router.post("", response_model=list[schemas.Message], status_code=201) @router.post( - "/", response_model=list[schemas.Message], status_code=201, include_in_schema=False + "", + response_model=list[schemas.Message], + status_code=201, + dependencies=[Depends(require_session_write)], +) +@router.post( + "/", + response_model=list[schemas.Message], + status_code=201, + include_in_schema=False, + dependencies=[Depends(require_session_write)], ) # backwards compatibility with pre-2.6.0 faulty route endpoint async def create_messages_for_session( background_tasks: BackgroundTasks, @@ -154,7 +173,12 @@ async def create_messages_for_session( raise -@router.post("/upload", response_model=list[schemas.Message], status_code=201) +@router.post( + "/upload", + response_model=list[schemas.Message], + status_code=201, + dependencies=[Depends(require_session_write)], +) async def create_messages_with_file( background_tasks: BackgroundTasks, workspace_id: str = Path(...), @@ -266,7 +290,11 @@ async def create_messages_with_file( return created_messages -@router.post("/list", response_model=Page[schemas.Message]) +@router.post( + "/list", + response_model=Page[schemas.Message], + dependencies=[Depends(require_session_read)], +) async def get_messages( workspace_id: str = Path(...), session_id: str = Path(...), @@ -299,7 +327,11 @@ async def get_messages( raise ResourceNotFoundException("Session not found") from e -@router.get("/{message_id}", response_model=schemas.Message) +@router.get( + "/{message_id}", + response_model=schemas.Message, + dependencies=[Depends(require_session_read)], +) async def get_message( workspace_id: str = Path(...), session_id: str = Path(...), @@ -316,7 +348,11 @@ async def get_message( return honcho_message -@router.put("/{message_id}", response_model=schemas.Message) +@router.put( + "/{message_id}", + response_model=schemas.Message, + dependencies=[Depends(require_session_write)], +) async def update_message( workspace_id: str = Path(...), session_id: str = Path(...), diff --git a/src/routers/peers.py b/src/routers/peers.py index e1aa45dd..90cdc1a5 100644 --- a/src/routers/peers.py +++ b/src/routers/peers.py @@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import crud, schemas from src.config import settings +from src.crud.session import is_peer_in_session from src.dependencies import db, read_db, tracked_db from src.dialectic.chat import agentic_chat, agentic_chat_stream from src.embedding_client import embedding_client @@ -167,19 +168,31 @@ async def get_sessions_for_peer( }, }, }, - dependencies=[ - Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id")) - ], ) async def chat( workspace_id: str = Path(...), peer_id: str = Path(...), options: schemas.DialecticOptions = Body(...), + jwt_params: JWTParams = Depends( + require_auth(workspace_name="workspace_id", peer_name="peer_id") + ), ): """ Query a Peer's representation using natural language. Performs agentic search and reasoning to comprehensively answer the query based on all latent knowledge gathered about the peer from their messages and conclusions. """ + # The session id arrives in the body, so require_auth can't gate on it. A + # peer-scoped key may only scope a chat to a session its peer belongs to; + # without this check it could read any session's messages (the dialectic + # injects session history) by naming it here. Workspace/admin tokens + # (jwt_params.p is None) are unaffected. + if jwt_params.p is not None and options.session_id: + async with tracked_db("peers.chat.is_peer_in_session", read_only=True) as s_db: + if not await is_peer_in_session( + s_db, workspace_id, options.session_id, jwt_params.p + ): + raise AuthenticationException("JWT not permissioned for this resource") + # Get or create the peer to ensure it exists async with tracked_db("peers.chat.get_or_create_peer") as peer_db: peers_result = await crud.get_or_create_peers( diff --git a/src/routers/sessions.py b/src/routers/sessions.py index eb8794d7..89ce665c 100644 --- a/src/routers/sessions.py +++ b/src/routers/sessions.py @@ -536,17 +536,28 @@ async def remove_peers_from_session( @router.get( "/{session_id}/peers/{peer_id}/config", response_model=schemas.SessionPeerConfig, - dependencies=[ - Depends(require_auth(workspace_name="workspace_id", session_name="session_id")) - ], ) async def get_peer_config( workspace_id: str = Path(...), session_id: str = Path(...), peer_id: str = Path(...), + jwt_params: JWTParams = Depends( + require_auth( + workspace_name="workspace_id", + session_name="session_id", + allow_member_read=True, + ) + ), db: AsyncSession = read_db, ): - """Get the configuration for a Peer in a Session.""" + """Get the configuration for a Peer in a Session. + + Member-read lets a peer-scoped key reach this route, but a peer may only + read its own per-session config — not a co-member's. Workspace/admin and + session-scoped tokens (which already span the whole session) are unaffected. + """ + if jwt_params.p is not None and jwt_params.p != peer_id: + raise AuthenticationException("JWT not permissioned for this resource") return await crud.get_peer_config( db, workspace_name=workspace_id, @@ -593,7 +604,13 @@ async def set_peer_config( "/{session_id}/peers", response_model=Page[schemas.Peer], dependencies=[ - Depends(require_auth(workspace_name="workspace_id", session_name="session_id")) + Depends( + require_auth( + workspace_name="workspace_id", + session_name="session_id", + allow_member_read=True, + ) + ) ], ) async def get_session_peers( @@ -616,7 +633,13 @@ async def get_session_peers( "/{session_id}/context", response_model=schemas.SessionContext, dependencies=[ - Depends(require_auth(workspace_name="workspace_id", session_name="session_id")) + Depends( + require_auth( + workspace_name="workspace_id", + session_name="session_id", + allow_member_read=True, + ) + ) ], ) async def get_session_context( @@ -808,7 +831,13 @@ async def get_session_context( "/{session_id}/summaries", response_model=schemas.SessionSummaries, dependencies=[ - Depends(require_auth(workspace_name="workspace_id", session_name="session_id")) + Depends( + require_auth( + workspace_name="workspace_id", + session_name="session_id", + allow_member_read=True, + ) + ) ], ) async def get_session_summaries( @@ -849,7 +878,13 @@ async def get_session_summaries( "/{session_id}/search", response_model=list[schemas.Message], dependencies=[ - Depends(require_auth(workspace_name="workspace_id", session_name="session_id")) + Depends( + require_auth( + workspace_name="workspace_id", + session_name="session_id", + allow_member_read=True, + ) + ) ], ) async def search_session( diff --git a/src/routers/webhooks.py b/src/routers/webhooks.py index f66164e9..91fc63f9 100644 --- a/src/routers/webhooks.py +++ b/src/routers/webhooks.py @@ -31,7 +31,7 @@ async def get_or_create_webhook_endpoint( webhook: schemas.WebhookEndpointCreate = Body( ..., description="Webhook endpoint parameters" ), - jwt_params: JWTParams = Depends(require_auth()), + jwt_params: JWTParams = Depends(require_auth(workspace_name="workspace_id")), db: AsyncSession = db, ) -> schemas.WebhookEndpoint: """ @@ -55,7 +55,7 @@ async def get_or_create_webhook_endpoint( @router.get("", response_model=Page[schemas.WebhookEndpoint]) async def list_webhook_endpoints( workspace_id: str = Path(..., description="Workspace ID"), - jwt_params: JWTParams = Depends(require_auth()), + jwt_params: JWTParams = Depends(require_auth(workspace_name="workspace_id")), db: AsyncSession = db, ) -> Page[schemas.WebhookEndpoint]: """ @@ -72,7 +72,7 @@ async def list_webhook_endpoints( async def delete_webhook_endpoint( workspace_id: str = Path(..., description="Workspace ID"), endpoint_id: str = Path(..., description="Webhook endpoint ID"), - jwt_params: JWTParams = Depends(require_auth()), + jwt_params: JWTParams = Depends(require_auth(workspace_name="workspace_id")), db: AsyncSession = db, ) -> None: """ @@ -88,7 +88,7 @@ async def delete_webhook_endpoint( @router.get("/test") async def test_emit( workspace_id: str = Path(..., description="Workspace ID"), - jwt_params: JWTParams = Depends(require_auth()), + jwt_params: JWTParams = Depends(require_auth(workspace_name="workspace_id")), ) -> None: """ Test publishing a webhook event. diff --git a/src/security.py b/src/security.py index 6988919e..3a60d8c1 100644 --- a/src/security.py +++ b/src/security.py @@ -80,6 +80,28 @@ def create_jwt(params: JWTParams) -> str: ) +def scope_requires_workspace( + *, peer: str | None, session: str | None, workspace: str | None +) -> bool: + """Return whether a peer- or session-scoped claim lacks its parent workspace. + + A peer or session scope is meaningless without a workspace: the route-level + check cannot rule out cross-workspace use (a ``{p: "alice"}`` token would + match ``alice`` in any workspace). Truthiness-based so empty-string claims + count as absent. Shared by `verify_jwt` (the token-shape invariant) and the + keys API (the creation-time guard) so the two rules cannot drift apart. + + Args: + peer: The peer claim, if any. + session: The session claim, if any. + workspace: The workspace claim, if any. + + Returns: + True when a peer/session scope is present but the workspace is not. + """ + return bool(peer or session) and not workspace + + def verify_jwt(token: str) -> JWTParams: """Verify a JWT and return the decoded parameters.""" @@ -101,12 +123,23 @@ def verify_jwt(token: str) -> JWTParams: raise AuthenticationException("JWT expired") if "ad" in decoded: params.ad = decoded["ad"] + # Normalize empty-string scope claims to None so a blank `w`/`p`/`s` + # cannot masquerade as a present claim in the checks below. if "w" in decoded: - params.w = decoded["w"] + params.w = decoded["w"] or None if "p" in decoded: - params.p = decoded["p"] + params.p = decoded["p"] or None if "s" in decoded: - params.s = decoded["s"] + params.s = decoded["s"] or None + # Token-shape invariant: a peer- or session-scoped token MUST also + # carry its parent workspace, otherwise the route-level check cannot + # rule out cross-workspace use. + if scope_requires_workspace( + peer=params.p, session=params.s, workspace=params.w + ): + raise AuthenticationException( + "Invalid JWT scope: peer/session token missing workspace" + ) return params except jwt.PyJWTError: raise AuthenticationException("Invalid JWT") from None @@ -117,9 +150,14 @@ def require_auth( workspace_name: str | None = None, peer_name: str | None = None, session_name: str | None = None, + allow_member_read: bool = False, ): """ Generate a dependency that requires authentication for the given parameters. + + Set `allow_member_read=True` on read-only session routes to additionally + grant access to peer-scoped keys whose peer is an active member of the + session. Never set it on routes that mutate state. """ async def auth_dependency( @@ -150,8 +188,14 @@ def require_auth( workspace_name=workspace_name_param, peer_name=peer_name_param, session_name=session_name_param, + allow_member_read=allow_member_read, ) + # Tag the closure so route-policy tests can introspect which routes opt into + # member read without re-deriving it from HTTP method (an unreliable + # read/write signal here — some read routes use POST for a richer body). + auth_dependency.honcho_allow_member_read = allow_member_read # pyright: ignore[reportFunctionMemberAccess] + return auth_dependency @@ -161,6 +205,7 @@ async def auth( workspace_name: str | None = None, peer_name: str | None = None, session_name: str | None = None, + allow_member_read: bool = False, ) -> JWTParams: """Authenticate the given JWT and return the decoded parameters.""" if not settings.AUTH.USE_AUTH: @@ -171,30 +216,66 @@ async def auth( jwt_params = verify_jwt(credentials.credentials) - # based on api operation, verify api key based on that key's permissions + # Authorize by the token's narrowest scope, not by the route's. A + # narrower-than-workspace token must NOT fall back to workspace access: + # `{w: ws, p: alice}` may only act on `alice`, never on a sibling peer. if jwt_params.ad: return jwt_params if admin: raise AuthenticationException("Resource requires admin privileges") - # For session level access - if session_name and jwt_params.s == session_name: - if workspace_name and jwt_params.w != workspace_name: - raise AuthenticationException("JWT not permissioned for this resource") + if not any([session_name, peer_name, workspace_name]): + # Self-authorizing routes decode the token here and compare the claims + # against body/path data inside the handler. This is needed for routes + # whose resource identifier is not available to require_auth(). return jwt_params - # For peer level access - if peer_name and jwt_params.p == peer_name: - if workspace_name and jwt_params.w != workspace_name: - raise AuthenticationException("JWT not permissioned for this resource") - return jwt_params - - # For workspace level access - can access all peers/sessions under this workspace - if workspace_name and jwt_params.w == workspace_name: - return jwt_params - - if any([session_name, peer_name, workspace_name]): + # Every scoped, non-admin path requires the token's workspace to match the + # route's. Check it once here so no individual branch below can forget it + # and silently re-open cross-workspace access (the bug this module fixes). + if workspace_name and jwt_params.w != workspace_name: raise AuthenticationException("JWT not permissioned for this resource") - # Route did not specify any parameters, so it should parse parameters itself - return jwt_params + if jwt_params.s is not None: + # Session-scoped token: confined to its own session. It gets no + # cross-scope access to peer routes. + if not session_name or jwt_params.s != session_name: + raise AuthenticationException("JWT not permissioned for this resource") + return jwt_params + + if jwt_params.p is not None: + # Peer-scoped token: its own peer routes... + if peer_name and jwt_params.p == peer_name: + return jwt_params + # ...plus read-only access to the sessions the peer is a member of. + # Gated on `allow_member_read` so only read routes opt in; writes stay + # denied. Requires the route's workspace so the membership lookup is + # scoped (every session route declares workspace_name); the workspace + # match itself was already verified above. + if allow_member_read and session_name and workspace_name: + # Lazy imports avoid an import cycle with the crud/db layers and + # keep this DB round-trip off the common (same-scope) auth path. + from src.crud.session import is_peer_in_session + from src.dependencies import tracked_db + + # Membership is read on a separate committed-only (read_only) + # connection, so a peer added to the session in a not-yet-committed + # transaction reads as a non-member: writes must commit before a + # member-scoped read. Fails closed. + async with tracked_db( + "auth.is_peer_in_session", read_only=True + ) as member_db: + is_member = await is_peer_in_session( + member_db, workspace_name, session_name, jwt_params.p + ) + if is_member: + return jwt_params + raise AuthenticationException("JWT not permissioned for this resource") + + if jwt_params.w is not None: + # Workspace tokens reach any route inside their workspace (the workspace + # match was verified above). Routes without a declared workspace (e.g. + # POST /v3/workspaces) self-authorize by reading jwt_params.w themselves. + return jwt_params + + raise AuthenticationException("JWT not permissioned for this resource") diff --git a/tests/conftest.py b/tests/conftest.py index 4aeaf56c..301d91d2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -78,6 +78,8 @@ _RUNTIME_MOCK_TEST_BLOCKLIST_PREFIXES = ( # LLM transport tests mock providers directly and don't need database/runtime setup. "tests/utils/test_length_finish_reason.py", "tests/utils/test_clients.py", + # Pure JWT scope tests — operate on src.security directly, no DB needed. + "tests/test_security.py", "tests/test_generate_jwt_script.py", ) diff --git a/tests/routes/test_auth_route_policy.py b/tests/routes/test_auth_route_policy.py new file mode 100644 index 00000000..b31f2cd3 --- /dev/null +++ b/tests/routes/test_auth_route_policy.py @@ -0,0 +1,108 @@ +"""Route-policy regression tests for auth scoping. + +Two invariants this guards: + +1. `allow_member_read=True` grants peer-scoped keys read access to sessions + their peer belongs to. It must appear ONLY on intended read routes — never on + a mutating route, where it would hand session members write access. HTTP + method is not a reliable read/write signal in this codebase (some read + endpoints use POST for a richer request body), so we assert against an + explicit allowlist instead of deriving from the method. + +2. The messages router dropped its router-level auth dependency in favor of + per-route dependencies. Every route on it must still carry auth, or a future + route added without an explicit dependency would serve unauthenticated. +""" + +from fastapi.routing import APIRoute + +from src.main import app + +# (method, path) pairs intentionally granting member peers read access. Adding a +# route here is a deliberate security decision: it must be read-only. Never add +# a mutating route. See CLAUDE.md "Auth scoping" for the rule. +EXPECTED_MEMBER_READ_ROUTES = { + ("POST", "/v3/workspaces/{workspace_id}/sessions/{session_id}/messages/list"), + ( + "GET", + "/v3/workspaces/{workspace_id}/sessions/{session_id}/messages/{message_id}", + ), + ("GET", "/v3/workspaces/{workspace_id}/sessions/{session_id}/context"), + ("GET", "/v3/workspaces/{workspace_id}/sessions/{session_id}/summaries"), + ("GET", "/v3/workspaces/{workspace_id}/sessions/{session_id}/peers"), + ( + "GET", + "/v3/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config", + ), + ("POST", "/v3/workspaces/{workspace_id}/sessions/{session_id}/search"), +} + +# Unambiguously mutating methods. POST is intentionally excluded: this codebase +# uses POST for some read endpoints (`/messages/list`, `/search`) to take a +# richer request body, so POST is not a write signal. The allowlist test above +# is the real guard against a write route opting into member read; this test +# additionally catches the clear-cut PUT/PATCH/DELETE mistakes. +MUTATING_METHODS = {"PUT", "PATCH", "DELETE"} + + +def _auth_dependency_calls(route: APIRoute): + """Yield the callables of every honcho auth dependency attached to a route. + + `require_auth(...)` closures are tagged with `honcho_allow_member_read`, so a + dependency is a honcho auth dependency iff its callable has that attribute. + Walks the dependant tree to cover both `dependencies=[Depends(...)]` and + parameter-level `Depends(...)`. + """ + stack = list(route.dependant.dependencies) + while stack: + dep = stack.pop() + if hasattr(dep.call, "honcho_allow_member_read"): + yield dep.call + stack.extend(dep.dependencies) + + +def _method_path_pairs(route: APIRoute): + for method in route.methods or set(): + if method in ("HEAD", "OPTIONS"): + continue + yield (method, route.path) + + +def test_member_read_allowlist_matches_routes(): + """Exactly the allowlisted routes opt into member read — no more, no less.""" + actual: set[tuple[str, str]] = set() + for route in app.routes: + if not isinstance(route, APIRoute): + continue + if any( + getattr(call, "honcho_allow_member_read", False) + for call in _auth_dependency_calls(route) + ): + actual.update(_method_path_pairs(route)) + + assert actual == EXPECTED_MEMBER_READ_ROUTES + + +def test_member_read_never_on_mutating_route(): + """A member-read route must never use a mutating HTTP method.""" + for method, path in EXPECTED_MEMBER_READ_ROUTES: + assert method not in MUTATING_METHODS, ( + f"{method} {path} grants member-read on a mutating method — " + "member peers would gain write access" + ) + + +def test_every_message_route_requires_auth(): + """The messages router has no router-level auth dependency; assert each route + carries its own so a newly added route cannot be silently unauthenticated.""" + prefix = "/v3/workspaces/{workspace_id}/sessions/{session_id}/messages" + message_routes = [ + route + for route in app.routes + if isinstance(route, APIRoute) and route.path.startswith(prefix) + ] + assert message_routes, "expected to find message routes mounted under the prefix" + for route in message_routes: + assert any( + _auth_dependency_calls(route) + ), f"{route.methods} {route.path} has no auth dependency" diff --git a/tests/routes/test_messages.py b/tests/routes/test_messages.py index 2d3c77e5..3539a735 100644 --- a/tests/routes/test_messages.py +++ b/tests/routes/test_messages.py @@ -7,7 +7,9 @@ from nanoid import generate as generate_nanoid from sqlalchemy.ext.asyncio import AsyncSession from src import models +from src.config import settings from src.models import Peer, Workspace +from src.security import JWTParams, create_jwt @pytest.mark.asyncio @@ -278,6 +280,96 @@ async def test_get_messages( assert data["items"][0]["metadata"] == {} +@pytest.mark.asyncio +async def test_member_peer_key_reads_session_but_cannot_write( + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + monkeypatch: pytest.MonkeyPatch, +): + """A peer-scoped key may read sessions its peer belongs to (membership-based + cross-scope read), but not write to them. Non-member peer keys and session + keys on peer routes are denied. Exercises the real session_peers lookup.""" + test_workspace, alice = sample_data + session_name = str(generate_nanoid()) + base = f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}" + + # Setup with auth disabled: create the session with alice as an active + # member, then commit so the independent tracked_db session in auth() (which + # only sees committed rows) can resolve membership. + client.post(f"{base}/peers", json={alice.name: {}}) + await db_session.commit() + + # Enforce auth for the assertions below. + monkeypatch.setattr(settings.AUTH, "USE_AUTH", True) + monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret") + + # Member peer key: reads allowed. + client.headers["Authorization"] = ( + f"Bearer {create_jwt(JWTParams(w=test_workspace.name, p=alice.name))}" + ) + assert client.post(f"{base}/messages/list", json={}).status_code == 200 + assert client.get(f"{base}/context").status_code == 200 + + # Member peer key: writes denied (write routes don't opt into member read). + assert ( + client.post( + f"{base}/messages", + json={"messages": [{"content": "nope", "peer_id": alice.name}]}, + ).status_code + == 401 + ) + + # Non-member peer key: even reads denied. + client.headers["Authorization"] = ( + f"Bearer {create_jwt(JWTParams(w=test_workspace.name, p='not-a-member'))}" + ) + assert client.post(f"{base}/messages/list", json={}).status_code == 401 + + # Session key: no cross-scope access to peer routes. + client.headers["Authorization"] = ( + f"Bearer {create_jwt(JWTParams(w=test_workspace.name, s=session_name))}" + ) + assert ( + client.get( + f"/v3/workspaces/{test_workspace.name}/peers/{alice.name}/card" + ).status_code + == 401 + ) + + +@pytest.mark.asyncio +async def test_member_peer_key_reads_only_own_session_peer_config( + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + monkeypatch: pytest.MonkeyPatch, +): + """A member peer key may read its OWN per-session config but not a + co-member's. The route opts into member read, so without the in-handler + self-check alice could read bob's config.""" + test_workspace, alice = sample_data + bob_name = str(generate_nanoid()) + session_name = str(generate_nanoid()) + base = f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}" + + # Create the session with alice and bob as active members; commit so the + # independent read-only tracked_db in auth() can resolve membership. + client.post(f"{base}/peers", json={alice.name: {}, bob_name: {}}) + await db_session.commit() + + monkeypatch.setattr(settings.AUTH, "USE_AUTH", True) + monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret") + + client.headers["Authorization"] = ( + f"Bearer {create_jwt(JWTParams(w=test_workspace.name, p=alice.name))}" + ) + # Own config: allowed. + assert client.get(f"{base}/peers/{alice.name}/config").status_code == 200 + # Co-member's config: denied even though alice is a session member. + assert client.get(f"{base}/peers/{bob_name}/config").status_code == 401 + + @pytest.mark.asyncio async def test_get_messages_with_reverse( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] diff --git a/tests/routes/test_peers.py b/tests/routes/test_peers.py index dd00eff9..847c24e0 100644 --- a/tests/routes/test_peers.py +++ b/tests/routes/test_peers.py @@ -7,7 +7,9 @@ from nanoid import generate as generate_nanoid from sqlalchemy.ext.asyncio import AsyncSession from src import crud, models +from src.config import settings from src.models import Peer, Workspace +from src.security import JWTParams, create_jwt def test_get_or_create_peer(client: TestClient, sample_data: tuple[Workspace, Peer]): @@ -625,6 +627,39 @@ def test_chat( assert "content" in data +@pytest.mark.asyncio +async def test_chat_peer_key_denied_for_non_member_session( + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + monkeypatch: pytest.MonkeyPatch, +): + """A peer-scoped key cannot chat scoped to a session its peer is not a member + of — the session id is in the body, so the handler checks membership. The + guard fires before the dialectic runs, so no LLM call is made.""" + test_workspace, alice = sample_data + session_id = str(generate_nanoid()) + + # Session exists but alice is NOT a member of it. + client.post( + f"/v3/workspaces/{test_workspace.name}/sessions", + json={"id": session_id}, + ) + await db_session.commit() + + monkeypatch.setattr(settings.AUTH, "USE_AUTH", True) + monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret") + client.headers["Authorization"] = ( + f"Bearer {create_jwt(JWTParams(w=test_workspace.name, p=alice.name))}" + ) + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/{alice.name}/chat", + json={"query": "what do you know?", "stream": False, "session_id": session_id}, + ) + assert response.status_code == 401 + + def test_chat_with_optional_params( client: TestClient, sample_data: tuple[Workspace, Peer], diff --git a/tests/routes/test_scoped_api.py b/tests/routes/test_scoped_api.py index a4d4677a..7b504a7a 100644 --- a/tests/routes/test_scoped_api.py +++ b/tests/routes/test_scoped_api.py @@ -141,7 +141,7 @@ def test_get_peer_by_name_with_auth( # Test with peer-scoped JWT if auth_client.auth_type == "empty": auth_client.headers["Authorization"] = ( - f"Bearer {create_jwt(JWTParams(p=test_peer.name))}" + f"Bearer {create_jwt(JWTParams(w=test_workspace.name, p=test_peer.name))}" ) # Get specific peer using get_or_create endpoint @@ -218,7 +218,7 @@ def test_create_session_with_auth( # Test with peer-scoped JWT if auth_client.auth_type == "empty": auth_client.headers["Authorization"] = ( - f"Bearer {create_jwt(JWTParams(p=test_peer.name))}" + f"Bearer {create_jwt(JWTParams(w=test_workspace.name, p=test_peer.name))}" ) session_name2 = str(generate_nanoid()) @@ -262,7 +262,7 @@ def test_get_session_by_name_with_auth( if auth_client.auth_type == "empty": # Test with session-scoped JWT auth_client.headers["Authorization"] = ( - f"Bearer {create_jwt(JWTParams(s=session_name))}" + f"Bearer {create_jwt(JWTParams(w=test_workspace.name, s=session_name))}" ) response = auth_client.post( @@ -282,7 +282,7 @@ def test_get_session_by_name_with_auth( # Test with peer-scoped JWT auth_client.headers["Authorization"] = ( - f"Bearer {create_jwt(JWTParams(p=test_peer.name))}" + f"Bearer {create_jwt(JWTParams(w=test_workspace.name, p=test_peer.name))}" ) assert auth_client.post( diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 00000000..1785be8b --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,287 @@ +"""Auth scope tests — DEV-1736 regression coverage. + +Prior to this fix `auth()` walked the route's declared scope first and fell +through to a workspace check, so a `{w, p}` token authorized any peer in `w`. +The contract now is: authorize by the token's narrowest claim, never widen. +""" + +from contextlib import asynccontextmanager + +import jwt as pyjwt +import pytest +from fastapi.security import HTTPAuthorizationCredentials + +from src.config import settings +from src.exceptions import AuthenticationException, ValidationException +from src.security import JWTParams, auth, create_jwt, verify_jwt + + +@pytest.fixture(autouse=True) +def _enable_auth(monkeypatch: pytest.MonkeyPatch): # pyright: ignore[reportUnusedFunction] + monkeypatch.setattr(settings.AUTH, "USE_AUTH", True) + monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret") + + +def _bearer(token: str) -> HTTPAuthorizationCredentials: + return HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) + + +class TestVerifyJWTShape: + def test_peer_token_without_workspace_rejected(self): + token = pyjwt.encode({"p": "alice"}, b"test-secret", algorithm="HS256") + with pytest.raises(AuthenticationException): + verify_jwt(token) + + def test_session_token_without_workspace_rejected(self): + token = pyjwt.encode({"s": "sess-1"}, b"test-secret", algorithm="HS256") + with pytest.raises(AuthenticationException): + verify_jwt(token) + + def test_workspace_only_token_ok(self): + token = create_jwt(JWTParams(w="ws-a")) + params = verify_jwt(token) + assert params.w == "ws-a" + + def test_workspace_peer_token_ok(self): + token = create_jwt(JWTParams(w="ws-a", p="alice")) + params = verify_jwt(token) + assert params.w == "ws-a" + assert params.p == "alice" + + +class TestAuthPeerScope: + """`{w: ws-a, p: alice}` may only act on alice in ws-a.""" + + @pytest.mark.asyncio + async def test_matches_own_peer(self): + creds = _bearer(create_jwt(JWTParams(w="ws-a", p="alice"))) + params = await auth(credentials=creds, workspace_name="ws-a", peer_name="alice") + assert params.p == "alice" + + @pytest.mark.asyncio + async def test_denies_sibling_peer_same_workspace(self): + """The original bug: peer-scoped token fell through to workspace auth.""" + creds = _bearer(create_jwt(JWTParams(w="ws-a", p="alice"))) + with pytest.raises(AuthenticationException): + await auth(credentials=creds, workspace_name="ws-a", peer_name="bob") + + @pytest.mark.asyncio + async def test_denies_workspace_route_with_no_peer(self): + """Peer-scoped token cannot use workspace-listing routes.""" + creds = _bearer(create_jwt(JWTParams(w="ws-a", p="alice"))) + with pytest.raises(AuthenticationException): + await auth(credentials=creds, workspace_name="ws-a") + + @pytest.mark.asyncio + async def test_self_authorizing_route_receives_claims(self): + """Body-scoped routes use require_auth() and compare claims in-handler.""" + creds = _bearer(create_jwt(JWTParams(w="ws-a", p="alice"))) + params = await auth(credentials=creds) + assert params.w == "ws-a" + assert params.p == "alice" + + @pytest.mark.asyncio + async def test_denies_cross_workspace(self): + creds = _bearer(create_jwt(JWTParams(w="ws-a", p="alice"))) + with pytest.raises(AuthenticationException): + await auth(credentials=creds, workspace_name="ws-b", peer_name="alice") + + +class TestAuthSessionScope: + @pytest.mark.asyncio + async def test_matches_own_session(self): + creds = _bearer(create_jwt(JWTParams(w="ws-a", s="sess-1"))) + params = await auth( + credentials=creds, workspace_name="ws-a", session_name="sess-1" + ) + assert params.s == "sess-1" + + @pytest.mark.asyncio + async def test_denies_sibling_session_same_workspace(self): + creds = _bearer(create_jwt(JWTParams(w="ws-a", s="sess-1"))) + with pytest.raises(AuthenticationException): + await auth(credentials=creds, workspace_name="ws-a", session_name="sess-2") + + @pytest.mark.asyncio + async def test_denies_workspace_route_with_no_session(self): + creds = _bearer(create_jwt(JWTParams(w="ws-a", s="sess-1"))) + with pytest.raises(AuthenticationException): + await auth(credentials=creds, workspace_name="ws-a") + + @pytest.mark.asyncio + async def test_self_authorizing_route_receives_claims(self): + creds = _bearer(create_jwt(JWTParams(w="ws-a", s="sess-1"))) + params = await auth(credentials=creds) + assert params.w == "ws-a" + assert params.s == "sess-1" + + +class TestAuthWorkspaceScope: + @pytest.mark.asyncio + async def test_matches_workspace(self): + creds = _bearer(create_jwt(JWTParams(w="ws-a"))) + params = await auth(credentials=creds, workspace_name="ws-a") + assert params.w == "ws-a" + + @pytest.mark.asyncio + async def test_workspace_token_reaches_peer_route(self): + """Workspace tokens still authorize narrower routes inside the workspace.""" + creds = _bearer(create_jwt(JWTParams(w="ws-a"))) + params = await auth(credentials=creds, workspace_name="ws-a", peer_name="alice") + assert params.w == "ws-a" + + @pytest.mark.asyncio + async def test_denies_cross_workspace(self): + creds = _bearer(create_jwt(JWTParams(w="ws-a"))) + with pytest.raises(AuthenticationException): + await auth(credentials=creds, workspace_name="ws-b") + + @pytest.mark.asyncio + async def test_passes_self_authorizing_route(self): + """Routes with no declared scope (e.g. POST /v3/workspaces) self-authorize + on the token's `w`. The auth dependency must let workspace tokens through.""" + creds = _bearer(create_jwt(JWTParams(w="ws-a"))) + params = await auth(credentials=creds) + assert params.w == "ws-a" + + +@asynccontextmanager +async def _fake_tracked_db(*_args: object, **_kwargs: object): + """Stand-in for tracked_db; the membership query itself is monkeypatched.""" + yield None + + +def _patch_membership(monkeypatch: pytest.MonkeyPatch, *, is_member: bool): + async def _is_peer_in_session(*_args: object, **_kwargs: object) -> bool: + return is_member + + # Names are resolved via lazy imports inside auth(), so patch the source + # modules rather than the security namespace. + monkeypatch.setattr("src.dependencies.tracked_db", _fake_tracked_db) + monkeypatch.setattr("src.crud.session.is_peer_in_session", _is_peer_in_session) + + +class TestAuthMemberRead: + """Peer-scoped key gets read-only access to sessions it is a member of.""" + + @pytest.mark.asyncio + async def test_member_peer_allowed_on_read_route( + self, monkeypatch: pytest.MonkeyPatch + ): + _patch_membership(monkeypatch, is_member=True) + creds = _bearer(create_jwt(JWTParams(w="ws-a", p="alice"))) + params = await auth( + credentials=creds, + workspace_name="ws-a", + session_name="sess-1", + allow_member_read=True, + ) + assert params.p == "alice" + + @pytest.mark.asyncio + async def test_non_member_peer_denied_on_read_route( + self, monkeypatch: pytest.MonkeyPatch + ): + _patch_membership(monkeypatch, is_member=False) + creds = _bearer(create_jwt(JWTParams(w="ws-a", p="alice"))) + with pytest.raises(AuthenticationException): + await auth( + credentials=creds, + workspace_name="ws-a", + session_name="sess-1", + allow_member_read=True, + ) + + @pytest.mark.asyncio + async def test_member_peer_denied_on_write_route( + self, monkeypatch: pytest.MonkeyPatch + ): + """Write routes never set allow_member_read, so membership is irrelevant.""" + _patch_membership(monkeypatch, is_member=True) + creds = _bearer(create_jwt(JWTParams(w="ws-a", p="alice"))) + with pytest.raises(AuthenticationException): + await auth( + credentials=creds, + workspace_name="ws-a", + session_name="sess-1", + allow_member_read=False, + ) + + @pytest.mark.asyncio + async def test_member_peer_denied_cross_workspace( + self, monkeypatch: pytest.MonkeyPatch + ): + _patch_membership(monkeypatch, is_member=True) + creds = _bearer(create_jwt(JWTParams(w="ws-a", p="alice"))) + with pytest.raises(AuthenticationException): + await auth( + credentials=creds, + workspace_name="ws-b", + session_name="sess-1", + allow_member_read=True, + ) + + @pytest.mark.asyncio + async def test_session_token_has_no_cross_scope_to_peer_routes(self): + """A session key never reaches peer routes, even with allow_member_read.""" + creds = _bearer(create_jwt(JWTParams(w="ws-a", s="sess-1"))) + with pytest.raises(AuthenticationException): + await auth( + credentials=creds, + workspace_name="ws-a", + peer_name="alice", + allow_member_read=True, + ) + + +class TestCreateKeyValidation: + @pytest.mark.asyncio + async def test_peer_key_without_workspace_rejected(self): + from src.routers.keys import create_key + + with pytest.raises(ValidationException): + await create_key(workspace_id=None, peer_id="alice", session_id=None) + + @pytest.mark.asyncio + async def test_session_key_without_workspace_rejected(self): + from src.routers.keys import create_key + + with pytest.raises(ValidationException): + await create_key(workspace_id=None, peer_id=None, session_id="sess-1") + + @pytest.mark.asyncio + async def test_peer_key_with_workspace_ok(self): + from src.routers.keys import create_key + + result = await create_key(workspace_id="ws-a", peer_id="alice", session_id=None) + assert "key" in result + + +class TestAuthAdminAndUnscoped: + @pytest.mark.asyncio + async def test_admin_passes_any_route(self): + creds = _bearer(create_jwt(JWTParams(ad=True))) + params = await auth(credentials=creds, workspace_name="ws-a", peer_name="alice") + assert params.ad is True + + @pytest.mark.asyncio + async def test_non_admin_token_denied_on_admin_route(self): + creds = _bearer(create_jwt(JWTParams(w="ws-a"))) + with pytest.raises(AuthenticationException): + await auth(credentials=creds, admin=True) + + @pytest.mark.asyncio + async def test_unscoped_token_on_self_authorizing_route(self): + """A token with no scope claims and a route with no declared scope is the + escape hatch for routes that introspect jwt_params themselves.""" + creds = _bearer(create_jwt(JWTParams())) + params = await auth(credentials=creds) + assert params.w is None + assert params.p is None + assert params.s is None + + @pytest.mark.asyncio + async def test_unscoped_token_denied_on_scoped_route(self): + creds = _bearer(create_jwt(JWTParams())) + with pytest.raises(AuthenticationException): + await auth(credentials=creds, workspace_name="ws-a") From 414e31c960d57623f39d9f10892d74d8dda0e9d9 Mon Sep 17 00:00:00 2001 From: Harish Kukreja Date: Mon, 22 Jun 2026 17:49:08 -0400 Subject: [PATCH 18/65] feat(deriver): age-flush stalled representation batches (#826) Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> --- .env.template | 1 + CHANGELOG.md | 4 + config.toml.example | 1 + docs/v3/contributing/configuration.mdx | 1 + docs/v3/contributing/troubleshooting.mdx | 2 +- .../core-concepts/design-patterns.mdx | 2 +- .../guides/recipes/unified-memory-setup.mdx | 11 +- src/config.py | 5 + src/deriver/queue_manager.py | 56 ++++- tests/deriver/test_queue_processing.py | 227 ++++++++++++++++++ tests/test_config.py | 14 ++ 11 files changed, 309 insertions(+), 15 deletions(-) diff --git a/.env.template b/.env.template index 0b4a6f99..451def53 100644 --- a/.env.template +++ b/.env.template @@ -129,6 +129,7 @@ LLM_OPENAI_API_KEY=your-api-key-here # DERIVER_MAX_CUSTOM_INSTRUCTIONS_TOKENS=2000 # DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100 # DERIVER_REPRESENTATION_BATCH_MAX_TOKENS=1024 +# DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS=1800 # DERIVER_FLUSH_ENABLED=false # Bypass batch token threshold, process work immediately # DERIVER_MODEL_CONFIG__FALLBACK__MODEL= # DERIVER_MODEL_CONFIG__FALLBACK__TRANSPORT= diff --git a/CHANGELOG.md b/CHANGELOG.md index d31b9d72..6bc93dc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added + +- `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800s) lets sub-threshold representation work units flush once their oldest unprocessed queue item ages out. Set it to `0` to keep the legacy behavior where sub-threshold tails wait indefinitely unless `DERIVER_FLUSH_ENABLED=true`. + ### Changed - Peer-scoped JWTs now get read-only access to the sessions their peer is an active member of (session context, summaries, peers, their own per-session config, search, and message reads). Session-scoped JWTs remain confined to their session and cannot reach peer routes. diff --git a/config.toml.example b/config.toml.example index 3aacfbb7..9e0e9c11 100644 --- a/config.toml.example +++ b/config.toml.example @@ -109,6 +109,7 @@ MAX_INPUT_TOKENS = 25000 MAX_CUSTOM_INSTRUCTIONS_TOKENS = 2000 WORKING_REPRESENTATION_MAX_OBSERVATIONS = 100 REPRESENTATION_BATCH_MAX_TOKENS = 1024 +REPRESENTATION_BATCH_MAX_AGE_SECONDS = 1800 FLUSH_ENABLED = false # Bypass batch token threshold, process work immediately [deriver.model_config] diff --git a/docs/v3/contributing/configuration.mdx b/docs/v3/contributing/configuration.mdx index 06add0d0..cd74a829 100644 --- a/docs/v3/contributing/configuration.mdx +++ b/docs/v3/contributing/configuration.mdx @@ -402,6 +402,7 @@ DERIVER_DEDUPLICATE=true DERIVER_LOG_OBSERVATIONS=false DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100 DERIVER_REPRESENTATION_BATCH_MAX_TOKENS=1024 +DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS=1800 ``` **Peer Card:** diff --git a/docs/v3/contributing/troubleshooting.mdx b/docs/v3/contributing/troubleshooting.mdx index bb71a475..7bfc6290 100644 --- a/docs/v3/contributing/troubleshooting.mdx +++ b/docs/v3/contributing/troubleshooting.mdx @@ -109,7 +109,7 @@ Messages are stored but no observations, summaries, or representations are being ```bash DERIVER_WORKERS=4 ``` -5. **Representation Batch Max** — By default the deriver is set to buffer its operations until there are enough tokens for a given representation in a session. This is set via the `REPRESENTATION_BATCH_MAX_TOKENS` environment variable. If you aren't seeing tasks continue it may be that the batch size is set too high or enough data hasn't flowed into to the session yet. See [token batching](/v3/documentation/core-concepts/reasoning#token-batching) for more details +5. **Representation Batch Max** — By default the deriver buffers representation work until a session has enough tokens for that representation, set via `DERIVER_REPRESENTATION_BATCH_MAX_TOKENS`. Sub-threshold tails become eligible after `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800 seconds), so quiet sessions eventually flush without disabling batching globally. Set the age to `0` for legacy behavior where sub-threshold tails wait indefinitely. See [token batching](/v3/documentation/core-concepts/reasoning#token-batching) for more details ## Alternative Provider Issues diff --git a/docs/v3/documentation/core-concepts/design-patterns.mdx b/docs/v3/documentation/core-concepts/design-patterns.mdx index 06582d80..9893d3f4 100644 --- a/docs/v3/documentation/core-concepts/design-patterns.mdx +++ b/docs/v3/documentation/core-concepts/design-patterns.mdx @@ -63,7 +63,7 @@ Sessions define the temporal boundaries of an interaction. How you scope them af Create a **new** session when context resets (new conversation, new day, new topic); **reuse** one when context should keep accumulating (ongoing channel, persistent thread). -**Don't scope sessions too thin.** Honcho only reasons over a peer once it accumulates ~1,000 tokens *within a single session* ([token batching](/v3/documentation/core-concepts/reasoning#token-batching)). Many tiny sessions each stall below that threshold, so low-volume or trickle inputs should append to one ongoing session rather than fragment across many (nothing is lost — it just waits). +**Don't scope sessions too thin.** Honcho batches reasoning until a peer accumulates ~1,000 tokens *within a single session*, with a default age-based flush for quiet tails ([token batching](/v3/documentation/core-concepts/reasoning#token-batching)). Low-volume or trickle inputs should still append to one ongoing session rather than fragment across many, so reasoning runs with useful context instead of many small delayed batches. **How cross-session reasoning works** diff --git a/docs/v3/guides/recipes/unified-memory-setup.mdx b/docs/v3/guides/recipes/unified-memory-setup.mdx index 1d547db2..ea921cc3 100644 --- a/docs/v3/guides/recipes/unified-memory-setup.mdx +++ b/docs/v3/guides/recipes/unified-memory-setup.mdx @@ -131,16 +131,17 @@ for i in range(0, len(messages), 100): session.add_messages(messages[i:i + 100]) ``` -Honcho only reasons over a peer once it accumulates ~1,000 tokens *within a single session* -([token batching](/v3/documentation/core-concepts/reasoning#token-batching)). Scope -the session to the volume you ingest: +Honcho batches reasoning until a peer accumulates ~1,000 tokens *within a single session*, +with a default age-based flush for quiet tails +([token batching](/v3/documentation/core-concepts/reasoning#token-batching)). Scope the +session to the volume you ingest: - **High-volume runs** (a day of emails, a CRM export) clear the threshold easily — a per-run session like `email-import-{date}` is fine. - **Low-volume or trickle imports** (a few short records at a time) should append to one **ongoing per-source session** (e.g. `email-import-gmail`), so content - accumulates across runs instead of fragmenting into thin sessions that each stall - below the threshold (nothing is lost — it just waits). + accumulates across runs instead of fragmenting into thin sessions that each flush + later with little context. The [Gmail](/v3/guides/gmail) and [Granola](/v3/guides/granola) guides are related import examples. diff --git a/src/config.py b/src/config.py index 267a8154..3c20b34f 100644 --- a/src/config.py +++ b/src/config.py @@ -830,6 +830,11 @@ class DeriverSettings(HonchoSettings): int, Field(default=1024, ge=128, le=16_384), ] = 1024 + # Sub-threshold work units become eligible once their oldest unprocessed + # item exceeds this age. 0 disables age-based flushing. + REPRESENTATION_BATCH_MAX_AGE_SECONDS: Annotated[int, Field(default=1800, ge=0)] = ( + 1800 + ) # When enabled, bypasses the batch token threshold and processes work immediately FLUSH_ENABLED: bool = False diff --git a/src/deriver/queue_manager.py b/src/deriver/queue_manager.py index e97e67ed..93bed1a1 100644 --- a/src/deriver/queue_manager.py +++ b/src/deriver/queue_manager.py @@ -330,8 +330,10 @@ class QueueManager: async def get_and_claim_work_units(self) -> dict[str, str]: """ Get available work units that aren't being processed. - For representation tasks, only returns work units with accumulated tokens - >= REPRESENTATION_BATCH_MAX_TOKENS (forced batching), unless FLUSH_ENABLED is True. + For representation tasks, only returns work units whose accumulated + tokens reach REPRESENTATION_BATCH_MAX_TOKENS or whose oldest pending + item exceeds REPRESENTATION_BATCH_MAX_AGE_SECONDS, unless + FLUSH_ENABLED is True. Returns a dict mapping work_unit_key to aqs_id. """ limit: int = max(0, self.workers - self.get_total_owned_work_units()) @@ -346,6 +348,7 @@ class QueueManager: select( models.QueueItem.work_unit_key, func.sum(models.Message.token_count).label("total_tokens"), + func.min(models.QueueItem.created_at).label("oldest_created_at"), ) .join( models.Message, @@ -358,15 +361,21 @@ class QueueManager: ) work_units_subq = ( - select(models.QueueItem.work_unit_key) + select( + models.QueueItem.work_unit_key, + func.min(models.QueueItem.created_at).label("oldest_created_at"), + ) .where(~models.QueueItem.processed) .group_by(models.QueueItem.work_unit_key) .subquery() ) query = ( - select(work_units_subq.c.work_unit_key) - .limit(limit) + select( + work_units_subq.c.work_unit_key, + token_stats_subq.c.total_tokens, + token_stats_subq.c.oldest_created_at, + ) .outerjoin( token_stats_subq, work_units_subq.c.work_unit_key == token_stats_subq.c.work_unit_key, @@ -379,22 +388,53 @@ class QueueManager: ) .exists() ) + .order_by( + work_units_subq.c.oldest_created_at.asc(), + work_units_subq.c.work_unit_key.asc(), + ) + .limit(limit) ) # Apply batch threshold filter (skip if FLUSH_ENABLED is True) if not settings.DERIVER.FLUSH_ENABLED and batch_max_tokens > 0: + max_age_seconds = settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS + threshold_clause = ( + func.coalesce(token_stats_subq.c.total_tokens, 0) + >= batch_max_tokens + ) + if max_age_seconds > 0: + threshold_clause = or_( + threshold_clause, + token_stats_subq.c.oldest_created_at + <= func.now() - timedelta(seconds=max_age_seconds), + ) query = query.where( or_( ~work_units_subq.c.work_unit_key.startswith( representation_prefix ), - func.coalesce(token_stats_subq.c.total_tokens, 0) - >= batch_max_tokens, + threshold_clause, ) ) result = await db.execute(query) - available_units = result.scalars().all() + available_rows = result.all() + available_units: list[str] = [] + for work_unit_key, total_tokens, oldest_created_at in available_rows: + available_units.append(work_unit_key) + if ( + not settings.DERIVER.FLUSH_ENABLED + and settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS > 0 + and work_unit_key.startswith(representation_prefix) + and int(total_tokens or 0) < batch_max_tokens + ): + logger.info( + "age-flushing work unit %s (tokens=%s < %s, oldest=%s)", + work_unit_key, + total_tokens or 0, + batch_max_tokens, + oldest_created_at, + ) if not available_units: await db.commit() return {} diff --git a/tests/deriver/test_queue_processing.py b/tests/deriver/test_queue_processing.py index 4c658d97..68d4852c 100644 --- a/tests/deriver/test_queue_processing.py +++ b/tests/deriver/test_queue_processing.py @@ -1,5 +1,6 @@ import asyncio from collections.abc import Callable +from datetime import datetime, timedelta, timezone from typing import Any from unittest.mock import patch @@ -18,6 +19,71 @@ from src.utils.work_unit import construct_work_unit_key class TestQueueProcessing: """Test suite for queue processing functionality""" + async def _add_representation_work_unit( + self, + *, + db_session: AsyncSession, + sample_session_with_peers: tuple[models.Session, list[models.Peer]], + create_queue_payload: Callable[..., Any], + token_counts: list[int], + created_ats: list[datetime] | None = None, + ) -> tuple[str, list[models.QueueItem]]: + session, peers = sample_session_with_peers + peer = peers[0] + + messages: list[models.Message] = [] + for index, token_count in enumerate(token_counts): + message = models.Message( + session_name=session.name, + workspace_name=session.workspace_name, + peer_name=peer.name, + content=f"Message {index}", + token_count=token_count, + seq_in_session=index + 1, + ) + db_session.add(message) + messages.append(message) + + await db_session.commit() + for message in messages: + await db_session.refresh(message) + + work_unit_key = "" + queue_items: list[models.QueueItem] = [] + for index, message in enumerate(messages): + payload = create_queue_payload( + message=message, + task_type="representation", + observed=peer.name, + observer=peer.name, + ) + work_unit_key = work_unit_key or construct_work_unit_key( + session.workspace_name, payload + ) + + queue_item_kwargs: dict[str, Any] = {} + if created_ats: + queue_item_kwargs["created_at"] = created_ats[index] + + queue_item = models.QueueItem( + session_id=session.id, + task_type="representation", + work_unit_key=work_unit_key, + payload=payload, + processed=False, + workspace_name=session.workspace_name, + message_id=message.id, + **queue_item_kwargs, + ) + db_session.add(queue_item) + queue_items.append(queue_item) + + await db_session.commit() + for queue_item in queue_items: + await db_session.refresh(queue_item) + + return work_unit_key, queue_items + async def test_get_and_claim_work_units( self, db_session: AsyncSession, @@ -1413,6 +1479,167 @@ class TestQueueProcessing: claimed2 = await qm.get_and_claim_work_units() assert rep_work_unit_key in claimed2 + @pytest.mark.asyncio + async def test_age_flush_waits_for_fresh_sub_threshold_items( + self, + db_session: AsyncSession, + sample_session_with_peers: tuple[models.Session, list[models.Peer]], + create_queue_payload: Callable[..., Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(settings.DERIVER, "FLUSH_ENABLED", False) + monkeypatch.setattr( + settings.DERIVER, "REPRESENTATION_BATCH_MAX_AGE_SECONDS", 1800 + ) + + work_unit_key, _queue_items = await self._add_representation_work_unit( + db_session=db_session, + sample_session_with_peers=sample_session_with_peers, + create_queue_payload=create_queue_payload, + token_counts=[100, 100, 100], + ) + + claimed = await QueueManager().get_and_claim_work_units() + + assert work_unit_key not in claimed + + @pytest.mark.asyncio + async def test_age_flush_claims_old_sub_threshold_items_and_fetches_tail( + self, + db_session: AsyncSession, + sample_session_with_peers: tuple[models.Session, list[models.Peer]], + create_queue_payload: Callable[..., Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(settings.DERIVER, "FLUSH_ENABLED", False) + monkeypatch.setattr( + settings.DERIVER, "REPRESENTATION_BATCH_MAX_AGE_SECONDS", 1800 + ) + old_timestamp = datetime.now(timezone.utc) - timedelta(hours=2) + + work_unit_key, queue_items = await self._add_representation_work_unit( + db_session=db_session, + sample_session_with_peers=sample_session_with_peers, + create_queue_payload=create_queue_payload, + token_counts=[100, 100, 100], + created_ats=[old_timestamp, old_timestamp, old_timestamp], + ) + + qm = QueueManager() + claimed = await qm.get_and_claim_work_units() + + assert work_unit_key in claimed + batch = await qm.get_queue_item_batch( + task_type="representation", + work_unit_key=work_unit_key, + aqs_id=claimed[work_unit_key], + ) + assert [item.id for item in batch.items_to_process] == [ + item.id for item in queue_items + ] + + @pytest.mark.asyncio + async def test_age_flush_zero_preserves_legacy_wait_for_old_items( + self, + db_session: AsyncSession, + sample_session_with_peers: tuple[models.Session, list[models.Peer]], + create_queue_payload: Callable[..., Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(settings.DERIVER, "FLUSH_ENABLED", False) + monkeypatch.setattr(settings.DERIVER, "REPRESENTATION_BATCH_MAX_AGE_SECONDS", 0) + old_timestamp = datetime.now(timezone.utc) - timedelta(hours=2) + + work_unit_key, _queue_items = await self._add_representation_work_unit( + db_session=db_session, + sample_session_with_peers=sample_session_with_peers, + create_queue_payload=create_queue_payload, + token_counts=[100, 100], + created_ats=[old_timestamp, old_timestamp], + ) + + claimed = await QueueManager().get_and_claim_work_units() + + assert work_unit_key not in claimed + + @pytest.mark.asyncio + async def test_flush_enabled_bypasses_age_and_token_thresholds( + self, + db_session: AsyncSession, + sample_session_with_peers: tuple[models.Session, list[models.Peer]], + create_queue_payload: Callable[..., Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(settings.DERIVER, "FLUSH_ENABLED", True) + monkeypatch.setattr( + settings.DERIVER, "REPRESENTATION_BATCH_MAX_AGE_SECONDS", 1800 + ) + + work_unit_key, _queue_items = await self._add_representation_work_unit( + db_session=db_session, + sample_session_with_peers=sample_session_with_peers, + create_queue_payload=create_queue_payload, + token_counts=[100, 100], + ) + + claimed = await QueueManager().get_and_claim_work_units() + + assert work_unit_key in claimed + + @pytest.mark.asyncio + async def test_age_flush_uses_oldest_unprocessed_item( + self, + db_session: AsyncSession, + sample_session_with_peers: tuple[models.Session, list[models.Peer]], + create_queue_payload: Callable[..., Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(settings.DERIVER, "FLUSH_ENABLED", False) + monkeypatch.setattr( + settings.DERIVER, "REPRESENTATION_BATCH_MAX_AGE_SECONDS", 1800 + ) + now = datetime.now(timezone.utc) + + work_unit_key, _queue_items = await self._add_representation_work_unit( + db_session=db_session, + sample_session_with_peers=sample_session_with_peers, + create_queue_payload=create_queue_payload, + token_counts=[100, 100], + created_ats=[now - timedelta(hours=2), now], + ) + + claimed = await QueueManager().get_and_claim_work_units() + + assert work_unit_key in claimed + + @pytest.mark.asyncio + async def test_age_flush_ignores_old_processed_items( + self, + db_session: AsyncSession, + sample_session_with_peers: tuple[models.Session, list[models.Peer]], + create_queue_payload: Callable[..., Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(settings.DERIVER, "FLUSH_ENABLED", False) + monkeypatch.setattr( + settings.DERIVER, "REPRESENTATION_BATCH_MAX_AGE_SECONDS", 1800 + ) + now = datetime.now(timezone.utc) + + work_unit_key, queue_items = await self._add_representation_work_unit( + db_session=db_session, + sample_session_with_peers=sample_session_with_peers, + create_queue_payload=create_queue_payload, + token_counts=[100, 100], + created_ats=[now - timedelta(hours=2), now], + ) + queue_items[0].processed = True + await db_session.commit() + + claimed = await QueueManager().get_and_claim_work_units() + + assert work_unit_key not in claimed + @pytest.mark.asyncio async def test_forced_batching_single_large_message( self, diff --git a/tests/test_config.py b/tests/test_config.py index 86ea0994..28b58d81 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,6 +8,7 @@ def _make_deriver_settings( MAX_INPUT_TOKENS: int = 25000, MAX_CUSTOM_INSTRUCTIONS_TOKENS: int = 2000, REPRESENTATION_BATCH_MAX_TOKENS: int = 1024, + REPRESENTATION_BATCH_MAX_AGE_SECONDS: int = 1800, ) -> DeriverSettings: return DeriverSettings( MODEL_CONFIG=ConfiguredModelSettings( @@ -17,6 +18,7 @@ def _make_deriver_settings( MAX_INPUT_TOKENS=MAX_INPUT_TOKENS, MAX_CUSTOM_INSTRUCTIONS_TOKENS=MAX_CUSTOM_INSTRUCTIONS_TOKENS, REPRESENTATION_BATCH_MAX_TOKENS=REPRESENTATION_BATCH_MAX_TOKENS, + REPRESENTATION_BATCH_MAX_AGE_SECONDS=REPRESENTATION_BATCH_MAX_AGE_SECONDS, ) @@ -25,6 +27,7 @@ def test_deriver_defaults_enable_custom_instructions_at_supported_cap() -> None: assert settings.MAX_INPUT_TOKENS == 25000 assert settings.MAX_CUSTOM_INSTRUCTIONS_TOKENS == 2000 + assert settings.REPRESENTATION_BATCH_MAX_AGE_SECONDS == 1800 def test_custom_instructions_tokens_can_be_disabled_with_zero() -> None: @@ -36,3 +39,14 @@ def test_custom_instructions_tokens_can_be_disabled_with_zero() -> None: def test_custom_instructions_tokens_cannot_exceed_supported_cap() -> None: with pytest.raises(ValueError, match="less than or equal to 2000"): _make_deriver_settings(MAX_CUSTOM_INSTRUCTIONS_TOKENS=2001) + + +def test_representation_batch_age_can_be_disabled_with_zero() -> None: + settings = _make_deriver_settings(REPRESENTATION_BATCH_MAX_AGE_SECONDS=0) + + assert settings.REPRESENTATION_BATCH_MAX_AGE_SECONDS == 0 + + +def test_representation_batch_age_rejects_negative_values() -> None: + with pytest.raises(ValueError, match="greater than or equal to 0"): + _make_deriver_settings(REPRESENTATION_BATCH_MAX_AGE_SECONDS=-1) From e2ff106f284eb6d60835af37fb02b8ccc5ba2a57 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:14:55 -0400 Subject: [PATCH 19/65] Filter noisy sentry traces/profiles (#834) * perf(reconciler): only trace Sentry transactions when work is found The reconciler enqueues sync_vectors every ~5 min per deriver instance. process_item wrapped every dequeued reconciler task in a single process_reconciler_task transaction, so idle cycles (the common case, where the cycle finds no rows and exits immediately) still created and sampled a transaction + profile, draining Sentry tracing/profiling quota. Remove the top-level transaction and push tracing into the sync batch helpers, starting a per-batch transaction only after rows are confirmed. Idle cycles now emit zero transactions; busy sweeps emit one smaller transaction per batch operation. Co-Authored-By: Claude Opus 4.8 (1M context) * perf(telemetry): drop infra/scrape transactions via a Sentry traces sampler Sentry was sampling every transaction at a flat traces_sample_rate with no sampler. The Prometheus /metrics scrape endpoint alone accounted for ~92% of all traced transactions (and their profiles), with /openapi.json and the deriver metrics server adding more pure noise. Add a traces_sampler that returns 0.0 for infra/scrape endpoints (/metrics, /health, /openapi.json, /docs, /redoc, and metrics/openapi transaction names) and the configured rate for real traffic. Sampling here (vs before_send_transaction) means dropped transactions are never recorded or profiled and the decision propagates to child spans. Shared init covers both the API server and the deriver worker. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/deriver/consumer.py | 25 +++--- src/reconciler/sync_vectors.py | 25 ++++-- src/telemetry/sentry.py | 56 ++++++++++++- tests/deriver/test_vector_reconciliation.py | 88 +++++++++++++++++++++ tests/telemetry/test_sentry_sampler.py | 60 ++++++++++++++ 5 files changed, 233 insertions(+), 21 deletions(-) create mode 100644 tests/telemetry/test_sentry_sampler.py diff --git a/src/deriver/consumer.py b/src/deriver/consumer.py index 8bbd9359..6e35d614 100644 --- a/src/deriver/consumer.py +++ b/src/deriver/consumer.py @@ -43,17 +43,20 @@ async def process_item(queue_item: models.QueueItem) -> None: # Handle reconciler first - it's the only task type that doesn't require workspace_name if task_type == "reconciler": - with sentry_sdk.start_transaction(name="process_reconciler_task", op="deriver"): - try: - validated = ReconcilerPayload(**queue_payload) - except ValidationError as e: - logger.error( - "Invalid reconciler payload received: %s. Payload: %s", - str(e), - queue_payload, - ) - raise ValueError(f"Invalid payload structure: {str(e)}") from e - await process_reconciler(validated) + # No top-level transaction here: reconciler tasks poll on a fixed + # interval and usually find no work. Tracing is started per-batch + # inside the reconciler only when actual work is found, so idle + # cycles don't consume Sentry tracing/profiling quota. + try: + validated = ReconcilerPayload(**queue_payload) + except ValidationError as e: + logger.error( + "Invalid reconciler payload received: %s. Payload: %s", + str(e), + queue_payload, + ) + raise ValueError(f"Invalid payload structure: {str(e)}") from e + await process_reconciler(validated) return # All other task types require a workspace_name diff --git a/src/reconciler/sync_vectors.py b/src/reconciler/sync_vectors.py index 00db6e3d..cdc18542 100644 --- a/src/reconciler/sync_vectors.py +++ b/src/reconciler/sync_vectors.py @@ -11,6 +11,7 @@ import time from dataclasses import dataclass from typing import Any, cast +import sentry_sdk from sqlalchemy import and_, delete, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm.attributes import InstrumentedAttribute @@ -618,10 +619,13 @@ async def _reconcile_documents_batch( if not docs: return False - synced, failed = await _sync_documents(db, docs, external_vector_store) - metrics.documents_synced += synced - metrics.documents_failed += failed - await db.commit() + with sentry_sdk.start_transaction( + name="reconcile_documents_batch", op="reconciler" + ): + synced, failed = await _sync_documents(db, docs, external_vector_store) + metrics.documents_synced += synced + metrics.documents_failed += failed + await db.commit() return True @@ -639,10 +643,15 @@ async def _reconcile_message_embeddings_batch( if not embs: return False - synced, failed = await _sync_message_embeddings(db, embs, external_vector_store) - metrics.message_embeddings_synced += synced - metrics.message_embeddings_failed += failed - await db.commit() + with sentry_sdk.start_transaction( + name="reconcile_message_embeddings_batch", op="reconciler" + ): + synced, failed = await _sync_message_embeddings( + db, embs, external_vector_store + ) + metrics.message_embeddings_synced += synced + metrics.message_embeddings_failed += failed + await db.commit() return True diff --git a/src/telemetry/sentry.py b/src/telemetry/sentry.py index 1d16b1e7..4720c418 100644 --- a/src/telemetry/sentry.py +++ b/src/telemetry/sentry.py @@ -6,7 +6,7 @@ import inspect import logging from collections.abc import Callable, Sequence from functools import wraps -from typing import TYPE_CHECKING, ParamSpec, TypeVar, cast +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast import sentry_sdk @@ -22,6 +22,56 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +# Paths whose transactions carry no debugging value but are hit constantly +# (health checks, Prometheus scrapes, OpenAPI schema, docs). Tracing them at the +# same rate as real traffic drowns the signal and burns tracing/profiling quota. +# Note: /docs and /redoc are disabled in production but are listed for safety. +_UNSAMPLED_PATHS = frozenset( + {"/metrics", "/health", "/openapi.json", "/docs", "/redoc"} +) + + +def _is_unsampled_transaction_name(name: str | None) -> bool: + """Match infra/scrape transactions by name. + + Fallback for transactions that don't expose an ASGI scope path (e.g. the + deriver's metrics server) or whose endpoint-style name encodes the route. + """ + if not name: + return False + return ( + name.endswith("openapi") + or name.endswith("metrics_endpoint") + or "prometheus.metrics" in name + ) + + +def traces_sampler(sampling_context: dict[str, Any]) -> float: + """Drop infra/scrape transactions; sample everything else at the default rate. + + Using a sampler (rather than ``before_send_transaction``) means dropped + transactions are never recorded or profiled, and the decision propagates to + child spans. ``SENTRY.TRACES_SAMPLE_RATE`` remains the rate for real traffic. + """ + asgi_scope = cast("dict[str, Any] | None", sampling_context.get("asgi_scope")) + if asgi_scope is not None and asgi_scope.get("path") in _UNSAMPLED_PATHS: + return 0.0 + + transaction_context = cast( + "dict[str, Any] | None", sampling_context.get("transaction_context") + ) + name = transaction_context.get("name") if transaction_context else None + if _is_unsampled_transaction_name(name if isinstance(name, str) else None): + return 0.0 + + # Respect an upstream sampling decision when continuing a distributed trace. + parent_sampled = sampling_context.get("parent_sampled") + if parent_sampled is not None: + return float(parent_sampled) + + return settings.SENTRY.TRACES_SAMPLE_RATE + + # Sentry SDK's default behavior: # - Captures INFO+ level logs as breadcrumbs # - Captures ERROR+ level logs as Sentry events @@ -44,7 +94,9 @@ def initialize_sentry( enable_tracing=True, release=settings.SENTRY.RELEASE, environment=settings.SENTRY.ENVIRONMENT, - traces_sample_rate=settings.SENTRY.TRACES_SAMPLE_RATE, + # traces_sampler supersedes traces_sample_rate; it returns the configured + # rate for real traffic and 0.0 for infra/scrape endpoints (see above). + traces_sampler=traces_sampler, profiles_sample_rate=settings.SENTRY.PROFILES_SAMPLE_RATE, before_send=before_send, integrations=integrations, diff --git a/tests/deriver/test_vector_reconciliation.py b/tests/deriver/test_vector_reconciliation.py index 1c004886..5e2e4712 100644 --- a/tests/deriver/test_vector_reconciliation.py +++ b/tests/deriver/test_vector_reconciliation.py @@ -6,6 +6,7 @@ message embeddings to the vector store, handling failures and retries. """ import datetime +from contextlib import asynccontextmanager from typing import cast from unittest.mock import AsyncMock, MagicMock, patch @@ -20,6 +21,7 @@ from src.reconciler.sync_vectors import ( ReconciliationMetrics, _get_documents_needing_sync, # pyright: ignore[reportPrivateUsage] _get_message_embeddings_needing_sync, # pyright: ignore[reportPrivateUsage] + _reconcile_documents_batch, # pyright: ignore[reportPrivateUsage] _reconcile_message_embeddings_batch, # pyright: ignore[reportPrivateUsage] _sync_documents, # pyright: ignore[reportPrivateUsage] _sync_message_embeddings, # pyright: ignore[reportPrivateUsage] @@ -993,6 +995,92 @@ class TestEndToEndReconciliation: mock_cleanup_docs.assert_awaited_once() +@pytest.mark.asyncio +class TestReconcilerTracing: + """A Sentry transaction is started only when a sync batch finds real work. + + Reconciler tasks poll on a fixed interval and usually find nothing; an idle + cycle must create zero transactions so it doesn't drain Sentry quota. + """ + + @staticmethod + def _fake_tracked_db(db: AsyncMock): + @asynccontextmanager + async def _cm(*_args: object, **_kwargs: object): + yield db + + return _cm + + async def test_no_transaction_when_no_embeddings_to_sync(self) -> None: + """The no-work path returns before starting a transaction.""" + metrics = ReconciliationMetrics() + with ( + patch( + "src.reconciler.sync_vectors.tracked_db", + self._fake_tracked_db(AsyncMock()), + ), + patch( + "src.reconciler.sync_vectors._get_message_embeddings_needing_sync", + new_callable=AsyncMock, + return_value=[], + ), + patch("src.reconciler.sync_vectors.sentry_sdk.start_transaction") as txn, + ): + worked = await _reconcile_message_embeddings_batch(None, metrics) + + assert worked is False + txn.assert_not_called() + + async def test_transaction_started_when_embeddings_present(self) -> None: + """A batch with real work starts its own named transaction.""" + metrics = ReconciliationMetrics() + with ( + patch( + "src.reconciler.sync_vectors.tracked_db", + self._fake_tracked_db(AsyncMock()), + ), + patch( + "src.reconciler.sync_vectors._get_message_embeddings_needing_sync", + new_callable=AsyncMock, + return_value=[MagicMock()], + ), + patch( + "src.reconciler.sync_vectors._sync_message_embeddings", + new_callable=AsyncMock, + return_value=(1, 0), + ), + patch("src.reconciler.sync_vectors.sentry_sdk.start_transaction") as txn, + ): + worked = await _reconcile_message_embeddings_batch(None, metrics) + + assert worked is True + assert metrics.message_embeddings_synced == 1 + txn.assert_called_once() + assert txn.call_args.kwargs.get("name") == "reconcile_message_embeddings_batch" + + async def test_no_transaction_when_no_documents_to_sync(self) -> None: + """The document batch also skips tracing when there is nothing to sync.""" + metrics = ReconciliationMetrics() + with ( + patch( + "src.reconciler.sync_vectors.tracked_db", + self._fake_tracked_db(AsyncMock()), + ), + patch( + "src.reconciler.sync_vectors._get_documents_needing_sync", + new_callable=AsyncMock, + return_value=[], + ), + patch("src.reconciler.sync_vectors.sentry_sdk.start_transaction") as txn, + ): + worked = await _reconcile_documents_batch( + MagicMock(spec=VectorStore), metrics + ) + + assert worked is False + txn.assert_not_called() + + def test_build_message_vector_record() -> None: """The shared vector-id/metadata builder: id is {message_id}_{position}, embeddings are coerced to float, metadata shape is fixed.""" diff --git a/tests/telemetry/test_sentry_sampler.py b/tests/telemetry/test_sentry_sampler.py new file mode 100644 index 00000000..c0d08a08 --- /dev/null +++ b/tests/telemetry/test_sentry_sampler.py @@ -0,0 +1,60 @@ +"""Tests for the Sentry traces sampler. + +The sampler must drop high-volume infra/scrape transactions (health checks, +Prometheus scrapes, OpenAPI schema, docs) while sampling real traffic at the +configured rate. These endpoints otherwise dominate transaction + profiling +volume and drown out useful traces. +""" + +import pytest + +from src.config import settings +from src.telemetry.sentry import traces_sampler + + +@pytest.mark.parametrize( + "path", + ["/metrics", "/health", "/openapi.json", "/docs", "/redoc"], +) +def test_infra_paths_are_dropped(path: str) -> None: + """ASGI requests to infra/scrape paths get a 0.0 sample rate.""" + assert traces_sampler({"asgi_scope": {"path": path}}) == 0.0 + + +@pytest.mark.parametrize( + "name", + [ + "src.telemetry.prometheus.metrics.metrics_endpoint", + "src.prometheus.metrics", + "fastapi.applications.FastAPI.setup..openapi", + ], +) +def test_infra_transaction_names_are_dropped(name: str) -> None: + """Transactions without an ASGI path still drop by their endpoint name.""" + assert traces_sampler({"transaction_context": {"name": name}}) == 0.0 + + +def test_real_route_uses_default_rate() -> None: + """A normal API route is sampled at the configured default rate.""" + ctx = { + "asgi_scope": {"path": "/v3/peers/alice/chat"}, + "transaction_context": {"name": "src.routers.peers.chat"}, + } + assert traces_sampler(ctx) == settings.SENTRY.TRACES_SAMPLE_RATE + + +def test_parent_sampling_decision_is_respected() -> None: + """When continuing a distributed trace, inherit the upstream decision.""" + assert traces_sampler({"parent_sampled": True}) == 1.0 + assert traces_sampler({"parent_sampled": False}) == 0.0 + + +def test_infra_path_overrides_parent_decision() -> None: + """Infra paths are dropped even if an upstream trace was sampled in.""" + ctx = {"asgi_scope": {"path": "/metrics"}, "parent_sampled": True} + assert traces_sampler(ctx) == 0.0 + + +def test_empty_context_falls_back_to_default_rate() -> None: + """A context with no scope, name, or parent uses the default rate.""" + assert traces_sampler({}) == settings.SENTRY.TRACES_SAMPLE_RATE From e8ef1a06e53bc3f69c3f2c7621cfe9abf66839bc Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Tue, 23 Jun 2026 00:03:21 -0400 Subject: [PATCH 20/65] Track user and session ID on Langfuse traces (#814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * telemetry: use session and user IDs in langfuse * test: update old span test * fix: disable langfuse in unit tests * fix: add post-loop synthesis span * refactor: address PR review feedback on langfuse tracing - Consolidate track_name onto LLMTelemetryContext as the sole home; remove the honcho_llm_call kwarg and update 4 callers to set it on telemetry directly. Sentry ai_track now reads telemetry.track_name. - Decouple escaped-stream self-stamping from run-context exit ordering: stream_final_response now resets _in_agent_run explicitly around drain. - Narrow langfuse_agent_step wrap in the tool loop — between-turn bookkeeping (iteration_callback, choice switch, increment) lifted outside the span so it scopes only the LLM call + tools. - Reword test conftest comment to behavior-only language. Co-Authored-By: Claude Opus 4.6 * refactor: switch langfuse spans to imperative handles Replaces the context-manager-based langfuse_agent_run/step with imperative LangfuseAgentRun/Step handles so the run span can outlive the function that opens it. Streaming responses now own the run handle from construction and close it after drain, stamping the accumulated streamed text as trace output (previously blank). Multi-turn generations always stamp provider/model and step metadata, fixing the regression where only the first turn was annotated. Co-Authored-By: Claude Opus 4.7 * fix(llm): record effective prompt-only input on run span The run-level Langfuse span recorded the raw messages parameter, which is None for prompt-only calls. Mirror execute_tool_loop's handling and record the synthesized user message so the trace input isn't blank. Co-Authored-By: Claude Opus 4.8 * fix(llm): drop StreamingResponseWithMetadata.__anext__ to prevent span leak The standalone __anext__ delegated straight to the inner stream, bypassing the token-folding and Langfuse run-handle close that live only in the __aiter__ generator. Any caller driving the wrapper via anext() instead of `async for` would leak the run span and lose final-stream token accounting. Latent today (all callers use `async for`), removed to close the footgun. Add tests covering the run-handle drain path: full drain stamps the accumulated streamed text as the span output and closes once; an abandoned stream still closes via the finally rather than leaking. * chore(llm): document intentional empty-body propagate_attributes block The `with propagate_attributes(...): pass` stamps the active @observe trace root via the context manager's __enter__ side effect; the empty body reads as deletable dead code. Add a comment so it isn't removed. Addresses PR review. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(llm): restore api.py types after __anext__ removal Dropping StreamingResponseWithMetadata.__anext__ made it stop satisfying the AsyncIterator protocol, breaking the result annotation and the isinstance narrowing in honcho_llm_call. Widen the tool-less result annotation to include StreamingResponseWithMetadata and narrow positively to HonchoLLMCallResponse before reading .content. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> --- .env.template | 1 + src/deriver/deriver.py | 2 +- src/dialectic/core.py | 12 +- src/dreamer/specialists.py | 2 +- src/llm/api.py | 103 ++-- src/llm/executor.py | 19 +- src/llm/runtime.py | 284 +++++++++- src/llm/tool_loop.py | 439 ++++++++------- src/llm/types.py | 56 +- src/telemetry/__init__.py | 13 +- src/telemetry/logging.py | 46 +- src/utils/agent_tools.py | 37 ++ tests/conftest.py | 9 +- tests/llm/test_langfuse_trace_annotation.py | 499 ++++++++++++++++++ tests/llm/test_telemetry_llm_call.py | 83 ++- .../test_cases/dialectic_tool_calls.json | 103 ++++ tests/utils/test_clients.py | 87 ++- 17 files changed, 1501 insertions(+), 294 deletions(-) create mode 100644 tests/llm/test_langfuse_trace_annotation.py create mode 100644 tests/unified/test_cases/dialectic_tool_calls.json diff --git a/.env.template b/.env.template index 451def53..3e378cda 100644 --- a/.env.template +++ b/.env.template @@ -25,6 +25,7 @@ LOG_LEVEL=INFO # LANGFUSE_HOST= # LANGFUSE_PUBLIC_KEY= +# LANGFUSE_SECRET_KEY= # COLLECT_METRICS_LOCAL=false # LOCAL_METRICS_FILE=metrics.jsonl diff --git a/src/deriver/deriver.py b/src/deriver/deriver.py index 303a6ec2..c6c3aa2c 100644 --- a/src/deriver/deriver.py +++ b/src/deriver/deriver.py @@ -147,7 +147,6 @@ async def process_representation_tasks_batch( model_config=model_config, prompt=prompt, max_tokens=max_tokens, - track_name="Minimal Deriver", response_model=PromptRepresentation, json_mode=True, max_input_tokens=settings.DERIVER.MAX_INPUT_TOKENS, @@ -159,6 +158,7 @@ async def process_representation_tasks_batch( call_purpose=CallPurpose.DERIVER_REPRESENTATION.value, parent_category="representation", observed=observed, + track_name="Minimal Deriver", ), ) llm_duration = (time.perf_counter() - llm_start) * 1000 diff --git a/src/dialectic/core.py b/src/dialectic/core.py index dbbfed35..64895cb9 100644 --- a/src/dialectic/core.py +++ b/src/dialectic/core.py @@ -301,13 +301,14 @@ class DialecticAgent: return tool_executor, task_name, run_id, start_time - def _telemetry_context(self) -> LLMTelemetryContext: + def _telemetry_context(self, track_name: str | None = None) -> LLMTelemetryContext: """Build the LLMTelemetryContext shared by answer() and answer_stream(). Carries the instance's `_run_id` (always set in __init__) + workspace + peer identifiers so LLMCallCompletedEvent and 's AgentIterationEvent can attribute every per-iteration LLM call back to - this dialectic invocation. + this dialectic invocation. `track_name` names the Langfuse trace/step + (e.g. "Dialectic Agent" vs "Dialectic Agent Stream"). """ return LLMTelemetryContext( workspace_name=self.workspace_name, @@ -316,6 +317,7 @@ class DialecticAgent: agent_type="dialectic", run_id=self._run_id, peer_name=self.observed, + track_name=track_name, ) def _log_response_metrics( @@ -446,10 +448,9 @@ class DialecticAgent: tool_executor=tool_executor, max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS, messages=self.messages, - track_name="Dialectic Agent", max_input_tokens=settings.DIALECTIC.MAX_INPUT_TOKENS, trace_name="dialectic_chat", - telemetry=self._telemetry_context(), + telemetry=self._telemetry_context(track_name="Dialectic Agent"), ) self._log_response_metrics( @@ -515,10 +516,9 @@ class DialecticAgent: tool_executor=tool_executor, max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS, messages=self.messages, - track_name="Dialectic Agent Stream", max_input_tokens=settings.DIALECTIC.MAX_INPUT_TOKENS, trace_name="dialectic_chat", - telemetry=self._telemetry_context(), + telemetry=self._telemetry_context(track_name="Dialectic Agent Stream"), ), ) diff --git a/src/dreamer/specialists.py b/src/dreamer/specialists.py index bae70eef..c0d86585 100644 --- a/src/dreamer/specialists.py +++ b/src/dreamer/specialists.py @@ -286,7 +286,6 @@ If you update it, send the full deduplicated list and remove stale entries. tool_executor=tool_executor, max_tool_iterations=self.get_max_iterations(), messages=messages, - track_name=f"Dreamer/{self.name}", telemetry=LLMTelemetryContext( workspace_name=workspace_name, call_purpose=call_purpose_slug, @@ -295,6 +294,7 @@ If you update it, send the full deduplicated list and remove stale entries. run_id=run_id, observer=observer, observed=observed, + track_name=f"Dreamer/{self.name}", ), ) diff --git a/src/llm/api.py b/src/llm/api.py index 9c9a628d..13c3a5e5 100644 --- a/src/llm/api.py +++ b/src/llm/api.py @@ -21,7 +21,6 @@ from tenacity import retry, stop_after_attempt, wait_exponential from src.config import ConfiguredModelSettings, ModelConfig from src.exceptions import ValidationException -from src.telemetry.logging import conditional_observe from src.telemetry.reasoning_traces import log_reasoning_trace from .executor import honcho_llm_call_inner @@ -31,7 +30,7 @@ from .runtime import ( effective_temperature, plan_attempt, resolve_runtime_model_config, - update_current_langfuse_observation, + start_langfuse_agent_run, ) from .tool_loop import execute_tool_loop from .types import ( @@ -54,7 +53,6 @@ async def honcho_llm_call( model_config: ModelConfig | ConfiguredModelSettings, prompt: str, max_tokens: int, - track_name: str | None = None, response_model: type[M], json_mode: bool = False, temperature: float | None = None, @@ -84,7 +82,6 @@ async def honcho_llm_call( model_config: ModelConfig | ConfiguredModelSettings, prompt: str, max_tokens: int, - track_name: str | None = None, response_model: None = None, json_mode: bool = False, temperature: float | None = None, @@ -114,7 +111,6 @@ async def honcho_llm_call( model_config: ModelConfig | ConfiguredModelSettings, prompt: str, max_tokens: int, - track_name: str | None = None, response_model: type[BaseModel] | None = None, json_mode: bool = False, temperature: float | None = None, @@ -138,13 +134,11 @@ async def honcho_llm_call( ) -> AsyncIterator[HonchoLLMCallStreamChunk] | StreamingResponseWithMetadata: ... -@conditional_observe(name="LLM Call") async def honcho_llm_call( *, model_config: ModelConfig | ConfiguredModelSettings, prompt: str, max_tokens: int, - track_name: str | None = None, response_model: type[BaseModel] | None = None, json_mode: bool = False, temperature: float | None = None, @@ -206,11 +200,6 @@ async def honcho_llm_call( call_thinking_budget_tokens=thinking_budget_tokens, call_reasoning_effort=reasoning_effort, ) - update_current_langfuse_observation( - plan.provider, - plan.model, - name=track_name, - ) return plan async def _call_with_provider_selection() -> ( @@ -267,8 +256,9 @@ async def honcho_llm_call( decorated = _call_with_provider_selection - if track_name: - decorated = ai_track(track_name)(decorated) + sentry_track_name = telemetry.track_name if telemetry is not None else None + if sentry_track_name: + decorated = ai_track(sentry_track_name)(decorated) def before_retry_callback(retry_state: Any) -> None: """Update attempt counter before each retry + log transient failures. @@ -397,8 +387,8 @@ async def honcho_llm_call( ) wrapped = _toolless_call - if track_name: - wrapped = ai_track(track_name)(wrapped) + if sentry_track_name: + wrapped = ai_track(sentry_track_name)(wrapped) if enable_retry: wrapped = retry( stop=stop_after_attempt(retry_attempts), @@ -406,7 +396,9 @@ async def honcho_llm_call( before_sleep=before_retry_callback, )(wrapped) result: ( - HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk] + HonchoLLMCallResponse[Any] + | AsyncIterator[HonchoLLMCallStreamChunk] + | StreamingResponseWithMetadata ) = await wrapped() else: result = await decorated() @@ -429,30 +421,59 @@ async def honcho_llm_call( ) return result - # execute_tool_loop raises ValidationException on out-of-range - # max_tool_iterations; fail-fast is cheaper than silent clamping here. - result = await execute_tool_loop( - prompt=prompt, - max_tokens=max_tokens, - messages=messages, - tools=tools, - tool_choice=tool_choice, - tool_executor=tool_executor, - max_tool_iterations=max_tool_iterations, - response_model=response_model, - json_mode=json_mode, - temperature=temperature, - stop_seqs=stop_seqs, - verbosity=verbosity, - enable_retry=enable_retry, - retry_attempts=retry_attempts, - max_input_tokens=max_input_tokens, - get_attempt_plan=_get_attempt_plan, - before_retry_callback=before_retry_callback, - stream_final=stream_final_only, - iteration_callback=iteration_callback, - telemetry=telemetry, - ) + # One run-level Langfuse trace wraps the whole run; step/LLM/tool spans + # nest under it (the run handle keeps `start_as_current_observation` open + # via ExitStack, so the run span stays current OTel-wise even though we + # never use a `with` block here). The handle is passed into + # `execute_tool_loop` so streaming results own it from construction and + # close the span after drain — that's how the streamed text shows up as + # the trace's output instead of blank. Non-streaming results: we end in + # the `finally`. + run_label = (telemetry.track_name if telemetry else None) or "Agent" + run_handle = start_langfuse_agent_run(run_label, telemetry) + if run_handle is not None: + # Mirror execute_tool_loop's prompt-only handling: when messages is + # omitted it seeds the conversation with a single user message built + # from prompt. Record that same effective input so the run span isn't + # blank for prompt-only calls. + run_handle.update( + input=messages if messages else [{"role": "user", "content": prompt}] + ) + try: + # execute_tool_loop raises ValidationException on out-of-range + # max_tool_iterations; fail-fast is cheaper than silent clamping here. + result = await execute_tool_loop( + prompt=prompt, + max_tokens=max_tokens, + messages=messages, + tools=tools, + tool_choice=tool_choice, + tool_executor=tool_executor, + max_tool_iterations=max_tool_iterations, + response_model=response_model, + json_mode=json_mode, + temperature=temperature, + stop_seqs=stop_seqs, + verbosity=verbosity, + enable_retry=enable_retry, + retry_attempts=retry_attempts, + max_input_tokens=max_input_tokens, + get_attempt_plan=_get_attempt_plan, + before_retry_callback=before_retry_callback, + stream_final=stream_final_only, + iteration_callback=iteration_callback, + telemetry=telemetry, + langfuse_run_handle=run_handle, + ) + except BaseException: + if run_handle is not None: + run_handle.end() + raise + # Streaming wrapper owns the handle and closes it after drain; + # non-streaming paths (always a HonchoLLMCallResponse here) close it now + # with the final content as output. + if run_handle is not None and isinstance(result, HonchoLLMCallResponse): + run_handle.end(output=result.content) if trace_name and isinstance(result, HonchoLLMCallResponse): log_reasoning_trace( task_type=trace_name, diff --git a/src/llm/executor.py b/src/llm/executor.py index 99c73c70..a9fe3703 100644 --- a/src/llm/executor.py +++ b/src/llm/executor.py @@ -20,13 +20,18 @@ from typing import Any, Literal, TypeVar, overload from pydantic import BaseModel from src.config import ModelConfig, ModelTransport +from src.telemetry.logging import conditional_observe from .backend import CompletionResult as BackendCompletionResult from .backend import StreamChunk as BackendStreamChunk from .backend import ToolCallResult from .registry import CLIENTS, backend_for_provider from .request_builder import execute_completion, execute_stream -from .runtime import AttemptPlan, effective_config_for_call +from .runtime import ( + AttemptPlan, + annotate_current_langfuse_trace, + effective_config_for_call, +) from .types import ( HonchoLLMCallResponse, HonchoLLMCallStreamChunk, @@ -261,6 +266,7 @@ async def honcho_llm_call_inner( ) -> AsyncIterator[HonchoLLMCallStreamChunk]: ... +@conditional_observe(name="LLM Call", as_type="generation") async def honcho_llm_call_inner( provider: ModelTransport, model: str, @@ -284,6 +290,11 @@ async def honcho_llm_call_inner( ) -> HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk]: """One backend call. No retry, no fallback, no tool loop. + This is the Langfuse trace boundary (``@conditional_observe``): every + provider call is its own trace. Multi-turn agents thread a shared + ``run_id`` through ``telemetry`` so their per-iteration traces roll up into + one Langfuse session (see ``annotate_current_langfuse_trace``). + The outer src/llm/api.py `honcho_llm_call` handles retry + fallback + tool orchestration on top of this. @@ -300,6 +311,12 @@ async def honcho_llm_call_inner( if client is None: raise ValueError(f"Missing client for {provider}") + # Stamp this trace (user_id/session_id/metadata) now that the @observe + # span is open and the resolved provider/model are known. Set early so the + # annotation lands even on the stream path, where the span closes once the + # generator is returned (before chunks drain). + annotate_current_langfuse_trace(provider, model, telemetry=telemetry) + if messages is None: messages = [{"role": "user", "content": prompt}] diff --git a/src/llm/runtime.py b/src/llm/runtime.py index ae551378..7fb2a3bb 100644 --- a/src/llm/runtime.py +++ b/src/llm/runtime.py @@ -12,8 +12,9 @@ Owns: from __future__ import annotations import logging +from contextlib import ExitStack from contextvars import ContextVar -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any from src.config import ( @@ -25,39 +26,284 @@ from src.config import ( ) from .registry import backend_for_provider, client_for_model_config -from .types import ProviderClient, ReasoningEffortType +from .types import LLMTelemetryContext, ProviderClient, ReasoningEffortType logger = logging.getLogger(__name__) # ContextVar tracking the current retry attempt for provider switching. current_attempt: ContextVar[int] = ContextVar("current_attempt", default=0) +# True while a `LangfuseAgentRun` handle is live (start → end). Set by +# `start_langfuse_agent_run`, reset by `LangfuseAgentRun.end`. Used by +# `annotate_current_langfuse_trace` to decide whether the current generation +# is the trace root (single-shot callers like the deriver — stamp trace attrs) +# or nested under an active run (multi-turn / streaming — skip trace attrs; +# the run span already carries them via `propagate_attributes`). +_in_agent_run: ContextVar[bool] = ContextVar("_in_agent_run", default=False) -def update_current_langfuse_observation( + +def annotate_current_langfuse_trace( provider: ModelTransport, model: str, *, - name: str | None = None, + telemetry: LLMTelemetryContext | None = None, ) -> None: - """Best-effort annotation of the current Langfuse span with LLM routing.""" + """Stamp provider/model + step metadata on the current Langfuse generation. + + Inside an active agent run, `propagate_attributes` already stamped + user_id/session_id/trace_name on the run span; this call only needs to + decorate the per-iteration generation. Outside a run (single-shot + callers — deriver, summarizer), this generation IS the trace root, so we + also stamp the trace attrs. + + Note: `model`/`metadata` are set on every call regardless of `inside_run` + so multi-turn iterations no longer lose provider/model attribution. + """ if not settings.LANGFUSE_PUBLIC_KEY: return + try: + from langfuse import get_client, propagate_attributes + + inside_run = _in_agent_run.get() + gen_metadata = _step_metadata(telemetry) if telemetry is not None else {} + gen_metadata["provider"] = str(provider) + gen_metadata["model"] = str(model) + gen_name = ( + f"{telemetry.track_name} LLM call" + if telemetry is not None and telemetry.track_name + else None + ) + + if not inside_run: + run_id = telemetry.run_id if telemetry is not None else None + trace_name = telemetry.track_name if telemetry is not None else None + trace_metadata: dict[str, str] = dict(gen_metadata) + if telemetry is None: + trace_metadata.setdefault("namespace", str(settings.NAMESPACE)) + # Empty body is intentional: propagate_attributes stamps the active + # @observe generation (this trace root, for single-shot callers) at + # __enter__; there are no child spans to scope here. Don't delete as + # dead code — the enter-time side effect is the point. + with propagate_attributes( + user_id=str(settings.NAMESPACE), + session_id=run_id, + trace_name=trace_name, + metadata=trace_metadata, + ): + pass + + get_client().update_current_generation( + name=gen_name, + model=str(model), + metadata=gen_metadata, + ) + except Exception as exc: # pragma: no cover - best-effort telemetry + logger.debug("Failed to update Langfuse trace metadata: %s", exc) + + +def _base_metadata(telemetry: LLMTelemetryContext) -> dict[str, str]: + """Static routing/attribution metadata (everything except ``iteration``). + + Rebuilt per run (cheap); callers that need ``iteration`` copy and add it. + """ + metadata: dict[str, str] = {"namespace": str(settings.NAMESPACE)} + for key, value in ( + ("workspace_name", telemetry.workspace_name), + ("call_purpose", telemetry.call_purpose), + ("agent_type", telemetry.agent_type), + ("observer", telemetry.observer), + ("observed", telemetry.observed), + ("peer_name", telemetry.peer_name), + ): + if value is not None: + metadata[key] = str(value) + return metadata + + +def _step_metadata( + telemetry: LLMTelemetryContext, + base: dict[str, str] | None = None, +) -> dict[str, str]: + """Per-step metadata: ``base`` (or freshly computed) plus ``iteration``.""" + metadata = dict(base) if base is not None else _base_metadata(telemetry) + if telemetry.iteration is not None: + metadata["iteration"] = str(telemetry.iteration) + return metadata + + +@dataclass +class LangfuseAgentRun: + """Imperative handle for the run-level Langfuse span. + + Owns an ``ExitStack`` that keeps ``start_as_current_observation`` and + ``propagate_attributes`` open until ``.end()``. This lets the run span + outlive the function that created it — streaming flows transfer the + handle to the response wrapper, which calls ``.end(output=...)`` after + the stream drains. While the handle is alive, the run span is the + current OTel observation, so step spans and auto-instrumented LLM + generations nest under it without any ContextVar choreography. + + Use ``start_langfuse_agent_run`` to construct; never instantiate directly. + """ + + span: Any # LangfuseSpan; opaque to keep src/llm/ free of langfuse imports. + _stack: ExitStack + _run_token: Any + _ended: bool = field(default=False) + + def update(self, **kwargs: Any) -> None: + """Set input/output/metadata on the run span (best-effort, no-op if ended).""" + if self._ended or self.span is None: + return + try: + self.span.update(**kwargs) + except Exception as exc: # pragma: no cover - best-effort telemetry + logger.debug("Failed to update Langfuse run span: %s", exc) + + def end(self, *, output: Any = None) -> None: + """Stamp final output (optional) and close the run span. Idempotent.""" + if self._ended: + return + self._ended = True + try: + if self.span is not None and output is not None: + self.span.update(output=output) + except Exception as exc: # pragma: no cover - best-effort telemetry + logger.debug("Failed to set Langfuse run output: %s", exc) + try: + self._stack.close() + except Exception as exc: # pragma: no cover - best-effort telemetry + logger.debug("Failed to close Langfuse run span: %s", exc) + try: + _in_agent_run.reset(self._run_token) + except (ValueError, LookupError) as exc: # pragma: no cover + # ContextVar.reset can raise if end() runs in a different async + # context than start(); telemetry must not fail user code. + logger.debug("Failed to reset _in_agent_run: %s", exc) + + +def start_langfuse_agent_run( + name: str, telemetry: LLMTelemetryContext | None +) -> LangfuseAgentRun | None: + """Open the one run-level Langfuse trace per agentic run, imperatively. + + Returns ``None`` when Langfuse is disabled or there's no ``run_id`` + (single-shot callers — those self-stamp via + ``annotate_current_langfuse_trace``). When non-None, the caller MUST + eventually call ``.end()`` — typically in a ``finally`` block, or by + transferring ownership to the streaming wrapper. + """ + if not settings.LANGFUSE_PUBLIC_KEY or telemetry is None or not telemetry.run_id: + return None + stack = ExitStack() + try: + from langfuse import get_client, propagate_attributes + + span = stack.enter_context( + get_client().start_as_current_observation(as_type="span", name=name) + ) + stack.enter_context( + propagate_attributes( + user_id=str(settings.NAMESPACE), + session_id=telemetry.run_id, + trace_name=name, + metadata=_base_metadata(telemetry), + ) + ) + except Exception as exc: # pragma: no cover - best-effort telemetry + logger.debug("Failed to open Langfuse agent run: %s", exc) + stack.close() + return None + + run_token = _in_agent_run.set(True) + return LangfuseAgentRun(span=span, _stack=stack, _run_token=run_token) + + +@dataclass +class LangfuseAgentStep: + """Imperative handle for a per-iteration step span under the run root. + + Owns an ``ExitStack`` holding ``start_as_current_observation`` open until + ``.end()``. While alive the step span is the current OTel observation, + so the LLM generation (auto-instrumented or otherwise) nests under it. + No trace attrs (the run root carries them); just the per-step + ``iteration`` metadata. + """ + + span: Any + _stack: ExitStack + _ended: bool = field(default=False) + + def update(self, **kwargs: Any) -> None: + """Set input/output/metadata on the step span (best-effort, no-op if ended).""" + if self._ended or self.span is None: + return + try: + self.span.update(**kwargs) + except Exception as exc: # pragma: no cover - best-effort telemetry + logger.debug("Failed to update Langfuse step span: %s", exc) + + def annotate_io( + self, + messages: list[dict[str, Any]], + content: Any, + tool_calls: list[dict[str, Any]], + ) -> None: + """Stamp this turn's messages-in / content-or-tool-summary-out. + + On a tool-calling turn the model returns no text yet, so we summarize + the tool calls for the step output preview; otherwise the assistant + text is used. + """ + if self._ended or self.span is None: + return + if isinstance(content, str) and content.strip(): + output: Any = content + elif tool_calls: + output = {"tool_calls": [tc.get("name") for tc in tool_calls]} + else: + output = content + self.update(input=messages, output=output) + + def end(self, *, output: Any = None) -> None: + """Stamp final output (optional) and close the step span. Idempotent.""" + if self._ended: + return + self._ended = True + try: + if self.span is not None and output is not None: + self.span.update(output=output) + except Exception as exc: # pragma: no cover - best-effort telemetry + logger.debug("Failed to set Langfuse step output: %s", exc) + try: + self._stack.close() + except Exception as exc: # pragma: no cover - best-effort telemetry + logger.debug("Failed to close Langfuse step span: %s", exc) + + +def start_langfuse_agent_step( + name: str, telemetry: LLMTelemetryContext | None +) -> LangfuseAgentStep | None: + """Open a per-iteration step span, imperatively. Returns ``None`` when + Langfuse is disabled or there's no ``run_id`` (no agent run to nest under). + """ + if not settings.LANGFUSE_PUBLIC_KEY or telemetry is None or not telemetry.run_id: + return None + stack = ExitStack() try: from langfuse import get_client - update_kwargs: dict[str, Any] = { - "metadata": { - "namespace": settings.NAMESPACE, - "provider": provider, - "model": model, - } - } - if name is not None: - update_kwargs["name"] = name - get_client().update_current_span(**update_kwargs) + span = stack.enter_context( + get_client().start_as_current_observation( + as_type="span", name=name, metadata=_step_metadata(telemetry) + ) + ) except Exception as exc: # pragma: no cover - best-effort telemetry - logger.debug("Failed to update Langfuse span metadata: %s", exc) + logger.debug("Failed to open Langfuse agent step: %s", exc) + stack.close() + return None + return LangfuseAgentStep(span=span, _stack=stack) @dataclass(frozen=True) @@ -231,6 +477,9 @@ def resolve_backend_for_plan(plan: AttemptPlan) -> Any: __all__ = [ "AttemptPlan", + "LangfuseAgentRun", + "LangfuseAgentStep", + "annotate_current_langfuse_trace", "current_attempt", "effective_config_for_call", "effective_temperature", @@ -238,5 +487,6 @@ __all__ = [ "resolve_backend_for_plan", "resolve_runtime_model_config", "select_model_config_for_attempt", - "update_current_langfuse_observation", + "start_langfuse_agent_run", + "start_langfuse_agent_step", ] diff --git a/src/llm/tool_loop.py b/src/llm/tool_loop.py index 734af8b3..34fdba26 100644 --- a/src/llm/tool_loop.py +++ b/src/llm/tool_loop.py @@ -36,6 +36,7 @@ from .runtime import ( AttemptPlan, current_attempt, effective_temperature, + start_langfuse_agent_step, ) from .types import ( HonchoLLMCallResponse, @@ -68,6 +69,17 @@ def _with_iteration_scope( return wrapper +def _step_label(base: LLMTelemetryContext | None) -> str: + """Stable per-agent step-span name, e.g. "Dialectic Agent step". + + No step number — Langfuse aggregates by name; the index rides on the + ``iteration`` metadata. The " step" suffix distinguishes it from the bare + agent name, which names the enclosing run trace (see + `start_langfuse_agent_run`). + """ + return f"{(base.track_name if base else None) or 'Agent'} step" + + def _telemetry_for_iteration( base: LLMTelemetryContext | None, iteration: int ) -> LLMTelemetryContext | None: @@ -79,17 +91,7 @@ def _telemetry_for_iteration( """ if base is None: return None - return LLMTelemetryContext( - workspace_name=base.workspace_name, - call_purpose=base.call_purpose, - parent_category=base.parent_category, - run_id=base.run_id, - iteration=iteration, - observer=base.observer, - observed=base.observed, - peer_name=base.peer_name, - agent_type=base.agent_type, - ) + return dataclasses.replace(base, iteration=iteration) def _emit_agent_iteration( @@ -221,6 +223,13 @@ async def stream_final_response( # value — telemetry can't tell the retry sequence apart. stream_attempt = 0 + # No ContextVar gymnastics around `_in_agent_run` here: the run handle + # is alive for the lifetime of the stream (owned by + # `StreamingResponseWithMetadata` and closed on drain), so this streamed + # generation correctly nests under the run span as the current OTel + # observation. The previous code had to flip `_in_agent_run` to escape + # the run; with imperative handles the run isn't going anywhere. + async def _setup_stream() -> AsyncIterator[HonchoLLMCallStreamChunk]: nonlocal stream_attempt stream_attempt += 1 @@ -292,6 +301,7 @@ async def execute_tool_loop( stream_final: bool = False, iteration_callback: IterationCallback | None = None, telemetry: LLMTelemetryContext | None = None, + langfuse_run_handle: Any | None = None, ) -> HonchoLLMCallResponse[Any] | StreamingResponseWithMetadata: """Run the iterative tool calling loop for agentic LLM interactions. @@ -337,195 +347,220 @@ async def execute_tool_loop( effective_tool_choice = tool_choice while iteration < max_tool_iterations: - # Reset attempt counter so each iteration starts with the primary provider. - current_attempt.set(1) - logger.debug(f"Tool execution iteration {iteration + 1}/{max_tool_iterations}") - - if max_input_tokens is not None: - if count_message_tokens(conversation_messages) > max_input_tokens: - hit_input_token_cap = True - conversation_messages = truncate_messages_to_fit( - conversation_messages, max_input_tokens - ) - - async def _call_with_messages( - effective_tool_choice: str | dict[str, Any] | None = effective_tool_choice, - conversation_messages: list[dict[str, Any]] = conversation_messages, - iteration_for_call: int = iteration + 1, - ) -> HonchoLLMCallResponse[Any]: - plan = get_attempt_plan() - return await honcho_llm_call_inner( - plan.provider, - plan.model, - prompt, # ignored when messages is passed - max_tokens, - response_model, - json_mode, - effective_temperature(temperature), - stop_seqs, - plan.reasoning_effort, - verbosity, - plan.thinking_budget_tokens, - stream=False, - client_override=plan.client, - tools=tools, - tool_choice=effective_tool_choice, - messages=conversation_messages, - selected_config=plan.selected_config, - plan=plan, - telemetry=_telemetry_for_iteration(telemetry, iteration_for_call), - ) - - if enable_retry: - call_func = retry( - stop=stop_after_attempt(retry_attempts), - wait=wait_exponential(multiplier=1, min=4, max=10), - before_sleep=before_retry_callback, - )(_call_with_messages) - else: - call_func = _call_with_messages - - response = await call_func() - - total_input_tokens += response.input_tokens - total_output_tokens += response.output_tokens - total_cache_creation_tokens += response.cache_creation_input_tokens - total_cache_read_tokens += response.cache_read_input_tokens - - # emit one AgentIterationEvent per LLM response BEFORE the - # no-tool early return. The terminating iteration counts too — it has - # an empty tool_calls list and is essential for cost calibration. - _emit_agent_iteration(telemetry, iteration + 1, response) - - if not response.tool_calls_made: - logger.debug("No tool calls in response, finishing") - - if ( - isinstance(response.content, str) - and not response.content.strip() - and empty_response_retries < 1 - and iteration < max_tool_iterations - 1 - ): - empty_response_retries += 1 - conversation_messages.append( - { - "role": "user", - "content": ( - "Your last response was empty. Provide a concise answer " - "to the original query using the available context." - ), - } - ) - iteration += 1 - continue - - if stream_final: - # Snapshot the plan that just succeeded — streaming retries - # pin to this exact client/model so we don't bounce back to - # primary after the tool loop settled on fallback. - winning_plan = get_attempt_plan() - stream = stream_final_response( - winning_plan=winning_plan, - prompt=prompt, - max_tokens=max_tokens, - conversation_messages=conversation_messages, - response_model=response_model, - json_mode=json_mode, - temperature=temperature, - stop_seqs=stop_seqs, - verbosity=verbosity, - enable_retry=enable_retry, - retry_attempts=retry_attempts, - before_retry_callback=before_retry_callback, - telemetry=_telemetry_for_iteration(telemetry, iteration + 1), - ) - return StreamingResponseWithMetadata( - stream=stream, - tool_calls_made=all_tool_calls, - input_tokens=total_input_tokens, - output_tokens=total_output_tokens, - cache_creation_input_tokens=total_cache_creation_tokens, - cache_read_input_tokens=total_cache_read_tokens, - thinking_content=response.thinking_content, - iterations=iteration + 1, - hit_input_token_cap=hit_input_token_cap, - ) - - response.tool_calls_made = all_tool_calls - response.input_tokens = total_input_tokens - response.output_tokens = total_output_tokens - response.cache_creation_input_tokens = total_cache_creation_tokens - response.cache_read_input_tokens = total_cache_read_tokens - response.iterations = iteration + 1 - response.hit_input_token_cap = ( - response.hit_input_token_cap or hit_input_token_cap - ) - return response - - current_provider = get_attempt_plan().provider - - assistant_message = format_assistant_tool_message( - current_provider, - response.content, - response.tool_calls_made, - response.thinking_blocks, - response.reasoning_details, + step = start_langfuse_agent_step( + _step_label(telemetry), + _telemetry_for_iteration(telemetry, iteration + 1), ) - conversation_messages.append(assistant_message) + try: + # Reset attempt counter so each iteration starts with the primary provider. + current_attempt.set(1) + logger.debug( + f"Tool execution iteration {iteration + 1}/{max_tool_iterations}" + ) - # Telemetry context — 1-indexed iteration. - set_current_iteration(iteration + 1) - - tool_results: list[dict[str, Any]] = [] - for seq, tool_call in enumerate(response.tool_calls_made): - tool_name = tool_call["name"] - tool_input = tool_call["input"] - tool_id = tool_call.get("id", "") - - logger.debug(f"Executing tool: {tool_name}") - - # the executor closure reads these from - # ContextVars to populate AgentToolCallCompletedEvent. Set BEFORE - # the executor call so two calls to the same tool in one iteration - # get distinct seq values. Reset last-tool metadata so we never - # observe stale state from a prior call. - set_current_tool_call_seq(seq, tool_id or None) - set_last_tool_metadata({}) - - try: - tool_result = await tool_executor(tool_name, tool_input) - # Stash ToolResult.metadata on all_tool_calls so - # specialist rollups can read created/deleted observation - # counts without round-tripping through the event store. - tool_result_metadata = get_last_tool_metadata() - tool_results.append( - { - "tool_id": tool_id, - "tool_name": tool_name, - "result": tool_result, - } - ) - all_tool_calls.append( - { - "tool_name": tool_name, - "tool_input": tool_input, - "tool_result": tool_result, - "tool_result_metadata": tool_result_metadata, - } - ) - except Exception as e: - logger.error(f"Tool execution failed for {tool_name}: {e}") - tool_results.append( - { - "tool_id": tool_id, - "tool_name": tool_name, - "result": f"Error: {str(e)}", - "is_error": True, - } + if max_input_tokens is not None: + if count_message_tokens(conversation_messages) > max_input_tokens: + hit_input_token_cap = True + conversation_messages = truncate_messages_to_fit( + conversation_messages, max_input_tokens ) - append_tool_results(current_provider, tool_results, conversation_messages) + async def _call_with_messages( + tool_choice_for_call: str + | dict[str, Any] + | None = effective_tool_choice, + captured_messages: list[dict[str, Any]] = conversation_messages, + iteration_for_call: int = iteration + 1, + ) -> HonchoLLMCallResponse[Any]: + plan = get_attempt_plan() + return await honcho_llm_call_inner( + plan.provider, + plan.model, + prompt, # ignored when messages is passed + max_tokens, + response_model, + json_mode, + effective_temperature(temperature), + stop_seqs, + plan.reasoning_effort, + verbosity, + plan.thinking_budget_tokens, + stream=False, + client_override=plan.client, + tools=tools, + tool_choice=tool_choice_for_call, + messages=captured_messages, + selected_config=plan.selected_config, + plan=plan, + telemetry=_telemetry_for_iteration(telemetry, iteration_for_call), + ) + call_func: Callable[[], Awaitable[HonchoLLMCallResponse[Any]]] + if enable_retry: + call_func = retry( + stop=stop_after_attempt(retry_attempts), + wait=wait_exponential(multiplier=1, min=4, max=10), + before_sleep=before_retry_callback, + )(_call_with_messages) + else: + call_func = _call_with_messages # pyright: ignore[reportGeneralTypeIssues] + + response = await call_func() + + total_input_tokens += response.input_tokens + total_output_tokens += response.output_tokens + total_cache_creation_tokens += response.cache_creation_input_tokens + total_cache_read_tokens += response.cache_read_input_tokens + + # emit one AgentIterationEvent per LLM response BEFORE the + # no-tool early return. The terminating iteration counts too — it has + # an empty tool_calls list and is essential for cost calibration. + _emit_agent_iteration(telemetry, iteration + 1, response) + + # Step span is current again (the generation closed); stamp this + # turn's I/O so it isn't blank. + if step is not None: + step.annotate_io( + conversation_messages, + response.content, + response.tool_calls_made, + ) + + if not response.tool_calls_made: + logger.debug("No tool calls in response, finishing") + + if ( + isinstance(response.content, str) + and not response.content.strip() + and empty_response_retries < 1 + and iteration < max_tool_iterations - 1 + ): + empty_response_retries += 1 + conversation_messages.append( + { + "role": "user", + "content": ( + "Your last response was empty. Provide a concise answer " + "to the original query using the available context." + ), + } + ) + iteration += 1 + continue + + if stream_final: + # Snapshot the plan that just succeeded — streaming retries + # pin to this exact client/model so we don't bounce back to + # primary after the tool loop settled on fallback. + winning_plan = get_attempt_plan() + stream = stream_final_response( + winning_plan=winning_plan, + prompt=prompt, + max_tokens=max_tokens, + conversation_messages=conversation_messages, + response_model=response_model, + json_mode=json_mode, + temperature=temperature, + stop_seqs=stop_seqs, + verbosity=verbosity, + enable_retry=enable_retry, + retry_attempts=retry_attempts, + before_retry_callback=before_retry_callback, + telemetry=_telemetry_for_iteration(telemetry, iteration + 1), + ) + return StreamingResponseWithMetadata( + stream=stream, + tool_calls_made=all_tool_calls, + input_tokens=total_input_tokens, + output_tokens=total_output_tokens, + cache_creation_input_tokens=total_cache_creation_tokens, + cache_read_input_tokens=total_cache_read_tokens, + thinking_content=response.thinking_content, + iterations=iteration + 1, + hit_input_token_cap=hit_input_token_cap, + langfuse_run_handle=langfuse_run_handle, + ) + + response.tool_calls_made = all_tool_calls + response.input_tokens = total_input_tokens + response.output_tokens = total_output_tokens + response.cache_creation_input_tokens = total_cache_creation_tokens + response.cache_read_input_tokens = total_cache_read_tokens + response.iterations = iteration + 1 + response.hit_input_token_cap = ( + response.hit_input_token_cap or hit_input_token_cap + ) + return response + + current_provider = get_attempt_plan().provider + + assistant_message = format_assistant_tool_message( + current_provider, + response.content, + response.tool_calls_made, + response.thinking_blocks, + response.reasoning_details, + ) + conversation_messages.append(assistant_message) + + # Telemetry context — 1-indexed iteration. + set_current_iteration(iteration + 1) + + tool_results: list[dict[str, Any]] = [] + for seq, tool_call in enumerate(response.tool_calls_made): + tool_name = tool_call["name"] + tool_input = tool_call["input"] + tool_id = tool_call.get("id", "") + + logger.debug(f"Executing tool: {tool_name}") + + # the executor closure reads these from + # ContextVars to populate AgentToolCallCompletedEvent. Set BEFORE + # the executor call so two calls to the same tool in one iteration + # get distinct seq values. Reset last-tool metadata so we never + # observe stale state from a prior call. + set_current_tool_call_seq(seq, tool_id or None) + set_last_tool_metadata({}) + + try: + tool_result = await tool_executor(tool_name, tool_input) + # Stash ToolResult.metadata on all_tool_calls so + # specialist rollups can read created/deleted observation + # counts without round-tripping through the event store. + tool_result_metadata = get_last_tool_metadata() + tool_results.append( + { + "tool_id": tool_id, + "tool_name": tool_name, + "result": tool_result, + } + ) + all_tool_calls.append( + { + "tool_name": tool_name, + "tool_input": tool_input, + "tool_result": tool_result, + "tool_result_metadata": tool_result_metadata, + } + ) + except Exception as e: + logger.error(f"Tool execution failed for {tool_name}: {e}") + tool_results.append( + { + "tool_id": tool_id, + "tool_name": tool_name, + "result": f"Error: {str(e)}", + "is_error": True, + } + ) + + append_tool_results(current_provider, tool_results, conversation_messages) + finally: + if step is not None: + step.end() + + # Between-turn bookkeeping lives outside the step span — the span + # scopes the LLM call + its tools, not the iteration accounting. if iteration_callback is not None: try: iteration_data = IterationData( @@ -602,6 +637,7 @@ async def execute_tool_loop( thinking_content=None, iterations=iteration + 1, hit_input_token_cap=hit_input_token_cap, + langfuse_run_handle=langfuse_run_handle, ) current_attempt.set(1) @@ -639,7 +675,18 @@ async def execute_tool_loop( else: final_call_func = _final_call - final_response = await final_call_func() + # Step span around the synthesis call — same shape as in-loop iterations + # so the generation nests under the run root instead of dangling at the + # trace. Imperative pair with a try/finally for the .end(). + synthesis_step = start_langfuse_agent_step( + _step_label(telemetry), + _telemetry_for_iteration(telemetry, synthesis_iteration), + ) + try: + final_response = await final_call_func() + finally: + if synthesis_step is not None: + synthesis_step.end() # emit the synthesis-call iteration event BEFORE merging cumulative # totals onto final_response below — otherwise the event's per-iteration diff --git a/src/llm/types.py b/src/llm/types.py index a81e6e43..8b394dd3 100644 --- a/src/llm/types.py +++ b/src/llm/types.py @@ -75,6 +75,12 @@ class LLMTelemetryContext: # agent — dialectic/deduction/induction. Used by agent iteration # event and tool call event. agent_type: str | None = None + # Human-readable name for the Langfuse trace + per-call generation + # (e.g. "Dialectic Agent", "Minimal Deriver"). Sole home for this name — + # callers set it here; `honcho_llm_call` no longer takes a separate kwarg. + # Also used to label the sentry `ai_track` decorator and as the source for + # the run-level `langfuse_agent_run` label. + track_name: str | None = None IterationCallback = Callable[[IterationData], None] @@ -134,6 +140,13 @@ class StreamingResponseWithMetadata: reflects tool-loop output + final-stream output. Callers that read `output_tokens` AFTER fully iterating the stream get the true total; callers that read it before drain see only the tool-loop portion. + + `langfuse_run_handle` (optional) is the run-level Langfuse span handle + transferred from `honcho_llm_call` when streaming. The wrapper owns it + after construction: on drain, the accumulated streamed text is stamped + as the run span's output and the span is closed. Without this transfer, + streaming traces would show blank output because the synchronous return + happens before any chunks arrive. """ _stream: AsyncIterator[HonchoLLMCallStreamChunk] @@ -145,6 +158,7 @@ class StreamingResponseWithMetadata: thinking_content: str | None iterations: int hit_input_token_cap: bool + _langfuse_run_handle: Any | None def __init__( self, @@ -157,6 +171,7 @@ class StreamingResponseWithMetadata: thinking_content: str | None = None, iterations: int = 0, hit_input_token_cap: bool = False, + langfuse_run_handle: Any | None = None, ): self._stream = stream self.tool_calls_made = tool_calls_made @@ -167,6 +182,7 @@ class StreamingResponseWithMetadata: self.thinking_content = thinking_content self.iterations = iterations self.hit_input_token_cap = hit_input_token_cap + self._langfuse_run_handle = langfuse_run_handle def __aiter__(self) -> AsyncIterator[HonchoLLMCallStreamChunk]: # Wrap the underlying iterator to capture final-stream output_tokens @@ -180,20 +196,32 @@ class StreamingResponseWithMetadata: self, ) -> AsyncIterator[HonchoLLMCallStreamChunk]: final_stream_output_tokens = 0 - async for chunk in self._stream: - if chunk.output_tokens is not None: - # Take the LATEST value, not the sum — providers report - # the cumulative usage in the final chunk, not deltas. - final_stream_output_tokens = chunk.output_tokens - yield chunk - # Stream drained — fold the final-stream output tokens into the - # tool-loop totals so DialecticCompletedEvent / downstream readers - # see the true cost. - if final_stream_output_tokens > 0: - self.output_tokens += final_stream_output_tokens - - async def __anext__(self) -> HonchoLLMCallStreamChunk: - return await self._stream.__anext__() + # Only accumulate when a Langfuse run handle is attached — for non- + # traced streams the buffer is dead weight. + accumulate = self._langfuse_run_handle is not None + accumulated_text: list[str] = [] + try: + async for chunk in self._stream: + if chunk.output_tokens is not None: + # Take the LATEST value, not the sum — providers report + # the cumulative usage in the final chunk, not deltas. + final_stream_output_tokens = chunk.output_tokens + if accumulate and chunk.content: + accumulated_text.append(chunk.content) + yield chunk + # Stream drained — fold the final-stream output tokens into the + # tool-loop totals so DialecticCompletedEvent / downstream readers + # see the true cost. + if final_stream_output_tokens > 0: + self.output_tokens += final_stream_output_tokens + finally: + # Close the run span once, stamping the streamed text as its + # output. In `finally` so an early-exit caller still closes + # the span rather than leaking it. + handle = self._langfuse_run_handle + if handle is not None: + self._langfuse_run_handle = None + handle.end(output="".join(accumulated_text) or None) __all__ = [ diff --git a/src/telemetry/__init__.py b/src/telemetry/__init__.py index abbc5df0..68e79773 100644 --- a/src/telemetry/__init__.py +++ b/src/telemetry/__init__.py @@ -52,8 +52,17 @@ async def shutdown_telemetry() -> None: This should be called during application shutdown to ensure: - CloudEvents buffer is flushed + - Langfuse's buffered observations are flushed (its background exporter and + atexit hook don't fire reliably on SIGTERM) """ from src.telemetry.events import shutdown_telemetry_events + from src.telemetry.logging import flush_langfuse - # Shutdown CloudEvents emitter (flushes buffer) - await shutdown_telemetry_events() + # Flush Langfuse even if the CloudEvents shutdown raises, so the final + # batch of spans isn't dropped on a noisy shutdown. + try: + # Shutdown CloudEvents emitter (flushes buffer) + await shutdown_telemetry_events() + finally: + # Flush any buffered Langfuse spans before the process exits. + flush_langfuse() diff --git a/src/telemetry/logging.py b/src/telemetry/logging.py index 065e1ccf..c2fcba69 100644 --- a/src/telemetry/logging.py +++ b/src/telemetry/logging.py @@ -5,9 +5,10 @@ and a conditional observe decorator that only applies when Langfuse is configure """ import datetime +import logging from collections import OrderedDict from collections.abc import Callable -from typing import ParamSpec, TypeVar, overload +from typing import Literal, ParamSpec, TypeVar, overload from fastapi import Request from langfuse import observe @@ -24,6 +25,8 @@ from src.utils.representation import ( Representation, ) +logger = logging.getLogger(__name__) + # Global console instance for consistent formatting console = Console(markup=True) @@ -32,6 +35,21 @@ COLLECT_METRICS_LOCAL = settings.COLLECT_METRICS_LOCAL P = ParamSpec("P") R = TypeVar("R") +# Langfuse observation types accepted by `@observe(as_type=...)`. Mirrors the +# literal union the SDK exposes; kept local so callers don't import langfuse +# internals just to name an observation type. +ObserveAsType = Literal[ + "generation", + "embedding", + "span", + "agent", + "tool", + "chain", + "retriever", + "evaluator", + "guardrail", +] + @overload def conditional_observe( @@ -42,7 +60,8 @@ def conditional_observe( @overload def conditional_observe( *, - name: str, + name: str | None = None, + as_type: ObserveAsType | None = None, ) -> Callable[[Callable[P, R]], Callable[P, R]]: ... @@ -50,17 +69,20 @@ def conditional_observe( func: Callable[P, R] | None = None, *, name: str | None = None, + as_type: ObserveAsType | None = None, ) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]: """ Conditionally apply the @observe decorator only when LANGFUSE_PUBLIC_KEY is present. Can be used in two ways: 1. As a decorator: @conditional_observe - 2. As a decorator factory: @conditional_observe(name="...") + 2. As a decorator factory: @conditional_observe(name="...", as_type="generation") Args: func: The function to potentially decorate (when used as @conditional_observe) name: Optional name for the observation (when used as @conditional_observe(name="...")) + as_type: Optional Langfuse observation type (e.g. "generation", "tool"). When + omitted, Langfuse infers a default span. Returns: The decorated function if Langfuse is configured, otherwise the original function @@ -69,6 +91,8 @@ def conditional_observe( def decorator(f: Callable[P, R]) -> Callable[P, R]: if settings.LANGFUSE_PUBLIC_KEY: observe_name = name if name is not None else f.__name__ + if as_type is not None: + return observe(name=observe_name, as_type=as_type)(f) return observe(name=observe_name)(f) else: return f @@ -81,6 +105,22 @@ def conditional_observe( return decorator +def flush_langfuse() -> None: + """Flush buffered Langfuse spans on shutdown. + + The SDK's background timer/atexit hook don't fire reliably on SIGTERM, so + the final batch is dropped without this. No-op when Langfuse is unconfigured. + """ + if not settings.LANGFUSE_PUBLIC_KEY: + return + try: + from langfuse import get_client + + get_client().flush() + except Exception: + logger.debug("Failed to flush Langfuse on shutdown", exc_info=True) + + # Bounded OrderedDict for accumulated metrics to prevent memory leaks. # If an exception occurs between accumulate_metric() and log_performance_metrics(), # the metrics would stay in memory forever. Using OrderedDict allows us to evict diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index dae38577..261817f5 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -2403,6 +2403,10 @@ async def create_tool_executor( metadata: dict[str, Any] = {} is_error: bool = False + # Langfuse tool observation; auto-parents under the active step span. + # Closed in the finally below with output + level. + tool_obs = _begin_tool_observation(tool_name, tool_input) + try: handler = _TOOL_HANDLERS.get(tool_name) if handler: @@ -2481,11 +2485,44 @@ async def create_tool_executor( provider_tool_call_id=get_current_provider_tool_call_id(), ) + _finish_tool_observation(tool_obs, result_str, is_error) + return result_str return execute_tool +def _begin_tool_observation(tool_name: str, tool_input: dict[str, Any]) -> Any: + """Open a non-current Langfuse "tool" observation for one tool execution. + + Auto-parents under the active step span (else standalone). Returns a handle + (closed by `_finish_tool_observation`) or None when disabled/setup fails. + All tools are ``as_type="tool"`` — they share one generic dispatcher. + """ + if not settings.LANGFUSE_PUBLIC_KEY: + return None + try: + from langfuse import get_client + + return get_client().start_observation( + as_type="tool", name=tool_name, input=tool_input + ) + except Exception: # pragma: no cover - best-effort telemetry + logger.debug("Failed to open Langfuse tool observation", exc_info=True) + return None + + +def _finish_tool_observation(tool_obs: Any, result_str: str, is_error: bool) -> None: + """Close a Langfuse tool observation opened by `_begin_tool_observation`.""" + if tool_obs is None: + return + try: + tool_obs.update(output=result_str, level="ERROR" if is_error else None) + tool_obs.end() + except Exception: # pragma: no cover - best-effort telemetry + logger.debug("Failed to close Langfuse tool observation", exc_info=True) + + def _emit_agent_tool_call_completed( *, ctx: "ToolContext", diff --git a/tests/conftest.py b/tests/conftest.py index 301d91d2..af35c999 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,10 +34,17 @@ from src.config import settings from src.db import Base from src.dependencies import get_db, get_read_db from src.exceptions import HonchoException -from src.main import app from src.models import Peer, Workspace from src.security import JWTParams, create_admin_jwt, create_jwt +# Disable Langfuse for the whole suite before importing src.main: @conditional_observe +# binds to settings.LANGFUSE_PUBLIC_KEY at import time, so blanking it here keeps mocked +# test calls from emitting traces to a configured Langfuse backend. Tests that exercise +# Langfuse patch settings.LANGFUSE_PUBLIC_KEY themselves. +settings.LANGFUSE_PUBLIC_KEY = None + +from src.main import app # noqa: E402 + # Create a custom handler that doesn't get closed prematurely class TestHandler(logging.Handler): diff --git a/tests/llm/test_langfuse_trace_annotation.py b/tests/llm/test_langfuse_trace_annotation.py new file mode 100644 index 00000000..e94e5acb --- /dev/null +++ b/tests/llm/test_langfuse_trace_annotation.py @@ -0,0 +1,499 @@ +# pyright: reportPrivateUsage=false, reportUnusedParameter=false +"""Tests for the Langfuse session/trace wiring in `src/llm/runtime.py`. + +One agentic run = one trace; `session_id = run_id` (globally unique, so it's +conflict-free across tenants — unlike the Honcho session name). The run handle +(`start_langfuse_agent_run`) opens an `as_type="span"` root and keeps it +current via an ``ExitStack`` until `.end()`. Step spans + nested generations +nest under the run while it's open. A single-call agent (deriver, summarizer) +gets no run handle and self-stamps its lone generation as the trace root. +Disabled (no LANGFUSE_PUBLIC_KEY) → no Langfuse calls at all. +""" + +from __future__ import annotations + +import contextlib +from typing import Any + +import pytest + +from src.config import settings +from src.llm import runtime +from src.llm.types import LLMTelemetryContext + + +@pytest.fixture +def capture_propagate(monkeypatch: pytest.MonkeyPatch): + """Stub `langfuse.propagate_attributes`, capturing the kwargs it's called with. + + Returns a dict that's empty until propagate_attributes is invoked. + """ + captured: dict[str, Any] = {} + + @contextlib.contextmanager + def fake_propagate(**kwargs: Any): + captured.clear() + captured.update(kwargs) + yield + + import langfuse + + monkeypatch.setattr(langfuse, "propagate_attributes", fake_propagate) + return captured + + +@pytest.fixture +def langfuse_client(monkeypatch: pytest.MonkeyPatch): + """Stub `langfuse.get_client()`, capturing observation/generation/span calls. + + Returns ``{"observation", "generation", "span", "run_span"}`` — each sub-dict + stays empty until the corresponding call is made: + - ``observation``: the span opened via `start_as_current_observation` + (run root or step span). + - ``generation``: the rename via `update_current_generation`. + - ``span``: the step I/O via `update_current_span`. + - ``run_span``: I/O written onto the span object handed back by + `start_as_current_observation` (merged, mirroring Langfuse's update + semantics). + """ + captured: dict[str, dict[str, Any]] = { + "observation": {}, + "generation": {}, + "span": {}, + "run_span": {}, + } + + class FakeSpan: + def update(self, **kwargs: Any) -> None: + # Merge (don't clear): Langfuse's span.update accumulates, so input + # set at run start and output set at run end coexist. + captured["run_span"].update(kwargs) + + @contextlib.contextmanager + def fake_observation(**kwargs: Any): + captured["observation"].clear() + captured["observation"].update(kwargs) + yield FakeSpan() + + class FakeClient: + def start_as_current_observation(self, **kwargs: Any): + return fake_observation(**kwargs) + + def update_current_generation(self, **kwargs: Any) -> None: + captured["generation"].clear() + captured["generation"].update(kwargs) + + def update_current_span(self, **kwargs: Any) -> None: + captured["span"].clear() + captured["span"].update(kwargs) + + import langfuse + + monkeypatch.setattr(langfuse, "get_client", lambda: FakeClient()) + return captured + + +@pytest.fixture +def langfuse_enabled(monkeypatch: pytest.MonkeyPatch): + """Turn the integration on with a known NAMESPACE (the tenant / user_id).""" + monkeypatch.setattr(settings, "LANGFUSE_PUBLIC_KEY", "pk-test") + monkeypatch.setattr(settings, "NAMESPACE", "acme-tenant") + + +@contextlib.contextmanager +def _inside_agent_run(): + """Set the `_in_agent_run` ContextVar for the body, resetting it after. + + Simulates execution nested inside a run handle without opening one (which + would itself call propagate_attributes and pollute the capture). + """ + token = runtime._in_agent_run.set(True) + try: + yield + finally: + runtime._in_agent_run.reset(token) + + +class TestAnnotateDisabled: + def test_noop_when_key_unset( + self, monkeypatch: pytest.MonkeyPatch, capture_propagate: dict[str, Any] + ): + monkeypatch.setattr(settings, "LANGFUSE_PUBLIC_KEY", None) + + runtime.annotate_current_langfuse_trace( + "anthropic", + "claude-x", + telemetry=LLMTelemetryContext(run_id="run-abc"), + ) + + assert capture_propagate == {} + + +class TestAnnotateInsideRun: + """A generation nested inside an active run handle: the run owns the trace + attrs, so this call must NOT propagate. It still stamps model + per-step + metadata + name on the generation (the multi-turn regression fix — + provider/model used to be dropped on every iteration after the first).""" + + def test_nested_generation_does_not_propagate( + self, + langfuse_enabled: None, + capture_propagate: dict[str, Any], + langfuse_client: dict[str, dict[str, Any]], + ): + telemetry = LLMTelemetryContext( + workspace_name="ws1", + call_purpose="dialectic.answer", + agent_type="dialectic", + run_id="run-abc", + iteration=2, + peer_name="alice", + track_name="Dialectic Agent", + ) + + with _inside_agent_run(): + runtime.annotate_current_langfuse_trace( + "anthropic", "claude-x", telemetry=telemetry + ) + + # Run handle owns user_id/session_id/trace_name — re-propagating here + # would clobber the run's session, so we don't propagate at all. + assert capture_propagate == {} + # Per-call generation: name + model + step metadata stamped every + # iteration (formerly only name was stamped, dropping provider/model). + gen = langfuse_client["generation"] + assert gen["name"] == "Dialectic Agent LLM call" + assert gen["model"] == "claude-x" + assert gen["metadata"]["provider"] == "anthropic" + assert gen["metadata"]["model"] == "claude-x" + assert gen["metadata"]["iteration"] == "2" + assert gen["metadata"]["agent_type"] == "dialectic" + + +class TestAnnotateOwnTraceRoot: + """A generation that IS its own trace root stamps the trace attributes: + single calls (no run_id → no session) and the no-telemetry case.""" + + def test_single_call_has_no_session_and_names_trace( + self, + langfuse_enabled: None, + capture_propagate: dict[str, Any], + langfuse_client: dict[str, dict[str, Any]], + ): + telemetry = LLMTelemetryContext( + workspace_name="ws1", + call_purpose="deriver.representation", + observed="bob", + track_name="Minimal Deriver", + ) + + runtime.annotate_current_langfuse_trace( + "gemini", "gemini-x", telemetry=telemetry + ) + + assert capture_propagate["session_id"] is None + assert capture_propagate["user_id"] == "acme-tenant" + # Single-call: this generation IS the trace root, so it names the trace. + assert capture_propagate["trace_name"] == "Minimal Deriver" + assert capture_propagate["metadata"]["observed"] == "bob" + # The generation observation is still named per agent+action. + assert langfuse_client["generation"]["name"] == "Minimal Deriver LLM call" + + def test_no_telemetry_still_stamps_user_id( + self, + langfuse_enabled: None, + capture_propagate: dict[str, Any], + langfuse_client: dict[str, dict[str, Any]], + ): + runtime.annotate_current_langfuse_trace("openai", "gpt-x", telemetry=None) + + assert capture_propagate["user_id"] == "acme-tenant" + assert capture_propagate["session_id"] is None + assert capture_propagate["trace_name"] is None + assert capture_propagate["metadata"]["provider"] == "openai" + # No telemetry → no per-agent generation name, but provider/model still set. + gen = langfuse_client["generation"] + assert gen["name"] is None + assert gen["model"] == "gpt-x" + + +class TestAgentRun: + """`start_langfuse_agent_run` returns an imperative handle: opens an + ``as_type="span"`` root, stamps ``session_id = run_id`` via + ``propagate_attributes``, and keeps the span open until ``.end()``. + Only fires for multi-turn runs (run_id present).""" + + def test_noop_when_disabled( + self, + monkeypatch: pytest.MonkeyPatch, + langfuse_client: dict[str, dict[str, Any]], + capture_propagate: dict[str, Any], + ): + monkeypatch.setattr(settings, "LANGFUSE_PUBLIC_KEY", None) + + handle = runtime.start_langfuse_agent_run( + "Dialectic Agent", LLMTelemetryContext(run_id="r1") + ) + + assert handle is None + assert langfuse_client["observation"] == {} + assert capture_propagate == {} + + def test_noop_without_run_id( + self, + langfuse_enabled: None, + langfuse_client: dict[str, dict[str, Any]], + capture_propagate: dict[str, Any], + ): + # Single-call agents (deriver/summarizer) have no run_id → no run root, + # so their LLM calls stay standalone, sessionless traces. + handle = runtime.start_langfuse_agent_run( + "Minimal Deriver", LLMTelemetryContext(workspace_name="ws1") + ) + + assert handle is None + assert langfuse_client["observation"] == {} + assert capture_propagate == {} + + def test_noop_without_telemetry( + self, + langfuse_enabled: None, + langfuse_client: dict[str, dict[str, Any]], + ): + handle = runtime.start_langfuse_agent_run("anything", None) + assert handle is None + assert langfuse_client["observation"] == {} + + def test_opens_run_root_and_owns_trace_attrs( + self, + langfuse_enabled: None, + langfuse_client: dict[str, dict[str, Any]], + capture_propagate: dict[str, Any], + ): + tele = LLMTelemetryContext( + workspace_name="ws1", + call_purpose="dialectic.answer", + agent_type="dialectic", + run_id="run-abc", + observed="bob", + track_name="Dialectic Agent", + ) + + handle = runtime.start_langfuse_agent_run("Dialectic Agent", tele) + assert handle is not None + try: + # The run IS the trace root: an as_type="span" observation whose + # name is STABLE (no step number) so Langfuse aggregates by name. + observation = langfuse_client["observation"] + assert observation["as_type"] == "span" + assert observation["name"] == "Dialectic Agent" + # Trace grouping: one Langfuse session per run, drillable per tenant. + assert capture_propagate["session_id"] == "run-abc" + assert capture_propagate["user_id"] == "acme-tenant" + assert capture_propagate["trace_name"] == "Dialectic Agent" + md = capture_propagate["metadata"] + assert md["workspace_name"] == "ws1" + assert md["agent_type"] == "dialectic" + assert md["observed"] == "bob" + # Honcho's Session is deliberately NOT the grouping key. + assert "honcho_session_id" not in md + finally: + handle.end() + + def test_marks_in_agent_run_for_nested_calls( + self, + langfuse_enabled: None, + langfuse_client: dict[str, dict[str, Any]], + capture_propagate: dict[str, Any], + ): + # While the run handle is live, nested generations see _in_agent_run + # set so they stay silent (the run owns the trace attrs); end() resets it. + assert runtime._in_agent_run.get() is False + handle = runtime.start_langfuse_agent_run( + "Dialectic Agent", + LLMTelemetryContext(run_id="r1", track_name="Dialectic Agent"), + ) + assert handle is not None + assert runtime._in_agent_run.get() is True + handle.end() + assert runtime._in_agent_run.get() is False + + def test_end_is_idempotent( + self, + langfuse_enabled: None, + langfuse_client: dict[str, dict[str, Any]], + capture_propagate: dict[str, Any], + ): + # The streaming wrapper may call .end() after the api.py finally already + # called it (or vice-versa); the handle has to tolerate that. + handle = runtime.start_langfuse_agent_run( + "Dialectic Agent", LLMTelemetryContext(run_id="r1") + ) + assert handle is not None + handle.end() + handle.end() # must not raise + + +class TestAgentRunIO: + """The run handle exposes `.update(input=..., output=...)` for stamping + the run-root span — the trace's input/output preview in the Langfuse UI. + A second call merges into the first (Langfuse's update semantics).""" + + def test_sets_input_then_output_on_handle( + self, + langfuse_enabled: None, + langfuse_client: dict[str, dict[str, Any]], + capture_propagate: dict[str, Any], + ): + messages = [{"role": "user", "content": "How many coffees?"}] + handle = runtime.start_langfuse_agent_run( + "Dialectic Agent", LLMTelemetryContext(run_id="run-abc") + ) + assert handle is not None + try: + handle.update(input=messages) + finally: + handle.end(output="You bought 4 coffees.") + + assert langfuse_client["run_span"]["input"] == messages + assert langfuse_client["run_span"]["output"] == "You bought 4 coffees." + + def test_end_without_output_leaves_output_unset( + self, + langfuse_enabled: None, + langfuse_client: dict[str, dict[str, Any]], + capture_propagate: dict[str, Any], + ): + handle = runtime.start_langfuse_agent_run( + "Dialectic Agent", LLMTelemetryContext(run_id="run-abc") + ) + assert handle is not None + handle.update(input=[{"role": "user"}]) + handle.end() + + assert "input" in langfuse_client["run_span"] + # Only input was passed → output is not written (so it isn't blanked). + assert "output" not in langfuse_client["run_span"] + + +class TestAgentStep: + """`start_langfuse_agent_step` opens a per-iteration child span under the + run root (one reasoning turn). Unlike the run handle, it does NOT touch + trace attributes.""" + + def test_noop_when_disabled( + self, + monkeypatch: pytest.MonkeyPatch, + langfuse_client: dict[str, dict[str, Any]], + capture_propagate: dict[str, Any], + ): + monkeypatch.setattr(settings, "LANGFUSE_PUBLIC_KEY", None) + + step = runtime.start_langfuse_agent_step( + "Dialectic Agent step", LLMTelemetryContext(run_id="r1") + ) + + assert step is None + assert langfuse_client["observation"] == {} + assert capture_propagate == {} + + def test_noop_without_run_id( + self, + langfuse_enabled: None, + langfuse_client: dict[str, dict[str, Any]], + capture_propagate: dict[str, Any], + ): + step = runtime.start_langfuse_agent_step( + "Minimal Deriver step", LLMTelemetryContext(workspace_name="ws1") + ) + + assert step is None + assert langfuse_client["observation"] == {} + assert capture_propagate == {} + + def test_noop_without_telemetry( + self, + langfuse_enabled: None, + langfuse_client: dict[str, dict[str, Any]], + ): + step = runtime.start_langfuse_agent_step("anything", None) + assert step is None + assert langfuse_client["observation"] == {} + + def test_opens_child_span_without_propagating( + self, + langfuse_enabled: None, + langfuse_client: dict[str, dict[str, Any]], + capture_propagate: dict[str, Any], + ): + tele = LLMTelemetryContext( + workspace_name="ws1", + agent_type="dialectic", + run_id="run-abc", + iteration=2, + observed="bob", + track_name="Dialectic Agent", + ) + + step = runtime.start_langfuse_agent_step("Dialectic Agent step", tele) + assert step is not None + try: + observation = langfuse_client["observation"] + assert observation["as_type"] == "span" + assert observation["name"] == "Dialectic Agent step" + # The per-step index rides on the span's metadata (str-coerced). + assert observation["metadata"]["iteration"] == "2" + assert observation["metadata"]["observed"] == "bob" + # The run root owns the trace attrs — the step must NOT propagate. + assert capture_propagate == {} + finally: + step.end() + + +class TestStepIO: + """`step.annotate_io` stamps this turn's I/O on the step span — without it, + only the nested generation would carry I/O and the step would show blank.""" + + def test_text_answer_sets_input_and_output( + self, + langfuse_enabled: None, + langfuse_client: dict[str, dict[str, Any]], + ): + messages = [{"role": "user", "content": "What is the user's name?"}] + step = runtime.start_langfuse_agent_step( + "Dialectic Agent step", LLMTelemetryContext(run_id="run-abc") + ) + assert step is not None + try: + step.annotate_io(messages, "The user's name is Jordan.", []) + finally: + step.end() + + # The step span's input/output is stamped via the handle's underlying + # span.update() — captured on the run_span fixture key. + assert langfuse_client["run_span"]["input"] == messages + assert langfuse_client["run_span"]["output"] == "The user's name is Jordan." + + def test_tool_calling_turn_summarizes_tools_as_output( + self, + langfuse_enabled: None, + langfuse_client: dict[str, dict[str, Any]], + ): + # A tool-calling turn has no text yet — the step's "output" is the set + # of tools it chose, by name (per-tool I/O lives on the tool children). + step = runtime.start_langfuse_agent_step( + "Dialectic Agent step", LLMTelemetryContext(run_id="run-abc") + ) + assert step is not None + try: + step.annotate_io( + [{"role": "user", "content": "how many coffees?"}], + "", + [{"name": "grep_messages"}, {"name": "search_memory"}], + ) + finally: + step.end() + + assert langfuse_client["run_span"]["output"] == { + "tool_calls": ["grep_messages", "search_memory"] + } diff --git a/tests/llm/test_telemetry_llm_call.py b/tests/llm/test_telemetry_llm_call.py index 817aa41e..ef3cc21c 100644 --- a/tests/llm/test_telemetry_llm_call.py +++ b/tests/llm/test_telemetry_llm_call.py @@ -13,7 +13,7 @@ Targets: from __future__ import annotations -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, patch import pytest @@ -684,3 +684,84 @@ class TestStreamingResponseTokenWriteBack: # And we yielded every chunk to the caller — the wrapper is a # passthrough, not a sink. assert len(chunks) == 3 + + +class TestStreamingResponseRunHandleClose: + """When a `langfuse_run_handle` is transferred to the streaming wrapper, + the wrapper owns it: the accumulated streamed text is stamped as the run + span's output and the span is closed exactly once when the stream drains. + The close lives in a `finally`, so an early-exit caller still closes the + span rather than leaking it. + """ + + class _FakeRunHandle: + def __init__(self) -> None: + self.end_calls: list[Any] = [] + + def end(self, *, output: Any = None) -> None: + self.end_calls.append(output) + + @staticmethod + async def _fake_stream() -> Any: + from src.llm.types import HonchoLLMCallStreamChunk + + yield HonchoLLMCallStreamChunk(content="hel", output_tokens=None) + yield HonchoLLMCallStreamChunk(content="lo", output_tokens=None) + yield HonchoLLMCallStreamChunk(content="", is_done=True, output_tokens=7) + + @pytest.mark.asyncio + async def test_full_drain_stamps_output_and_closes_once(self): + from src.llm.types import StreamingResponseWithMetadata + + handle = self._FakeRunHandle() + wrapper = StreamingResponseWithMetadata( + stream=self._fake_stream(), + tool_calls_made=[], + input_tokens=0, + output_tokens=0, + cache_creation_input_tokens=0, + cache_read_input_tokens=0, + langfuse_run_handle=handle, + ) + + async for _ in wrapper: + pass + + # Closed exactly once, with the concatenated streamed text as output. + assert handle.end_calls == ["hello"] + # Ownership released so a second drain can't double-close. + assert wrapper._langfuse_run_handle is None + + @pytest.mark.asyncio + async def test_abandoned_stream_still_closes_via_finally(self): + from src.llm.types import StreamingResponseWithMetadata + + handle = self._FakeRunHandle() + wrapper = StreamingResponseWithMetadata( + stream=self._fake_stream(), + tool_calls_made=[], + input_tokens=0, + output_tokens=0, + cache_creation_input_tokens=0, + cache_read_input_tokens=0, + langfuse_run_handle=handle, + ) + + # Consume one chunk, then abandon the stream. `aclose()` is what the + # runtime/GC drives when a caller stops iterating early; it throws + # GeneratorExit at the suspended `yield`, firing the `finally`. + from collections.abc import AsyncGenerator + + from src.llm.types import HonchoLLMCallStreamChunk + + agen = cast( + "AsyncGenerator[HonchoLLMCallStreamChunk, None]", wrapper.__aiter__() + ) + first = await agen.__anext__() + assert first.content == "hel" + await agen.aclose() + + # Span closed once with only the text accumulated before abandonment — + # the span is closed, not leaked. + assert handle.end_calls == ["hel"] + assert wrapper._langfuse_run_handle is None diff --git a/tests/unified/test_cases/dialectic_tool_calls.json b/tests/unified/test_cases/dialectic_tool_calls.json new file mode 100644 index 00000000..0ae3b923 --- /dev/null +++ b/tests/unified/test_cases/dialectic_tool_calls.json @@ -0,0 +1,103 @@ +{ + "description": "Force the dialectic agent to actually invoke tools (grep/search) by asking an enumeration+aggregation question that the prefetched first turn cannot answer in one shot. Used to verify Langfuse tool-call observations nest under the step span.", + "workspace_config": {}, + "steps": [ + { + "step_type": "create_session", + "session_id": "tool_calls_test", + "config": { + "reasoning": { + "enabled": false + } + }, + "peer_configs": { + "user": { + "observe_me": true, + "observe_others": false + }, + "assistant": { + "observe_me": false, + "observe_others": true + } + } + }, + { + "step_type": "add_messages", + "session_id": "tool_calls_test", + "messages": [ + { + "peer_id": "user", + "content": "Monday I grabbed a $5 latte at Starbucks before my standup.", + "created_at": "2024-03-04T08:30:00" + }, + { + "peer_id": "assistant", + "content": "Nice, a classic way to start the week.", + "created_at": "2024-03-04T08:31:00" + }, + { + "peer_id": "user", + "content": "Tuesday I tried a $4 cold brew from Blue Bottle, really smooth.", + "created_at": "2024-03-05T09:15:00" + }, + { + "peer_id": "assistant", + "content": "Blue Bottle makes a solid cold brew.", + "created_at": "2024-03-05T09:16:00" + }, + { + "peer_id": "user", + "content": "Wednesday was a $6 oat-milk mocha at a little place downtown.", + "created_at": "2024-03-06T08:45:00" + }, + { + "peer_id": "assistant", + "content": "Oat milk mochas are underrated.", + "created_at": "2024-03-06T08:46:00" + }, + { + "peer_id": "user", + "content": "Thursday I skipped coffee and just had tea at home.", + "created_at": "2024-03-07T08:20:00" + }, + { + "peer_id": "assistant", + "content": "A calm morning, sounds good.", + "created_at": "2024-03-07T08:21:00" + }, + { + "peer_id": "user", + "content": "Friday I splurged on a $7 pour-over at the roastery near the office.", + "created_at": "2024-03-08T08:50:00" + }, + { + "peer_id": "assistant", + "content": "Ending the week strong!", + "created_at": "2024-03-08T08:51:00" + } + ] + }, + { + "step_type": "wait", + "target": "queue_empty", + "timeout": 180, + "flush": true + }, + { + "step_type": "query", + "description": "Global query (no session history) + empty prefetch (reasoning off) forces grep/search tool calls", + "target": "chat", + "observer_peer_id": "assistant", + "observed_peer_id": "user", + "reasoning_level": "max", + "input": "How many separate coffees did I buy this week, and exactly how much did I spend in total across all of them?", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does the response state that the user bought 4 coffees totaling $22 (or correctly enumerate the $5, $4, $6, and $7 purchases)?", + "pass_if": true + } + ] + } + ] +} diff --git a/tests/utils/test_clients.py b/tests/utils/test_clients.py index f5506790..1567eef8 100644 --- a/tests/utils/test_clients.py +++ b/tests/utils/test_clients.py @@ -9,6 +9,7 @@ Tests cover: - Provider-specific features """ +import contextlib from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -37,6 +38,7 @@ from src.llm import ( honcho_llm_call, honcho_llm_call_inner, ) +from src.llm.types import LLMTelemetryContext class SampleTestModel(BaseModel): @@ -910,8 +912,10 @@ class TestMainLLMCallFunction: assert response.content == "No retry response" - async def test_track_name_updates_langfuse_span_name(self): - """track_name should rename the top-level Langfuse span.""" + async def test_track_name_on_telemetry_names_langfuse_trace_and_generation(self): + """track_name on telemetry should name the Langfuse trace + generation + per agent and stamp provider/model metadata (via propagate_attributes + + update_current_generation; see annotate_current_langfuse_trace).""" mock_llm_client = AsyncMock(spec=AsyncAnthropic) mock_response = Mock() @@ -921,11 +925,18 @@ class TestMainLLMCallFunction: mock_llm_client.messages.create = AsyncMock(return_value=mock_response) mock_langfuse_client = Mock() + captured: dict[str, Any] = {} + + @contextlib.contextmanager + def fake_propagate(**kwargs: Any): + captured.update(kwargs) + yield with ( patch.dict(CLIENTS, {"anthropic": mock_llm_client}), patch.object(settings, "LANGFUSE_PUBLIC_KEY", "test-public-key"), patch("langfuse.get_client", return_value=mock_langfuse_client), + patch("langfuse.propagate_attributes", fake_propagate), ): response = await honcho_llm_call( model_config=ConfiguredModelSettings( @@ -935,19 +946,75 @@ class TestMainLLMCallFunction: prompt="Hello", max_tokens=100, enable_retry=False, - track_name="Dialectic Agent", + telemetry=LLMTelemetryContext( + workspace_name="ws1", track_name="Dialectic Agent" + ), ) assert response.content == "Named response" - mock_langfuse_client.update_current_span.assert_called_once_with( - name="Dialectic Agent", - metadata={ - "namespace": settings.NAMESPACE, - "provider": "anthropic", - "model": "claude-4-sonnet", - }, + # No run_id → this single call IS the trace root: it names the trace + # and stamps metadata via propagate_attributes... + assert captured["trace_name"] == "Dialectic Agent" + assert captured["session_id"] is None + assert captured["metadata"]["namespace"] == settings.NAMESPACE + assert captured["metadata"]["provider"] == "anthropic" + assert captured["metadata"]["model"] == "claude-4-sonnet" + # ...and the generation is named + carries per-call model/metadata. + mock_langfuse_client.update_current_generation.assert_called_once() + gen_kwargs = mock_langfuse_client.update_current_generation.call_args.kwargs + assert gen_kwargs["name"] == "Dialectic Agent LLM call" + assert gen_kwargs["model"] == "claude-4-sonnet" + assert gen_kwargs["metadata"]["provider"] == "anthropic" + + async def test_no_telemetry_still_stamps_trace_without_name(self): + """Without telemetry, propagate_attributes still fires with namespace + metadata, but the trace stays unnamed — track_name lives exclusively + on telemetry now. The per-call generation still gets model/metadata + stamped (the multi-turn-regression fix means we always stamp these).""" + + mock_llm_client = AsyncMock(spec=AsyncAnthropic) + mock_response = Mock() + mock_response.content = [TextBlock(text="Unnamed response", type="text")] + mock_response.usage = Usage(input_tokens=5, output_tokens=5) + mock_response.stop_reason = "stop" + mock_llm_client.messages.create = AsyncMock(return_value=mock_response) + + mock_langfuse_client = Mock() + captured: dict[str, Any] = {} + + @contextlib.contextmanager + def fake_propagate(**kwargs: Any): + captured.update(kwargs) + yield + + with ( + patch.dict(CLIENTS, {"anthropic": mock_llm_client}), + patch.object(settings, "LANGFUSE_PUBLIC_KEY", "test-public-key"), + patch("langfuse.get_client", return_value=mock_langfuse_client), + patch("langfuse.propagate_attributes", fake_propagate), + ): + response = await honcho_llm_call( + model_config=ConfiguredModelSettings( + model="claude-4-sonnet", + transport="anthropic", + ), + prompt="Hello", + max_tokens=100, + enable_retry=False, ) + assert response.content == "Unnamed response" + assert captured["user_id"] == str(settings.NAMESPACE) + assert captured["trace_name"] is None + assert captured["metadata"]["namespace"] == settings.NAMESPACE + assert captured["metadata"]["provider"] == "anthropic" + # Generation gets model + metadata even without a track_name — only + # the name kwarg stays None. + mock_langfuse_client.update_current_generation.assert_called_once() + gen_kwargs = mock_langfuse_client.update_current_generation.call_args.kwargs + assert gen_kwargs["name"] is None + assert gen_kwargs["model"] == "claude-4-sonnet" + class TestEdgeCases: """Tests for edge cases and boundary conditions""" From a0cc938f4ac1a95922347e197dd0c21b283406be Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Tue, 23 Jun 2026 10:42:03 -0400 Subject: [PATCH 21/65] feat: add model config option for json_object mode (#820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add model config option for json_object mode * fix: catch possible validation error from structured output * fix(llm): harden structured_output_mode json_object path Follow-up fixes to the json_object structured-output mode for OpenAI-compatible providers without Structured Outputs support: - runtime: carry structured_output_mode onto the per-attempt fallback config (select_model_config_for_attempt dropped it, silently sending json_schema to a provider that can't parse it) - backend: return a graceful empty on a contentless json_object response instead of raising, matching the json_schema path, and preserve token usage by normalizing the response - backend: narrow the parse-failure catch to BadRequestError only, so transient JSONDecodeError/ValidationError propagate to retry/fallback instead of being swallowed to empty on the first attempt - config: reject structured_output_mode on non-openai transports (silent no-op otherwise); trim docs to the deriver, the only structured-output feature - backend: validate clean JSON before repair, cache the schema instruction, and share json_object setup between complete()/stream() Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(llm): consolidate structured-output repair, drop dead seam Fold the OpenAI backend's three structured-output repair sites (LengthFinishReasonError, parsed=None, json_object) into the one shared _parse_or_repair_structured_content helper, gated by an empty_on_missing flag: json_object returns a graceful empty on a contentless response so a loose provider can't crash the call, while json_schema raises so the retry/fallback chain engages. Delete the dead execute_structured_output_call seam and its only collaborators (attempt_structured_output_repair, StructuredOutputFailurePolicy) — it was never called and its single-shot validate/repair/empty model conflicts with the retry behavior in honcho_llm_call. No behavior change. Adds tests covering the json_schema parse fallbacks (repair, refusal passthrough, no-content raise). --------- Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- .env.template | 1 + docs/v3/contributing/configuration.mdx | 7 + docs/v3/contributing/troubleshooting.mdx | 14 +- src/config.py | 31 ++ src/llm/backends/openai.py | 208 ++++++++---- src/llm/request_builder.py | 2 + src/llm/runtime.py | 1 + src/llm/structured_output.py | 57 ---- tests/live_llm/model_matrix.py | 12 + tests/live_llm/test_live_openai.py | 56 ++++ tests/llm/test_backends/test_openai.py | 390 +++++++++++++++++++++++ tests/llm/test_model_config.py | 56 ++++ 12 files changed, 720 insertions(+), 115 deletions(-) diff --git a/.env.template b/.env.template index 3e378cda..84d8ba84 100644 --- a/.env.template +++ b/.env.template @@ -123,6 +123,7 @@ LLM_OPENAI_API_KEY=your-api-key-here # DERIVER_MODEL_CONFIG__TEMPERATURE= # DERIVER_MODEL_CONFIG__THINKING_EFFORT=minimal # DERIVER_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024 # Gemini/Anthropic only +# DERIVER_MODEL_CONFIG__STRUCTURED_OUTPUT_MODE=json_object # for providers without json_schema support # DERIVER_DEDUPLICATE=true # DERIVER_MODEL_CONFIG__MAX_OUTPUT_TOKENS=4096 # DERIVER_LOG_OBSERVATIONS=false diff --git a/docs/v3/contributing/configuration.mdx b/docs/v3/contributing/configuration.mdx index cd74a829..e770e96b 100644 --- a/docs/v3/contributing/configuration.mdx +++ b/docs/v3/contributing/configuration.mdx @@ -60,6 +60,12 @@ You can mix providers freely — for example, use Gemini for the deriver and Cla For OpenAI-compatible proxies (OpenRouter, vLLM, Ollama, etc.), use `transport = "openai"` and set `MODEL_CONFIG__OVERRIDES__BASE_URL` on each feature to point at your endpoint. + +Some OpenAI-compatible providers don't support OpenAI Structured Outputs (`json_schema`). Set `DERIVER_MODEL_CONFIG__STRUCTURED_OUTPUT_MODE=json_object` to request loose JSON mode and inject the schema into the prompt instead. + +This setting only applies to the **deriver** on the **`openai`** transport — it is the only feature that uses structured output. The dialectic, summarizer, and dreamer don't request structured output, so the setting has no effect there, and the anthropic/gemini transports reject it. + + ### Tiered Model Setup Once you're past initial setup, you can assign different models per feature for better cost/quality tradeoffs. This example uses OpenRouter with light/medium/heavy tiers: @@ -373,6 +379,7 @@ DERIVER_MAX_CUSTOM_INSTRUCTIONS_TOKENS=2000 # DERIVER_MODEL_CONFIG__THINKING_EFFORT=minimal # DERIVER_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024 # DERIVER_MODEL_CONFIG__TEMPERATURE=0.7 # Optional temperature override +# DERIVER_MODEL_CONFIG__STRUCTURED_OUTPUT_MODE=json_object # for providers without json_schema support # Backup model (optional) # DERIVER_MODEL_CONFIG__FALLBACK__MODEL=claude-haiku-4-5 diff --git a/docs/v3/contributing/troubleshooting.mdx b/docs/v3/contributing/troubleshooting.mdx index 7bfc6290..0443eff7 100644 --- a/docs/v3/contributing/troubleshooting.mdx +++ b/docs/v3/contributing/troubleshooting.mdx @@ -142,7 +142,19 @@ If calls to an OpenAI-compatible proxy fail: DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=http://host.docker.internal:8000/v1 ``` -3. **Structured output failures** — vLLM's structured output support is limited to certain response formats. If you see JSON parsing errors, check the deriver/dream logs for the raw response. +3. **Structured output failures** — vLLM's structured output support is limited to certain response formats. If you see JSON parsing errors, check the deriver/dream logs for the raw response. See [Deriver produces no observations](#deriver-produces-no-observations) below. + +### Deriver produces no observations + +If messages are processed (the queue drains, no errors in logs) but peers never accumulate observations — and you're using an OpenAI-compatible provider — the likely cause is that the provider doesn't support OpenAI Structured Outputs (`json_schema`). The OpenAI backend requests `json_schema` by default; providers like **Z.AI GLM** and some **Ollama/vLLM** deployments either reject it or silently ignore it and return prose, which the deriver can't parse into observations. + +**Fix:** set `STRUCTURED_OUTPUT_MODE=json_object` on the deriver's model config to request loose JSON mode, which injects the schema into the prompt instead: + +```bash +DERIVER_MODEL_CONFIG__STRUCTURED_OUTPUT_MODE=json_object +``` + +This is a per-model-config setting on the OpenAI transport; set it on whichever features use the affected provider (e.g. `DREAM_DEDUCTION_MODEL_CONFIG__STRUCTURED_OUTPUT_MODE`). ### Thinking budget errors with non-Anthropic providers diff --git a/src/config.py b/src/config.py index 3c20b34f..fa700d6d 100644 --- a/src/config.py +++ b/src/config.py @@ -61,6 +61,10 @@ ThinkingEffortLevel = Literal[ "none", "minimal", "low", "medium", "high", "xhigh", "max" ] +# "json_object" injects the schema into the prompt for OpenAI-compatible +# providers that don't support json_schema (Structured Outputs). +StructuredOutputMode = Literal["json_schema", "json_object"] + class ModelOverrideSettings(BaseModel): """Advanced module-level transport overrides.""" @@ -133,6 +137,23 @@ def _validate_thinking_constraints( raise ValueError("thinking_budget_tokens must be >= 1024 for Anthropic models") +def _validate_structured_output_mode( + transport: ModelTransport, structured_output_mode: StructuredOutputMode | None +) -> None: + """Reject ``structured_output_mode`` on transports that ignore it. + + Only the OpenAI backend honors this setting (it controls the json_schema vs + json_object structured-output path). On the anthropic/gemini transports it is + a silent no-op, so a value set there is a misconfiguration — fail fast at + startup rather than letting the operator wonder why it has no effect. + """ + if structured_output_mode is not None and transport != "openai": + raise ValueError( + "structured_output_mode is only supported on the 'openai' transport; " + + f"remove it from the '{transport}' model config" + ) + + class FallbackModelSettings(BaseModel): """Independent fallback model configuration. No inheritance from primary.""" @@ -152,6 +173,8 @@ class FallbackModelSettings(BaseModel): ) thinking_budget_tokens: int | None = None + structured_output_mode: StructuredOutputMode | None = None + max_output_tokens: int | None = None stop_sequences: list[str] | None = None @@ -171,6 +194,7 @@ class FallbackModelSettings(BaseModel): @model_validator(mode="after") def _validate_runtime_shape(self) -> "FallbackModelSettings": _validate_thinking_constraints(self.transport, self.thinking_budget_tokens) + _validate_structured_output_mode(self.transport, self.structured_output_mode) return self @@ -195,6 +219,8 @@ class ConfiguredModelSettings(BaseModel): ) thinking_budget_tokens: int | None = None + structured_output_mode: StructuredOutputMode | None = None + max_output_tokens: int | None = None stop_sequences: list[str] | None = None @@ -215,6 +241,7 @@ class ConfiguredModelSettings(BaseModel): @model_validator(mode="after") def _validate_runtime_shape(self) -> "ConfiguredModelSettings": _validate_thinking_constraints(self.transport, self.thinking_budget_tokens) + _validate_structured_output_mode(self.transport, self.structured_output_mode) return self @@ -239,6 +266,7 @@ class ResolvedFallbackConfig(BaseModel): validation_alias=AliasChoices("thinking_effort", "reasoning_effort"), ) thinking_budget_tokens: int | None = None + structured_output_mode: StructuredOutputMode | None = None provider_params: dict[str, Any] = Field(default_factory=dict) max_output_tokens: int | None = None @@ -274,6 +302,7 @@ class ModelConfig(BaseModel): validation_alias=AliasChoices("thinking_effort", "reasoning_effort"), ) thinking_budget_tokens: int | None = None + structured_output_mode: StructuredOutputMode | None = None provider_params: dict[str, Any] = Field(default_factory=dict) max_output_tokens: int | None = None @@ -410,6 +439,7 @@ def _resolve_fallback_config( seed=fallback.seed, thinking_effort=fallback.thinking_effort, thinking_budget_tokens=fallback.thinking_budget_tokens, + structured_output_mode=fallback.structured_output_mode, provider_params=fallback.overrides.provider_params, max_output_tokens=fallback.max_output_tokens, stop_sequences=fallback.stop_sequences, @@ -443,6 +473,7 @@ def resolve_model_config(configured: ConfiguredModelSettings) -> ModelConfig: seed=configured.seed, thinking_effort=configured.thinking_effort, thinking_budget_tokens=configured.thinking_budget_tokens, + structured_output_mode=configured.structured_output_mode, provider_params=configured.overrides.provider_params, max_output_tokens=configured.max_output_tokens, stop_sequences=configured.stop_sequences, diff --git a/src/llm/backends/openai.py b/src/llm/backends/openai.py index fe8962d5..05ad1216 100644 --- a/src/llm/backends/openai.py +++ b/src/llm/backends/openai.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import logging from collections.abc import AsyncIterator +from functools import cache from typing import Any, cast from openai import BadRequestError, LengthFinishReasonError @@ -12,6 +13,8 @@ from src.exceptions import ValidationException from src.llm.backend import CompletionResult, StreamChunk, ToolCallResult from src.llm.request_builder import apply_sdk_passthroughs from src.llm.structured_output import ( + StructuredOutputError, + empty_structured_output, repair_response_model_json, validate_structured_output, ) @@ -19,6 +22,23 @@ from src.llm.structured_output import ( logger = logging.getLogger(__name__) +@cache +def _json_object_instruction(response_format: type[BaseModel]) -> str: + """Schema-injection instruction for json_object mode. + + The JSON schema is static per response_format class, so cache the serialized + instruction — the deriver issues one structured call per batch on the worker + hot path and would otherwise re-walk the schema + re-serialize it every call. + """ + # "JSON" must appear in the messages to satisfy the json_object contract. + return ( + "You must respond with a single JSON object that conforms exactly to " + "the following JSON schema. Do not include any text, markdown, or code " + "fences outside the JSON object.\n\nJSON schema:\n" + f"{json.dumps(response_format.model_json_schema())}" + ) + + def _uses_max_completion_tokens(model: str) -> bool: """OpenAI reasoning models (gpt-5 family + o-series) require ``max_completion_tokens`` instead of the classic ``max_tokens`` parameter. @@ -143,10 +163,22 @@ class OpenAIBackend: ) if isinstance(response_format, type): + if self._structured_output_mode(extra_params) == "json_object": + self._apply_json_object_mode(params, response_format) + response = await self._client.chat.completions.create(**params) + # A loose provider that returns nothing shouldn't crash the call. + content = self._parse_or_repair_structured_content( + response, response_format, model, empty_on_missing=True + ) + return self._normalize_response(response, content_override=content) params["response_format"] = response_format try: response = await self._client.chat.completions.parse(**params) except LengthFinishReasonError as exc: + # Truncated output: repair the partial content directly. repair + # handles empty/unrepairable JSON with its own model-aware fallback + # (PromptRepresentation -> empty, others -> raise), which differs + # from the parse-fallback terminal below, so it stays a direct call. truncated = exc.completion raw_content = truncated.choices[0].message.content or "" content = repair_response_model_json( @@ -158,41 +190,42 @@ class OpenAIBackend: truncated, content_override=content, ) - except (BadRequestError, json.JSONDecodeError, ValidationError): - fallback_response = await self._create_structured_response( - params=params, - response_format=response_format, - ) - content = self._parse_or_repair_structured_content( - fallback_response, - response_format, + except BadRequestError: + # A 400 means the provider rejected the request shape — most + # often it doesn't support OpenAI Structured Outputs (json_schema). + # Retrying or re-requesting won't help (it rejects the same shape + # again, the latency trap of #797), so return empty rather than + # erroring existing flows. The warning is the signal to set + # structured_output_mode=json_object. There is no response body to + # account for, so token usage is legitimately zero here. + logger.warning( + "Structured output via json_schema rejected by model %s; " + + "set structured_output_mode=json_object if the provider does " + + "not support OpenAI Structured Outputs.", model, ) - return self._normalize_response( - fallback_response, - content_override=content, - ) + # empty_structured_output() validates {} against the model, which + # itself raises if the model has required fields. Fall back to + # empty string content rather than letting that escape the handler. + try: + fallback_content: Any = empty_structured_output(response_format) + except ValidationError: + fallback_content = "" + return CompletionResult(content=fallback_content) parsed = response.choices[0].message.parsed - raw_content = response.choices[0].message.content or "" - if parsed is None and raw_content: - content = repair_response_model_json( - raw_content, - response_format, - model, + if parsed is not None: + return self._normalize_response( + response, + content_override=validate_structured_output( + parsed, response_format + ), ) - return self._normalize_response(response, content_override=content) - if parsed is None: - refusal = getattr(response.choices[0].message, "refusal", None) - if refusal: - return self._normalize_response( - response, - content_override=refusal, - ) - raise ValidationException("No parsed content in structured response") - return self._normalize_response( - response, - content_override=validate_structured_output(parsed, response_format), + # parse() returned no model: repair raw content, surface a refusal, + # or raise so the retry/fallback chain engages on a junk response. + content = self._parse_or_repair_structured_content( + response, response_format, model, empty_on_missing=False ) + return self._normalize_response(response, content_override=content) if response_format is not None: params["response_format"] = response_format @@ -233,15 +266,20 @@ class OpenAIBackend: params["stream"] = True params["stream_options"] = {"include_usage": True} if isinstance(response_format, type): - # parse() supports BaseModel types but streaming create() does not — - # convert to a json_schema dict so the streaming path works. - params["response_format"] = { - "type": "json_schema", - "json_schema": { - "name": response_format.__name__, - "schema": response_format.model_json_schema(), - }, - } + if self._structured_output_mode(extra_params) == "json_object": + # Inject the schema into the prompt for providers without + # json_schema support; repair happens downstream. + self._apply_json_object_mode(params, response_format) + else: + # Streaming create() can't take a BaseModel like parse() does; + # convert to a json_schema dict. + params["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": response_format.__name__, + "schema": response_format.model_json_schema(), + }, + } elif response_format is not None: params["response_format"] = response_format elif extra_params and extra_params.get("json_mode"): @@ -382,37 +420,93 @@ class OpenAIBackend: raw_response=response, ) - async def _create_structured_response( + @staticmethod + def _structured_output_mode(extra_params: dict[str, Any] | None) -> str | None: + # Threaded in via extra_params (see build_config_extra_params). + if not extra_params: + return None + return extra_params.get("structured_output_mode") + + def _apply_json_object_mode( self, - *, params: dict[str, Any], response_format: type[BaseModel], - ) -> Any: - structured_params = dict(params) - structured_params["response_format"] = { - "type": "json_schema", - "json_schema": { - "name": response_format.__name__, - "schema": response_format.model_json_schema(), - }, - } - return await self._client.chat.completions.create(**structured_params) + ) -> None: + """Configure params for json_object mode in place (shared by complete/stream). + + Injects the schema into the prompt and requests loose JSON, so providers + without OpenAI Structured Outputs (json_schema) support still return JSON. + """ + params["messages"] = self._with_json_schema_instructions( + params["messages"], response_format + ) + params["response_format"] = {"type": "json_object"} + + @staticmethod + def _with_json_schema_instructions( + messages: list[dict[str, Any]], + response_format: type[BaseModel], + ) -> list[dict[str, Any]]: + """Add JSON-schema instructions to a copy of messages for json_object mode. + + The Anthropic backend has its own schema-into-prompt injection + (``_append_text_to_last_message``); the two are intentionally kept + separate since the providers want different placement and wording. + """ + instruction = _json_object_instruction(response_format) + new_messages = [dict(message) for message in messages] + first = new_messages[0] if new_messages else None + # Only merge into a leading system message when its content is a plain + # string; non-string content (e.g. a list of content parts) would be + # corrupted by f-string coercion, so prepend a fresh system message. + if ( + first + and first.get("role") == "system" + and isinstance(first.get("content"), str) + ): + first["content"] = f"{first['content']}\n\n{instruction}".strip() + else: + new_messages.insert(0, {"role": "system", "content": instruction}) + return new_messages @staticmethod def _parse_or_repair_structured_content( response: Any, response_format: type[BaseModel], model: str, + *, + empty_on_missing: bool, ) -> BaseModel | str: - raw_content = response.choices[0].message.content or "" + """Validate (or repair) the raw structured content of a response. + + Shared by the json_object path and the json_schema parse() fallbacks + (truncation, parsed=None). On a contentless response with no refusal, + ``empty_on_missing`` selects the terminal behavior: json_object returns a + graceful empty so a loose provider can't crash the call, while json_schema + raises so the retry/fallback chain engages on a junk response. + """ + message = response.choices[0].message + raw_content = message.content or "" if raw_content: - return repair_response_model_json(raw_content, response_format, model) - refusal = getattr(response.choices[0].message, "refusal", None) + # Fast path: clean JSON validates directly. Only fall back to the + # repair pipeline when validation fails — repair is comparatively + # expensive and silently degrades malformed input to an empty model. + try: + return validate_structured_output(raw_content, response_format) + except (StructuredOutputError, ValidationError): + return repair_response_model_json(raw_content, response_format, model) + refusal = getattr(message, "refusal", None) if refusal: return refusal - raise ValidationException( - "No raw content available for structured output repair" - ) + if not empty_on_missing: + raise ValidationException("No parsed content in structured response") + # empty_structured_output() validates {} against the model, which itself + # raises if the model has required fields. Fall back to empty string + # content rather than letting that escape the handler. + try: + return empty_structured_output(response_format) + except ValidationError: + return "" @staticmethod def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: diff --git a/src/llm/request_builder.py b/src/llm/request_builder.py index fd8d69c3..3da437b5 100644 --- a/src/llm/request_builder.py +++ b/src/llm/request_builder.py @@ -90,6 +90,8 @@ def build_config_extra_params(config: ModelConfig) -> dict[str, Any]: extra_params["presence_penalty"] = config.presence_penalty if config.seed is not None: extra_params["seed"] = config.seed + if config.structured_output_mode is not None: + extra_params["structured_output_mode"] = config.structured_output_mode if config.provider_params: extra_params.update(config.provider_params) diff --git a/src/llm/runtime.py b/src/llm/runtime.py index 7fb2a3bb..ed01229d 100644 --- a/src/llm/runtime.py +++ b/src/llm/runtime.py @@ -364,6 +364,7 @@ def select_model_config_for_attempt( seed=fb.seed, thinking_effort=fb.thinking_effort, thinking_budget_tokens=fb.thinking_budget_tokens, + structured_output_mode=fb.structured_output_mode, provider_params=fb.provider_params, max_output_tokens=fb.max_output_tokens, stop_sequences=fb.stop_sequences, diff --git a/src/llm/structured_output.py b/src/llm/structured_output.py index 76c0690a..0bb346ff 100644 --- a/src/llm/structured_output.py +++ b/src/llm/structured_output.py @@ -1,22 +1,12 @@ from __future__ import annotations import json -from collections.abc import Awaitable, Callable -from typing import Literal from pydantic import BaseModel, ValidationError from src.utils.json_parser import validate_and_repair_json from src.utils.representation import PromptRepresentation -from .backend import CompletionResult - -StructuredOutputFailurePolicy = Literal[ - "raise", - "repair_then_raise", - "repair_then_empty", -] - class StructuredOutputError(ValueError): """Raised when structured output cannot be validated or repaired.""" @@ -79,54 +69,7 @@ def validate_structured_output( ) -def attempt_structured_output_repair( - content: object, - response_model: type[BaseModel], - model: str, -) -> BaseModel | None: - if not isinstance(content, str): - return None - try: - return repair_response_model_json(content, response_model, model) - except (StructuredOutputError, ValidationError): - return None - - def empty_structured_output(response_model: type[BaseModel]) -> BaseModel: if response_model is PromptRepresentation: return PromptRepresentation(explicit=[]) return response_model.model_validate({}) - - -async def execute_structured_output_call( - executor: Callable[[], Awaitable[CompletionResult]], - *, - response_model: type[BaseModel], - model_name: str, - failure_policy: StructuredOutputFailurePolicy = "repair_then_raise", -) -> CompletionResult: - result = await executor() - - try: - result.content = validate_structured_output(result.content, response_model) - return result - except (StructuredOutputError, ValidationError): - if failure_policy == "raise": - raise - - repaired = attempt_structured_output_repair( - result.content, - response_model, - model_name, - ) - if repaired is not None: - result.content = repaired - return result - - if failure_policy == "repair_then_empty": - result.content = empty_structured_output(response_model) - return result - - raise StructuredOutputError( - f"Failed to produce valid structured output for {model_name}" - ) diff --git a/tests/live_llm/model_matrix.py b/tests/live_llm/model_matrix.py index a9abf0f9..2478c6c3 100644 --- a/tests/live_llm/model_matrix.py +++ b/tests/live_llm/model_matrix.py @@ -84,6 +84,18 @@ MODEL_FAMILIES: tuple[LiveModelFamily, ...] = ( supports_caching=False, docs_url="https://openrouter.ai/models", ), + # OpenAI-compatible providers that don't support OpenAI Structured Outputs + # (json_schema) and need structured_output_mode="json_object" on the + # ModelConfig. Point LLM_OPENAI_BASE_URL/API_KEY at the target (Z.AI GLM is + # the canonical #797 repro; vLLM/Ollama are self-hostable equivalents) and + # set the model via this env var. Empty default_models → skipped unless set. + LiveModelFamily( + provider="openai", + family="openai_json_object", + env_var="LIVE_LLM_OPENAI_JSON_OBJECT_MODELS", + supports_structured_output=True, + docs_url="https://docs.z.ai/guides/llm/glm-4.6", + ), LiveModelFamily( provider="gemini", family="gemini_2_5_class", diff --git a/tests/live_llm/test_live_openai.py b/tests/live_llm/test_live_openai.py index 60d89161..e5617ff0 100644 --- a/tests/live_llm/test_live_openai.py +++ b/tests/live_llm/test_live_openai.py @@ -25,6 +25,11 @@ _GPT5_SPECS = tuple( for spec in get_live_model_specs(provider="openai") if spec.family == "gpt_5_class" ) +_JSON_OBJECT_SPECS = tuple( + spec + for spec in get_live_model_specs(provider="openai") + if spec.family == "openai_json_object" +) @pytest.mark.asyncio @@ -134,3 +139,54 @@ async def test_live_openai_gpt5_reasoning_structured_output_and_prefix_caching( assert parse_calls[0]["kwargs"]["reasoning_effort"] == "minimal" assert "max_completion_tokens" in parse_calls[0]["kwargs"] assert "max_tokens" not in parse_calls[0]["kwargs"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_spec", _JSON_OBJECT_SPECS, ids=lambda spec: spec.id) +async def test_live_openai_json_object_structured_output( + model_spec: LiveModelSpec, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """For OpenAI-compatible providers without json_schema support, json_object + mode must skip parse(), request {"type": "json_object"}, and still produce a + valid structured object (the #797 fix, proven against a real provider). + + Configure: LLM_OPENAI_BASE_URL + LLM_OPENAI_API_KEY pointed at the target + provider, and LIVE_LLM_OPENAI_JSON_OBJECT_MODELS=. + """ + require_provider_key(model_spec) + backend, config = make_backend(model_spec, structured_output_mode="json_object") + parse_calls = wrap_async_method( + monkeypatch, backend._client.chat.completions, "parse" + ) + create_calls = wrap_async_method( + monkeypatch, backend._client.chat.completions, "create" + ) + + messages = [ + { + "role": "system", + "content": "You answer questions about a test run.", + }, + { + "role": "user", + "content": ( + "Return provider='openai', " + f"family='{model_spec.family}', and answer='json-object-ok'." + ), + }, + ] + + result = await execute_completion( + backend, + config, + messages=messages, + max_tokens=512, + response_format=StructuredLiveResponse, + ) + + assert isinstance(result.content, StructuredLiveResponse) + assert result.content.provider == "openai" + assert parse_calls == [] + assert create_calls, "expected a chat.completions.create call" + assert create_calls[0]["kwargs"]["response_format"] == {"type": "json_object"} diff --git a/tests/llm/test_backends/test_openai.py b/tests/llm/test_backends/test_openai.py index dd0c7808..ec00306f 100644 --- a/tests/llm/test_backends/test_openai.py +++ b/tests/llm/test_backends/test_openai.py @@ -1,10 +1,65 @@ +import json +from collections.abc import AsyncIterator from types import SimpleNamespace +from typing import Any from unittest.mock import AsyncMock, Mock +import httpx import pytest +from openai import BadRequestError +from pydantic import BaseModel from src.exceptions import ValidationException from src.llm.backends.openai import OpenAIBackend +from src.utils.representation import PromptRepresentation + + +def _await_kwargs(mock_method: Any) -> dict[str, Any]: + await_args = mock_method.await_args + if await_args is None: + raise AssertionError("Expected the mocked method to have been awaited") + return await_args.kwargs + + +def _bad_request_error() -> BadRequestError: + """A 400 like a provider that doesn't support json_schema would return.""" + request = httpx.Request("POST", "https://example.test/v1/chat/completions") + response = httpx.Response(400, request=request) + return BadRequestError( + "response_format json_schema is not supported", response=response, body=None + ) + + +async def _empty_stream() -> AsyncIterator[Any]: + chunks: list[Any] = [] # async generator that yields nothing + for chunk in chunks: + yield chunk + + +class _StructuredResponse(BaseModel): + answer: str + + +def _structured_create_return(content: str, parsed: Any = None) -> SimpleNamespace: + return SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason="stop", + message=SimpleNamespace( + content=content, + parsed=parsed, + tool_calls=[], + reasoning_details=[], + refusal=None, + ), + ) + ], + usage=SimpleNamespace( + prompt_tokens=10, + completion_tokens=5, + prompt_tokens_details=None, + ), + ) @pytest.mark.asyncio @@ -493,3 +548,338 @@ def test_openai_classic_models_use_max_tokens(model: str) -> None: ) assert _uses_max_completion_tokens(model) is False + + +@pytest.mark.asyncio +async def test_structured_output_parsed_none_with_raw_content_repairs() -> None: + """parse() returning parsed=None but with raw content repairs that content.""" + client = Mock() + client.chat.completions.parse = AsyncMock( + return_value=_structured_create_return('{"answer": "ok"}', parsed=None) + ) + + backend = OpenAIBackend(client) + result = await backend.complete( + model="gpt-4.1", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=_StructuredResponse, + ) + + assert isinstance(result.content, _StructuredResponse) + assert result.content.answer == "ok" + + +@pytest.mark.asyncio +async def test_structured_output_parsed_none_returns_refusal() -> None: + """parse() returning parsed=None with no content surfaces the refusal.""" + client = Mock() + response = _structured_create_return("", parsed=None) + response.choices[0].message.refusal = "I can't help with that" + client.chat.completions.parse = AsyncMock(return_value=response) + + backend = OpenAIBackend(client) + result = await backend.complete( + model="gpt-4.1", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=_StructuredResponse, + ) + + assert result.content == "I can't help with that" + + +@pytest.mark.asyncio +async def test_structured_output_parsed_none_no_content_raises() -> None: + """json_schema with no parsed model, content, or refusal raises so the + retry/fallback chain engages — it must NOT silently empty like json_object.""" + from src.exceptions import ValidationException + + client = Mock() + client.chat.completions.parse = AsyncMock( + return_value=_structured_create_return("", parsed=None) + ) + + backend = OpenAIBackend(client) + with pytest.raises(ValidationException): + await backend.complete( + model="gpt-4.1", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=_StructuredResponse, + ) + + +@pytest.mark.asyncio +async def test_structured_output_default_mode_uses_parse() -> None: + """Default json_schema mode uses parse(), not the json_object path.""" + client = Mock() + client.chat.completions.parse = AsyncMock( + return_value=_structured_create_return( + '{"answer": "ok"}', parsed=_StructuredResponse(answer="ok") + ) + ) + client.chat.completions.create = AsyncMock() + + backend = OpenAIBackend(client) + result = await backend.complete( + model="gpt-4.1", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=_StructuredResponse, + ) + + assert client.chat.completions.parse.await_count == 1 + assert client.chat.completions.create.await_count == 0 + parse_call = _await_kwargs(client.chat.completions.parse) + assert parse_call["response_format"] is _StructuredResponse + assert isinstance(result.content, _StructuredResponse) + + +@pytest.mark.asyncio +async def test_structured_output_json_schema_rejected_returns_empty_without_second_request() -> ( + None +): + """A provider that rejects json_schema (400) returns empty, no second request. + + Retrying or re-requesting the same shape is pointless (#797), so a + BadRequestError is swallowed to an empty representation rather than erroring. + """ + client = Mock() + client.chat.completions.parse = AsyncMock(side_effect=_bad_request_error()) + client.chat.completions.create = AsyncMock() + + backend = OpenAIBackend(client) + result = await backend.complete( + model="glm-4.6", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=PromptRepresentation, + ) + + assert client.chat.completions.parse.await_count == 1 + assert client.chat.completions.create.await_count == 0 # no second request + assert isinstance(result.content, PromptRepresentation) + assert result.content.explicit == [] + + +@pytest.mark.asyncio +async def test_structured_output_json_schema_rejected_with_required_fields_does_not_raise() -> ( + None +): + """A json_schema rejection must not raise even when the response model has + required fields (empty_structured_output() can't build an empty instance) — + it falls back to empty content instead of escaping the handler.""" + client = Mock() + client.chat.completions.parse = AsyncMock(side_effect=_bad_request_error()) + client.chat.completions.create = AsyncMock() + + backend = OpenAIBackend(client) + result = await backend.complete( + model="glm-4.6", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=_StructuredResponse, # has a required field + ) + + assert client.chat.completions.parse.await_count == 1 + assert client.chat.completions.create.await_count == 0 # no second request + assert result.content == "" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "exc", + [ + json.JSONDecodeError("Expecting value", "not json", 0), + ValueError("transient parse glitch"), + ], +) +async def test_structured_output_transient_parse_error_propagates_for_retry( + exc: Exception, +) -> None: + """Non-400 parse failures propagate so the retry/fallback chain can engage. + + Only a 400 (json_schema rejection) is treated as terminal-empty; a transient + decode/validation glitch must re-raise so tenacity retries and the fallback + model gets a chance — not be silently swallowed to empty on the first try. + """ + client = Mock() + client.chat.completions.parse = AsyncMock(side_effect=exc) + client.chat.completions.create = AsyncMock() + + backend = OpenAIBackend(client) + with pytest.raises(type(exc)): + await backend.complete( + model="glm-4.6", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=PromptRepresentation, + ) + + assert client.chat.completions.create.await_count == 0 # no second request + + +@pytest.mark.asyncio +async def test_structured_output_json_object_mode_request_shape() -> None: + """json_object mode skips parse(), requests json_object, injects the schema.""" + client = Mock() + client.chat.completions.parse = AsyncMock() + client.chat.completions.create = AsyncMock( + return_value=_structured_create_return('{"answer": "ok"}') + ) + + backend = OpenAIBackend(client) + result = await backend.complete( + model="glm-4.6", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=_StructuredResponse, + extra_params={"structured_output_mode": "json_object"}, + ) + + assert client.chat.completions.parse.await_count == 0 + assert client.chat.completions.create.await_count == 1 + call = _await_kwargs(client.chat.completions.create) + assert call["response_format"] == {"type": "json_object"} + + system_messages = [m for m in call["messages"] if m["role"] == "system"] + assert system_messages, "expected a system message carrying the schema" + system_content = system_messages[0]["content"] + assert "JSON" in system_content + assert "answer" in system_content # schema property serialized in + assert isinstance(result.content, _StructuredResponse) + assert result.content.answer == "ok" + + +@pytest.mark.asyncio +async def test_structured_output_json_object_mode_repairs_markdown() -> None: + """A provider that ignores json_object and returns prose must not crash — + PromptRepresentation repairs to an empty representation, not an exception.""" + client = Mock() + client.chat.completions.parse = AsyncMock() + client.chat.completions.create = AsyncMock( + return_value=_structured_create_return( + "Sure! Here are the facts:\n- the user likes coffee" + ) + ) + + backend = OpenAIBackend(client) + result = await backend.complete( + model="glm-4.6", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=PromptRepresentation, + extra_params={"structured_output_mode": "json_object"}, + ) + + assert isinstance(result.content, PromptRepresentation) + + +@pytest.mark.asyncio +async def test_structured_output_json_object_empty_content_returns_empty() -> None: + """An empty body with no refusal must produce a graceful empty result, not + raise — matching the json_schema path's behavior on a contentless response. + """ + client = Mock() + client.chat.completions.create = AsyncMock( + return_value=_structured_create_return("") + ) + + backend = OpenAIBackend(client) + result = await backend.complete( + model="glm-4.6", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=PromptRepresentation, + extra_params={"structured_output_mode": "json_object"}, + ) + + assert isinstance(result.content, PromptRepresentation) + assert result.content.explicit == [] + # Usage from the (empty) response is preserved, not zeroed. + assert result.input_tokens == 10 + + +@pytest.mark.asyncio +async def test_structured_output_json_object_empty_content_required_fields() -> None: + """Empty content for a required-field model falls back to empty string content + instead of raising (empty_structured_output can't build the instance).""" + client = Mock() + client.chat.completions.create = AsyncMock( + return_value=_structured_create_return("") + ) + + backend = OpenAIBackend(client) + result = await backend.complete( + model="glm-4.6", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=_StructuredResponse, # has a required field + extra_params={"structured_output_mode": "json_object"}, + ) + + assert result.content == "" + + +@pytest.mark.asyncio +async def test_structured_output_json_object_mode_does_not_mutate_messages() -> None: + """The schema-injection helper must copy, never mutate the caller's list.""" + client = Mock() + client.chat.completions.create = AsyncMock( + return_value=_structured_create_return('{"answer": "ok"}') + ) + + backend = OpenAIBackend(client) + original_messages = [{"role": "user", "content": "Hello"}] + await backend.complete( + model="glm-4.6", + messages=original_messages, + max_tokens=100, + response_format=_StructuredResponse, + extra_params={"structured_output_mode": "json_object"}, + ) + + assert original_messages == [{"role": "user", "content": "Hello"}] + + +@pytest.mark.asyncio +async def test_stream_structured_output_default_mode_uses_json_schema() -> None: + """Streaming in default mode converts the model to a json_schema dict.""" + client = Mock() + client.chat.completions.create = AsyncMock(return_value=_empty_stream()) + + backend = OpenAIBackend(client) + async for _ in backend.stream( + model="gpt-4.1", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=_StructuredResponse, + ): + pass + + call = _await_kwargs(client.chat.completions.create) + assert call["response_format"]["type"] == "json_schema" + assert call["response_format"]["json_schema"]["name"] == "_StructuredResponse" + + +@pytest.mark.asyncio +async def test_stream_structured_output_json_object_mode() -> None: + """Streaming in json_object mode requests json_object + injects the schema.""" + client = Mock() + client.chat.completions.create = AsyncMock(return_value=_empty_stream()) + + backend = OpenAIBackend(client) + async for _ in backend.stream( + model="glm-4.6", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=_StructuredResponse, + extra_params={"structured_output_mode": "json_object"}, + ): + pass + + call = _await_kwargs(client.chat.completions.create) + assert call["response_format"] == {"type": "json_object"} + system_messages = [m for m in call["messages"] if m["role"] == "system"] + assert system_messages and "JSON" in system_messages[0]["content"] diff --git a/tests/llm/test_model_config.py b/tests/llm/test_model_config.py index bfc1f392..2c31448c 100644 --- a/tests/llm/test_model_config.py +++ b/tests/llm/test_model_config.py @@ -41,6 +41,62 @@ def test_fallback_config_is_independent() -> None: assert config.fallback.base_url == "https://example.com/v1" +def test_select_model_config_for_attempt_preserves_structured_output_mode() -> None: + """The per-attempt fallback config must carry structured_output_mode. + + The operator sets it on the (independent) fallback; dropping it on the final + attempt would silently send json_schema to a provider that can't parse it. + """ + from src.config import ResolvedFallbackConfig + from src.llm.runtime import select_model_config_for_attempt + + config = ModelConfig( + model="gpt-5.4-mini", + transport="openai", + fallback=ResolvedFallbackConfig( + model="glm-4.6", + transport="openai", + structured_output_mode="json_object", + ), + ) + + # Final attempt swaps to the fallback. + selected = select_model_config_for_attempt(config, attempt=3, retry_attempts=3) + + assert selected.model == "glm-4.6" + assert selected.structured_output_mode == "json_object" + + +def test_structured_output_mode_rejected_on_non_openai_transport() -> None: + """structured_output_mode is a no-op off the openai transport — reject it.""" + with pytest.raises(ValueError, match="structured_output_mode is only supported"): + ConfiguredModelSettings( + model="claude-haiku-4-5", + transport="anthropic", + structured_output_mode="json_object", + ) + + +def test_structured_output_mode_rejected_on_non_openai_fallback() -> None: + from src.config import FallbackModelSettings + + with pytest.raises(ValueError, match="structured_output_mode is only supported"): + FallbackModelSettings( + model="gemini-2.5-pro", + transport="gemini", + structured_output_mode="json_object", + ) + + +def test_structured_output_mode_allowed_on_openai_transport() -> None: + config = ConfiguredModelSettings( + model="glm-4.6", + transport="openai", + structured_output_mode="json_object", + ) + assert config.structured_output_mode == "json_object" + + def test_base_url_is_allowed_for_any_transport() -> None: config = ModelConfig( model="claude-haiku-4-5", From ff821e0b4f03834fbfae3abfcd6378913bf75150 Mon Sep 17 00:00:00 2001 From: Aru Sharma <70081536+staru09@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:45:30 +0530 Subject: [PATCH 22/65] docs: add Goose MCP integration guide (#831) Co-authored-by: Cursor --- docs/v3/guides/integrations/mcp.mdx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/v3/guides/integrations/mcp.mdx b/docs/v3/guides/integrations/mcp.mdx index 37d2f977..10bfdfd0 100644 --- a/docs/v3/guides/integrations/mcp.mdx +++ b/docs/v3/guides/integrations/mcp.mdx @@ -224,6 +224,32 @@ Add to `~/.config/zed/settings.json`: Zed uses `context_servers` instead of `mcpServers`. Native HTTP support requires Zed v0.214.5 or later. +### Goose + +[Goose](https://goose-docs.ai/) supports remote MCP servers natively over Streamable HTTP. + +The easiest way is to run `goose configure`, choose **Add Extension → Remote Extension (Streamable HTTP)**, and enter the name `honcho`, the URI `https://mcp.honcho.dev`, and the headers `Authorization: Bearer hch-your-key-here` and `X-Honcho-User-Name: YourName`. + +Or edit your `config.yaml` directly (on Linux, `~/.config/goose/config.yaml`): + +```yaml +extensions: + honcho: + enabled: true + type: streamable_http + name: honcho + description: Honcho persistent memory & personalization + uri: https://mcp.honcho.dev + headers: + Authorization: "Bearer hch-your-key-here" + X-Honcho-User-Name: "YourName" + timeout: 60 +``` + + +To teach Goose the recommended memory flow, save the [instructions](https://raw.githubusercontent.com/plastic-labs/honcho/refs/heads/main/mcp/instructions.md) into a `.goosehints` file in your Goose config directory (or a project root). This is Goose's equivalent of Claude Desktop's "Project Instructions". Not sure of your config path? Run `goose info`. + + --- ## Optional Configuration From ab8c5aeaa36f515dc274f616e14020c4b68a82d2 Mon Sep 17 00:00:00 2001 From: Rajat Ahuja Date: Tue, 23 Jun 2026 14:41:33 -0400 Subject: [PATCH 23/65] feat: add route-level latency metrics (#837) --- src/main.py | 3 +++ src/telemetry/prometheus/metrics.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/main.py b/src/main.py index f38946df..b0611f59 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,6 @@ import logging import re +import time import uuid from collections.abc import Awaitable, Callable from contextlib import asynccontextmanager @@ -250,6 +251,7 @@ async def track_request( token = request_context.set(f"api:{request_id}") try: + start_time = time.perf_counter() response = await call_next(request) # Track metrics if enabled @@ -259,6 +261,7 @@ async def track_request( method=request.method, endpoint=template, status_code=str(response.status_code), + duration_seconds=time.perf_counter() - start_time, ) return response diff --git a/src/telemetry/prometheus/metrics.py b/src/telemetry/prometheus/metrics.py index d7bfe627..9038b9d0 100644 --- a/src/telemetry/prometheus/metrics.py +++ b/src/telemetry/prometheus/metrics.py @@ -12,6 +12,7 @@ from prometheus_client import ( REGISTRY, Counter, Gauge, + Histogram, disable_created_metrics, generate_latest, ) @@ -38,6 +39,12 @@ class NamespacedGauge(Gauge): return super().labels(**kwargs) # type: ignore[return-value] +class NamespacedHistogram(Histogram): + def labels(self, **kwargs: str) -> NamespacedHistogram: + kwargs["namespace"] = cast(str, settings.METRICS.NAMESPACE) + return super().labels(**kwargs) # type: ignore[return-value] + + class TokenTypes(Enum): INPUT = "input" OUTPUT = "output" @@ -65,6 +72,15 @@ api_requests_counter = NamespacedCounter( ["namespace", "method", "endpoint", "status_code"], ) +# Per-route latency. Buckets are a geometric ladder spanning +# the full range of API classes +api_request_duration_seconds = NamespacedHistogram( + "api_request_duration_seconds", + "API request latency in seconds", + ["namespace", "method", "endpoint"], + buckets=(0.05, 0.1, 0.25, 0.5, 0.75, 1, 2, 5, 10, 20, 30, 60, 120), +) + messages_created_counter = NamespacedCounter( "messages_created", "Total messages created", @@ -160,6 +176,7 @@ class PrometheusMetrics: method: str, endpoint: str, status_code: str, + duration_seconds: float, ) -> None: try: api_requests_counter.labels( @@ -167,6 +184,10 @@ class PrometheusMetrics: endpoint=endpoint, status_code=status_code, ).inc() + api_request_duration_seconds.labels( + method=method, + endpoint=endpoint, + ).observe(duration_seconds) except Exception as e: self._handle_metric_error("record_api_request", e) From 715d8a90b97af8e842de5c409220acd9916d8263 Mon Sep 17 00:00:00 2001 From: Rajat Ahuja Date: Tue, 23 Jun 2026 15:55:03 -0400 Subject: [PATCH 24/65] fix: compact honcho logging (#836) * fix: compact honcho logging * fix: guard ms/s metric formatting against non-numeric values Only apply float formatting when the metric value is numeric so a str value with an ms/s unit falls through to a plain string instead of raising. Applied to both the compact and rich log paths. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- .env.template | 1 + config.toml.example | 1 + src/config.py | 8 ++++ src/crud/document.py | 12 +++-- src/deriver/enqueue.py | 4 +- src/dreamer/dream_scheduler.py | 6 +-- src/reconciler/scheduler.py | 2 +- src/reconciler/sync_vectors.py | 4 +- src/telemetry/logging.py | 85 +++++++++++++++++++--------------- 9 files changed, 74 insertions(+), 49 deletions(-) diff --git a/.env.template b/.env.template index 84d8ba84..ebe7fa16 100644 --- a/.env.template +++ b/.env.template @@ -8,6 +8,7 @@ # Application Settings # ============================================================================= LOG_LEVEL=INFO +PERFORMANCE_LOG_FORMAT=compact # compact|rich # SESSION_OBSERVERS_LIMIT=10 # GET_CONTEXT_MAX_TOKENS=100000 # MAX_FILE_SIZE=5242880 # Bytes diff --git a/config.toml.example b/config.toml.example index 9e0e9c11..54bf1ffd 100644 --- a/config.toml.example +++ b/config.toml.example @@ -6,6 +6,7 @@ # Application-level settings [app] LOG_LEVEL = "INFO" +PERFORMANCE_LOG_FORMAT = "compact" # "compact" for single-line logs, "rich" for local panels SESSION_OBSERVERS_LIMIT = 10 GET_CONTEXT_MAX_TOKENS = 100000 MAX_FILE_SIZE = 5242880 # 5MB diff --git a/src/config.py b/src/config.py index fa700d6d..5a0e3937 100644 --- a/src/config.py +++ b/src/config.py @@ -1353,6 +1353,7 @@ class AppSettings(HonchoSettings): # Application-wide settings LOG_LEVEL: str = "INFO" + PERFORMANCE_LOG_FORMAT: str = "compact" SESSION_OBSERVERS_LIMIT: Annotated[int, Field(default=10, gt=0)] = 10 MAX_FILE_SIZE: Annotated[int, Field(default=5_242_880, gt=0)] = 5_242_880 # 5MB GET_CONTEXT_MAX_TOKENS: Annotated[int, Field(default=100_000, gt=0, le=250_000)] = ( @@ -1401,6 +1402,13 @@ class AppSettings(HonchoSettings): raise ValueError(f"Invalid log level: {v}") return log_level + @field_validator("PERFORMANCE_LOG_FORMAT") + def validate_performance_log_format(cls, v: str) -> str: + log_format = v.lower() + if log_format not in ["compact", "rich"]: + raise ValueError(f"Invalid performance log format: {v}") + return log_format + @model_validator(mode="after") def propagate_namespace(self) -> "AppSettings": """Propagate top-level NAMESPACE to nested settings if not explicitly set.""" diff --git a/src/crud/document.py b/src/crud/document.py index 018f3411..11eb121d 100644 --- a/src/crud/document.py +++ b/src/crud/document.py @@ -1024,8 +1024,10 @@ async def is_rejected_duplicate( # If new document has more or equal information, keep it and delete existing if score_new >= score_existing: - logger.warning( - f"[DUPLICATE DETECTION] Deleting existing in favor of new. new='{doc.content}', existing='{existing_doc.content}'." + logger.debug( + "[DUPLICATE DETECTION] Deleting existing in favor of new. new=%r, existing=%r.", + doc.content, + existing_doc.content, ) # Carry the reinforcement count forward so replacing a duplicate counts as # another derivation rather than resetting times_derived to 1. @@ -1041,8 +1043,10 @@ async def is_rejected_duplicate( # reinforcing the same document must not lose updates. existing_doc.times_derived = models.Document.times_derived + 1 await db.flush() - logger.warning( - f"[DUPLICATE DETECTION] Rejecting new in favor of existing. new='{doc.content}', existing='{existing_doc.content}'." + logger.debug( + "[DUPLICATE DETECTION] Rejecting new in favor of existing. new=%r, existing=%r.", + doc.content, + existing_doc.content, ) return True diff --git a/src/deriver/enqueue.py b/src/deriver/enqueue.py index aaeda415..05883306 100644 --- a/src/deriver/enqueue.py +++ b/src/deriver/enqueue.py @@ -495,7 +495,7 @@ async def enqueue_dream( is_in_progress = await db_session.scalar(in_progress_check) if is_in_progress: - logger.info( + logger.debug( "Skipping dream enqueue - already in progress: %s/%s/%s (type: %s)", workspace_name, observer, @@ -515,7 +515,7 @@ async def enqueue_dream( is_pending = await db_session.scalar(pending_check) if is_pending: - logger.info( + logger.debug( "Dream already pending in queue: %s/%s/%s (type: %s)", workspace_name, observer, diff --git a/src/dreamer/dream_scheduler.py b/src/dreamer/dream_scheduler.py index bad850f4..43bba428 100644 --- a/src/dreamer/dream_scheduler.py +++ b/src/dreamer/dream_scheduler.py @@ -218,7 +218,7 @@ class DreamScheduler: configuration = get_configuration(None, session, workspace) if not configuration.dream.enabled: - logger.info( + logger.debug( f"Dreams disabled for {workspace_name}/{session_name}, skipping dream" ) return @@ -319,7 +319,7 @@ async def check_and_schedule_dream( ).total_seconds() / 3600 if hours_since_last_dream < settings.DREAM.MIN_HOURS_BETWEEN_DREAMS: - logger.info( + logger.debug( f"Skipping dream for {collection.observer}/{collection.observed}: only {hours_since_last_dream:.1f} hours " + f"since last dream (minimum: {settings.DREAM.MIN_HOURS_BETWEEN_DREAMS})" ) @@ -359,7 +359,7 @@ async def check_and_schedule_dream( ) ) if pending_exists: - logger.info( + logger.debug( "Skipping dream schedule for %s/%s: pending dream already in queue", collection.observer, collection.observed, diff --git a/src/reconciler/scheduler.py b/src/reconciler/scheduler.py index d4f314a3..e171c4fa 100644 --- a/src/reconciler/scheduler.py +++ b/src/reconciler/scheduler.py @@ -264,5 +264,5 @@ class ReconcilerScheduler: ) return False - logger.info("Enqueued reconciler task: %s", task.name) + logger.debug("Enqueued reconciler task: %s", task.name) return True diff --git a/src/reconciler/sync_vectors.py b/src/reconciler/sync_vectors.py index cdc18542..68e9ac59 100644 --- a/src/reconciler/sync_vectors.py +++ b/src/reconciler/sync_vectors.py @@ -728,7 +728,7 @@ async def run_vector_reconciliation_cycle() -> ReconciliationMetrics: if not (embs_work or cleanup_work): break - logger.info("Vector reconciliation cycle completed (pgvector mode)") + logger.debug("Vector reconciliation cycle completed (pgvector mode)") return metrics # External vector store mode - reconcile documents, embeddings, and cleanup @@ -755,5 +755,5 @@ async def run_vector_reconciliation_cycle() -> ReconciliationMetrics: logger.debug("No work done, breaking reconciliation loop") break - logger.info("Vector reconciliation cycle completed") + logger.debug("Vector reconciliation cycle completed") return metrics diff --git a/src/telemetry/logging.py b/src/telemetry/logging.py index c2fcba69..441b1f96 100644 --- a/src/telemetry/logging.py +++ b/src/telemetry/logging.py @@ -17,7 +17,6 @@ from rich.console import Console, Group, RenderableType from rich.panel import Panel from rich.table import Table from rich.text import Text -from rich.tree import Tree from src.config import settings from src.telemetry.metrics_collector import append_metrics_to_file @@ -176,28 +175,6 @@ def format_reasoning_inputs_as_markdown( return "\n".join(parts) -def log_representation( - representation: Representation, -) -> None: - """ - Log representation in a tree structure. - Args: - representation: Representation to log - """ - tree = Tree("📊 REPRESENTATION") - - type_branch = tree.add(f"[bold cyan]EXPLICIT[/] ({len(representation.explicit)})") - for i, obs in enumerate(representation.explicit, 1): - type_branch.add(f"[dim]{i}.[/] {obs}") - - type_branch = tree.add(f"[bold cyan]DEDUCTIVE[/] ({len(representation.deductive)})") - for i, obs in enumerate(representation.deductive, 1): - type_branch.add(f"[dim]{i}.[/] {obs}") - - console.print(tree) - console.print() - - def accumulate_metric( task_name: str, label: str, @@ -276,16 +253,20 @@ def log_performance_metrics( task_slug: str, task_name: str, metrics: list[tuple[str, str | int | float, str]] | None = None, - title: str = "⚡ PERFORMANCE", + title: str = "PERFORMANCE", ) -> None: """ - Log performance metrics in a clean table and optionally send to global collector. + Log performance metrics and optionally send them to the global collector. + + PERFORMANCE_LOG_FORMAT=compact emits numeric metrics on one INFO line and + keeps large "blob" metrics at DEBUG. PERFORMANCE_LOG_FORMAT=rich prints the + local Rich panel, including blob metrics, for interactive readability. Args: task_slug: Slug of the task that generated these metrics task_name: Name of the task that generated these metrics - metrics: Dictionary of metric names and (value, unit) tuples - title: Table title + metrics: List of (metric_name, value, unit) tuples + title: Prefix for the log line """ task_name = f"{task_slug}_{task_name}" # No-op if metrics were evicted (due to MAX_ACCUMULATED_TASKS limit) and no @@ -300,7 +281,41 @@ def log_performance_metrics( if COLLECT_METRICS_LOCAL: append_metrics_to_file(task_slug, task_name, metrics) - # Remove metrics with "blob" unit type. They get printed separately below the table. + if settings.PERFORMANCE_LOG_FORMAT == "rich": + _log_performance_metrics_rich(task_name, metrics, title) + return + + # Keep large text payloads out of the compact INFO summary. + blob_metrics: list[tuple[str, str | int | float, str]] = [] + summary_parts: list[str] = [] + for metric, value, unit in metrics: + if unit == "blob": + blob_metrics.append((metric, value, unit)) + continue + if unit == "ms" and isinstance(value, int | float): + formatted_value = f"{value:.0f}ms" + elif unit == "s" and isinstance(value, int | float): + formatted_value = f"{value:.3f}s" + elif unit in ("", "tokens", "count", "id"): + formatted_value = str(value) + else: + formatted_value = f"{value}{unit}" + summary_parts.append(f"{metric}={formatted_value}") + + if summary_parts: + logger.info("%s %s | %s", title, task_name, " | ".join(summary_parts)) + else: + logger.info("%s %s", title, task_name) + + for metric, value, _unit in blob_metrics: + logger.debug("%s %s :: %s\n%s", title, task_name, metric, value) + + +def _log_performance_metrics_rich( + task_name: str, + metrics: list[tuple[str, str | int | float, str]], + title: str, +) -> None: blob_metrics: list[tuple[str, str | int | float, str]] = [] non_blob_metrics: list[tuple[str, str | int | float, str]] = [] for metric in metrics: @@ -317,24 +332,20 @@ def log_performance_metrics( table.add_column("Unit", style="dim", width=8) for metric, value, unit in non_blob_metrics: - if unit == "ms": + if unit == "ms" and isinstance(value, int | float): formatted_value = f"{value:.0f}" - elif unit == "s": + elif unit == "s" and isinstance(value, int | float): formatted_value = f"{value:.3f}" else: formatted_value = str(value) table.add_row(metric.replace("_", " ").title(), formatted_value, unit) - # Build content for the panel content_items: list[RenderableType] = [table] - if blob_metrics: - for metric, value, _unit in blob_metrics: - content_items.append( - Text.assemble(" ", (f"\n{metric}:", "bold"), " ") - ) - content_items.append(Text(str(value))) + for metric, value, _unit in blob_metrics: + content_items.append(Text.assemble(" ", (f"\n{metric}:", "bold"), " ")) + content_items.append(Text(str(value))) panel = Panel( Group(*content_items), From a65a40630196e2e0db64136e0401da177389d074 Mon Sep 17 00:00:00 2001 From: Ken Weiner Date: Tue, 23 Jun 2026 13:11:36 -0700 Subject: [PATCH 25/65] feat: send OpenRouter app-attribution headers on OpenAI-compatible clients (#805) * feat: send OpenRouter app-attribution headers on OpenAI-compatible clients Sets HTTP-Referer and X-Title on every AsyncOpenAI client constructed in src/llm/registry.py (default, override-cached, and module-level CLIENTS) and in the embedding client, so OpenRouter attributes Honcho's requests to the "Honcho" app in its dashboard/analytics. Other OpenAI-compatible providers ignore unrecognized headers, so this is safe to send unconditionally. * fix: scope OpenRouter attribution headers to OpenRouter base URL only Address review feedback on #805: - Only inject attribution headers when the configured base_url starts with https://openrouter.ai (via new _openrouter_headers() helper) - Rename X-Title to X-Openrouter-Title per OpenRouter docs recommendation Co-Authored-By: Claude Sonnet 4.6 * refactor: drive default headers from base-URL map, drop embedding path Replace the OpenRouter-specific _openrouter_headers helper with a generic _DEFAULT_HEADERS_BY_BASE_URL prefix map + _default_headers_for lookup, so OpenRouter always receives its attribution headers and another provider can be added with a single map entry. Revert the embedding-client change (OpenRouter has no embeddings endpoint, so that gate was dead code) and add a unit test for the lookup helper. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Sonnet 4.6 --- src/llm/registry.py | 29 ++++++++++++++++++++++++++++- tests/llm/test_registry.py | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/llm/test_registry.py diff --git a/src/llm/registry.py b/src/llm/registry.py index d89f088d..6dd5eecc 100644 --- a/src/llm/registry.py +++ b/src/llm/registry.py @@ -32,6 +32,27 @@ from .history_adapters import ( ) from .types import ProviderClient +# Client-level ``default_headers`` applied to OpenAI-compatible clients, keyed by +# base-URL prefix. Currently only OpenRouter, which uses them for app attribution +# (https://openrouter.ai/docs/app-attribution); add a prefix here to tag another +# provider. Other OpenAI-compatible backends ignore unrecognized headers. +_DEFAULT_HEADERS_BY_BASE_URL: dict[str, dict[str, str]] = { + "https://openrouter.ai": { + "HTTP-Referer": "https://honcho.dev", + "X-Openrouter-Title": "Honcho", + }, +} + + +def _default_headers_for(base_url: str | None) -> dict[str, str]: + """Default headers for ``base_url`` (prefix match); these merge under any + per-request ``extra_headers`` passthrough, which wins on key collision.""" + if base_url: + for prefix, headers in _DEFAULT_HEADERS_BY_BASE_URL.items(): + if base_url.startswith(prefix): + return headers + return {} + @lru_cache(maxsize=1) def get_anthropic_client() -> AsyncAnthropic: @@ -49,6 +70,7 @@ def get_openai_client() -> AsyncOpenAI: return AsyncOpenAI( api_key=settings.LLM.OPENAI_API_KEY, base_url=settings.LLM.OPENAI_BASE_URL, + default_headers=_default_headers_for(settings.LLM.OPENAI_BASE_URL), ) @@ -70,7 +92,11 @@ def get_openai_override_client( base_url: str | None, api_key: str | None ) -> AsyncOpenAI: """OpenAI client for a specific (base_url, api_key) pair. Cached by key.""" - return AsyncOpenAI(api_key=api_key, base_url=base_url) + return AsyncOpenAI( + api_key=api_key, + base_url=base_url, + default_headers=_default_headers_for(base_url), + ) @lru_cache(maxsize=128) @@ -106,6 +132,7 @@ if settings.LLM.OPENAI_API_KEY: CLIENTS["openai"] = AsyncOpenAI( api_key=settings.LLM.OPENAI_API_KEY, base_url=settings.LLM.OPENAI_BASE_URL, + default_headers=_default_headers_for(settings.LLM.OPENAI_BASE_URL), ) if settings.LLM.GEMINI_API_KEY: diff --git a/tests/llm/test_registry.py b/tests/llm/test_registry.py new file mode 100644 index 00000000..71f3302e --- /dev/null +++ b/tests/llm/test_registry.py @@ -0,0 +1,22 @@ +"""Tests for src.llm.registry helpers.""" + +from __future__ import annotations + +from src.llm.registry import _default_headers_for # pyright: ignore[reportPrivateUsage] + + +def test_default_headers_for_openrouter_base_url() -> None: + """OpenRouter base URLs get the app-attribution headers.""" + headers = _default_headers_for("https://openrouter.ai/api/v1") + assert headers["HTTP-Referer"] == "https://honcho.dev" + assert headers["X-Openrouter-Title"] == "Honcho" + + +def test_default_headers_for_non_openrouter_base_url() -> None: + """Other OpenAI-compatible providers get no extra headers.""" + assert _default_headers_for("https://api.openai.com/v1") == {} + + +def test_default_headers_for_none_base_url() -> None: + """A missing base URL (default OpenAI) gets no extra headers.""" + assert _default_headers_for(None) == {} From 70ce692079b21afe2ec943b9e50d72dac7b572f1 Mon Sep 17 00:00:00 2001 From: TcDrozd <106362126+TcDrozd@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:49:17 -0400 Subject: [PATCH 26/65] fix(agent_tools): strip display-format "id:" prefix from source_ids and get_reasoning_chain lookups (#795) * fix(agent_tools): strip display-format "id:" prefix from model-supplied observation IDs Observations are presented to agents as [id:xxx], and models sometimes copy the prefix verbatim despite tool-schema instructions to pass the bare ID. This silently corrupts source_ids provenance on create_observations_* (broken links stored in document metadata) and breaks get_reasoning_chain lookups. Normalize at both entry points. delete_observations is intentionally not touched here since #746 already covers it. Only the "id:" prefix is stripped: document IDs are nanoids whose alphabet includes "-" and "_", so more aggressive cleanup could mangle legitimate IDs. Related to #719. Co-Authored-By: Claude Fable 5 * fix(agent_tools): strip whitespace remaining after "id:" prefix removal Addresses CodeRabbit review: defends against "id: xxx" with a space after the colon, and matches the docstring, which already promised surrounding-whitespace stripping. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- src/utils/agent_tools.py | 24 ++++++++++++++ tests/utils/test_agent_tools.py | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index 261817f5..404882ba 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -1290,6 +1290,22 @@ class ToolContext: parent_category: str | None = None # Parent category for CloudEvents +def _normalize_observation_id(obs_id: str) -> str: + """Strip the display-format ``id:`` prefix from a model-supplied observation ID. + + Observations are presented to agents as ``[id:xxx]`` (see + ``Representation.str_with_ids``), and despite tool-schema instructions to + pass the bare ID, models sometimes copy the prefix verbatim. Since document + IDs are nanoids whose alphabet includes ``-`` and ``_``, only the ``id:`` + prefix and surrounding whitespace are stripped — anything more aggressive + could mangle legitimate IDs. + """ + obs_id = obs_id.strip() + if obs_id.lower().startswith("id:"): + obs_id = obs_id[3:] + return obs_id.strip() + + async def _handle_create_observations_impl( ctx: ToolContext, tool_input: dict[str, Any], @@ -1309,6 +1325,13 @@ async def _handle_create_observations_impl( obs["level"] = forced_level else: obs.setdefault("level", default_level) + # Models sometimes copy the display-format "id:" prefix into source_ids; + # normalize so provenance links reference real document IDs. + source_ids = obs.get("source_ids") + if isinstance(source_ids, list): + obs["source_ids"] = [ + _normalize_observation_id(s) for s in source_ids if isinstance(s, str) + ] # Validate observations individually so valid ones are still processed observations: list[schemas.ObservationInput] = [] @@ -2203,6 +2226,7 @@ async def _handle_get_reasoning_chain( observation_id = tool_input.get("observation_id") if not observation_id: return "ERROR: 'observation_id' is required" + observation_id = _normalize_observation_id(observation_id) direction = tool_input.get("direction", "both") if direction not in ("premises", "conclusions", "both"): diff --git a/tests/utils/test_agent_tools.py b/tests/utils/test_agent_tools.py index 8ddab1bc..12037bf7 100644 --- a/tests/utils/test_agent_tools.py +++ b/tests/utils/test_agent_tools.py @@ -34,6 +34,7 @@ from src.utils.agent_tools import ( _handle_search_messages, # pyright: ignore[reportPrivateUsage] _handle_search_messages_temporal, # pyright: ignore[reportPrivateUsage] _handle_update_peer_card, # pyright: ignore[reportPrivateUsage] + _normalize_observation_id, # pyright: ignore[reportPrivateUsage] _validate_peer_card_entry, # pyright: ignore[reportPrivateUsage] create_observations, create_tool_executor, @@ -247,6 +248,40 @@ class TestCreateObservations: assert doc.level == "deductive" assert doc.source_ids == ["premise1", "premise2"] + async def test_source_ids_display_prefix_is_stripped( + self, + db_session: AsyncSession, + make_tool_context: Callable[..., ToolContext], + ): + """Models sometimes copy the '[id:xxx]' display format into source_ids; + the prefix must be stripped so provenance links reference real IDs.""" + ctx = make_tool_context(current_messages=None) + + result = await _handle_create_observations( + ctx, + { + "observations": [ + { + "content": "Inferred preference for early mornings", + "source_ids": ["id:premise1", "ID:premise2"], + "premises": [ + "User schedules meetings before 9am", + "User mentions waking at 5:30", + ], + }, + ] + }, + ) + + assert "Created 1 observations" in result + + stmt = select(models.Document).where( + models.Document.content == "Inferred preference for early mornings" + ) + doc = (await db_session.execute(stmt)).scalar_one_or_none() + assert doc is not None + assert doc.source_ids == ["premise1", "premise2"] + async def test_empty_observations_list_returns_error( self, make_tool_context: Callable[..., ToolContext] ): @@ -475,6 +510,27 @@ class TestCreateObservations: create_documents.assert_not_awaited() +class TestNormalizeObservationId: + """Unit tests for _normalize_observation_id.""" + + @pytest.mark.parametrize( + "raw,expected", + [ + ("doc_abc123", "doc_abc123"), + ("id:doc_abc123", "doc_abc123"), + ("ID:doc_abc123", "doc_abc123"), + (" id:doc_abc123 ", "doc_abc123"), + ("id: doc_abc123", "doc_abc123"), + # nanoid alphabet includes '-' and '_'; these must survive untouched + ("3-bwp1hxCRkRbUh_nrqn0", "3-bwp1hxCRkRbUh_nrqn0"), + ("id:3-bwp1hxCRkRbUh_nrqn0", "3-bwp1hxCRkRbUh_nrqn0"), + ("_leading_underscore", "_leading_underscore"), + ], + ) + def test_normalization(self, raw: str, expected: str): + assert _normalize_observation_id(raw) == expected + + @pytest.mark.asyncio class TestDeleteObservations: """Tests for _handle_delete_observations.""" From 1c23a2e24ac4784077745a8d9d6fd8d961feaef0 Mon Sep 17 00:00:00 2001 From: Rajat Ahuja Date: Wed, 24 Jun 2026 10:29:52 -0400 Subject: [PATCH 27/65] chore: use git tags to fetch secrets for unified test (#838) * chore: use git tags to fetch secrets for unified test * fix: test failure * fix: override AUTH_USE_AUTH and SENTRY_ENABLED * fix: upload traces * chore: rm run on PR * fix: rm bucket from logs --- .github/workflows/unified-tests.yml | 64 +++++++++++++++++++++++++++-- tests/unified/runner.py | 46 +++++++++++++++++---- 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/.github/workflows/unified-tests.yml b/.github/workflows/unified-tests.yml index bfc27018..55b938e7 100644 --- a/.github/workflows/unified-tests.yml +++ b/.github/workflows/unified-tests.yml @@ -40,17 +40,75 @@ jobs: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: arn:aws:iam::444554165670:role/GitHubActionsS3Role + role-to-assume: ${{ vars.AWS_OIDC_ROLE_ARN }} aws-region: us-east-1 role-duration-seconds: 43200 # 12 hours - - name: Fetch secrets from AWS Secrets Manager + - name: Resolve secret ids from latest git tags + id: resolve-secret + env: + SECRET_PREFIX: ${{ secrets.STAGING_SECRET_PREFIX }} + run: | + set -euo pipefail + : "${SECRET_PREFIX:?STAGING_SECRET_PREFIX secret is not set for this environment}" + # Keep the secret-name prefix out of public CI logs. + echo "::add-mask::${SECRET_PREFIX}" + + # Two newest v tags, highest first (tags are public). + versions="$(git ls-remote --tags origin 'v*' \ + | sed -n 's#.*refs/tags/v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$#\1#p' \ + | sort -t. -k1,1nr -k2,2nr -k3,3nr -u)" + latest="$(printf '%s\n' "$versions" | sed -n '1p')" + second="$(printf '%s\n' "$versions" | sed -n '2p')" + if [ -z "${latest:-}" ]; then + echo "::error::No v git tags found to resolve a secret version" + exit 1 + fi + + latest_id="${SECRET_PREFIX}${latest}" + echo "::add-mask::${latest_id}" + echo "latest-id=${latest_id}" >> "$GITHUB_OUTPUT" + echo "Latest version: ${latest}" + if [ -n "${second:-}" ]; then + second_id="${SECRET_PREFIX}${second}" + echo "::add-mask::${second_id}" + echo "second-id=${second_id}" >> "$GITHUB_OUTPUT" + echo "Fallback version: ${second}" + fi + + # Fetch the latest tag's secret. continue-on-error so a not-yet-published + # latest falls through to the second-latest instead of failing the job. + - name: Fetch staging secret (latest) + id: fetch-latest + continue-on-error: true uses: aws-actions/aws-secretsmanager-get-secrets@v2 with: secret-ids: | - ,testing/unified/tests + ,${{ steps.resolve-secret.outputs.latest-id }} parse-json-secrets: true + # Runs only if the latest fetch failed; this one is NOT continue-on-error, + # so if the fallback also fails the job fails loudly. + - name: Fetch staging secret (fallback to second-latest) + if: steps.fetch-latest.outcome == 'failure' && steps.resolve-secret.outputs.second-id != '' + uses: aws-actions/aws-secretsmanager-get-secrets@v2 + with: + secret-ids: | + ,${{ steps.resolve-secret.outputs.second-id }} + parse-json-secrets: true + + # Configure the test environment. Disables auth/Sentry/CloudEvents telemetry + # (their endpoints aren't reachable from CI), and points REASONING_TRACES_FILE + # at a shared path so the API + deriver record full LLM I/O for auditing — the + # runner uploads it to S3. Written after the fetch steps so these win over the + # values loaded from Secrets Manager (last $GITHUB_ENV write wins). + - name: Configure test environment + run: | + echo "AUTH_USE_AUTH=false" >> "$GITHUB_ENV" + echo "SENTRY_ENABLED=false" >> "$GITHUB_ENV" + echo "TELEMETRY_ENABLED=false" >> "$GITHUB_ENV" + echo "REASONING_TRACES_FILE=unified-reasoning-traces.jsonl" >> "$GITHUB_ENV" + - name: Verify Docker is available run: docker info diff --git a/tests/unified/runner.py b/tests/unified/runner.py index 297b7100..eaa78409 100644 --- a/tests/unified/runner.py +++ b/tests/unified/runner.py @@ -119,6 +119,7 @@ async def save_results_to_s3( # Create comprehensive results object timestamp = datetime.now(timezone.utc).isoformat() github_run_id = os.getenv("GITHUB_RUN_ID", "local") + github_run_attempt = os.getenv("GITHUB_RUN_ATTEMPT", "1") github_sha = os.getenv("GITHUB_SHA", "unknown") github_ref = os.getenv("GITHUB_REF_NAME", "unknown") @@ -132,6 +133,7 @@ async def save_results_to_s3( }, "metadata": { "github_run_id": github_run_id, + "github_run_attempt": github_run_attempt, "github_sha": github_sha, "github_ref": github_ref, }, @@ -145,31 +147,59 @@ async def save_results_to_s3( ], } + # One "folder" per run: /// holding results.json plus + # the reasoning-trace file(s), so a run's summary and full LLM I/O live together. date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") sha_short = github_sha[:7] if github_sha != "unknown" else "unknown" - ref_name = github_ref if github_ref != "unknown" else "unknown" - key = f"{s3_prefix}/{date_str}-{ref_name}-{sha_short}.json" + ref_slug = github_ref.replace("/", "-") # branch names may contain "/" + run_slug = f"{ref_slug}-{sha_short}-{github_run_id}-{github_run_attempt}" + run_prefix = f"{s3_prefix}/{date_str}/{run_slug}" + results_key = f"{run_prefix}/results.json" s3_client = boto3.client("s3", region_name=aws_region) # pyright: ignore s3_client.put_object( # pyright: ignore Bucket=s3_bucket, - Key=key, + Key=results_key, Body=json.dumps(comprehensive_results, indent=2).encode("utf-8"), ContentType="application/json", ) + logger.info(f"Saved test results to S3 key {results_key}") + + # Upload the reasoning traces (full LLM/deriver I/O) captured this run. The + # API and deriver both append to REASONING_TRACES_FILE (file-locked). Use + # upload_file so large trace files stream via multipart instead of buffering. + traces_path_str = os.getenv("REASONING_TRACES_FILE") + if traces_path_str: + traces_path = Path(traces_path_str) + if traces_path.is_file() and traces_path.stat().st_size > 0: + traces_key = f"{run_prefix}/{traces_path.name}" + try: + s3_client.upload_file( # pyright: ignore + str(traces_path), + s3_bucket, + traces_key, + ExtraArgs={"ContentType": "application/x-ndjson"}, + ) + logger.info(f"Saved reasoning traces to S3 key {traces_key}") + except Exception as e: + logger.error( + f"Failed to upload reasoning traces: {e}", exc_info=True + ) + else: + logger.warning( + f"REASONING_TRACES_FILE={traces_path} is missing or empty; no traces uploaded" + ) try: url: str = s3_client.generate_presigned_url( # pyright: ignore "get_object", - Params={"Bucket": s3_bucket, "Key": key}, + Params={"Bucket": s3_bucket, "Key": results_key}, ExpiresIn=259200, # 3 days ) - logger.info(f"Saved test results to s3://{s3_bucket}/{key}") - return url, key # pyright: ignore + return url, results_key # pyright: ignore except Exception as e: logger.warning(f"Could not generate S3 presigned URL: {e}") - logger.info(f"Saved test results to s3://{s3_bucket}/{key}") - return None, key + return None, results_key except Exception as e: logger.error(f"Failed to save results to S3: {e}", exc_info=True) From 810e25c4878a33c5f53e026e1102d8b6193a5983 Mon Sep 17 00:00:00 2001 From: Aru Sharma <70081536+staru09@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:25:45 +0530 Subject: [PATCH 28/65] fix: checker bug_fix (#840) --- src/utils/agent_tools.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index 404882ba..bac3948a 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -1329,10 +1329,11 @@ async def _handle_create_observations_impl( # normalize so provenance links reference real document IDs. source_ids = obs.get("source_ids") if isinstance(source_ids, list): - obs["source_ids"] = [ - _normalize_observation_id(s) for s in source_ids if isinstance(s, str) - ] - + normalized_source_ids: list[str] = [] + for source_id in cast(list[Any], source_ids): + if isinstance(source_id, str): + normalized_source_ids.append(_normalize_observation_id(source_id)) + obs["source_ids"] = normalized_source_ids # Validate observations individually so valid ones are still processed observations: list[schemas.ObservationInput] = [] validation_failures: list[ObservationFailure] = [] From 60a15e664d7298eb790b788e95c6ca2e6bd30c80 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:44:13 -0400 Subject: [PATCH 29/65] v3.0.11 Release Candidate (#841) * chore(docs): Release Candidate Changelog and Version Updates * chore: fix basedpyright error --- CHANGELOG.md | 20 ++- docs/changelog/compatibility-guide.mdx | 4 +- docs/changelog/introduction.mdx | 31 ++++- docs/docs.json | 2 +- pyproject.toml | 2 +- scripts/update_version.py | 165 +++++++++++++++++++------ uv.lock | 4 +- 7 files changed, 177 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc93dc8..474c198d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,20 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [Unreleased] +## [3.0.11] - 2026-06-24 ### Added -- `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800s) lets sub-threshold representation work units flush once their oldest unprocessed queue item ages out. Set it to `0` to keep the legacy behavior where sub-threshold tails wait indefinitely unless `DERIVER_FLUSH_ENABLED=true`. +- `api_request_duration_seconds` Prometheus histogram tracking per-route request latency, labeled by method and endpoint (#837) +- LLM `provider_params` passthroughs (`extra_body` / `extra_headers` / `extra_query`) are now forwarded to the underlying provider transport across all backends, with shape validation that rejects non-mapping values (#821) +- `structured_output_mode` model-config option to use `json_object` mode for OpenAI-compatible providers that lack native Structured Outputs support (used by the deriver) (#820) +- OpenRouter app-attribution headers (`HTTP-Referer` / `X-Openrouter-Title`) are now sent on OpenAI-compatible clients when the configured base URL is OpenRouter, so requests are attributed to "Honcho" in OpenRouter's dashboard (#805) +- Langfuse traces are now tagged with user and session IDs for easier trace filtering (#814) +- `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800s) lets sub-threshold representation work units flush once their oldest unprocessed queue item ages out. Set it to `0` to keep the legacy behavior where sub-threshold tails wait indefinitely unless `DERIVER_FLUSH_ENABLED=true` (#826) ### Changed -- Peer-scoped JWTs now get read-only access to the sessions their peer is an active member of (session context, summaries, peers, their own per-session config, search, and message reads). Session-scoped JWTs remain confined to their session and cannot reach peer routes. +- Peer-scoped JWTs now get read-only access to the sessions their peer is an active member of (session context, summaries, peers, their own per-session config, search, and message reads). Session-scoped JWTs remain confined to their session and cannot reach peer routes (#679) +- Compacted Honcho's log output, with guarded ms/s metric formatting that falls back to a plain string for non-numeric values (#836) +- Sentry now drops noisy infra/scrape transactions: the reconciler opens a transaction only once a batch has rows (idle cycles emit none), and a `traces_sampler` returns `0.0` for `/metrics`, `/health`, `/openapi.json`, `/docs`, `/redoc`, and the deriver metrics server. `SENTRY.TRACES_SAMPLE_RATE` still governs real traffic (#834) ### Fixed -- Peer- and session-scoped JWTs were effectively workspace-scoped: authorization walked the route's declared scope and fell through to a workspace match, so a `{w, p: alice}` token could act on any peer in the workspace. JWTs are now authorized by their narrowest claim and never widen to workspace access. -- The keys API now rejects creating a peer- or session-scoped key without a workspace. Such keys were minted successfully but failed verification on every request. +- Peer- and session-scoped JWTs were effectively workspace-scoped: authorization walked the route's declared scope and fell through to a workspace match, so a `{w, p: alice}` token could act on any peer in the workspace. JWTs are now authorized by their narrowest claim and never widen to workspace access (#679) +- The keys API now rejects creating a peer- or session-scoped key without a workspace. Such keys were minted successfully but failed verification on every request (#679) +- Agent-supplied observation IDs carrying the display-format `id:` prefix are now normalized (prefix and trailing whitespace stripped) before `source_ids` are stored and on `get_reasoning_chain` lookups, fixing corrupted provenance links and broken reasoning-chain traversal (#795) +- Fixed a `create_tree` keyword-argument mismatch in the Dreamer's surprisal tree construction (#749) +- Providers that omit output-token counts (observed with Gemini on tool-loop completions) returned `output_tokens=None`, which raised a Pydantic validation error that aborted the call and crashed the Dreamer's induction phase before inductive conclusions were persisted. `None` is now coerced to `0` so token accounting degrades gracefully (#809) ## [3.0.10] - 2026-06-15 diff --git a/docs/changelog/compatibility-guide.mdx b/docs/changelog/compatibility-guide.mdx index d1e225b9..42b2f373 100644 --- a/docs/changelog/compatibility-guide.mdx +++ b/docs/changelog/compatibility-guide.mdx @@ -30,7 +30,9 @@ This guide helps you match the right SDK version to your Honcho API version. New | Honcho API Version | TypeScript SDK | Python SDK | |-------------------|---------------|------------| -| v3.0.9 (Current) | v2.1.2 | v2.1.2 | +| v3.0.11 (Current) | v2.1.2 | v2.1.2 | +| v3.0.10 | v2.1.2 | v2.1.2 | +| v3.0.9 | v2.1.2 | v2.1.2 | | v3.0.8 | v2.1.2 | v2.1.2 | | v3.0.7 | v2.1.2 | v2.1.2 | | v3.0.6 | v2.1.1 | v2.1.1 | diff --git a/docs/changelog/introduction.mdx b/docs/changelog/introduction.mdx index c5bc129d..a69d4567 100644 --- a/docs/changelog/introduction.mdx +++ b/docs/changelog/introduction.mdx @@ -27,7 +27,32 @@ Welcome to the Honcho changelog! This section documents all notable changes to t ### Honcho API and SDK Changelogs - + + ### Added + + - `api_request_duration_seconds` Prometheus histogram tracking per-route request latency, labeled by method and endpoint (#837) + - LLM `provider_params` passthroughs (`extra_body` / `extra_headers` / `extra_query`) are now forwarded to the underlying provider transport across all backends, with shape validation that rejects non-mapping values (#821) + - `structured_output_mode` model-config option to use `json_object` mode for OpenAI-compatible providers that lack native Structured Outputs support (used by the deriver) (#820) + - OpenRouter app-attribution headers (`HTTP-Referer` / `X-Openrouter-Title`) are now sent on OpenAI-compatible clients when the configured base URL is OpenRouter, so requests are attributed to "Honcho" in OpenRouter's dashboard (#805) + - Langfuse traces are now tagged with user and session IDs for easier trace filtering (#814) + - `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800s) lets sub-threshold representation work units flush once their oldest unprocessed queue item ages out. Set it to `0` to keep the legacy behavior where sub-threshold tails wait indefinitely unless `DERIVER_FLUSH_ENABLED=true` (#826) + + ### Changed + + - Peer-scoped JWTs now get read-only access to the sessions their peer is an active member of (session context, summaries, peers, their own per-session config, search, and message reads). Session-scoped JWTs remain confined to their session and cannot reach peer routes (#679) + - Compacted Honcho's log output, with guarded ms/s metric formatting that falls back to a plain string for non-numeric values (#836) + - Sentry now drops noisy infra/scrape transactions: the reconciler opens a transaction only once a batch has rows (idle cycles emit none), and a `traces_sampler` returns `0.0` for `/metrics`, `/health`, `/openapi.json`, `/docs`, `/redoc`, and the deriver metrics server. `SENTRY.TRACES_SAMPLE_RATE` still governs real traffic (#834) + + ### Fixed + + - Peer- and session-scoped JWTs were effectively workspace-scoped: authorization walked the route's declared scope and fell through to a workspace match, so a `{w, p: alice}` token could act on any peer in the workspace. JWTs are now authorized by their narrowest claim and never widen to workspace access (#679) + - The keys API now rejects creating a peer- or session-scoped key without a workspace. Such keys were minted successfully but failed verification on every request (#679) + - Agent-supplied observation IDs carrying the display-format `id:` prefix are now normalized (prefix and trailing whitespace stripped) before `source_ids` are stored and on `get_reasoning_chain` lookups, fixing corrupted provenance links and broken reasoning-chain traversal (#795) + - Fixed a `create_tree` keyword-argument mismatch in the Dreamer's surprisal tree construction (#749) + - Providers that omit output-token counts (observed with Gemini on tool-loop completions) returned `output_tokens=None`, which raised a Pydantic validation error that aborted the call and crashed the Dreamer's induction phase before inductive conclusions were persisted. `None` is now coerced to `0` so token accounting degrades gracefully (#809) + + + ### Added - Messages are now embedded via a background task rather than blocking API request @@ -671,7 +696,7 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [Python SDK](https://pypi.org/project/honcho-ai/) - + ### Added - `page`, `size`, and `reverse` pagination parameters on `Honcho.workspaces()` and `HonchoAio.workspaces()`, closing the gap from 2.1.0 which added these to other list methods but not to `workspaces()`. Honoring `reverse` on the workspace/peer/session list routes also requires a Honcho server with the matching API fix; older servers silently ignore the parameter. @@ -823,7 +848,7 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [TypeScript SDK](https://www.npmjs.com/package/@honcho-ai/sdk) - + ### Added - `peers` option on `Honcho.session()` — attach peers to a session at creation time instead of needing a follow-up `session.addPeers()` call. Accepts the same `PeerAddition` shape as `session.addPeers()` (peer ID strings, `Peer` objects, arrays of either, or a record with per-peer `observe_me`/`observe_others` config). diff --git a/docs/docs.json b/docs/docs.json index 669faf96..889ea9e6 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -24,7 +24,7 @@ "navigation": { "versions": [ { - "version": "v3.0.10", + "version": "v3.0.11", "api": { "openapi": ["v3/openapi.json"] }, diff --git a/pyproject.toml b/pyproject.toml index 95a7789c..a79a3d85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho" -version = "3.0.10" +version = "3.0.11" description = "Honcho Server" authors = [ {name = "Plastic Labs", email = "hello@plasticlabs.ai"}, diff --git a/scripts/update_version.py b/scripts/update_version.py index 4d99020b..40416567 100755 --- a/scripts/update_version.py +++ b/scripts/update_version.py @@ -6,6 +6,7 @@ This script helps update version numbers across the Honcho repository. It handles the main API, Python SDK, and TypeScript SDK in a single operation. """ +import argparse import json import os import re @@ -17,11 +18,11 @@ from datetime import datetime class VersionUpdater: def __init__(self, base_path: str): - self.base_path = base_path + self.base_path: str = base_path def get_current_versions(self) -> dict[str, str]: """Get current version numbers from the repository.""" - versions = {} + versions: dict[str, str] = {} # Main API version with open(os.path.join(self.base_path, "pyproject.toml")) as f: @@ -89,7 +90,7 @@ TYPESCRIPT_VERSION= os.unlink(temp_file) # Extract all versions and changelogs - updates = {} + updates: dict[str, dict[str, str]] = {} # Parse API version api_match = re.search(r"^API_VERSION=(.*)$", content, re.MULTILINE) @@ -131,7 +132,7 @@ TYPESCRIPT_VERSION= ) -> str: """Extract changelog content between markers.""" lines = content.split("\n") - changelog_lines = [] + changelog_lines: list[str] = [] in_section = False for line in lines: @@ -158,8 +159,8 @@ TYPESCRIPT_VERSION= """Remove empty changelog sections.""" sections = ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"] lines = changelog.split("\n") - cleaned_lines = [] - current_section = None + cleaned_lines: list[str] = [] + current_section: str | None = None section_has_content = False section_start_idx = -1 for i, line in enumerate(lines): @@ -346,28 +347,30 @@ TYPESCRIPT_VERSION= self._update_compatibility_guide("typescript", new_version) def _update_docs_json(self, new_version: str): - """Update docs.json - only update versions with same major version.""" + """Update docs.json version label(s) sharing the new version's major. + + Uses a targeted regex replacement rather than a JSON round-trip so the + file's existing formatting (compact inline arrays) is preserved instead + of being reflowed. + """ file_path = os.path.join(self.base_path, "docs/docs.json") with open(file_path) as f: - data = json.load(f) + content = f.read() # Get major version of new version new_major = new_version.split(".")[0] - # Update only matching major versions - if "navigation" in data and "versions" in data["navigation"]: - for version_entry in data["navigation"]["versions"]: - if "version" in version_entry: - current_version = version_entry["version"].lstrip("v") - current_major = current_version.split(".")[0] + def _replace(match: re.Match[str]) -> str: + # Only update labels whose major version matches the new version's. + if match.group(1) == new_major: + return f'"version": "v{new_version}"' + return match.group(0) - if current_major == new_major: - version_entry["version"] = f"v{new_version}" + content = re.sub(r'"version": "v(\d+)\.\d+\.\d+"', _replace, content) with open(file_path, "w") as f: - json.dump(data, f, indent=2) - f.write("\n") + f.write(content) def _update_sdk_changelog(self, version: str, changelog: str, relative_path: str): """Update an SDK's CHANGELOG.md file.""" @@ -405,12 +408,39 @@ TYPESCRIPT_VERSION= f.write(new_content) def _update_changelog_md(self, version: str, changelog: str): - """Update the main CHANGELOG.md file.""" + """Update the main CHANGELOG.md file. + + If an ``## [Unreleased]`` section is present, it is promoted to the new + version (its contents replaced by ``changelog``, which the caller is + expected to have already merged). Otherwise a new version entry is + prepended above the most recent release, preserving the legacy behavior. + """ file_path = os.path.join(self.base_path, "CHANGELOG.md") with open(file_path) as f: content = f.read() + date = datetime.now().strftime("%Y-%m-%d") + + # Ensure changelog content is properly formatted + if changelog.strip(): + formatted_changelog = changelog.strip() + else: + formatted_changelog = "### Changed\n\n- Updated version" + + # Promote an existing [Unreleased] section if one exists. Match from the + # "## [Unreleased]" header up to (but not including) the next release + # heading, and replace the whole block with the new version section. + unreleased_re = re.compile( + r"\n## \[Unreleased\][\s\S]*?(?=\n## \[)", re.IGNORECASE + ) + if unreleased_re.search(content): + replacement = f"\n## [{version}] - {date}\n\n{formatted_changelog}\n" + new_content = unreleased_re.sub(replacement, content, count=1) + with open(file_path, "w") as f: + f.write(new_content) + return + # Find the position after the header header_end = content.find("\n## [") if header_end == -1: @@ -420,15 +450,6 @@ TYPESCRIPT_VERSION= # No existing entries, add after title header_end = content.find("\n", content.find("# Changelog")) - # Create new entry with proper formatting - date = datetime.now().strftime("%Y-%m-%d") - - # Ensure changelog content is properly formatted - if changelog.strip(): - formatted_changelog = changelog.strip() - else: - formatted_changelog = "### Changed\n\n- Updated version" - new_entry = f"\n\n## [{version}] - {date}\n\n{formatted_changelog}\n" # Insert the new entry @@ -624,7 +645,68 @@ TYPESCRIPT_VERSION= f.write(content) +def _resolve_changelog(value: str | None) -> str: + """Resolve a changelog argument that is either inline text or a file path.""" + if not value: + return "" + if os.path.isfile(value): + with open(value) as f: + return f.read().strip() + return value.strip() + + +def _updates_from_args(args: argparse.Namespace) -> dict[str, dict[str, str]]: + """Build the updates dict from CLI flags (headless mode).""" + updates: dict[str, dict[str, str]] = {} + if args.api_version: + updates["api"] = { + "version": args.api_version, + "changelog": _resolve_changelog(args.api_changelog), + } + if args.python_version: + updates["python_sdk"] = { + "version": args.python_version, + "changelog": _resolve_changelog(args.python_changelog), + } + if args.typescript_version: + updates["typescript_sdk"] = { + "version": args.typescript_version, + "changelog": _resolve_changelog(args.typescript_changelog), + } + return updates + + def main(): + parser = argparse.ArgumentParser( + description=( + "Update Honcho version numbers and changelogs. With no version " + "flags, opens an interactive editor; pass one or more --*-version " + "flags to run headless (agent-friendly)." + ) + ) + parser.add_argument("--api-version", help="New Main API version.") + parser.add_argument("--python-version", help="New Python SDK version.") + parser.add_argument("--typescript-version", help="New TypeScript SDK version.") + parser.add_argument( + "--api-changelog", + help="API changelog markdown, or a path to a file containing it.", + ) + parser.add_argument( + "--python-changelog", + help="Python SDK changelog markdown, or a path to a file containing it.", + ) + parser.add_argument( + "--typescript-changelog", + help="TypeScript SDK changelog markdown, or a path to a file containing it.", + ) + parser.add_argument( + "-y", + "--yes", + action="store_true", + help="Skip the confirmation prompt (implied in headless mode).", + ) + args = parser.parse_args() + # Get the parent directory of the scripts folder (the project root) script_dir = os.path.dirname(os.path.abspath(__file__)) base_path = os.path.dirname(script_dir) @@ -633,6 +715,8 @@ def main(): # Get current versions current_versions = updater.get_current_versions() + headless = any([args.api_version, args.python_version, args.typescript_version]) + print("Honcho Version Updater") print("=" * 50) print("\nCurrent versions:") @@ -640,12 +724,15 @@ def main(): print(f" Python SDK: {current_versions['python_sdk']}") print(f" TypeScript SDK: {current_versions['typescript_sdk']}") print() - print("Opening editor for version updates...") - print("Leave version fields blank to skip updating that component.") - print() - # Get all updates at once - updates = updater.get_all_versions_from_editor(current_versions) + if headless: + updates = _updates_from_args(args) + else: + print("Opening editor for version updates...") + print("Leave version fields blank to skip updating that component.") + print() + # Get all updates at once + updates = updater.get_all_versions_from_editor(current_versions) if not updates: print("No versions specified. Exiting...") @@ -661,11 +748,12 @@ def main(): }[component] print(f" {component_name}: {current_versions[component]} → {info['version']}") - # Confirm - response = input("\nProceed with updates? (y/n): ").strip().lower() - if response != "y": - print("Cancelled.") - sys.exit(0) + # Confirm (skipped in headless mode or with --yes) + if not headless and not args.yes: + response = input("\nProceed with updates? (y/n): ").strip().lower() + if response != "y": + print("Cancelled.") + sys.exit(0) # Apply all updates updater.update_all(updates, current_versions) @@ -673,6 +761,7 @@ def main(): print("\nVersion updates complete!") print("\nDon't forget to:") print(" - Review the changes with `git diff`") + print(" - Run `uv lock` to refresh the lockfile") print(" - Commit the changes") print(" - Create git tags for the new versions") print(" - Push the changes and tags") diff --git a/uv.lock b/uv.lock index a71283bb..2d0ce1d7 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-10T19:45:16.447512Z" +exclude-newer = "2026-06-19T14:44:24.535479Z" exclude-newer-span = "P5D" [manifest] @@ -1159,7 +1159,7 @@ wheels = [ [[package]] name = "honcho" -version = "3.0.10" +version = "3.0.11" source = { virtual = "." } dependencies = [ { name = "alembic" }, From 2f3a478948719126b9b0b6291f50987b1250cbd6 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:25:26 -0400 Subject: [PATCH 30/65] fix(llm): stop capturing live LLM clients in Langfuse generation spans (#849) * fix(llm): stop capturing live LLM clients in Langfuse generation spans honcho_llm_call_inner is the @observe generation boundary, and default auto-capture serialized every argument into the span input -- including client_override (a live AsyncOpenAI/genai client) and selected_config (which carries api_key). Auto-capture deep-copies the client into a half-constructed object whose teardown raises: - AsyncHttpxClientWrapper ... no attribute '_state' (OpenAI, stderr flood) - BaseApiClient ... no attribute '_http_options' (Gemini, HONCHO-4HA) and it leaked ModelConfig.api_key into traces. Switch from auto-capture (denylist) to explicit annotation (allowlist): disable capture_input/capture_output on the decorator and stamp curated, serializable input (messages) and output (HonchoLLMCallResponse) via the new annotate_current_generation_io helper. Full trace fidelity is preserved; no client object or secret can reach a trace. Fixes HONCHO-4HA Co-Authored-By: Claude Opus 4.8 (1M context) * feat(llm): track call tuning knobs as Langfuse model_parameters Restore full trace fidelity after disabling @observe auto-capture: surface every tuning knob (temperature, max_tokens, tools, reasoning effort, ...) on the generation via model_parameters, sourced from the resolved effective config instead of the raw function args. Use a deny-list, not an allow-list: dump the whole ModelConfig and exclude only secret-bearing fields (api_key, base_url, fallback, provider_params), so new config knobs are traced automatically without keeping a hand-written list in sync. The live client is never passed -- there is no useful trace representation of it and serializing it is what triggered HONCHO-4HA. Adds a deny-list test proving secrets never leak even when the config carries a real api_key/base_url/provider_params (the production override-client path). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(llm): duplicate token usage to Langfuse + skip payload build when disabled Mirror per-call token usage (input, output, prompt-cache read/creation) onto the Langfuse generation via usage_details, so Langfuse renders native tokens and cost in addition to the CloudEvents accounting. Also guard both generation-annotation blocks behind settings.LANGFUSE_PUBLIC_KEY so the model_dump-backed model_parameters payload (and the usage dict) are only built when Langfuse is actually configured (addresses CodeRabbit: the annotate helper no-ops when disabled, but the payload was still being constructed every call). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/llm/executor.py | 124 +++++++++++++++++++++++++++++++++++- src/llm/runtime.py | 43 +++++++++++++ src/telemetry/logging.py | 32 ++++++++-- tests/utils/test_clients.py | 94 ++++++++++++++++++++++++--- 4 files changed, 275 insertions(+), 18 deletions(-) diff --git a/src/llm/executor.py b/src/llm/executor.py index a9fe3703..017ce669 100644 --- a/src/llm/executor.py +++ b/src/llm/executor.py @@ -19,7 +19,7 @@ from typing import Any, Literal, TypeVar, overload from pydantic import BaseModel -from src.config import ModelConfig, ModelTransport +from src.config import ModelConfig, ModelTransport, settings from src.telemetry.logging import conditional_observe from .backend import CompletionResult as BackendCompletionResult @@ -29,6 +29,7 @@ from .registry import CLIENTS, backend_for_provider from .request_builder import execute_completion, execute_stream from .runtime import ( AttemptPlan, + annotate_current_generation_io, annotate_current_langfuse_trace, effective_config_for_call, ) @@ -45,6 +46,82 @@ logger = logging.getLogger(__name__) M = TypeVar("M", bound=BaseModel) +# ModelConfig fields that must NEVER reach a trace: secrets and nested holders +# of secrets. Everything else on the config is a safe tuning knob and is dumped +# automatically — so new knobs get traced without touching this code. Keep this +# a deny-list (small, stable) rather than an allow-list (drifts with the model). +_UNSAFE_CONFIG_FIELDS = frozenset( + { + "api_key", # provider secret + "base_url", # may embed credentials / private host + "fallback", # ResolvedFallbackConfig carries its own api_key/base_url + "provider_params", # opaque dict; can carry auth headers/keys + } +) + + +def _langfuse_model_parameters( + *, + max_tokens: int, + config: ModelConfig, + json_mode: bool, + verbosity: str | None, + stream: bool, + tools: list[dict[str, Any]] | None, + tool_choice: str | dict[str, Any] | None, + response_model: type[BaseModel] | None, +) -> dict[str, Any]: + """Serializable tuning knobs for the Langfuse generation. + + Surfaces everything @observe auto-capture used to show (temperature, tools, + ...) MINUS the live client and the secret-bearing config fields. We dump the + resolved `effective_config` and deny-list only `_UNSAFE_CONFIG_FIELDS`, so a + new ModelConfig knob is traced automatically — no allow-list to keep in sync. + `mode="json"` coerces enums/sub-models to JSON-safe values. See HONCHO-4HA. + """ + params: dict[str, Any] = config.model_dump( + exclude=set(_UNSAFE_CONFIG_FIELDS), exclude_none=True, mode="json" + ) + # Per-call extras that live outside ModelConfig. + params["max_tokens"] = max_tokens + params["stream"] = stream + params["json_mode"] = json_mode + if verbosity is not None: + params["verbosity"] = verbosity + if response_model is not None: + params["response_format"] = response_model.__name__ + if tools: + params["tools"] = [ + t.get("name") or t.get("function", {}).get("name") or "unknown" + for t in tools + ] + if tool_choice is not None: + params["tool_choice"] = ( + tool_choice if isinstance(tool_choice, str) else str(tool_choice) + ) + return params + + +def _langfuse_usage_details(response: HonchoLLMCallResponse[Any]) -> dict[str, int]: + """Token usage duplicated onto the Langfuse generation. + + These counts are also emitted via CloudEvents (LLMCallCompletedEvent), but + we mirror them here so Langfuse renders per-call tokens + cost natively, + including Anthropic-style prompt-cache reads/writes. Zero-valued cache keys + are dropped so non-cached calls stay tidy. Stream calls don't surface token + totals at this layer, so usage is set only on the non-stream path. + """ + usage: dict[str, int] = { + "input": response.input_tokens, + "output": response.output_tokens, + } + if response.cache_read_input_tokens: + usage["cache_read_input_tokens"] = response.cache_read_input_tokens + if response.cache_creation_input_tokens: + usage["cache_creation_input_tokens"] = response.cache_creation_input_tokens + return usage + + def _outcome_from_error( err: BaseException | None, ) -> Literal["success", "error", "cancelled"]: @@ -266,7 +343,18 @@ async def honcho_llm_call_inner( ) -> AsyncIterator[HonchoLLMCallStreamChunk]: ... -@conditional_observe(name="LLM Call", as_type="generation") +@conditional_observe( + name="LLM Call", + as_type="generation", + # Disable @observe auto-capture: it would serialize `client_override` (a + # live AsyncOpenAI/genai client) and `selected_config` (carries api_key) + # into the span. Auto-capture deep-copies the client into a half-built + # object whose teardown raises `_state`/`_http_options` AttributeErrors + # (HONCHO-4HA) and leaks the key. We set curated input/output explicitly + # below via `annotate_current_generation_io`, preserving full fidelity. + capture_input=False, + capture_output=False, +) async def honcho_llm_call_inner( provider: ModelTransport, model: str, @@ -331,6 +419,26 @@ async def honcho_llm_call_inner( thinking_budget_tokens=thinking_budget_tokens, reasoning_effort=reasoning_effort, ) + + # Explicit generation input + tuning knobs (replaces @observe auto-capture, + # which would serialize the live client / api key). Set before the stream + # branch so it lands on the generation span for both paths. Guard on the + # public key so we don't build the (model_dump-backed) payload when Langfuse + # is disabled — the annotate helper no-ops, but the payload still costs. + if settings.LANGFUSE_PUBLIC_KEY: + annotate_current_generation_io( + input=messages, + model_parameters=_langfuse_model_parameters( + max_tokens=max_tokens, + config=effective_config, + json_mode=json_mode, + verbosity=verbosity, + stream=stream, + tools=tools, + tool_choice=tool_choice, + response_model=response_model, + ), + ) # json_mode + verbosity are per-call transport toggles, not ModelConfig # knobs — they pass through extra_params. execute_completion merges # build_config_extra_params(effective_config) on top for top_p/seed/etc. @@ -416,7 +524,17 @@ async def honcho_llm_call_inner( cache_policy=effective_config.cache_policy, extra_params=call_extras, ) - return completion_result_to_response(backend_result) + response = completion_result_to_response(backend_result) + # Explicit generation output + token usage (replaces @observe + # auto-capture). The stream path closes this span before drain, so its + # output is stamped on the run-level span instead + # (StreamingResponseWithMetadata). + if settings.LANGFUSE_PUBLIC_KEY: + annotate_current_generation_io( + output=response, + usage_details=_langfuse_usage_details(response), + ) + return response except BaseException as exc: error = exc raise diff --git a/src/llm/runtime.py b/src/llm/runtime.py index ed01229d..ec07acf8 100644 --- a/src/llm/runtime.py +++ b/src/llm/runtime.py @@ -102,6 +102,48 @@ def annotate_current_langfuse_trace( logger.debug("Failed to update Langfuse trace metadata: %s", exc) +def annotate_current_generation_io( + *, + input: Any = None, # noqa: A002 - mirrors langfuse's `input` kwarg name + output: Any = None, + model_parameters: dict[str, Any] | None = None, + usage_details: dict[str, Any] | None = None, +) -> None: + """Set explicit input/output/model_parameters/usage on the current generation. + + Used in place of ``@observe``'s auto-capture (disabled on + ``honcho_llm_call_inner``) so the provider client and api-key-bearing + ``ModelConfig`` arguments are never serialized into traces. Auto-capture + deep-copies those args, producing half-constructed clients whose teardown + raised ``AsyncHttpxClientWrapper ... no attribute '_state'`` / + ``BaseApiClient ... no attribute '_http_options'`` (HONCHO-4HA) and leaked + ``ModelConfig.api_key``. We instead hand Langfuse curated, serializable + values: ``messages`` in, response out, and the call's tuning knobs as + ``model_parameters`` — preserving (and tidying) full trace fidelity. + + Best-effort: telemetry must never fail the LLM call. + """ + if not settings.LANGFUSE_PUBLIC_KEY: + return + payload: dict[str, Any] = {} + if input is not None: + payload["input"] = input + if output is not None: + payload["output"] = output + if model_parameters: + payload["model_parameters"] = model_parameters + if usage_details: + payload["usage_details"] = usage_details + if not payload: + return + try: + from langfuse import get_client + + get_client().update_current_generation(**payload) + except Exception as exc: # pragma: no cover - best-effort telemetry + logger.debug("Failed to set Langfuse generation IO: %s", exc) + + def _base_metadata(telemetry: LLMTelemetryContext) -> dict[str, str]: """Static routing/attribution metadata (everything except ``iteration``). @@ -480,6 +522,7 @@ __all__ = [ "AttemptPlan", "LangfuseAgentRun", "LangfuseAgentStep", + "annotate_current_generation_io", "annotate_current_langfuse_trace", "current_attempt", "effective_config_for_call", diff --git a/src/telemetry/logging.py b/src/telemetry/logging.py index 441b1f96..b653952e 100644 --- a/src/telemetry/logging.py +++ b/src/telemetry/logging.py @@ -61,6 +61,8 @@ def conditional_observe( *, name: str | None = None, as_type: ObserveAsType | None = None, + capture_input: bool | None = None, + capture_output: bool | None = None, ) -> Callable[[Callable[P, R]], Callable[P, R]]: ... @@ -69,6 +71,8 @@ def conditional_observe( *, name: str | None = None, as_type: ObserveAsType | None = None, + capture_input: bool | None = None, + capture_output: bool | None = None, ) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]: """ Conditionally apply the @observe decorator only when LANGFUSE_PUBLIC_KEY is present. @@ -82,19 +86,35 @@ def conditional_observe( name: Optional name for the observation (when used as @conditional_observe(name="...")) as_type: Optional Langfuse observation type (e.g. "generation", "tool"). When omitted, Langfuse infers a default span. + capture_input: When ``False``, Langfuse does NOT auto-serialize the + function's arguments into the span input. Set this on functions that + receive live SDK clients or secret-bearing config as parameters + (e.g. the LLM executor): auto-capture would deep-copy those clients + into throwaway, half-constructed objects whose GC raises + ``AsyncHttpxClientWrapper ... no attribute '_state'`` / + ``BaseApiClient ... no attribute '_http_options'`` (see HONCHO-4HA), + and would also leak ``ModelConfig.api_key`` into traces. Pair with an + explicit ``update_current_generation(input=...)`` call to keep + full-fidelity input. ``None`` leaves the SDK default (capture on). + capture_output: When ``False``, Langfuse does NOT auto-serialize the + return value. Pair with an explicit + ``update_current_generation(output=...)``. ``None`` = SDK default. Returns: The decorated function if Langfuse is configured, otherwise the original function """ def decorator(f: Callable[P, R]) -> Callable[P, R]: - if settings.LANGFUSE_PUBLIC_KEY: - observe_name = name if name is not None else f.__name__ - if as_type is not None: - return observe(name=observe_name, as_type=as_type)(f) - return observe(name=observe_name)(f) - else: + if not settings.LANGFUSE_PUBLIC_KEY: return f + # `observe` treats None as "use SDK default", so passing the optionals + # straight through is equivalent to omitting them. + return observe( + name=name if name is not None else f.__name__, + as_type=as_type, + capture_input=capture_input, + capture_output=capture_output, + )(f) if func is not None: # Used as @conditional_observe (without parentheses) diff --git a/tests/utils/test_clients.py b/tests/utils/test_clients.py index 1567eef8..319e5f1f 100644 --- a/tests/utils/test_clients.py +++ b/tests/utils/test_clients.py @@ -960,11 +960,29 @@ class TestMainLLMCallFunction: assert captured["metadata"]["provider"] == "anthropic" assert captured["metadata"]["model"] == "claude-4-sonnet" # ...and the generation is named + carries per-call model/metadata. - mock_langfuse_client.update_current_generation.assert_called_once() - gen_kwargs = mock_langfuse_client.update_current_generation.call_args.kwargs - assert gen_kwargs["name"] == "Dialectic Agent LLM call" - assert gen_kwargs["model"] == "claude-4-sonnet" - assert gen_kwargs["metadata"]["provider"] == "anthropic" + gen_calls = mock_langfuse_client.update_current_generation.call_args_list + meta_kwargs = next(c.kwargs for c in gen_calls if "model" in c.kwargs) + assert meta_kwargs["name"] == "Dialectic Agent LLM call" + assert meta_kwargs["model"] == "claude-4-sonnet" + assert meta_kwargs["metadata"]["provider"] == "anthropic" + # Input/output are stamped explicitly: @observe auto-capture is + # disabled so the live client / api-key-bearing config never reach + # the trace (HONCHO-4HA), with no loss of trace fidelity. + input_kwargs = next(c.kwargs for c in gen_calls if "input" in c.kwargs) + assert input_kwargs["input"] == [{"role": "user", "content": "Hello"}] + output_kwargs = next(c.kwargs for c in gen_calls if "output" in c.kwargs) + assert output_kwargs["output"].content == "Named response" + # Token usage is duplicated onto the generation (also in CloudEvents) + # so Langfuse renders native per-call tokens + cost. + assert output_kwargs["usage_details"]["input"] == 5 + assert output_kwargs["usage_details"]["output"] == 5 + # Tuning knobs are tracked as model_parameters (not the live client + # or api-key-bearing config). No serialized client/secret anywhere. + params = next(c.kwargs for c in gen_calls if "model_parameters" in c.kwargs) + assert params["model_parameters"]["max_tokens"] == 100 + assert params["model_parameters"]["stream"] is False + assert "client_override" not in params["model_parameters"] + assert "api_key" not in params["model_parameters"] async def test_no_telemetry_still_stamps_trace_without_name(self): """Without telemetry, propagate_attributes still fires with namespace @@ -1010,10 +1028,68 @@ class TestMainLLMCallFunction: assert captured["metadata"]["provider"] == "anthropic" # Generation gets model + metadata even without a track_name — only # the name kwarg stays None. - mock_langfuse_client.update_current_generation.assert_called_once() - gen_kwargs = mock_langfuse_client.update_current_generation.call_args.kwargs - assert gen_kwargs["name"] is None - assert gen_kwargs["model"] == "claude-4-sonnet" + gen_calls = mock_langfuse_client.update_current_generation.call_args_list + meta_kwargs = next(c.kwargs for c in gen_calls if "model" in c.kwargs) + assert meta_kwargs["name"] is None + assert meta_kwargs["model"] == "claude-4-sonnet" + # Input/output stamped explicitly (auto-capture disabled; HONCHO-4HA). + input_kwargs = next(c.kwargs for c in gen_calls if "input" in c.kwargs) + assert input_kwargs["input"] == [{"role": "user", "content": "Hello"}] + output_kwargs = next(c.kwargs for c in gen_calls if "output" in c.kwargs) + assert output_kwargs["output"].content == "Unnamed response" + + +class TestLangfuseModelParameters: + """`_langfuse_model_parameters` is the deny-list seam that keeps secrets and + live clients out of Langfuse traces while still surfacing every tuning knob + (HONCHO-4HA). It dumps the config and excludes only secret-bearing fields, so + new knobs are traced automatically without an allow-list to maintain.""" + + def test_secret_fields_never_leak_but_knobs_do(self): + from src.config import ModelConfig + from src.llm.executor import ( + _langfuse_model_parameters, # pyright: ignore[reportPrivateUsage] + ) + + # A config shaped like the production override path: real api_key / + # base_url / nested fallback / opaque provider_params. + config = ModelConfig( + model="gpt-4o", + transport="openai", + api_key="sk-super-secret", + base_url="https://user:pw@private.host/v1", + temperature=0.7, + provider_params={"x-internal-auth": "leak-me"}, + ) + + params = _langfuse_model_parameters( + max_tokens=256, + config=config, + json_mode=True, + verbosity=None, + stream=False, + tools=[{"name": "search_memory"}], + tool_choice="auto", + response_model=None, + ) + + # Secrets and their nested holders are excluded entirely... + assert "api_key" not in params + assert "base_url" not in params + assert "fallback" not in params + assert "provider_params" not in params + # ...and no value anywhere echoes a secret. + flat = str(params) + assert "sk-super-secret" not in flat + assert "leak-me" not in flat + assert "private.host" not in flat + # Tuning knobs (config-derived + per-call) are still tracked. + assert params["model"] == "gpt-4o" + assert params["temperature"] == 0.7 + assert params["max_tokens"] == 256 + assert params["json_mode"] is True + assert params["tools"] == ["search_memory"] + assert params["tool_choice"] == "auto" class TestEdgeCases: From eb386c3ceb77774b29108f9ab114e71d52b7d420 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:48:02 -0400 Subject: [PATCH 31/65] fix: translate canonical tool_choice in OpenAI backend for cross-provider fallback (#850) The OpenAI backend passed tool_choice through raw while the Anthropic and Gemini backends translate Honcho's canonical vocabulary to their native form. On a mixed-provider fallback chain (e.g. Gemini primary -> OpenAI backup), a canonical "any" reached OpenAI unchanged and was rejected as an invalid param, since OpenAI only accepts none/auto/required. Add a _convert_tool_choice to the OpenAI backend mirroring the others so a single TOOL_CHOICE value resolves correctly regardless of which provider a fallback lands on. "any"/"required" -> "required", auto/none pass through, a tool-name string or {"name": ...} dict -> a function selection. Co-authored-by: Claude Opus 4.8 (1M context) --- src/llm/backends/openai.py | 28 +++++++++++- tests/llm/test_backends/test_openai.py | 61 ++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/llm/backends/openai.py b/src/llm/backends/openai.py index 05ad1216..92f13385 100644 --- a/src/llm/backends/openai.py +++ b/src/llm/backends/openai.py @@ -352,8 +352,9 @@ class OpenAIBackend: params["stop"] = stop if tools: params["tools"] = self._convert_tools(tools) - if tool_choice is not None: - params["tool_choice"] = tool_choice + converted_tool_choice = self._convert_tool_choice(tool_choice) + if converted_tool_choice is not None: + params["tool_choice"] = converted_tool_choice if extra_params: for key in ( "top_p", @@ -508,6 +509,29 @@ class OpenAIBackend: except ValidationError: return "" + @staticmethod + def _convert_tool_choice( + tool_choice: str | dict[str, Any] | None, + ) -> str | dict[str, Any] | None: + # Translate Honcho's canonical tool_choice vocabulary to OpenAI's. This + # mirrors the Anthropic/Gemini backends so a single TOOL_CHOICE value + # works regardless of which provider a fallback chain lands on. Notably + # OpenAI has no "any" — it spells the same intent "required". + if tool_choice is None: + return None + if isinstance(tool_choice, dict): + if "name" in tool_choice: + return { + "type": "function", + "function": {"name": tool_choice["name"]}, + } + return tool_choice + if tool_choice in {"any", "required"}: + return "required" + if tool_choice in {"auto", "none"}: + return tool_choice + return {"type": "function", "function": {"name": tool_choice}} + @staticmethod def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: if not tools or tools[0].get("type") == "function": diff --git a/tests/llm/test_backends/test_openai.py b/tests/llm/test_backends/test_openai.py index ec00306f..4270ebd8 100644 --- a/tests/llm/test_backends/test_openai.py +++ b/tests/llm/test_backends/test_openai.py @@ -504,6 +504,67 @@ async def test_openai_backend_converts_anthropic_style_tools() -> None: assert call["tool_choice"] == "required" +async def test_openai_backend_translates_canonical_any_tool_choice_to_required() -> ( + None +): + """Regression: a Gemini→OpenAI fallback passes canonical "any", which OpenAI + rejects as an invalid param. The backend must translate it to "required".""" + client = Mock() + client.chat.completions.create = AsyncMock( + return_value=SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason="stop", + message=SimpleNamespace( + content="ok", + tool_calls=[], + reasoning_details=[], + ), + ) + ], + usage=SimpleNamespace( + prompt_tokens=10, + completion_tokens=5, + prompt_tokens_details=None, + ), + ) + ) + + backend = OpenAIBackend(client) + await backend.complete( + model="gpt-4.1", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + tools=[ + { + "name": "get_weather", + "description": "Lookup weather", + "input_schema": {"type": "object", "properties": {}}, + } + ], + tool_choice="any", + ) + + assert _await_kwargs(client.chat.completions.create)["tool_choice"] == "required" + + +@pytest.mark.parametrize( + ("canonical", "expected"), + [ + ("any", "required"), + ("required", "required"), + ("auto", "auto"), + ("none", "none"), + (None, None), + ("search", {"type": "function", "function": {"name": "search"}}), + ({"name": "search"}, {"type": "function", "function": {"name": "search"}}), + ({"type": "function"}, {"type": "function"}), + ], +) +def test_openai_convert_tool_choice(canonical: Any, expected: Any) -> None: + assert OpenAIBackend._convert_tool_choice(canonical) == expected # pyright: ignore[reportPrivateUsage] + + @pytest.mark.parametrize( "model", [ From 2583c126f087c4a3049fc46c2a520e902af58475 Mon Sep 17 00:00:00 2001 From: Rajat Ahuja Date: Wed, 1 Jul 2026 10:42:56 -0400 Subject: [PATCH 32/65] feat: add exact content deduplication in document creation (#861) * feat: add exact content deduplication in document creation * feat: add comment for index * fix: harden times_derived logic across all callers to use max of inputs and existing + 1 --------- Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> --- src/crud/document.py | 98 ++++++++++- tests/crud/test_document.py | 332 ++++++++++++++++++++++++++++++++++++ 2 files changed, 426 insertions(+), 4 deletions(-) diff --git a/src/crud/document.py b/src/crud/document.py index 11eb121d..ed381340 100644 --- a/src/crud/document.py +++ b/src/crud/document.py @@ -428,6 +428,19 @@ async def query_documents( return docs +def _normalize_content(content: str) -> str: + """Normalize document content for exact-match deduplication. + + Content is compared after trimming surrounding whitespace and lowercasing + + The SQL filter in ``create_documents`` must stay in sync with this: + ``lower(regexp_replace(content, '^\\s+|\\s+$', '', 'g'))``. Postgres' + ``trim()`` only strips spaces, so a regex is used to match Python's + ``str.strip()`` across all whitespace. + """ + return content.strip().lower() + + async def create_documents( db: AsyncSession, documents: list[schemas.DocumentCreate], @@ -440,12 +453,17 @@ async def create_documents( """ Create multiple documents with optional duplicate detection. + The ``deduplicate`` flag additionally enables semantic (cosine-similarity) + dedup via ``is_rejected_duplicate`` for documents that survive the exact + deduplication check. + Args: db: Database session documents: List of document creation schemas workspace_name: Name of the workspace observer: Name of the observing peer observed: Name of the observed peer + deduplicate: Enable semantic duplicate detection Returns: List of DocumentCreate schemas that were actually inserted (excludes @@ -456,8 +474,76 @@ async def create_documents( # Store (document_model, embedding) pairs - IDs aren't available until after commit docs_with_embeddings: list[tuple[models.Document, list[float]]] = [] + # exact-content dedup (independent of `deduplicate`): pre-fetch + # existing live documents whose normalized content matches anything in this + # batch, scoped to (workspace, observer, observed). The SQL normalization must + # mirror _normalize_content. + batch_normalized: set[str] = {_normalize_content(d.content) for d in documents} + existing_by_normalized: dict[str, models.Document] = {} + if batch_normalized: + # The `normalized_content_sql.in_(...)` filter below narrows to the + # (workspace, observer, observed) partition via the single-column indexes, + # then evaluates lower(regexp_replace(...)) per row. + # TODO: add a partial expression index matching + # this filter exactly + # CREATE INDEX ix_documents_normalized_content + # ON documents ( + # workspace_name, + # observer, + # observed, + # (lower(regexp_replace(content, '^\s+|\s+$', '', 'g'))) + # ) + # WHERE deleted_at IS NULL; + normalized_content_sql = func.lower( + func.regexp_replace(models.Document.content, r"^\s+|\s+$", "", "g") + ) + existing_result = await db.execute( + select(models.Document).where( + models.Document.workspace_name == workspace_name, + models.Document.observer == observer, + models.Document.observed == observed, + models.Document.deleted_at.is_(None), + normalized_content_sql.in_(batch_normalized), + ) + ) + for existing_doc in existing_result.scalars(): + # If multiple historical rows share normalized content, reinforcing + # one is sufficient; keep the first. + existing_by_normalized.setdefault( + _normalize_content(existing_doc.content), existing_doc + ) + + # Tracks normalized content already accepted from this batch so exact + # duplicates within a single inference call collapse to one document. + seen_in_batch: set[str] = set() + for doc in documents: try: + normalized_content = _normalize_content(doc.content) + + # Exact-match dedup, always on: + # 1) collapse exact duplicates within this batch (drop silently). + if normalized_content in seen_in_batch: + continue + seen_in_batch.add(normalized_content) + + # 2) drop exact duplicates of an existing live document, recording + # the re-derivation as reinforcement on the existing row. + existing_match = existing_by_normalized.get(normalized_content) + if existing_match is not None: + # Reinforce the existing row. greatest(...) keeps the bump atomic + # server-side (concurrent workers can't lose an increment) while + # still honoring an incoming doc that already carries accumulated + # reinforcement (times_derived > 1, e.g. a future re-ingestion or + # collection-merge path). Mirrors the superior-replacement branch + # in is_rejected_duplicate. + existing_match.times_derived = func.greatest( + models.Document.times_derived + 1, + doc.times_derived, + ) + await db.flush() + continue + # for each document, if deduplicate is True, perform a process # that checks against existing documents and either rejects this document # as a duplicate OR deletes an existing document that is a duplicate. @@ -1038,10 +1124,14 @@ async def is_rejected_duplicate( return False # Don't reject the new document # Existing document has more information, reject the new one but record the - # reinforcement: a semantic duplicate was derived again. Assign a SQL - # expression so the increment is atomic server-side -- concurrent workers - # reinforcing the same document must not lose updates. - existing_doc.times_derived = models.Document.times_derived + 1 + # reinforcement: a semantic duplicate was derived again. greatest(...) keeps + # the increment atomic server-side -- concurrent workers reinforcing the same + # document must not lose updates -- while still honoring an incoming doc that + # already carries accumulated reinforcement (times_derived > 1). + existing_doc.times_derived = func.greatest( + models.Document.times_derived + 1, + doc.times_derived, + ) await db.flush() logger.debug( "[DUPLICATE DETECTION] Rejecting new in favor of existing. new=%r, existing=%r.", diff --git a/tests/crud/test_document.py b/tests/crud/test_document.py index ccde3dac..1258b522 100644 --- a/tests/crud/test_document.py +++ b/tests/crud/test_document.py @@ -465,6 +465,338 @@ class TestDocumentCRUD: # Original is soft-deleted; replacement isn't inserted until create_documents runs. assert len(live) == 0 + @pytest.mark.asyncio + async def test_exact_dedup_within_batch_drops_repeat( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """Exact (case/whitespace-insensitive) duplicates within a single batch + collapse to one document, even with semantic dedup disabled.""" + test_workspace, test_peer = sample_data + test_peer2, test_session, _ = await self._setup_test_data( + db_session, test_workspace, test_peer + ) + + # Three "exact" matches that differ only by case/surrounding whitespace. + doc_schemas = [ + schemas.DocumentCreate( + content="User likes coffee", + embedding=[0.1] * 1536, + session_name=test_session.name, + metadata=schemas.DocumentMetadata( + message_ids=[1], + message_created_at="2026-01-01T00:00:00Z", + ), + ), + schemas.DocumentCreate( + content="user likes coffee", + embedding=[0.2] * 1536, + session_name=test_session.name, + metadata=schemas.DocumentMetadata( + message_ids=[2], + message_created_at="2026-01-01T00:01:00Z", + ), + ), + schemas.DocumentCreate( + content=" User likes coffee\n", + embedding=[0.3] * 1536, + session_name=test_session.name, + metadata=schemas.DocumentMetadata( + message_ids=[3], + message_created_at="2026-01-01T00:02:00Z", + ), + ), + ] + + accepted = await crud.create_documents( + db_session, + documents=doc_schemas, + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + deduplicate=False, + ) + + assert len(accepted) == 1 + live = ( + ( + await db_session.execute( + select(models.Document).where( + models.Document.workspace_name == test_workspace.name, + models.Document.observer == test_peer.name, + models.Document.observed == test_peer2.name, + models.Document.deleted_at.is_(None), + ) + ) + ) + .scalars() + .all() + ) + assert len(live) == 1 + # Within-batch repeats are dropped silently, no reinforcement. + assert live[0].times_derived == 1 + + @pytest.mark.asyncio + async def test_exact_dedup_against_existing_reinforces( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """An exact match of an existing live document is rejected and reinforces + the existing row, even with semantic dedup disabled.""" + test_workspace, test_peer = sample_data + test_peer2, test_session, _ = await self._setup_test_data( + db_session, test_workspace, test_peer + ) + + await crud.create_documents( + db_session, + [ + schemas.DocumentCreate( + content="User likes coffee", + embedding=[0.1] * 1536, + session_name=test_session.name, + times_derived=1, + metadata=schemas.DocumentMetadata( + message_ids=[1], + message_created_at="2026-01-01T00:00:00Z", + ), + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + deduplicate=False, + ) + + # Case/whitespace variant of the existing content -> exact match. + accepted = await crud.create_documents( + db_session, + [ + schemas.DocumentCreate( + content="user likes coffee ", + embedding=[0.9] * 1536, + session_name=test_session.name, + times_derived=1, + metadata=schemas.DocumentMetadata( + message_ids=[2], + message_created_at="2026-01-02T00:00:00Z", + ), + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + deduplicate=False, + ) + + assert len(accepted) == 0 + surviving = ( + ( + await db_session.execute( + select(models.Document).where( + models.Document.workspace_name == test_workspace.name, + models.Document.observer == test_peer.name, + models.Document.observed == test_peer2.name, + models.Document.deleted_at.is_(None), + ) + ) + ) + .scalars() + .all() + ) + assert len(surviving) == 1 + assert surviving[0].content == "User likes coffee" + assert surviving[0].times_derived == 2 + + @pytest.mark.asyncio + async def test_exact_dedup_honors_incoming_times_derived( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """Reinforcement folds in an incoming doc that already carries + accumulated reinforcement: the existing row becomes + ``greatest(existing + 1, incoming)``.""" + test_workspace, test_peer = sample_data + test_peer2, test_session, _ = await self._setup_test_data( + db_session, test_workspace, test_peer + ) + + async def _live() -> list[models.Document]: + return list( + ( + await db_session.execute( + select(models.Document).where( + models.Document.workspace_name == test_workspace.name, + models.Document.observer == test_peer.name, + models.Document.observed == test_peer2.name, + models.Document.deleted_at.is_(None), + ) + ) + ) + .scalars() + .all() + ) + + # Existing row already reinforced twice. + await crud.create_documents( + db_session, + [ + schemas.DocumentCreate( + content="User likes coffee", + embedding=[0.1] * 1536, + session_name=test_session.name, + times_derived=2, + metadata=schemas.DocumentMetadata( + message_ids=[1], + message_created_at="2026-01-01T00:00:00Z", + ), + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + deduplicate=False, + ) + + # Incoming exact match claims more accumulated reinforcement (5) than + # existing + 1 (3) -> incoming wins. + accepted = await crud.create_documents( + db_session, + [ + schemas.DocumentCreate( + content="user likes coffee ", + embedding=[0.9] * 1536, + session_name=test_session.name, + times_derived=5, + metadata=schemas.DocumentMetadata( + message_ids=[2], + message_created_at="2026-01-02T00:00:00Z", + ), + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + deduplicate=False, + ) + assert len(accepted) == 0 + live = await _live() + assert len(live) == 1 + assert live[0].times_derived == 5 + + # A normal re-derivation (times_derived defaults to 1) now bumps by one: + # greatest(existing + 1, 1) -> existing + 1. + accepted = await crud.create_documents( + db_session, + [ + schemas.DocumentCreate( + content="USER LIKES COFFEE", + embedding=[0.4] * 1536, + session_name=test_session.name, + metadata=schemas.DocumentMetadata( + message_ids=[3], + message_created_at="2026-01-03T00:00:00Z", + ), + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + deduplicate=False, + ) + assert len(accepted) == 0 + live = await _live() + assert len(live) == 1 + assert live[0].times_derived == 6 + + @pytest.mark.asyncio + async def test_exact_dedup_flushes_before_semantic_replacement( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """An exact-match reinforcement in a batch must be visible to a later + semantic replacement of the same existing row when autoflush is off.""" + test_workspace, test_peer = sample_data + test_peer2, test_session, _ = await self._setup_test_data( + db_session, test_workspace, test_peer + ) + + await crud.create_documents( + db_session, + [ + schemas.DocumentCreate( + content="User likes coffee", + embedding=[0.5] * 1536, + session_name=test_session.name, + times_derived=1, + metadata=schemas.DocumentMetadata( + message_ids=[1], + message_created_at="2026-01-01T00:00:00Z", + ), + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + deduplicate=False, + ) + + db_session.autoflush = False + accepted = await crud.create_documents( + db_session, + [ + schemas.DocumentCreate( + content=" user likes coffee ", + embedding=[0.5] * 1536, + session_name=test_session.name, + times_derived=1, + metadata=schemas.DocumentMetadata( + message_ids=[2], + message_created_at="2026-01-02T00:00:00Z", + ), + ), + schemas.DocumentCreate( + content="User likes coffee and tea", + embedding=[0.5] * 1536, + session_name=test_session.name, + times_derived=1, + metadata=schemas.DocumentMetadata( + message_ids=[3], + message_created_at="2026-01-03T00:00:00Z", + ), + ), + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + deduplicate=True, + ) + + assert len(accepted) == 1 + assert accepted[0].content == "User likes coffee and tea" + + surviving = ( + ( + await db_session.execute( + select(models.Document).where( + models.Document.workspace_name == test_workspace.name, + models.Document.observer == test_peer.name, + models.Document.observed == test_peer2.name, + models.Document.deleted_at.is_(None), + ) + ) + ) + .scalars() + .all() + ) + assert len(surviving) == 1 + assert surviving[0].content == "User likes coffee and tea" + assert surviving[0].times_derived == 3 + @pytest.mark.asyncio async def test_delete_document_success( self, From 14538cfc906c1d209983f69c3a703485b452f3c4 Mon Sep 17 00:00:00 2001 From: ajspig <46900795+ajspig@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:48:01 -0400 Subject: [PATCH 33/65] Abigail/conclusions level filter (#851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(conclusions): expose reasoning level + allow filtering by level The `level` of a conclusion (explicit / deductive / inductive / contradiction) was filterable server-side but stripped from the `Conclusion` response and not surfaced in either SDK. This adds it end-to-end so callers can list explicit-only ("not dreamed on") conclusions without dropping to raw HTTP. - api: add `level` to the Conclusion response schema - python sdk: `ConclusionLevel` type, `level` on Conclusion/response, `level=` kwarg on ConclusionScope.list() and the async variant - ts sdk: `ConclusionLevel` type, `level` on Conclusion/response, `level` option on list() - tests: assert level is exposed; add level-filter list test Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(conclusions): use generic filters= on list() instead of level= kwarg Match the documented SDK convention (peers/sessions/messages all take a generic `filters` dict passed through to the same dynamic server-side filter logic) instead of a one-off `level=` kwarg. `level` filtering now works as `list(filters={"level": "explicit"})` alongside any other supported filter/operator. The `level` field on the Conclusion response (added in the previous commit) is kept — it's still not otherwise returned by the API. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(conclusions): allow filtering by level on query() in py + ts SDKs The branch's level-filter work exposed `filters=` on `list()` but left `query()` (semantic search) hardcoding `{observer, observed}`, so callers could filter the list endpoint by reasoning level but not semantic search — asymmetric in both SDKs. - Python: add keyword-only `filters` to `ConclusionScope.query` and `ConclusionScopeAio.query`, merged over the scope's observer/observed. - TypeScript: add optional `filters` arg to `ConclusionScope.query`, mirroring the existing `list()` change. The server `/conclusions/query` endpoint already honors filters in the body (verified against production), so this is purely SDK surface parity. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(filters): document filtering conclusions by reasoning level The using-filters page covered workspaces/peers/sessions/messages but not conclusions. Add a "Filtering Conclusions" section showing level-based filtering on both list() and query(), including the common "explicit only" (exclude dream-derived) case and the in[deductive,inductive] inverse. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(conclusions): simplify filter merge to a single dict spread Replace the merged_filters + if-block pattern in list()/query() (py sync, aio, ts) with a single dict spread that layers the caller's filters over the scope's observer/observed (and session). No behavior change — same merge order (caller wins) — just less code. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(conclusions): reject scope-managed keys in SDK conclusion filters The generic filters= argument on ConclusionScope.list()/query() spread user-supplied filters last, so a stray observer/observed/session key silently overrode the scope and returned data from a different peer pair. Add a fail-loud guard in both the Python and TypeScript SDKs that rejects scope-managed filter keys with a clear error, directing callers to peer.conclusions / conclusions_of(target) and the session= parameter. session_id remains a valid filter on query() (which has no dedicated session parameter). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> --- .../features/advanced/using-filters.mdx | 59 ++++++++++++ sdks/python/src/honcho/aio.py | 41 +++++++-- sdks/python/src/honcho/api_types.py | 5 + sdks/python/src/honcho/conclusions.py | 64 ++++++++++++- sdks/typescript/__tests__/conclusions.test.ts | 55 +++++++++++ sdks/typescript/src/conclusions.ts | 92 ++++++++++++++++--- sdks/typescript/src/index.ts | 1 + sdks/typescript/src/types/api.ts | 11 +++ src/schemas/api.py | 9 ++ tests/routes/test_conclusions.py | 75 +++++++++++++++ tests/sdk/test_conclusions.py | 70 ++++++++++++++ 11 files changed, 458 insertions(+), 24 deletions(-) diff --git a/docs/v3/documentation/features/advanced/using-filters.mdx b/docs/v3/documentation/features/advanced/using-filters.mdx index 306312ce..2a015c03 100644 --- a/docs/v3/documentation/features/advanced/using-filters.mdx +++ b/docs/v3/documentation/features/advanced/using-filters.mdx @@ -614,6 +614,65 @@ messages = session.messages(filters={ ``` +### Filtering Conclusions + +Conclusions are scoped to an observer/observed peer pair (accessed via +`peer.conclusions` for self-conclusions or `peer.conclusions_of(target)` for +conclusions about another peer). The observer and observed are filled in +automatically by the scope, so the `filters` you pass add to them. + +The most useful conclusion-specific field is `level`, the reasoning level: + +- `explicit` — extracted directly from messages +- `deductive` / `inductive` / `contradiction` — derived later during dreaming + +A common request is to surface only the directly-stated facts and exclude +anything inferred during dreaming — filter `level` to `explicit`: + + +```python Python +# Only conclusions extracted directly from messages (exclude dream-derived) +explicit = peer.conclusions.list(filters={"level": "explicit"}) + +# Only dream-derived conclusions +derived = peer.conclusions.list(filters={"level": {"in": ["deductive", "inductive"]}}) + +# Same filtering on semantic search +results = peer.conclusions.query( + "food preferences", + filters={"level": "deductive"}, +) + +# Conclusions about another peer, explicit only +bob_explicit = peer.conclusions_of("bob").list(filters={"level": "explicit"}) +``` + +```typescript TypeScript +(async () => { + // Only conclusions extracted directly from messages (exclude dream-derived) + const explicit = await peer.conclusions.list({ filters: { level: "explicit" } }); + + // Only dream-derived conclusions + const derived = await peer.conclusions.list({ + filters: { level: { in: ["deductive", "inductive"] } } + }); + + // Same filtering on semantic search (query, topK, distance, filters) + const results = await peer.conclusions.query( + "food preferences", + 10, + undefined, + { level: "deductive" } + ); + + // Conclusions about another peer, explicit only + const bobExplicit = await peer.conclusionsOf("bob").list({ + filters: { level: "explicit" } + }); +})(); +``` + + ## Error Handling Handle filter errors gracefully: diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py index 9ff27a12..2cf39ab1 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -47,7 +47,11 @@ from .api_types import ( WorkspaceResponse, ) from .base import PeerBase, SessionBase -from .conclusions import Conclusion +from .conclusions import ( + _SCOPE_RESERVED, + Conclusion, + _reject_reserved_filter_keys, +) from .http import routes from .message import Message from .mixins import AsyncMetadataConfigMixin @@ -1460,17 +1464,28 @@ class ConclusionScopeAio: size: int = 50, session: str | SessionBase | None = None, *, + filters: dict[str, Any] | None = None, reverse: bool = False, ) -> AsyncPage[ConclusionResponse, Conclusion]: - """List conclusions in this scope asynchronously.""" + """List conclusions in this scope asynchronously. + + Pass ``filters`` to add criteria merged with this scope's + observer/observed (and session, if given) — e.g. + ``{"level": "explicit"}`` to get only conclusions extracted directly + from messages (i.e. not derived during dreaming). See + https://honcho.dev/docs/v3/documentation/features/advanced/using-filters + """ + _reject_reserved_filter_keys( + filters, _SCOPE_RESERVED + ("session", "session_id") + ) await self._scope._honcho._ensure_workspace_async() resolved_session_id = resolve_id(session) - filters: dict[str, Any] = { + filters = { "observer_id": self._scope.observer, "observed_id": self._scope.observed, + **({"session_id": resolved_session_id} if resolved_session_id else {}), + **(filters or {}), } - if resolved_session_id: - filters["session_id"] = resolved_session_id query: dict[str, Any] = {"page": page, "size": size} if reverse: @@ -1504,12 +1519,24 @@ class ConclusionScopeAio: query: str, top_k: int = 10, distance: float | None = None, + *, + filters: dict[str, Any] | None = None, ) -> list[Conclusion]: - """Semantic search for conclusions asynchronously.""" + """Semantic search for conclusions asynchronously. + + Args: + query: The search query string + top_k: Maximum number of results to return + distance: Maximum cosine distance threshold (0.0-1.0) + filters: Optional dictionary of additional filter criteria, merged + with this scope's observer/observed (e.g. ``{"level": "deductive"}``). + """ + _reject_reserved_filter_keys(filters, _SCOPE_RESERVED) await self._scope._honcho._ensure_workspace_async() - filters: dict[str, Any] = { + filters = { "observer_id": self._scope.observer, "observed_id": self._scope.observed, + **(filters or {}), } body: dict[str, Any] = { diff --git a/sdks/python/src/honcho/api_types.py b/sdks/python/src/honcho/api_types.py index 897a86fe..64ee7b65 100644 --- a/sdks/python/src/honcho/api_types.py +++ b/sdks/python/src/honcho/api_types.py @@ -10,6 +10,10 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field +# Reasoning level of a conclusion. "explicit" conclusions are extracted directly +# from messages; the others are derived during dreaming. +ConclusionLevel = Literal["explicit", "deductive", "inductive", "contradiction"] + # ============================================================================== # Configuration Types # ============================================================================== @@ -414,6 +418,7 @@ class ConclusionResponse(BaseModel): observer_id: str observed_id: str session_id: str | None = None + level: ConclusionLevel = "explicit" created_at: datetime.datetime diff --git a/sdks/python/src/honcho/conclusions.py b/sdks/python/src/honcho/conclusions.py index e76b1900..708cc3ed 100644 --- a/sdks/python/src/honcho/conclusions.py +++ b/sdks/python/src/honcho/conclusions.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any from pydantic import BaseModel -from .api_types import ConclusionResponse, RepresentationResponse +from .api_types import ConclusionLevel, ConclusionResponse, RepresentationResponse from .base import SessionBase from .http import routes from .pagination import SyncPage @@ -24,6 +24,34 @@ __all__ = [ "ConclusionCreateParams", ] +# Filter keys that define a conclusion scope (the observer/observed peer pair). +# They are set from the scope itself, so a caller must not pass them in `filters`. +_SCOPE_RESERVED = ("observer", "observed", "observer_id", "observed_id") + + +def _reject_reserved_filter_keys( + filters: dict[str, Any] | None, reserved: tuple[str, ...] +) -> None: + """Raise if ``filters`` contains keys managed by the conclusion scope. + + The observer/observed peer pair (and, on ``list``, the session) is fixed by + the scope, so letting a user filter override it would silently return data + from a different scope than requested. Fail loud instead. + """ + if not filters: + return + clash = sorted(k for k in reserved if k in filters) + if clash: + guidance = ( + "Choose the peer pair via peer.conclusions / peer.conclusions_of(target)" + ) + if "session" in reserved or "session_id" in reserved: + guidance += "; use the session= parameter to filter by session" + raise ValueError( + f"Filter key(s) {clash} are managed by this conclusion scope and " + + f"cannot be passed in filters. {guidance}." + ) + class ConclusionCreateParams(BaseModel): content: str @@ -43,6 +71,9 @@ class Conclusion: observer_id: The peer ID who made this conclusion observed_id: The peer ID this conclusion is about session_id: The session this conclusion relates to + level: Reasoning level ("explicit", "deductive", "inductive", + "contradiction"). "explicit" conclusions are extracted directly + from messages; the others are derived during dreaming. created_at: Timestamp for when the conclusion was created """ @@ -51,6 +82,7 @@ class Conclusion: observer_id: str observed_id: str session_id: str | None = None + level: ConclusionLevel = "explicit" created_at: datetime.datetime def __init__( @@ -61,12 +93,14 @@ class Conclusion: observed_id: str, session_id: str | None, created_at: datetime.datetime, + level: ConclusionLevel = "explicit", ) -> None: self.id = id self.content = content self.observer_id = observer_id self.observed_id = observed_id self.session_id = session_id + self.level = level self.created_at = created_at @classmethod @@ -78,6 +112,7 @@ class Conclusion: observer_id=data.observer_id, observed_id=data.observed_id, session_id=data.session_id, + level=data.level, created_at=data.created_at, ) @@ -169,6 +204,7 @@ class ConclusionScope: size: int = 50, session: str | SessionBase | None = None, *, + filters: dict[str, Any] | None = None, reverse: bool = False, ) -> SyncPage[ConclusionResponse, Conclusion]: """ @@ -178,19 +214,28 @@ class ConclusionScope: page: Page number (1-indexed) size: Number of results per page session: Optional session (ID string or Session object) to filter by + filters: Optional dictionary of additional filter criteria, merged + with this scope's observer/observed (and session, if given). + Supports the same operators as other list endpoints — e.g. + ``{"level": "explicit"}`` to get only conclusions extracted + directly from messages (i.e. not derived during dreaming). See + https://honcho.dev/docs/v3/documentation/features/advanced/using-filters reverse: If True, reverses the default ordering. Default: False. Returns: Paginated response containing Conclusion objects """ + _reject_reserved_filter_keys( + filters, _SCOPE_RESERVED + ("session", "session_id") + ) self._honcho._ensure_workspace() resolved_session_id = resolve_id(session) - filters: dict[str, Any] = { + filters = { "observer_id": self.observer, "observed_id": self.observed, + **({"session_id": resolved_session_id} if resolved_session_id else {}), + **(filters or {}), } - if resolved_session_id: - filters["session_id"] = resolved_session_id query: dict[str, Any] = {"page": page, "size": size} if reverse: @@ -224,6 +269,8 @@ class ConclusionScope: query: str, top_k: int = 10, distance: float | None = None, + *, + filters: dict[str, Any] | None = None, ) -> list[Conclusion]: """ Semantic search for conclusions in this scope. @@ -232,14 +279,21 @@ class ConclusionScope: query: The search query string top_k: Maximum number of results to return distance: Maximum cosine distance threshold (0.0-1.0) + filters: Optional dictionary of additional filter criteria, merged + with this scope's observer/observed. Supports the same operators + as the list endpoint — e.g. ``{"level": "deductive"}`` to search + only conclusions derived during dreaming. See + https://honcho.dev/docs/v3/documentation/features/advanced/using-filters Returns: List of matching Conclusion objects """ + _reject_reserved_filter_keys(filters, _SCOPE_RESERVED) self._honcho._ensure_workspace() - filters: dict[str, Any] = { + filters = { "observer_id": self.observer, "observed_id": self.observed, + **(filters or {}), } body: dict[str, Any] = { diff --git a/sdks/typescript/__tests__/conclusions.test.ts b/sdks/typescript/__tests__/conclusions.test.ts index 8e7a6a09..7716d3ac 100644 --- a/sdks/typescript/__tests__/conclusions.test.ts +++ b/sdks/typescript/__tests__/conclusions.test.ts @@ -278,6 +278,61 @@ describe('Conclusions', () => { }) }) + // =========================================================================== + // Scope-reserved filter guard + // =========================================================================== + + describe('reserved filter keys', () => { + test('list rejects observer/observed scope keys in filters', async () => { + const peer = await client.peer('reserved-list-peer', { metadata: {} }) + + for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) { + await expect( + peer.conclusions.list({ filters: { [key]: 'someone-else' } }) + ).rejects.toThrow(/managed by this conclusion scope/) + } + }) + + test('list rejects session keys in filters (use the session option)', async () => { + const peer = await client.peer('reserved-list-session-peer', { metadata: {} }) + + await expect( + peer.conclusions.list({ filters: { session_id: 'sess' } }) + ).rejects.toThrow(/managed by this conclusion scope/) + await expect( + peer.conclusions.list({ filters: { session: 'sess' } }) + ).rejects.toThrow(/managed by this conclusion scope/) + }) + + test('query rejects observer/observed scope keys in filters', async () => { + const peer = await client.peer('reserved-query-peer', { metadata: {} }) + + for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) { + await expect( + peer.conclusions.query('q', 10, undefined, { [key]: 'someone-else' }) + ).rejects.toThrow(/managed by this conclusion scope/) + } + }) + + test('query allows session_id in filters (no dedicated session param)', async () => { + const peer = await client.peer('reserved-query-session-peer', { metadata: {} }) + + // Should not throw the reserved-key guard; session_id is a normal filter + // for query. The call may return no matches, which is fine. + await expect( + peer.conclusions.query('q', 10, undefined, { session_id: 'sess' }) + ).resolves.toBeDefined() + }) + + test('non-reserved filters (level) still work on list', async () => { + const peer = await client.peer('reserved-allowed-peer', { metadata: {} }) + + await expect( + peer.conclusions.list({ filters: { level: 'explicit' } }) + ).resolves.toBeDefined() + }) + }) + // =========================================================================== // Conclusion Deletion (DELETE /conclusions/:id) // =========================================================================== diff --git a/sdks/typescript/src/conclusions.ts b/sdks/typescript/src/conclusions.ts index 7c854b57..c9add4e2 100644 --- a/sdks/typescript/src/conclusions.ts +++ b/sdks/typescript/src/conclusions.ts @@ -3,6 +3,7 @@ import type { HonchoHTTPClient } from './http/client' import { Page } from './pagination' import type { Session } from './session' import type { + ConclusionLevel, ConclusionResponse, PageResponse, RepresentationOptions, @@ -10,6 +11,43 @@ import type { } from './types/api' import { normalizeSearchQuery, RepresentationOptionsSchema } from './validation' +/** + * Filter keys that define a conclusion scope (the observer/observed peer pair). + * They are set from the scope itself, so a caller must not pass them in `filters`. + */ +const SCOPE_RESERVED_KEYS = [ + 'observer', + 'observed', + 'observer_id', + 'observed_id', +] + +/** + * Throw if `filters` contains keys managed by the conclusion scope. + * + * The observer/observed peer pair (and, on `list`, the session) is fixed by the + * scope, so letting a user filter override it would silently return data from a + * different scope than requested. Fail loud instead. + */ +function rejectReservedFilterKeys( + filters: Record | undefined, + reserved: string[] +): void { + if (!filters) return + const clash = reserved.filter((k) => k in filters).sort() + if (clash.length > 0) { + let guidance = + 'Choose the peer pair via peer.conclusions / peer.conclusionsOf(target)' + if (reserved.includes('session') || reserved.includes('session_id')) { + guidance += '; use the session option to filter by session' + } + throw new Error( + `Filter key(s) ${clash.join(', ')} are managed by this conclusion scope ` + + `and cannot be passed in filters. ${guidance}.` + ) + } +} + /** * Parameters for creating a conclusion. */ @@ -32,6 +70,12 @@ export class Conclusion { readonly observerId: string readonly observedId: string readonly sessionId: string | null + /** + * Reasoning level: 'explicit' conclusions are extracted directly from + * messages; 'deductive'/'inductive'/'contradiction' are derived during + * dreaming. + */ + readonly level: ConclusionLevel readonly createdAt: string constructor( @@ -40,13 +84,15 @@ export class Conclusion { observerId: string, observedId: string, sessionId: string | null, - createdAt: string + createdAt: string, + level: ConclusionLevel = 'explicit' ) { this.id = id this.content = content this.observerId = observerId this.observedId = observedId this.sessionId = sessionId + this.level = level this.createdAt = createdAt } @@ -57,7 +103,8 @@ export class Conclusion { data.observer_id, data.observed_id, data.session_id, - data.created_at + data.created_at, + data.level ) } @@ -182,14 +229,26 @@ export class ConclusionScope { * @param options.page - Page number (1-indexed, default: 1) * @param options.size - Number of items per page (default: 50) * @param options.session - Optional session (ID string or Session object) to filter by + * @param options.filters - Optional additional filter criteria, merged with + * this scope's observer/observed (and session, if given). Supports the same + * operators as other list endpoints — e.g. `{ level: 'explicit' }` to get + * only conclusions extracted directly from messages (i.e. not derived during + * dreaming). See + * https://honcho.dev/docs/v3/documentation/features/advanced/using-filters * @returns Promise resolving to a Page of Conclusion objects */ async list(options?: { page?: number size?: number session?: string | Session + filters?: Record reverse?: boolean }): Promise> { + rejectReservedFilterKeys(options?.filters, [ + ...SCOPE_RESERVED_KEYS, + 'session', + 'session_id', + ]) const resolvedSessionId = options?.session ? typeof options.session === 'string' ? options.session @@ -198,9 +257,8 @@ export class ConclusionScope { const filters: Record = { observer_id: this.observer, observed_id: this.observed, - } - if (resolvedSessionId) { - filters.session_id = resolvedSessionId + ...(resolvedSessionId ? { session_id: resolvedSessionId } : {}), + ...options?.filters, } const reverse = options?.reverse @@ -227,22 +285,32 @@ export class ConclusionScope { /** * Semantic search for conclusions in this scope. + * + * @param query - The search query string + * @param topK - Maximum number of results to return (default: 10) + * @param distance - Maximum cosine distance threshold (0.0-1.0) + * @param filters - Optional additional filter criteria, merged with this + * scope's observer/observed. Supports the same operators as the list + * endpoint — e.g. `{ level: 'deductive' }` to search only conclusions + * derived during dreaming. See + * https://honcho.dev/docs/v3/documentation/features/advanced/using-filters */ async query( query: string, topK: number = 10, - distance?: number + distance?: number, + filters?: Record ): Promise { - const filters: Record = { - observer_id: this.observer, - observed_id: this.observed, - } - + rejectReservedFilterKeys(filters, SCOPE_RESERVED_KEYS) const response = await this._query({ query, top_k: topK, distance, - filters, + filters: { + observer_id: this.observer, + observed_id: this.observed, + ...filters, + }, }) return (response ?? []).map((item) => Conclusion.fromApiResponse(item)) diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index f6b11d26..90bff9f2 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -40,6 +40,7 @@ export { // API types (snake_case, for advanced usage) export type { + ConclusionLevel, ConclusionQueryParams, ConclusionResponse, MessageResponse, diff --git a/sdks/typescript/src/types/api.ts b/sdks/typescript/src/types/api.ts index 65c6d88c..dda25ad1 100644 --- a/sdks/typescript/src/types/api.ts +++ b/sdks/typescript/src/types/api.ts @@ -242,12 +242,23 @@ export interface MessageSearchParams { // Conclusion Types // ============================================================================= +/** + * Reasoning level of a conclusion. "explicit" conclusions are extracted + * directly from messages; the others are derived during dreaming. + */ +export type ConclusionLevel = + | 'explicit' + | 'deductive' + | 'inductive' + | 'contradiction' + export interface ConclusionResponse { id: string content: string observer_id: string observed_id: string session_id: string | null + level: ConclusionLevel created_at: string } diff --git a/src/schemas/api.py b/src/schemas/api.py index 4863d99a..a276c4b7 100644 --- a/src/schemas/api.py +++ b/src/schemas/api.py @@ -29,6 +29,7 @@ from src.schemas.configuration import ( SessionPeerConfig, WorkspaceConfiguration, ) +from src.utils.types import DocumentLevel # --------------------------------------------------------------------------- # Metadata validation helpers @@ -446,6 +447,14 @@ class Conclusion(BaseModel): serialization_alias="observed_id", ) session_name: str | None = Field(default=None, serialization_alias="session_id") + level: DocumentLevel = Field( + default="explicit", + description=( + "Reasoning level of the conclusion: 'explicit' (directly extracted " + "from messages) or 'deductive'/'inductive'/'contradiction' (derived " + "during dreaming)." + ), + ) created_at: datetime.datetime model_config = ConfigDict( # pyright: ignore diff --git a/tests/routes/test_conclusions.py b/tests/routes/test_conclusions.py index a2ef56a0..77cb23fb 100644 --- a/tests/routes/test_conclusions.py +++ b/tests/routes/test_conclusions.py @@ -737,6 +737,7 @@ class TestConclusionRoutes: assert conclusion["observer_id"] == doc.observer assert conclusion["observed_id"] == doc.observed assert conclusion["session_id"] == doc.session_name + assert conclusion["level"] == "explicit" assert "created_at" in conclusion # Verify internal fields are NOT exposed @@ -744,6 +745,80 @@ class TestConclusionRoutes: assert "internal_metadata" not in conclusion assert "collection" not in conclusion + @pytest.mark.asyncio + async def test_list_conclusions_filter_by_level( + self, + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + """Filtering by `level` returns only conclusions at that reasoning level. + + `level="explicit"` is the "not dreamed on" view — it excludes the + deductive/inductive conclusions produced during dreaming. + """ + test_workspace, test_peer = sample_data + + test_peer2 = models.Peer( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add(test_peer2) + await db_session.flush() + + test_session = models.Session( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add(test_session) + await db_session.commit() + + await self._create_collection( + db_session, test_workspace.name, test_peer.name, test_peer2.name + ) + + # Two explicit, one deductive, one inductive + levels = ["explicit", "explicit", "deductive", "inductive"] + for i, level in enumerate(levels): + db_session.add( + models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content=f"{level} conclusion {i}", + embedding=[0.1] * 1536, + session_name=test_session.name, + level=level, + ) + ) + await db_session.commit() + + # No level filter -> all four + all_resp = client.post( + f"/v3/workspaces/{test_workspace.name}/conclusions/list", + json={"filters": {"session_id": test_session.name}}, + ) + assert all_resp.status_code == 200 + assert all_resp.json()["total"] == 4 + + # level="explicit" -> only the two non-dreamed conclusions + explicit_resp = client.post( + f"/v3/workspaces/{test_workspace.name}/conclusions/list", + json={"filters": {"session_id": test_session.name, "level": "explicit"}}, + ) + assert explicit_resp.status_code == 200 + explicit_data = explicit_resp.json() + assert explicit_data["total"] == 2 + assert all(item["level"] == "explicit" for item in explicit_data["items"]) + + # level="deductive" -> only the one deductive conclusion + deductive_resp = client.post( + f"/v3/workspaces/{test_workspace.name}/conclusions/list", + json={"filters": {"session_id": test_session.name, "level": "deductive"}}, + ) + assert deductive_resp.status_code == 200 + deductive_data = deductive_resp.json() + assert deductive_data["total"] == 1 + assert deductive_data["items"][0]["level"] == "deductive" + @pytest.mark.asyncio async def test_create_conclusion_success( self, diff --git a/tests/sdk/test_conclusions.py b/tests/sdk/test_conclusions.py index 33331de3..9c2cdbc0 100644 --- a/tests/sdk/test_conclusions.py +++ b/tests/sdk/test_conclusions.py @@ -769,3 +769,73 @@ async def test_observation_create_mixed_session_and_sessionless( assert session_obs.session_id == session.id assert global_obs.session_id is None + + +@pytest.mark.asyncio +async def test_list_rejects_reserved_scope_filter_keys( + client_fixture: tuple[Honcho, str], +): + """`list` rejects observer/observed/session filter keys managed by the scope. + + These keys are fixed by the scope (observer/observed) or by the dedicated + ``session=`` parameter, so passing them in ``filters`` would silently return + data from a different scope. The guard raises before any HTTP call. + """ + honcho_client, client_type = client_fixture + reserved = [ + "observer", + "observed", + "observer_id", + "observed_id", + "session_id", + "session", + ] + + if client_type == "async": + observer = await honcho_client.aio.peer(id="test-obs-reserved-list-observer") + target = await honcho_client.aio.peer(id="test-obs-reserved-list-target") + obs_scope = observer.conclusions_of(target) + for key in reserved: + with pytest.raises(ValueError, match="managed by this conclusion scope"): + await obs_scope.aio.list(filters={key: "someone-else"}) + # A non-reserved filter (level) is allowed through. + await obs_scope.aio.list(filters={"level": "explicit"}) + else: + observer = honcho_client.peer(id="test-obs-reserved-list-observer") + target = honcho_client.peer(id="test-obs-reserved-list-target") + obs_scope = observer.conclusions_of(target) + for key in reserved: + with pytest.raises(ValueError, match="managed by this conclusion scope"): + obs_scope.list(filters={key: "someone-else"}) + obs_scope.list(filters={"level": "explicit"}) + + +@pytest.mark.asyncio +async def test_query_rejects_reserved_scope_filter_keys( + client_fixture: tuple[Honcho, str], +): + """`query` rejects observer/observed filter keys but allows session_id. + + Unlike ``list``, ``query`` has no dedicated session parameter, so + ``session_id`` remains a normal filter and must NOT be rejected. + """ + honcho_client, client_type = client_fixture + reserved = ["observer", "observed", "observer_id", "observed_id"] + + if client_type == "async": + observer = await honcho_client.aio.peer(id="test-obs-reserved-query-observer") + target = await honcho_client.aio.peer(id="test-obs-reserved-query-target") + obs_scope = observer.conclusions_of(target) + for key in reserved: + with pytest.raises(ValueError, match="managed by this conclusion scope"): + await obs_scope.aio.query("q", filters={key: "someone-else"}) + # session_id is a normal filter for query (no dedicated param) — allowed. + await obs_scope.aio.query("q", filters={"session_id": "some-session"}) + else: + observer = honcho_client.peer(id="test-obs-reserved-query-observer") + target = honcho_client.peer(id="test-obs-reserved-query-target") + obs_scope = observer.conclusions_of(target) + for key in reserved: + with pytest.raises(ValueError, match="managed by this conclusion scope"): + obs_scope.query("q", filters={key: "someone-else"}) + obs_scope.query("q", filters={"session_id": "some-session"}) From ba421a25ee9ab090ab40227562771180e88251ed Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:13:12 -0400 Subject: [PATCH 34/65] chore: changelog updates --- CHANGELOG.md | 2 ++ docs/changelog/introduction.mdx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 474c198d..7c56ddec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - OpenRouter app-attribution headers (`HTTP-Referer` / `X-Openrouter-Title`) are now sent on OpenAI-compatible clients when the configured base URL is OpenRouter, so requests are attributed to "Honcho" in OpenRouter's dashboard (#805) - Langfuse traces are now tagged with user and session IDs for easier trace filtering (#814) - `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800s) lets sub-threshold representation work units flush once their oldest unprocessed queue item ages out. Set it to `0` to keep the legacy behavior where sub-threshold tails wait indefinitely unless `DERIVER_FLUSH_ENABLED=true` (#826) +- Conclusion responses now include a `level` field (`explicit`, `deductive`, `inductive`, `contradiction`); list/query endpoints support filtering by `level` via `filters`, with reserved filter keys protected from being overridden by user-supplied filters (#851) ### Changed @@ -29,6 +30,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Agent-supplied observation IDs carrying the display-format `id:` prefix are now normalized (prefix and trailing whitespace stripped) before `source_ids` are stored and on `get_reasoning_chain` lookups, fixing corrupted provenance links and broken reasoning-chain traversal (#795) - Fixed a `create_tree` keyword-argument mismatch in the Dreamer's surprisal tree construction (#749) - Providers that omit output-token counts (observed with Gemini on tool-loop completions) returned `output_tokens=None`, which raised a Pydantic validation error that aborted the call and crashed the Dreamer's induction phase before inductive conclusions were persisted. `None` is now coerced to `0` so token accounting degrades gracefully (#809) +- Document creation now performs exact (case-insensitive, whitespace-trimmed) content deduplication before the existing semantic dedup step: exact duplicates within a batch collapse to a single insert, and an exact match against a live document reinforces it (atomic `times_derived` increment) instead of creating a new row (#861) ## [3.0.10] - 2026-06-15 diff --git a/docs/changelog/introduction.mdx b/docs/changelog/introduction.mdx index a69d4567..c66e53a9 100644 --- a/docs/changelog/introduction.mdx +++ b/docs/changelog/introduction.mdx @@ -36,6 +36,7 @@ Welcome to the Honcho changelog! This section documents all notable changes to t - OpenRouter app-attribution headers (`HTTP-Referer` / `X-Openrouter-Title`) are now sent on OpenAI-compatible clients when the configured base URL is OpenRouter, so requests are attributed to "Honcho" in OpenRouter's dashboard (#805) - Langfuse traces are now tagged with user and session IDs for easier trace filtering (#814) - `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800s) lets sub-threshold representation work units flush once their oldest unprocessed queue item ages out. Set it to `0` to keep the legacy behavior where sub-threshold tails wait indefinitely unless `DERIVER_FLUSH_ENABLED=true` (#826) + - Conclusion responses now include a `level` field (`explicit`, `deductive`, `inductive`, `contradiction`); list/query endpoints support filtering by `level` via `filters`, with reserved filter keys protected from being overridden by user-supplied filters (#851) ### Changed @@ -50,6 +51,7 @@ Welcome to the Honcho changelog! This section documents all notable changes to t - Agent-supplied observation IDs carrying the display-format `id:` prefix are now normalized (prefix and trailing whitespace stripped) before `source_ids` are stored and on `get_reasoning_chain` lookups, fixing corrupted provenance links and broken reasoning-chain traversal (#795) - Fixed a `create_tree` keyword-argument mismatch in the Dreamer's surprisal tree construction (#749) - Providers that omit output-token counts (observed with Gemini on tool-loop completions) returned `output_tokens=None`, which raised a Pydantic validation error that aborted the call and crashed the Dreamer's induction phase before inductive conclusions were persisted. `None` is now coerced to `0` so token accounting degrades gracefully (#809) + - Document creation now performs exact (case-insensitive, whitespace-trimmed) content deduplication before the existing semantic dedup step: exact duplicates within a batch collapse to a single insert, and an exact match against a live document reinforces it (atomic `times_derived` increment) instead of creating a new row (#861) From da0d92a4755b1c755ee7254950c1a6b944332b6e Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:55:15 -0400 Subject: [PATCH 35/65] chore(docs): Add detailed system diagram to docs --- docs/images/honcho-system-diagram.png | Bin 0 -> 1754710 bytes .../core-concepts/architecture.mdx | 14 ++++++++++++++ .../documentation/core-concepts/reasoning.mdx | 2 ++ 3 files changed, 16 insertions(+) create mode 100644 docs/images/honcho-system-diagram.png diff --git a/docs/images/honcho-system-diagram.png b/docs/images/honcho-system-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..1606c803da9b6f065d1bc26005f092eb7f190f70 GIT binary patch literal 1754710 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

gT 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

X0ZB=1TF2b(iQv(Uw%ab5iQg!e2tF3V5=nE)g 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%0A%%t&5!kUU=pRq4X?cr!zsM^lR0JI^s6 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 I{AoV8AHmg;~8Pk|2aDrIk4pj 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

Y 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�AMF3l@1WB0=Oq$Ul`7wDn>RjoZYKMoCrrOi2V&4^ zl3?8#S*ISZFGcx0)Olj=#E^@pdm1m_Gk2jSgNpeRg{oB6>ul+qtW`1L{!#3lO@T^^ z&opH?=XzeYSsESX4-fP*%!}@fUuknR5!7d1<>-J<}Z<}luAefC>VO;r^p++A% zbM>aCz2lgzXH@N~Zw3V^-%}d36r(4XRg;u+2;$f%n(n$koRLdexzYP+^yDeCC*yZH zYF2B{)RgAVdZw$$C^kurQPuF(mZpvB1gi0cm<{1^tIYyWs(Jzr4SYcTE5L(;fO2zb z%=WRx5q0=Y*2GZMYWstwQ%G`UP4!u{P756#Y3(r9^y&gofE?){gm`;Pk*f0s;b zZU|rfk)7lA+j#Fn4;{RsO&RH(&!3pj%X4GX#x}hhDk24mbmM2ZDAxZ_t%I3wx$r2T zn}_jvmPNn2(0Go8)}milxPs47nPZr7@hM`#w^BZHBkZv>Tz<@$4be&y zy$Q8YRW|-!U&Oh(L{@*&_87y@&;4`=C?$Lzwjsyv;}@@;@_jfiBs?B#7Evk5ikLw} z7&ym;v;c-<;}h0oRl zuiwIu7+sj-!q7LR;OVBb<&4R{;g8->vr z?U=#Lz^16G6@?u2!|VOFF8%cQ1eEmMfmO}6Jm<1z^Gwjn;Q8g4OB>gbz7AR?p^eYO zejkmkdx_w{62opKBaYw*=%n9Xb=#k9Ig9eCutwEkZH+eSBHn!dyO#mQ*VjLTx`rP) zez~TqW9Y#9$c?X~gNPw?U<~+5zwwD-Y>IQv)6=qKlj-^2hlE~MAGN_z3UknzZ_Hc6 z*X*1g&MK){pQ?zL*g(`KTLt)_2cq%zvQ&lD6)Auh0buBW1_aSYVlJS720p%x7zwPB zLCnAd;12w)OdHz?%h~IpoC!agxhO=$#kS^o`&l+#p!o=%&gY)WBTlLq6+~uUfvt-- z`5{?V^`B3_{SDl@mRo{*{)iVJ;#+d6#%`KVvqcQRzp);ce$^}`* z-zr`yhZB?srCp6@YbZaJFS>SW>4rnQ!=eQpMKkPIg-}BHVFuZM4=9@50~rZ7h}-IV zc!Zp!xVhF4c)B%+BqlEo#WAfEKnkYYLu6UY7a$x)wm|qOIv~#WB##F2p&+1x2r|5b ze^PsWcQ1SILO(npV3xGIc+bf*S3W*%*E9UuDXMaY+B|0Joy+@t#u+kdB^DH_Y|8C% zRNgIb(WQ&4?w=Rgn*#mcupb)nF8N+3x{p&;#75U7`-*0Q;F-*X1B2i4Zg=Oqxj2&q zFfQaSV$V#?mb7@z@*n3+H~relB6`_#H8B6O^yk|@*rWWM2}+acIt2>2B^<48AR%Ni z0cRd@kL1nrW;o!|D5^V`B-ajv7PSn=Q)@W%r{CTy=doq{KV8oHJ5L#8ri8pd9?&Fz zS|2M0HW_^e-8PO9XF7dJV#h~+dKNtwv}`9+ri--d&HxO-1Alswo|Sm(;>Ncv@1O|N zbdSl1BN=B!@mg*&Osv~jH;5PBmF()1do2-DhMGdag6-Mt9mC4gK-$05!*jjxPIBAr z&)=E(B*PG`qZ)BeB1GlG7VvI<_}uRtvu8k`9PcRY4-iP)8+grHC7aa_QS&GUv>ydR zF<0U^e+Q+5>*x?{>ah_1CF#+7a%}WbOxmgyfwrZtJgv6QQa1;hcTA2a#{G__KO}oR zUvq!LHPnSTgC@&LvHOKCO?k~mu>8j$$f(j0;f%bTt+~WLs@3T0v$DRB|M=M7ocz`t zM5Nt8O+^`xZ0=M;3K4HLm`pm53qhM7i~h=r;7gFwnwm=Yd4O({K0Wz?>-M)##I8Ak zsTI#pGA)C**ayl%>b79p#el01rfPU29i9w~JP#;JaZ*`UpVjIrMB7>2=vsJ()OB+K zS}l5K6(H~Yaj9VMx`PO%d>N@IgDi@XBI(jH!o@-8^YIcJSsxxIF2mV)db8zgz2o?E zBDY=c^UEH$QL`#hC^iuqBef>aJ3o+VIhsey+;~>_ue$~Dz-hEBMz6TPO~B&jwv)n^zfL0*L$VII)djR^obJ{PXUyJ!5+_+nvYjNs-`Z1H)t;Rn)GuP;sy+LcZZH*#`n z8a{_3Y-~Jg3NXIL^>x}Z3kbL#LE#VrroquqyT39t_~{BD>MCcQpx#+oqklAG|LzXy zu+yj5@e!t8jkO}(#&$%C+csD2)SWWSLEydeipea=&Hjo2eED3j00FWjHHH$$#7zH3IUKVrvr>Gk9v*=G z;Yrf>dR~6`>AJ^-z{Cx&uxd)yQpr|4cv82L&Cmdjg}8gDEoE#Wv3?Fn$i*OLLawOk zG?NTqDi1gfxSg`-y9bE&GkA+oX7<)K{unVXvQQ>nRQ=+7F|t|s7r+jo1t2j(B%i2A zI(*1P*kP6sL>=(J0@X5x0aw2d0Smzq=dvQ>`hd+GH(NV4=kYnI;PmO4FLK?zS+s$V zxy%X+lj{T2+^z^!v>&wigEa049-E_nz3t@5?SS+&{z0ahCr6 zl`fbNO?5$&i)(R_}SX zuXkM(5a2^hgP{D&;4RLcEW*2>p>(=Qo`qoH=k*DMSedNYJOZR#|vRuapK zM2ct%GTScNefL;R-W|oGS^FB|T^=?X%bGxu(Q$h?d4Vz5!>S<7d4HW%TcBkb5PgFL_@K4*vNX*-q5NCCOXms6l0Al<454^W_yL?HD(C@+dP8uVoKhY=w3zh^-9 ze#WCOYYPv0o{*vwC=|)S1My(bY&!v@(l;Z6z}5IJIh2@OgfH3*M2s0Os3J~)6rTq~ zgIvd3p^RA!EA?rl2jStGZ%t`k(XYJ9G0RF*$bB^E7UWBjS%QeaBv6yE|FJOVX-GV2 zHH@g}NY8)PT)y3O>l>fekH`G`;u3OI|2=G@%sydibxmHz4F+-ykJKZT;s{JMuyCIq zg9ws}!D=I1+%!cxk}JD4qwKtYuGZLVg*FWJ`FB5VRtC0X2>FMw1^TBP__!k}(sGs# zM?pFhYPx)qiYvX)pSBoTt5^?RU|FUtoQ49B7J%xjrraKYh$}x86r$ZqF8)}Lk@eq% z24sS6!<@_cB#k`Z6x(yN5Hbg4iew<7P4}!CRG8udm9(xhCYAtah^i9kqmeg3dqu17 z%X87>r5iK;h=WDV5af!itW5N?u2QX9k9QWO&AsvGeE~op52D6)u90^YURt$8{6yTH zSJlp6IYw(Gzpv%5N4i~T`-6IfCCADL2xUyGuzoP0P}L$;+pBi{g|a$Ecno`DMDKPW zo~;@&v2$9ylgLbI{pNQy1fW|L1l-`^D0OOms z*&L8wGbKYoMlYhI7ZGJO>v_QB4pm6uq%kON9GLBNS<6i+k^mfTF_cu4l+}4eAl6{> zag1NzGL>(wuf3K7wu{YgicbZG9HepX=}uv6C@@`1E?W$~=TuN&I^RQ~$u>;x|C=#v zdz^?LST&Ru0rE68e(@g&U#jPvIRA3G+7`pLi$Ml^#EVMCz zGU1TWZ0yet=3W0kF1V-TZUoeFcpJtSk&WE}_F;sHgaQF3-XDOoTm*(h3;LsEu=Ul_ zNcN?30~N*$gkkO_IgJ`>f$X5vEK*=hz&~2>^%A8UvdH4o8&pLp!SpmVXx*Me$(xl)k7Ql!K` zZ_s2qw^F34sT^0b$g;f@et&^BAicu*A+GNfIr5|}EU?THZhP%KDsK;1tV#rzOx(Cc ztR3&;(5?7#bKs(}z%#LGkv!Unmn_{-=anpDH_Ox!>S)_@AE4HJxC8tM7f_h0Gfg|W zzfmm zEvnMB<*K!NNsixHwI1^};37p2i~1k4E8jU?@DKqR4V5n;<{uAh0V~QxHvb*Axe+2Z zc%rna0XTvOogp0IFiG`7a%E@^(sv`-pNp`SG>ALGSDjOj4ZfB4PJVx%sK|SD__Ef@ z{$YN##(z~6WFBKiaV&ExXgs#yxdofv(_=E^HfZTdSgDG z5zx2TET0{pKtux&@v9>^a-130oM!5^fSY(cgY_GZuc@TW%#FwE3epIBL+*`~af$uCDXO>r}?fPkgnt_61L&W06p4sK=V zHYD#b0YAjRzemr|Vcqy3c%QG;aluzoce(g%_5Jj%J#VKTvU=p%Gz*Pn7bjLUr2bw` z-ZoGc`3SAA??N=|`>+pO$*-{7OOJ}cLGoFJM}sgo*7C+tDH~PT>38i;qWD&Dh_)s% z^NY0pUPu1x$B+xt;nI(bZhUJ6E_oAj zTP*d}sh<1CiU0t4;0zEl_+#cEd2~w$Q8K&#zDv`7FpBsy9H3~{?UG}yt^W*J2An^+ z%(@EWor6FL{nCAmPVK@)Kso6-L((0eDa7nt6GSRgd!K`r4C#e4W3~Wq{xSRJ`96_l zE=fjjgzW;4>$0}m-C~$Tv<4@wB6vZ4V-;|Lasogr^0Nm9P`OZI+ZXUN)saI8LSp*s z&C5ch7v|bVfgiU2${Df?LGUCBbY`pH`#r>y@_8HuSl2oei(Z(%8*}gOqNSl~pww8t z#OgNleS81d9(d$M#0l&g3(uj2<=ERwujS3+WWRsUh@;s#oLhc&V?=S~Gocz0=dki9 zY==J?um^vV<4p8u%fAp~kU1!RI!BHJR~%CA0b|@ekS5|bOx{S*RpU$r9G4I3 zpRfa=dXs>0kVa{`=_i?j_%XjIP1pe0u9+MaYzHWoUA@cnD_SdC;)NzHExLs^yFQ{T z?(3E#+m+=aLROhSFygDNW)Krql$p6|JwHh!viH0PA-hV|-YpZ|F_O?N2NSbyBT;)b z;>8P$?G)mUk%XB^#IAvoyQX@h^lrbMnf{imkO;N@P zN9zGYz!k6HQx>;6CPBg!9|5oy1sNtnw=YnjJhHkx{YHccDQS}0u70F6B{Uy_Y@?0r zTvVV&xz~dShzsGQJvr(0>9B6d6D67@wsa&H(@NqxoN4WOc1%e{$Df|g#omZ;x%F@> zXu1S7RxpJ-`UVCfeiU6!I?&vk>f(C)atjhbEZJD^Ln@^m6)LxfbN;$ut)^``_Px6`881(b3kkp7cs zm_Vd(69EX6OE+cSzcSYuUjN_$WR%soX{$Pnrl&gT-vCiG zt)hE)`h0d@h)BaTwa+pV*=j0x1T6y^}vYU9)jSi;8-7{(-qk#QnE(?|M=pR|GY7NZCqxC*WWGT~* z$#FVyZ04l>uj6YOb*JEXj;vDz!3Y&$8I zt91PL9SFay`v}O=ej~Cfe|o$d&Nx-9IoM<8TqCgp6o@4yg@9bYR}#hHSt1N?V~uiknm z2;b6BHS*rZr~{nm?j3U}wpdREeo-ngEDs;f!a$)j9!#+@uMh{Iy255M-oIgVtWEB`7nbT3S44GmlRxXAvb{ zP!_>?E{zBbMwX3m<79iEog&CYbj_2QKlghLf^f~J^4bejYe+qJZOLVg6M>BDoBNd_ zT22sFvwnz}slZM&z1?-=5U_uk#`VzOehA!zhm_&P?ChLes;9n?ZxBiJbf+n;kHIz0 z>gwc)3#|Q0{%=W3dOz*4)@h142l`%!FKCY9<^I)Hw?=7wJP)I%b7s>Xj!VdHo)vx> zQgjBMBWXo~ajDQojTP=?268J`->d^}3~D9%L@P=YE3 z0R3~Nuv>}XMR^Y2*fj^jH98AzyJl3nUw%LcO9%#3ssNJ8_74fXBeblq$&A4f^}aene(G8WcA3d2`!49sivNEqzt{$jIUzx~O-yg*43ME2_mTuyi$*!*q7 zSkV{ZnC7r?)7*Ha#^1224V;S5;P$wsU~Dyo1o@F z{QL9>n5@bQf~dsfqa}aY zmmqn4jMekOJVQAsxltmO9^%rcCa*3%#z&47K}2xPTL>;IV6BAHd4JC{;8~AMfNZ4* zEA7c!67yp=1;O7D<|*5?XK<>foy!C9Y&(V9-j7!TTM(QflpFTZ=OFyIn-Wn&8A=Nv z(EY5j4iHcw8=Kuf);p47NXLJgO`argmtI->v+VzKd0Re3QOw6CuS}oJkX<4e{=A8! zgq1vCfS%4XT-3;jD}Cj%VdBsWp=R#b;F8~>KbOVkTP@hxN=sOe!Z((V9xeJ~b<-Il zeYeg3rx&`cG`hIG@;)PY$^`ta7Nny%T0Umx&m@?EZizc$XX`$ANZqPlVWTY82_lBR zs8J|}iA^El!2voa01at9*9pKwnr>W~iy|^gDPy!d+Ya&$y$B9)0)~QK0XicjPQYRA zRD9lSix!Y$Ma>pKdq2`c*Bi;WatnaKNw`xRajnSw`+Y@_f(WehG^T;=vYDHE|L#wS z*g`kNAz#Y1m$*^OkduApAN8OJH3eCoH8P|*6w{AvSW3mnZm3A~HNMUoDkI-U?Lm!W zYKr=a9aQqBf{Zz;oir{U{+CbKux0rel>HG6|Gk~b5KJd4V`}Us^Q?LggZ@{qi+YSZ zUm1Ccw1#KL2hbb`2O4yQho5Z>_JAtd{q9)F6V6>vHnK#aOv#W(aO*Di;BV~)p>>*7 zP|>76+XBjAE6MM1C~wXr0ZfuD56UziNHP@Bmt*xr_cBR^kRz@d9Q5VK6eQ64ttYo@ ze3By@h(_U4E%zS_0}~cGDZb#k?>I8N&a(v#s&nHVd8y8|7YL~eK(pd?b8e|^8=Po- zQ;-UfX(#kfdlD#p@~tzCYF?FHUA@p;d%`jRA9M=-y|oHFRy}wCE)H_Lh@*fbK{!I z3}vMYE8sW?AmY7!{|AJ^3Pr%9?ll9Gq%SR5F$b#Sci&mxFL&2w2EHDX>=XXDfM`P6 z=^!Ph&-*C(*alOx4oV_I@oaQXA)i50#lP{AgD%1zNz7jyif`__+otPJ|K~9O@(x%W z4{5T%l(EL1vq>!y{V2!+qY4eL6S2KK{&!p68tUJDdbaX)t;h@xntNj#hf8!KnI2)H z<;bD}7bY^q(Yj~|r943I>kJiWF?T~jT?(_f^!ga2)sB~^v>3#>!J$qF5!S_f7lxxP z&04y`{b>mzI%*AI7jpqz9ywvt-(Lbpp`Ow{JwGpS^OKJmXbCciaN{o4#mE&y{`Y*Q zNqZ3pG#5h^I8-gKc^XPB`$6r+``eqWhurfkjrp_b6CfwxZtB=e$q)zf9bw%sJvs2* zFuuKNgd9mAR_u6Vl4ez9u<20fY+?YtoN-?)+iX4wBz=}c7*L3!!oQIhp>LmA`qEYw83qxqqgE;Z ztPr0yo*2<|#HUnPHG|eyp%ba$D{QBsj%z36b!KrOH<$2lcf5TU{>Qu8UQRVets_pq zXT~$ar(>01Nz5dMKKb?5B3QAPg`52s7q?5)deX4r8ZS1585?e$X=J=mi)SK5zW}U4 zaSK)lU|pO8_%;cc1<|>*ldo8CietPjulo*kZy;Q#02{(J|JSUmUK$TInHo_td~XW8u{es1<4LMU_VA87cT zyMvSS>#hFfaCcnABZUy{1E^$E1tfh(P#7xUlwDwZhk{E`9bfMSI!zxCFK z|Ge%$-icPnKx44j7(aTn3++V)XpzZ}_J6-`xB!c0p$(&d!KF(Z5YpJI5h1~ik>lrU zvg-w;wYOIA1;|+n@7uUTbT*kDE)f9{#c&wr&pG+Wy=?w&3%!qqnUbwp%v6#66bw%^ z2?iNWW4Tz+Z%FlDN1$2}qcbo~q0;=1!R!GiL1uGxlTM)#F(Dq9ov&&B^}pbZrLA6NNf8D?LIsB>G3D$lv;Blwp}sEiv@SRZ`M8E1y?z6~mq(Z%e@ zX=uJ1%&}wqtGi*8Yy{r!-JCOn{d$Ct>rcq{?iin)qq}t(ln&lgffW{_+X^bKyatQHwTFRrNz!=@lD9NpN{fm>rD`s}#XUwyw41CQc!@{uE0kuf8ymj-dh^v+G4%^2UF58vCR=acuAp4uQcGoB z1KAFPkogQ|^>d*Cf||=Vn^QCBeacp-@t=2v%v%u2I>G?Beky!Ic*N0X(L7z6L{b$` zf4!;Suc8BB=14ZiKa2G`1aO?<^MAO$up&NUQ5YDV@jOXQ>iMc)`8Q761IV*vp##2z`EAX`rc~r>7U4fcKne<3VK75zIkH zxcuk=3W1HclCPlX?4+@O8;YM7ktxND!d$B3eh~9(K>3ResbPtIXp!dpU?}eA1zsJ1 zkMAblQ7BbB+qRP(bB6XJo^Ue19Br_(vKoCEhGp2izG7qe*@&5QuapFGhLQerKz^MZ zTw-QoJ>{pI`|0x{tgq>cx4)~)(zjYkkNqT%+F=pxBbe~A1EbK8yF&Y!gzPZd~Aq_QD_-JcdtD7;;5DpocuUMrQE=%`$ z9OkSV7}6T}&-i5X&Lc>{tG3!`D6+F9p8EfBM(a<4Qo^$wj`f-m-<*!M~7Z;4Goc0g94BL(dz21+&VE8g5IBou;aVgmk>M; zKizncRrOB(hKvy604i03HW&|j{&zU9sg^{Y_2yyJ10#1S?g3+3@gFhL-sKda7Y(5bidv0?~28c1nFALsNc(pOdC^B3C zkYW#H|I3MQo5GG4C}G@p#7Q)Y%~W{ApGb)7IF-&LMeLuSQf2j7y*6l(-cTO1y(VQu zF$b4g#3=mUZr=I~d)JDnQ&K_m#%88UJfK0~$F=8|%lvAhD9HT~B}B)uU|^E4SHE)c zuC{0nBN4vj`WtKp?S^U55q$7W*d#LVv66Hvr_YoIiqW zyWLQ9<+oq>xsca!%%P%<&BHsrUo;WgB%PZmb#ua0S*wX_SgF(;ZW1+IzUt~}fWYg2 zPG)t;oht00+PB`njn395!to-*-vDN6RYfx9fcZDqJG;UtfBX6lGlTFCG70W(=+jVB z)*Lp;*zsY6s7K$qP1N&emmxmg9?0pRr+(@3FVFVtL~Y&5=9QoV%S>X_nWgWaYw!`+ z-DFPe)k#x2d zLK?K-A+wW@1=TI(wt{_Rcnfd9X3hMA|ND(|g4_0vtb)1pLAu-m$?YKO=Y6&?()l>H zwoz?x7GgC}6VtJp=l<@;e}5e02T>)sJnlVDzF}iUKVvvS{4X2bG0JuRDEn2_?#CuW z`0sCSJo`=-5hWJIl=pF~{hH1Rf@LHHVQ-}vO@n^k^^W=ZZJZ(zMDPQ}$|SLiL3V8B z9SRWBoxI!v4~PyrVAgFe$^NG77hC+@n2d?96mW_ba7xj(RzE%e=g*LWYZ%wx{0`e8 zX7l};!jtcsQo|Hm)&Ebk`1D9JmzN`MWfIHoI9n17jE6bQ45)}R2r8&lFqq-DC;8`H zt`9`SF$j9cuUtbo#PDqRqHo(S(XqtLBy3sob_x>ZbpPY-BkD1U zh;9U>QB?fg2{-2heGAUwrZ}Y>>(eh&i;3Nt#pC`lD9pr=vGgUc>d^mjmHecJMDStX z-u()ynH9WztiKwSWh8QRDL5`9tJ1n{4WSIkhD<92b=}Pon>o^znzpE{o4V=2NkPY9 z{Q1^QUvVAsj2h3{`tOEYvN{SLOT>NnW)Ua`{AuX$4mC$t(b`_728{n0QTS8rIXa>t zRT5X;E5F|O`eGpgw+24N0;IVyXvkKhJ-@kPk)Kn_Sh7?mZY! zYEh5%Oj82o0a+9)Ow3pE?Y@V({`=;@pk-bm(OqSK^jB=YjbKIQGO`tay%7w1$X|c^ z3KoUH-)#}4f;re9bFt^soA=*qum8|VS1R_eAsEAdqa2%?9CjM6XK)oZX7=9nAZ2%i2Zz9*k%IoMYu3;dWCBFEJmQk_1)m#MNY#CSU#!N! zM5LGz0c9FKXi$+n6dPxa+80Fc!%56ZmjhMMVqn1d_P2v{8=-&{HwCrU>vPtX}|~hO`e9FOt@+E>k-`AeAL?Ph%B-$3E zs{Ntn%a^-PBM1PH758;Q1c&G0SvAwb&S8!+a6tntGpJZIR5*=0bV5rEXL|?U_9LU} zX791PL0SKkp-{&itptdMSmQoOEQhYvCpnWaN{6-Gv}KJ?K8J|N6aj)Fctmb%sosZk z#>YUGH*lOntCB=RZqCpu;mcNxz4fWhL4k@f6-FoJcJAWmqDHrn3@GNvojm!M;rY1$ z#PE{X2|U@MV<_4eXEvh=ph>$ev&`yf|rs%>| zGX43##KF~4mA4cH=KTU^?kZuLqP`%L;cEf6s>w@ytXTvdoEPVc~B-RJ`0|Es7(P7r(5C;-q zoS;u9l9XF>Vv=_O+Pg9f#uv?bfFP^hSNn9?ruuQ|xU#Po!|KyiKSGibUAce4^nAa- z@MhhA+a~_`qWs{MDnoHoOSz7#23x+0MH<#sx!S>Fo7`(bm53ue^uqYBJIf}r|J-E< zPM}jO1zA7r8XB|)Eq-?d{gw;rT1RjU@({tO`}>diwu8g5S50WAMJnA~j`I^pC-QWM zR(4(io3Xa|a_A@J7Zu0N?JzrH)U+Fi@Dp$`S+0R8)hI>J&*Seda{^(5YA;Nr ziJ^ueP`JK)Ryx;*NQooZNXl~1t)#tCJ%cEiAmhbtw#Z#d<;Ncnd?HU&Y40k0k3+sE;5*O4pu8d>p#pHWrrLv1I<$48d=&@kdi$b`VKgves^XO7^3a+WN9%DrBQXq zwWmg=F;M~Kvw0wksFF=_5H#Vm_(p&*jcAMbb97~?4YJBRI)X;bm)5sXUHJB02RAJf zwQM6eha>QI1^994HvFFT9SGDZ$$2I&m#@bm{$!=VG0Sm{g*Un-b6VWj9kWAkmmfz| z6U9@@dTsK5geF5%8>IH`hKSReY9`zS*GGlyBR2&RJ=S*swKLQ)7(Ihj(P@vY%yl7^ zU6GYR>DIh`M6hc{_+n8M6DSF)!3N+Rth>~y9RtWzmhzlDoyq~ozyzWfTEM8!@;kBeqY9!ZAKc| z$6P=4Tcy$cfIi$Lq*i)Jx0lM6aA3s+B!0YLLbv?hb&pc-p+{L#*r#pcEV>cZy<9W_ zQeUdYzy#c;rJo`zyU#K-%M0fJ2!eR|26sP|qJf~ZV%OUV@W!brea4RXTt(MUZ!9Ee zX`!15&){2ZbWs7PYIFxfPHzQsk0d7IIwpV#U|mLTiTHr*{d#O?Lp4mawmbi`sW32W6{_T zya-Xpr8Ie`rI8QT{_u$hW!zUb*i zx-WHj0k-7(x{VbA!>}f@Ow7KcB)A*Fk5l#ne4hsTkA11mbC?MU;DtTbX!+P>FG4^b zlugZq+#lZ$u0y2?OQ&K5aEQ!t8cGTehupd|I3 zl|W<-2;dlEPx1Hh*QhPI@ zEnsq;thmlXq68o$^5k+5kV1ndJGL+FX)?nYUR!Y zz1@;Mt(a&aC-|H{j0tdg)>n#+3eSOj*kR!~()6|)BrFHBF$R&0h82qpyUUw~!Kr6_ zzdEizCs^=8#EY}n{q~~kH$9vWUgS7`$g6-SS2iD-m#sR_z5kZM=D3^$X|gVYz>l|B z8b5R3%%s%x{c*~OHbl2wj|Y%mEV(C;S~xyL)BrRFkGn~mN*6e}=RuS2JRxZgY$Waw z%ciy30(vYoeFh|(Zw?wrTu8O_#4s`jPA$k#x*f&h}vNT2)8rE>+$c zC#p4my$GKBR~|?_IbJsH6T^J<@Cnw#es*D{UvApc${o?Za-rep>BBPF0Ub|gl?EW2ECf>l8kCA0)nLv;&6dZ9FZeo?t zKNq_M5tLIM1(;gALY(4PTgM0xk+z1wyOfE{;WXjXX<8gsYi&cb_t2NG84%$w*`qZJ zoO!;Wobi?cj{kM93y_<>n%PlzwXr8%emya&`p!%XjZXylDz3+PxHsic-E~?>^H_oG zOCAVIMQ?@DIPD3zT<5R07Z2ZeAx9Jd5}AX;(nsZ=zVY6sLfhy1&9Jwy#3E-s07k-;M;Oky$XC79g<(OthVLUbema?ei2W8M>@| zazH;^&I!yFvE4;?2lW8^9ZtYAADllsQEtyi#9jP>ANtD)k#}iM^HFZUz156*4vqsQnJM6d*zu(KQMri3&o6+g)0Kmu56DpB) zLu@K3A6`p5zcCc~;`aCQsEn|iL>^~9*T*SID@LhHvL;w4QL_%TLHq6a<1(84;{dnS zhCW6fGh^+oVzsZ2hUYvFqk`@M95U%wUtPj}{e9`)%P@khE?&E-n>OtnkLQ$^bH&N% zlXUl9+uh-^=43-uZFL=3zOT6k0U=cf3pXkL2_pG&Rqa}~I6>t&hv-G%31j!=gQBJp=!;mv!*?_GZc zR*4RyOh|vjiB{=N#ln^5d_ePgXEzZaAJKH%!8Fw!)%N_sP6_L(Z*2F;0=) zAnEkIMP?aeSZJ@cqmnBmTt(9#apG%mmgVcULs*{-XTFw0@f4i+1B%3R&{tFcInENR zx;21=XJHxV7`Gb4CB#Fws^jN-pa+xo@Yh!!1inZQmzTYX-$4-eCiH}}>COnZ1tu~Nw zbcrI21J(V`QHYRjtd*`=`GpN=qHD4fND`i>SG% z#a(IfO0)q&XDS?r8^au@M3pX&AGx*$tb^S)R3N4*XR}ggl^NX_J#Y8MNlei)A33wH zw?yrjn|!H{=t>a*W#&wra&Pu)4~%aJ$KY+taib%r@?j;6sYHX5byKkV5x}n27gh!w zBrPHe9_F$3;V|obYV-Q+RP*W1^d!Mc_LpicE{TTe0h97c37&n%*B_~2LbX=AUR1t$c;IN#+}-%P_uhhy%^))GnfG0g zb9yAEpPMAFRLFYdS%&bML|i{d-nf<2u#-8W;d?CrA6YO@ply`KOW7xZU*fQFeXHV% zD=K#yH?rA0y?bYkT1{n{I^hssIOBfQmWP~@r*m|ksY?;!6}`se}#k z3$Sa%180=u4u2btn~C*6^?Pg`QqugG-mcGZO6%I=xh$wvgayarA4Wh6bK{63u2)Sr zAW^-1l4NlK1oNAq-2}B;?vmS~7JHBkYZ7G&{l;`L2_G*Md@-d9I3;zT+;b0*5IEWk z$9*sLkRW5VrKV;r$@19lHr}v!W|Z>#F&1Pipa!Tpc9C_b{V;pZ;OpQKuyN+<55Pt- z0gjI@I(ZE=EiPd!o8~XPZrw}W3DG(O76t2s`gdRmCL6i$l?K>vi=x}{Kr__omkU~T_@5davH*e2(WEj%%Bc||lLz`o*c}9TBoDb? za#>obzL+r51n?&oBocbrYO7~@G+dV#r&ZgKW#b59A}ZXDs|)?3Ai_srbA)27H8W}W z%8?+MPj~L-x-5JrvuT%o!!>m8lGdudq{5-2NiU@g51mWoJ0v*j8qN!CY@gV3TivKl zP~+#PTJ!Fx$_`*@QXQVYc%(t=st5h#OXGK4cL@%R;Y|usG%kcB9O!VZzXdHWPMWBW zb7OZ5+g*4+A2ZesWU;3?y0P5=>TdZWUR267iH&s9@Hpv~Ju|*tw#agQUWo1E<8PDWJD` zJ?xqp_vV5t>x&*Hn!|?m7CdxE1MmfkVgp)E3%`Nq567;20v1^e`Ud@iM%7DV?*|9H zf(7sRdQ=L#gy|JqIY%Fh23kxVG?w(c9=R|%7xB8}Gf-{ykbN~rib<%mXXYezV;=Kq zegvJJc6JJ|?{N;CdertA^(WJ3lcFzXpNKzL?24K8_bU_fZ25ZTm z1Q|2imvlfMa-g2*!q$|SeGF ze?VEr-?~aWkPrDx=VOM@@y|LNwA-3XJDYRTN1o4hLuHPCV|i$>Kp{bXLE*Xk9j4k9 zPxca1k}y^&|z zGnPkMAvkBUazLQLcGd#a=S(DQM{=Uk7q zlIP%H3#c1*C|D&!;fyRk3Fv1Nr!I@OzmY$6v_0G~D`Mv3h&llumqfNSMiNHBq;SaH zvQ9AnK&9IOHJ353cl?v`idG4C-tMc4Uo`n(8i(JfYp-Ia`e7!hAIa7+MPH+$@$E?6 z=P={vN@h#MHOR`;b!qjRDT;HqGcjQ?XLqzi9*q~L-kJg>Of6!tJxQhcIi_LL`pL$T zKDTVc49DxOF`}j0#h-mI9b_~$S^VgAgtLa2w^>_c(a!&7?5W9t6D-JvHnT#={(1|% zVFImbPIQ4T6OTrFono@;>(B$sQBqEp{cO_?tdr|0b*fp8pccDVZoPg;fMZg;{yHzW z5AWm|A?85^YGk}01@#=1w0RAt3bt#59y1U|ye20%GN9j5co2Wyw5pBrc6g2wNE~0k zB2-W}xz0OXD?%+1Hyq-+7r)^B^3DsT4xDGaCz-q=>ln);bw@14+6&X-{1PMm!O^5F zL};g#Xzu#D+6%p{@q z^4N>?mUl^XDsPIogwF@v6hk=JkPJbha;TH>^U_*mhn=DG(g|Xm+w4X24}y}cQ78Mc zkzO-aidxPkwp0m|0FwRSe zwb_Yt6&F$d*fY97csb7X@ct@QktxB;LK=2)Q3&G2kq1_Hj51i2dM9tsUhS_a=4Ma&0$^U8Q-tT_HaB1$xK?CO0uz=V;J);`amHjU1=oL=evy~mazA97%_r?; z*4`LW?>@1fwL@tL6y#1K(*fy?Q07|&@}U_HT0LXI$uMLuuO7NKv!pn5H=wk0Xv zIbmI6@Z6?KqM)pO`tdX5Uy{mRp?kmZldvN_ogh!_k>+9V6g){(Spsn?3Bdo9OJ1hy zh6fXyU#FdE5o(Uye8z6abxi?IuEU?2mLxLHE<@N(F1tv=nq@9LRt~ZyB$T-&;yL@^&iXx_?SwG{RBwB$ zgjb5AZitoQmm5R5;+v_ueYW`g6S$k(i#rPKDpX95v?3OnHqOWLQZ)*QhU~v!+|yx| z_eKg|-qQGmVxz^I(D#lmMJwWCGOY>KLMv-fq85xFd89`}a?2?g+Aiu2N@bxvLrFy&Q}%w3%}o+g>FU34=*VT(lkW)*x`xr^rfn zk-!8o(d|GvXqLDJCn~98blEKAwE}^P;OeEP{B}%?u`A5>QBiq4%D4c=DRn|gKk}f& zF$n3s-)*E~zz(sn5ox+)>zn=nkYmU?A8I`>(@MTX9|mS1tBIbPqvmfB6nSUm5XDgG z#j+{eKrWq92QD}owyC0{;2A*88k=@Qi)`yP%dPhM_QY}_RA0hbXRQ$w`*nYobeKK+ zo*|YxU=u`XQFb23%pWnzci89ZHI`#&5$!&IP=>(EZ(d8Z(ZEU?6 zS8!cLF3QVxBs_R+Dfq!NhFQ3c0u##gsFebvr)BmY<H{)iotYTe9hGiwNv{oMCDZnJ=O`JDFPHT6)#pFs_Z_h8m*+#rqsUs8DC0o+YXVXaTQCfKz>K(OE+o|Gx z_kyl>T`{Y}M5@M@h%SL6qI<0G`R?kXS z<3?MBT;~!{33?wwF0|WTv50$L=hr;$=IMKJ@72^mg0%1tXxGh$v z7pP;$CPP`oj#O;7=z>1U)am#NQq1!y9W#f3Xz+9byWy@dZoSh6@!9yrp39T1%z@;zGF3&q zVvAzbC)XmEu56C9$4lKWUoX*ADKUn5J~3m$D5G|m={@Rglwo8GU_?*mecP~A`Yp6$ z2*aeMScvQRYl#rDI5%&o1`736eMEs}*u{vsZrEA3<;Se%<8Ceo z$Lh);wmkX<&G(;h5jG~2&rsO?ppIrXH>kXZHo1v86&t9D3=;N(%_y8tGG8AtdizcX z!T&m=Zyg|<=l0{otJnW;0xe9(fM6OM(M*^7zO{W7%beeyGkVV-=ORXPOxyabJR%FvVF#Ktw@ph?u#B#|q20?>9eqpix^W7`Gl)9+#_tkfgQR6W=yDKAH~gR^T9(RC%;d|s{vvG()* z_a2A&dl|@Zb)BipA6ShzlRC+B2mDbRkMs6h?$}H;y)UlER(-ew+X9#U7Zw5fIrXqA zF(niQ9z?8#AT954RH!FL--qs2g)H~6efKwCBf|y9!`&iv@q~Iy6PDbu$g59*619zy zA8)NpM_x*ILBPZJ_-!7|mMQO2-y$p9GhH6ET~B;ie@<2WGGe}3+5v3YT-(8>b7xl>e+F@*?OGp=;1|B&gb7?1lIzp5v+>Fjqis%LCvA~N)pvxFe@!aAKpQk7n8vps7H)D#u z6y;Xqol@N+p2XKzhZou#40g8G7dmwWX#`J9saz;pnNX$Pm&CkB(1|9&Ei!F9U%B1e zo0_jcmzi3txV|ODP&LPpN+BS-eADmc+WK7mV{NPJyx3vhgq3Goxg^0kFCvOj03Qoj zG)^wFBt$NqP#HUdoFML(%dmwzsG)n4m$+5rtrXu3S$3>__>Pyb$bS;*KYq<%9T4&q z!}sd+j~4j3!(BNU%a&#WTXcK>I*)yca1|`j3?10BANxXQ+=Zjx>8wRvkHo+8*ORf8 z_eg>;xIRQ`$Xy+KsiaBeWgv|60`@=%xJ$JnJlx+#Lf2Iyt{V>bASlz~B@x_AF6Z~p z%9GqjM_+r%g!v?OQ5q)~ElX#csF++XVY~+BX6G=t9uW&AHzDKQ4kzkScr=;au6b0l zS-3mLNaIXuSVyXs5Z`26)Rtbl<}f-bgx4wq5VYg>)HZI$JrKa(PdV_wRY9UsJR$)} zs6<%Y`|#y1sA(nD2h5i~f6{O%@t5UEBqnb?k4&Y7PDQs3B8KULT*O&Y|ufG`XN zwQGtSjujWy?m`t^X$Yh=<4uRXNK9MF49xVItUuHt>XU*RBt71fN zSKN7xX(cB(%tkzxvU0uBD)SQ;vni`w?7m5oL72ms2Jgy7fA!+*Gp}K-XZ0E(nUHEc zQMR112Zl^CIH4N{rUbyTmI=UzP{J_2rNF^CW+yvYtxB(>0cnr=_|T19<42OqG_{mg zPZm=+iqm)I?bC_!8VV*FEV;{@N$yE>ivbYW*D_8Mcx< zWjp;9W%d%mPf+RCM=~LOi1#*g-T`1g4dBpb#4Q8$xm7Uu?6+iJ5L>mjnmx{rvQjn& zWNfo@)+tI#^|Vy59`cluxQL)(5zm-CV0ckEoZ;Sko(qubg{EBUN`3E1(G8Pr-$=0G zX`t5%R^!JnRtDHs;uLVx08wL>H{zhL{FmUMdn!4GDlrPzll!1xqRCFyRnz(+*t~hA z{wng->OpXhvaalHZ{ECADJ3@M5!zodc1ue-6+9cW?N7IG6q!iRwZa(jhpj!PTd&hD zBxkV+cx?FEQ0rP1kdX^eyx-NZ=+p_oC?o-_+nA?bvDxk;^u`tO=7$%+&Z`Cq*U;z| z9O;g97jh>6bR)SggwJo=dfXsHQHnrI_t1P;rs^}kLU~pDq21&oCPJ-WYz02Rq3)_q zHgM#Xr}#@p(LdtH_agn>dG>64M&#ClWd6KQxKHppU5Wc>B)I=bhWN?F{4s@pY(a${UmRGvg^#-W>CG zvUD0RCf~R8q2ALlDX%EBaD!wF$h&jpDHRs#n@MHb2x73q=t4vRhlYVsjLX|uOwgu6 zMji2a(r)(Bh`_6q)#|=sWW%Q`R4}ZpJCdL=Ofooojv-u>m zN~bHJ5c5h1vF^HeI#*lFb*dV-3iWOaDi1NwIF46qj%ufoam1dp)JXM%*bW4VcOWmV z?AQ^^%d=NC(e;ybCp5E+Y41|k5mhvAe*xsb!>TB9=>m;$%G7;m3noJ?mBN8rdP71B zWl2kJl*r2=p)}O4hsdn-oUL9stYr*GE8(gl?JSK}3IvxJTCEoPOGRWW4slaSyn2nr zii5p_c2A)0CKF`AKV~Y-6KJ{i>W;U>D;oE=41D79`7q9r?XqSC!~`Erbwa-Eh34}1 z`5rHm#K{TA2whg%B3ibf8FVu9bohtd)UgAYs`hxfN~^`Fd1tK_LJ?(hHg!s`$=6K$ zZH-TF2^(3@ba4hgpA0@2Hmq>*^-EqFnK4o@Q+?9KJX;2>a1?98z}Bhj{+EZ3G%Sl& zzNZkFdY>veXuHOrtdkW|GJYgd*KMu%YJ2V3TCL?)*UMYQ6WxO-Y<5g1OSfZE^Dh4> zw(`zrD5LPPn0u8W{?Fr)y=yM&9j9l%TKvARSmtENg;tF@MiTEZC;tkW8O)yK%1^{xcn%sRp&dZcP z;l*9yx4|V34Iy%AA_mW?x| zv`mn>rE;1Xxr6r@1KkmY0b~AJooi;6-D zkxjs~3L|=f9pFrwBR%#RnkmM-r6_;ulHF|fP9ALU_CP&_#!|UKro)%_Ay^NlL4lMB zCh+l37uwyjXuV3fb5zr#(wo`<5^SBOwwC)>mA9-IzU?d{=C3Jm#cx#nDdi!B zLZH7UV~H$zw19Xp9G(?&vZKUJy|*B2Wu^T*$o}`uJ15(qZaH+D-YQwsUQhTlc4xk~eGNqka~T zYKK11i6d>owyDi)OZ7rp0~;|DOz5@3^5WrB)3&$j%saBRDl-TYdm(^s8 zUI8sw3ujF56Qm|PY07HJ8O4T1k*&>o&YG0G%Vqph_f9op9J^o1HTxTs_;$2`Am*6z})gqYg=5#t8$lPjemq zM{W9JLtPUI=LuV4$9xKx_?b8Vd^#Ni9yFUDS5!%T-;b6!yNJq2_SY5H=QZk&3Fb{7 z`SYX`eo&Us6ZKHyDV%pZ1n=CE{Sexxn_mI@^TK^7J95Ep?gJ18)a>C)%>d|NDg?6# zvml4)^X8q5i!(;=MN{VD(g(1pJeSd+&k%gE2qftzpD{RXNMi@cQg7~yy+ZVUAM_eh zJ6}>w{)iOhd0NNo*U;q9DeqI-+D6&c0L7)D1RsC;H+9rdR4|*nIyU z*bu&&;L*Q`=w@JsewM_mWA1NG*FN&fGVbN2=h*^6?s>#$f}G3lX6de{K9-egT~5dV zF;cn6*32^WUJ;ayF}@>k(qG(*83F;pZVp7H21s!)c7uJmJnOx9XdYh>;4h7O9L;kU zW;Dkw135CeYVNV#AlY8jXpchp7SMx-L`_lN)SB#;02{bC^HTtDwwT9oeg1LL}RLk)`sV)95V~$)YfI(mCR_n2#FG-#FP%#yS z+g@>yqlq7d>=j1Tbz>O|ES!$=D*mV(@PxffWXV6gfK+VL%` zlXT${pOH!1iez+FtZBahcD!|TS=MBB#Zm6nw9Gq@?pyIEs^>Bli zJ8o;a6y*^0;W$4xD0M2W;w+)rfKtwhBORKPWKCU-o|~`ArLfLe01d}_^(^N!!QG*E zj|rECP5|x496b>so#0JhzFOS%SV(P3U^3bA={AR>L&$4y0OH4Zw7$#{&(yCi3?~DU zi%KZMWnmZqta`X zDS~%C?Dz94*Rkm~;+CV!dN2z0vjKYEENfA8-w=I%d;-&}9VinY-DSRvoxGj~=YddQ z%q^9RYs3T_RIskE6TEkVBB?hC#?W;GyJJ@LKA+X(i-FkyuTpo{0x`lNuUxs{g|jUx zA9eKuCJJh%@)MUNUDF|^d-HNN2u`T;f$!Kfv}AK^!;5$yHt=JpbeGmV{9kVb1X#42 z6!HeozpaANCf3$af|iEO^f3cmU^aI^C>@9{gey>sY7R#fBah9tCI(Cd#z={gJxHFP z!lu^|Dzv9Vzd}-1JMvmjybdL*_GdK=Ou3Gh#itcNX7*43GMX5J@L2>@T{%Fy#CH0I zQa&P&w3{h3F82m7gxZ$F{LXYG>fY6Iy3cTvxvki<1@nN)SQJ>#V-euJ7 zy$hw+C!He0@1lBzu-LnPWdO{0Zw510JE6(X2WSQPkF>jp4u{?UOttE8xUM1d@q;-| ztqSx-fb&8Wc#`8P3b@^=nYUoVPa7CQi3T+~UKLsWO2jgzqISD<@Zw2iPn*CQ{;}2w z3Cgw^zMJzi;|ld;$nyc1J`TIN@r%c&sdgPAX4;{En&4Y{s=z0~!Vz@v$x2Fq1MpdO?G7bIQB(R%i4JxQxxp{qmPF&3ir zkFh1%(z2HZ^dwT65kfT12l%v}s9{3X#`7~FV_10a$)B?HmO|si7sL3~n|Y;^WK69~ zcPUS%>Xfhg@}8L&qhBr{hrPcZu7lc{MaOzUl$KTqsExED{zR4eY@;n7`rL=ClbBmp+y(+gwkpHF&b2qky1w+raC z+dS;Y6ki!;l-+E7j9*;}8bD)N1RqltHgH|>^PNB-}Ams}b)oXR{_dgq~@sZgQR;_gFE!Rjc2ib7#3=%sbAwMOfJIYcZ0O{A3KReAYx%9!YXsVV(C3eA1FD%U6_@%^g^EBSuD;&@F*Mea&S|3}<5^9PRErho{ ztWlw9T%X#jAyD)7>sS`+t5Jr;L$%$PDXvF;?)BD#R$WHMT+40e~CVNq-Ue8uaCipN3fEp3WD z)ccslIi8#iFEh~Ex}Ri*Sf_EAe>+!WR?I>*i^<=LwQCm}eVL`9X)|)nK zv_LWilA4y3{Le?)7d&PW1-aQK{e9m2i&rUXQc3O>)5;%&mcT;=bUW)tfhQgoSJei z(ja*0s|X1w}WVOeo!-_Vc|KMz(;kFJZ6(Zkknq9*L5*ADMf@>mthW*{ooZSf9;1J$Tw;faZXCu`2U+pI#! zB3Z1qc?q+OdSxetK;bmp@7YeVRdcIH;bX@`3MV_tC}xH;>ji^Mw=S?d+-L7uYqT>sx|#tN&<0UCuA~mF;Zc6rI+DcM*?svu^}+7u^OMSS z`x{qh^i(@!AMh$IjKWx{MR@mm7^YZ~Z_(x`yTV(Kbt8Sa8tN^ZS4!xWL_^3o&|p#2 z=eZ$^e|hje`P|bpDr&E9$azQ)mJ$wqRKGv^5)t~cf)|n_0&!B_V=|TECF729+wB+Q z4D@X!n>Lo`o~&`U*jygz9)N;*=a6!mo@=&YcN2&!@eQ2S?d`S8PVCkuUA?r^9`BG9 z*CFmD>~gy~oet1xmp$!e$9Nu1GJ)#;r(M6ZyzOoUM7QtIu&9G%eD9)t%4gg*Lb@>tu zs^i-PP_0KgFW8+^BZc>KiJmiAS!&&$^7$^{{e48(^+SbV9E8e97#6r&OIfN{e(@Z@ zWqNi6u7R8eT|k%)%L>GuQ_I$WX&HK&sfe2e7ziaAFtdQL^7h*Yh!|7b8NUZz*}V{t zt`ckoq_TIJu*xZl%{78rwaU_oX@3s%(N(mp?(^GJ>_H=TasbvM8%mNQCL2kzP#T+u zEX{B-kB%kGc_;tqr?v%Rw_xU`eCAAB!b=vM(u1>DjXK##@{}EAS?c{xrpXH@XdI52 z*necry)&}H;g4Et7_BYhQRO)V1+$~*8C>{hFw z8w+&9fYR#ZU%0!ubh*7hZ4IvbS{g6QqkuJYqyd&y5;^r+G}5tAUT~Y`o zAvpP0AjswIF~ztTyapuTR76V3)Tv^{sZbhXvUZMZwkDFw6E%l0As}4hgQ%K#A{Z2a zh4wkTJOq694LfUxHJgEd%}xJim;s3Rlxi1Kq6zCj^JCGpPv)Ik48Pr%V~= zY3WPXv)LVNGDC(@Uq}Up?5z3=@uG)gU}&s_y5$^t#=SlE;C94{=dM&ik#TVhY>ds7do^p*IFEj*S5?m2)D0W^9U+ z-#JTTF!Ol|)q=%&b;Sx~-)`ydrciFMHzu^vQI-Lp43Fe0TSJb4np%!StHoN&J-L~F zpY3i{>W-Sayf6DVw}KSvhpPivwB)vRJryY>7AL%)?v`s5*wu;u ze^P_rf2AYE<^)>;7fH`eeyi7igP-@{#v?LzKP|LO-9Gp|M!YzIt>(?MBn-b&*nU9W zzy1|W!ycTby+3j&mkYCSR~5{*Z&Q5W2R%scBhhhGTyi8`HRv)0kY1LgO1t9p!AO!+ z7_-6C`Q`0AG@iRaTX0NL>5-mtONwY^1@SWjr^R^NQH-Qwdw!LDf-KMexh>2mSxaM2z|aa_<|SQ!Dg z6{7|Xzg}wq1+I9)%+PwW8Z~8Ey`=pWd%ED1Yx;7658)p(X(=KyS`zHASj{%BIELtJzPfjQB1~y0+4DB_|wAx~x%uggSz0a7%ry`q|l9DT! zrD0{sHZBmg8rL_4Jz+D%I6}$y88fGC!AzC3hPtAg zU%OTZjN~QiFb_knaYRn7Lxn!bD~X+ut0B&Ytn#Kns7^VUbY?_P@Q!-&hRBRt*v>Tb zYb;sTD}_7TN;W#mQ(t%rTwl(J2E$f`vg|s1XRmgD0JFxvjHXr9l-&y?*r(#lF9z2nLN_!}FnMe|xcq+5jm(@U1M4P}bEWS;Ip zflNkIL4}FF;zA8jsm+$OLwPOVioC%NQNccumyz;kXeJM*>kfvNHBL%H$-Rzt&{b6IbFD8>}p zFInw*;~mtMYoet`%DekzKLqgJqZN@;F`<}NS&z`xEql(SsIew+5y`7_ziE|1*4Ca@ zN447%yP!RToed}9)~`^?6AdsA*R8TW&850q>9S*oHY9R;dFQ?eHk7)xhzKiNQa7&0 ztp=qeqGcKICVA-iKNk>Bu3w^G-^gtQ+M~vp>L4eFp&J@+QH8EZ>%PaS>j53((&B|- zc2-j=gD!~Z<^qfZYwSH_ge$^0HCe!~NPVgz)% zcudJ#QG-vmx{X#Szdr1`g;M^~eq4>`PK1VOFJh%_(?pC!%_Kehz$EvXBlWqit|kYb zTHlXa=E~sW%YOWU>Q8bz6gcg8CBbXal56)LbSEEa)mI)X<{Rr`%McQVfj-F63$ALy5NsRzDD0_r#uG4 zWP|SPxub?@B3(1mIft`3?=_vIxWxx7bUEQ1-2144*R?{1mm)HeXj+)R!IR&BMog~) z87d)a2HUlsfq_R)Vxo`AoK3%h@=u7o!(%!iOz{CbW8w6H66yAsx;dDQ%OkFGSCvfP z0XTKyZ{JM#%Lx~YKBy?u$c~t4cLK>6zXmDF&guR|8&sJCgp*D{tNqhsAEcu8B;G=6 zU9;O_-pJ?EsUgEM;M7Ud9+;xiih-$_Z}5iuNoj%`frf=;NMm+lo({Ayym23aB$T>1 z)>2Y-v%qPB*WZ}>Tj$y^nCN|Wi@9p)lb$AxO$F&?ltYSrWO*uTKtow2?UrkyE(X1$ zs);jK5NiViEYjXb-TxTC&W#vw2Hl(SQOnZy?PZs}2d@&O`H(9M%)_+9yol41ftNG= zSyXO=$4SwY!)cXQR`Ta48`j_WPH`D!EZKlmPsqLo_*jpu0d2{vcjk>+8iQc#^3(~e)OGYtvpFXUY=rtZ+Wr^E- zat#;&&7mIgISjp)7@ycee|{KL5aUfgB!)8sSc{ZvxOFRVgH{iFXNMkby(p|gUQBFx zKrYS+{oZEh&1(20Q=Cxm`S`A8i*R1cCjl^xw@ILX{h-It(skaH0c$Ja=*_%2%G1qU z00M|7YL%8?+S5!`wl^=Y(u8zRww&x;k2HwoeL)b$SiUk;LA0x`=eeUv7FNrHdyzR! zZ{c|N%s}sr_Tcg-JCc1_W^#OA$}Y`h4NEg8O&)n2+aEn?lb~jjkv)?&Dn-~mzx*b3 zrn|boYbD6Uq}#Z_sJzJG*n~BjdlQi!p@s5{aY=EvUhu4@G7_Wz49nKNy!tYy2$#B^xgEEO47ziY87~Vch~okL#pPHfMW2f_ zJm85NYvo|&lm*H4RdhC*FPd`;UaydDZD)Ivp?4P)i}fM>yt%nRv~23y-^J=)2830p z&S^5?!uJ44LOC3RW$9rPQvmKm_}r^`edDTtOlo2e0MDkU=Yq?Bdi{bkn$vTBbePrD#`ahZ=b_yQ6a*r z+N&sVtjuGamDWUkivqBf>F*1Yjuhv?2#0LskE9YBKVl>VAQIJbcEqv`nab~GdVQ}j zWn2NRP8G)Ylob2EKn@f67RlTeJ@TIw{@-8WbRvt34;%d$&<%rNBym1q-Is{8lL3Z% zCd}2PccNU{^Q5BPOH@)Z6e}rifBoH3!;U=(`*_A zEm-MSiY%`+Z_4WzPaHtNm*Y`+`siG`p}Jc}1iPiDntqY!* z)hh6r(3DpdTjfURGn?|Wz^IBSY5;Ilh*7CGqpxKJ8Wx#EexNkP#Ui>B49$^s4CObQ z4xTH|saY}w#mzL(v6$ID3LOaF{E;3I{zBY5)+F~@fhAp9xbm;3{$P{|sC;MVWOf^a+`$w@8!tP9_SEy5Qm z=Cw>&#)7K9vFv}96hEH7P9CS8pTT&BLkmXBtu|Kev@!CO0LXtzUwN?YwYzPAo>+96 zDc4FV8JFj24icF$d{7^J3cowagj!!y$}&>w*agdjp0}?!biJouoLqj!*cJTk7BuDr zc6A<(Q%Vh;h)t@2d|3}Wj!nb7`V?W}7DSdL*wBQ{*LV+G*J3%7FS9QCvqzLc8z+jq zrSo~{Gsdrr6@fTn($cY^-e38Na$`lt-mal}W#*lU2EGi!ASm7~F16Be#>uX~w>zi* zu07w*{BW{SP!90BlaCRsvG=^^KJ|-mbZEe{GT;1UC-EQq=Ff{<<-f<7yUzdQ%->h4 z1POY-RPoU5uN(-lPPlgExr6QDsypv%c4hy^ZG$(cQ525Y1+OAY<`7N;#mwpn$ip+<^#<79XCXiB_Nd9e8j z-^o1xpaa^M{`o1-h~t*Hop(xA^gf6T`gL;2u;A<(swH7m{$C^ zaxEg3RZ0=OxVXN#?^gXEAeAdWX}DWHKi|OgPwx(yIt1CtIU?5%%?IsISWIC?jw*I* z0n@;`un`=alIEl!xDVJ}TR+qRBXd61e@Ht8i$NH1h-dg6#|Z2qdz0+y0@Ve?89BIht`3LkE(nHsb8SBPAKyM6 zue|j2yv?xuwHsIj~#RFSkK5|XSLVppATrFkcKJyu}1W+~_TrpC0dv4?VL z?55b#wF?76cg4u<(y0jX&Tvr^9d{A-CNQP0eYUsS_^&bgl{ujw*gbcyAu(Tg&h4{}rQ8^0hz?T8{s8QOp<&vaa zFNgbYiW}w-2JJB+jCv($i%Y3O-P=C>Cam4rS)_G>Z{2;4X)0TN){!W49%Q@zsc}F2 zzYH3&He`kourvfY<^H>w`M5ASUb(Is2qzITf&Wbm_P7>-b%9_i zu@E1KuYZ4~-!9!Be@Ies<+YmZaK)#gH2OOD{Qw6%H6FohJx%#3_}m?LFYwknq8mPq zIKpM92Muc0O;qL;SblW%c!4I3J=i1R%C_zcfu^xT-FE^6lA!a%hyv4yyIPK$FEt!0 z-sgx{3QR{HWy=V2+Gf<**5&|;4)=Xb@>Q;&dsGA@1ioLx!QV@oY)T`xomY^@qCv$gSG9^D1S6z=9 za!2{U*)=GjZG!;4%h1N27OZu;bJ2ZzInRxp^X90GK2AD5 z%8l0fb~JwdcoPV?irN((#5O3X!ULUf^g#og4eBqL=R~>*edQ4M2j<_OKjK$r%xc52 z9aqFVt#EX0QurvhS#Wd#L!H{$`o3f9F`J#H+Xs^GbbDL(-5VPvRf(T7NT?4#WUDaw zB?-PzO}xua-x~wE_P6GCE*&iYjRe01hBFE{yS>3TH3+@_YKiW_y?-1knb2c;)}&v9 ziE9=vb5mQ@1QPk;`m)%E;65|!|M)-cac#xbRX2C{+A+L9qe9-`p&3QDX{s!zZgx+`%B-=$Y52nv5MU%k$Xy6tHnR;v(}x> z7na-1Q?n(jvuzP__?(x%b$`P}i7cl(8U?`$<|wEB3!t?pip~)o^$}Jf9H&|UzC{MM zXAe#7JHQZRPgKaH0d+%6nbL4chS{CU{Ag0asI*0nql32mdRb{^x$5(;eab&XStX^= zdW%Ld%2k))R-LOUaN<#ymUZu49ONaUTA5PKW=v#G z>B)QAM$x|`+D1B?+Fyb@rI}w8_PIT^eZy+1yHs`eqf_W&=Ca_YLKl%w3ek~DkmQdB!)PO~UymGRQ{UF^dfuEyKB$a(m)UGu}oz&?Yv zHp_r>Y*a{QsB61Of1zJ# z#dXDj{G;dm*Ynf&hRdxN+Dtbt{ki$iJaO60Jrxpw67wqf^~DWTUB=ou9r1p$)~~ws zC!5iH55dAI1oCw+snJ3|HkIpPLV2dfCYJx|)rBz>aay@fn0|cg-`>?#9v57&3Sg>Z zMIR(k@(QoKe~-PV$mX^3Jh}b!?TToIe|@Cif4cR#f@{6_i0cSD$saNZ&4y11X? zFY?3pQ^1vzUf6dlP)0Z8_m}uH@F$75UQINTDC z2GSymaUlpx`eaK=jz{` zo25(Z&}OIA#Yc?l(E0f^S9OXw_4P2HDy#;yaaIuK@^abE-?9hYs1cxK*65|i8^1fO zDYKrnnN?>wPeONAaPP?k$S*XJrjE5AE9#{F5j+3y>x;cGDx`6N+5UR}O4xra4yK|q zr`@i4cR{|EKB{G@MMuJo$R+wO)yy%k3e4AUvj#-wtB0yrGi_(uza0=F_2hohkP=vR zvm4QMfScXT>ZC@A@96V3c5UaIZL8g{wKeVqJWghB-nciF+~aR))wA_-Ij8rYS}A_f zcT*%`cU&}qUdQ@k(fy?)z(~1nza!{2|H#T==%xSb%dFF-0o-^3FKXIkLf{oWH=w?lZl^H~5IOTmma-y@-m_jJ3Pid!CC3`w7A$ ziN=Ze1}SWrHg;H))9AVoh!2}sX@@;P&D(MI0$|iNQ@XpI3M6z6H1COTU-N~0c%!?Q zVWp9cdTX_2L#O5!vCfmt>K+b>Ld}Cy>-TmPx<@^FrxFO`W8-&f?2Fl*I!X)PE7h;g z6x?H0Q(xX4uxfp(Fr|9&SoUB-lB)OxRsb5hCRd1(m~*`Kjy_mXN0ol6USajiQ8JoM za3svpGV++ZLD`mc%Q1y*xB8;1l|ib(RC2Cn-Hg(`X&K7{PdMu~Pwp!}^Vd$mt|Sb> zT50Q&eYU9Ys~sH{%%>JDNwrw>XhBtVDmPCSEpS zTc{$PONn)(-b4LnGJ8qrnr&W+US9&vHaB{E!73%iS6TeyNkS0NGM_C;Fl*Od3tL;k zEL}MENsIGv81<)Jr^VHk$p)pF3TdIN`%AkM%5i15Z}F^lOs^JRnjUaFm?Qnc@o+8r zY;>7WthVUOwFRkH)dX;jeerSC@s|eZGClvekUv)5x40%FOjpY}IClzR`|8i=`Ss6a z60n+&o5@oBV;}t3-#^X^cuJLqE1+K;WN%WMKn%$TgIvvT>w*mC=u9OT7?K_^2nV-P z(Cn9Xg4~HrvJ^;T({KEwGGMCtm1fGH34RmdLoJts9?h(kTTO>v3En9zs)~xon7{L>L2~R52oodwuk*K^FLSFUspoL4tIy+ zh^4BW-G{6nC*7zxeCtIEmX5>U)>&Iz8B#6%N_&0HA27pT*Q6$(3{Fd zt2d#Vt%!JVg;eoiu=CD3mv$8iHa(821|y1xK{S%TrR?FN1pA$B@|K-TN*y zW>m9{!UR^*o27U@aT#Q&xaweQ39ou3R5;^V`qqGjo09Aedr0Bx1Jpji`zi|GzxRKv zcUMt_Lz+Rv%UAy@3w@u#Nknx{mhkIWay>?-yuAJ4SkF1@Zx8(SMZe#2x}&)0H9M{@ zH9woZ^!v*MQom@3xFUKO&{sC&PirZ1#j9}C^c5)X51 z>06(tRs)C9E-#S%CCs#$bMvit_$4n(B@?C23F&4>&g<7ovI@JH>mU6Lk-!*9^5l6? z<;&tfzi-Vo7iT5^mlvjfWeaDnV?{*%T}fZV=pQTATo!J!I)B16>9>VKMIe8E_dw-- zrm;&Xp2fj4nLhtnD~X7;;ytmyNLG;IVNKWHsOG0;T23@QG`c~4G8Kt!XkZ=7_8mAf zGRf@V^zPB2w)8ud4@aqf`&6{J*ZtYj8TJXH*$>>Wj*$uqY`ntRADJIw`i{_g+|}a< z`UcfARQzGBE%w6{FaNpd&d9s;=b~yVZu|&^(Al9Y>mOw<6S+{D>kRToVu8bxd5|8=L~%3Y(I2YO7$3r0CZ(5!KR z&nUnJ{f6s{ql(3LR%#FfYmy8{9aMY_VBq-4Y?*x$gkiRW%{ax6Ubgn9+6o zq@lB7l&f0rn9GlUMLf)}xlG-XqL#AW_v0mh#*kl8=?Q&V|Me%XACCX$e!{Q9bWj(% zyruhp3l+XTxF9S@pJU$@+rrwI;Stg5VBV$|HW`uQosZUtpu2aq98 z12aAcE6HW$fbJDm8xCL^bHst~7$)|!)@TmoY?@?p_c>ME-QnbzsWE7aySKS*AjG#& zMVkD+jkT#QR&)YMLLgI6k_8#{sB}2=3`R|&I*$Lm@&EYl;ZG5ebyB%y^8WR#s(aA1 zvDlc;u=sX8wAs?i6}2^A2mXC|em^NL(x0^)mznC!)J=uNp`?{xMu@U3=TYmn>j{S8@|NB$b4cN=I!ExUb2jM!x1`Crq>YLX8O-o&e z2pe+M7kZR`F7zrKR|F9`8RKQAAfudg@U?6On+v{fP|_PhgYrgVcz70cCOVspN zQb_clj$*>DfqaXxf}8~CGgxmYoP_}|aeY{WUJwU8{@{0D8oaXz{jlbw&{ew(n&j^{ z=$g_%td?^IK8(|Y5*{&}N)`y&V=|BH@$$q;+-*TWo6Q38+ zF1zY#9o3R5DPdMW@dKDoe^pTJdDb^oxUd+-fW3F)p^@uhhY)-J^Md(IX-unUlvcgB zf1OWNGU1@{vIZ(iH-y37!^rze%3FD6!;$25!28zY@pFZp)eU{;+u%B+-ZTj^%gkl) z9oV*<>+3~iDFGIFuNQ0f4JqqA?jF-iP3wSRXJYA;Vc1nW3*Kc0;8x;Vfc7m~x#*Vx zGa1B07KOIv-LTCwt{&5h8|2ZyHEP|zIv_`w5NG4K;dkWrI|f-ZIzBc?{FIwP>cij2 zOWna}yx9 zC$A3Chj}*r&c|6%<%KRWABc*-j@T_VQA$>#G)7@bb?bfz{HaauN9!6H?E2^owk2&*I`76+|Q5tyr6}(lp=cu4Vp22mT z73BN5-ojjXmro`SK7nfDND5ZlsNNyWf7J=w4^q(mB|;HC;u^b8K-)=ob>HT<%QII^x3xY+WRTjLciSpI zJ974XQ)InL)nS$3c=lG+gwOzhB0;Rl7>t8pAbM~Xy4l;OZ+PeusI(`bd1C-n8d-!f=~wG+hj$H?(uRqp z{0y13xsj;xVa@Yk#RG*Wz1eexkUNwN$JYAe|7y%8W0l5OdCaxtJ|g^_q#o##w)b^C2Fv9E@lC{{@fu)?r>Fl-qud@88}7^QGA8`ZwN*r z!84}B&xn8tBp=>-A1?%uNIx{}xb%jKs%MSoC4H;ertZz9ZiG5F_ZtF}-S;6V{Jv=g zXYN$CBT3GrGt+cdyw93O*heK8k*|EaM$+>0OB-#2)bm;**9N!rcWB8bCR_CGR}ZC= zjlAyJs2IsDcFptG(6tw`52r!XbKxWp{wFvCV@vhJc>lCLe$3(qh2tRu;u04orMlsh zjZ%pA0gn;@(WkPi7l9sPTRV_5nlg|0PC=mNgVRDW#z3m_3TRjdz&qbo=(HDd5Fr7; ziTZsXUg;n>so$-rJ7B~^j*d+%2GmU*6eq3&35lC_l5WL*nkZOK-lO`}A9Oy&U`*!2 zk$$6846y!vXHvMp)TEtHf7{<~s>iund(t7W^w~v@mglyV+(IKg?h1r&DcCO*ooU&_ z_g6XjDzcd|@!Me4$gi@v?BAE&@M>tWziF#K{iQDgKjN4rn8wOPcATiEl{^k*j*G{3 zc#OQ=O?G^n%rxr-^<>_#vz{|JIOTZnX+5^nNbcB2M)pqfuKccN{aw2Svl~-V>Yk;f zBpq!x{(KXXenMSBW;K3klMBu;_Q#NLOxMhveq~h8RU%%;F_L^P=Q=u?*+QF61`E+? z{C^J$_HoKQPQ>wI&<=>KMsFQz1bFK73`~iPC7b@F0W^lBKw()pNpDuf(+aSpuQdv- zK;E$p`g1j@4_`k+Eox(4PZtFf*}w;b=y!EDBpDkzf3-C5 z(X(SItG-!=tM>T>0hx~Z=8Ubh^Mu^?f16(g1CPplaC-JjnT|Dc=@#i{hGGrb#1w2B zi@c(svN9J>?`#n7>Ga^_sJOL%-uR;F0QchDs59hM_lkT$a|FnW|DvYOJopcpMf%&PJSa;&?CJa6~tJ53aA44SAbFWv&VEr-b zN}d9zN1I=)@H|Du>s`yskZDdx@a&Z=n$)^jf+_Q?1P-x9$W;mS#u)wc!? zn$afHIUa(RKWo*mX@{EArL`yq{K+sgCu_wuq*QH$AH zeG49e<24HSNFo@f!Wu@UeiQY^v|47&UY!C4R7TBO+v%N2hCmtD3Sm7F!lgzqkM#He z-}#}r9ytd>Rl|f!kIdc+1E%!$C)oNdpnTnx2(C$4l5tBsyTcW|u=iOogT*U4iQ?2N zHd|PkR$u)nsfeY4rGD-+kB>x_>s z@2X_@^Ro>CP{mO;(hyhGq@K1iUou%b=yM89HRr#7CBd&sy_XACf>+!RN{#Gp`X;{&759;)lPGmiixf`gBS({2@!_$tGtk*jcm z;_Qsm#ucZKI8}xzWS=`yOil?CTl+s&Z7Jjr1R`4HAGSv)-R;NT#-_cG_-JHW2V9?z zA_~?-z-@ooSk+y-^ziwY#@VcnR}xz8(yV>>_ZRY9I7SaHavjm7!}?r;j{va4PReCl zm*k?12&dEd6R$IWdTtGoo!MC|VObSfIAfS*3c-Q+iY&l*(Jp{MI0Nyb5Rp@h&Re%E z$jvpkKFKYc$fZ>MkUicI@hKCBDE{*M;&~qP1;C0LnK?A_SdVm=Kri=o%xG9!(6Rej z5W81|pZ?`x|Chu8M8}uIuOh&0GhwKc4;LK9_d2 z`yE6iviHJdmp`ZskxN+@^+nh#jeM`FKzflFi+c0-TpWIgv?4Lwo%V$@5heiv6TaWLeNvLYG*p`Dh5# znWqV&577YH2lhH`$KOA~ln}k+YPVQ8a~f6;hNI6hd~ml0%Kbm2W9)?lVZzAZx4@m2 z46E;Be(gLHZI}{aHvqE@*Z(z*RFK?Wl4_-8JBGO@q3{|~-@pG6d!>Vp;X&hL8zu7y zboncwo@bhiIWfPjpt($t5U=Hc#A#4qK_$2k-c;5-pxKUnmDNAsZo)Bo!Q1qJX63e2 zcARl;>+xZi4O zY-vcAmq&gx=4aUzj3V!`)g4&ArSQu^bNFUe4GN;ahxeAv;~XjCmaGMSnUuv7C2RK% z+lps9oKbS1b_nh>MnXYB!MQISOJ)jSjyd~$TI7{H;kAv4-1e>}F`?Rce&tm3o@SbN zEAD$X^HKo_HqZ^Z9V!cXy4ARU=^=t%fKA8s877(Pr&RtED$2w&MBT+c&+3!u^fY;nfFNIuw;@cg2Y+=gDcA*1f%Q zW1b+t?0VaaJ#}NAsA^oyf3B~COJIIBC@gHOSWuv{oBY*pbFhA=?n(p@khdHewwDSx z7p{T*oN4IpYPth_mhJ)jQ>-if{thOb@{Dq3WXIQ#A7kYF>Q(LCE+q+?Vs7IWWTg6}SJswj++K0u{_>;M6<+xWvz-VI4Y#K& zEW6$vYExK6$Gv+U0rt|CbbD>>uh43pu)Q>N8Uab;ccZfm{W5$dN+T6Zf5E_Vx z5F2jVA4_PLJ`%?z2=>KF^*sez=+m{cM#ep9pwiW{K@xfuneuTfglR37H*D?6R zX67Dx6NC06Iv~^ZI>***)<3Qgx@)t*@$ly^yx3YQhz(T0yt{nW!Qvsh_3-1cL~Twqmv~r&u{6l&c(^p9{T@& zx@1X-C#Nk#^{@@F&c)5gM%;u)^JMz~pE+Gx?7F-s%;D)NoVtBE4VoZeRMM{2iPgV}qXYGXriC?yJkD z4(DXC{Fotl zeOvr;oV=h-y3g)l5XrH4iXib{mWNvIXtrX=dTXv?_yae zR#^Y*#tFCcJ$h3+x=)M~#Tj*zbDIq9T(}0JC-ZN)+b~8C|MoAie!K3*09%I>JHcKM z#)E^~ds<>heZJ`Z(|@iqJ=GZjsk83QM|wU5_%2^Z;s$PcpZl22yTg%stIlhg2#(#I zuK1FF9OdO7W{we_n={dTC{Fp82d!fBNPm1ea=wBKJ~Vf}%f)2r9M1oj_4?2uIiA(!;m11)Y9Nft)<$%?B@c*(d;pa2m zEF(-6$FNUu=p{Hz3BqPG%Q}aR1I&XbWffNV8%na-VOOY|b-DCk)&ke&B$M68w9j>n z3Jn2w`5)FMu!rElm~K{&g~h$wiwD05`(Kk>4Mxyye_au-*@$F1YHI# zG(d6tz=}h*c3gW;1Q`cB&R)sFU}Op3k?yh>;jY43Y)sD%9pAwF?3M-!uY$`$O1&q2 ziy0!`se97C_JV5-c}mc>-mqP}>e>r7t}?ngp5T#imA?dqL*p_9wp^7}Ys)GxjiZ)P zU2f3BzI3)iz7ox;dex~g!k5NxCKm&F3AIilW@#0 zMBT?a>ScHp(+A^C;cqdqT{ds`nl(+MYdY=A= z^Xn5BYftq1#R&{~V{e*P&6YnD!P3=&`f%)$Vc_EO8e(jAk_D>y6#qP~{B(6Y{!_uj zfz~0EsjG$b!4~hkrB^LBNr6j1bCL<~P0ldkN!=I90KTZ6)6DE;O7*CPNCGlB{QOPyYX~EHqb{>`Dr{B53B%E~RXdvwd)$@eF}c==X!do_|Vg z*$_@o%Zmlkx*>0zO)G09QA`9ui7t~``~Ge2D|+EQXfKlJBWx_;^vmB0^2GxeYP7$b zc4KjgsdH;W0Oa3a{K!m>7nA6gaz^7N}34SKRMM^!xa3c~{`7=e7x>LdlB%mS7>G^JS&wKhO9DoaQ58FW%i=`R6Bu z?`jxxOHIwkl*U(YvkFC#_1Y&Kmfnyc-8Pkdiu9PsN^amUkH7lFOCRyEmyxHRdq(xF z8FSEJ?`bi+-O*b(LjNPlvGgm*=-0gaOGY0Pj-mmIilF66KoB8r{nSo%im`K4t9Sd~ zLJ3-}0Pn%TP)^df zo)}wqIz+jcuE%v12dpWApgXwb`VGHyuSQ@$HCUeR2BmC1m49tK7^iAnKnLuCT9B7P z&Eq2_)IezYwK}}+)Vl3kx^U2I+;XZ0$4!>k>itCIPyer=Qs>O(!4!dxcv@cUC1b{j zQRtmMvU0bWdP<$}fnR;1SYMO4W`m~@$__7 zj?1n-OJcR4} zf6~nB51-qCvZP@rgPfHIA~)$$tJ#dbw%|}eq4QiVR2ky%XC?<35uz>T+kU?XgZ7gt zuK{Cz)%X*YJE9*Rjg#}?X6CwV_WJYZ&zuV-7LdSyAkF!C-`i%$?xBk6)#84NK~>`N z!HWF(X!h+9$JVb2IFOzMIT`KBa9qlCaKu6ybdDIIfOk!gh5Ad(2)xgTxKwiSxA&B` z*F&u#_;j@Cotq5)o>*rylytKT@i>qBseif|V?h$c?sA2|Cb!e*Q>#-&ts^@FtxC9~UATQGzl}mIt$B7MxPW;;V*9#%TW}JG451 zv@NDdt2Zp`(&eiZidRf?xnZoS4UN~w-;c5UJQn*PlNy%DQ>PKK9yxlMRwMUtg6N%WJY6;oFBkPlMD(Rs?>4=gP#q$<_zGlu() zOb52tP@esBA<^$4v*vZ>*0-UGR}1k~Dh^ZGj9CBz&92vvrq?`kI}$g5YF6l4=3T{n zMGjgtX;eAoT4WP zBq!U~^1QxeIkgvlCg|WhV?o99A>r@Xi(k7L^D0U$nqwy*L$_l9Ldy?DH9QSaX8H+| zXjG>ls_Ucu1kl(HG<{34PexyhxR!xW@8Tz@=4Au^KKPPF@+8Im4*i2gsYhDdVZ*m0 zQJ_>=0O|(0BxM$wPm2q)(!OC6WQ)&A6s>NIb$_QYI&X%(@L)*}{xG{N8a8kRVuBP2 z!V@m@8}nf<;(h~IL=Bi`uK_^rZMj2p+XXIsaRsWS zlc=tHR>dEfV5F;Qb>FLG&C`BsTqyzlaigNl5OLS+>bkX@rCP6}5;#4MM}nfQzIkDB z&C3QOwy%}X=-CifIRG+asmO2j6IxDzRXT!=W!rt)OovlBX`>A-GsOUm*Lm*~!99mk zhq9)R0d;aw;YYIj^`u9KGGl;k+nGlz??u19VJAR8wgacLd3qm^hmk^sS2|s-=Qwec zoHPgCH+LW}ut;gjN6hijyA0id=G-<-fY4#CoiQ6Q1D9|0e*WnjaXsy+B9g09G*KV1 z>tlR-Jj8Bxj$Weo;buJ6x9J%hB#&Grthvv=ecT|p_SpY@P?pyXg5IPN8NC&=6}k;m zPB-n_earIpVY}8ik#aK0scXwTKOhP#E00k3`63eRt}Yklkk=l60(0F1BgIMd{{mMnv0`& zzaKQ}1Vf)>LO?fgd)>5xIRRzpAfZ^DUag?Z?t&}*(5{><@VeoQ}% zgcT+#EU~n`qy(JY*~oyJz_2`LP%m69^RV_7(jIewHXJc40yu>AXJ0~4yXPr67hsBo zr@6ixUp1>TR}?XwAopA+m%}%H#IK|Qbj+nV3km^XzhW=n>nMe(WY4T+=3m0myztS8 zQLAhb8ev`)cLE;gr90vnAp+u&Z#9KWKp#OEbXXvuik)*L0NVGND^CxN{j#WDtE^!e z*40(4^pt?s1b3r!)yoz7+)L^wX#YxPZohEk^A&}J;RhX+v{`G}KU)3q@68M4$58LL z2Xe+6%(b9^Y^Q267ATOvp$H&K6Tl-m0fgXleR8AW{L8~P&OZFoWm*K|9C{K*&4XJ< z?8gRX#~ZjG9g02k@JlN&y)`4G=ZY%aN!{i*(5+@I0KOS zNF%s4t#ILO2$zJ2$y6#Ma-2Y^3mOO4XB92Zb%`$_mj=~u5<;u5oSGew8gCb9u{EPS zB(7H($_<^CPK=9F`O`2AD#vfLKd|XW1|s~rzQr9;gU6dkU|Vhp0%3CsuRqcP()mJ5 zHI6^UmRl61X!lM3WRgn1n$Q0bFh2F7YItx`E1yFCvF7`An)S)U->b&NeH8+-Ku)o( z8!BUmG10ru-Cg;r8_Q7VBrUCe(JARwKVn&S^)CJZi%vqNW~G5&8zA=+;Tqeq+OIa8R>pjZ=5v6H}9+DspxB#qmU#D=T1-KK--q6AZwqK{fD;Cmmo~ z{FQR|>U;AUqXB~tU;r+=ze(1%Rv6V&(3|hK1^HC10vXdXIvf|@f$yl%V3@?AnaBurBGC2HUO7QS@2HER)_~DD~jEe+-lq{}S(tYol z1$d(-vV4CpH0n`-c?2mVYJLMHB>*;O!$e*N=mmV?Tfp$v)Hcd|Y;HhWEwB5< zQ%ravvxgLK98Ca&gb}9b?#Y<1IrvuN&!l;f25xy5!TgVlD$1mHJgSf2X%A7Kt8SSaJi;k6< zK&N=(`S!kI`fYYkFpXgF)er;I$QG?@V*r_?uD)Vc%$-eqq@$sMJ%`jdPksRC$T)H~ zjZmLqyx#+j3tx2cT@Yt`E3%D1lSl$xN#;nyHHcjLwm^MvG49p;A)=@5A`9cu4xO{@ zQ$T7;ULaWpL2i!;On`A#HTuj#M3pgCE}acb3f_#D!;bf>noh$Gx<*(FJ(>Ty*PWP0 zXTjgBTfSy3~Pz!MfuqWquCROk9a@M1_FXLjht03T#%h70WZ!m16Xe?W?eP%sdB8Fq#iF zCIMVyOZkZ*I{L{Wo-(nSSKwx~Lf_Cqp$=%_EDd~xz%1V;;t#|4GBV?CgvSh#scVO} zYPE>)ajG9 ztq{%=!}`;pSzOJS5=7|q>r?v^$^*)9sTmmWduv(E0_#J~8#Pl&V*h6FwT~0$ zadLvXuzV=~F%kqNY2VmqrD!F4XgiA~8=@|2uD?~xR})XOZvjr;4d$U9AL@7p$xe~D zoV&xC1e1XMip$m_n_!Fh@C;9L5WLjZp!?ek67x3=879{47eCoQ`t|8v$$qU@Vs;|~ zo~$`P`r9s~t2I&;LMUcJnP&%N!P+UZf~^mrld_Ea`d5#UaC)^tkeprS`_3!?1d0eG z@CIVwi?DN@)hcsH4W^IYR@~-B)|w+a86DR89#I{OO|we3^%y*N6ZPEeqnnk#VIdaP1_C z%N84278GOCY;V~7+@ClOL#%$vO!U=oafM_}4Y$WhOlm%~CB_%;e4&t4t!f+)eq)sE zeut{68vNU#hv`R8h?U$hbrHZvfl{o@@SqYR1Ad4Z7`7e;?}snt>9KHidxQGsYOS%f zo?yS&SBbqj9#d8WIrikP}E2#r( z)Xz@fYCX!H-*Os3QE#VPZE+rH;rTv@^sl=gm}yCaBhBPLpcSa-=QZ%oOD9WVd90N& zYuml@LY@N!`v*hARAw%D&g&SZUG7)0{OGv}Gedc1aq)c1TV7!-wes6Jk;unOwQMwH z=UcXam;wUGG>he*-z^upB0_2GkY~1>4_KG;{6pQvvvGqNOH58a3)Tz~9xL z4VZ0o%C6iBM3B9saC%u9$_s!WS$Ss%LlP-1{~=-+xu_&rluA=rytB@6Ogy|~5 zl5zQ)x8T3{;2oXAG)jG#{(h{gsw~U`dsfVoX$#brC5ap{nK;ww)PjpZH{lFUd>l|h zhmUoMFA7nxPbi5sLsS&sl-O>I20L4)3Cg*{RG@SQB7_(foaug>jSOx*VaTiI&n{hv zlDSPX5yehnKH|vHcul>Dan`Qt(&Zb>Bc`0E9#cQ9u>;3stXr^#fkqN!wU{o_u3O?^A4R6o4r zeEG={k8ve&{0Yz$B`nShQ~Ba0Gm}L}O5+KFxRO2TE-JYQoUetDR(fXlmTJ{IVoBqT z?!^HBN^S)sz#iWiE7)KV=SclPT)W7p$OfEwjxckejdq6I19QtnHsb;}0)H3W8-4o? z22Q*foCPuW^nTxa?wrUH`WyEbf6vL(JBzlO=yAQ~*P+=XpG;*U&a^P0y7Hq6$m<+a=V^h(`Rm}p&&mC213(Os_49aW~j^J^felMkyhY||9USL#;vx;v)`eE zE*46aKz^f`FZdeIW%#8cluc~YM_P^(tGVaDyActYNNC!x`x&*hbbNbWp?8v@Vyo;0 zEz@)K%E-BkfgbU$OAy3}|LjycGyhnl2Mr(mmj3C2%V(E?bKn4rU6;1@u_h+xa&kvg zHtWV;=FLIZs#ibi=FQOS>2n4#+mpgs?gg_zxKb9%i;3IDO(7z9*TgdFXFL~P29xQY z$r$4GdtaQEBW-X5KP99S znx%-4Td%_L*}iZ<;XUM5_U5#^7J^S8N$lhR(b#d~_KY9*h_ZldnuYoW6q`xtUY(9Afs6RiEw!OMLm(sI%3PyG@oYqAiT~eMmDL)vv}LA*d?u7`hX4_=(B}2Y*#M^i(tN>`xGNu#x+% z)0SjvEK1^4vuIk}>h;4<1cjZQc}=@s!h)DgKxQ0w$qK%X=`7SFe*dJk3I%B?m=T^6 z!uTE`H()p_!I5HK1r-t=Y7wUoq@c4}Ccsm^{(4+jk~oYL-!>XdeQN=GG%@Ne2!AHp z48tZ_`glI1eabP5gqEjV8S7~n46}{LM3@6&Dw*6N7z_D;`bwY|Ft$6xTtzWx*lEvq z=Tgq-4UZTZhiRXjXvRvb!{VgFsNwex=%o5q!+xGMId8&Mqq#!Q!D+WP0cv|0Vat`6 z{PeNbh==^>uZYUVoJ7|eDQlnUXovYeB^GrA?LOo&2U#K#lzXgRY@5sV!y)&c(rBR; z4D_;+#8A63)8j(A^KBe``IT#ANVo&1HZt%i!^-c$UzMUK%0wU3EgfTzT{7cMzVl@t2FDFR2s@MRQtrxI+ z9=islcOGg|93{SDOR@$S2ta&3Q}n%WJnVT_2}^P>F_DRLe}??9GdRuML=QP{AH8A-?WTn{i6k^Y|(k!2E!5*Q~3f`of4ev(ufBPRNFwW^;m! z9tRgGH8)evXl_|=+s-YYA7RQ(TSx>N$%17AR6bC;zOL5I2W_;}Ri_x2Pp$pRi`qw> zF8^SN|IVKZU9daPZZANF#`{B3 z!p?jZqst#u>H44+q0o5gch> z17Xh`oG+_cvl#DndXF+^(>5Qu3lGJiJR_w*0;{*PyPPj?*vSB3FyJ0_c|DeU111_4 zUw#Fr09xgSKPz6574CW0uT7L|7)JMMOXvKV*>Wvn^A1B zSISw)q2(a&(m|OANV6+jkCQbVY&K`Qqy!@k;>e~2qrbC=O_m>y^y4QdK>>;RnQvPC zmx+tvMkb{b|7@#UR!3tGBxY2V=TaWssK+t1!*^?nZ|Y}}2wK2{UXMi~{^S4h%YqD{ zR-0s~MY@tkNWqJW}ITDr<|RTbI1sLNL^s>rs~Xni#^b=lmU+c}xBXlKgDg{%as*A&(~`ZAU) zF-FZG25GgfaZTpHBTn+(Ts%`VklbXiOYL|j{j(1a{B1=bGKr8r9^0NrZA9-4!=`Vx z7b-5yzLMdu+czuK*YlA&HXt$gS|7DPy<|tl&ig49Bhc6x0(wn|8m~SXLr<5~r7UP1zB1x} zP>ucLg$(=ok>mi59Hv=nO7-R>tBSi*M^nMMkiOUOXdvHYdg_XFp@N*Kr)yn1LI=3k zoHXqM{*ntLwknXojBP??WhwRTP-}SRcbdEb_3H_OL#I-t`k|uUjKj!%{E@)chZ)a7 zTDs~XkslcDxNX<|j?Rly5SeB}&p-JDn8cwz^+jRBi47H&mCS9p3ZQ)>Y;4TeA$2Z6 ztDTJUT%+nGOQo*gdM%#$M}i?OH~*^1Tl*xLX~^Q%S*q>87qlcC@+mknuMbJ3f4-f6 z!6h(hDD=eJdaP721?5A`$W{icYn1kOnVYx96TA?I3`7=`5NI{;TBsDPHloY?wF>jWsb(~yn11s-aM=ENsR+jaJ}=eZF@ zr%k8`!W4}W!!WYRCUEF9Ah?(Fi_Ndb>1%(21{=InyW(=JF&CJeIq+YHusBs-wyA@G z;%9y5AS{%wZZ9C>N+mM-}3IQ>hosj-a$!*F(oP zH(b}mmZ>~}qozWh zq+%N%&HbN`YI?!9ymFxncAgZdTa3OXQV%gihfseC^nyU=0c0_6M&!*q zj&Hr3F>~pm*)6c8j*!|uOb3nC8hN{Ko-2NFl(IoeXM4j+v-f^Sh8Yp68n(r{5XHhAWLC9WV<9wX60^D8B4MaC-j z%6sh<7nKq6T8Twhuh7kap(eKbf=BfLf*z3%iS>G~!+HuG%8>TgfPjA+%F%s-RAgM{ z82O9&S}pe*v&dgTeW}mnrz;B~7UXCIJOi^+ER4+R1+0S*G<`b+HMIPrUA@dB)=CemrwHvE^ZU6H{*) z=ULU=TdPeC4qltPDL$*5&PDkIeEj}Y7TOFI$VVMMFW)^l;83-r27E?IO0NI!=<7yh?=w~ub* z_33#u`W>LTo0(c6h4|H|EC!NmO9|N&j3lwE`@HnL|II+_ER^x1r#0>%i*6De?3C^w z1=UKcsG|GcplEoS%=k8$@h`Gh-ln<(GY|2BsHAjDRr@$EbY1v}>5>gVj&F_e8 zqb~5)^ssHDGUohA{!0zY?x>V0m_#V??$u>3HS`bT%YbR)skf ze*jGC_V@XUiSTF)#hiq z^AR-FeE7QUAh83ir+!ioX3JJ581X1TG%w5egoEKCgDpdsD2J!NA4DTZAxvRcuHXXR zGvSjDkG1BqG-ooE=ze6(oe!Hs(F%Yv9za3(hF=?BJoFR4YpT;q#T=V^S$zwIPr$_$ zdD4^map1iU7>*r97X9?oQD8x9rOKE(spbHV)Hf*iM}O30y<jd-rtQiTNDvYUGd5!1_^nce>M-?T?m;$zl z>zHBiy5!K9vn-@u@D#-%Fi^f`8BWUx)xwOM5j?oU9O6(s$&R)kkBG1TsYPte05!RE zaeUMKp-Ej1hztw`p=^2_IDNAb;0Ee%qrKqtT~d4ESbhA9R^}L~{m}iGY88FQk(h3) zU(mC9zgK~2j>}=XkYBO{^uo_)+@m3ZHsSA#>94wIH1afx<9ne{r4?lh-~&BD@PM-+ zdGh_WHiD~}Ktae+@D&ekTBO8N>P>-O)IaInDlUBce6XYrMj-mi?(l*Fz%#N%h(?7C zAZ}}g6Ge>hTzzR!DMrL01PBAe?<~!gX1)ZKK`OJ#rRF5@Hh0W;fHKk=iV^vn7UFY8 z^YkZZTc%Fk)^ar8;@F)4F=?>gbo*KM$QRxf7SH)}f&eNfE*^Qin$9rFA0 zT4(G>TxSuk_e%Sz#={Cg#N0)T(J$W_Q*vP5o#?h@inE<{Iyc4;+ zH0v7aY|b&+d6i`xxbwKtWliLgrI^wZWs2{(`RzNp0(lW-@lbPkk-^y$HY&0bLr4TY z!%;urb}{yn;a@U$%kEzV2btt2NFJzr$D2VM2YS2t+3~~*XF5$p^2L*LtqaPtg8sJ( z4l?841BXmE|JvBoy{EOmeAm5W`}j-e61Yw3Kma+AhLyKPaf7xuDLb~+5kWAfC#Ep2 zwS&|@M4`^ZZ9g}4$mA$q(BhD@I5E$M1r*CaQPzHW<2Z5T6X~N9kPR&=;PE-?qzK;q z9t0<-OH5pd0|TMW+<^2Hs>w8jdl|a7R`OV-LXBHlX`Gim>EkFk)rMMfeSTV2GpI~l zqz`#q5}vO|12gtY6K{9NN9i5JGb6n{tfMifY>4_IgSTmW-i=YpEoJSuz}J0Xm3>d} zl_^X#zZ@%;dn>gX=rSUoagTfw<>WCD$A1oPUt`@2RM(5ij9tkZT+PVtceVh)!9aaR zj?VZ)Qso6{M>qa@Rw8-1Syy)$BSH%1S}XlvV1%s;^RLch!yjc+hcjVl<1@$DeEkqx zmoB@JZwKP^$Do2l3N};E!WLD%Z{K&GN_8m>>DblKV46=4IZyK@?^ka>iD_g#Xw$QT zl&1=bj{EBYIIilkX1QIbKL|j*b1Ch-d44z#_qzb6sT$N&o;WyUfnhFXl9!|6>xshJquVGg)ZjHy#?#-gLdIfyQW}6|J6aoZ zNv{S=^ih{EIA|)F5yu2V9HC0mz!}Kx1m*Iv@2!yR-;T=2l*EkL=~~^E`qIQ*Abcrt zWW!738E@sr5y<%v`8-&%!jh$U0#_|Ra~29R2QCc`X&t>er)%ue+KnPn%8q6r9+?PF zN_GI*R>t+`lyQ#5jN-Tx9>uF2c>?T05jf7!q6TXFcvU zJ#vP)7G>W|AZ$P&xCe-!2)Q?Z5aSR%7#JHSB~0I3aiJqMtE8o=ICp?uz0c7 z(RYDRF)iefjatRs|MvXAP3BfpF=62Y_qUR6A6Cf#XX9+WJ3FekqC!F(_Y9@TVv0sK zi9k^lhT5NDwxa$pOd54Uu(Mg0S*>fc5WdCQ=jnf%YIL1tgsRDuhv)%WaMBOqiZ_&D z7W;usNBM^h@ojf${np8_mw1#)OgXQry~O8xQYNUpWHM%T5;}3g0itQ{q3(=J%zJmrE!1> zzAnJQN}6jN$0M?)-&S8xQ0fq>+sA|ooslwj_d0*)_{4@tclE1FQWj*2*EV_j(3-@p zy|}syGADcjh}8nzM_($`T)9PejBD8Q&OWM@#-Y`CCMXF{Q)$-NVYVR#A~&Z;oXMJAraBWRe-6 z?y^jv_Uv_e8;~v+L}gH)MOAX|My8;XozeU`F~f;jjSa&44c##VJe3f5xA9R#FCrCG z1k+c;&iXCULHW(9G<;Y4Ixhznsy~=GA}U$W@7Q2u0&P+*E$J1|c6B6s+eiq~0Z)fVso3M;C^?u>Ikwkk6gI2c<@%=K>Etg-aF+;^K_Z4rh34@*di^W4Z_N{40%ioQJhPs@1~8HAfqlqYAAfH%4hMua+uDd z@_e0~e|A!fy$I2m`{r2)qKwCVel=1X>zM-P?snI~__gBxYS6({)`U~sWQQ{V>lSny zHRYiqY{E?COhN6CG8;2J}6lq*y}l%wAyDK%7Xe< zc~xhY8)Mj5%g76!bpYw=y=3erhbDFumB-}lls50)2Z+==YQ{k53596r@I^WMLr{#Z zD;-Phoa&)^DBy$0-kRl8r%#;rroJa0NqfOOo_?)sY3=?k4tj`b0~U8c*4Rvk{-E9N z=q7yBCyY!XTzKZn%y7ddTsHWXKEW;_XA>ykb?>Im)%+6#TIPoXnn%Ze zX1G1SYw3Qa(QuEYg!dVN2ZF$gnGKH;K)T1NFysbIy$_SN5o{_&ngpm;jq#*A^OxThtV>Zvq6pOaV0$am^EL=?>d%0t86m zQi!r*@)}?9;4i;iy_J=Z^yGq!2P~A=*ch<)2J?gvLDQ|PfCtzKNDKF%aui}2 zk}xDF0;*~bVH*_ZqP`~tWYBzQOinUt6%Hv_0cln_74nP$~wj&LXup^lI9f(n|78v1F%%>oq%f9Vt z3uHGCB(Ua9APM%;Y{t1tK6UuC*n^*(41+PyLg0)*|88tRV)V8o)pVm$|LY?!Nj=+M zK$5H#bNRT+`u(2pi96$CZC3rxT0p;#TtzJ4NGyU9aS>wF1ahNV z#W?+W$KA4M?4|>i+z3 zRMB_jyI@3Q{P_U)vLOQACz(K?M`;93DC%j7ryhe2EP8DCwy3A^6+|?F1ZC!DTNJ%IR!FLnHjNHYKxl z3BuYc2TciGMZJ*w$JgP4M7f|c(bMu=)Ohgm+LL-xfNZljgjcWHE?}8(&>BsC*RHL% zqNuAt|JkGj-(3EUggfC>pqBeBk=^Upy;~O(nOM)3!w9Lt@x2KDkA$MK^zP#aYdUWA zpt#|v|NOdDXk8B0U8co18AwpZT()KH# zi#qEsHJ-fsn_mk{hF70-WeqU(R50OU)=UQ%$S$sC4rWF*2>ma{iFKD?!Ubu>S&@fK z2Rz?}S#j+AXxR}Qs6WX9!j)*LP6K@)0SJ&kQREV^O5E|c+ms+B;N_dXqDRUtg7@dT z(41mwdRLSoK&xFq@?v#asStewc2)wN2o@Z>h6+g{i{?pM-cXOM8bWF+L2pG#bMOWP^6l#=TIhP3Ru|@<|`- z+U-NnU22};)~_o^D5wHw!K+&L@Ya>T;R9+ir`f2LQfj6-Qlqk`ca2+gd$&w}i00Xi z)kO`oQ3#}f1{$(3k9>TD0LY%>7cKvC+wc!TaX1x(4O9eW$};2I$U-eJV<$TWw`Xk8 zx8q;!aM>t~-L4YD^UtE_23I*m!MTDCG^?wJ)1Ez9af-u_L1fHVbWd#MF)p!iM{@Bh zUOH{D+%3Phh?O3hP^#RC3XGJZ%}}Jxg1EE}=_4EIYtTZJW}(-GO%np`%|L|^EnIcQ zEudn#vGH%xvMLC)I6xu5Zxi8-y?2=2ozM$@b@a8s(kDUrxW30{oZ7uZ$OD_T#$>T* zvuI?mK@J;LgbwbWge=c41fh+A_CDv};PMQG7Z>A?2RY-S3=FsJ)KCn{tw7&WqH{?v zFZLe;LW6U;zpFw(0cUe?Y3X-xJreJ>VE%>=T3ah~naoMY&JsC?7QcJf=X)+!V!10i2Tc${AF^MaBKX_sd>p@)LWyIV z-?5j{3c3}tfOMJRlsm)KCi=*0g9|e?8Mng|PVqvPr(omS|Ic?MB)7fu+s9WKM6yyV zgy!uf?w4>q74sGO?S3QC!pYFZy}T7*?>d5i+q<RC7X!-%BME-uB*9oup!Q!_23G!#H#xKFvU|%Jipyr<|Pv zKJjh-SKGgc58d43^^ou5Ndis%{v27=N(K=_6*ZC9Ckh*CE#G*lsOW_}e4ms4px&FU z`OL`+3yFj*JGU_(d+@NR#cYhOt5Nyz^P+RV0%k?$AI}~wwJs4V9kt_3J2KhavX+et zrNoTQPnf4SE6B2}W}kn{f-hIu$7yH56{<8HQXIb5!J_A~>97Rg8{g~oP|*L96` z9`aa_?V0>`ON~YAzf65uB4~<+>V~T2$HLsA@Q6^4V%LJU<25w{Ch|27kfamA#8Nf> z8MxCU?CckxNI)}17VxNN0bJ=7i4DZnt^G`$oZwA!vGbUYuN9P}iVA zfQ;8_Dv10WA(ABds69)ru5-ZF{o`LmeN}eLd?ak1zl(SGV|l+8a3lgPlEaI1PTh#U zlvQHHAQtdBlimgY4eh(UmM)o4v+|QdC4LP^f)@84VM8#Q*Q!ZI8c;(BJYN&rvIw&8 z2*VILbIOLH*?>J25JRYyRRi=TDYOFOeY3swiPY9N>o=`5L$#Z99^n&X9Z2;ES zv;z{CwQ(pY?9q1^XHfRBozyrk!Sg%V0?Pezf&O!|-zz`n*_%e8bbDWPhJD<^Rf7_~ zu?ti?n)O~2VHK|nXv(>oF4e`${4buG-W_DMDx|I}-QcS$DRDRR z9O0aY3D5HbCe+Igi2O^O^i-uQ0t_cf`PN$%?g$)!rfA=G<*QcO%U&9a0J}rbwB(E! zyHWp})Fh1Qbwmw)OMxcjB&5i06NfBCXppH=?t1ZYam|l{Y}CFfTU19L_DQVQG?|2C z)6Nmh%(T8ZFb1@OPfrLf2Da&+zuzK$zldN*I7ElYG(D_UjZik>p+?Vuu1L?j=e~4% z7>y0cBx`HHW$d4>NzkvsIc?^q$!cq>wm93exP3VLJ4$2L0sw=S*kv(eaW(_l=mW<_ zP?b^%QoPUm^4k&ma|oba&awf*j)2$Z15_v_;N!RfrXB;R=mYt=T{5X3zW`JFMe3A| zy_OPtaay{*I@{dppcVQWbzw8}==(jrxCHT*7+Y@FMf}9cDt_@lmoq+HVkYIr!enuE zM))6WBs$8NF$=@Oz^b}GPv6p6sA7f)RJgkC(2^aMxQZDAhO9}Q4ceh77Pam91ZV;9 zxuoC-8+7@6$_*MvItJmiQ5j!~)z+rJLQXt@Tq|Y<(yY(<54a;J>=L0pkQuxdRRn&9 zBq(pqF~dyr_@vWq2`Hs{ofx%4DnWh}wN3^etrnVgpbeIL6^Q#)9o{6d_*S1{IT+|k z%a+45M;*#2*#(2f{45?}en5rOs1`Eo+58O#&mR=t6zMzom{x!@CukJLkzK!&0gdz~ zs0#p+{w%|;Mv(TmOw15+6dXi-wugX>+A$j)si@@M@O|8(Q8kc__s0vO60TNoS%6~Z zEhui1W?hix@N6Uq8k-xZtL_N4TF6VJPESWv9dLq0Kk9!lHk-($r9Uw_MTat}t!30k z4lttE833)(Wen`Yc?7zPkaE~>`nc3hL00H}*(3~YCj<skq{U@6s|S1V@>zI8x`C?9y7D_yS3n^&SwZ^Ku+~PqVLRIs0|-vxH%270OP6i=*B7lRO=2!Kk&C{CLM`e!j%7Iet{SgtA>ZP(N zeafE~GXyoi!`ouz8p`vj2^hAFx0j+!K~&mAJ^q$@Est3^d=xel{VyLRqh4y}`f+G+ zIy#)QtLLC3F^{_)@@+muhB?kpwuoOi|zrO#1p1l>qg% zDH^*R_itES7+iD$2}=4>fd;BDH`}%%pwxZ4;BzmZS$R8s4cVI0b=n3nleV%+Fs952 zoZdZ=Hdwy#Z)2n)#Wers*3mRB_N1M$6@NOdK>>vCG8AU)Vgop1bXN=0;qsaK4QyA; z6_y#K3hV$yw@1Cp)pn3MAm(WlandOTEVBj-LoAwT0{!MlHcd@z4@GO1FT>tyHoBDB z_7yS$j^Kdsqsl39Pt{6E1}(tlk;R$PzY|TVSOU)v=(a-3sgx_~DO?2i`Z!$$^y^$C zcq%uWH;hWPx_oyYL*E*x+H9DHL4#;yK8SLpLL-S34}OC?oc*S5}ba!P=|-d6CqJfyT{B6(5PWy1)4z;K|xTKxaf|ySWX-uL>y7!PG}rJ6Xc+R z7?8CcyYS8IPz3L5?!9lVBpn48Y@xO8m%G`+(ttS<0tC$xJ~lEe^vVdq7*QfoMTVih zn51oV1faF&0{vi`*PJDD6EFrmrxg8jpO?)g@A;tpwu{Fi>g5}9_J${^&b&{V!KG=q ztnOg=TUa)zZS;|R9Zrv>X!+?u+RGC*G}zo!+;_lDDOGd0ynN6*_rmnT&N#|bSWpYL zD?wWMZ4IOlZYn>&Nq#>;>xXrtX7`61Yr z#}7cW7V=l*3V6QGLD$B1lsl^y=V*kETjaQ$iTI{PR|ji%6f>xB48 zG*BPIOvsle88c+}D`IXfUKIblkqnGccknEU*+>3DQsN?;Nh@5L<)V_>Kz&yZX z5dZy@hW3_nts$G_++p*9dcK_Dd=8b)$&h&(8~>-@p%rB}j7f^>AELiq%d8v$mY&7% z|1tL6@mRO-`z1+6X_*mEkw~_TaHB_2q(MeVb`sf{-B}If$;_6FvS-<=D6(gSWM*XV z^*b-CNT2WTpI-M%x3~AT&g&eIGXju3!xJGeMrAIRu6*JmwYOrGY|eB6{&*8 ze=#ho(}MHlK#BteoxNmtE&c-W1=ch@17*gz-P_~JGz3T`7+HmS1aK7q2&B>x;KEK$EX3;Tcu!2(GN-`Pzj z2Oo0F1lN~v!r~%PR)qokL1DZv2r#Ag*_VArnb_bokx{267AyA@nl&)Q7k?yuBgzNtTC=i9#w2AoSJ9QDg)nQkKjm zW?*pKS0q9Z>v!N&C(oi(G&=k-H1SB{<%VzN^>?D$A%#Mq>zj|Yo?Ps*v@kU`+|K<#K?Y@t@ zjB??ewL>l}wk9&isXAeRTr>xQk@^O1T`M@yg{U?)_>8Wr_^|QC{WNyqwK=LT^j&WU z09nOkaplAw>L&)z-jfWGR|nk%t}hh4)qpE^a(n>+H_aqy)Pt*8@LKmma~eoxg(WaM zH5^DIt`P|C8kor4DANwZC#SWo`UVrcD@+5$Le1-`g8l+#1V?_-_9EthAlQMgRlKAhNw+NW6*;Z4c+2Kr3sUEx-_(mvf`i9HkkICArmmCrE4Cqz?bKU+1 zG(o`1GciX-DA`Eyk)Co!;JqRWHQ0vC`f{6{CrFcspeQN;MNxu!P2)B`8FQ(d&*$>N z4tW7qo7(XX&jIPJ#4hPU0$rD_=9Wz4%7 zVoqD>o(H@-g~kb(M$$fFS>RkJ7Q`V;X(*sG9Mi1307G4R-D4E-J8{#BF*7IxZ9ZcE z?h@2IVuDDW_8y@r0`FR`5WHJz1WttG!hUBF5(_6&K0KI59E3_zW-91bHVuv|DU$Kv zpF;rCV={{f^b4&EZ37n)k#wDc711SC5KBq2v~eVN$pxTT-n4x&Do>W{lRkmIgvJW8$)f$R;Pr(R$@{!<0jv_b&=?=yqCW6UL? zuR=WCJASGQ#(|eNc<`xd8ra&f%6fJG`~#TfaYJ6aP?>3G0!@Y2TsWIx*AcUTCh>C?BEJ1Kh3S^rnWRu_PWY`U<)U% z@+?G3=28Q3NHe!78?#9GagplFPoHMowS?sFW*p@Ou$H)^Agz(CgqksIywEY6NJeeP zh3l(XK1tl$(HcP!>x!%^&cEViTBp}NZvg-*tg4>?=9rLda-s9M@QKyZw?I z9|#S3$V^xl``^ttaVO(PMEffbX@UdCr9Te=%4kMY4ovGzvXQ!DsF<@>rh3SIArc#; zdyIwpu;3C!-)K!B#>ZPY@(4bg>kCfq}uS`^=7vk#f{_j%%OwW5? zcJ=D+VF(P!?x*FFKgmZr|brqRlN$o0ZLiYoIr0Z#%MtE7|t%=$0 zNmLgTAJN(|6S9C@P?P@p*z$CBu`}i-(L;sRi1pC!J6q6ANWG}^xFQ|~DHsC{ebo?q z;j2an&@VU6{c`p`#pwY_SG|nsUnQnje6?$C52jq1wPRh@ia%>n8KW@NM!ViJh_5Cm zKWkx|XeDfUSh1xW^4k~zl1JFO8Pvj34J_kKgk>obcdBoN{DLU0Wm-orV*8WgWFX$T zGx@+GN`xSQ)%(1kNYC;NL^kcfW!001M1tg{|7yqbe;l!!zZY5os1uKyL*#g>ZKM@c zb6hDOXs2x^zZ`#`Hz&L}^2`+4X92o60l7p-Lr37>fa@9mCR^j*ZweAB+FOOh!;py1 zCI12xN_(lJ9KE9@%EZADQPz1%*IOlU7l|^UCSrjLy2}g?Qjg**$&KXMa^Ne9=0ek7 z%?W^#(lIZ?K~vS$4-X+pfCXJ7$%v0!?)=+;Y&&oM_M`f3l1r1|%GL5|FGlP@`5!eq zq4Popo>Px|p3%)9MtH%ue0!waNsZ)Fjvcl`s%9YmijTya+Ou>up}Elcr=QfX?idH` z;`fvD!i!eZz*9*!r1?|z6z@=p(ZqnXBLVPL_DD0)@-Z#$Q;zh%%58oz;tu~*>>1V!EtN= z=oJ!;=1t3rFw>x*=_yx;C^wKV^sMWcM{+*HkhX9FeuBo}J?N93p_h#v9XX6ZhvYyO zsJ4UNc%^Pn@en^O@aB|7AbAYxJLwFxD~$!zLeB8(y<*J?q@u?h>4*yEETlA6Q$GXi z)W2ESmO8(Ja@5?6GVw6Z2>@_q6{=3NwH8(?QE02NJ-o(d3^J-i1r z4PRxU0v7NzSld{3d$V$Vz6BTasV@|L{@Ph5M4*xO7fu$2XT%g;1~*9>#=tZaKrcg( z5Wz=uko_l*G{nBnAO7)gP0s~IgI;#KFo(t-pqS}LMVoE5rK{CAOo$`yXg(vSrCp5U zsUI6FW0lgl)omvhguY0V)^e>|1CH(S_c#ewE3YB0xYZ3uezbjU>PG!HIh)CL6ve}0zoOYuG)C8j)IFBz0!Xfnp zOQB&wymv@Gf$m6=D&_klV%=ldo-f;Sm}4MyqA&rF-%llp?t8^piO;ZRixa(hCh{X( z=}u|ejc<^UugEwX?|Qlwy5x!yH9j-81E}6}BefoLFWxqY(^FFJd-u)I&XCs%Lj|z2 zdl!;(;04@tvT?EARBzQ$_%$(cl-cfWO4X~MLdk7TOO@hs_F6pKX*UYxoU%Xz|4_6Y6qU=Oa-9_AJPQ^kz@k> z8A(WMjRJSLo~jvQXIp{Mk4FoEPJRWSCKyBm_P?`5NF7L0^Ts5VFsUK#|Dgl|+I<+mZL zx&I#|T;&J^p_BbogksKnr20nelh^}t;7)g3Ky*?I$VG~Qvt;&YVR6cC>GbX&&(uo| z*uS1}N-1V>dNBk5Oo{XyJ5C^1er1h|w0Z`E)|8NOlSVDm%4sM$-KNt-E^^I}VU*HM8;} z0$}V&r&&b?nh^*+3cSboYdIo=WG=610jw^!-${4dv?!iho*8m#w9+9NJ4fxFnRB}K z*`D&^K;MU#x5y?C8E;c_A_O{eLw$ z*}DV4+_!_tJBh1x1Vl*MfxNnh{br`gQ;|+6%b1aVM(GV7vlF%I!{!5az|GViM3AmW z!t6H%@eGh^7tB1JQ$}PnVvL~NQ^Ec3ITrI!4SW7z%#GU4La7Tpg1x}Fyp6;so?k`X ze749+@P@Sm zbC};JETA+tDA^Uwe&{bw4+0NwIT;cBPYi@;bSkV)jm0tx^Q2^bJflIsC;<0kqMUpf(_A zmD6qCT2mt)hH&zO{~&eHXv>c@C64B=Q@JpcN%$c z5-hEZbgeFQcM*htlsC){(cM3}?*7iL-kbX;^71AF?(G}_2Ca|9!SZQqS=4o7k3NJD z@2sYc;UbSFW*xlp%OlrPptTVcC_Qep1}<8=Q669p5zf0?T={?>u>u)>b2%hpoXU0N zf?HkI%tLMN_?+~O-X8NZhEjrUS$<@IiQQ!$&VxkLSo=Vt7!m!m(0*hEfc?!<0BJ5? z>INA#aGfp|G|x@+`|NyBoWstWnL|7LjukH)5j7Hh?~Ihnj+{Z~t^n)~Qg8RTIYWL- zwT%a5yaB(+bMXZS{qF6TiYexnbrAvUB;763mN=36fn+M{@<1}U)6N}k0;gt>h}nKF zArLYJ7M%04awG&@@i|xPbe|eO-M%381xcXpfd-hzQUu$CRmK3hO2}?)2Z^+gLy{%z zoq~AY6*^DVDY$fG9v#7n8xqI4w*(8~KkpgT5BYdjt`9foyj#DIv9@~Bw#tVRqM%`I~Io`sHl0wTf!2{Xd^*fR)ES!&0;fM|r$XnId7sz8zGDCl8jk-P~ujfx}@ zn?@`@XQ&0zSG=~&(Ak9VL+e-Bhx2f|VqoJ=d=f@Z>~!mcO}pZ=3uaazJoj~(OOX2d zPAHL}wb56SR_9oyIfi85R_7+(SH7a$e>ZLAqR$3xbAxzhJ(XXZWc}BdH_}C1L|;h& zv$hO~rK*CcBhVpB6;i07<2%R&ttN>O4=gE1Y9*|Ki*#9?ce+${ald3?x_Twdy+M$} z1EjDN3K8){5D#8+JXqq1pV9G&S0knA7CYaPGaT9R4Gz~gLr^_+L^UO(<<~}k!o0tp zWjV${dQ0eZ1des?eIzoWD{qsN9bS5m<)y5R5)qrh$Jm@j;?y$Wn-dS-PFVm$P&!%6 zek!&0ib6I$A*zlUiP<9&miONl--)lfp>ss6HNKZqTq_>GYen6ZQHDE^iu!%!RPcP~ zam1e?f`rN*h)y)x{YGZeA-td$cBJNv;xZ9)pg63xg`7N=fw0!8iD9Y^m89wz+-*;! zH+Adh{AG3_a?W}%b#avP4~Px68wwrOE(M}#$+z$s0DkhyMmOt6<8cUL2Ms~`wQcOG|B2YhooVBCmM zcJk32Kgc@fT@$izl~*WovGt2%c{^<;D3KKG-l%SH7!3L)d-C3Sbye*SHQ7*hg3>+(0W z#B6eGu=V?Z(Ze6N+K{Y7HE)Wd)Xvpqr3c9RC7N;+OFn7~4v8a_WYXB8ff1JS;bG_4 z@t=@B>HU(douixP-Qle212oew{7#a&(~wyBPf88=BIPAsHy$miyg#ziHRyMT3-f~T zeTCwOkn!mb z>+fM8HnW`QJA_2e3i3r$2oKAuQXu_&s1O&RY;N%_V4W*8BSwD>^d+Z-X`$$@IzJ6?c?Y|-~RGHDjcXl^;Elr+rjmzTGfTx8YD?@-nxKHG-94UTZY zU2aO>zo}ud-&G}Kro$d)mK*igEIBz0<4Bis3)^}pv&`Z zSnmTQF4acf<$s{n*1mo%?ZMPjW~&94pmqOZnq1mWkQ?m9H5*>Xk7g3{6vyxKN%%4k(Pcl4pzG6Kw ziJgS{a+*{R6v(a>99{Wwr5w1#&#D{f^-XOW)`}D+H?rJbGQ7d~n7Un~6k z*4tvljK&bIYvy2_&TV-5?~4vlJ-Ca7k(Nw5l#>ZsGEA8-X2J!G%9o5OueW$AtqcPW z=g0rZUfy8n*C7A>Ve3=9{z2y`cHY&zgFf5ftN8G08sW`(1kf-QKg5#pdSy2^-`F1pRri}`>*{qJ!m#q_J}#%wa1e`$S9Fb9Vv_iaK? zi7^vzbrbkL$c^TXP+#q?BBP5#&8(kSw5IJ2o2*Hu>-B6b@xc7%slhVfoBaIBHxA7n zW^_f(l{cQf<#$W*!_0O~j$hD>HKRL@ilJDuqwb4#=Q3ZoKi;qu82{Y}B4MTB|6eA_ z%9{b=lxnwyYqNOedcJ0D9a1h}_Y4}zF|4*>{wzbgz*a>h(6<%oEj|*FJ zAZjNgk{GVFg0wSA+u2?@ZT2xnm&`vf!ojf>ztZ+vhV(@ zOeFO$i%e^s5)>2PuZo4wSA=hYa7B0_byh;gPE4ha3$K}6PKAjbo0on=tw5^MLoDy`cpF}_8W z-U>UO{IAgU+K_Db1w7pWh%F^|j;dm$b3Jc_AB@;YWGy>*-Y#-{PJ^@EWQ+A$pW8~e zolkTR4O+a6SnG491J~B8w#hp&J~o?|4Ri*kA=XQJYzMhMa2oF8TsWC{770QS?rK4~ zeQ-@rZE4PQ3~)r_o0^~aZ9$d&eKSy!oi}*yIXRkhkEwlaCV#!FHCWoR)5aTwIX0Z{ zkTxA2==s@FrG9Y=WjBYbXTpXa`wxVb(W#RTsuoBEYu#aU;O4$y z>*t8k0Og(Ty=vt=F*L!kB1m0H1Fq$f@c0W&9XHBWNvS$Z2ur<0=}))3AW_#t7|uVB zClg=2m)BQ8#uV*bLL}ut4p=-0^;s1)7m= zWq%koC{Q;0Lccr4;PF^m7axK>E8hj0eR&B1Co4&?F)D(#<3i0^J2Y;K%|(OrLpxTo zu)4-FBlnEBQcxI)rEF0Dt!n_V4Z={)lEr_^#m4pweBShC>lOnuL(tjAdZO(z$~BvA5wO0Ki#IkH(F=U^0)x=Q%%)i5qMdp z|JG`m1a>dP)zsIuR}ctYtTIi4BF(`0VIHf^&t*Pm#Z~z6Dz2FF%J-4vOf;Bi1y`@jR zJPfZB?zWfHInEi>ZdeMLyEwWe-fgE#^)+4A=)jl|cI^0s-mGO>V$S5+AypKkwV{3v zFn(7(-s7w99ukW#V6T+^l?#}IEQ(k*_?T)pvxdcnKLUP*-fq-MK@D>FCj)Gu4(T`> za}D5p2jGH};r)Oxj1V?o(_!wB6dJdM?RTmtRPJlz>_q={@-d*KD?~+aa9&pSPZTZ! z5O}f4A^H?V9@7RIN>4L4G|6UCq9slpozu{a535>FEIJ^^oF%JB>i^`HtQ(qNA1ZBe z<>b{I4IvrgME-37VO~JW;q(9i`B?b#Q9rVI$np{)a1N>!%0tmfZ0cu=(l}-8{Uk6f z{|GDqUf$j7ke2dIP;ena2h0NQ{O2AYdenZn?$TOd+M-z`8#B%ipJtoaHrRDCaY+=}zsY~f*KXE7{&>m@jOBsn79yu>bU4k({u*_ZoJWYP!`@RD zOLXoLZT8!uc(Hp9oxZhW)n8CN8!}GpL(2Dh#pU~b_)Up<&@ie1YWRel^NgG>U$|x5XAzHxY7ixt_jk<{`x~4P|>O>OQD1cn>0kb&?T1;(r`gfr9Ta zT40nYeE)3{goG0$u2P^TMW|m8D$D|Mxw@B~-C9`aG~z{e&Wr$~h^x^K^h!<*JnjFV z1fMNdX(h_KmhoWQjHIea$BJ{~1$q;E{(CEzuBWpqGnls*huV&jY3*!oa1)jv!F2r_ zB#Mif+R zpJOJ+&n&mJScZtF=z7z1G@>f{v0j@%)B4>rd!wxZ{k+@{N*3xO_mg!G1CzIglu z)JL2kGb*rKIzGMwl47Jt$8FZk@e9SVwk!RVJp20BudRa-Z?v>np=0ttl(XNLYp_aW#x=RWb&@tqvfq6?MUyklrFHz#*P zCb08+YN**H@ypNg0Z`=p?5vASk`5wR4h-+wJw$PA9D2aBKGo^xwviAjZaAxS&n5v#S_ypiAf{kHre#Tc78lp{by z$;Egvke}QC2U2U|0sP&v0t%F@oPp%R^My=U!xGASj&NP^1Ac&74NwMzsEb*0N44mH z9n>|B3F|5?vg6qo>jSo=9m?3(ujD{dlz?tjqQiWN7w9=Px7tU5gFuLULnNOGgppf} z3kb@GnpWItgY+}?t#{B3pbgfg;K<^z^Bq;_Au?tSvZJDhi$Orx6E{GcT9SbMj1AO2 zZ?l~hGD9MPg#Z;$HtL+JLmFR$2yi2J_mikzj2?dus#gKfFg>4Srjb=q1u$lziF+A} zxg{XmZU+YcRb3}LjTO4rpi}}>2mqONBtWYHve|CtsuaMLE7T;Y6(%KZqLG3@!BF5^ z2Om8mEU)1&fh}76$>R}eg2+%z;(2yEn6+LLZN?D&VT9IJ2l>I?-~yH!DG;rszJTT< z#G5@4-@O^!4|%73kFY}s+JU;(cx;=ns7DxNKw#|nvJ*NQLAxRkOConDr}Zb>BpSj= zC+$n*0NH?HK=bj;NZ5ZL3q;s%*<#?upr!`HsDp@JDH-A$A2SqcBQ0eoS%mB2LXk=a ztI8MvLiaArP7osX*Q7acUswW=w4gp*kB{@gChhzViN{JMjze#U&g;#jZPz?(BY->4fINZ<{i znLP;8f?NkgEb6U~RpRgh;5qMCpb`;F>w)O`i2(sH7D>ZPIwt7&0oF0G@_5kLGHwcR z4}?Hj@sd+Kq4%U;_pg*2rt2XQ4B3PdUFG>7w@R0-tXA?rpxCBRWqk5?YmO{h2qXBo zy`g`-U_28s`uh4j8BhS^J5ii4UCrVYyB8Ez-iHiT4=$ID-<`>o^5;oTf+2_~AEO&ZOqVoar%%PSi`9duG~|bLpjPJ}4i3E@H^|f6 ziMkiN2ztOy2vxS6c|v6)U%(Oo}E69*ja@A)zR1EJ0rG% z<8B;kC+3C9)y-aZLK?1r?BDG;$x^_FID&VB%659L^A42@A@Y1M9{?b|>-D>QDz?ot zy`I~2I9ldWfK-rY^ za9F>{F6x8)rhT5uMgYfZ2aae-ADG%3A9qz~3*;~Z%Z`0_$kNC&y8wed=ss-c2db0U zI{^I_0I}zKZ-cyMs3U2K*|{Bm7IC%#i0OQ;;dKdh9=T1)D>Ua_jP6c~GyY0^ac=DE zcRANND5f?CsQ<|_C5cjniC;LSxV$u&IWk6K>3IF6t2|Lm-H%1`2KDNkMJP><2Eqr4h8$ zkU}6Cxl_xs+3m2MPv}ecfw?MeQhx-BbxU(GG=(51JwFyB1oV=a!#&G{A&ZN)PeW%$ zEN2E$N*F~gexqHDjZH^7^ekqTI|M|F)}U>W&L9pW0-fp3QirRh<<@bYRO$= zvga5kM0BU(+=3^8Ci|UDbF{waL!Ru+GPtppJN|XWKgc^t2vN1B=nPpG2O7HFGHrmhKwY>42WNpAn{0H_$XNEhv-!l|hA>J3#WJ zda=IN`TEy5@T@sdNh{a-w9mbH~6kzhwbp zPJr+Y!vs5_emcP0eODPI_Y-dI6mleg8grNskz_)H_6sx0@@}7YsT)wlhm zhhE^D>+34_tX|hz$J}!kKjt#LsA69iyeyFo@%6nN7sj<#io!4xzEYjM(^bm5IclsV zzyV&wzl~6f><{2`$_CEDqQcyA85MR{ke50g#Et!cNO9f!Nlul~f+#B9pdW$Y&OjC( zCXZc5s0Fn&N@@Jou?*A#LK^=Zv={h{w4*f#^ zof=)j2>XQOz`rb_UI)-rbm>zc)O@82y4A&g?9%8Pd(=J*8mO}Sn{t6#=44_0!wg02 z#5|3(cVN$>Hn10pt~4E24X!g)zWq8pspi9jsb12aBRe|xf`Ly!REs%v!#N0}g6q_g z(mi9XdKXC6UMK>x=lH;vWC(R;ol~+G=40uEm__jZMa&GZ10zdsKuLcMOd_sU#q&=bcEWZc2r2F65 z0n6DL0Xyct^C9VHXJzviZoQ|DfNA88K38Ef2aRC@0sIc9B1Zb%ECT8F9w=AIyX7cy)K@2;e@6*xi>l;@L*eG2Y|McTLAE7Cbxx3 zAma|K-;!Hs8d1@e2(k0_F_e_8E|aHC0uWaE+8fLdt218wdprItW31d2xcHx+U={@r z3{h1)d8NMQNv-C@u(!#eSUFg+ZR~Hgi$llQfHihd+zQ~0&ZxbfZZkOUNj4}{|LB}w zOpTC2jev^w5Mn1fb0mU>YK(4p273B#J7QnoLE4O3hrGJcCJeyk%$OOd&Q_EpCR9r@ zYfe~4S>Fi&F9`w{60u{=*L&~1KLq(=d35}2 zCJuofys9IL5z^&`TC)ogOwBa2%UdbQC}#m?`(u;(GMe2o<~{AQqJf?rXv-!UDOx~8 zQgchk)ie;aY6~E`2~-Zt5zV@v2M6m2U@EgU5Sic4^l8O^(9Uh&QSoCUGvHOUq0kET zDIq`E2KMtk3tmZ2gYe)yB;+GL;2nXeG0-=hx_ac5=@yWOWJvE@Ln4yu*r$qCFGJLG z_gcgQ8PAd%r^EifWE)QyI#-@g;d(Ms*xj+Rs``M8O;2=XRif}2v#I!XZLO{An}boW zUrQ8Zc66$rwse#=?TdlUuokk3@Xhx~>;BGlcCpM?Ir z_f%~ZJPz}B04XhiD~}3NUpWQ$68AUXBDemEILDpri$`g!U=*JG4G?JG^SvKLx1Gx^Mw<**N^YhvKK1JEmOmze7s2(OcFH6=z5;SZFMdkL1;%M! zh|{}a51nP4k!#o>NjTEIjeWoln%kfEUv4q7+&72DfM?lGBYNwt$|dB+8xTP8(r`JR zg8g_T!?Yj5)rxSG5tyyJ{4a|P5wd^0TB}~YToz)c-Ho=^#YZsF*G0un$*%sdR zsx1s@+L@(&<<|j&?T|J(w|C~g6o*wqe%;D(gebqakt6cn8f38=cU~<_<{#QqTTDCf3_A37oU##;HGkb|$9Q znlt4Lb^%ILn;ZpmP(?+5+vsBT4Ke-|A4YA8bmM4sK*)9y7S3+~EgpjHhcnS%R2tPAyXxG6&NL_X>{7@B(C8 z3^J{LMo$B@oC9KDt}WHNg`AMRh%3|#Xf zq3%eMVe;eoJeO8gE$viMc~$+~t*CiOMO1zFp@x7CHNhq9X#v5ZLNHizI#>UE9k^GA zj63ZQ&xqIG!lRfI^`uHnehT%>6SN1zl}|-WtcSsyM^JX~W3Kjhn+~n-Ki0&i*Eh{a zD1TO{F|I&?-ay3t)mPdQPqp(x=Ka-_#oAbxP4&%sT;QMw7=m0pnRGSK1|elhcChK% zhA*whhcN0AGLk1(X7qIMRxSPCd@gXtyC4Mp^0Dn}#Hg0q`?ZT#YGu>~MZCA-xuo60xWQ7bp|!Loo~s{{!kCdg;M zaJo^}H*@$`3)7os1h3)^I>@t%0t1=bn>Gyw6As~S{P-cR6&MJc2NayBv_}n{w^|am z12_mSh3I!~s4Flf4Rl-{Mie}~t6L55x7qyr@2ovXi!aYZN5V4=G_kg7WNR;+k_f&751u*S zvPF{N!4b9=4!`51f%E{^zO(k>jmS;tjYv3ByhW}vB%9!sYWanp<7fH2hPOajY(2C3 zbndoyrShzmbkBG*DNv&zNuLms+(E!VL%92#qX1j}@2m@-Y0tTkn` zS4;|CaohJ-(*NIM+%Sfp+W7<{b|NC42{uQ0`wAmt8#O6}h%fM^XkI!`yGRpy&L` zcCJFkf!T6D-r9EByruuxtMu?Ld%EOnzQ2V(+;Z283 z==Hk9a;;6e5t~K;;8n}8SJwU&m-(%}UGpuCdL7WW>QySO0E-w4kJA>{M9uMTSuJ}s$Bozz-G4gVU? z>JF`C{U+!!tDLf$WQskG(tUPrVH0uPYhWyFJ?izFkyE~Q$p3iA)>{!nPgpx|K3q`E zeE*Q^|D_Y!B#+V2qq5IDylMU`?;;7%=5TKImE9M@hr>;`kfv?yR{eh|X09*BZF^6f z3PdsN-0ASLlY1lG0v`Bd$TKxnc)pAUQfhw2LN>ql?q_N2yk6tD>EOWOXL^5Z7$(^e z_3%Ak->rNft8mcWzm@IV-r)G#@16;6T(+B9J4?Pzm6P{+EDdWqQ%(g2e(5sVJl5qU z8B|3gWopbIk7*ZMhB!pAbnbSlaopuY0UMvW+16}YmO&-h=yNKn$tz}$82p2~cM|NW zH$4RNl+c>HJ(|Q$Yl>&FW~BX-Pta$lswr)o_I8O4gY5+Ro&XHBee1a%mltkRM2a`6Pp%)&5CXs*{?tA z2WYkU?6eofvRBP{>Yx#!e=%sH4b>iw%G6C(>VHaUNH zG_`G^VhfLJtLeuw%8a!veeX8@celOWR;Qx!3C@qQPhWjw4*eInp1BPv0|kV)BM((N z(kL_Z=>U!JXO0kFUM8L6NKZNN^DE7W{4mYK|5N4vk3aTp z7Z!LX!YPcKNBe7S--yv3Y3dX7=#w{2-W;AOqLu1@$N%?I#@gx(FMY|}yN$0eW2vL9 z-~3Zcmyu79J1)-d?V`uIL_Ae+2Odr1GurLMZQm#2_g$>2*qK3y;5?bgZCZjew2alb zFLBT7>~=VK<SiO9ghl+s*Ed?zmMeC zn9!%ZgH(xYS^&$#{c=(!pv)`s=jzzmwqYM`zf&6V+f=MPfGr9~UpX+z`f9exgvkYo z%a7BCUKZfIW;}6VAynp)YW(=*ghViVYlFd=DT<0By9NHcpN$uNKl(Dai1Vc$NIRrn zdst=0BJ}dz#Dk;0b-iCCWa(mKpb(FF^7P58n0N0_ z7T>tk&=9VerY@jvQpzJPkam*n%N@@!{aTUI8h%+%Z>r|U>27?g(Q2pajvE*F8tp0a z9LfFJT2_7coj%2Ecx}}6e{~(%buFy<-)6lVuVD|`dh%&~cJffQg{GOAHnw?@9wL=6 zok{TY%SvxxjJCR<;P1NsiR+N=RSHX2{#ZSIPq&|^tc(}R-&RLm*Snsn&`{;Y-97bc zl7Qk&(u98lQQR?EvA^w&_0=RHzqUO(>{!d|u_-Bg#ocC3t9y@+$+Sd6I= z<-?-YO^GQdBsms5=Io32&+-N(IhPlhK&nlzb##5duC8*P3}K47d5K*MRI9gQwfQh`KQ=?jbLp_5U=Mk@Z{;>mAA+H`s~Q%;{q;08f66I0k1V} zz9uNvr`|D2O{~@FaM%~7(bW4G+oTm+TiJAsSYPU8Pb{~uW}S<7lbURO45p%Akgve*ij>|S(D_d*Or`$F=|fcUW0$>-YWP;siTTp0 zD42>X6pDF=&()182IzD3$$H(A@yClvZa5s@j#pOIbj@bP28$HhMx>TZ*wR#2e0*)2X5?h7 zH?w2r^V`;Gnmh{1xCu&K>(l$v?#{lDD>brhxU6L&Eq^+TpFTO2rD*D+S&#EAJckBK zO~(az6t#~*%;{zw>ZdOYM3Y@-1KpZ-QqPw%apaa(C8c}~DSQ)wr=V6F-LlUmE?943 z&gVr(1TIU_jri7SY3bZc#!dZG45@{0G>a2G!~iGQm?9^1<1L5QQs1jKVyBrnT^p<9 zlQ*UwN5}XjHh(-`G@n>B#UYX#S!vuqp*i$4hvoqBg(ufb2TpL7l?xn*E#^RG0{i*E zw_P7!m|u+Aw_*I7H_!$nxU#9-V{vdlY4`sZ*1BpGNMg6;&sJG(HZvFvoC~LG&&iSQ zOy4={DnTsx?6eMvpHqyLGmZM<6IZ>m2du<31RQ4-z4smTj#BXoKcrP@<8G-d ze^=}f$Irur-l@2k}=7QMHRoBi9zTQx@_ z$YPc0n5i0yPu4#u5M&r6VWM5w5f&pWq^bL@ou+w!gvolTh5N$4JAP!@<4x%*P!f=v z$XE@=$MWaTQ|ZQ=1d(fAINci>PnJFtaXlDX~#U*fMNAVc8*lD8|0SB zhX>`4rj0$htktODTfjrv<4tqUT;bm$Q(I3Lf-n1=(hRbzB+g|XeU&ru zk*?wxP3NP&SF?+*xgNA}b*324Znio+k=2kiZs9~RJ3Hs%ru%h!v|^F(8$X$bYb}xW zdSN3%odY3arIh-fvNYdkvr=QaeG}@hl~h|12p)f#FvRhbo+mB!ov7GVLANI9Tujq7 z{`9J(@x}9E=CLA!sp@_SfnR$kE%%atxK~@QIzHP)1g&sxOc3snvzn;pDBNSK^4ej0 zzwJWF8w$A-vYLeja?ITUmD0LJvb!?l&3|50eKy-vZ(L8wb@+Pr6^g7LhbFx!AUiLGkblvR!8+ANx9IaFn z3r9bHyY4+>$Lc)i;&h}g7Iz{1z|GuI~qDl0@Nd8oB28(y|9@%L-)yk5apLNI0< zJ+*C?ucSgRV;?_tw~bU=a;i#Pk`Qt29;NuV*IC#Fl*Tf2@-l2&_i(|Mm{>fIsrAQH){n8?!qrGF6zm9e(q^bPgw?in?n@@~ zw~fc@_C=|EzplnVK4Eom0|JLe4H&9?uMqrUygDN>?^GANBlpFkCz4=jkMDE|=E-RC z8e01HeZsJv4ZF#t<&hBF;dwYmUde0Q~3+^bM?5$E%zqq_;s>yKW{ zo-Zf})YRS9*m!eBB}b&GJc$d#s?+Q56hu#%(n@JarqOuQA%%gGYug!}M+zQlLuCKN zcj-Ndb9!0ExR>-w)OHtJY`dItYD~yP^*6g*lKasyvaZBf`%Z3mma6-qB0nh^s5C0s zc;J6pvfaj(ZZsp&DNgs>x6k*k;&_E)qgH$D{52s|)5TgAVN_(7c|%V~c5;XPW%_c?jtRo|8|aTnRqKhGZ^ zbFSr_<_o+UJVMU8r0=E@+e=>#Tr zAH1VX$6L8s^EaVt&Q=uE($m>J(_XO+UOUAjDJ>Vlq*b)E;OEs2zo)fNqqog2@Cv7E zB6JzPoJ)6y%3q+_x{xk)Ac5Ms_kM7ciW+}!rSU}ByFhm?r@m`POOnPZ7y7Ertt)m5 zUe~@9NWnXxR8iO9N_#an*t}AjlItvwPE=Y@bUYQc+H{M(4tb2zM?9Oh`i!i!apkJn z`_2i0X*${ay}3X1ZezHKnerz2GG${gRHu^7mB9Nkq??shp*Qn(V@H%@?>!SI0;QeK z6V-!@sq;HJ7NX0T3T*4kdB%C&qVVfaxZm|nP@x-`uoW$>?$0rAG>R)H`l^7vf7pjg zEzix3JcdJ<<6+R$3l7d}A@|R)vT(*UfR2)CcEdQ8Ss*XVHAAZ`_gjK)a}+(dEA!L^ zub1^ZRP#zi{yAzt@!rVDL)6G10>wVAU?-}tl=TI?dGuQ!}c z+&)BTYQ<7I#QC4BT;-f>woh$?drncdeO*xq=pN@kQJsFLa(bNey~WXYmJSu#wnMh2 zih-nYi@aL@9l8-aJLo z6yr}#Gi@>%uGw!jlTj}f+SF@)IQ67{PgA!Pw!$jATrZMq1dkiPNqiwIa_Ys1(?rn} znMlR%g_yyCn;(=S>Y01>lqx{5i!=Hn3p1F4G}e1mR6ERXR=voR?2o_`$?YGu8hYEL z?RALjeCtQ^OS~!MJ+_tk3k`CKN~tDztjxU&VL$(k-|>Z)w{Tm^;>_Zd?iUN9*Wkku zUCgthJ}j?$$93cRcE5yEuPvE#wKOtJTIqk86)+a(Ps;dOuwVEe-@3`TlS#7lmRY>; z{_1mTYN0cXOicT24wChU$xDu3zH&00p8krW;y;?*VYL=KG@9X8e%|jrGZtFv>U>vS zIEAPqwChC|rDng-XPX~F!YxzY{0AkgB2AO}lLI&sRv-(LFpjktM-}ODtEz z@#MMZAqEoJik7otl`_&SVUeu&w#oY@JvZgu$A!<^XGu@U*MGsgvpY}v;O)q0U5r+x zNa6Pb#A+p@gg;HasUuuj#D}e4(p}8nZ;BT(cV6pTOmPHnXj(o0W$!LBQT@TP z*t^9J7S@)s;x;m!3-$;sEjRV&zf~9?SlVGa`|U+M=#B3~Gj)=3XcqGe+O0HrXcNx_~QcW6TFn=tVczd;yYU6*@ z6r}R|Q}ZqKi>+9%%*}ecVw-(?ZxDf-!o7be`+Rnax?hOB<&5J;rDo?7%nI4{4hao= zoWAe;)R&&Y^FIBy)Al)sk9t&P#@uJKpWlx?UEpEA$1?D|NnkzOw-9@k*ie6l8+R|?f4#OWDu!=U zv&Z=ED?A=L9iaKz2X4t!Y2S`0P4p(3KT*J(#?gPp9-&s<$wNV5rzo#0{d>10-IMO2Xy6W}WEV?gK zg5`r;$Aqe8GvY(+y=8C6A2gp})BG{zV_Mi@{)DVMGgPRfN%qH>O{{Dc`ngimJq35V z>3zgk6e$}d_BRC=30~!byV!jMN3FG=Gl%^e$;N!h&GWc172cn6b{%!J6EhMGRfpb`IDPVLx+fmh&T#Z_YH6#Myl(o%Hxw+~hw{bh z!%kk<5y8;l<~70M8i%QA7tdyJit%w$(&sdCq4?TM^tr|`xv9O5gXt{eLR87^D*kE5 z)6b&gyqeVN($tdvgP>n)+Sa+SrAcf45&!N3CE+4!UYdt;=%Ur%+&(*X$fOd29gZWGFyi}rDKvBv(HkfIS$>OxPilf(KKAMv=IHC3R4 zI>6sX`Nmc6N+q4HLJ{mmnJd22mq_2>I5G7tO1c<+JS&#e5N)hP?01pN1^;rqhNdIm z@%?oYKJ?8##&a4oZsFp^BA1D~K0J;;sy6Atc(GPVI*EsY(0IbzM<*kO(biq><>0*c z#DyfkIeo9PFoS-gG*+Yy1rICIyPkY>F2%xO?s<}>WY0L&KgrWpMF*Z%t6$^f0%Wf zkjwQXt7mK~pM;J$%?!k8>V}wQNgsUPk}QxtZtQ2q)H9VX*FbocHT!tA-oIJW+Y3Cy zgLm$?p&>9n65I2J26TO69ny0fCO^z&#j3Rb{B*%}h?uFvteU-P`V?(%0)4NQ#n*B< zap&pQvv9G+(W?|+P|FFfoHY=>{nENNfhj_aNHE9ab`h@r%kL4TJH_%ukcHVJ=ylIzE0s`PF%{MdbGXW-jIn#%>uKeTk6?&OiUKT8|VbDj|(KQ z+1Kkyjfr>6rASS-c<1cu?LV$VKN()r#kW#kYm?VR3jlt#nXx9O(*ze9hGAdE@w zd|6i7<)f=7_&n{+g?H@vOeaM*=eV#$B$WxeLo)L2)cGQM%BLZ~l1; zciKGT;V9P`){75fQY&mCe`?^R2^0=;<*_K}M1p0#U!fmy;llMyicW*F2E#nI{GocY zIq5H-@Wu4Z9KY%@b~e6I$W$8Z zKysjzpQV(yI7VcU+eG*xzWR&yq`6mvWn-b)#Vzd)cvZT2KxY*rH>b#r&LZBYsKMVf( z1V^*ZyPecr7q32Ba3Y^;=g{rc;XKT#qac?pcUqr~pSjmmKO%6u!|suWjwmh4e|o98 zG&n9+zVZ&wkCLLkS3A0AH$(kel8Pt9i|t`e_s;toi}r{2q^X2u+bcT$a1oh` z-mC2!CL8fn+eCAC+vl^IKW0J#{cx$rc^L?7sSms~K1lpn`Ji^Or67^9Z5>gxixTUg z1?|ORx=%RPg|!#N0y|7aUk>f;5H)FfN&WqOa>G&?C!9G>c~9hHJ@%(ki`t{;)8{T)NLQ;X|b(U)9R2-4f##6@V&)41J>-YJTgJx+52m;hC+gQ4(|?`|tG`?rA$(V! zx+ec>V^Yf4f!GG4qqI%U`5rk81p(2Pxv!=iiDRYW=41sKOr5NJ=VbLe%7gK)8?cE6 z#ESGs5A9TI)iYJf&Q(ndZO!sYE9GdlAD7X7MX(7prvAFxSCJZH>|k0{`N34yaqV*7y62QAPysoS2uaPj@sIR_d#}?c|_T znxZmmOV&LWb~k#ebhgq;uHS0ggk(L1W2$OTGGsQLlTT)=64WGm$Ofr|72Jr78JrKQ zlZWGYJ1qFn1bd}Ou7BXo8|LFpVTDvrTiq=?_%p>?9Atu9Vl8_|%v+E4=_%)SRK>}~ zh*T)$>`UXaSc$ne+ky@E^+$&7+C7=|ZM+&+7hW^}5x>*ApTKFqBES(|U%O+&@BMz+ zW6$gxCKNYW+^6~f82jpgrrP&!K>Ns#qPWPZUkoar3dl_ znC3UT(D16Gzt_W;%BA-}Yw5Y@HOy;^u$|C5y_)vE*RI$lSq;mzdEis8xRrH=R$oRE zm_MFL)jJc#70lJ~G@qU79vqmNXW6A01j9h1ubvc}ci)yQ>FsSV?vl1?VbmqN#Ro#S z^~KM^O+SSI2Ib_NDQI;DMru!IY#SAU=Dz)Tx zyhtaP>OqxeDu*&?UthZt`!H;e?sWKNVr~uv(9p*sh8s;hEJZkC&;Bh?}W?CX{515 ztnIlcc+a96#5ySLOHMnK1a|W&(Uyzo!(WtQVWa2b$WNl)R0Q8P9jjaBr>lx@OSS9- zCC%oR+-g*RB(7JNo+WH34ccXOTTnsP{m$(wCh^zcn|m+)>;Ix`PC zx%8?BZXQ=;Dx$T~8?LZmg_)o#i!4Mwl)AgQ#5pi!HwX-(0)gZsj5KZT%RZAupvs1K zzmn)0uzcB~&3ce|Q=;hYxb9V~T>AjZ;#vAo)@HQ?Bclrb#h8Pq7PVHwDVW~h+jP@8bKjT9 zjhuszwSFMsz`|3dE8y}flG|s$nSGgI(@tcBcWj|d_-&SN-^rXhyw?F*_*S=K6FXGM zDmJGCvLpM`k326lvGV5l)!=t5F}I*tIE}0b`$?ObSXmpP?kibR@Ieub;qhfP|kz15iJKoxO(thJwsfL&+M*!xW92l zj$eo%n-7nMDM}_D9(#MOLG&6!S|Q2APN+lZXW#nP!|%G^mkn~~d)XUkJyrv?&}qR` zQ6KkFNselUTovJs4)nbr^vtYG9rkBZ5g_*T=HI#KGkw3yO%=ug2iu*ub^S5Ra^1wxND1C%7g+XVfdjo_J2OMk~f zC!LJTdQ3|7-|Lrwnxsa1%iM!Dh2ejUD_2Lhb3t@lY69OmyH4C)sHlbtBtE}6_WjB~ zLTq5g(QRLB3MRGe;679gB72d^#bB(K|GgmjR~h))SlcSI! zr=qSA!ejl_3V&WmTX|qwvhOKotA%_6i#g80S|phJuX>EOB!#6Le(-BdvZT%x|1#_% z?bIZ`Kl4WNZ#O2lLaCn`x8^D69(9H9#{_cydQ%;ANYpv%xrTCD)z`a-W*~(TLpIVl zgdCLkzRM(V6}a^fG0UUhoU`>opx-ooy0~AcJyXoCXI}G^Tc6yjv$6yYne+bc=jxzJzmcw&TfmK4`ao@ zg;tjc$d0P>{5yBZc7cS4D(Uj(v+e(q{6B|ReXS1j>MH(FK&uA{YFC%19a?Kkn9h3m zRglOR0j_(~4V-PYO8so#&66qcU4uP0_0LOx=;{08@sQmK>Io9fe&qr3FB5=n-s$af z@{*SALijyII`3o*T>X)Tev1bx^#NN!^%dw)=6x+1oS!0)2*O)=#qDm>{dQifISHyf zcVokbTrV;I7uPjEdY=Y#>+-|D4TMSsXp|&bdvoQhqJj0zY-A%wo(_?JoGQn1WE0Pk zPC;aE?~<0Tx;^t!4Re*w+hG=4=JbC&M-V2&tK2WUV z>|8FL*27MeX(RY;tdE3N{nefE<)Q$i{p9vQ3$Ygw|NJrcziZ-mjh>)9dGapx#uQfa z$ZP%3Z+CA`Te>3q0?T1POs)7M#0H($l;ZNX;QUWF1Sq@M0JFRLkt%!}z|BoJmjhk@j2^jP-jc4wwe=(xy$E1%9^lkHhM3Za$nryHVX?B4W(m$X4 zOthS(w^1jiTQ)1)U?Himug_6q2sSi2gOfgiXFf1_6p3Y!NBw!#7rRioXzm*_EU>5+ zYc@H`)oo%|N(iocX*FaizVBUfQ_jrclbLtDvK;XlS_Qw#+9rr4Dc%({~k_2M7;$=!v( zjTG+aCF=kC4%z6)7JS6$q9|Rz+9sR-p&fiRK{^e{el%16&wmI!clwOVwz{%%hzWkQPQv@e@sa?Ix(4@C|7D$l!T?BOHD)tdC+14- z$tbn&$eVVO+U&pUi*pNsxdy+PUK874{d8`e`aRmxk)KdqZj`JLKlVMm{&oQ{ z5V8Pmuo>?C%;D4KSn)F zl@hSUoHdU(8H zRyb+mToQw8dCK@L=rlC}VrNZaHToLvqAN-5iEDRY`I-;@99U2zqalZP_J*sg`^Xq9 zvaqs+;la^W=_g}B{}Bc6AJv~cXZUt)CAUxQ6#eNZVq%c|yZSDY865jV2g=ix`sGO3 zNm?oY`P9FH5WpW+!50}kG+&+gOGH+=0GcxJ9Dem3xLo6(B9hr4B{Ssyym@UpE&*<} zdkU5XRWMy|;N27KOAnO(icH@lnV^Qbi*X8w{&jN4Kmde~RJg1ke$@%%0+$xt3fb=5A9%;w?r+5Fw9YS-` zDjxoh%zwEW5L}L#IiOcHH8nS)QurB*C~xHiexkl({nlyE)j-)~pjPQb?Jv8NWI6jt z!0);Ywzh|Y@;;nxr+%fzWCemcX3*$Wsq>711KP9XJ9{mgmrqE`cPSz~1_ve>@$amW zm6^XCn_B|5tm<&@+4gtpZ<~Auyu3tGPtEhi%B=QF%YTo8>;E+ESm4bGuY5i1Ymc8d zOQSCqKC$m)^nq_Z_FCyxN1yyu`v2c2WVW1zjA)AJ52W$r&`z#w-<^a%VGz*0>zmf| zn(Ul*kI3KzV}5>qkruwTx*ARuJqb>4L`IH*ogcTNZWI+2t*|g?Z(ob=CV`pav8Jlj*rOfJC z!+gHvC=(;BL%{z*<&q4Dlihq*xy*CJ#``%NJH!tn6!h$)#Gi)x!_*8atuRH6blWMFmGYCUOOr@q@~D8RAoW285rD-_?qYdyA~;byLC zseX0wEc(O8CFqIW+{eG4@dJE4q20hmv$LQezP4>STc#@D z))ghT25bY>o*8nMcV~&2k%?)Ru+be~;=k2l(_0VpD;hRWMTvjDUyJINYzSLg1Ozio zRb6en9X{?0Pj&j}t{zuh%%7gSX7gd^L-&(vzKvg==*Mk*@gX0w9p8DueH`9uMB*3l zqB0dJZb`@L z`NdXXLnecjy_v>hPl>hi^u0ua_wLyGbW6wVlf1N;JuFek1Vuo;JBnNqf^terOBdq+ zf6eN6(X7D_xxI_`FOEZ}dL}qmNo^tNoDj9L_q$Yur?C--C*wFySTg*3=mfIGM>oO~ zH7OEsfYY8FWGS+mZf;*l2IreZE~_3pIkewD|1)R{UTfNMjOdADaFTmP{$t3dfJ23N zBfdx8<0M*T-zF@-2pCxl#1>CK(q5lmM*2A>@F|dr{tS@Jf%(8hO>F-Oz-R!&Fd0Y} zUoiLF{h){3rc-~`m))XbGQlOjS=x+D^G3(fo9^T-RbmmC9eT4(OM{yGfzlKG{db~J zt-uS+y6$yL;LncOtXsV7^Z=}F)zsZ&A_&ELH6X|G<|V)fiUhijI09~+QZN1E;O z0$z-15Rck3=-%ribNXzb*WqvCS=m7B#ux8&dTt)a&C8m-foojVo}#;e7UHpB;Qd`L z-=8LsaH15VaJC6si6c`2eKAddAF})1rhXC4{tviIP0(cIFU;CcdjyCd`E{)@?Qfym z;8L#uj>ywO`o?cLIhP{Z;SY007B?M`DU)a(R#m-Wq6F-A6C^DS7$0tiNTZ^r79rgu zUN+O2?6h}~<~hk4jLN=PA+Cl83Eg;Q@ zWZRn-{P8j_=b27@!aB;c!c?xRo^Utiy1GK8$t!oosnBCBub8*MEu3k`gPT`S@G zcV(HUmuWz2Sk{a8mhvwKAzQHk>C(OFq_=`Pcpw`=<@XAinFNyJtwne;H4 z?sb@%fO@>q6Yd{IE_w&L=dfW^1O7?(M09;=)%H{=p7vdERQoVY;dKw>&jdqL0YXB(Mu0*l<-gp<9?2c6T_Ug-0#MDfx=;tHTkP82Y^$jRdWg4ejF z$+U%W5#f!sem<{ZT%hi|9J0XtU2YB2GUeGLZDBc!lIRyV$hS`g;$-*DSJ zlx*kLQE{vvX0pOQuHc+9vv0UGadS`lf|#*;(OpEtNLn6yYHF$z*2KL%t-dR^ex;$= z)D72D9L*jjF`BkZ$TU)z-G?b7wtgBwlwO{d$nphTyB}e>cTyM&m10GRMBvc0*!*RiUiS z&4he4mv#N79m5#to2ULK%;_eBFZ{c`{T@huh%t!Y{Bg&IE!bDB#<+SeBFi83FY;4SdlCq@1bV4sbXxFP?&cWKxTGh@D; zq((FZ=xV$O(k)52pej?AXv98=TVYraa`v-ROX}ND$Kc8y7 zH2k0}GgJOFug6$rg!&SsCCv$S&t2ug8qB}g4h(iK+Q$tB_XZV1jKI68xcfz`i;u|r z(U*PeCeOngNYl~RD330e-6k}ISTQDe?=TN)_sR;$yR^<-655lcHY>2 zo9};<@E6rwMv}_3&6A*VGFM@jv2R)mwKfb6j{3WIpiMSh?jTSl9UTIVb|LSKc@BZtsVu)NOC^DO1|AvXH&~CkJ6`%W4HhHdKU=Uq zz9G;Gq%H%c2*zbY8@01u!xrm#6s$3cX1*rsyK~&y#>RK~oTPh(A1HgyR{FPz&!=$? z*mmCME59gAyBo{|j&(utQHlL!+6=OO4bKu1KHQ}Pe+1sNfGkVj?eq~A6}_R*>{VX)@Es{T zt>25=IyxW52Gh*+Ybt5R`}0(xc}ss@DVJ3^X%@3xNwHqR`-bA8Ww z9g<;=k}*MjK~#@u^($@Sg)4=xgNzC)+$+GIq7M zGvREOT-P;7vK$w2TqQ4Hi(L?>`n1d>$d$%qQ= zbkOY!eag}DK~svGS5`A$zEs*g?smVbASY+A((}o2sj7|c`kJ}NsjDc};@-n`w)^Hs ze*XTzvRKBcoNeLuLS)`V4n;t^-lO ze6Pon!=G#yhxTu;6f*;n8b5e@YCYHTp>JBZ*%iYy5GhT!uV(S?){srP`qj^eYNtAn zT3s6o)$B5^yYqU%HV4{BsMZ`?1d41sV(TMBStc(@Q8~8iLk(BfMr9moL9#8Mh-RZS-7U^ZgG@w5@n#_UR3a zX_$W<7r(lKe+}82Ox4FS8%`hM+yE<{I{fHJOtr%^K9V+F-}33&Qgf7dD9qiYxvsT9 z`V2m_p?C;4i19h2Z=#U%Dp`e4 zK)M;$xzBQAAug)@wXBp5VB+M^^XITNcZMj1xUfW;NioTjeWI7GLgcixVhXanco}Fl z0|9oB=@SP3(EX$p`2As01OC+(KpMRj64>rho%@<;M;dmn zfvw)0`H+)Q6S`w^xJ0Tjhbms3^sArMSN}6DWV`{$8w~rh8ZS-#>mIK#nb0?m?#HlqS4qmX(5K8ZG!o-_Bn%7zcs>$%VCdK(KrQaXUlXsu)N*vfgzEaOy^7_@_G>A!3G3*7k}nk}9^> z7jv)Mc5yTPc;??i!mqM1r#<9^!)gXKwaB99_`mN3!2j(OHZnliLEZPXn=I+Rg_zc> zfuF()j-UF51_I@em->xg<&x}pn0i&ZpBU7`JC*UR>ElO2WZ;;hKWjyt{)b#gs4RbQ zD59*xunfsABrMx5+q=rs0ZubHvJdBhIku&8lq(q<>SA*Ubzt7W{(W0CLT3($n3Wg)^eYA3JT8Y>s#w+&dE6Q#d1(Q@M6%s#w4Bp9(rQ@_DJ`@Q zP!1@(hp>7-1Rm*2%8$|3H9oQB2y8F~2pP~w!`^Pr9t3#9)Zrm>`oAN|PbAe8O}UC@ zS7`6){u{Z5?AO0CgRbBDs>{O{_ItGlmHz4xIzXDt7TqDgzC)1(AgJgMVW*P^ftLov zKbZNL8{gPF%y|$9SeI!7n~zD{TzT%CUJxn?t{I>Tcki+~Uqp(ao$BuHZpDgy4L|xB zEFrAM>jx)kaVGAgI{Xs&^T{I43E&Q+F+-CLo)De7m&s=sNuY8VAnyjh6g-Ei^Jx2& zR9P8!@FCJtKeoHKbYa=CyEw&dgZ;IX&@~N_acpB^bq%R8}_F0SC%DKR%ejy zM@yb%Wo0E!?}i*TAxUlAUY#U|ITZ<|DMHgG~FnnD=8tkcKSdW%w(rgP8-rxN!JMmAf@Q zjBX0R)I7SGG>%g}LE!8znch)2SRb8YbDl_w2w1f^)fcit4NNrz;wQX4tPwoxgOh1b z?XN`xYkci)#YxiERJ{~^$!kLeX`*2Dr7a(L_pWm{?sOx|-MiywZUMWnY%?>wFYKhc z)ij1{cNUONbW7g%ADK&Ee6Q6kus>E?Z>AB=RQ&iRRc*kA1fi8KO2u6R8x6OAb*XoF z&<<^QaEjiqv^VXTq+p$6g|YhwSIF}T?1C?3<5ub-QA^#`+BIMC4Kt}dJ-`QVx=F-h zm~H!xm9}LCDe4@T+h6O&dXc8BdxZPHtr>_Y*GLgi8ip)~QllS=_>r#IP-IWbA!Nee z)7JUpt$5b;7<&3P&ui!Y@Ugz5Cr`g8x100oeGj~y?R+(1rTXIBz#jG)ugT7vyYlICSh&}y&(7@KptI?My)j}xIuS|HB(bdiMDLGfvE5>s(AH&0p)V=HHrL$w% za5eg#RZU~Z)hst)_gAE{TifSgB?a`$$S3kvIL;1hY^u9P@|_*Gf!V@aJ+YG$L`gk{ zYJ_)z&xY#cvy|f^#kkk%9?+TdDl$6PtC&ubpd{a)MI)Uxc}I=_N{i+MI_r>afiHh= zto)gG_CL|;H{q+#2Z~w!KGXcBvU4hFao@G~4NUxU7)W)Ri?`y$)3fnQJL*%hj5EDJ1h(6PYY!)#X5S|Cls1MVIs`4*FfF{ym+f* zUXwSsPy1G=%n2k3pn1J)3xtUmaWUaEA7#$J zwu?NyJ`deBXecS_Vcr(6or?vLlDz|kP4UfyFilI*!&xjCRNp_b;ea=kq0m@PvTandrYBkN3s z0>k`epY8ke95df-Tu%K6ZFuahkhqWkBgM$ckw{EZAO7I*{i>gek~;yEH{%YxIrGJ} z0*y~`(WcYZ8Q;4b;5L2yz!RN6R1QiM7AxOugiXmeyKkH<<+{h96EcyG9etm)Kt(?<^(T_dn>~40_p~Ia$~*)=tx< zw`uJqup|>R(-Q8kCUixZl$wdr$K6=Gq2Jx_Wv4N`w7dFJe6wIZ0utX{nvBq#W*b7G zQ2S+T(L}HEJu6Y)p(AMKma2DSR$*U8E?!Cd!j+x=BPM-egXFF?nrRn`n1j+IBU$qGVjGGAs5e+U!k^gjWxXU;^hVi9KjM;91dv@GfP%v{f#m$_f{ zW%U&3>a_5kPvG9lV)?E@xl2G7a$eR)(XU@1A0EKj)!sj8zYoTKp@QXD4sSok%_*?- zd`L9qXKmPnoIHK1ZH?3KN|&+(>$QQKDrh zL^6nc{DKaEgtJgT^SQnA7$#B=eUqjM`vK4dlNc_Y?n(a0c>hxUHX%Jt0K1EB)D|o2 zt{i7NmbN(mykPxij!u*JUhl|IGK{F@Q9-K4x1w&D#V1{V1LP3-@lKC{cl#0LR5s`I zG|)l7;dSR4#WgTvAX#tIe0C;L$NTp_FL%DJj6U)`Z?JV7YLJzy@=JPQ-IY=In|ywH z!k14#F}DHes?T^g{M8|TB%J-&)M~lkZn!-);PQRoUn165|LLUX;tgG<=4X9)(ZRvN zYK2ZCclN;RS3(j9@w5bHZJ=a_jF2<1Pp+t}PF|*F;(%_?d=Q*(6Bh)KjvK?l$Ik`r zpW2=I+?o<2=(u@R=ze`ojs9lT+LkQyj%?$3M?`~x5MRJf6w8e3TJg$Ww&t)Wq5-X9byU$E_EqeHb`S3K|gqd5zKddCXy1< zgP1ZZh+VAN1s11tY{bs0USm~!teAz)OE28L_8C6(yh}iUhu-(Lj&55f1@onBhw2xc zj8ane1P+5*o7}MLc>=`QT&E8I$xkV0;!Q=01N>7wk%JJ8@5^Gju0$KWh^d%_n+d?Ykm1r zZH@?WAigHdK3*EYX8QejNlWey}_?mK+2+NB6(<`Kx*j z_@;@&7`mksAOV$MWR1?*Bd7qvKqcxTwk{P~^vZ@9*vwt*z-MIYp}(?6-=j za9noV)PF#JzS_Owx%|qLsy^$+#>Vw!XDIU}Ilt}FX+fv=DvKlI>>zlF>++{@^b4N6 zbf3DDbA8?_8sMl7xr4#kEaaA~T5xf__JOGZV2VaIXGgsg3B^=xnIZ2D%DM&q$(k`4)NDq8@+Zo9p!$0jPOc&_js8^ zK^T;Lzhb!GJb;i~FBqyg{hQOtu`!dvu(3X{n<4zxKN^=0=NJXxhh2`|`o21XR{2$10ffsAD`VFHmX zE-2dAM-jw_$SvQ1b8qB;%IvsHi`x2IW!Bb|O3)qBM|9w2)6wDag_JA4^^1b@-Qw$m z&Kmoat0P5wy2s#?;w_h~+@cR6B~Bi>pp++sQ(OW(?jkaiE3b_sdP<*og1h7P8P+Y- zXX_97r>>*Z>2#pAx#E4F!p*9@mL2xzxSgPd_8}gVRD;JwKAK@c80pqi93^;9%ePDi zDUt9V)oyvoj!z=e1d$G@RMey<~l-mF)o#+VX`As+2Lk zS_ZmcdFS2Ly{Tzw+huMu@f98$51)qKC15JkM=S;)A+K&Ty)rxkvvPhuukjpOguUv} zvhv6(kjI{Fb>)_sde4aHIb%jFS>Z0dPpFy1YIK>!AF_GUx6-QFmDac?)i&Sc=Hc7u zR+lKkw%udCCs^o{bn^7+izfbTJU~^VuY8&fd2#Kpx`W%lwvOU*U8-V;yPpSqyW z7tLa~u(MXQ*oNx4Hh2Hc36e3jWMM@aA-s@ND@n*BepU4HmhZ>05xIqjRn-`6eVcDix5sFSFy{z3P`3gG}F41lrjNd(1)E$kM`e@k-x<>Yk z%YD)1@cAivjwrPXQqSE=m5QWi!E$LwJ%HDoh++?&c+F(MIsd{hk}%gjnS=rbl##dk zFD40G*V5AJ7xeO~TH*Z6HvaLYlEF@!wgJ?l(JtD{D1V(cJMv0tGJGy zPW{X5q%*BDSq`=hZaMV?fGcf(ho!%vY7}PIG`}QmL*o#dpXT3f(f*#hyRB)4vElPs zC0LGt3Z%T?mW&}LrVy+zpvI>rnC0*&oJC}GlSxOi-5{bOd~!X+2sz{ry~N4lo8_~% zAaKl%wTrK-?9LYDJ}Mo&tvq=>$SjXZ`K;5mR}vuGgJP4;{5B{x#$PJew*93hxnny{~U-3 z)?Pa)uD7`*r5{vH_mnGASprqed_oH2AJvB1FiZ4r+!7EnHtyl}Es-HiHP0qBM;>A3 zg*tRyGSGqEfQb|e6%=FrMz#9huhCJsM1;=MB7KNsVg6y&p5wTImpAE_r!1>e-QxCR z=u1Z-k>1A2ayefbC_b=W%@S3YnR<1(5@qT%dOh!Y5**H1AbSO!uD!hZI=ft9)5m9K zS?nL6T#@Pykh3GsaB1uLMahm$`vC=DSGoDvgIICR-JN%4_p)qv*TRi`Rz6@{hL0Br z@q+5#SdKG08*F$g0kwL=h;AFv%4(I`KJM;?_679YjlO51`?cI;whSzU>c5jeuuuYW zD&D+;hpZyK2=iU5@wHjg;Qh$1Rbh z#{(h7qX5?(RY$18h{FByd9rC@HLO{+st1NtEYDBO0+6L+89TvWJ?UQ$f-`F3D`%h= z&1B{}1U>t$;AJj1?V=l7i{HbHk^okY@qgM$@-+t{I;rwwF&M)K^GZT5W zn`)c-#6=Jb#mpHy3I)lYVFMZWsrkYzpY%!n)nLNP%4Bkfu)ax<%gp1;pwenLuT17c z9_T9~j|>kkrtgg1vN2$+BH}^*tUHXk{w@X6wE&urK0d_W$!2D&S#B0hIEQEbG$xAf z{J<)ZfO3SWoBGb^pwAnuB1ktL^eA9kO~eqhOm|tzV=RNo{rnp~S!(&0AB=cIcUD!= zaBQ4ee*tv|o*YE)wwxbs`6;l4HF^l7*MWQzH@nRqrEzAb&|+fV)UCL-GL45_Ib1j> zhWW!t_UGQx%(qJ_IW@z?c_oJjsK)Acb`7aJ=~!qDlHO^_qGfap6^cFU%(r>dc&Ay_ zdQuu2&mH&j(P;OraCA;}HvK)UIaLpIy@Ak!-c@dZ3peT#Hq7>fSt*vB0|-0qW}Hg| zxuE7Pk=(bv2<#+eSH{zBMPWT8WX~KukK**1pz=C4dfh+*VWeAVc+Sf)zp_fJ#QI4v zetulQ-xDJ+cWJVI)q9*)!u7KO!r}c*+0DcdWIj`|;Jlgd+R*yrW+-iW$$beZ;ty1uK9UL^*8{Zn5v+45c5YL^e^-71Edegc0QD}2thp` zgqh2qAQApbvXCCN(|_$eJWkK=;euf)Pkm0oh+bJdR$IkfapqoOJ-q@P?R7ttbTWSM@O#N8W;>S6q+G!Y5 zXOqGFgF&`WK^RFD%p}NPoKPO3YO9wA8oNHjNk-}8Hy`AVM3$?&-9w3G214hJog&Av z^>|V;Zjc=ssNplwsh4CoZj(!0I+p!bUiZD#zQ`=Yi^qv)ANgr4K2lPR`=@MtP@dGF;%uZ;dU7W9Wm1j zdZr$^J=({qt7XuGyxmOGkU!HTLH7 zicuAlGNwQq#fz=Da%2_CF)sGb-mwtjPW)%4k_37)@4k}5-f1jHrJ+EVC%<>=1=$9U zX}s36`2tf(7XO_Gdfr8bGQ{n~8^ zbH&!ql%e>CBg;u|B^&T>>$xe~N{Y>vFGT{_n}W`hw-i#@cPvIPIAWQvA2D#DS#=^6 zZq0GrtilFqk*WQ^()Cw5N0U;Y`2w{y^RU=AS`1kg;0l#*39t2iVbA>SyHHw=9)qvG zD+i&h@!v};p{)3`G#(gEzc4yZcdG>x$-js~iLnxizWty>8{&B)uQXU2=OldZaM0X) zl3kq^!3}I)Unmii+N#ayJwp$40B*aC zdcmY*Of>S2c5a2cMtDlp2nHL|871I%_wvTP5z z(}Kcx@7^!6X;c!CG8ilD5DBQv$+`Af36?w!lOMeeEjIPT$jXKtVR2vP?S2n5VA%z~ z-@~L#wj-~{S;G3g>-Jl#-~{YxXyWA>cOBv-_|85{r)EUg-DFN(1<&xMegJl`7d$wV zYEc0b{hX6oOaH<%P^1HE5ELeWpS8Y10<9}cz;8~bi?aHAEYN7*zKy&z)2a~ZqJ~#{ z^_GGD#qn|#1sCk7Ye~{&E9x=dtq46jH;+tg^Qibk7CNZ>SaIO^&egeI%R9P#(YQ>O zH2+yoy;G+l(90Il4Ortfh*IIZ-7Odt=*isXBbaA0oG7tL+*@ByqV)t>`cQF8jB~w=;nH<(72r~LBcta9+b#{ZSKTiI z%w93AJt)~ty>tS(e5aThpe%Xj_n;eHpy zuqk^YJ@(!&Dg=Ty zeJ}xRc(^{g_|%PYYWeC+x9mjnWfjws>xk)=y)8@6%}8bIcf&^zie*5{Sb2Fefa7m= z3Klg3lZn}4CUk5)Yu%WXI~Hi98?kONLDtfz$AuNwwIXaX;M(gp_Oq4{!zb>z+k)RCxd-s%ICakT> zB&cdt+_2l|Pj>!ClBc>2P=BAsPZ0Jz0y1zW?-hG8^mc>>V*n3?8v1zW~}Kk^WW2P zB=kf;s+Nh*1dL3(C6;-4lKj@v!>loc5lG!9m|*!$qr^3H~TvcQCw zdwH0Fj6+ksd;aY=vF~8AXIR#{@(rz-&YLpY9sGVs{Hd*%2Gy_;+Y>!uWj;6*+q+-ci<~I%BP^f#( zF6&v1!Ur0T2{gC#EwGY406w?v%e&?HVP4D*KP@T!RdmH%{2 zOb>3H?u^^$mg;Ft7BQ=GF|Zt&MA1m=%@d_G$Ymvm_2>EQ%tp-+i27m56H!R-07cc) zILzyV9*uQvOpZ(=q!C{s<5z)Y zZd{%#YN=C(i?r8cx0@3qC`V2P%gEk#xlDn*ooB<0V(jx>q&IM7OF$g-l7zpgyF1!& zy**yUOmA7#C4do~gRM1qA47<71rkJ7vE=@yGplpY!7A1t%xNn|K!k3S|Vd`U|#gO*G%^DU#Tp~itx{dWE4w=x^=U#QsE*odp;;mK)A3W*W0-q_s1Sqo!G%1l znYoC_oZwvc!%rv`9 zKUwu+*=@6SF4a&NJZ*w}mRO{J5r`9&w_prluCm7UP$%W#a@b5lkrDF4n!wXzlrcj& zJ!I5HGOydNLmWoU8YvE`c`={5&VT1|0}8h)llQRdh@q$l(tNV2hss0A@P*&7Vsn^) z#Qb@7HcvF9_aJz|RTf!$PQE_w;}&B_U=T$iYJ$m;xkmmR9~I>!1OU(q7$ua3hx@}f zGJ}<$sFh(GSYiX(dia&aM~wVqhRLqW50OEi7X+DI-uh~cls*VrGd^6I>RuZ>Gd6#n zLZnx2`Id&%(5pFWasgdlLQvGgF_G52H+d{Vn4%WkXQ2|tgSQ6rwNz8YK>RCgI3fjx zX>8=NU~b3$WA0F+tS5q9o!~(>85LTK>F)*^-pa2dydjPEY7htQ_Qr3`K8?>sT-h2A z3-@eP_R71`9$2tFMiok}+p@nL{> zFAMceG)_eW<=Q)@%!l$_9!X4($9s>nR!B^CxLi)pAURmvfV@$gy}=C2xKI~Yz~6X7 zc++P)tU1-UuDRWH8bZW}d*|G`;;e-BK8U9}*(f>@cPv!U@zYh@YB|kB*z8cXSta?S znyp}?h}oDL(+Fm()BR8L7{UMw!{V9qElo4vy2fCoV`sTB_~VT-E&*q#m)xO|kG;A- z@4Ai!Vrfn?H#JEwN;^!g2ClD4hmbon;^*{dUW~Xt7j<+X8$mg97I5=*RrJm)b6lj} zOs_XD9;m>IuN1bv?TpQx3lQ8nYyA&fKH0I^)-bDURf+OlWA%|sX&aMuSRp+Ge29P! zJiT9a8a`bLO*%WKERX)9L6Y*Hrrj|{sOsrQqWGU=U1&uB5vXG{dylJt2ENyxeli2^ z%jK3h=-+P`ObvfS5gEN?hi7>9cBm`>TrC zfE5Z?PHIz5v827YyBmjw_~OH5kQKBi8%%*{h+B=P@dZ%SOL9$jyt^2VP}HLc%!=ZI zFR&F1Apcy)*?3I&EJUf2QI|G9xUJfLL`lY% zc&ql+2gQ|qUGohf;V^cr8gZ!P0jg1)?aa zqy3!+pRdD?g4<9%Q2z9r+t(nt1*YVUNpYt(T;kQSQ6Ym=GwQbVwPmXM{H?1~yCh1X z4gBK`*5y)|O-rRlFV}}b1tQ~nkrXSJKRaa3dh%J1jtvybfF`&6#P<9*X6rQ|8=iK= zbU)&CR?2%jpbusc-*n&65r{SB;s0aoyW^>T-~U5)Mgt+6Bt<1;pOP(mZ>ePOd2mQk zky#`&BV`>U+d<3DjANcdwu56I9OL&o6&3G3zwbXD=bV?<>viAPeO>o;UC(RWs=JtX zC(G>l#R?4;ii_O_bSj^&mff3UC@(@M)?K-H3?0@ zTg*uu*XbaO{RmC zgeWE=UJPNY)eW~qmwzLR^t0Qz+rcj@WnlWP|Fmr#Ors4;4uuVy-1(}TrPf%gj9R1b zttVM7;hjEmtqU1vKUCwILYfG{WO$6f5`rI<)P-T3DYh^w-q5n;4`R|m4Fju4&s_@r zg#rA^(>?VHEVq`5;dyRi)9|9?lk5(^{Z~v-?C1a`|kzO5G~H>W&YoB8rK=m!n=+;!BTJx=yiMBr~8AluQZq zFEnJ!(B3l0p6SW6(P~r$ys~Y#>6K2|fm77-T6kz=;41m5)t4;-TdpjsdR%|J&r$wj zr?_G^Z0|-B%GCruQ7xll*dWsa8cae81XNS{uS_d(0im?V>jU@V*S6Sb%c# zfwE>uhU}Si`AGIU$+g)bi{~x|*D5ADWVIYoq=}oh=hZH9w|F%PfPx=57 zW?9;?d;7~%`f+iaPZ zm4FFXZMI~0F7yV~IbXcLt@_6IyJ21+{e3f+_Nz19>eXM`!oemue>`q=^gBc4 zY}U+aw~iuU!O0dCm!>2~wOU6*O^_DI)2M;Xfa_EZ@#q>JWE&Q->E+q* z$@vqL@afeRgeI!Npzca#KHxckd%TTS>e=G}9aNx<<~7bk-F~El+Wsn$Yf#i={C%R@Z*j&VTnzCMGyp)BD|Jf^U0dTyIWD4tD)9d zw^V^Q@}9$a@0y-TWi%cv7f&;RX1C=i#r$Fqv4dCkcx4Vo25rVKs#Xshny0W8lelR7 zrb<%gp5L{uO_A4;8=J4|-DW%L83vL(2cPuVfZu`xjLW^dXRcT;oI|ECbX5xxhduth zYK{5er_6WTZ_8;nV0$ggNWxD5%ZNO3gn<@1X^e!oCP^7qZ%rso`*>K=3t7MHSj(6+ zwSF#5V-FkAv4VJkk=DL*Yt??%T(B^(>$JIXVD&S6+I6CrOI5)ny_~8cD_g-fONg|R zxdp12<9WUvl=vgm?KRJ4o=0vZU+()No&8MPi$_bDf4R+6gUZf0cjb80_l+Sb{<%Vm zANKYLdD-q8t##OI;{RcA`-47^lo2DxkxFEB39w)KxYisGyB&;`XdEeS*>7-Dz-8tK zzEZNO;_z$RV5P!ei)DbtZ&r^@vB~SD=zczyw#{BL&dwTABez!VIP;rc4*XK*D|Q2k1t3>GnXOKOCaQ`r zxR^+#I9*z<_Yr?T*q^zI-5N2Sp=x8AQI8yBPc>icQH=si_K|&RQ?2R4rNAtZ%_8iG z;!vCN597cX6bxdM4-GATx*_kJQ>2ZF{`OSSrbtJ8KBELbE{0ux7ZAfPwu>C<5v$=G)xmltV!9iD2 zruHXsK689}e7P6-pBA9NTp1z9M^wms1cLAmW z_ve5w$@qGZVZwp91KhT8e4L7yqjI2U6#Tu9doUznV~kVa4&0=`Z`2#ygLzy3nr-RD zd9A{pM3+sGrC!Z&k<}G(a3-_{+pm{C(xkcu*0@1UQLJo`8+O>g=`s}$TGu? zD&P9G+5ox)#p4N!LEHnAeD`Cauw?@?J&(6kvc!awl>^<>8~3=c7SUSil_r5bPOrBb zi#;>5$CRv93AY&ME?;?Z(YUAAwj)YLR-YV!EyompE4|K4TZL%^2N+IE^njCBb$nK; z<)n$^91ypO8o{OA?bD18IoYiCIq|`R5E}n zu^%`uoy=lQ_pCkzz6wYB4!unyWU9Jh8oSafeG7mF1a$-*dIo{RE-JTEjt72bjLIW; zw^CV}Je5K*gmtC!MO&yMTX%YrCx7e_Q4cFqt7F?I zjaAOV>Jc9GzCGYE4@p1n=36U*dV$+60$$17Ud0Xe$)YbKz|K@v!zM|w8?z=3OPyy2 zSgAwsyPLEkR=!>Bi`oz#bhgU4GWm7V_yY4uUt9sq32oMI=)Na2ADnQo_x}?9!&k?Q zS=R?XdO3t&{2#^vYYRTZLlc;-j7at7Wex1(tf*w_jY}REkzc1%JiL8m1OjI@HJ>UN z6ln42=afBfI;PawzE#N8nOmFAp%@cGHhni;+JL1T~)=j6`?I5&)s)Y|@R&o4x1d_oyTh+EdGVyjDtibmuML2i`e~##w>T^=5rfMm?GX%T#9@BY zQ>+`AEMEPjk#M1_&y7As%~^FLvuE`u7Y9V{IXYt)D}#?kCIhxK4khf%}} z$p;UJ%11B1YA7qM>kCPgbGTn(B8&*&tRet;CfBQ2Y`k1F(G{_q0c@cr(q}3p`o3K; zx7gsuj%$RS<-V2{Fb@#X>avOn%CUxcfzp|JMp9^OgYCqOL0_R;NQWJaxSW``{e@t7 z4o@JKvh@myvGm*!0dmTHQb-u8Ydk?&1E1Xb+!*|6-OV?o%dnwYwZ;~%*(r0A4uuai^_F!j4wb-t>J8z_^kOXdebi|Dc}&3?c)(NYUf7|gu0V;#L!fO>q*}(UpQ`ot_1Yb0iwi3+B!6dh{`RDC zpVaxNPkHO*=oLK1RNU`Kt@iblzV1tdPwV+<(k&%CL1i;whZL=HDN9(qb}#GsT!CcDuur*wTY$YM8i}Pp0eFL3lY+He z?3KzKfVEs(7A^7_cTUS8l#l|p&6SI#boW!7+TS}r+x4-LpdfP4f<`(-zo{2=i?O;k z*JEdxSlH@G?AVS4wZHdjzSn^|pKaB&!t^W zKl-+5OqP>Rh*o|BH|VnXaQS+#6ntz=q{)jbQWZ0-3qQjTXj`X?t8GDKSh&T!hpNj;EL$7J;@obiW~-xa(L|9qi_>=}?xy^mPn?2+-F+$qSQwNBFg_-8sOpuU zoR~lb1ieRXUzLi+?8L^yFHzVgufL|xH^od<$a=6l!5G9EE_i-I!Qe&4ro`IxTeD^; zL0xDvCbP-`8YQFDf_H<@+9}dUbVPHy^K}TiYDU&O4_dZP=7s%IDr0KnEHJ(O#&S1_ zP57NjMg>4Pwz16|o#!eT;{Hlv8ApfgHbU(7hhE{j3f;S6Xx|;L^QmyP6sPn&2xo!U#@NGr2EeKU`OKo?+kcTbC3$i>(gEp#Mz1Ox3Q%0%x4|t8VT! z6kFsmgO~(jI2XPAM9%+|u@kp|5YQ;i-HF1*i*i^jH+rV=w?X4sg4v;NV(xR7nl>)pPs#S^OzDl2YFXi< z)X#$``|_Cf9Lr+fZC#I8ouf%JWt=1ThN|ZV!3-$Rhz|qm>bn%(1)_>B;x=WUBtlZ_o0ujfn3@o z!pcE4+UZk$<8XE0-Ia)rr7xAH4LDdTm*3XzeWda0>qA5V+A&+jH!Id=XAC`b;gz9x zla;Zb>GGj8Jq||v!h=qMq0CYSlGyJ8sOs{&C|ZkwE!wL-y%O(UW4yBQ)__ZF|}gRB1oY zR11prA|`MQu0VOCw~Ywid=l|Olh0(CH^NQ)a(&jDf|ojMSQ_7Q%j(0kgX$sJ#nLOz z!}3<0`3faNdX?sOW{Ef)AVXxsIcR+5lIHM=BIMla#V<%o#-xRzl ziGb@(z{0(VIUsTbV|%>--WsQssGE{|Lwq*A#`-q91xV8MyIb)>YcLfBCdtckIuf0F zZ=g1a6x+%NM31Sw?ms?ti^rP3kXchhV{B|xRVe0blGMPt$x^k#ha#*`O>~Xtr`Z7$ zrP-ycKM4Fv01{St+Sw#<8$AoooB|)kxXKC$JeN8F&jv$8(G;qspE_WxeulQyv=zwZ zSw!W)+dQ_7?rfb{W@GP$P>f}Rm<1y8c=)r3*Hlz56fDj*!dZOmhIY?0k3bRy7?w_b z%UvEyd=_w4T>DN3OUT#~BY!N*#P+c93p;(NG}@o&I=rwNV!DpKk%==lt}TniEizOR zqZ=C6=0y?I-qk6iJ?00Rul*gr`Hek2mckQj5Znn1<@ntyY5yuaKhOkn>{;Jl0oP}L zP!#7E5dSp$f6MO8Y=2?@PR8%2-}w;}HU^U+zh+TcCfgIKwr!fngq{)8pkzKq84W+8 zNMT{uSDjHMG%c7mJ@NIGQZTRB>1OsJKLEcisJUgh6)+|HNPHh`LY7zUSJ^{Z?io3YU9 zNWsd@`+nA=J#`^>!0Pn-oxx5N?Z#3(f1;g=`20jsY3lm+PweA_+-QI%8_F>190D$w#JR3vUz&knaMWS{V@JfNRO{U>^=Q6)w zKNyIQg*sF4&?QzWsffF9s;M{3?9kU-NQ2Jw?wd(F@gW>nztwNY~NT^vAw0SB2r%$2{pkmAI5R#fa>|HdGOv|>xG67OB=Dt&22T?7XoFE zMPzrM0R&1e775?3t{rwZo}{2`Q($#2FC2H(v@}@7oVP?I z>BHQMkJsk3#icB`I+QKX)aVY}(hL9cl!-a#I^Ec~Ti5+|&GrwhRcL}YtuL*+J0r%@ zvRJsjs@mHdLpRT0hRI>@he%WPCAGHX?`%jN=(W;230A{Z)%E>VTpk3*p2Xt| zK|t^3>YW`M3-~-nu9B`y=5MPaI8<5#eSrvYx*S3ER+=%cd3qywI_eT z!kUGHA+*x@tD=61a~yDXGg~a;9iX1BOzm9%KBt>LfFUB?EuU3&fG-)hHcGPlCPdmx zO(j%}yxDSiIu#zI4d69>WUf8Ku4q^1lLA+JD<%2ITbV?gA9`?~j<>!xQ+u2s_mZuJ z%;Ou)%LWD`3-jIKlB;jsBy^z~LuAA~cm!APdn`>x4|OMO01-uphtJ_akW&Q+75hkG zz?rSWMKzH+OookdnMqcUpf+z&uiR{I61WMf8A;p2qJ!AO&s^20GVjmmRBG(YOuRVn z=s@AW8dPfy?yoyZCCPSI@kK#vxR|&Of$OgC*P7y2f?mWm?Tgh3|UgQ({1bn@WGMmLR$(jAC z7@JVa8>38*WwB}Suerj5=YcxQf=Ww?8Jn*U9+7{RD67N8kp&rQ!U%c1;iV`1SqyKn_um|UcX{;-*(Kuk$euO(hwHt0HcYpJEz(&uX* zv~7*dSCSAWiSqWnMK9u*uDzP2qq|{(_Fh9DPY$?39<8mLtuI$+U0SH>f63o5Q0B5g_rDldjR2;G1lUKjAEY$w~YK zLLXULl-`~-en9j^Xot~}B5zWED|1jN(BXFevirxW!^aujd;z%1>TaS_KXOj?J?#I$ z!9J(f<_%Sb+FR1Z*l2}{q6>(OR8-s$GJ`)yeLJH_o% zphjOa&`kOfKHxlC^_ZE7feeZLJ%NbS?AgzZoY0ZAc{59<)5DwDP>&hnulFAp1oigA z10~z8vg($0Pq%2co?`O|v^3Nv%NmA2gybT41c)=tP);S?RN#*F5clmU z#EmwOz2Qm5<8gRxkBKTR1#<`TRs445EH~U1p@kl3lk#`lw^Uxb&81m0#_vqV>^>Gg zjeHxSe;=HSV+nlnCVd8s)~blS`I;g(HgWl6kJ33nA#Oa z&p46VK3>7+d}%agb+Ey@D940UOB3YPCeNfyVHp$b9DzBdNiN84*Xz@6}l0qmeX zthCtFaL#RBPJ#vSBZ^EiclFkSFSV8WBQGyDA7_`r&^$II^MdC_%QTVDsN?@5X zgMh*&<-E<%r=dAil?l?pcOhm4)z?bjVi78>&v0t{viR=Cj2+oiC6Q}GCu7{IZdH18-oJ$+EFW6|UhrQ8a-p#mJ<*_C^qnd(HPurW1bEWz1 zji3eM9T-wC$RHNTw{$~UYiBiPw^dm`L{u1dFAD(aCC(br(FtF?6U(?ASRKD`oNwyQ ztTQlQGC(I8&R>4;X=tEg5{&5SGJbw>pyb;4S&COjlUOnz*vNhg;T9MB0HD^|q6?pD zB?*j+Z%$l%Ir&^Ly{{5tyfJ)|O0nLaDVKFDqdE3zP#tO%FhZfnAWmhjWx0V@9fpi5 z28U#Vo-v$-R&Qsm_*Ox??18{p!p7{qt7Th_KnG|Y(Ifs$?m!K1!0NmLb~NcDz23aN z?x%=gDIp^uuWR09w#DZ3T6`MfYEmXcD4N1xcN8j>VCOo~cRd(?$ZZG6*N`7dL(SO6 zXlA-6CZLpf?Ge`1D&E498xNylAvpmY^V5*mBOnf#>$=A8ehWe^$5=OQdsr6#mCs?q%wb^p))myl5KcSyt90Y6|}{n zKoyZbb{&b+ppy)#EY>rF)kUEY_bP$YK|Pg={fy6M81m4G)>_%-LrSi!PX|?AmvjUu zRYijrNYJ7l8$sC)j+=44qgxgCGD6_8Q?B+*6N&5t|13%x_tC5(t@puakGUlFA zPYz^(ZAQ}6GS4W{L{^x7_V$XtfP zvj823%_o7k$|DHeroaOU%9+V`5WB8oK;mj`3h`KCICAk(Eed6=sHmv&s$}N=r5a#+ zDcagXL>si6AxV*68nNkuu}mBXvAQ3 zZ>M_!%}>$tK9do8IZ$_QT*Rwt`^~_3Cpq7XQtq0I$gTBf7dC7ZhVL=F@7w&?(^=wX z_)e$Xz$`K4QKYr0bcCQD2EbmgRjv{Vi}5a9i;nlbIdg3;4~R&Cr5G+`1ri*CxRzC| z)G00PWTKm&cJt15_oSIXa3&uOAJeI78?9DLHXs>+XoZ=zbhY~HeFbR^?6DaRe(}Wz z$HQ#2oN2BqSC14>j00J!`meL{wCi$`mh<3lFL?Q38j7d*&2yG%Tdn-UT09{^%CE!~ zsyF9(aj6xhPqll%pEfKL`Qb+H&(6Sy%W6fhlM7zL4#gr~luCLkRj*Hw<3cvLvcn!X zQ>cmr@+`w_)<`}sY?|A(JeK&rHJy&T5y)Lk*Lh}u=nl9`VPX&H0ObVn4hFN~mj?CM zR#&{eoTfrLi!F6|ZQ=SOuR2C+ikcH+tO`=U7}-#MO=_Pyio=3wl5&;899*l^<)n)y zTar0&z~co|Aj7J>5;;^?m_r7}%(>|RiFcbA@0{487O^xpZ`bML&T51dw{~&31~B7T z@aASEbVx?z&6>Heh0JNPFPb6EE`dqW;hDa+Lnn7lFr`z1sqn>jw^UapVM85L`2r*Y#vAtGiyz{#7cB^8NV*;rl}qB6`AJ@?22qkpSkj)HM_6Wn$Feth$e zTb!HZgP!Rt*y^jk6Rz1$F+Nitfe=1y7uiz};pL7= z7C;TV_Cw5SGzCRO^f!mP+Q=f{F0TnL-4W*E=2AjdZYp92C@EGtO0)-RZ?SLpcYa@? z{Q)#S^f;?`eXyUDVgL><48nrBVX8CB#>9`>dA|87O6RO1ih%COyk*AFjn()brFT&o zIoDaTbaU+~8alRj=CAC=ZnWCp7z%VtA*XlWH?rX!_ z7v+`d65Imrdu{?2Y7<>ib;Pdkw$vyr1)S`S1TyJh$uq-Jh!5r~Ntc0qmyQni?WcV8+Vs5}T`~6flN?^K3XivKlPR^H9!!b4F zg)cHU@FHNvtObYD&x-}6*kMGgZxIk@85y21HQ!1ub)Hwu>x%L^vBeg`6pkD9^4{;O@U6nX={`WV$9>| zvG(&ANYHE2*`Y#R_go~GX3eYf9#THn)Y5>^+7)b3-=iMrN5taC8rr6-K@+&{@9JDYVzpM=#5*tg4W4SBK!u5oR#klW#(-}f)*&OMsxI&rF0k!@k1Udee zST%zrcqiG}lIBRCEg-3}b7Q_$$85~l7+v_$=~Hoy-9TyMOmVgh!DT9D@pGG{cPKcz z{f8ZuM{FV3u(_ocrM3o)OHHioDr4hUCI?yyB<19DxbMT|Yk^_hMswi+pe`H6G(CG8 zfaGy)14Qm7cBqYgH567VzcQX~J)ZTQ;)vjRxwi{|re_Ew>aD~jEKU2$p=%v=D#I{N z%Q0V<2BMHTqy0cW*|)bk($-o~w(e}L(mXdztZvo7J?);9C7^$6GmwKjR{Mb<)x5%5 z=iP0tj+EX9udc2``gOZ-0CtS*D#N3@rQZDQ3ma;ae(-tYB}67#AFsaBeXg)ouK0X+ z`n}axsTf%bzBYRUz%pvVdL>2 zR7}k%aK0GHkl@owKRGs4HJ!rTtb4JPNH@Q&D2Ox}-g9imcV}^O9EgTpS~Kk>gw@=N zlay(S;p3%C@V(w!Q3S+lcvD2UDP~+2XYzD|bFuExp2uWce8l@ znvFuAo$*PHY9OE2@h&mSn>^=b_#^MFP38|0?F#m)@ zt$I1@@AcQMB@Sflw(k@=uPUM>^sgnWA|$<=H~nrAGq*42K*ctrV-=(~Rt41uy25E` z`Av+nv~q3}7EETqs%)bNP};;N<3ug28;qw{yKrCz(-8$z@_Day<_IbPI3Kn!@vp(+ZPa$$q&Aw{^|7))xz1CRL_;3)0dv;*k0sZMoY! zShVwe`K01dh1c$&&FoCO%P%nVF?T?NA1_Z*`X0Rb-I(=5a7@Rr_}PEJovcuNsa9-9 zuU4QOxwW;Yt@OssHuR>ne{&2EW6w~GB0tyW$#*;nbD6QqvAFVrcX?W7vAYc$Jeunb@bn2Wr#gx(OFfh@b;N_FvK&#-|U-OQw8zMgNz#mCHT z98V3Y1@7`o>}puKsq>0dk1Uomocjpot70k#>|_}TyU6sSR6j~xBy&S#sq-& zumto4nF`!CE0|XL69<@d#8XqJRLxb&VJ0lqljePu9?+I`_n@vhTxrXAtdTt|X^NE| zFcOME#Ply$IymWtof5WS^JrV)t^fsBHGJPhE5IewuJIe%OvRQGOveL7Dbg$q42wWh z*^Ta8XiPEC9OfmdMM5hHv56KJDMRHCH7 z@2rsjz^wjYdTIzhJv||Y9=qN(`gq^w-Qzaa4np8`pA}yJ(H|Y#n8n* z&Jj0rqEddt;ud1lUc9f>lrkOoOrT+)nO!u_W%+3Z9OajLHM}LzMz@VagEoLH zkU}8US|5lB*_4hq$CT#50R^KF9dwH`GXNer?;>6d0q_%nYXQu$L}n=82Rwd9S709+ z0yuIpeJG)vlfEY7t0vj#j#b~!f_8b4Ky>~27bUvpr5DXI#2oFzE_S-a)*X}ClvAgy zYeNfE!(ULn@iE)xbNrxBr-ym}fEq5jHZ>?+0i@?=@0!@H>O(iu)6 zOwToBNh!iH+d*H2?tY~=l@XxQ3Z8hVaaeE>9!&++?&N3(-P}={GxR`5TLf0Fu6 zgAJy9>WqZ(4=bf1+rOHsh(LX|zN+Ml`+>Fm>nrF2+1R)XZ-HmR%6@ z3`LqPoh(dLaU~P*t2J`;E^mN`kUC`Oc|O2a;E%rQ^o~K2k3bHKL{R zYc5qpruilTbt9dCGQzpk8#CcPUg__x`hp_Nl9he<-U9gxQ>yP(AQyL=rwo9)tz7sK z3pPS&5V0I!`d_Xq^yIm}@P6kKO^$yxv5P7OfYRo|KoB zD#3(cXm$P@0K?VD)JYUan-FY(*U&sbm|(T|HKrh>t){osh;9~9WC&PAJf?oj8MXt} zU*Bm0sFjT$U{BT6QmQ;^p+|$&F`^U4{HL;d;-7I7|(3d7UVcG7kNkk9j zIfcXaj*U4al)wF1R-vP=3&tPEEk=zxew~i_{W=H(rq%r28}8?VrnDdRGp!s1D%dE`T=P_maWII7vTocEqjvvcQNF9qlc+2dFXVz; zT+BrlxCB=%^pmHef>s$P4BPOJk^$7b=BR2|V&_DcX1_Cma)^0U!~c9*Ad}IzYZAVs z-JAi$!0MdINKOUSLPH>e?gKzYf@~acK<-VG;Ym}7kbCD_KaMnIJiMdIcuYPcuMG2? zh(0(&yYbXlFtKClcQCgt{ia-`?*bJMDk8!Ho>?*k-%uq)^oN$+c& zHT3hfG?+LMWdPqFNpZs@3z9vr&4~VrGX9jLSkOYL*HSkp<37xInU8ve->0sOa_B&p z%X3HZD9D^u0ikrHi~Diyv3UScLeYfq1zW6>OnHlwdVUfUwnLoi4$j1TL*Pvn@#b0l z=4KqHdsCVN4LS8WwE!-quODubMDeTnxyP{L14+j(WFQyFyxxbz@(BU`znc9iiAV^K zisPR|BvPMdH?6ky!g^pJlwHOi@O`Ti*bN4P8(b>(_We;Y|~mwHv@e0UuBF7I}mh zporWUPv0d2s)0NL+!%WlGMN*{3RPki2Kem8TZz%n35Y4z8MY?s9YXKA*#Ongr+|LI zrxvQYzZGw83OcRae5aijqu;05WDm=%(EJv$`BYBJ6|fj18t|`QUcJ0r@Ft~|d#Q1( z7pLo#@pf*7H~$vR!=FwV`$PB@_I2s+T@<+6ku?2bHe3I%y5oS;Cm+W-oTd%6i8`Wj zsdM(g@y)Ck@Q*G%d=&ZPJPt?F1Yqa8GB;0IopVncPR;-+dXMmP?B9 zlg2id7S+6<)OC;0n9fKou3X{$K*+#6aOnf#HJ_s&xfeQ7{EdTzE{Q~$Z3%q1gbU(% z?^;ru!3w)<^-P6NXQN)dnz8)^#Lw0V`6SV|y>`1WcYz*K8kMqk<(q}s8LN+Sf!1l* z@Oa882@uh~Z*@Unit0g_5kyCCq;)8Sr2j0DZs z>yEL;u|GO&0;r%oZ{*ppD);Yg-zlxfl|W$E(9x+pGDX=<-bcx_Qk=gu)$Jg#D-z|U zSXfk2jQxf1x(7jxQaiPy9le5^&xi5rg+)#7ZC`5ExVAQBk#S>#jFj)=2nV%f0ANsH z8R7x!kH&o{8_eHV435R+*vckqzDm0NoClB~(-e(Iic5%Q^oH)3*%Qjt)tg9eXSL|_ zB%hP~x4EuhmF`B0K1gkxkgIe%67>G|u?Hu39QyyWTPrIAA=wN%vcq$Bh4k=P?Q7+rzJ2Lf#Xi;bD4sKaCf zo>lE2P@OZII?itJ?D1xY>Z>z2>G+{#a4X+&R*h}hp=Q3#O`G5FdQ|jAnaBG??oFJn zJf>B~ari&e^A^KP|Xe1+SPfuely$3tl2 zZ&h_pJODro8ZN?-w_S%Dvg8;_ifuYwUMAGVi-AYZ0=RC&o zS~32gkGg!Wy+qrvynOVlM)~c#q_@#iO1pL|G5`AL~4XVtD|yj_{n}N7K1UDq=djXsxDziH@T8^vIj+B#;>SV*Y3d@g7du&hP2%%IP*g9{KJ>|ar^2DxKlTR&MR=dW z(|L8!*Eq1X)Kce5?}k1C?61EskWl9njBXfTITHNyyx1ROyUcA?Q&TL~iZwEPaJZ2* zh}8pNkIB}MKM(!;WAnvL>2sC8L~X*he2Py?vIgbfuiPI2`?*@AMp6BE^G+Y5I?$u) zgla8gXp|@ZnZqZ{A9(!FuWFw7Ym@!!$K*y53D)+SSB>DBqWi?W-_L10bSub~g!7B6 z@HE$9F8Q$<)L-$}SQFjbeXe3&RR4$iIkbFg?f8gx9kG_;6zqqcg9HZlb3Hm>NIh+= z+rPY(fP9jLyRG?`I!XR9g2PO)$ZbZ|8h0ELTzCA(h)#tbd3}y>w8iA`>-Kq=wc=>i z^`(w(G4DNQTx2z?K@JzuFFFc|?DfHJPz1^+a$NQHx&n~$M6mgc3b3h)6XRMC9HQQT z{RRTkxX&m?O}fb7UAyoj?VALs(@+<=L#T7`eri^;T}g!n?IV;SzlVO?#2-_B8A2v< z3fNfGx6|-YONL`QGzfPv0nhU3!tBr`WM2)VTIz((0^ckjo-u6*uU9kMr=S z0Q{4z?~7~S_8mV&s?X&>ew&lu0}=6`Be+4v`N$~xCGUbfaA<-0-<$Jg_I#f%1O4~z z0Yn}K6PUHx_lyH94#Fb^(3zE&KkP%>PCD{oh!72xkML zW=G9xIsY$v`e!Iec8l2{FZ@`@FK6M8p#%BdQTVT@>Y-5j6Ja{|qV?>5vd|xcV+}hR zHP&uwMLz0~h2Za&x{(Rq468f3GxPO8}+UX_C6M<*G@mW>Cfrp_ z-#w`hY0%uIP0+b2DCEdtdyg4Ok%W4U9fsOYdiB@LNjGc>^`95 zB!1i50K2Xa7_cY#hW$i(1J-fMYNwS)?w{I<+7iFJS?6ll+Z*2WKajh>Uj2J{*9fzo zpXcVuyM8nK#XDBnLZ4X{b~)UdaX$CQDmCEDCo=!Q1FmO7wEs4ivqov+N%*th&rMaX z^@A|p9ZD5DOeRnZY5HfQ@aao?JWov;kS>4quwnR6E=A*Ap>xmW`sV&z#*91Gq&%@Y z=fx?y$oB61gQ0#BzXV@lH@)0aK1d(V7c^ZYcUNF{4eUOFd{Bz+`S|?M zfBVbW%c%>Azj^e>gU)H;iG6#DR0Vx*#chBme3To7^9SPY%EjM|4~`{(vp_RE|DS_xP^sOCw+@QnOirXh{a7YS@?KLd$*PAU z|7*sq1xMF0QJ+PTXAYkseNzRnWg7ZAul$S0{#t`WdgdoXJRrYw=hA6~2d4OE4mUsB zzlW4(m?CNtseEGT%5ch0COdZ(4+{&%038tB9y9Ih_YGVNL>5omr~iC6NSx#j$p@N6 zN9KK_?7xWyu;Z{&Lx&6B6z+V&gYxsw=i@F=H{(3IrM@)u&l?}A``4CTy4`s|cm1zt ze17K7bTlFILrQ{^i-rg_utq>tfF}iynWOuj?fCQApYx9Z`J8_XpAY^T)(}bd6ZSqZ z^f8y+0tm46; zIbY@iSX()fPxKe-uOA)!Fg;=;7-Yx};tx_~Ha-w^`0vH~Q!G-ISb3gMWy-?oUkm?_ zg#-MEEdcZiDZ;suIB9YO6ie?y(&yZd;=dOBa~5D!7I;rg{p$6-K{H&*vq5tQ{J%uz zZ^4v5f`?VQvliQQVmLzNChop~OXLd-C+?}e`gO3>pF{sS5a54aQs?giiOm;w@7-3y z<#`;O#~4ob2EEk(UC$lxl1~1F?8}gi=mxw4EU^Dooil=sUi};6k7c@R$pOVnG2 ze{U4OMwV(^yW`tQ+54VlZoC5l+5DUT_8<0ph$D3Jq>$+M|1s?Se;%Rjt+tG{9W2*Y zq~&EB@9g9fc~Wrulbtg7_hbJ01d8yNxMq3%VFvWD7m>nO@FRzvweIL_d(CN-(9i+R z`!~-laSbz3KY<7|(sAzvxj=k`vlwOshO^u@XlC!dAT1KWxTVPqP}v{$-lhEop*Ar4 z+VgB_c}6OcjHa`>VX-^h>2MCtzKndXu1#jtX--DzD%42@7y8xHisb4@k0{v|KSb1>BKPeF+*64%zX!Q`<|i2 zOTxEY4v}Pkvinpk-~k#G2y+BNjKaPOC_sPi-;YfIi;V~^kcv6eA@FMeB;kO(jX0&# z{9{0XXtAHoM-Str%>~A>yc#S9Vq!QLb^lF&eFEJ9>|wPipP=K*%sakJ*QQuNEB=${ zPa{a)i$C!sMI1z4(iZ!IBSX3J?|Zj?xfNT9hG8WCL7)^f!r7XFK8Oo%FRVy-Z@jEk zz{Vd8kM8+JP*xp6N?*8u4p?}_J^F}2pr1Jf5v0&r%D99Cg)U8J8JyeBZTr0*+Awb5sId#XJ$|C z-~RXOFCDEiO@Y7gX=-Lo#jW#az}&4?Ij5+cDEe#v-j|HO*ueY*F2L~eRXTw7ADkO2 z8!jcMp{w=!z83HD;;7y01@PCh;9eO8vR1ro>Y;RMm48{AtiYejJjmbtUlk+*NvZgbtihBOwqA6q$K2&N1Z&LX)tj0=b7`hG_WK>*l{DRpnI zYAfdY@rVP&cjk_Ws+{@u%LK+d`dyP%LB1!vO`uu*Z-u|erdABJ(KE?;Xp1N|?tDrU z>uePq`0)EEiO4T-|G<+0aCQx-VfZxTS|$ljhwMshy!9@0LEH1hXlQBN_5`UroCeJ% z8hh{IhzD2skL94|!^0o@#HDc9KZHi?jV-Y-{duj{Q5vD!=@Fi`&6m)TSc8D}ZdlXT>2w6@Md7=^N)fC}1 z5oL6;S1!9pKQB{uC+U$Qy#|lgJ+4l-M;cW@4O)@%oe47%HF9lBvu|{ptN_s%anfnNrv8V_Dndu>L+;85e`j_4W9;#L1Pvw$3>z?oX zi$-rn@psSyo~>_1>z_;PWp#|<#LvV0{I3U%#T~)(_HwW7GyWfh-QVVZ45*XlZHfB_ z!RfpRNOSIG$0hXJA|ME+b^`DJG4`EdO=aKLiUJx`7y&`4HhNKM(h)^ey7U^5E?o$n zAV^V=A_xdb5u`(c^j=k(5_;%}lt4n2PJp~8qfT*Ve((RwJdU~M-kh@cT5GR;Ze6~? zY2MiX0pt8~FMN4onAd(#$nxuw0Q=JXcwc`0$>N_6l5KXFF!S-AJz3%}8wDWkdsJVx zNMas$vi;Vo;`gvWPd7b`VFuG#jQ;D_s!ozn)azNXAAZp69RTa49}M*!t*clqbQI5<-Qzrhm)N<0%Pv|SoExck9{{+>YWwd(MSK> z2Y#=qvXiL`-oE(p45~{c-fizjpAG%>>;L+EzXqm1uaikWwsLt|LbPjv?SIVoTbeHu z3io@i{LA9ucmXik<*yG+{?C*8bE*9`l=aZ*6k90mQWRhMA5X!mDtGkjjc?#Z2ehJv z3;*2F8WrGCXq7w3evZ>$@9=MT{p;VcY9HOB8`+WdLNHz(CgyOn;K%j8X7V;xztk^f z5XZJt*mr!)eqQ)%^ouMYIdHQ*aI+s8Rv!MNIsffNRe_{Hoj)vf*;tajw5(hflg+>5 zyX5g1G7exFO;CPA$kF*4XZvXn2e_Io@m%bEB?A6C?Z$XA75`S#qzYbsSb1=4=v#LcG z|8rewY8tnN%m~8GV-jakkwz~(>F)}LcU)ynUO-6^eJ z_$g4iA10ZqIr>9+`EyXr>Ko8&^%1hu{li1`2WEL%$j*bHUfKUx)v1tU+pJ%NYz~gO z^CJy+Jyx4IZW)bimx5Q*e#o8ZuyLNzLyB;t!&HCI!#7E0d??$_LlXX5b~;4jafymh zZVHTr5vQc8e}M5b$F?mawgv8<5e#Gd<57WK@lM@)v;4nJ;NNCN<^zNWTA9{G=Br=# zwoCBOfBLtp!01mS8gl#dAVT1wV*)d;{+@hUMUHNteq_(cuJ;qke*afyiH`{$J;4S$ zZqkw?!wO!18REYO?H?bc?h&P3U(>Sx@thw?wUD~et717+e_d<;zi~pTWtThN{!~C~T8Z7=t zL`uC++T_ch(-&0e^y9&^i%1SUE)GptY+J+Qxk4Mle}HWnBOo}W&;`f;6pj799(}Yy z5%*z{Zta&F6Jnj?<}4R%%atMdi>X!|6?_o)2$>q$2x@92o+2?+L`UAC?V8~ zAMWwJ;{Uqup?LORjt%=cT0aKon`T3Bld@`$aD&}?fBVAJYovRq=sh0YczHj&KN#gPvwM#*BZ_zL{}c60 zSId0%CO0PhF~aPsC&~Zzm0&#3k2M+AvSrT=ThBgQ3;beZkt%jWqZ z{eP^NKd$zx0g+n@NhvM<6T!C~-7ZqTMw|J|_Rui*6S^JcttnNv09fO6N<#=OcUscH zbhJvE2^m$ktsf@YtJ8CRZqh%exy-h9V_=e_89ei4;E(5n@y1K}c*d4IwP00sJv=d~ zJYE@{+^P@(``xTd*>|vhzNh(oqvnn9=ucs z;H?ff3)lX6iOgA|jC6!K@B2JQc98Uc@~yrax8+~<=O+8*7Vk-$IGf@$m3%3e{EK-|G6Db8sHf~Wl|8oteRP_zAZ zDWyRz=VFq$8(+zOai>^^H~X1Di)$c}J<>8>jrvg5d_!C(_=xx*)a#(1*>`sJ^(Y;o zTq__0Gd-E!4St49#&N2U`_}8W^naVxR_nurHM4CI8PVFCf`2({(NE?vX7tdL8$afh z!|gC(VbT|)wZvd{#8$v-?X$>Y69vEE0lk1<=Kb5r-rw#LL%`xfF$(c= zvABDIM(5oFxxPXc-@U8nEDre@oYpC`)s=PDF3^j)(s^g*_(JX0xa$}UeOb`>$-OMln)|;mmbD#*7jL=3tilDi6qNuY) zN!#Y)NHx_C3~eZq@KMI~xMKXZ%Zp^hZd+lGZ)S8d^`Otb1Z~FZPHwkB^22L^oB0wFg2Xo9*5ikl&Wk#| zvie_4?&Xum1d**r*$G_#7A1eXv;X;d&3R+_pC9^>941-6e)5;7`|VEZIq74V?rsZh za3l%~DWHZOvAiAfWSgQCM?n*}t=oi;TVK@d=S1oYEY@brGIvQPk=09LxbfQRBoOai zN1a-9B??8owxVJ)E>e-%JM-U_e{No+X51Ea`S|CDec5VF?!wQ#H~O_*8xE5Xp0#{! zBF_YSiFg>|w?Nxu9Q*f8rJbsrBqqF3n&Ya4EY6o(JHzQ_ z27=foHW4`-AD3)R?ke52#N>$dky=}O*kUXEp?$4!1FMxjLH=9haum30RlTyIY~{w>w6_QQbOQlHA2Z_z=- zR{2tvYdIpzrSyghWs#^ehNkkAK(S0iFcxVCf!1{Ro61syB6tsJlg%5zcr!#o$o{gY z{+7r7<0ljO&}%SCqHe8LKOHqMgJpy+>efpbmgYBtJkm=bJO)t5TV$nzzA^1#tO!t)|$EKqmFUi;07B-T>ujGv< z3fX97OM)zcsWbtL|n=JGXn^~CcgBN`7G(s0>_C>^@ z@coRjgRaGaoq*1Pwb#kQ4?Z2k+jTq8p87gc`SF4EJneYY=n$)4TcvZp73cpFV}2h} z)nt;%iSjjvwi!;PA4_RE>#^^^7txE-5z=1YGoXwVQLI&wPxSdN*rjqDuA9<%d1~#K z4gK?f{a&Ab8Z3^{n6kEwp>kiY-C7uNs1MuBkdI&n!!UF{)o~`p_h3kBe_b6v8O)M) zyA~k1fp@0z;(II2i=)n{QX6FL0HSX9VfA#B!a{y=XAywHavh+}v#V?Zj<+&6(wnKI zKyL{K_?(~>l~A&Db~p!p>s7!R0&IPx#1;yh7rut_D@XQkcz{Vc-kZDIT7^U4mc-%G z?2509L(lMjORu^3ScaBn@If?h{)EuJ?bkKx#c$(r`qd{#XshFTrQ_|q`&{z1DX}6#(^{B=6^3quoVx$v5jmyZl8uszo*|K zxduYmdDO+BN4KoVe@xL!xl{{XEsLLu=D*?7?CXAV9dUX|38nd3C*ieFC#)@~E^w*K zoNr-)Z@&RBQh#&EGNlr|x3gx~d{U%DfxotIbKGmW-PUM$#HhmY4X;tnpzxsalM*y1 z&OeMn#Px8uc|q_-v5HKJAGSYFKblRc2_<{stt~}eDndO#EMLgM3b`vIDkTk z3AZ}cGQ!|cV*-M0`Nar{B8`n$%sr^jMhs`94kluW9l(Vmi_Zx|t1T=JS;?LcJ?zCy zVcaLYHW!8l%Ix&rPPXcNkkfcOHLrj;=q^Ff@z<>QbJ7s_eRo1NH{ss<8|WnuV$4pC z-FkGUOW+kh;9VO}W_A%&?uO3mjCcS#t8txTxO`Kug?GV*zWW=o3)@>uVe+ZDC34OGE!kL)bjxKVxko`n6aaJ9Ko_Evv@Hb0ep}WpfxT7(|Ct85-e5Td9orWKi=U{xh_u3yVgW z0-98Ej#T=HR^a_1)C0}NNFNX~N6)N46+0JeQ#XEDM6{PvC$i=(@4}PwIh@LVy3PTc zQX`R=(t0U4@(+sVm&+#8qo^sKw(x@nTSGL8A6oOZJ^1CT!Iun?bWq4fXMRdB38et! zOi$420h6A?{h`kf@Ze&Z%Hupbg$+yf1USRh2e*2zxCA2o*xNIt1yh_-2^#h%-_qHP*Tw4IJTiIvf zkwHIl=DG3c#lAGTP=myL%;|g%e~p8}ua>Hr3<%j|ZmDcNA*U?ML)Hz`+!hD0X$#@wK?K}0anQ2m zI((;IiPfa+0Hx%*s?AVQ#z;S>dgiOn4`7uR4PF@lNxypSQSoPC=Qm$6O7A-&_?g69 zR-Rbba|<4TP2hBXrcLX<)y`Cew7s^0lU&iz-7L)k7^}4@ms6VTj>72+^*BWJFkkh; z*G?AFEj;ek1gVdQA)3Fohx2l6a)!EPk@@DO;&5sDtl7;u%e%-VF>)90ne;Tv(B@O(Ji}QUM-FOUWF?EZq$q3M z^OTyanAfW%9NZ*f6JEx6j=z^`x6sl6ljAc@|J z+WZ%m@Z<8xW|E2hZ>BUCkYlQ^BAaG#eV-HyjZgJ|>+}^Zw}2;abDco%uB6S!89ciC z!tYU@HZmNkHPZt1-G|{u%8{R6ixzj)G5f4{4mhyCC1X$G!K6ROTz12UWxr!)u4Ha z3>)%@wOwHVMa=AQno{e%B-8xzanIxWdmup+_!18IUV9D|ne!=1p77OXCzGRP@*-Vd zF6mV++nG(Ykfeg6_R`m0x z`sSg}i6U-?XR4R$ysnGR6#6Uo&p#olrM-YKw(0NA)y#W|zRa6fXhG|vOnV1`+6L2; z)HN9*o3Cl)Ie&TNVP3&#Awk7@p*j2x*P7A&VVf-3YM-Yyj6o+Ev}_~y@^y>zfqWOP zgo!|W#`3W8WJt2?l|q?JvU^XFhNgJT#viwPeZ|Cocy zrUM$MAE~A)FN(QNpO>#fr(F)U9ewX4yc^TKSxV7(?~v<}vT@kV4xL8s%;@>_#$PtG z$E8BsnGjhoJg!Hs%&0jtFf&X1O@b!(ww+8eL5kIoCl(x-387ZKQr6@&C1;XBL=kp1 zs~-nStkuCrN@jBDy0Q8J5~dP=?L*U{#cdK6pY<0>kJ>pm_tsGQVdrv=44vUKn3MHq zasT4yPHS&)BOjdPpeV-wy254tKAjZ{<%_Kjj#Qx&i~TIQV*quy;tI-lzcL8&R0k%-cgepXLi)fR zH(YAdUT9t*;J#v9y^GCj?xzkS27O+=lBfRu)E1faYIDV(#9Zc#0Vs=z?YUYt8N{I2 zI0}SGgt&ZqeB&VxZP*3!#!kG)mFNUOF94aP*2C~&I zx-nhf95fBhZU5ZS2V)&o)kTp*wRMTFn(!{F&RhWDU~{FJQa?!yrg;9FVU^3>IrlV6 zR5Ics1cz6e{rzQMx90d=6dz58f>ywD zMCA6$LE0jg@qUX4+Q`eU$0A#KOl6~|R2Rj~t6!#AWowPpTy5_YeV$D1lLcAb5vzlE zMfjef>Oy{4nsCm)U0SkLdH>R+E4IJ*~+GOkVu`efLnS=Fpj?JR*i0zcr zglBTUvvy5%u;b3xpyK%ovt2mTQuU~JWKDRrSQcmQRHmb0&~GxS4J zzQ;W7C*4Z8%pI$nUWKC>lCbz)Pd3Xs1eNXVoRZD^$r1k{8v^eCfOK0VI%LDb=9m2m z(5euqLm?AdVrqYuB~C*)BqDxwo6BrMXq4G69F_8lIKvC}Y!AgH3hr6}(EuSlRjdb) zP4-tx>AfzRWX4#qcspH(%;^|}ZC?@jr+uvdOWIQjTz$?1`MP{&n&)G4Li}}TPk2uT z(&_C*jN%3g7z#Ew(9mi-d1?q5ja1!CtbWyF#lWfH8{dFBDM0#xPMe6A#TQF@LD;wG zPJ@hWU1olp*M;^ySWAJIaq8$cR+`9o3Klbx(JbX(F<)E;>kU)30@B*Z)fPtTz!I(+ zSAYlI)?B>%&OatGnDAPbWRUbMp$JtOC;?mJ+WA{XZwdfK%B2X8%b<*fgN&#&i8r}Z zgk=zV_;MdqR5BYpS*GEgqL{%~S4~G}UB^8v-AZ3^eF_(EdSmFhP_PP&vg z$GB+@0CVvZN~ysJwi+B`$`upYrV@bQrA#EoUc_)~v+;2Cx`f2y(e(#0h1tXTRB%$- zxRRF}VyeuTClkR%M*144={Db^me>kTof^$Nt<1Il6bxK{cVSL!Qv6+)halh!==cDH z&pB;*vPW9Skt}uLY2&oE`KXM0xt-$ek0iMtjxDjWqars+_4&PCAMT;jsuFar zqdvCbCX?9v<&4$+?z(v?&kwZPxAMTMTg4?NOUp*^*KJ>z7tL1|^HyVU`MlA%1Bs0Y zZpEFIPa3u@>AnpwEg6R)u%5X#&k^$`ZJAS1^LSWA$~i5Jno0LY5bKU=fXUHs`l;~R zUDxf#;A(V_xq@N#-b#0~UAw&cD9S3wWtRVyL%I>;j1g}_Y2p6X@I3GkXX&*7&+Y0wKQwr{8v@u8nB&>9&7#lQxkPABUDQYCH7bGUk5mw z^18~@7g|`VbqiezB`3%TM%jq_gSTKuN1kQw>_Wzy_wNm4S|!nJTryVg?EJHrX$#TH9+0pY-3NUa3XbPqlhAaV$nhX zwep=~yqd<=V5jB-vc=cc6C6DOxHn&own?3dT17d($>Tzj+h9Ri=(~-<_JOzm@DVZ^ z$P4X;LXs)kT*4;-R0XR`eU$9|W>$~DXZRx9p=%->ap1Jt+l?6bf%tOGR9QyXUVV`KI7-RYC_pY74u4U2=iSB=Q zUli=f^e>+MSq;W4BzZfKW-Y4f5dRmnf=K6v;8vR|Sy+}*q6Mhiq)P&&FOUbIg(xbq z{Ka)_zd{}xP2d){h9`hNWq_6R_vf)X_$qT9GQ>O`Ms`wqAHS*5OtrIylAa)aiq27Q z;XhZKcjO3^sBZ;yLiSv0CxARLna#(xr)m7NA_}>$V1>BOmr;R<*bY~qXC?Z z$7{;EvG|IzY=n}?Sf2|bn`6*@P$ArV=>+CjU5MHd?LldDGbi22Vp4g#_s`ssbb^2CubKSh{66Col->|x79Ft zI`^eoz;W&_o8+eEZ<@@N)yHKU!EQN%$Ypbd>!Eszl+S35S;~x6qafKe*kYg1Di8@= zP)Fg#Rlc@$JF{@XNkbzOHFA-IN~PWoK>h177W1-iQbS!%z@!33zOCI`I{+1?5wNsg z7v?Vc?Y_1Lf;6Hys<1bhl;X@KoJA(!k+qfuU!+)^a+1C0QhQ=hBZWSoS*I-8{>`xG!eHjt;j4*!p<% z&Es5m{oGl?F&cf>yNCh1ZB-FQSj0*J1ikeSJ zed^rCpVg|?TGYt9^8IulzxxzsSj*b`B9Cc*mZSa!d;DIE`Tie2i8CungDWD89^R)5 zao?ec#`(kk(|KKuX4?OWUve{Lk}jRUUHY((3!RiC=~gx-vLaO;mVzM|AV$(20m;Ge zFo9fS(u@A#M8#cG^;gzaayJHm{I7BhvN%>2s#DY?mU2!)n=4nIV7ai};eurU>~yE&7Sm0aW==?xU^y@feLlYFr2;{r{V zyFd9KCVk4qD8zG7K3wq_hjv9(6PjHqVVb1AS;nOxE7A(OPVP`oIH=~d^|IJf0XpFNqQo`wA!_@UbGi!>lbt|nuvsg}Ig9`M?5+I52&Pw-0P0aaV#s^q4R!uF%p%Y%as~XGL>>GaVSt)SOlBcq9w2$+ZnEfWe2=AAyWFgjGShbH-k^3(j2N^9?7ygu_%ec`jngyOOOdccwtZ$oGaI4I$q&p}A;eLq6g z=5895C2V#wR4n}%#(|i3wbl`9cT%>1;CQQ-Mg~c>xl&cYY8%*^$6;*Xw=r4Ew(#;j zZ^@=KQ7vz$+EJWPa9LFEE?rNsLFkV7zZZd(owr;x)xF1tOaj76A%)5oiWFD`oO)P2 zNJH|R>7nZqr(H8&JX^bwZQkksG!*C`M`F&nBG>9qI zO3pB=DJ@)%*?Z*%+Q~l!(P^|E6)I-5im6DB5p!Lz8nG=7gO+?kbNR4&V~*!)Fcr)h zO)i%f=#@S?ztzIz_IhO2B$(-po&FK*r^Yz<$UP$8w5;4(*`2YRD`xgpJ}$njR#E5e zlXY5e8I%N_`6cVQbf8=1Q8deE5z$I5m!r3Ejx76*dqWc5uIn8%8wZ~#P=nvhJE+A_ z`qZ0m>0EN-Y(1LiW$AKb__gQZDvM#nimpV+~|5DSGK^zb8)qmYN0ht~?=r;PN zeIxY(@a-UvnV0$f?n#jmhG=wryxslJewaIKh45yJ&BeXS?tEZO*6H0nnYOBU@;SXg zQ!@O(T6=5{I%}D$hrMygkBjFE;ps?yi(Bd2HxY{?<>lS!^3{&&q;K(QKm>eER5AEY z_yE~|r5IAnZ&=kw>JcnJ(~eM83v^UTmKaKg??%`p#dZsq4?YNWbP^c_94o|w4s|}JCz6T8pxq|xv}bDr0RgXS|OHb5t5zS zzh-ji-NA|xEa3b%$e}UotfYm4^6_tZgI#ZK8dBK^z9Au_Im1Z#c`H$KrI=D{vv&^< z!U2|itlHnN<@s4^rCujh_hMOl+Q@wXqN=)l>K`{*fDpALPf?51dDjH6{HbA%u1+Dt$q*G^L)>HR4O;Fl`Tz-kbO-TlID&AnRlhWfb}wSx(SYk2nIG&) zFVDA$@#}!{8vfJ%yLA=(eW*X!wNW@z!h}@uYlrx0w>r@? z=7%2!uqQ8$^EtG!G3Q<;;z->AkuvbBqtEmUxhyMC`V^#21Jw) z|LpWY|HClIZ?OOmR>(XY6QL19bXoa^+f*Job)D5<^kkm%vz1}50bR?=uEiA!HN6#} z+$!m~eSQDz_}1$LA)6{ztfLop;Z}>7W?O)dJAb#(8mO!B18G~;A>a!-2i8X+Ct4UU6 zVhx_r!TQpb>0Bb7`yRl;@fZdeJl$DZg087*{lsPcB*KAS`r4%vQT!dH_gZeFuR{Bn zVT{xcYfIJ3_MXDont8GFYo}u`D;%8odJ?Iv$M!l)>?`i!S;-?G6_UiBnbcw5K9~v< z;d5c;?1Q2Nu=MgRdgv^d5T1?aG_K!XD7DDpBcI`6TokY(>N7Atji^U0H{EEkRBtZf zig|#m0NDWEWw`KUo~1(jTA+d#6wB6fb8Wu@r^7!Ka{2W@u9I^je#Be9wCrL^<_1Fo zoJH~X(p;;O--_Fz9C$=>qSx)bU(iRpegi?V4HZ4ZtF-?Qz2onpVLC_#>+lhC8*lx` zAzP77hY6&p=h}*azy8~o_ft*AcFPp1c?&eBU`qc5Bgrm2EpnKoIeZksY)%plEG+7T)AFX z)QJQ&4|b`cuskLy9&PHrt1|8gIPd{=d#>`Yxh{>0Cd56I!JiAd$$e=e&QUxeQ}tmW z_KYUYEnFxe>KzNZdYEM?B9qgsI{z~BIx}TEU+w|G0-uSJKOH>_RN3zXQ3K{I*#c#Q zP?YObe8s5%T1%{V>vSoxXsL4S>eeEtnG{gGa2Tz4N1-PL=*~V*q6q)Snjw9AzAGCy zWYZVCQDFg8GVi{sU8=&}o&uJJ&>*46d7Q57jNJ3N;q7H#!iZrZ(!GrfU3*uE4nGs z>$JMah1(xSQ~Odz#Lr%?R&GI?5RZMzzDE*<6Dsr(o;TJNfC;Yv?SX47se>`e)l#L zw<--kXcb<)&GS76|6&gPo|iKy1aqsy?KG*FOLjc%@~!uS&e}zYY2vZf(l;Fh^h9Hb ze!>i@Y@^z$B*&)Xn;^335f1k4Kx$OVmYb{?tAaWEeOrzE3@oOT(GOgQCY)d zIE^Ovq8BTaz2|`ZdF_z!P!)U_6ub6PDU>!ZLau35gJRbrqwn4-JeHFOOqy@lNA3q- z@XPfP!s#+2W^#>KVwIuk-E;Dk2o`B6JFhM@xffgz;$$Y6bcca-Rr{6Z$*{!Z@ zI+~C+ZDRYm&qqq1P(5`MT56Q*a{|{_m$P;RI$Z9sOkQB!o7r7Rc0XxiQr4t(Yq~AE z8A!0=@XmGz;A2tLK?q)i#GUMz>rQX(eh_R90|MA}3R3dm$tpcvOhD5c+Ap=njs5|< z*PNK{WEUD=qQ=~0L-MYo9y=@pner7?5hHW3sPpXh2S_bWZ>pmykYe9>z0R!dw7UE; zOp;qN*xPp9E++$+EUZ|X8zw+XJuR%&^xGm~@SW+{#sM(LDeHL6eu}sE?NfUymV8WM zQ1_YtoQmW|pQCG&BM4(r+87Gss_`orgc)$NHN8(6zAJ`sG->k z;*R52blN-U#%{`ooiit*?Zm!P8*S;1S{$=(Y!ujdpdLXhB#$srB_ul3S&NLhs0$K^ z(lrpP`Ocwb*gDDksq2H5LDWfj-F;fR1v^ln|8=+7843KJ9}vhZn=89z^O>Nd<>iJR zZE{PlVAAF-2BDf8mE2^=0n2ySI#(J^t5wYa0cK-TJ^u3w-e%( zLil+Wz^_|0@+Qe-ef+d)vGva$)W2L2sY&u|LiKZQW;gv0Sj+($JhciW@=%pktF4tc zZ$J4ZImqONh_EaZ~=*ags?wB7;5l4vN&vmgz=BX)op*J5C@~2Zr*%&V&`#68K}HU&yCmP zm&Da8e7r^LBM~Mh$E69AtB|ut@H$H#_irx6_f0m6e1Ic1-@DO*5j<4sX#QW=SqMn;$@CZE|iISapk?f zvRn*s93?IVSAE2vCYz1}n{cQtL1tl1?cdF?<^sr3|y~u5Je0_-1mwzAOf?>!G^1S$I-@@^;-r ziSeuIxf{QCk-sc&5HL-4N?bubpQ)|Q)sb#Iq`8`1r1EF3`;R}#fbJHzttgLog1>6+ z$5_9RUL%Cvf(rc6hAG2$JTfmuK|ZwXmqsBo8JVP9&=m^RA)62wl)`@%5aE>KH{hFJ z8P2^|lZ?za-oKB;;aZ=#q0btL?lE6{6J2|{58AVFIu^ak^*}!`cB7^IDQxe;fuuPT z+;7`U)vX;SP^#!Xat^BR)^5kHn^kd$Pg}cd_L3%zt*{O&y<(Z|bzT$6&v%OK!xEmI z$4>6%1ks*Ds@^AUEw}2vpl1tQ&?2=o%3h$@t4sgXd)xyP?=K=GeWJ_5kRi?KTZbSc3=k{v*~HdX?IMG-JxTu8_)Tq5GkbCl%7N}QwMd|{Kl zW)^?>=%Y}IjwK+{Q9RQ!b^zXS!@-=UI~%}?_JZ@uFnIzy1)J82gG6hcVYO?fqXtWR zZsgwn*2IKERns`_C*STEC@pijgo<)c$ zy-))5t(^%P#a0B%Vay~(ggJa!RbVw&@hTpo%%b-rP=I2P`&c4v_xU z8}TQAsAuGnAzmI@p@nK;#+tbqv$_3;_Y4=GmmNUx00mM*%x}OyMzj+0=;`C%+T(?>ZFjxm#B%Qph(1N8S8ORl#Y=08 zQBosD9y7G%)`sFR-sf>gS_H4y;UVm9BYPCPcb~~&&|+1%e|`X=7N|Xhc_K( zA5<1!oR*@jBFi>CA}PLYc`ONEy-*Zt6WqzflFBCl`}hbj@5?37H?y7=>f|Sf>7YP; zfS}tVSU{+}mr@NntA+q7m(52>&XaR1kaI)dl9SVRl*PVb>Z*EXr?3lv8T6eh>?f=|$XaXS!iNB752aem3V>nAe2QtDCjm3bg~yY}2=uS5lR z0z;)7sw6=K*v(pnCD30@Hildb`VJIFk(w$+MD%@G(H>Bgrt8E8av5{Td-0l_Pi1CP zWjp)!!pyY8;LOb!-Y>pV*?&1z;)jdbZKMC~j;V9wPdwDG2>FUk?Tq{Bj}Anyol6`imrAea47rdOjv!V!i9;z?+-obQZe< z`VN(HG|GDszI)9?KxP$l#jK|5QzZGQxtkM@-XAmx6Mo%QL; zfvQfvZ}ayRst30CfpQ0E`5Z9x*#mo3475OY+-n0nGn{F>UC7Y@Ei+Mb&u`k(lsecp zrMrYQAuZp1?8`(Qh~;*vsYs(5BRhHSt$||W@@fxoOB4Ayb!QHMw7hzXEv!6{*V;Py z`~cfjQGC;4pp82CKnJ}wxeY&COUZb>uH4y; zL)wm8%Xa=4tg(edqmoh}vy`E-sr`55DjX)grSqqX9`NQchVW!bo} zg?3gGyBJ)QchYe1Tnv5v^YIj)OFuh)rN3YkGPO+nFbQPpa1f^gu5NBJI@0><((QOKm}DBU^| zLm%Uq4bb$!@$NAB8dl}euxMYy-Ag9bOU|tfp3QI zz2;;!sBf2YvVx8xe2SM3$cCAZM?ou5b!16gTT-4K-a}D4mES` zmZPiezVdwkm1+3hbHWHmca}>?)45Rp)MiKHz}vX(WY~aiStA)k#RnI-H|Xajwko=P z0!2YPx~Kx@n;o2v;yl1BA6RPL`%wwolc7MfJ6!KekTvN6Irv(jFgJ%@3lxv^Q5OXf zROus-5h@taz9r8o5#ekICRJ5r=RP3OB2pcss9S|P0cyMP*f1dUTxPcV~9pCfV z{>PLHsQ0i`iB57KDFK+B+FT*!Q|;@nE#XYmJWVfv^>~PAe_)%h5uZza(llgW0h*4E zGg)OjKa*$iDd`eTUsFf7yQ|TcO$14Al`85YEms%cir3i=6q5aOG{OdhRPAU*exV$4kR13NN@MWK5xU`5J(vjpBNz*aS6XEJBJzI zS;lN!YQ2CtVC*IEViYOf_!eK|y6ejI-T;$>=r-ww-D6^Z%Z1&(2!fNC;W338rpcP3 z6M0id0<%(_KAyGKPM-n>e_YiJY(j&bxr#3Ea=(uP+IdWV%4Atjgc{9dIMG)bC^p}{ zrdK7$bUS-)bbZTMlHn6*QP3;1ou8d|PP3Hu+1x8mPgGlBi5~?@%2LfhNoSIHHRjyO zyeBL^2QY&6@nu8M1K`Y*&}s&Vs#A+>67Bqs;$nn!R?d-gqB%t0!jd83_{|xiWV@Ej zqnMs?WGS>Q1d&shO`WrC*@9BofHx$Q4<_{QrCp4n?ES5k?Xb_)8r2nVs&SUiHRuI5 z+|dpz-_zLRvw=A7PY3(f%OkJQ9|tR*P4!r)y6-KCFWb|-_Vm+4s*QbAJhvvF*o*KZ z{Y4)5_tL(MZ=T#g8f}2k$dO2F$XqkE`)5t=r`_Du$atUnLP3UDVnu{{vedx0u`N`&`Bux55;Q?X_g>GkTf?+6_4ccT>e zobYdC9t6H+cUE<;k=-P=qKji*I~z10W#P2p2cS&c(w|GE!Q#m{@|;2QIwnTj$ePZ` z{S5;F7+nk+h#kuhw?>^Kh*sxLtqraUoyjrC8oJv5(*|%9BO#<~K_i|Z0w`^8wQ7=y z-G<24t$t*EDvuFak|me3!J&Z)0m3 zf^Xr?`#ysHfzd#W%}7rBZ3a6tF%j@eWApM>AbmzZ><*a!<^j}o+b<1%O9uBjiaNDP zjqzn1$DoHt;`zkNjoc>#t;BQd0s7@*)o;YEra9|kVD2@ypVOOo(rT9=LOQpKjL6$@ z-mSnk(6~%&^#tVSLm&g4d3vNRX;fbN5hje896s+9yFD;8Tb21oHK(Dn5l(u-M#`cy zG;U!%mHBIeJm!_jfg3N)w!b`~RIaeVdiQLwC8gOUhad>MHM@@1{gcff&EMF>5cUE# zu;&Vm8%Y4Vvk4+8)QsC}PD@<}{d6A&*Ttwe+J1%7+S!`nkoUZEAh2n4X0-gbQHH6ZaShw65Wf2d|z{PN}P9PJBZULz| zqZvEUJuMIQ`8YxAiBSHH4Kzja*RDs^f(g8f(w3CwT-XD((r_^qHPgOl5r~{B2M+qN za#7ptCX|ZQjv0FOE?2#xVIEB(!gWp{$17Dzd)4M8LRwEb#bmJ_QO;GxY?UlM3ko7` zo9R2Ax9LS6y~SzBYr(L6PMD|utIgq8`<~qz6b*@8lS6AsIt0okk<4ovbZ@+;W1^5G zpNrQxyuBI)Bn|2<-1dRB0V+jBdf!EmTNiV=TagT$et{u%sYesm3E#N5eWBLrM)c+? z;hsGf@aR3a!>e}O5_LFe_3&@xEGE4Ad)+%vV|8m5B0AyQNe>O4|9(o3U)s^8iS+Go zwGh2zkN?j3faz$0qtg^~zp9z1e2)*UCl3?0&P}e}u`hY8^#|SvB+aFj7^R|C9}N6) zxQ&LcA8kFNFhiQR^lmi!P<&j{*~Kh)_v$2$<@VH$@JcgEdN3e|dek1BZPZ-F(j_@@ zY!LeQSmGx^O)mwn*f}(QaZ{`bi!Mm9Bv}CO1=R?UDaH2ZcYh4KSCRt^x5LCjxQ9@vkA zVu8USENI|c$jw7M+TfPaG|7wS0ww;p#M5jLV)1i2-0$lI+| zSUJOb8c)QUSdvY6Sq|!66H|8LHn{sT}|Hs%>2SoWieML|N z0kKfprV$C1JVoi0k~&aC8dSPH3k3-UL6H)qd;dF* z=l0p1otd55&wLQ3;rOC3Z>!v$$s6>li`fDmTSr2WZw9WB-h%7&&w^lO#wGMLXQ)xY zYywEH`X8dHL6Lk1SydnscEU_h%m+`ViDN{Wx zW_gmoNr2(d45^cVvFcd30*orsSaeJqvDW2X7I{oC(P0j zN14>l0_2HSa2Sh!xzbOAa5%m+JH~FZG~BbWUe7a7?xq{1k{(23IV^A0tVNnks%|Zo znfO!?#dL*3~@OHf}o6xCT0B>m&FnKg={78e8x*{Y5(l zk66iKALW;!aghhYhj2Jz1B~Sf;JjI7W!K5@edKis+iv0m5FL+vsK`CYGVNWR2*d7I(*=cKNhWD67h+pQb#M>RrxQSj0Xhf3*u9p%bSdN2YWl6F_l^eGcLb5^7LY7U^%i%Fa z#FmQK&@zgeP$l8ySKaEvr56yj_&-+`n1`jEf1tosp*C<6mX-DpxUp`&jt>Kl4W1VW z=2v|1V{9>d#(;30Gw_;*Ad`UaoizbMd}+|7mQ$lt$gCL8Jdgv1ATiz>UOE0Hj^6Tx z+sgqcH3+f;907`Kh3I#PTw{Pe^5hGm`2h99ClesW5z!qHpVk_q(dtF~J{Mg2jHtqM z_{d|T_VKBuqd;_LHUFFxWLqA6U_Dy@6TLVUe7WMzPz@AThbrOFr<6-b(U?)*hc(v^ zoX4L*lRToCJ~EQSd=siLO$T-91w8E!g()m@z(w4_ag_Y^x}z7Q5>m1`F+ z88=^2Pt-;jrD9Dby7q-^G401AfN>Pw)o)(zZef$-Y@-re!HpIYfFJW-AxCjD0sY6A zr{yY_24FJO43w{7w8ZtGAR4+c48OMoY&T<|KiC*DZ@HaNj#ZAoXKC0NX8`U{Jz{?$ z&J44Gu1@x=T%Y%s6=$mf_+`&e&9;JT!BRbuL{p6zaPcEcrSK(5Jh+c)x@?CU{aCC7 zzS9FYO1~2OOo4jU7tc$LvJ9KRO@~Q1gjZFfdAGLGpWA#Qfd~=bTAk$kS=dEHi5lqN z0m?H-k`9Nt$_40#3kVrgnW%hjw`0ubQ~hNdX)eZ2QRuDmQYMj)`xW7f&9;Bp*X zVzUnXur#K{i%OJgINa?JMOpGSd*KGn?e@pztO<4k&Q(DIZJK=Z)S-GlF?Y)B@~~h^ z($%h`r3@^N==p=9r=UpUZ6c_1Ckd56#Y;iHdQjmP{P>zsn1Odh<3yq<8-K22+k(^O zt(3=RU|?%Ee{av9pX7@0=N-=-&y`g!B4x?i*lAc0k=Glq$OAzgxBw>N!=4sfj>96m zx+JA+2*^c*hqDrqKrvVYmFg^1TKi*3D(L)>ngqvx^f)P~BK>srQE0M4_ha2-FK(ur z$zIZKhtqH#qT*rkZwF~5{cqX(j|H{mIXjQFXP6gUfIFfZe1OIH6#c9cr1*sb6npO` z>^pxU_A#>$%0Hxv7;^C7N~A6{OkaWYz5Y3CO3+~q(v^NHE1I<{P*s2laOopiHFWXE_jTS@Ep71{t}tC>1X*NN4c zz@%P3y@MN84-tT^yd3#oZGNE3*bo0kwcmsH5$Xza)V2OU>MsZgS>RtQ3>%^hQiCJ^qH0o2j)BL^w(yBn|y+{sU>CUA-og--Km~ivHuW__Sq7T6gp@0*XDswT&DL* zEU#5%Qr8x>rZA}wJ#HBHJh#lPj1`}iByu9d%=NxdMq1GrsUbf%Fbvm3F&!)Li7k-7 zPX}(rvEUgs`m70jVVTg`fewNgKQf&sTUYIk)sqln%~F#v+SEdje+s8}@I_Up7a@y| zY_cQF4iBsPZ0OB=_8kc738KMcl_y_5whLILZ1hY+Tt2wOe#JNXi1LoeIyiob$nT`u zr8-V`45dA6xtE@E0Y!PIBu!nhG_7T`^`asxqJg0LI8=jZH41L4B!WA4xB3PI62Sq` z3L)hHm2COR5SVGUX7A8AW~Y}V-~sWiDBaQaLEu;N2z-(QQv(Qb2kHb9SN3=4P08AK z&2vgW3fFP*SV%W@PR<*j^XolPnYk>)ifxFf#9k(PX{eU>dE#5ogTcIEUrUmR>+4It zMf7l$ZJRFMq{MEr3(Fu{dT5J%8K&_udb5KZlNh*0O!V}@X7@m^4m|uZy~UZ&mfJ>x zH7^(z9{x5mF_!y7|navUP>u5IQqp5sB_DxkoPM=jQkwD)c)~-oX27Q;mUYSHOim675Fo4Z*;Hud6OM1Cec#^(sUoVOX z-i2^3pvrRK<|r9+s-+t1c92dzD50~><~FWLY#vbEATq-EFvC=pSkhfOgMHf*^RPjD z05OK)|9DNo@Hf6)6#w2ehu9O-Z`5r|5Ue;9JWqi1dDemUr|5x&%|=l%FCm2JzN<+T zxVd{>M{p|-GUOggdl~baJvKfu&>;)~!sT-5?ez#eR2!Fg|L42s#U_~}5eNIgy`4Xo zdHnRH32y_Qd-H@VT|R3qi{^QrA)f4m{; z)~xiE&U$R)gjpl1Ccz+H0ZzQ^ajmJ@f422`Z2I*;y9EF-a8Ny3D^@1M)VpkbR3;Ja zO8?m$7sU#A;(s*80Vjbm9)M17!QA<~Db%dW6Q7S5frW7ht{Ni!bG(KEa;P?IbHVGM zfFS{ajI$3_dQgVfgaTX?*Wz+sxy~L@T@$)7TxJ&b03R|Kp(-ACQ1HNOIeH%?smi5_ z>NjO{;}u^H51Z@IQO&h}IA-oG3wO>zO>{ewrMXpQmd2>W2046o4H{ibiwfX;qRtI( z%GJ8sr5q0*SV$Dl*)I|qdfYLwg%(WK>)KvR*BiS9ShwartN8>pb6Vtlg6sGjGK=qU zk%!+?v6Dl4qaF78Fi-nA^~9w&dAAUcsQYi{Fdom+s4=aBr?vF~>xUtyr<&>4bz)U> zMACTttk%oafPyTM>i@a@y^`*I8 zj_5pArYV_gRE_awE5^0o2eCX|hD~w0(qr5y0n2#m*IdRa?9Y7tQzUTn1bd|t(YVPxRXPh4wb%5dZ#=K!#Kxso@biw{S4ebAU-$=7Dzlr!->Og2%ORehil zdm^RlIJTH6-GeIr|1oNm1>%xeP?a_F>cbjbQBD)XcU`Vwr?fUEEZ`1`UUM61g4Jnw7=~g zNAT{${>K~F{{_2<|2)(Jukyl%_p7_b5mA`v3U@u9q-upnZMg;-vediu!jurWhr( z_xdc#TB`ol`akA9VCGZf5vLamB2JTik~q3W&Hw#j^j648*W2ut08sr;s^}j=Uadj>mUMUL~X)h_x_mM zpL`hNhwpE1AY>)H+uRWQkLgQDtHyuwCR3oAo{qi@_8l#iFJ0;=Vpj5x0iVEV4lY%F z@gN1Z)7CsUK9De9`f}GOq4!_ETwi5Vhv*I2Y zrtr9RGMW%_T^W1xg@iKs;Li8q-hADm}Jd2JZb5Ur30alPSpo&n-TA6ue``(Ni7HYKW(k0J`nJj zy7h5|(;I{c=_2kAMrP2CEV`)eTVHbilf#G43DJ`#i>$LDq7kp4w@li`$Ns~aKi7dk zA6?3eq?JKd1$!$Qk59`R43VTo{EtyeVpA{ zK~Coet{~m1ICv+n>L&MJNJ}Vd4FZdBWcSKTTXdBE4?(WU!Yi7}cGRoQT38U-!EW<( zi0iA7jA}P_e7{KsbYy-qvXbRRsVMOfi+?aMV%k!Rc`QxL)pwU|*dh^s-K^@vnTX{U z;-TG?(I0eLhv;V^7jihLDZroa+t-Hej0meHV$n$Y))(26oxlhw7=!20-L*{4*%}|^b3a!!(&jpgw4YS@-ID~%sSl*?wwix1!C&k8;{`tWvzM0nHF8t(3Afl8 zv7ej)UOw2APIB9_;oJsWQ{JJ^n`D5yaXmCw48#ea7yeJagZ~&D7D#-E?sA+Gi3tE`>Cd zNDbfCWGWxKeV6CLpNe3UJ}{vZrDldxBZs>!i=`!=9Ux&7_b@r=ODA|=-=Ot36*nIT z#M&nuk}l{?ebQfT>j>Os;E)-Dmt0OqQ*gu?egiW%oUC||c6!INXqa|s_xBNLs$32M zfwAxui{+#g^zgxgXXx&$&FBAk?eA^2yRAJx{no(KJza*q!j_waivOH6HTQ)9guFn8 zmM)|UVAX`jv4LKj*`r<001_FeJ#Np9nttpI~4hKkE~Yx#ngmeUZ64rVMi=1g3>&U&x7#e*n?{rV@$te6qSt zCBw{!ZP?q}dKW}@5Dj`E>?}CUqBA3N-g<^+f}e#{<3A6bKb1rC=MW*M31&m2r!G>> zPDgWXvIXn~bS!eTtxFiRB2j|b>P8K>d4}YIja^5;j~zbO<<@NV?*;Czdk}~V-4C)6 zRzFhx_8<;Vw+`{|{v0$FAL4#Q!hpH--`Y7&0_KH@dCqcigTwYehbb6R9-gQd+bP|D zZ#47`FPqm|ZqzkyYlptQ-(GxLJJ5Z-={_EQ>+$K#W;V|kJN9C03?S1r%ceqt-~2aD z*ijgLUgLDz8{VrYV{oiK9|a=2v;#_4*GUcGtw{8(CHeM`tsUQ5<9~dBT_l+ORPB9G&r%zQ=0>asNe&)hA263eJP0I!}>Bu>Z$s~qIXwH+g=4Rc@ATZ zkAG6fvb&O5OdAClol+eJj!HVAR2(z>^$Yw=QK0-^SG~(DQDoe)xB2;y_Mi61II%7i zh%|!!(sUD@4W+b6tbY^z?{`7pgirvPs=T-KfPJzvVV6g^zC5XJpqx$I6u24>z%*75 zS#TER{s@@)yC|Vcp4rNOO6o2WzClxc;gAv3vXff{nGomWmXdO(WB`2_vkJh($A( zC5KB-S#nXY(jfI$U)}U+VXAZvLz&&?i`aiuUxN8ve;;RS5H zdbIBOTOGT{)@8e$ry-;Svnw=VI!O-Mq_NQwzo%<>#KJ!w;;bb^Do|P{Hz~$95HKeoc*4vrS(Gcb3kQFR=I5QYkAi) z5}4y2|DN+EQE}fOXaO{oIN3gC$khMyso`|IXK0ght~Wcr-{i~rqX@sj3MS&Wo)2=k ztd;%LntWWvXEmA1ivJvC^J(B4k>COUM3d;PXYUDi^>=Xft4JydW&G_g{r&jnB8RCE z_lB|@753TxX^gJVi7OXyZn4lywT)*pr9(0Xcy8|7F`}F^$VRZ5C`AMC>@GA*jCM1jCdAe14)a=gPMlgVOko2^cCL4Util?-%*ZgdBETN{QX`lgzpk}>fw{{Bsqlik35+;E6|`f&RNQSN@)(cTDEL?QOhQ`ImZt=nO2`{y=k{$U$0UO>3X>*oX6SqF(MRScVvTh+Vn(+ zC||iZKG^s(Bok)V-haojdT`Slyy8liv55t+DU3O6^y#5ZKPMpJ`FqLh-)L(S^91)3z2oEV;KS$uZijYsXkHXL7)#S{`4lc4xVNzP*MM&vq!F zQb57MnO*4;%vq?QRrCprdoR+lL9qgNu;Gq8HT>B{AKO~Xn}1!|xn&1_E#)C;Ie~wX zaFgk4#_()K#dC6X_;7)O{VA|cguBt(y^fUn{Oi;mRssItN<&Zm$@Rf%_9EUdawfD# zwPwGUbl+xk?3~Z0MEw?Jxo5bT03}Z{(YC$&a~DFoh(IW{i&l+f8$H4Ec6Wfq@cTU0 zmqLJB;CURL0cyL8z>XOWJ0bWFHA_!)n8n`QjWT5f4z#M1cQ=$Um{G`ANKq7E8_RtSb z*r$KVKk&!`U(eyTZI~E=NvA9_dYc>N;f9iKw`K*v&%xuQ6fQLm0Vr6@ZU^sYyQ`1fuOy z6U-p4oj-u+H46Upex;DSVgGJ>1racW6EBx@m#eZ$(Uq6P#Oiok#mWRW;vs!^A8oRwdQy)dMC}n*3MuuF2xu` zhFXVbAS+xm_CBp$ed=vv>D{T>!H}pw@@_o2`|dNBh}U7o5ucwOu+|xIg&Y?|=dp)p zADbDj`((Q>770SsQnRooyZBhUzeIXe2{2X;?J3l@xcVO~gd}D$g9n+eHVM@}Epn5% zkY&Qf>Q2p@&1HhKR(41QzMV`6WBhPn@-=Q`ySQMhG>vtYW(s}Ba=);Ffuu_)2M9fK zP2~Pkjl@>jJ1lb zjYIm0+|WdT=5vkJUf`zSAd6b&xk~ueyaNkgc8z;b-hy>2g;6u2a7q{0b08>?8?UQ5 zww)UwBFBMvPF`)n$qjR-L^GjwdwOOJbu92J~uCw=Y5} z4JJ!`Olt*%s8Z5q|9oXl9(2NW$}S-LVz_aK69NGR7)a|EjIR8_BJ2MQ;R68%YnRZ= z8%&6JNl|JxZ@lzU|1SaBoyl=;*g5PU4%2Y@$2oKvHlPzpyHdRzRGj13j?qmD#bG$kYR17w{J+)b?x?7@&=gFU%EyK z91rfbq#RuT!S4eEM=^b4L~5~&l<r)mMq5cm6a3CX z4Ma;W9t*E~!?PtUV3qk#j+J&P*a=;uC(9Gr)aYm^XV^AH8F77Mp)gGlW{uA{5j|G; z1?S|$4&cAE(AJFIJT(8>4##teeLj_Sv2#OTmfa{K#KTI$-wL}d%1e*??#`ulH|U@C zb%D^t$O{l}_-1PYB|UXy$0a}h4{F0g(g}dd?d(LZ;uO-V3O$*tb>J^&&#%6?ub6?v z@Eu*BW&WPy`ZAUKQU+4F`5gS!u$G8@3vRZE1jt^EeVX=Hob$^$u})PSwVp@m)VNNbNp>?bNlCrJu?#p1NXJ-5#{@JR;~g@VJt5 zCH|t)Z(iGNIw9W){A*p$Ef1a(&mH|-@oXWMjcZ+zgOIoQ_Makv$MrDQM(AT+WW`WJ zQjX>Cnbv#5X4H7V85@a?Cf=^FfE`H=#Oz9e{B6;7>efi&?OVQ3f)S~Pho({ei{RVW zi(8`;3zTIqsjTPv=g(kSz-Tq_E4i`r379l6T8-`{9a*2=unVx?6Qw&^&IxuzN3-mF z7^jSC`tSg%n=N|UMJOO4XQ7q0%a7*pFdG7(MKbW2Iiq5=ELGl?Z8;yqjEIOP;)SeG znS;dphmU=7|4pFnl-!_D4HDn~ljBGyk~FG#{r?Ew6F}TBR`DKW?_zK&iss14+Rv%+ z|CFwIVnl>bBrVEqP=7O^NgwEh9vK1U@a0eY0nBJ)%0jD=!fClhJaFq;*Q!e70HH#J zlsLGw2J2{2-7<3a0fSB|^;$->V@S}xR$vGtJcu?<+e!UXjsNB!z22IE#8X-}7ZMPR1>=!ll=8Z)bA zCf7vDgv?0IDw%Yl3(1tNf<2Cd(Tdujg?IQW)Y0o{za*u|sA`B!XD7JJ2Xd>++R;?o zUho&~?Zh{{3wA`?)>L4~gWrlLxDEL>%TI^_vtdxDOK{b~}X^mZ6=4Mrf0R1KF$#z->rMLKY%K5f!e+ajQ(uRp2 z>1>TE%WfHIX?hTG`kBiY?&3GHvF@~84KZ!&L3OlPh|Sr@PUes4l!{FMv@N&mBgYGJ zn5r+;Om}hhSFk*w_qj1VVO_NMHlqY^u>2{--Bbu9swDIe$BwRvnNzSbG+6kNbbcs4 zM5pRMStcfXfX*`9G}U+oBUv3u%$|3t(n0q@4CZsmyKu=`?kgJJRxM5x*Jo3;)iyk9 zuEhMO1kA|yplwi7KI_P847*k?KM5sQ)S|Vqr$JA_i}No4;)hSM`_``3f6jNMUXI&W z9u(6NM_^vp6}+rBVqT^9AjfB+^?sYL-12vU)N(lUl{{5sla;>cKQ#QN!jYqZG(Gdw zO0*kM%%(_f{u36m7r&jLea`{$GuD$STzmpmC0abx@N^f{NEtyEuu z)?i%LCq6b2z#T@4Se_)u(|LQ)aPk~?wjjb4qYVe+%?(wva|8Sn-S9_eO^AP>>Y8#F zQd2-Dxri?8Lb?f3U)=NHF+SLxr2FEY6QbX@UgUmydN#XB?dfPaxcK_Ge5i2UqX*HO z-bDoyN6&^2tO`c|P;h^+0nCxOKd*rS>lBu26laAS9I*hb()9Jj+Scp-`~b=q&MQ5Y z8)8$d8A6SY5M|HLN{;wbCH%y)!jryLcH}%tz7m0IQWqkj<@(mZOBJ6d&ug(JTsM8U zP@R<%m!Ac(6wsZAm0N#)_}BWGpnStw59SUgI+rFt9VJf6td3CEiSGlFc>@Hse<7UK>r9cKs6}KQYEC{ZGD`~TGyy<99Zm5 z8F6sP1qU|{9Ct)vYuHIIBPp_K%vx6%y*4;J1)Lc{ke zrip$zT+%NBTta8g0RU|V;4-#Ekd}Zs8w0LN5d_#lr6a3#Dl*Q9mMPe!0;zFtBMtFI z@zAZF>sySz^TWv~aIT(;5`|~&P$P&*s3IcJzidNslCXJoH8*!x4*QNK51$Zuu_dO^ z2N(@;y;0&XGG|nw#msw($QE75@II?TJcEQV`pF%I|VqCZ8l z$F03c0W!g*UlTH}g{4L>k)xAaXe9btL3YeJzR4A9-kV7ZF5zuT-l%`2c($n9vb@Q?~h=wVtiv zJCo(VT@!={A+SZys%^UanUMRtqzP9!W`aw>)q8^|NX(ne&2^)tLZYh|jCT~aYQM+p zd+BMIS~Yz4tXief{3+{BbG;L1*2-d$KA;>i%a={lAMQBOkdkd{+wg#tmg}lJHOFQ5 zQvw&7qfiUBcI=hWi(WY-qP>W>v2SU-C3) zulL`BNH}N%Ad-UP|J?dZUIZXIL2h+^+K@i@;8rpb$~dA-*7V&s(~VmqgpU@{7V8>s zgl~+35TqE+SUI(lZ4ej@0|6JkwA8J`80WVbAl??fjD(y-mBIK!3mQ5V&x1NmkbY~m z&2W>Yt^5-T$hgF{Oa<&8Aj)m*wC#={7cqcPh0m2@MF?RW0hNi2-AdoLN+ot ziJgj?L0J6rfJ+el;j6CMRH>rd-2XAK#uUsQqzQRpo3K6;HXiA ztn+Z40s2&3-Is&jwWn8T-jTKrg>YHTH**;`C$e0PZfi?`;kc`f;7tAMN4+3%U`vmr z%4iQ(2WvHYaOh+%`Wt|&ZX*CmT2_8CcV)6IvFUuWUbPIk+ij5vFbB)C4FiWwo75KD zf}21`aG7XZfaS&PzF1!h`m-d%mLP1ikU@~?Z=GX|rIZ!wuL zS?5%DN;U1u{hl~r@5>6ikeIyIQzNgbH{7>*7G@p=Xj2rmE1iqE^QC7!zaSkaj7D_m zR_-X50P$sotOnI5miU#RXWt-bZDS>|GM+5kz{3K331U+KPo1TJZvt?)iw(f-dRcJ! zKQk$!$nP@C^+g58XtKPRbg!DQ^3x=C2BAluh9jO|IJ_MzD?g)F2V-ZXQ?fvn7e(1L^{}U=W81i0>SY-q2s$ zjfYX-SsJsh#l^$NqT!p&paz9InRNe|LJbkdlNDOD$XqIOtD{3M>G%uiS*c~;e`GiQ zv*dPNNQ6bj+Y&_*Z#o`}JFd>S6jlfaL6GA@3(HMGL+48Fru)}t0d}+`x;|1+=>#}U zQ=1-;WCNM118~Uljm>$O0d~;qR7L`{h`iH+`Cv1|*Yz=XrDH1Mv~Zd8$i+M*p12Yzbiug#8}asAd%Jplk;at|d>;a?FuAFBLL9>kkHw_k07=iJQ3( zae!9r0F8wlZ}*dZ61szD$?va%_RqRJ0NqmJ=tY@|1scx@65dBS|pG;~+1!v!~C<64hrl}d&0ze{N94Xhr zKK{I)Su~6Wv@hu{J9S*A+?Q1n(h;saA};%iPJ8qLmhh#C{ONs>f+CgDvx=+$7*p57 zb$_*7_pt}a40=O%13c-L@()Oqw^3#Q+?%>cq1BQ3cJt~~wey3{Gj3YOx|BmO!8cS_ z*yt*0!`#U+w3coUSGk)sl8tQzXWF{3JT#!WMN2JSCFh?`iGl-nmAP@ml{uj_D2%*uZPGfA4{Goo4=1~~Y7^wVkQ^*M zgE2C@1MUsig2ub8>P44vCL2tgKJOz-UPj!XCHXr%@GYmNCIW_aw2$lNTjcr3f;04a z8NIZOzEusM3&B-$B3_a+Qkl1>iTIar@;lu5q;3%BB;7>(igN#R(L?!)31fD^-CHcZ(a*p z%#KVkO*xN;ftpg>7DH!sq7p1XM`It45}-~rPeiSyrRT(J2H)<{q<-CJ2_TQc2?@-M zmOu}Kpn^vLWY-{7!s7B_5o>^O7pu#0wv~1*wD8HWO?MY)3J{xM-D(gS4w`Yc8HBm> zNN@-OOu7_{k=nR@&$E-CHBW<{es*b90LT@d8t|=gbwt+LKNr|fN}9Bs&^GH{5jy^C zPDiWgF6f$A^wR>scE1lIgPO4@i$F}%4isZ}3weyzp?6T5z@!BqWgEx)z)v)QI=N7p ze!JU<_+I+VY&~*?nmd1NSl>8C~G;Qjs;#Z20a#)o(;PVoZ1og3OrtFrsy#- zH&w=~?mMxiDgXN5&IH9%&0)WE@5ifJnX?tk z#9B)?7*qI9P+K+MRb_x64}D%(0YK{b6sRYpCQ+rwNAfePzdW6|h&?4{sP&d{%0R`N)INvnrw16!?` zmTiM!Dj!W4c|lAOan_Un@~vsdN3Z%c`8~d}-1q?`sE4*^_F|yL?MHQ1C3E@9M`f;_ zR!U>{usrlgY6<{ajR#=9lR0^vQaXXIG;`uyU9NUc2nduenQ=9@P$~pSVRiDNH197+ z`}@SJwRD!DD?f0ADc;8~Nay}BQ{kd7%5fFypixcnp4NFjj4oW5^x8QR8r~ExGo`b? zMhb(m^O44>WmP9LSHAIsW?wB%1sAKD0RBJ2*mTIrGr>)ZE<%{Qc2o9UpEeGT1x&dX z`|QUOT6<%3jfVxAdam_*u4(i7ZpBZBS5C* zNxuLP;^L20!CPv8uHjl8a>nWzEC8Uax>I@qZy`I$xD%M6K>*+<)|kH_{A^s#NooNQ zzN}3n_kdqxQB6AMi0PwaCuZ_Iya1l&R!4PAIa%|9h6{E3$oRdaMH6jk&(RG4b55%~ zt-9QO0Dw0e0F?$%sMa!#d7oy_k(#h)^uL?`SnUvDY>VF@`oS^9GSIS7oZNv{_<20k zuNX3xb;l^$R)O&yN%&x`|Dup;V_NTi0HAz}QA}_UL_=Cz@*DztIo!O^LDXm7U;r#L zBk@};lhlS<=hY@>-M~XXzOGhT=X!@KAWkwB=AU)QReOb50SE1|wb}f&b=#aGZ|N3M z@JY}Xq1^&>Pl&wAb1M&nakYdy%oMR$tbw!BKq`&$Ogq1aJvFlTu&(mUQc-9ItL^b=!wL{a>NIy)iVjhd|tO z6q7{4d?7UX1=3-FxuqQd>wlYfcyp@5QKOf-(ghqNr$OT+g%O2dw4z{agO0`X!+|&Y zg;SqkY)DS>7~jct6~(h;yyW~HGvAy?EBPpwCmnO;-qNd-k>XDvWXy*>npfmT5$P;A zQ$5aox{MOoQ>_l8gMmy&ofuoyoY1;g49uO?W=Y>K=+b1?6RD{pZE{UHEZt-pjDGdr z8u@jRmNk_F3pY!fc>sR%y?{WKD$MCuaZjOIS?d>bT@VJm8rxKGi&&w3zV$s%Rp6B? z0@(gO)dM`50vBnW#(OM=1gs;jR;&4cpvk*piY`uBRc^ChGAp35?s`jWvxGQi;(ixk zHXCFaw1r1g$xKY!&Xo3feqlh~L0(m{ZnrBTZ5msfT+XvtD+F8N!EtlwMqBoyPbte6 zK3RW*dt%3aS*&!K$v(oms9+}EJWgwQ{3)(ILUBMCX^f0tQ&B}G*mOJ|VjcM`N>^6( zIw9?AQrvLOh3wvIs^f{#mTqN36+xVQfzBh#Bb^7PSX)ZQnv~Uz-;=gXd8MGgG)OiT z45+6C`Ey#!@J`jv)#i}Ju`mmH$T$9&Icmf<*3bm69JO2%UerUzeofDYADFU#sHT(3 zZOFzP+Z3!e+E80Oq$e1Bf>qVWGMxts?^k6^Aq$t=9h)X#jQMGm#dE#s`-)tR+6Rp~S+EOMpz#-ZO`h z8LBn?N#Uw)C2uIbc{;WGNMGz3R77T@CG199B5RWd%9B*do3H53`f%CQbUVEFr#qWJ z^;0hh6p_&sE;es@%TE*C?zV%j5l#lGZ^l70 zXVp@BxXAN&D#Y)cLF}jOMo||k!8#lQfMgyEvY!NsgHFKC0zKpD901{)u>^1_mp!Xa zCQ-vKmIz_L8h`pg_kE`5^wZHPfGt@YM_ARXWkJIVrRY+^syxhWx#agz(5R;J={$gb>>dA*JLNdV98Yz&VU%L15unm) zsIl|zdF2clFbWnY4xdJGw1afm2vygTNaN@0xwB6Rh-ZJz1Juh1;FV@@(Ue@o>yZWR zYHG7VUv(dXpQ4Thc~>=nJ8b}Z&oWd(k%HSPLjcHL2ADtUs$Pz%3;k5Zk37234$UBp zGxsa01Zo4t`YFM|^ya5tewHBhUmw4#>IJ?hC^Q4@&2`(-xykp|E+4LbpRMEaK_#9> z^BP-`xx>PEizW{mTp-r6XZ5(P0~AjRj#c9T`dk=Vy8yN zn*r=J2TNIx-{yEckg5`K)I;bramQ-^0ilnpmF^)uYA*@*uIJJcRwkKV>@6{Bg5u_a z3s_G}hWVx315=VhLVZK3!;`g5$h{&ScgTY)bjEqbzt_lq3 zTBgC*95BHndU?yLCo}?sg~+RDO!!AvMJ*Yku8z)?cdcITSgdgj&oh{zHE&~0mlk#I zA08KY5O4D4;cze(9jS|Q^+FNy3(g98RL~j;2*sq$p!#g;S9lwXC6O6itJ5k%QsmS@ zE{GqM-sG*=_tg&PCvw_44Tilwrv|`BBHGL(F0S?WbIlEW)YZKuHa{eY&ajwvI-vJ` zHuad*j1om8-CDJ0@}-G;IZ$FCcP_Z=XF;J6$`A=Jp|+Lcwa61Tnw;)IrenDSXT23_ z%aOSnjm1|1zeKY~tS+>(6F)?TyE`wBXxVs(OL4@{bCSHj6(VJGOE_ypE! z=M0u-2U;fku8P=LX3J!+hNSDAMy1w|W&75QYGD>A;>Szg(0b=$mywYIR;^)!)yi0l z%HUcur{!nkoc@Ggs$IUNIk%~I`FgA_eQg=yS;j2T=JBm&jl7Fx7bnjRCL@ob9r}Jy zi}!Sn?qYhqLjoM@S6;X1gw2UT@lLZIh+ua_OLAM^T&k;c_QyW_NvDion+N}z4Tn-C zG`t>78qU(Z1Cfzm2*r@|t$Lo0wM{~cDE~23nAR$T=ED{O18jlso;6oH%la|(t`%kU z9Er0GF$FUF#JDk)>{Zlr3lvHva(J4aZZ=79ZKgUho$k&`1{$?CH8zr#Wh(hAL-^4* ztnXOz+&RIsZ-!v3g-tTcYOzV7@TM*Y$F$`T*jQnJlca5*B=|lFcqhv0fX9EHLK1FK|zb&=8>xX^f^lOuj^r@=qIBy`Ad?WL3 zhMB-+YPOfz)Fk2+QE5}9k#{J~ykC^DR(buq>|n!6M4hiuRJS~XKN@pNC7fAL<0CC* z=$R^g6O?BSiMVdy8Mr8xHJ&KhYVawrcE5re9)h^1pv@#z6WvIW`#vF~$3La8k?#v( zt7~F1A!)@v>>1;cwj!sYWM*!k?f%j1)Mn6T#o)y} zsu2&DZ(L9}2`1(1PV^T}35^>*)cr1&P25vp`A)RE5%Q~*T^*^Jg#ARCO66W#`NodL zuD~;1lP~<>3@`fQa|T_L7CKYU4?om5x$Yvb{QQ!L*j;>%A%wkA{8!~xOBYQ(4J1@f zusSj-6g~vENl;YQYg_8ClZ_C9FP9|E8Ku6C4W~(O;kcF90iYB^(cC0Mn3({Jo&k!8 zA-IMrGTWW}$*IpSsif)cwvuSnlye}hO+TXc(HrwKLBix#--->5056Nc?glJY^Ml^Q zvB@acpnUSZVi387-W19?Idtg0B+ukV!m;-#FMV;h_p%@aUV%~yI0jx;VE5{ zH%V#1X^y-LM@u5ak6vgnrbB#|M-rl|s#lr?tB{2GZ@O#LTpkMbWpgUyUj#z?ARF#Z z$0rzwN%c70+u64>v60HqR0ls4Zc%h=;JP=fZoHrY zYQE3gDS+pW*|XZN7fPwA@jN1>t7Nj6yZ)Iy_5S5R1J%iX3~ID5wBt^nQPEpv3Uusg zVJXIs-<21*N(*_<)DCMNE6qNOu>2b_2htaL-`vdG`gTS)4gt~e2wUUy_XQ$EYp>X@D z9EIndaEo8JR0*wfZ{UjzH62~fzQ@O`^+~5?5p-X1Xc*@x$RoSf@5K@)SWpQ?K5A#( zd0H7v(o}8j`HnQ{vi}RHk?QDb7MIL|f)T*rkz43=0}OFscs$Tz@v{JP^{5hLZGkz?_#SdgrM_ z{(Y9b6>h?yHi*2{&^YE{>x`}oE^}QTV0Ts}dRt8)<sC%=7{z)Qh@qXMQ+=F6ssa&kwd(eCro}zAz*lQ25BlKs9ig$H*T9ll`(S03p1ExUxlIBVwBNDjU7fF!ED!C3qEY_5mWJp8id-*& zNTb!ZJ4|0L9hq`F6J=jq$(iIKCb)p`3?~dq(7ify;NpX(N0PoQjPbOm8y^LJt@0nc zKc!=s!^4kVDTyQ$t?n|viI~auv@=P;ij0&l(yE&0cnG1E=BLBRBK6e;Ez;l@SEs)m zJH6=U$5Q21cB9tp8|r1sAf?d*%h+)DnV@;~t~F)Xv046hL85>w&hX5IVxzix_lO8G z?8*TnQbuf*XRddSCu1XXS=LM%RdSVSXto;id#kZd`6CT;RBPMGm9?L1*mFhprI-gV zK2Qi_NV3qGv2~_=lgj2-tu0ulE6l5XIxogCFzPnF_S3SrZMCj;G_vcW*jp#Gf~u~q zP*L7rJEVK-mCce_#)z*$e`#yg%EG8+OEA0SN}lanQj@=fRs(Gq=M%kP=QInGU?eAS z*T(1E1gu+<2N%OkT0~V}yNb)up!X_38XNT-2+XiM&# z&@CD(8TMUM=Kf*MAjtW;DlmqGX2!M2<*IV>P*sAmqB2sg?c`8{)vnNeuNPO_Rt)XjJ?3KLlX_7?WihFoFDdkLKZMr#-g7x% zi{e#7+6bm8AsNO7`AlV85*+p6*|-uyiIAnh+XE=4*!SCuxVJ@u=M^BNiPeTRJqM`a=7}AT zO6bzhP$wNOIH|k3{8A|Lkxp?qFzb*J+F1*u$8~Q#L8)6gOG;liVb4Q9Mz=dVCMb2o zF$`Ai?pB^v2I>^cD1gYwwNRzRXH|f=yMW^j) zXKBVna?a>yokh8l5_qf*evKb(_{byD+{V#$^@6}^dz~)AUS1iWNXDtK-@h

f4G3 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

Cp;#W9HgEJfy^86L<~+?=*+ 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

Uf%E2@`>Ja8%_7#^A}e?xJMwecj&pYgKk>! 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ⅈ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 z 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 Date: Thu, 2 Jul 2026 13:00:14 -0400 Subject: [PATCH 37/65] chore(docs): SDK Updates (#867) --- docs/changelog/introduction.mdx | 20 +++++++++++++++++++ .../api-reference/endpoint/health-check.mdx | 3 +++ docs/v3/openapi.json | 11 ++++++++-- sdks/python/CHANGELOG.md | 11 ++++++++++ sdks/python/pyproject.toml | 2 +- sdks/typescript/CHANGELOG.md | 11 ++++++++++ sdks/typescript/package.json | 2 +- 7 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 docs/v3/api-reference/endpoint/health-check.mdx diff --git a/docs/changelog/introduction.mdx b/docs/changelog/introduction.mdx index c66e53a9..62af1a6d 100644 --- a/docs/changelog/introduction.mdx +++ b/docs/changelog/introduction.mdx @@ -698,6 +698,16 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [Python SDK](https://pypi.org/project/honcho-ai/) + + ### Added + + - `ConclusionLevel` type (`explicit`, `deductive`, `inductive`, `contradiction`) and a `level` field on `Conclusion`, exposing the reasoning level the server already tracked but previously stripped from responses. + - `filters` parameter on `ConclusionScope.list()` and `ConclusionScope.query()` (sync and async), passed through to the same dynamic server-side filter logic as `peers()`/`sessions()`/`messages()`. Filter explicit-only conclusions with `filters={"level": "explicit"}`, or by any other supported field/operator. Requires a Honcho server with the matching API support (Honcho v3.0.11+). + + ### Fixed + + - Scope-managed filter keys (`observer`, `observed`, `session`) are now rejected with a clear `ValueError` if passed in `filters`, instead of silently overriding the scope and returning conclusions from a different peer pair. Use `peer.conclusions` / `conclusions_of(target)` and the `session=` parameter instead. `session_id` remains a valid filter on `query()`. + ### Added @@ -850,6 +860,16 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [TypeScript SDK](https://www.npmjs.com/package/@honcho-ai/sdk) + + ### Added + + - `ConclusionLevel` type (`explicit`, `deductive`, `inductive`, `contradiction`) and a `level` field on `Conclusion`, exposing the reasoning level the server already tracked but previously stripped from responses. + - `filters` option on `conclusions.list()` and `conclusions.query()`, passed through to the same dynamic server-side filter logic as the other list endpoints. Filter explicit-only conclusions with `{ filters: { level: 'explicit' } }`, or by any other supported field/operator. Requires a Honcho server with the matching API support (Honcho v3.0.11+). + + ### Fixed + + - Scope-managed filter keys (`observer`, `observed`, `session`) are now rejected with a clear error if passed in `filters`, instead of silently overriding the scope and returning conclusions from a different peer pair. Use `peer.conclusions` / `peer.conclusionsOf(target)` and the dedicated `session` option instead. `session_id` remains a valid filter on `query()`. + ### Added diff --git a/docs/v3/api-reference/endpoint/health-check.mdx b/docs/v3/api-reference/endpoint/health-check.mdx new file mode 100644 index 00000000..35ceba0d --- /dev/null +++ b/docs/v3/api-reference/endpoint/health-check.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /health +--- diff --git a/docs/v3/openapi.json b/docs/v3/openapi.json index adcd9d29..85e4891a 100644 --- a/docs/v3/openapi.json +++ b/docs/v3/openapi.json @@ -9,7 +9,7 @@ "url": "https://honcho.dev/", "email": "hello@plasticlabs.ai" }, - "version": "3.0.7" + "version": "3.0.11" }, "servers": [ { @@ -1574,7 +1574,7 @@ "get": { "tags": ["sessions"], "summary": "Get Peer Config", - "description": "Get the configuration for a Peer in a Session.", + "description": "Get the configuration for a Peer in a Session.\n\nMember-read lets a peer-scoped key reach this route, but a peer may only\nread its own per-session config — not a co-member's. Workspace/admin and\nsession-scoped tokens (which already span the whole session) are unaffected.", "operationId": "get_peer_config_v3_workspaces__workspace_id__sessions__session_id__peers__peer_id__config_get", "security": [{ "HTTPBearer": [] }], "parameters": [ @@ -2783,6 +2783,13 @@ "anyOf": [{ "type": "string" }, { "type": "null" }], "title": "Session Id" }, + "level": { + "type": "string", + "enum": ["explicit", "deductive", "inductive", "contradiction"], + "title": "Level", + "description": "Reasoning level of the conclusion: 'explicit' (directly extracted from messages) or 'deductive'/'inductive'/'contradiction' (derived during dreaming).", + "default": "explicit" + }, "created_at": { "type": "string", "format": "date-time", diff --git a/sdks/python/CHANGELOG.md b/sdks/python/CHANGELOG.md index 2c8d3f91..84843c55 100644 --- a/sdks/python/CHANGELOG.md +++ b/sdks/python/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2.2.0] - 2026-07-02 + +### Added + +- `ConclusionLevel` type (`explicit`, `deductive`, `inductive`, `contradiction`) and a `level` field on `Conclusion`, exposing the reasoning level the server already tracked but previously stripped from responses. +- `filters` parameter on `ConclusionScope.list()` and `ConclusionScope.query()` (sync and async), passed through to the same dynamic server-side filter logic as `peers()`/`sessions()`/`messages()`. Filter explicit-only conclusions with `filters={"level": "explicit"}`, or by any other supported field/operator. Requires a Honcho server with the matching API support (Honcho v3.0.11+). + +### Fixed + +- Scope-managed filter keys (`observer`, `observed`, `session`) are now rejected with a clear `ValueError` if passed in `filters`, instead of silently overriding the scope and returning conclusions from a different peer pair. Use `peer.conclusions` / `conclusions_of(target)` and the `session=` parameter instead. `session_id` remains a valid filter on `query()`. + ## [2.1.2] - 2026-05-21 ### Added diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 0e6ad9ba..6fe801ff 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho-ai" -version = "2.1.2" +version = "2.2.0" description = "Official DX Optimized Python SDK for Honcho" dynamic = ["readme"] license = "Apache-2.0" diff --git a/sdks/typescript/CHANGELOG.md b/sdks/typescript/CHANGELOG.md index bd6dd844..179cca72 100644 --- a/sdks/typescript/CHANGELOG.md +++ b/sdks/typescript/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2.2.0] - 2026-07-02 + +### Added + +- `ConclusionLevel` type (`explicit`, `deductive`, `inductive`, `contradiction`) and a `level` field on `Conclusion`, exposing the reasoning level the server already tracked but previously stripped from responses. +- `filters` option on `conclusions.list()` and `conclusions.query()`, passed through to the same dynamic server-side filter logic as the other list endpoints. Filter explicit-only conclusions with `{ filters: { level: 'explicit' } }`, or by any other supported field/operator. Requires a Honcho server with the matching API support (Honcho v3.0.11+). + +### Fixed + +- Scope-managed filter keys (`observer`, `observed`, `session`) are now rejected with a clear error if passed in `filters`, instead of silently overriding the scope and returning conclusions from a different peer pair. Use `peer.conclusions` / `peer.conclusionsOf(target)` and the dedicated `session` option instead. `session_id` remains a valid filter on `query()`. + ## [2.1.2] - 2026-05-21 ### Added diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 4ac6cea4..9819ddf9 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@honcho-ai/sdk", - "version": "2.1.2", + "version": "2.2.0", "description": "Official DX Optimized TypeScript SDK for Honcho", "author": "Plastic Labs ", "license": "Apache-2.0", From 502e20a1cc69deffb383ec730fa2cb2cbabfacb7 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:49:23 -0400 Subject: [PATCH 38/65] fix: Add namespace correlation to sentry monitoring (#870) --- src/telemetry/sentry.py | 5 +++++ uv.lock | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/telemetry/sentry.py b/src/telemetry/sentry.py index 4720c418..d84837ed 100644 --- a/src/telemetry/sentry.py +++ b/src/telemetry/sentry.py @@ -102,6 +102,11 @@ def initialize_sentry( integrations=integrations, ) + # Tag every event with the configured namespace so errors can be filtered by + # instance. Set on the global scope so it applies regardless of the current + # isolation/task scope. + sentry_sdk.get_global_scope().set_tag("namespace", settings.NAMESPACE) + def with_sentry_transaction( name: str, op: str diff --git a/uv.lock b/uv.lock index 2d0ce1d7..c16f23c4 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-19T14:44:24.535479Z" +exclude-newer = "2026-06-27T20:44:32.746059Z" exclude-newer-span = "P5D" [manifest] @@ -1270,7 +1270,7 @@ dev = [ [[package]] name = "honcho-ai" -version = "2.1.2" +version = "2.2.0" source = { editable = "sdks/python" } dependencies = [ { name = "httpx" }, From 602347d76c4e5e464e883f5df32b5bad5aa7952b Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Thu, 2 Jul 2026 13:49:53 -0700 Subject: [PATCH 39/65] feat(telemetry): CloudEvents + Langfuse tracing as projections over a captured LLM stream (#845) * feat(telemetry): CloudEvents + Langfuse tracing as projections over a captured LLM stream Capture each LLM call once (CapturedLLMCall) and fan it out to multiple exporters -- "one data model, two projections": a CloudEvents trace stream (llm.call.traced / trace.content) and a Langfuse projection, both reconstructing trace -> run -> step -> generation from the same source of truth. - Capture seam (src/llm/capture.py): one canonicalization + content-addressed hashing point, with an O(N) per-span memo so repeated context isn't re-hashed. - Session correlation threaded telemetry -> captured call -> exporters, namespaced only at the Langfuse export boundary. - Span identity consolidated onto LLMTelemetryContext; dropped TRACE_ENDPOINT. - Canonical generation/step names; dreamer branches nest under one dream trace; tool calls become spans under their step. - LANGFUSE_EXPORTER_MODE toggle ("exporter" default; "inline" kept one release for side-by-side validation), centralized into computed settings predicates. - Per-run/per-trace dedup registries (trace_session, langfuse_session) bounded by an LRU so dedup and span grouping survive long-running workers. - Embedding-call tracing; deterministic high-volume event sampling. Co-Authored-By: Claude Opus 4.8 * fix(telemetry): address trace-review findings (span/step_seq collisions, test, logging) - Dreamer specialists mint a distinct span_id per execution (trace_id stays the shared dream run_id), so their CloudEvents trace resource ids no longer collide between deduction and induction. - Tool-loop no-tool early-return streams the tail with the next ordinal (iteration+2) instead of reusing the in-loop call's step_seq, avoiding a colliding trace resource id; mirrors the synthesis path. - Tighten test_clips_oversized_string to assert output stays within TRACE_MAX_BYTES. - emit_trace logs the swallowed exception with exc_info for debuggability. Co-Authored-By: Claude Opus 4.8 * fix(telemetry): silence exporter-mode Langfuse warning + drop summarizer run_id placeholder Two CloudEvents/Langfuse correctness fixes, independent of the trace viewer. Langfuse exporter-mode gating: annotate_current_generation_io (and its two executor.py call-site guards) were gated on LANGFUSE_PUBLIC_KEY instead of langfuse_inline_enabled. In the default `exporter` mode they called get_client().update_current_generation() with no active @observe span, logging "No active span in current context" (~14 per dialectic run) and building throwaway model_dump payloads on every LLM call. The LangfuseExporter projects I/O from the captured stream, so these helpers must no-op in exporter mode. Gated all three on langfuse_inline_enabled; added a regression test; fixed a stale conditional_observe docstring. Summarizer run_id placeholder: AgentToolSummaryCreatedEvent hardcoded run_id="deriver"/iteration=0 because summarization is a single LLM call, not an agentic run. That placeholder pollutes run_id grouping in the CloudEvents stream (any consumer that groups by run_id sees a phantom "deriver" run). Made run_id/iteration optional (None) and re-keyed get_resource_id on message_id:summary_type (the real per-summary identity; run_id/iteration can no longer identify it); bumped schema_version 2->3. Xatu ingestion stores only the CloudEvent envelope, so the field/resource_id/version changes are transparent to it. Co-Authored-By: Claude Opus 4.8 * docs: update docstrings to be less verbose * fix(telemetry): address PR review on captured-stream tracing - embedding traces get a fresh span_id under parent_span_id=run_id, so sibling embeddings in one run no longer share a span/idempotency key - capture the provider finish_reason from stream chunks instead of hardcoding "stop" on a successful drain - gate the Langfuse exporter behind TELEMETRY.ENABLED (master switch) so disabling telemetry sends no traces at all - rename _emit_derived_content -> _emit_hashed_content - inline the _emit_trace wrapper; drop unused trace_session.end_run Co-Authored-By: Claude Opus 4.8 (1M context) * refactor: rename TELEMETRY_TRACE_PAYLOADS to TELEMETRY_TRACE_PAYLOADS_ENABLED * fix(telemetry): capture provider tool calls in trace stream The captured trace stream dropped assistant tool calls for openai/gemini: build_captured_messages only read {role, content, tool_call_id}, but those providers keep tool calls outside content (openai's tool_calls, gemini's parts), so replayed tool-call turns landed as empty content and gemini lost its text and tool results entirely. Anthropic (tool_use in content) was fine. Normalize each input message per provider into a unified tool_calls [{id, name, input}] field on CapturedMessage/TraceContentEvent, recovering gemini text/results along the way, and fold tool_calls into compute_content_hash so empty-content openai turns no longer collide in the dedup store. langfuse_exporter._input now surfaces the calls. Also fix a silent serialization drop: gemini thought_signature is bytes, so model_dump(mode="json") on the traced event raised UnicodeDecodeError and emit_trace swallowed it -- dropping the whole tool-calling iteration from the trace stream (billing and Langfuse were unaffected). base64-encode the signature on the telemetry path; replay keeps the raw bytes. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(telemetry): type replay tool-call dict for bytes signature thought_signature widened to str | bytes | None, but _tool_call_result_to_dict's literal was inferred as dict[str, str | dict[str, Any]], so the bytes assignment failed project-wide basedpyright (the per-file pre-commit hook didn't catch it). Annotate the dict as dict[str, Any]; the replay path keeps the raw bytes unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * test: remove 3 tests --------- Co-authored-by: Claude Opus 4.8 --- .env.template | 5 + src/config.py | 48 ++ src/deriver/deriver.py | 5 + src/dialectic/chat.py | 8 + src/dialectic/core.py | 7 + src/dreamer/specialists.py | 9 + src/embedding_client.py | 26 ++ src/llm/backend.py | 3 +- src/llm/capture.py | 403 ++++++++++++++++ src/llm/executor.py | 73 ++- src/llm/runtime.py | 62 ++- src/llm/tool_loop.py | 109 ++++- src/llm/types.py | 82 +++- src/telemetry/__init__.py | 2 + src/telemetry/emitter.py | 63 ++- src/telemetry/events/__init__.py | 74 ++- src/telemetry/events/agent.py | 19 +- src/telemetry/events/trace.py | 161 +++++++ src/telemetry/langfuse_exporter.py | 398 ++++++++++++++++ src/telemetry/langfuse_session.py | 122 +++++ src/telemetry/logging.py | 11 +- src/telemetry/trace_exporter.py | 169 +++++++ src/telemetry/trace_session.py | 73 +++ src/utils/agent_tools.py | 7 +- src/utils/summarizer.py | 19 +- src/utils/types.py | 17 + tests/llm/test_capture.py | 488 ++++++++++++++++++++ tests/llm/test_langfuse_trace_annotation.py | 123 +++-- tests/llm/test_telemetry_agent_iteration.py | 10 +- tests/telemetry/conftest.py | 36 ++ tests/telemetry/test_cross_agent_trace.py | 215 +++++++++ tests/telemetry/test_embedding_trace.py | 111 +++++ tests/telemetry/test_emit_function.py | 12 +- tests/telemetry/test_events.py | 5 +- tests/telemetry/test_langfuse_exporter.py | 438 ++++++++++++++++++ tests/telemetry/test_trace_events.py | 218 +++++++++ tests/utils/test_clients.py | 2 + 37 files changed, 3519 insertions(+), 114 deletions(-) create mode 100644 src/llm/capture.py create mode 100644 src/telemetry/events/trace.py create mode 100644 src/telemetry/langfuse_exporter.py create mode 100644 src/telemetry/langfuse_session.py create mode 100644 src/telemetry/trace_exporter.py create mode 100644 src/telemetry/trace_session.py create mode 100644 tests/llm/test_capture.py create mode 100644 tests/telemetry/test_cross_agent_trace.py create mode 100644 tests/telemetry/test_embedding_trace.py create mode 100644 tests/telemetry/test_langfuse_exporter.py create mode 100644 tests/telemetry/test_trace_events.py diff --git a/.env.template b/.env.template index ebe7fa16..24014ee2 100644 --- a/.env.template +++ b/.env.template @@ -276,6 +276,11 @@ LLM_OPENAI_API_KEY=your-api-key-here # TELEMETRY_MAX_BUFFER_SIZE=10000 # TELEMETRY_NAMESPACE=honcho # Inherits from NAMESPACE if not set +# Full-fidelity payload tracing (llm.call.traced / trace.content). Default-off +# TELEMETRY_TRACE_PAYLOADS_ENABLED=false # Trace events ship to TELEMETRY_ENDPOINT +# TELEMETRY_TRACE_MAX_BYTES=262144 # Per-message cap; oversized content is clipped +# TELEMETRY_TRACE_PURPOSES=[] # JSON list of CallPurpose values to capture; empty = all + # ============================================================================= # Cache # ============================================================================= diff --git a/src/config.py b/src/config.py index 5a0e3937..0e39fdcc 100644 --- a/src/config.py +++ b/src/config.py @@ -1176,6 +1176,19 @@ class TelemetrySettings(HonchoSettings): # that join high-volume events to aggregate envelopes first. HIGH_VOLUME_SAMPLE_RATE: Annotated[float, Field(default=1.0, ge=0.0, le=1.0)] = 1.0 + # --- Full-fidelity payload tracing (llm.call.traced / trace.content) --- + # Master toggle for replay-grade content capture. Default-off. + TRACE_PAYLOADS_ENABLED: bool = False + + # Per-message cap (bytes) for captured content; oversized string content is + # clipped (with a marker) and the call is flagged was_truncated. + TRACE_MAX_BYTES: Annotated[int, Field(default=262144, gt=0)] = 262144 + + # Allowlist of CallPurpose values to capture; empty = all. Typed as str to + # keep the enum out of config (validated against CallPurpose at the producer, + # same pattern as LLMTelemetryContext.call_purpose). + TRACE_PURPOSES: list[str] = Field(default_factory=list) + class CacheSettings(HonchoSettings): model_config = SettingsConfigDict(env_prefix="CACHE_", extra="ignore") # pyright: ignore @@ -1345,6 +1358,17 @@ class VectorStoreSettings(HonchoSettings): return self +class TraceViewerSettings(HonchoSettings): + model_config = SettingsConfigDict(env_prefix="TRACE_VIEWER_", extra="ignore") # pyright: ignore + + ENABLED: bool = False + HOST: str = "127.0.0.1" + PORT: int = 8002 + STORAGE_DIR: str = "./traces" + MAX_REQUEST_BYTES: int = 10 * 1024 * 1024 # 10 MB + VENDOR_CDN_BASE: str = "https://cdn.jsdelivr.net/npm" + + class AppSettings(HonchoSettings): # No env_prefix for app-level settings model_config = SettingsConfigDict( # pyright: ignore @@ -1364,6 +1388,29 @@ class AppSettings(HonchoSettings): EMBED_MESSAGES: bool = True LANGFUSE_HOST: str | None = None LANGFUSE_PUBLIC_KEY: str | None = None + # How Langfuse traces are produced: + # "exporter" (default) — Langfuse is a projection over the captured + # CapturedLLMCall stream (LangfuseExporter), the same source of truth as + # the CloudEvents trace stream. + # "inline" — legacy live instrumentation (@observe + propagate_attributes + # spans during execution). Kept one release for side-by-side validation. + LANGFUSE_EXPORTER_MODE: Literal["inline", "exporter"] = "exporter" + + @property + def langfuse_inline_enabled(self) -> bool: + """True when the legacy inline Langfuse instrumentation is active + (keys configured + ``LANGFUSE_EXPORTER_MODE == "inline"``).""" + return ( + bool(self.LANGFUSE_PUBLIC_KEY) and self.LANGFUSE_EXPORTER_MODE == "inline" + ) + + @property + def langfuse_exporter_enabled(self) -> bool: + """True when the Langfuse exporter (a projection over the captured call + stream) is active (keys configured + ``LANGFUSE_EXPORTER_MODE == "exporter"``).""" + return ( + bool(self.LANGFUSE_PUBLIC_KEY) and self.LANGFUSE_EXPORTER_MODE == "exporter" + ) # Origins allowed by the FastAPI CORSMiddleware CORS_ORIGINS: list[str] = [ @@ -1394,6 +1441,7 @@ class AppSettings(HonchoSettings): CACHE: CacheSettings = Field(default_factory=CacheSettings) DREAM: DreamSettings = Field(default_factory=DreamSettings) VECTOR_STORE: VectorStoreSettings = Field(default_factory=VectorStoreSettings) + TRACE_VIEWER: TraceViewerSettings = Field(default_factory=TraceViewerSettings) @field_validator("LOG_LEVEL") def validate_log_level(cls, v: str) -> str: diff --git a/src/deriver/deriver.py b/src/deriver/deriver.py index c6c3aa2c..0db9477d 100644 --- a/src/deriver/deriver.py +++ b/src/deriver/deriver.py @@ -1,6 +1,8 @@ import logging import time +from nanoid import generate as generate_nanoid + from src import crud from src.config import ConfiguredModelSettings, settings from src.crud.representation import RepresentationManager @@ -142,6 +144,7 @@ async def process_representation_tasks_batch( model_config = base_model_config # Single LLM call + trace_id = generate_nanoid() llm_start = time.perf_counter() response = await honcho_llm_call( model_config=model_config, @@ -159,6 +162,8 @@ async def process_representation_tasks_batch( parent_category="representation", observed=observed, track_name="Minimal Deriver", + trace_id=trace_id, + span_id=trace_id, ), ) llm_duration = (time.perf_counter() - llm_start) * 1000 diff --git a/src/dialectic/chat.py b/src/dialectic/chat.py index ae6ea290..9c118803 100644 --- a/src/dialectic/chat.py +++ b/src/dialectic/chat.py @@ -50,6 +50,9 @@ async def agentic_chat( session = await crud.get_session( db, workspace_name=workspace_name, session_name=session_name ) + # Read the opaque Session.id while the instance is still bound; the ORM + # object detaches once this read-only session closes below. + session_id = session.id if session else None workspace = await crud.get_workspace(db, workspace_name=workspace_name) configuration = get_configuration(None, session, workspace) @@ -68,6 +71,7 @@ async def agentic_chat( agent = DialecticAgent( workspace_name=workspace_name, session_name=session_name, + session_id=session_id, observer=observer, observed=observed, observer_peer_card=observer_peer_card, @@ -111,6 +115,9 @@ async def agentic_chat_stream( session = await crud.get_session( db, workspace_name=workspace_name, session_name=session_name ) + # Read the opaque Session.id while the instance is still bound; the ORM + # object detaches once this read-only session closes below. + session_id = session.id if session else None workspace = await crud.get_workspace(db, workspace_name=workspace_name) configuration = get_configuration(None, session, workspace) @@ -129,6 +136,7 @@ async def agentic_chat_stream( agent = DialecticAgent( workspace_name=workspace_name, session_name=session_name, + session_id=session_id, observer=observer, observed=observed, observer_peer_card=observer_peer_card, diff --git a/src/dialectic/core.py b/src/dialectic/core.py index 64895cb9..6f5b17ab 100644 --- a/src/dialectic/core.py +++ b/src/dialectic/core.py @@ -68,6 +68,7 @@ class DialecticAgent: observed_peer_card: list[str] | None = None, metric_key: str | None = None, reasoning_level: ReasoningLevel = "low", + session_id: str | None = None, ): """ Initialize the dialectic agent. @@ -81,9 +82,11 @@ class DialecticAgent: observed_peer_card: Biographical information about the observed peer metric_key: Optional key for logging metrics (if provided, agent won't log separately) reasoning_level: Level of reasoning to apply + session_id: ID used for grouping traces (not session_name) """ self.workspace_name: str = workspace_name self.session_name: str | None = session_name + self.session_id: str | None = session_id self.observer: str = observer self.observed: str = observed self.observer_peer_card: list[str] | None = observer_peer_card @@ -179,6 +182,7 @@ class DialecticAgent: workspace_name=self.workspace_name, run_id=self._run_id, parent_category="dialectic", + session_id=self.session_id, ): query_embedding = await embedding_client.embed(query) @@ -316,6 +320,9 @@ class DialecticAgent: parent_category="dialectic", agent_type="dialectic", run_id=self._run_id, + trace_id=self._run_id, + span_id=self._run_id, + session_id=self.session_id, peer_name=self.observed, track_name=track_name, ) diff --git a/src/dreamer/specialists.py b/src/dreamer/specialists.py index c0d86585..b0d44ec8 100644 --- a/src/dreamer/specialists.py +++ b/src/dreamer/specialists.py @@ -169,6 +169,10 @@ If you update it, send the full deduplicated list and remove stale entries. SpecialistResult with metrics and content """ run_id = parent_run_id or generate_nanoid() + # Specialists sharing the orchestrator's run_id (one dream trace) each get a + # distinct span_id so their CloudEvents trace resource ids don't collide; + # trace_id stays run_id so Langfuse still groups them (keyed by agent_type). + span_id = generate_nanoid() if parent_run_id is not None else run_id task_name = f"dreamer_{self.name}_{run_id}" start_time = time.perf_counter() @@ -292,6 +296,11 @@ If you update it, send the full deduplicated list and remove stale entries. parent_category="dream", agent_type=self.name, run_id=run_id, + # Root span per specialist run (distinct span_id, see above). + # parent_span_id stays None for now; wiring specialists as + # children of a dream-level trace is forking (out of scope). + trace_id=run_id, + span_id=span_id, observer=observer, observed=observed, track_name=f"Dreamer/{self.name}", diff --git a/src/embedding_client.py b/src/embedding_client.py index 1efdc3d2..07197f43 100644 --- a/src/embedding_client.py +++ b/src/embedding_client.py @@ -9,6 +9,7 @@ from typing import Any, Literal, NamedTuple, TypeVar import tiktoken from google import genai from google.genai import types as genai_types +from nanoid import generate as generate_nanoid from openai import AsyncOpenAI from .config import EmbeddingModelConfig, resolve_embedding_model_config, settings @@ -88,6 +89,7 @@ def _publish_embedding_event( get_embedding_call_purpose, get_embedding_parent_category, get_embedding_run_id, + get_embedding_session_id, get_embedding_workspace_name, ) @@ -121,6 +123,30 @@ def _publish_embedding_event( run_id=get_embedding_run_id(), ) ) + + # Trace stream (ground-truth) — gated on payload tracing. Each embedding + # gets its own span nested under the driving agent run (parent_span_id = + # run_id), so multiple embeddings in one run don't share a span id. + if settings.TELEMETRY.TRACE_PAYLOADS_ENABLED: + from src.telemetry.events import EmbeddingCallTracedEvent, emit_trace + + run_id = get_embedding_run_id() + span_id = generate_nanoid() + emit_trace( + EmbeddingCallTracedEvent( + trace_id=run_id or span_id, + span_id=span_id, + parent_span_id=run_id, + session_id=get_embedding_session_id(), + call_purpose=purpose_slug, + parent_category=get_embedding_parent_category(), + provider=provider, + model=model, + provider_input_tokens=input_tokens_estimate, + provider_output_tokens=0, + input_count=input_count, + ) + ) except Exception: # pragma: no cover - telemetry must not raise logger.debug("Failed to emit EmbeddingCallCompletedEvent", exc_info=True) diff --git a/src/llm/backend.py b/src/llm/backend.py index 5645998c..380911d2 100644 --- a/src/llm/backend.py +++ b/src/llm/backend.py @@ -14,7 +14,8 @@ class ToolCallResult: id: str name: str input: dict[str, Any] - thought_signature: str | None = None + # Gemini returns this as raw bytes; other providers omit it. + thought_signature: str | bytes | None = None @dataclass(slots=True) diff --git a/src/llm/capture.py b/src/llm/capture.py new file mode 100644 index 00000000..ff0d1b0e --- /dev/null +++ b/src/llm/capture.py @@ -0,0 +1,403 @@ +"""Structures for data captured from LLM calls via telemetry. + +All capture is best-effort: `dispatch_captured_call` swallows exporter exceptions +so telemetry can never break the LLM call path. +""" + +from __future__ import annotations + +import base64 +import contextlib +import hashlib +import json +import logging +from dataclasses import dataclass, field +from typing import Any, Protocol, cast, runtime_checkable + +from src.config import settings + +from .backend import CompletionResult as BackendCompletionResult +from .backend import ToolCallResult +from .types import LLMTelemetryContext + +logger = logging.getLogger(__name__) + +# Sentinel roles for non-message content stored in the shared content store so +# the same hash+dedup machinery covers them. They never collide with real +# conversation roles ("user"/"assistant"/"system"/"tool"). +ROLE_OUTPUT = "assistant" +ROLE_TOOL_SCHEMA = "__tool_schema__" +ROLE_THINKING = "__thinking__" + + +def canonical_json(obj: Any) -> str: + """Deterministic JSON encoding used for every content hash.""" + return json.dumps( + obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str + ) + + +def compute_content_hash( + role: str, + content: Any, + tool_call_id: str | None, + tool_calls: list[dict[str, Any]] | None = None, +) -> str: + """Content hash covering the FULL message identity, not just the text. + + Includes `tool_calls` so two assistant turns with identical (often empty) + content but different tool calls don't collide in the dedup store. + """ + digest = hashlib.sha256( + canonical_json( + { + "role": role, + "content": content, + "tool_call_id": tool_call_id, + "tool_calls": tool_calls or [], + } + ).encode("utf-8") + ).hexdigest() + return f"sha256:{digest}" + + +def clip_for_trace(content: Any) -> tuple[Any, bool]: + """Clip a content value to `TELEMETRY.TRACE_MAX_BYTES`, returning (content, truncated). + + Only oversized string content is clipped (with a marker); non-string + structured content is left intact. Returns the input unchanged when it + fits or when the cap is non-positive. + """ + max_bytes = settings.TELEMETRY.TRACE_MAX_BYTES + if max_bytes <= 0 or not isinstance(content, str): + return content, False + encoded = content.encode("utf-8") + if len(encoded) <= max_bytes: + return content, False + marker = "…[truncated]" + keep = max(0, max_bytes - len(marker.encode("utf-8"))) + clipped = encoded[:keep].decode("utf-8", errors="ignore") + marker + return clipped, True + + +@dataclass(slots=True) +class CapturedMessage: + """One input message, normalized to a provider-agnostic shape. + + `content` is the message text; `tool_calls` holds any tool calls in a + unified `{id, name, input}` shape regardless of provider. `content_hash` + covers all identity fields so the ref and the shipped `trace.content` agree. + """ + + role: str + content: Any + tool_call_id: str | None + content_hash: str + truncated: bool = False + tool_calls: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass(slots=True) +class CapturedLLMCall: + """Everything one LLM call needs to be reconstructed, captured once.""" + + # Correlation (span tree) + trace_id: str | None + span_id: str | None + parent_span_id: str | None + iteration: int | None + step_seq: int + attempt: int + was_fallback: bool + run_id: str | None + # Path identity + workspace_name: str | None + call_purpose: str | None + parent_category: str | None + agent_type: str | None + # unique session ID for grouping traces + session_id: str | None + observer: str | None + observed: str | None + peer_name: str | None + track_name: str | None + transport: str + provider_label: str | None + model: str + # Context window + input_messages: list[CapturedMessage] + tool_schemas: list[dict[str, Any]] + tool_choice: Any + # Output (replay-grade) + output_content: Any + output_tool_calls: list[dict[str, Any]] + thinking_content: str | None + thinking_blocks: list[dict[str, Any]] + reasoning_details: list[dict[str, Any]] + finish_reason: str | None + # Accounting copy (so the trace stream stands alone) + input_tokens: int + output_tokens: int + cache_read_tokens: int + cache_creation_tokens: int + was_stream: bool + # True when any input message was clipped to TRACE_MAX_BYTES. + input_truncated: bool = False + + +def _normalize_message( + message: dict[str, Any], transport: str | None +) -> tuple[Any, str | None, list[dict[str, Any]]]: + """Normalize a provider-native message to (content, tool_call_id, tool_calls). + + Providers stash tool calls and results outside `content` (openai's + `tool_calls`, gemini's `parts`), so a naive `content` read loses them. This + lifts them into a unified shape: `content` becomes text, `tool_calls` is a + list of `{id, name, input}`, and tool results surface as `content` keyed by + `tool_call_id`. + """ + content: Any = message.get("content") + tool_call_id: str | None = message.get("tool_call_id") + tool_calls: list[dict[str, Any]] = [] + + if transport == "openai": + for tc in cast("list[dict[str, Any]]", message.get("tool_calls") or []): + fn = cast("dict[str, Any]", tc.get("function") or {}) + args = fn.get("arguments") + if isinstance(args, str): + with contextlib.suppress(json.JSONDecodeError): + args = json.loads(args) + tool_calls.append( + {"id": tc.get("id"), "name": fn.get("name"), "input": args} + ) + + elif transport == "gemini": + parts = message.get("parts") + if isinstance(parts, list): + texts: list[str] = [] + results: list[Any] = [] + for raw_part in cast("list[Any]", parts): + if not isinstance(raw_part, dict): + continue + part = cast("dict[str, Any]", raw_part) + text = part.get("text") + if isinstance(text, str): + texts.append(text) + elif "function_call" in part: + fc = cast("dict[str, Any]", part["function_call"] or {}) + tool_calls.append( + {"id": None, "name": fc.get("name"), "input": fc.get("args")} + ) + elif "function_response" in part: + fr = cast("dict[str, Any]", part["function_response"] or {}) + resp = fr.get("response") + if isinstance(resp, dict): + results.append(cast("dict[str, Any]", resp).get("result")) + else: + results.append(resp) + if tool_call_id is None: + tool_call_id = fr.get("name") + content = "\n".join(texts) if texts else (results[0] if results else None) + + elif transport == "anthropic" and isinstance(content, list): + texts = [] + for raw_block in cast("list[Any]", content): + if not isinstance(raw_block, dict): + continue + block = cast("dict[str, Any]", raw_block) + btype = block.get("type") + text = block.get("text") + if btype == "text" and isinstance(text, str): + texts.append(text) + elif btype == "tool_use": + tool_calls.append( + { + "id": block.get("id"), + "name": block.get("name"), + "input": block.get("input"), + } + ) + elif btype == "tool_result": + if tool_call_id is None: + tool_call_id = block.get("tool_use_id") + inner = block.get("content") + texts.append(inner if isinstance(inner, str) else canonical_json(inner)) + content = "\n".join(texts) if texts else None + + return content, tool_call_id, tool_calls + + +def build_captured_messages( + messages: list[dict[str, Any]], + memo: dict[int, CapturedMessage] | None, + transport: str | None = None, +) -> tuple[list[CapturedMessage], bool]: + """Create a list of CapturedMessage from LLM response messages. + + Conversation is append-only. Uses hashed message content to deduplicate + across turns. Messages are normalized per provider, then content is + truncated and hashed. + """ + captured: list[CapturedMessage] = [] + any_truncated = False + for message in messages: + key = id(message) + cached = memo.get(key) if memo is not None else None + if cached is not None: + captured.append(cached) + any_truncated = any_truncated or cached.truncated + continue + role = str(message.get("role", "")) + raw_content, tool_call_id, tool_calls = _normalize_message(message, transport) + content, truncated = clip_for_trace(raw_content) + any_truncated = any_truncated or truncated + captured_message = CapturedMessage( + role=role, + content=content, + tool_call_id=tool_call_id, + content_hash=compute_content_hash(role, content, tool_call_id, tool_calls), + truncated=truncated, + tool_calls=tool_calls, + ) + if memo is not None: + memo[key] = captured_message + captured.append(captured_message) + return captured, any_truncated + + +def build_captured_call( + *, + telemetry: LLMTelemetryContext | None, + transport: str, + provider_label: str | None, + model: str, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + tool_choice: Any, + result: BackendCompletionResult | None, + attempt: int, + was_fallback: bool, + was_stream: bool, + finish_reason: str | None, +) -> CapturedLLMCall: + """Assemble a `CapturedLLMCall` from telemetry + the provider result.""" + memo = telemetry.hash_memo if telemetry is not None else None + captured_messages, input_truncated = build_captured_messages( + messages, memo, transport + ) + + output_tool_calls = [ + _tool_call_to_dict(tc) for tc in (result.tool_calls if result else []) + ] + + return CapturedLLMCall( + trace_id=telemetry.trace_id if telemetry else None, + span_id=telemetry.span_id if telemetry else None, + parent_span_id=telemetry.exported_parent_span_id() if telemetry else None, + iteration=telemetry.iteration if telemetry else None, + step_seq=telemetry.step_seq if telemetry else 0, + attempt=attempt, + was_fallback=was_fallback, + run_id=telemetry.run_id if telemetry else None, + workspace_name=telemetry.workspace_name if telemetry else None, + call_purpose=telemetry.call_purpose if telemetry else None, + parent_category=telemetry.parent_category if telemetry else None, + agent_type=telemetry.agent_type if telemetry else None, + session_id=telemetry.session_id if telemetry else None, + observer=telemetry.observer if telemetry else None, + observed=telemetry.observed if telemetry else None, + peer_name=telemetry.peer_name if telemetry else None, + track_name=telemetry.track_name if telemetry else None, + transport=transport, + provider_label=provider_label, + model=model, + input_messages=captured_messages, + tool_schemas=list(tools) if tools else [], + tool_choice=tool_choice, + output_content=result.content if result else None, + output_tool_calls=output_tool_calls, + thinking_content=result.thinking_content if result else None, + thinking_blocks=result.thinking_blocks if result else [], + reasoning_details=result.reasoning_details if result else [], + finish_reason=finish_reason, + input_tokens=result.input_tokens if result else 0, + output_tokens=result.output_tokens if result else 0, + cache_read_tokens=result.cache_read_input_tokens if result else 0, + cache_creation_tokens=result.cache_creation_input_tokens if result else 0, + was_stream=was_stream, + input_truncated=input_truncated, + ) + + +def _tool_call_to_dict(tool_call: ToolCallResult) -> dict[str, Any]: + """Normalize a ToolCallResult to a JSON-safe dict for the trace stream. + + `thought_signature` arrives as raw bytes from Gemini; base64-encode it so + CloudEvents JSON serialization can't choke on non-UTF8 bytes (which would + silently drop the whole event via the best-effort emit path). + """ + out: dict[str, Any] = { + "id": tool_call.id, + "name": tool_call.name, + "input": tool_call.input, + } + sig = tool_call.thought_signature + if sig is not None: + out["thought_signature"] = ( + base64.b64encode(sig).decode("ascii") if isinstance(sig, bytes) else sig + ) + return out + + +@runtime_checkable +class LLMCallExporter(Protocol): + """A sink that consumes a `CapturedLLMCall`""" + + def export(self, call: CapturedLLMCall) -> None: ... + + +_EXPORTERS: list[LLMCallExporter] = [] + + +def register_exporter(exporter: LLMCallExporter) -> None: + """Register an exporter (idempotent on identity). Called at startup.""" + if exporter not in _EXPORTERS: + _EXPORTERS.append(exporter) + + +def clear_exporters() -> None: + """Drop all exporters — used on shutdown and in tests.""" + _EXPORTERS.clear() + + +def has_exporters() -> bool: + """True when at least one exporter is registered.""" + return bool(_EXPORTERS) + + +def dispatch_captured_call(call: CapturedLLMCall) -> None: + """Fan a captured call out to every exporter.""" + for exporter in _EXPORTERS: + try: + exporter.export(call) + except Exception: # pragma: no cover - best-effort telemetry + logger.debug("LLM call exporter failed", exc_info=True) + + +__all__ = [ + "ROLE_OUTPUT", + "ROLE_THINKING", + "ROLE_TOOL_SCHEMA", + "CapturedLLMCall", + "CapturedMessage", + "LLMCallExporter", + "build_captured_call", + "build_captured_messages", + "canonical_json", + "clear_exporters", + "clip_for_trace", + "compute_content_hash", + "dispatch_captured_call", + "has_exporters", + "register_exporter", +] diff --git a/src/llm/executor.py b/src/llm/executor.py index 017ce669..956bc673 100644 --- a/src/llm/executor.py +++ b/src/llm/executor.py @@ -25,6 +25,7 @@ from src.telemetry.logging import conditional_observe from .backend import CompletionResult as BackendCompletionResult from .backend import StreamChunk as BackendStreamChunk from .backend import ToolCallResult +from .capture import build_captured_call, dispatch_captured_call, has_exporters from .registry import CLIENTS, backend_for_provider from .request_builder import execute_completion, execute_stream from .runtime import ( @@ -138,7 +139,7 @@ def _outcome_from_error( def _tool_call_result_to_dict(tool_call: ToolCallResult) -> dict[str, Any]: - result = { + result: dict[str, Any] = { "id": tool_call.id, "name": tool_call.name, "input": tool_call.input, @@ -189,7 +190,7 @@ def _emit_llm_call_completed( call_purpose=call_purpose, parent_category=(telemetry.parent_category if telemetry else None), transport=provider, - provider_label=_infer_provider_label(provider, model, plan), + provider_label=infer_provider_label(provider, model, plan), model=model, effective_max_output_tokens=max_tokens, provider_input_tokens=(result.input_tokens if result else 0), @@ -217,7 +218,7 @@ def _emit_llm_call_completed( logger.debug("Failed to emit LLMCallCompletedEvent", exc_info=True) -def _infer_provider_label( +def infer_provider_label( _transport: ModelTransport, model: str, plan: AttemptPlan | None ) -> str | None: """Best-effort vendor inference for relay setups. @@ -243,6 +244,49 @@ def _infer_provider_label( return None +def _maybe_dispatch_capture( + *, + plan: AttemptPlan | None, + telemetry: LLMTelemetryContext | None, + provider: ModelTransport, + model: str, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + tool_choice: Any, + result: BackendCompletionResult | None, + error: BaseException | None, +) -> None: + """Build a CapturedLLMCall and fan it out to registered exporters. + + No-op when payload capture is off + `has_exporters()` is checked BEFORE building. + Best-effort: never raises into the call path. + """ + if not has_exporters(): + return + try: + outcome = _outcome_from_error(error) + finish_reason = result.finish_reason if result is not None else outcome + dispatch_captured_call( + build_captured_call( + telemetry=telemetry, + transport=str(provider), + provider_label=infer_provider_label(provider, model, plan), + model=model, + messages=messages, + tools=tools, + tool_choice=tool_choice, + result=result, + attempt=plan.attempt if plan is not None else 1, + was_fallback=plan.is_fallback if plan is not None else False, + was_stream=False, + finish_reason=finish_reason, + ) + ) + except Exception: # pragma: no cover - best-effort telemetry + logger.debug("Failed to dispatch CapturedLLMCall", exc_info=True) + + def completion_result_to_response( result: BackendCompletionResult, ) -> HonchoLLMCallResponse[Any]: @@ -422,10 +466,11 @@ async def honcho_llm_call_inner( # Explicit generation input + tuning knobs (replaces @observe auto-capture, # which would serialize the live client / api key). Set before the stream - # branch so it lands on the generation span for both paths. Guard on the - # public key so we don't build the (model_dump-backed) payload when Langfuse - # is disabled — the annotate helper no-ops, but the payload still costs. - if settings.LANGFUSE_PUBLIC_KEY: + # branch so it lands on the generation span for both paths. Guard on inline + # mode (matching annotate_current_generation_io's own gate) so we don't + # build the (model_dump-backed) payload when the helper would no-op — in + # exporter mode there's no active generation span to stamp. + if settings.langfuse_inline_enabled: annotate_current_generation_io( input=messages, model_parameters=_langfuse_model_parameters( @@ -528,8 +573,7 @@ async def honcho_llm_call_inner( # Explicit generation output + token usage (replaces @observe # auto-capture). The stream path closes this span before drain, so its # output is stamped on the run-level span instead - # (StreamingResponseWithMetadata). - if settings.LANGFUSE_PUBLIC_KEY: + if settings.langfuse_inline_enabled: annotate_current_generation_io( output=response, usage_details=_langfuse_usage_details(response), @@ -552,6 +596,17 @@ async def honcho_llm_call_inner( result=backend_result, error=error, ) + _maybe_dispatch_capture( + plan=plan, + telemetry=telemetry, + provider=provider, + model=model, + messages=messages, + tools=tools, + tool_choice=tool_choice, + result=backend_result, + error=error, + ) __all__ = [ diff --git a/src/llm/runtime.py b/src/llm/runtime.py index ec07acf8..93e961c9 100644 --- a/src/llm/runtime.py +++ b/src/llm/runtime.py @@ -33,14 +33,6 @@ logger = logging.getLogger(__name__) # ContextVar tracking the current retry attempt for provider switching. current_attempt: ContextVar[int] = ContextVar("current_attempt", default=0) -# True while a `LangfuseAgentRun` handle is live (start → end). Set by -# `start_langfuse_agent_run`, reset by `LangfuseAgentRun.end`. Used by -# `annotate_current_langfuse_trace` to decide whether the current generation -# is the trace root (single-shot callers like the deriver — stamp trace attrs) -# or nested under an active run (multi-turn / streaming — skip trace attrs; -# the run span already carries them via `propagate_attributes`). -_in_agent_run: ContextVar[bool] = ContextVar("_in_agent_run", default=False) - def annotate_current_langfuse_trace( provider: ModelTransport, @@ -56,16 +48,16 @@ def annotate_current_langfuse_trace( callers — deriver, summarizer), this generation IS the trace root, so we also stamp the trace attrs. - Note: `model`/`metadata` are set on every call regardless of `inside_run` - so multi-turn iterations no longer lose provider/model attribution. + `model`/`metadata` are set on every call regardless of `inside_run`, so + every multi-turn iteration carries provider/model attribution. """ - if not settings.LANGFUSE_PUBLIC_KEY: + if not settings.langfuse_inline_enabled: return try: from langfuse import get_client, propagate_attributes - inside_run = _in_agent_run.get() + inside_run = telemetry is not None and telemetry.parent_span_id is not None gen_metadata = _step_metadata(telemetry) if telemetry is not None else {} gen_metadata["provider"] = str(provider) gen_metadata["model"] = str(model) @@ -76,7 +68,7 @@ def annotate_current_langfuse_trace( ) if not inside_run: - run_id = telemetry.run_id if telemetry is not None else None + session_id = telemetry.span_identity() if telemetry is not None else None trace_name = telemetry.track_name if telemetry is not None else None trace_metadata: dict[str, str] = dict(gen_metadata) if telemetry is None: @@ -87,7 +79,7 @@ def annotate_current_langfuse_trace( # dead code — the enter-time side effect is the point. with propagate_attributes( user_id=str(settings.NAMESPACE), - session_id=run_id, + session_id=session_id, trace_name=trace_name, metadata=trace_metadata, ): @@ -123,7 +115,10 @@ def annotate_current_generation_io( Best-effort: telemetry must never fail the LLM call. """ - if not settings.LANGFUSE_PUBLIC_KEY: + # Gated on inline mode (NOT just key presence): this writes to the *active* + # @observe generation span, which only exists in inline mode. In exporter + # mode `conditional_observe` applies no decorator. + if not settings.langfuse_inline_enabled: return payload: dict[str, Any] = {} if input is not None: @@ -157,6 +152,9 @@ def _base_metadata(telemetry: LLMTelemetryContext) -> dict[str, str]: ("observer", telemetry.observer), ("observed", telemetry.observed), ("peer_name", telemetry.peer_name), + ("trace_id", telemetry.trace_id), + ("span_id", telemetry.span_id), + ("parent_span_id", telemetry.exported_parent_span_id()), ): if value is not None: metadata[key] = str(value) @@ -167,10 +165,13 @@ def _step_metadata( telemetry: LLMTelemetryContext, base: dict[str, str] | None = None, ) -> dict[str, str]: - """Per-step metadata: ``base`` (or freshly computed) plus ``iteration``.""" + """Per-step metadata: ``base`` (or freshly computed) plus the per-step + ``iteration`` / ``step_seq`` / ``attempt`` counters.""" metadata = dict(base) if base is not None else _base_metadata(telemetry) if telemetry.iteration is not None: metadata["iteration"] = str(telemetry.iteration) + metadata["step_seq"] = str(telemetry.step_seq) + metadata["attempt"] = str(telemetry.attempt) return metadata @@ -191,7 +192,6 @@ class LangfuseAgentRun: span: Any # LangfuseSpan; opaque to keep src/llm/ free of langfuse imports. _stack: ExitStack - _run_token: Any _ended: bool = field(default=False) def update(self, **kwargs: Any) -> None: @@ -217,12 +217,6 @@ class LangfuseAgentRun: self._stack.close() except Exception as exc: # pragma: no cover - best-effort telemetry logger.debug("Failed to close Langfuse run span: %s", exc) - try: - _in_agent_run.reset(self._run_token) - except (ValueError, LookupError) as exc: # pragma: no cover - # ContextVar.reset can raise if end() runs in a different async - # context than start(); telemetry must not fail user code. - logger.debug("Failed to reset _in_agent_run: %s", exc) def start_langfuse_agent_run( @@ -230,13 +224,16 @@ def start_langfuse_agent_run( ) -> LangfuseAgentRun | None: """Open the one run-level Langfuse trace per agentic run, imperatively. - Returns ``None`` when Langfuse is disabled or there's no ``run_id`` - (single-shot callers — those self-stamp via - ``annotate_current_langfuse_trace``). When non-None, the caller MUST + Returns ``None`` when Langfuse is disabled or there's no span identity + (single-shot callers without a ``span_id``/``run_id`` — those self-stamp + via ``annotate_current_langfuse_trace``). When non-None, the caller MUST eventually call ``.end()`` — typically in a ``finally`` block, or by transferring ownership to the streaming wrapper. """ - if not settings.LANGFUSE_PUBLIC_KEY or telemetry is None or not telemetry.run_id: + if not settings.langfuse_inline_enabled or telemetry is None: + return None + session_id = telemetry.span_identity() + if not session_id: return None stack = ExitStack() try: @@ -248,7 +245,7 @@ def start_langfuse_agent_run( stack.enter_context( propagate_attributes( user_id=str(settings.NAMESPACE), - session_id=telemetry.run_id, + session_id=session_id, trace_name=name, metadata=_base_metadata(telemetry), ) @@ -258,8 +255,7 @@ def start_langfuse_agent_run( stack.close() return None - run_token = _in_agent_run.set(True) - return LangfuseAgentRun(span=span, _stack=stack, _run_token=run_token) + return LangfuseAgentRun(span=span, _stack=stack) @dataclass @@ -328,9 +324,11 @@ def start_langfuse_agent_step( name: str, telemetry: LLMTelemetryContext | None ) -> LangfuseAgentStep | None: """Open a per-iteration step span, imperatively. Returns ``None`` when - Langfuse is disabled or there's no ``run_id`` (no agent run to nest under). + Langfuse is disabled or there's no span identity (no agent run to nest under). """ - if not settings.LANGFUSE_PUBLIC_KEY or telemetry is None or not telemetry.run_id: + if not settings.langfuse_inline_enabled or telemetry is None: + return None + if not telemetry.span_identity(): return None stack = ExitStack() try: diff --git a/src/llm/tool_loop.py b/src/llm/tool_loop.py index 34fdba26..0feec1d8 100644 --- a/src/llm/tool_loop.py +++ b/src/llm/tool_loop.py @@ -30,7 +30,12 @@ from src.utils.types import ( set_last_tool_metadata, ) -from .executor import honcho_llm_call_inner +from .capture import ( + build_captured_call, + dispatch_captured_call, + has_exporters, +) +from .executor import honcho_llm_call_inner, infer_provider_label from .registry import history_adapter_for_provider from .runtime import ( AttemptPlan, @@ -81,17 +86,67 @@ def _step_label(base: LLMTelemetryContext | None) -> str: def _telemetry_for_iteration( - base: LLMTelemetryContext | None, iteration: int + base: LLMTelemetryContext | None, + iteration: int, + *, + step_seq: int, ) -> LLMTelemetryContext | None: - """Return a copy of `base` with `iteration` set, or None if no base. + """Return a copy of `base` with per-step correlation set, or None if no base. We always copy rather than mutate the caller-supplied context so callers that pass the same context into multiple `honcho_llm_call` invocations - don't see drift across concurrent runs. + don't see drift across concurrent runs. `parent_span_id` is set to the + span being looped over (`base.span_id`) so nested generations are correctly + treated as children of the run span. """ if base is None: return None - return dataclasses.replace(base, iteration=iteration) + return dataclasses.replace( + base, + iteration=iteration, + step_seq=step_seq, + parent_span_id=base.span_identity(), + ) + + +def _make_stream_capture_finalizer( + telemetry: LLMTelemetryContext | None, + plan: AttemptPlan, + messages: list[dict[str, Any]], +) -> Callable[[str, str], None] | None: + """Build the streamed-call capture finalizer, or None when capture is off. + + Snapshots the input messages now and returns a closure the streaming wrapper + calls on drain with `(streamed_text, finish_reason)`. Tool calls already ran + in the loop, so the final streamed turn is text-only. Returns None when no + exporter is registered. + """ + if not has_exporters(): + return None + captured_messages = list(messages) + + def _finalize(text: str, finish_reason: str) -> None: + from .backend import CompletionResult as BackendCompletionResult + + result = BackendCompletionResult(content=text, finish_reason=finish_reason) + dispatch_captured_call( + build_captured_call( + telemetry=telemetry, + transport=str(plan.provider), + provider_label=infer_provider_label(plan.provider, plan.model, plan), + model=plan.model, + messages=captured_messages, + tools=None, + tool_choice=None, + result=result, + attempt=plan.attempt, + was_fallback=plan.is_fallback, + was_stream=True, + finish_reason=finish_reason, + ) + ) + + return _finalize def _emit_agent_iteration( @@ -328,6 +383,12 @@ async def execute_tool_loop( messages.copy() if messages else [{"role": "user", "content": prompt}] ) + # Seed one hash memo for the whole span. dataclasses.replace copies the dict reference into + # every per-iteration telemetry copy, so each appended message is content-hashed exactly once + # across the span. + if telemetry is not None and telemetry.hash_memo is None: + telemetry = dataclasses.replace(telemetry, hash_memo={}) + iteration = 0 all_tool_calls: list[dict[str, Any]] = [] total_input_tokens = 0 @@ -349,7 +410,7 @@ async def execute_tool_loop( while iteration < max_tool_iterations: step = start_langfuse_agent_step( _step_label(telemetry), - _telemetry_for_iteration(telemetry, iteration + 1), + _telemetry_for_iteration(telemetry, iteration + 1, step_seq=iteration + 1), ) try: # Reset attempt counter so each iteration starts with the primary provider. @@ -392,7 +453,9 @@ async def execute_tool_loop( messages=captured_messages, selected_config=plan.selected_config, plan=plan, - telemetry=_telemetry_for_iteration(telemetry, iteration_for_call), + telemetry=_telemetry_for_iteration( + telemetry, iteration_for_call, step_seq=iteration_for_call + ), ) call_func: Callable[[], Awaitable[HonchoLLMCallResponse[Any]]] @@ -453,6 +516,13 @@ async def execute_tool_loop( # pin to this exact client/model so we don't bounce back to # primary after the tool loop settled on fallback. winning_plan = get_attempt_plan() + # +2 (not +1): the in-loop call we just made used iteration+1, + # so the streamed tail needs the next ordinal — otherwise its + # trace resource id collides with that call's. Mirrors the + # synthesis path's distinct-next-value behavior. + stream_telemetry = _telemetry_for_iteration( + telemetry, iteration + 2, step_seq=iteration + 2 + ) stream = stream_final_response( winning_plan=winning_plan, prompt=prompt, @@ -466,7 +536,7 @@ async def execute_tool_loop( enable_retry=enable_retry, retry_attempts=retry_attempts, before_retry_callback=before_retry_callback, - telemetry=_telemetry_for_iteration(telemetry, iteration + 1), + telemetry=stream_telemetry, ) return StreamingResponseWithMetadata( stream=stream, @@ -479,6 +549,9 @@ async def execute_tool_loop( iterations=iteration + 1, hit_input_token_cap=hit_input_token_cap, langfuse_run_handle=langfuse_run_handle, + capture_finalizer=_make_stream_capture_finalizer( + stream_telemetry, winning_plan, conversation_messages + ), ) response.tool_calls_made = all_tool_calls @@ -612,6 +685,9 @@ async def execute_tool_loop( # Snapshot the plan the loop settled on — streaming retries pin to # this exact client/model rather than re-running provider selection. winning_plan = get_attempt_plan() + stream_telemetry = _telemetry_for_iteration( + telemetry, synthesis_iteration, step_seq=synthesis_iteration + ) stream = stream_final_response( winning_plan=winning_plan, prompt=prompt, @@ -625,7 +701,7 @@ async def execute_tool_loop( enable_retry=enable_retry, retry_attempts=retry_attempts, before_retry_callback=before_retry_callback, - telemetry=_telemetry_for_iteration(telemetry, synthesis_iteration), + telemetry=stream_telemetry, ) return StreamingResponseWithMetadata( stream=stream, @@ -638,6 +714,9 @@ async def execute_tool_loop( iterations=iteration + 1, hit_input_token_cap=hit_input_token_cap, langfuse_run_handle=langfuse_run_handle, + capture_finalizer=_make_stream_capture_finalizer( + stream_telemetry, winning_plan, conversation_messages + ), ) current_attempt.set(1) @@ -663,7 +742,9 @@ async def execute_tool_loop( messages=conversation_messages, selected_config=plan.selected_config, plan=plan, - telemetry=_telemetry_for_iteration(telemetry, synthesis_iteration), + telemetry=_telemetry_for_iteration( + telemetry, synthesis_iteration, step_seq=synthesis_iteration + ), ) if enable_retry: @@ -680,7 +761,9 @@ async def execute_tool_loop( # trace. Imperative pair with a try/finally for the .end(). synthesis_step = start_langfuse_agent_step( _step_label(telemetry), - _telemetry_for_iteration(telemetry, synthesis_iteration), + _telemetry_for_iteration( + telemetry, synthesis_iteration, step_seq=synthesis_iteration + ), ) try: final_response = await final_call_func() @@ -692,7 +775,9 @@ async def execute_tool_loop( # totals onto final_response below — otherwise the event's per-iteration # token counts would double-count the running totals. _emit_agent_iteration( - _telemetry_for_iteration(telemetry, synthesis_iteration), + _telemetry_for_iteration( + telemetry, synthesis_iteration, step_seq=synthesis_iteration + ), synthesis_iteration, final_response, ) diff --git a/src/llm/types.py b/src/llm/types.py index 8b394dd3..33058e09 100644 --- a/src/llm/types.py +++ b/src/llm/types.py @@ -6,15 +6,22 @@ of the migration toward src/llm/ owning all non-embedding LLM orchestration. from __future__ import annotations +import asyncio +import logging from collections.abc import AsyncIterator, Callable -from dataclasses import dataclass -from typing import Any, Generic, Literal, TypeVar +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar from anthropic import AsyncAnthropic from google import genai from openai import AsyncOpenAI from pydantic import BaseModel, Field +if TYPE_CHECKING: + from src.llm.capture import CapturedMessage + +logger = logging.getLogger(__name__) + T = TypeVar("T") # OpenAI GPT-5 specific reasoning levels. @@ -65,12 +72,23 @@ class LLMTelemetryContext: parent_category: str | None = None run_id: str | None = None iteration: int | None = None + # OpenTelemetry-style span-tree correlation. + trace_id: str | None = None + span_id: str | None = None + parent_span_id: str | None = None + # Monotonic executor-call ordinal WITHIN a span (total ordering of its + # steps). + step_seq: int = 0 + # Retry/fallback attempt within an iteration. + attempt: int = 1 # Optional peer context (dream agents pass observer/observed; dialectic # passes peer_name). Kept here so AgentIterationEvent can populate # them without a separate threading path. observer: str | None = None observed: str | None = None peer_name: str | None = None + # Used to group traces (should not use session_name because it is not unique) + session_id: str | None = None # Tool-related context: agent_type is the human-readable identifier of the # agent — dialectic/deduction/induction. Used by agent iteration # event and tool call event. @@ -81,6 +99,19 @@ class LLMTelemetryContext: # Also used to label the sentry `ai_track` decorator and as the source for # the run-level `langfuse_agent_run` label. track_name: str | None = None + # Per-span memo for O(N) message capture in CapturedLLMCall + hash_memo: dict[int, CapturedMessage] | None = field( + default=None, compare=False, repr=False + ) + + def span_identity(self) -> str | None: + """Effective span id: the new `span_id`, falling back to legacy `run_id`.""" + return self.span_id or self.run_id + + def exported_parent_span_id(self) -> str | None: + """`parent_span_id` for EXPORT, collapsing the self-parent sentinel to None.""" + pid = self.parent_span_id + return None if pid is not None and pid == self.span_id else pid IterationCallback = Callable[[IterationData], None] @@ -147,6 +178,13 @@ class StreamingResponseWithMetadata: as the run span's output and the span is closed. Without this transfer, streaming traces would show blank output because the synchronous return happens before any chunks arrive. + + `capture_finalizer` (optional) closes the replay-grade content capture for + a streamed call. The synchronous return happens before any chunks arrive, + so the streamed text only exists once the stream drains — the wrapper calls + the finalizer with `(accumulated_text, finish_reason)` in its `finally`. + A partial/aborted stream still finalizes, with `finish_reason` = + "cancelled"/"error". """ _stream: AsyncIterator[HonchoLLMCallStreamChunk] @@ -159,6 +197,7 @@ class StreamingResponseWithMetadata: iterations: int hit_input_token_cap: bool _langfuse_run_handle: Any | None + _capture_finalizer: Callable[[str, str], None] | None def __init__( self, @@ -172,6 +211,7 @@ class StreamingResponseWithMetadata: iterations: int = 0, hit_input_token_cap: bool = False, langfuse_run_handle: Any | None = None, + capture_finalizer: Callable[[str, str], None] | None = None, ): self._stream = stream self.tool_calls_made = tool_calls_made @@ -183,6 +223,7 @@ class StreamingResponseWithMetadata: self.iterations = iterations self.hit_input_token_cap = hit_input_token_cap self._langfuse_run_handle = langfuse_run_handle + self._capture_finalizer = capture_finalizer def __aiter__(self) -> AsyncIterator[HonchoLLMCallStreamChunk]: # Wrap the underlying iterator to capture final-stream output_tokens @@ -196,16 +237,23 @@ class StreamingResponseWithMetadata: self, ) -> AsyncIterator[HonchoLLMCallStreamChunk]: final_stream_output_tokens = 0 - # Only accumulate when a Langfuse run handle is attached — for non- - # traced streams the buffer is dead weight. - accumulate = self._langfuse_run_handle is not None + # Accumulate the streamed text when either consumer needs it: the + # Langfuse run span (stamped as output on drain) or the content-capture + # finalizer. + accumulate = ( + self._langfuse_run_handle is not None or self._capture_finalizer is not None + ) accumulated_text: list[str] = [] + last_finish_reason: str | None = None + stream_error: BaseException | None = None try: async for chunk in self._stream: if chunk.output_tokens is not None: # Take the LATEST value, not the sum — providers report # the cumulative usage in the final chunk, not deltas. final_stream_output_tokens = chunk.output_tokens + if chunk.finish_reasons: + last_finish_reason = chunk.finish_reasons[-1] if accumulate and chunk.content: accumulated_text.append(chunk.content) yield chunk @@ -214,14 +262,36 @@ class StreamingResponseWithMetadata: # see the true cost. if final_stream_output_tokens > 0: self.output_tokens += final_stream_output_tokens + except BaseException as exc: + stream_error = exc + raise finally: + text = "".join(accumulated_text) # Close the run span once, stamping the streamed text as its # output. In `finally` so an early-exit caller still closes # the span rather than leaking it. handle = self._langfuse_run_handle if handle is not None: self._langfuse_run_handle = None - handle.end(output="".join(accumulated_text) or None) + handle.end(output=text or None) + # Finalize the content capture with the full streamed text. Even a + # partial/aborted stream captures, tagged with the right outcome. + finalizer = self._capture_finalizer + if finalizer is not None: + self._capture_finalizer = None + finish_reason = ( + (last_finish_reason or "stop") + if stream_error is None + else ( + "cancelled" + if isinstance(stream_error, asyncio.CancelledError) + else "error" + ) + ) + try: + finalizer(text, finish_reason) + except Exception: # pragma: no cover - best-effort telemetry + logger.debug("Stream capture finalizer failed", exc_info=True) __all__ = [ diff --git a/src/telemetry/__init__.py b/src/telemetry/__init__.py index 68e79773..03cc9e0c 100644 --- a/src/telemetry/__init__.py +++ b/src/telemetry/__init__.py @@ -42,6 +42,8 @@ async def initialize_telemetry_async() -> None: from src.config import settings from src.telemetry.events import initialize_telemetry_events + # Master switch for every trace sink, Langfuse included: telemetry off + # initializes nothing. if settings.TELEMETRY.ENABLED: await initialize_telemetry_events() diff --git a/src/telemetry/emitter.py b/src/telemetry/emitter.py index 2413c7c2..201d16aa 100644 --- a/src/telemetry/emitter.py +++ b/src/telemetry/emitter.py @@ -100,6 +100,7 @@ class TelemetryEmitter: max_retries: int max_buffer_size: int enabled: bool + drop_reason_prefix: str _buffer: deque[CloudEvent] _flush_task: asyncio.Task[None] | None _client: httpx.AsyncClient | None @@ -118,6 +119,7 @@ class TelemetryEmitter: max_retries: int = 3, max_buffer_size: int = 10000, enabled: bool = True, + drop_reason_prefix: str = "", ): """Initialize the telemetry emitter. @@ -130,6 +132,9 @@ class TelemetryEmitter: max_retries: Maximum retry attempts on failure max_buffer_size: Maximum events to buffer (oldest dropped if exceeded) enabled: Whether emission is enabled + drop_reason_prefix: Prefix for the dropped-event metric reason label + (e.g. "trace_") so a second emitter's drops are distinguishable + from the primary metrics emitter's in Prometheus. """ self.endpoint = endpoint self.headers = headers or {} @@ -139,6 +144,7 @@ class TelemetryEmitter: self.max_retries = max_retries self.max_buffer_size = max_buffer_size self.enabled = enabled and endpoint is not None + self.drop_reason_prefix = drop_reason_prefix self._buffer = deque(maxlen=max_buffer_size) self._flush_task = None @@ -292,7 +298,9 @@ class TelemetryEmitter: cloud_event = CloudEvent(attributes, body) if will_drop_oldest: - prometheus_metrics.record_telemetry_event_dropped(reason="buffer_full") + prometheus_metrics.record_telemetry_event_dropped( + reason=f"{self.drop_reason_prefix}buffer_full" + ) self._buffer.append(cloud_event) buffer_size = len(self._buffer) @@ -375,7 +383,7 @@ class TelemetryEmitter: for event in reversed(batch): if len(self._buffer) >= self.max_buffer_size: prometheus_metrics.record_telemetry_event_dropped( - reason="send_failed" + reason=f"{self.drop_reason_prefix}send_failed" ) self._buffer.appendleft(event) logger.warning( @@ -527,3 +535,54 @@ async def shutdown_emitter() -> None: if _emitter is not None: await _emitter.shutdown() _emitter = None + + +# Separate emitter for the full-fidelity trace stream (llm.call.traced / +# trace.content). Kept distinct from the metrics `_emitter` so a trace burst +# can never evict billing events from the metrics buffer. +_trace_emitter: TelemetryEmitter | None = None + + +def get_trace_emitter() -> TelemetryEmitter | None: + """Get the global trace-stream emitter instance (None when payload tracing off).""" + return _trace_emitter + + +async def initialize_trace_emitter( + endpoint: str | None = None, + headers: dict[str, str] | None = None, + batch_size: int = 100, + flush_interval_seconds: float = 1.0, + flush_threshold: int = 50, + max_retries: int = 3, + max_buffer_size: int = 10000, + enabled: bool = True, +) -> TelemetryEmitter: + """Initialize and start the global trace-stream emitter. + + Drops are recorded under the ``trace_`` reason prefix so they're + distinguishable from the metrics emitter's drops in Prometheus. + """ + global _trace_emitter + + _trace_emitter = TelemetryEmitter( + endpoint=endpoint, + headers=headers, + batch_size=batch_size, + flush_interval_seconds=flush_interval_seconds, + flush_threshold=flush_threshold, + max_retries=max_retries, + max_buffer_size=max_buffer_size, + enabled=enabled, + drop_reason_prefix="trace_", + ) + await _trace_emitter.start() + return _trace_emitter + + +async def shutdown_trace_emitter() -> None: + """Shutdown the global trace-stream emitter.""" + global _trace_emitter + if _trace_emitter is not None: + await _trace_emitter.shutdown() + _trace_emitter = None diff --git a/src/telemetry/events/__init__.py b/src/telemetry/events/__init__.py index 3da745dd..000e70b4 100644 --- a/src/telemetry/events/__init__.py +++ b/src/telemetry/events/__init__.py @@ -89,6 +89,11 @@ from src.telemetry.events.reconciliation import ( SyncVectorsCompletedEvent, ) from src.telemetry.events.representation import RepresentationCompletedEvent +from src.telemetry.events.trace import ( + EmbeddingCallTracedEvent, + LLMCallTracedEvent, + TraceContentEvent, +) logger = logging.getLogger(__name__) @@ -120,6 +125,11 @@ __all__ = [ "CallPurpose", "EmbeddingCallCompletedEvent", "EmbeddingCallPurpose", + # Trace (full-fidelity payload) events + "emit_trace", + "EmbeddingCallTracedEvent", + "LLMCallTracedEvent", + "TraceContentEvent", # Reconciliation events "SyncVectorsCompletedEvent", "CleanupStaleItemsCompletedEvent", @@ -172,6 +182,27 @@ def emit(event: BaseEvent) -> None: ) +def emit_trace(event: BaseEvent) -> None: + """Queue a payload-trace event on the SEPARATE trace emitter. + + Distinct from `emit()` so a trace burst can never evict billing events from + the metrics buffer. No-op when payload tracing is off (trace emitter None). + Best-effort — swallows failures so telemetry never breaks the LLM path. + """ + try: + from src.telemetry.emitter import get_trace_emitter + + emitter = get_trace_emitter() + if emitter is None: + logger.debug("Trace emitter not initialized, dropping trace event") + return + emitter.emit(event) + except Exception: # pragma: no cover - best-effort telemetry + logger.debug( + "Failed to emit trace event %s", type(event).__name__, exc_info=True + ) + + async def initialize_telemetry_events() -> None: """Initialize the telemetry events system based on configuration. @@ -188,6 +219,17 @@ async def initialize_telemetry_events() -> None: logger.info("CloudEvents telemetry disabled") return + # Langfuse as a projection over the captured LLM stream. Gated behind the + # telemetry master switch above, so disabling telemetry disables Langfuse + # too. Registering the exporter makes has_exporters() true, which is what + # turns on CapturedLLMCall building. + if settings.langfuse_exporter_enabled: + from src.llm.capture import register_exporter + from src.telemetry.langfuse_exporter import LangfuseExporter + + register_exporter(LangfuseExporter()) + logger.info("Langfuse exporter registered (LANGFUSE_EXPORTER_MODE=exporter)") + await initialize_emitter( endpoint=settings.TELEMETRY.ENDPOINT, headers=settings.TELEMETRY.HEADERS, @@ -203,6 +245,27 @@ async def initialize_telemetry_events() -> None: "CloudEvents telemetry initialized, endpoint: %s", settings.TELEMETRY.ENDPOINT ) + # Full-fidelity payload tracing — opt-in, separate emitter + content exporter. + if settings.TELEMETRY.TRACE_PAYLOADS_ENABLED: + from src.llm.capture import register_exporter + from src.telemetry.emitter import initialize_trace_emitter + from src.telemetry.trace_exporter import TraceExporter + + await initialize_trace_emitter( + endpoint=settings.TELEMETRY.ENDPOINT, + headers=settings.TELEMETRY.HEADERS, + batch_size=settings.TELEMETRY.BATCH_SIZE, + flush_interval_seconds=settings.TELEMETRY.FLUSH_INTERVAL_SECONDS, + flush_threshold=settings.TELEMETRY.FLUSH_THRESHOLD, + max_retries=settings.TELEMETRY.MAX_RETRIES, + max_buffer_size=settings.TELEMETRY.MAX_BUFFER_SIZE, + enabled=True, + ) + register_exporter(TraceExporter()) + logger.info( + "Payload tracing initialized, endpoint: %s", settings.TELEMETRY.ENDPOINT + ) + async def shutdown_telemetry_events() -> None: """Shutdown the telemetry events system. @@ -210,7 +273,16 @@ async def shutdown_telemetry_events() -> None: This should be called during application shutdown to ensure all buffered events are flushed before exit. """ - from src.telemetry.emitter import shutdown_emitter + # Tear down the trace path first (flush its buffer, drop exporters + dedup + # state) before the primary emitter, so a late capture can't re-register work. + from src.llm.capture import clear_exporters + from src.telemetry import langfuse_session, trace_session + from src.telemetry.emitter import shutdown_emitter, shutdown_trace_emitter + + clear_exporters() + await shutdown_trace_emitter() + trace_session.reset() + langfuse_session.reset() await shutdown_emitter() logger.info("CloudEvents telemetry shutdown complete") diff --git a/src/telemetry/events/agent.py b/src/telemetry/events/agent.py index df771709..95145608 100644 --- a/src/telemetry/events/agent.py +++ b/src/telemetry/events/agent.py @@ -191,12 +191,18 @@ class AgentToolSummaryCreatedEvent(BaseEvent): """ _event_type: ClassVar[str] = "agent.tool.summary.created" - _schema_version: ClassVar[int] = 2 + _schema_version: ClassVar[int] = 3 _category: ClassVar[str] = "agent" - # Run identification (may be placeholder if not from an agentic loop) - run_id: str = Field(..., description="Nanoid for run correlation") - iteration: int = Field(..., description="Iteration number when this occurred") + # Run identification. + run_id: str | None = Field( + default=None, + description="Run id for agentic correlation; None when not in a run", + ) + iteration: int | None = Field( + default=None, + description="Iteration within an agentic loop; None when not in one", + ) # Context parent_category: str = Field(..., description="Parent category") @@ -258,8 +264,9 @@ class AgentToolSummaryCreatedEvent(BaseEvent): ) def get_resource_id(self) -> str: - """Resource ID includes run_id and iteration for uniqueness.""" - return f"{self.run_id}:{self.iteration}:summary_created" + """Idempotency key. A summary is unique per (message it covers up to, + tier)""" + return f"{self.message_id}:{self.summary_type}:summary_created" class AgentToolCallCompletedEvent(BaseEvent): diff --git a/src/telemetry/events/trace.py b/src/telemetry/events/trace.py new file mode 100644 index 00000000..37383bc0 --- /dev/null +++ b/src/telemetry/events/trace.py @@ -0,0 +1,161 @@ +"""Replay-grade payload events for full-fidelity LLM tracing. + +Two ground-truth (never-sampled) events that make the CloudEvents stream carry +the exact context a model saw, content-addressed to keep payload O(N): + +- ``LLMCallTracedEvent`` (``llm.call.traced``) — one per LLM call. Carries + span-tree correlation, path identity, content *references* (hashes, not bytes) + for the context window and the replay-grade output, plus a self-contained + accounting copy. It deliberately does NOT claim a join to + ``llm.call.completed`` — cost is computable from the trace alone, so the + billing and audit streams stay decoupled. +- ``TraceContentEvent`` (``trace.content``) — one per unique message, emitted + once per run and referenced by hash. ``content_hash`` covers the full message + identity ({role, content, tool_call_id}) so identical text under different + roles can't collide. ``generate_id()`` is overridden to derive the CloudEvent + id from the hash with NO timestamp, so accidental re-sends of identical + content dedupe at the transport layer too. +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import Field + +from src.config import ModelTransport +from src.telemetry.events.base import BaseEvent + +__all__ = ["EmbeddingCallTracedEvent", "LLMCallTracedEvent", "TraceContentEvent"] + + +class LLMCallTracedEvent(BaseEvent): + """One replay-grade record per LLM call. Ground-truth, never sampled. + + Content fields are *references* (content hashes) into the ``trace.content`` + store, never inline bytes + """ + + _event_type: ClassVar[str] = "llm.call.traced" + _schema_version: ClassVar[int] = 1 + _category: ClassVar[str] = "trace" + _volume_class: ClassVar[str] = "ground_truth" + + # --- Correlation (span tree) --- + trace_id: str | None = None + span_id: str | None = None + parent_span_id: str | None = None + iteration: int | None = None + step_seq: int = 0 + attempt: int = 1 + was_fallback: bool = False + parent_event_id: str | None = None + + # --- Path identity --- + call_purpose: str | None = None + parent_category: str | None = None + # Used for grouping traces + session_id: str | None = None + transport: ModelTransport + provider_label: str | None = None + model: str + + # --- Context window (content-addressed) --- + input_message_refs: list[str] = Field(default_factory=list) + system_prompt_ref: str | None = None + tool_schema_refs: list[str] = Field(default_factory=list) + tool_choice: Any = None + + # --- Output (replay-grade) --- + output_content_ref: str | None = None + output_tool_calls: list[dict[str, Any]] = Field(default_factory=list) + output_thinking_ref: str | None = None + output_signatures: list[str] = Field(default_factory=list) + # Reserved: Honcho captures the normalized request/response, not wire bytes. + raw_response_ref: str | None = None + finish_reason: str | None = None + + # --- Accounting copy (stream stands alone; NOT joined to llm.call.completed) --- + provider_input_tokens: int = 0 + provider_output_tokens: int = 0 + cache_read_tokens: int = 0 + cache_creation_tokens: int = 0 + was_truncated: bool = False + + def get_resource_id(self) -> str: + """Idempotency key. ``tool_call_seq`` is deliberately absent — it indexed + tool *executions*, not LLM calls, and was never a valid join field.""" + return f"{self.span_id}:{self.iteration}:{self.attempt}:{self.step_seq}" + + +class EmbeddingCallTracedEvent(BaseEvent): + """One trace-stream record per embedding-provider call.""" + + _event_type: ClassVar[str] = "embedding.call.traced" + _schema_version: ClassVar[int] = 1 + _category: ClassVar[str] = "trace" + _volume_class: ClassVar[str] = "ground_truth" + + # --- Correlation (span tree) --- + trace_id: str | None = None + span_id: str | None = None + parent_span_id: str | None = None + iteration: int | None = None + step_seq: int = 0 + attempt: int = 1 + session_id: str | None = None + + # --- Path identity --- + call_purpose: str | None = None + parent_category: str | None = None + provider: str + model: str + + # --- Accounting copy --- + # v1: input tokens are a tiktoken ESTIMATE (no authoritative provider count is + # plumbed yet); output tokens are always 0 (embeddings produce none). + provider_input_tokens: int = 0 + provider_output_tokens: int = 0 + input_count: int = 0 + was_truncated: bool = False + + def get_resource_id(self) -> str: + return f"{self.span_id}:embedding:{self.call_purpose}:{self.input_count}" + + +class TraceContentEvent(BaseEvent): + """One unique message in the content store. Ground-truth, never sampled. + + The hash covers the full message identity, and the event id derives from the + hash with no timestamp. + """ + + _event_type: ClassVar[str] = "trace.content" + _schema_version: ClassVar[int] = 1 + _category: ClassVar[str] = "trace" + _volume_class: ClassVar[str] = "ground_truth" + + content_hash: str + role: str + # Message text, normalized across providers. + content: Any = None + tool_call_id: str | None = None + # Tool calls in a unified {id, name, input} shape (provider-agnostic). + tool_calls: list[dict[str, Any]] = Field(default_factory=list) + # Tags Honcho-authored content (system prompts, scaffold) so tenant-facing + # views can withhold globally-shared content (the §6.3 access invariant — + # dedup is global, the content store has no tenant column). + honcho_authored: bool = False + + def get_resource_id(self) -> str: + return self.content_hash + + def generate_id(self) -> str: + """Content-addressed id with NO timestamp/version. + + Overrides the base (which folds timestamp + honcho_version) so any + cross-process or cross-retry re-send of the same content collides on + the same id and dedupes at the transport layer. + """ + digest = self.content_hash.split(":", 1)[-1] + return f"content_{digest[:22]}" diff --git a/src/telemetry/langfuse_exporter.py b/src/telemetry/langfuse_exporter.py new file mode 100644 index 00000000..e66eb8ac --- /dev/null +++ b/src/telemetry/langfuse_exporter.py @@ -0,0 +1,398 @@ +"""Langfuse projection over the captured LLM trace stream. + +`LangfuseExporter` is an `LLMCallExporter`, active when +`LANGFUSE_EXPORTER_MODE == "exporter"`. It receives one `CapturedLLMCall` at a +time and rebuilds the Langfuse trace tree from the ids on each call, since there +is no live span nesting to inherit: + + Trace (id = create_trace_id(seed=honcho trace_id)) + └─ [dream root] (multi-specialist agents only — one "Dream" span per trace) + └─ run span (one per (run_id, agent_type); name = track_name) + └─ step span (one per (agent_type, iteration); name = " step") + ├─ generation (one per CapturedLLMCall; name = " generation") + └─ tool span (one per requested tool call; sibling of generation) + +Run and step spans are created once per trace and reused as the `parent_span_id` +of later calls (tracked in `langfuse_session`). Single-shot callers +(deriver/summarizer, `run_id is None`) skip the run/step wrappers and put the +generation at the trace root. + +The Dreamer runs two specialists (deduction + induction) under one run_id, so its +branches hang off a single synthetic "Dream" root to keep the trace +single-rooted; single-specialist agents (dialectic) let their run span be the +root. Keeping exactly one root is also why child spans are demoted from the SDK's +auto-root flag (see `_demote_from_root`). + +Best-effort throughout: every export is wrapped so telemetry can never break the +LLM call path. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from src.config import settings +from src.llm.capture import CapturedLLMCall +from src.telemetry import langfuse_session + +logger = logging.getLogger(__name__) + +# finish_reason values that mark the generation as failed. +_ERROR_FINISHES = frozenset({"error", "cancelled"}) + + +class LangfuseExporter: + """`LLMCallExporter` that projects captured calls onto Langfuse traces.""" + + def export(self, call: CapturedLLMCall) -> None: + if not settings.langfuse_exporter_enabled: + return + try: + self._export(call) + except Exception: # pragma: no cover - best-effort telemetry + logger.debug("Langfuse exporter failed", exc_info=True) + + def _export(self, call: CapturedLLMCall) -> None: + from langfuse import get_client + + client = get_client() + seed = call.trace_id or call.run_id or call.span_id + if not seed: + return + lf_trace_id = client.create_trace_id(seed=seed) + + # Agentic runs (run_id set: dialectic / dreamer) get a run span and + # per-iteration step spans; single-shot calls put the generation at root. + # Branch = agent_type so co-trace specialists (dreamer) don't collide. + parent_span_id: str | None = None + if call.run_id is not None: + branch = call.agent_type or "_" + # Multi-specialist agents (the Dreamer runs deduction + induction in + # ONE trace) hang every branch off a single synthetic trace root, so + # the trace has one root instead of one per specialist. That root + # also stamps the trace attrs. Single-specialist agents (dialectic) + # get None here and let their run span be the root. + root_span_id = self._ensure_trace_root(client, lf_trace_id, call) + run_span_id = langfuse_session.ensure_run_span( + lf_trace_id, + branch, + lambda should_stamp: self._create_span( + client, + lf_trace_id, + parent_span_id=root_span_id, + name=call.track_name or "LLM run", + metadata=self._metadata(call), + # The synthetic root stamps the trace attrs when present; + # otherwise the first branch's run span does. + stamp_trace=should_stamp and root_span_id is None, + call=call, + ), + ) + parent_span_id = run_span_id + if call.iteration is not None and run_span_id is not None: + parent_span_id = langfuse_session.ensure_step_span( + lf_trace_id, + branch, + call.iteration, + lambda: self._create_span( + client, + lf_trace_id, + parent_span_id=run_span_id, + name=self._step_name(call), + metadata=self._step_metadata(call), + stamp_trace=False, + call=call, + ), + ) + + self._create_generation( + client, + lf_trace_id, + parent_span_id=parent_span_id, + # Single-shot: the generation is the trace root, so it stamps the + # trace attrs. Agentic: the first branch's run span already did. + stamp_trace=call.run_id is None, + call=call, + ) + + # Tool calls the model requested this iteration: siblings of the + # generation under the step span. Skipped at the trace root (single-shot + # callers don't use tools) since there's no step to anchor them. + if parent_span_id is not None and call.output_tool_calls: + for seq, tool_call in enumerate(call.output_tool_calls): + self._create_tool_span( + client, lf_trace_id, parent_span_id, seq, tool_call, call + ) + + # -- observation builders ------------------------------------------------ + + def _ensure_trace_root( + self, client: Any, lf_trace_id: str, call: CapturedLLMCall + ) -> str | None: + """Single branch-agnostic trace root for multi-specialist agents. + + The Dreamer's deduction + induction specialists share one trace (same + run_id) but each builds its own run span with no parent — so Langfuse + sees two roots, races the trace name between them, and renders the + specialists as separate sub-traces. One synthetic "Dream" root (which + also stamps the trace attrs) gives the trace a single root with both + specialists nested beneath. Single-specialist agents (dialectic) return + None and let their run span be the root. + """ + if call.parent_category != "dream": + return None + return langfuse_session.ensure_trace_root( + lf_trace_id, + lambda: self._create_span( + client, + lf_trace_id, + parent_span_id=None, + name=self._trace_name(call) or "Dream", + metadata=self._root_metadata(call), + stamp_trace=True, + call=call, + ), + ) + + def _create_span( + self, + client: Any, + lf_trace_id: str, + *, + parent_span_id: str | None, + name: str, + metadata: dict[str, str], + stamp_trace: bool, + call: CapturedLLMCall, + ) -> str | None: + """Create a (run or step) span, returning its OTEL span id. + + Created-and-ended immediately: nesting is by id, so children link fine to + an already-ended parent. Span durations are therefore approximate — an + accepted v1 trade for not having a 'run finished' signal in the stream. + """ + obs = client.start_observation( + trace_context=self._trace_context(lf_trace_id, parent_span_id), + name=name, + as_type="span", + metadata=metadata, + ) + if stamp_trace: + self._stamp_trace_attrs(obs, call) + if parent_span_id is not None: + self._demote_from_root(obs) + obs.end() + return getattr(obs, "id", None) + + def _create_generation( + self, + client: Any, + lf_trace_id: str, + *, + parent_span_id: str | None, + stamp_trace: bool, + call: CapturedLLMCall, + ) -> None: + level = "ERROR" if (call.finish_reason in _ERROR_FINISHES) else None + obs = client.start_observation( + trace_context=self._trace_context(lf_trace_id, parent_span_id), + name=self._gen_name(call), + as_type="generation", + model=call.model, + input=self._input(call), + output=self._output(call), + metadata=self._step_metadata(call), + usage_details=self._usage(call), + level=level, + ) + if stamp_trace: + self._stamp_trace_attrs(obs, call) + if parent_span_id is not None: + self._demote_from_root(obs) + obs.end() + + def _create_tool_span( + self, + client: Any, + lf_trace_id: str, + parent_span_id: str, + seq: int, + tool_call: dict[str, Any], + call: CapturedLLMCall, + ) -> None: + """Create a tool span for one requested tool call, under the step span. + + Built from the model's request (`output_tool_calls`): tool name + input + args. Result/duration/error aren't on the captured call (they live on + AgentToolCallCompletedEvent) — a later enrichment, not v1. + """ + obs = client.start_observation( + trace_context=self._trace_context(lf_trace_id, parent_span_id), + name=str(tool_call.get("name") or "tool"), + as_type="tool", + input=tool_call.get("input"), + metadata=self._tool_metadata(call, seq), + ) + self._demote_from_root(obs) # always a child of the step span + obs.end() + + @staticmethod + def _demote_from_root(obs: Any) -> None: + """Clear the AS_ROOT flag the SDK auto-stamps on a child observation. + + `start_observation(trace_context={"trace_id": ...})` marks EVERY span it + mints with `AS_ROOT=True` (langfuse `_client/client.py`) — including the + step/generation/tool spans we link under a run span by id. With several + root-flagged spans in one trace, Langfuse resolves the trace's root (and + therefore its name) from whichever it ingests first: a race that names a + dialectic trace after a child ("... step"/"... generation") and renders + children as if each were its own trace. Demoting every span that has a + real parent leaves exactly one root, making name + nesting deterministic. + Verified empirically against Langfuse cloud (the dangling remote-parent + id on the surviving root is benign and unavoidable — it's present even + with native context nesting). + """ + span = getattr(obs, "_otel_span", None) + if span is None: + return + from langfuse import LangfuseOtelSpanAttributes as Attr + + span.set_attribute(Attr.AS_ROOT, False) + + @staticmethod + def _trace_context(lf_trace_id: str, parent_span_id: str | None) -> dict[str, str]: + ctx: dict[str, str] = {"trace_id": lf_trace_id} + if parent_span_id is not None: + ctx["parent_span_id"] = parent_span_id + return ctx + + def _stamp_trace_attrs(self, obs: Any, call: CapturedLLMCall) -> None: + """Stamp user/name on the trace via the root observation's span. + + Called once per trace, on the first branch's run span (decided by + `langfuse_session.ensure_run_span`) or, for single-shot calls, on the + generation (its own trace). + + Deliberately does NOT set a Langfuse session: no Honcho construct is a + conversation thread. A dialectic chat is a one-shot query scoped to a + session, not a turn in a multi-turn dialectic exchange (no such primitive + exists), so grouping independent queries under one Langfuse session would + invent a conversation that isn't there. The Honcho session rides in + metadata (`honcho_session`) instead — a correlation key, not a group.""" + span = getattr(obs, "_otel_span", None) + if span is None: + return + from langfuse import LangfuseOtelSpanAttributes as Attr + + span.set_attribute(Attr.TRACE_USER_ID, str(settings.NAMESPACE)) + trace_name = self._trace_name(call) + if trace_name: + span.set_attribute(Attr.TRACE_NAME, trace_name) + + # -- field mappers (port of runtime._base_metadata/_step_metadata) ------- + + @staticmethod + def _metadata(call: CapturedLLMCall) -> dict[str, str]: + # `trace_id` is the run grouping key (also handy for cross-referencing the + # CloudEvents stream). `span_id`/`parent_span_id` are intentionally omitted + # until the source mints distinct per-call span ids: today every call in a + # run shares span_id == trace_id == run_id, so surfacing them here only + # duplicates trace_id and misleads. Re-add once the source differentiates. + md: dict[str, str] = {"namespace": str(settings.NAMESPACE)} + for key, value in ( + ("workspace_name", call.workspace_name), + ("call_purpose", call.call_purpose), + ("agent_type", call.agent_type), + ("observer", call.observer), + ("observed", call.observed), + ("peer_name", call.peer_name), + ("trace_id", call.trace_id), + # Honcho session as a correlation key, NOT a Langfuse session — see + # `_stamp_trace_attrs`. Lets you filter "queries scoped to session X" + # without falsely grouping one-shot dialectic queries as a thread. + ("honcho_session", call.session_id), + ): + if value is not None: + md[key] = str(value) + return md + + @staticmethod + def _root_metadata(call: CapturedLLMCall) -> dict[str, str]: + # Branch-agnostic: the synthetic dream root spans both specialists, so it + # carries only trace-level fields — not a single specialist's agent_type/ + # observer/observed/call_purpose. + md: dict[str, str] = {"namespace": str(settings.NAMESPACE)} + for key, value in ( + ("workspace_name", call.workspace_name), + ("trace_id", call.trace_id), + ): + if value is not None: + md[key] = str(value) + return md + + def _step_metadata(self, call: CapturedLLMCall) -> dict[str, str]: + md = self._metadata(call) + if call.iteration is not None: + md["iteration"] = str(call.iteration) + md["step_seq"] = str(call.step_seq) + md["attempt"] = str(call.attempt) + md["provider"] = str(call.transport) + md["model"] = str(call.model) + return md + + def _tool_metadata(self, call: CapturedLLMCall, seq: int) -> dict[str, str]: + md = self._step_metadata(call) + md["tool_call_seq"] = str(seq) + return md + + @staticmethod + def _trace_name(call: CapturedLLMCall) -> str | None: + # Branch-agnostic trace label: the Dreamer's two specialists share one + # trace, so the trace name must not be pinned to whichever specialist's + # run span stamped it first. Per-branch identity stays on the run spans. + if call.parent_category == "dream": + return "Dream" + return call.track_name + + @staticmethod + def _step_name(call: CapturedLLMCall) -> str: + # Canonical, index-free name: Langfuse aggregates step spans by name and + # the iteration/step_seq/attempt ride on metadata (see _step_metadata). + return f"{call.track_name} step" if call.track_name else "Agent step" + + @staticmethod + def _gen_name(call: CapturedLLMCall) -> str: + return f"{call.track_name} generation" if call.track_name else "generation" + + @staticmethod + def _input(call: CapturedLLMCall) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for message in call.input_messages: + entry: dict[str, Any] = {"role": message.role, "content": message.content} + if message.tool_call_id is not None: + entry["tool_call_id"] = message.tool_call_id + if message.tool_calls: + entry["tool_calls"] = message.tool_calls + out.append(entry) + return out + + @staticmethod + def _output(call: CapturedLLMCall) -> Any: + if isinstance(call.output_content, str) and call.output_content.strip(): + return call.output_content + if call.output_tool_calls: + return {"tool_calls": [tc.get("name") for tc in call.output_tool_calls]} + return call.output_content + + @staticmethod + def _usage(call: CapturedLLMCall) -> dict[str, int]: + return { + "input": call.input_tokens, + "output": call.output_tokens, + "cache_read_input_tokens": call.cache_read_tokens, + "cache_creation_input_tokens": call.cache_creation_tokens, + } + + +__all__ = ["LangfuseExporter"] diff --git a/src/telemetry/langfuse_session.py b/src/telemetry/langfuse_session.py new file mode 100644 index 00000000..3dc2c13e --- /dev/null +++ b/src/telemetry/langfuse_session.py @@ -0,0 +1,122 @@ +"""Per-trace span registry backing the `LangfuseExporter`. + +The exporter sees one `CapturedLLMCall` at a time, but a single agentic run fans +out into many calls that must nest under one run span with per-iteration step +spans. Langfuse links observations by OTEL span id, and each id is minted fresh +and unpredictable — so this module remembers the run/step span ids created for a +trace and hands them back as the `parent_span_id` of later calls. + +Spans are keyed per branch (the `agent_type`) within a trace. The Dreamer's +deduction and induction specialists share one trace but are separate sub-trees; +without the branch key their iterations and generations would collide. + +Per trace, it holds each branch's run span id, the per-(branch, iteration) step +span ids, and whether trace-level attrs have been stamped — so each is created +once. The stamp decision is made inside `ensure_run_span` under the lock so it +can't double-fire across branches. + +Bounded by an LRU over traces (`_MAX_TRACES`), lock-guarded, best-effort. +""" + +from __future__ import annotations + +import logging +import threading +from collections import OrderedDict +from collections.abc import Callable +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +# LRU window / runaway backstop — far more than the traces ever live at once; the +# least-recently-used trace is evicted past this. Not a tuning knob. +_MAX_TRACES = 4096 + + +@dataclass +class _TraceState: + root_span_id: str | None = None # synthetic trace root (multi-specialist agents) + run_span_ids: dict[str, str] = field(default_factory=dict) # branch -> span id + step_span_ids: dict[tuple[str, int], str] = field( + default_factory=dict + ) # (branch, iteration) -> span id + attrs_stamped: bool = False + + +_traces: OrderedDict[str, _TraceState] = OrderedDict() +_lock = threading.Lock() + + +def _get_or_create_state(trace_key: str) -> _TraceState: + """Return the `_TraceState` for `trace_key`, creating it if new and marking it + most-recently-used. Caller MUST hold `_lock`. Bounded by an LRU: a new trace + past `_MAX_TRACES` evicts the least-recently-used (almost always finished) one. + """ + state = _traces.get(trace_key) + if state is None: + if len(_traces) >= _MAX_TRACES: + _traces.popitem(last=False) # evict the least-recently-used trace + state = _traces[trace_key] = _TraceState() + else: + _traces.move_to_end(trace_key) # mark most-recently-used + return state + + +def ensure_trace_root(trace_key: str, create: Callable[[], str | None]) -> str | None: + """Return the single trace-root span id for `trace_key`, creating it once. + + Used by multi-specialist agents (the Dreamer) whose branches share one trace + but must all hang off ONE root span. Single-specialist agents don't call + this. Mirrors `ensure_run_span`'s retry-on-None: a failed create just yields + None (the caller then roots the branch directly) and is retried next call. + """ + with _lock: + state = _get_or_create_state(trace_key) + if state.root_span_id is None: + state.root_span_id = create() + return state.root_span_id + + +def ensure_run_span( + trace_key: str, branch: str, create: Callable[[bool], str | None] +) -> str | None: + """Return the run span id for `(trace_key, branch)`, creating it once. + + `create` receives `should_stamp` — True exactly once per trace, on the first + branch's run span — and builds the Langfuse run span (stamping trace-level + attrs iff asked), returning its span id (or None on failure). The stamp + decision is computed here, under the lock, so it can't double-fire across the + Dreamer's two specialist branches; `create` must not re-enter this module. + """ + with _lock: + state = _get_or_create_state(trace_key) + existing = state.run_span_ids.get(branch) + if existing is None: + should_stamp = not state.attrs_stamped + existing = create(should_stamp) + if existing is not None: + state.run_span_ids[branch] = existing + if should_stamp: + state.attrs_stamped = True + return existing + + +def ensure_step_span( + trace_key: str, branch: str, iteration: int, create: Callable[[], str | None] +) -> str | None: + """Return the step span id for `(trace_key, branch, iteration)`, creating once.""" + with _lock: + state = _get_or_create_state(trace_key) + key = (branch, iteration) + existing = state.step_span_ids.get(key) + if existing is None: + existing = create() + if existing is not None: + state.step_span_ids[key] = existing + return existing + + +def reset() -> None: + """Drop all tracked traces — used on shutdown and in tests.""" + with _lock: + _traces.clear() diff --git a/src/telemetry/logging.py b/src/telemetry/logging.py index b653952e..9c5f6648 100644 --- a/src/telemetry/logging.py +++ b/src/telemetry/logging.py @@ -75,7 +75,11 @@ def conditional_observe( capture_output: bool | None = None, ) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]: """ - Conditionally apply the @observe decorator only when LANGFUSE_PUBLIC_KEY is present. + Conditionally apply the @observe decorator only in legacy inline mode + (``langfuse_inline_enabled`` — i.e. a key is set AND + ``LANGFUSE_EXPORTER_MODE == "inline"``). In exporter mode the LangfuseExporter + rebuilds every observation from the captured trace stream, so a live + @observe span here would double-emit. Can be used in two ways: 1. As a decorator: @conditional_observe @@ -105,7 +109,10 @@ def conditional_observe( """ def decorator(f: Callable[P, R]) -> Callable[P, R]: - if not settings.LANGFUSE_PUBLIC_KEY: + # Only auto-instrument with @observe in legacy inline mode. In exporter + # mode the LangfuseExporter produces every observation from the captured + # trace stream, so a live @observe span here would double-emit. + if not settings.langfuse_inline_enabled: return f # `observe` treats None as "use SDK default", so passing the optionals # straight through is equivalent to omitting them. diff --git a/src/telemetry/trace_exporter.py b/src/telemetry/trace_exporter.py new file mode 100644 index 00000000..0023f97a --- /dev/null +++ b/src/telemetry/trace_exporter.py @@ -0,0 +1,169 @@ +"""CloudEvents exporter: turns a CapturedLLMCall into trace events. + +Registered into the `src/llm/capture.py` exporter registry at startup when +`TELEMETRY.TRACE_PAYLOADS_ENABLED` is on. For each captured call it emits: +- one `trace.content` per unique message/output/thinking/tool-schema (deduped + per run so each ships once), and +- one `llm.call.traced` carrying the span-tree correlation + content refs + + a self-contained accounting copy. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from src.config import settings +from src.llm.capture import ( + ROLE_OUTPUT, + ROLE_THINKING, + ROLE_TOOL_SCHEMA, + CapturedLLMCall, + clip_for_trace, + compute_content_hash, +) +from src.telemetry import trace_session +from src.telemetry.events import emit_trace +from src.telemetry.events.trace import LLMCallTracedEvent, TraceContentEvent + +logger = logging.getLogger(__name__) + + +class TraceExporter: + """`LLMCallExporter` that ships replay-grade content to the trace stream.""" + + def export(self, call: CapturedLLMCall) -> None: + # Double-gate (the exporter is only registered when on, but a config + # flip or a stray registration shouldn't leak payloads). + if not settings.TELEMETRY.TRACE_PAYLOADS_ENABLED: + return + purposes = settings.TELEMETRY.TRACE_PURPOSES + if purposes and call.call_purpose not in purposes: + return + + run_key = call.trace_id or call.span_id or call.run_id or "" + was_truncated = call.input_truncated + + # --- Context window: reuse precomputed input-message hashes --- + input_message_refs: list[str] = [] + for message in call.input_messages: + input_message_refs.append(message.content_hash) + self._emit_content( + run_key, + content_hash=message.content_hash, + role=message.role, + content=message.content, + tool_call_id=message.tool_call_id, + honcho_authored=message.role == "system", + tool_calls=message.tool_calls, + ) + + # --- Tool schemas (Honcho-authored, content-addressed) --- + tool_schema_refs: list[str] = [] + for schema in call.tool_schemas: + ref, truncated = self._emit_hashed_content( + run_key, ROLE_TOOL_SCHEMA, schema, honcho_authored=True + ) + was_truncated = was_truncated or truncated + tool_schema_refs.append(ref) + + # --- Output content / thinking --- + output_content_ref: str | None = None + if call.output_content not in (None, ""): + output_content_ref, truncated = self._emit_hashed_content( + run_key, ROLE_OUTPUT, call.output_content + ) + was_truncated = was_truncated or truncated + + output_thinking_ref: str | None = None + if call.thinking_content: + output_thinking_ref, truncated = self._emit_hashed_content( + run_key, ROLE_THINKING, call.thinking_content + ) + was_truncated = was_truncated or truncated + + signatures = [ + block["signature"] + for block in call.thinking_blocks + if block.get("signature") + ] + + emit_trace( + LLMCallTracedEvent( + trace_id=call.trace_id, + span_id=call.span_id, + parent_span_id=call.parent_span_id, + iteration=call.iteration, + step_seq=call.step_seq, + attempt=call.attempt, + was_fallback=call.was_fallback, + call_purpose=call.call_purpose, + parent_category=call.parent_category, + session_id=call.session_id, + transport=call.transport, # pyright: ignore[reportArgumentType] + provider_label=call.provider_label, + model=call.model, + input_message_refs=input_message_refs, + tool_schema_refs=tool_schema_refs, + tool_choice=call.tool_choice, + output_content_ref=output_content_ref, + output_tool_calls=call.output_tool_calls, + output_thinking_ref=output_thinking_ref, + output_signatures=signatures, + finish_reason=call.finish_reason, + provider_input_tokens=call.input_tokens, + provider_output_tokens=call.output_tokens, + cache_read_tokens=call.cache_read_tokens, + cache_creation_tokens=call.cache_creation_tokens, + was_truncated=was_truncated, + ) + ) + + def _emit_hashed_content( + self, + run_key: str, + role: str, + raw_content: Any, + *, + honcho_authored: bool = False, + ) -> tuple[str, bool]: + """Clip + hash a non-message content value and emit it. Returns (hash, truncated).""" + content, truncated = clip_for_trace(raw_content) + content_hash = compute_content_hash(role, content, None) + self._emit_content( + run_key, + content_hash=content_hash, + role=role, + content=content, + tool_call_id=None, + honcho_authored=honcho_authored, + ) + return content_hash, truncated + + def _emit_content( + self, + run_key: str, + *, + content_hash: str, + role: str, + content: Any, + tool_call_id: str | None, + honcho_authored: bool, + tool_calls: list[dict[str, Any]] | None = None, + ) -> None: + """Emit one trace.content, deduped per run (skip if already shipped).""" + if not trace_session.mark_emitted(run_key, content_hash): + return + emit_trace( + TraceContentEvent( + content_hash=content_hash, + role=role, + content=content, + tool_call_id=tool_call_id, + honcho_authored=honcho_authored, + tool_calls=tool_calls or [], + ) + ) + + +__all__ = ["TraceExporter"] diff --git a/src/telemetry/trace_session.py b/src/telemetry/trace_session.py new file mode 100644 index 00000000..a08ca9d7 --- /dev/null +++ b/src/telemetry/trace_session.py @@ -0,0 +1,73 @@ +"""Per-run content dedup for the trace stream — makes bandwidth O(N). + +Tracks the set of content hashes a run has already shipped, so each unique +message ships its `trace.content` exactly once per run. + +The set is bounded by an LRU over runs: once more than `_MAX_RUNS` runs are +tracked, the least-recently-touched one is evicted (almost always a run that has +already finished), so dedup keeps working for active runs no matter how many the +process has handled. A single run exceeding `_MAX_HASHES_PER_RUN` unique messages +stops deduping and emits-anyway, bumping a metric — that loss is measured. +""" + +from __future__ import annotations + +import logging +import threading +from collections import OrderedDict + +logger = logging.getLogger(__name__) + +# LRU window over runs + per-run hash cap. Generous — a run rarely has more than +# a few hundred unique messages, and far fewer than _MAX_RUNS are ever live at +# once; both are runaway backstops, not tuning knobs. +_MAX_RUNS = 4096 +_MAX_HASHES_PER_RUN = 8192 + +# trace_id (fallback span_id) → set of content hashes already shipped this run. +# OrderedDict so we can evict the least-recently-used run when over _MAX_RUNS. +_runs: OrderedDict[str, set[str]] = OrderedDict() +_lock = threading.Lock() + + +def mark_emitted(run_key: str, content_hash: str) -> bool: + """Return True if this hash should be shipped for ``run_key`` (first time), + False if already shipped this run (skip the ``trace.content``). + + A run exceeding `_MAX_HASHES_PER_RUN` returns True (emit-anyway) and records a + drop of the dedup *guarantee* — the event still ships, we just stopped + tracking. Tracking a new run past `_MAX_RUNS` evicts the LRU run instead (a + routine, lossless bound — the evicted run is almost always already finished). + """ + with _lock: + seen = _runs.get(run_key) + if seen is None: + if len(_runs) >= _MAX_RUNS: + _runs.popitem(last=False) # evict the least-recently-used run + seen = _runs[run_key] = set() + else: + _runs.move_to_end(run_key) # mark most-recently-used + if content_hash in seen: + return False + if len(seen) >= _MAX_HASHES_PER_RUN: + _record_overflow("max_hashes") + return True + seen.add(content_hash) + return True + + +def reset() -> None: + """Drop all tracked runs — used on shutdown and in tests.""" + with _lock: + _runs.clear() + + +def _record_overflow(reason: str) -> None: + try: + from src.telemetry import prometheus_metrics + + prometheus_metrics.record_telemetry_event_dropped( + reason=f"trace_dedup_{reason}" + ) + except Exception: # pragma: no cover - best-effort telemetry + logger.debug("trace dedup overflow (%s)", reason) diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index bac3948a..d4df768b 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -2523,8 +2523,13 @@ def _begin_tool_observation(tool_name: str, tool_input: dict[str, Any]) -> Any: Auto-parents under the active step span (else standalone). Returns a handle (closed by `_finish_tool_observation`) or None when disabled/setup fails. All tools are ``as_type="tool"`` — they share one generic dispatcher. + + Only fires in legacy *inline* mode. In exporter mode there's no live span + context to parent under, so this would emit a rootless tool trace per call; + the LangfuseExporter already projects tool spans (from ``output_tool_calls``) + nested under the step span, so a live observation here just double-emits. """ - if not settings.LANGFUSE_PUBLIC_KEY: + if not settings.langfuse_inline_enabled: return None try: from langfuse import get_client diff --git a/src/utils/summarizer.py b/src/utils/summarizer.py index 42d18bcc..2abdb0f9 100644 --- a/src/utils/summarizer.py +++ b/src/utils/summarizer.py @@ -6,6 +6,7 @@ from functools import cache from inspect import cleandoc as c from typing import TypedDict +from nanoid import generate as generate_nanoid from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession @@ -219,6 +220,9 @@ async def create_short_summary( formatted_messages, output_words, previous_summary_text ) + # Mint a root span id. + # No session_id or run_id for tracing + trace_id = generate_nanoid() return await honcho_llm_call( model_config=_get_summary_model_config(), prompt=prompt, @@ -227,6 +231,9 @@ async def create_short_summary( workspace_name=workspace_name, call_purpose=CallPurpose.SUMMARY_SHORT.value, parent_category="summary", + trace_id=trace_id, + span_id=trace_id, + track_name="Short Summary", ), ) @@ -251,6 +258,9 @@ async def create_long_summary( formatted_messages, output_words, previous_summary_text ) + # Mint a root span id. + # No session_id or run_id for tracing + trace_id = generate_nanoid() return await honcho_llm_call( model_config=_get_summary_model_config(), prompt=prompt, @@ -259,6 +269,9 @@ async def create_long_summary( workspace_name=workspace_name, call_purpose=CallPurpose.SUMMARY_LONG.value, parent_category="summary", + trace_id=trace_id, + span_id=trace_id, + track_name="Long Summary", ), ) @@ -514,17 +527,13 @@ async def _create_and_save_summary( "ms", ) - # Emit telemetry event (only for non-fallback summaries) - # Note: Using AgentToolSummaryCreatedEvent with dummy run_id/iteration since - # this is called from the deriver, not from an agentic loop + # Emit telemetry event (only for non-fallback summaries). if not is_fallback: # `prompt_tokens` is set in the `if not is_fallback` block above for # both SHORT and LONG summary types — we're inside the same branch, so # it's guaranteed bound here. emit( AgentToolSummaryCreatedEvent( - run_id="deriver", # Placeholder - not from an agentic run - iteration=0, # Placeholder - not from an agentic loop parent_category="deriver", agent_type="summarizer", workspace_name=workspace_name, diff --git a/src/utils/types.py b/src/utils/types.py index 2a470d20..33a2993d 100644 --- a/src/utils/types.py +++ b/src/utils/types.py @@ -114,6 +114,12 @@ _embedding_run_id: ContextVar[str | None] = ContextVar("embedding_run_id", defau _embedding_parent_category: ContextVar[str | None] = ContextVar( "embedding_parent_category", default=None ) +# Honcho Session.id for the embedding's trace grouping (e.g. a dialectic +# prefetch embedding shares the dialectic invocation's session). None when the +# embedding isn't scoped to a session. +_embedding_session_id: ContextVar[str | None] = ContextVar( + "embedding_session_id", default=None +) def get_embedding_call_purpose() -> str | None: @@ -136,6 +142,11 @@ def get_embedding_parent_category() -> str | None: return _embedding_parent_category.get() +def get_embedding_session_id() -> str | None: + """Read the Honcho Session.id attached to the current embedding call scope.""" + return _embedding_session_id.get() + + @contextmanager def embedding_call_purpose( purpose: str, @@ -143,6 +154,7 @@ def embedding_call_purpose( workspace_name: str | None = None, run_id: str | None = None, parent_category: str | None = None, + session_id: str | None = None, ) -> Generator[None]: """Tag any embedding calls made inside this `with` block. @@ -172,6 +184,9 @@ def embedding_call_purpose( if parent_category is not None else None ) + session_id_token = ( + _embedding_session_id.set(session_id) if session_id is not None else None + ) try: yield finally: @@ -182,6 +197,8 @@ def embedding_call_purpose( _embedding_run_id.reset(run_id_token) if parent_category_token is not None: _embedding_parent_category.reset(parent_category_token) + if session_id_token is not None: + _embedding_session_id.reset(session_id_token) @dataclass diff --git a/tests/llm/test_capture.py b/tests/llm/test_capture.py new file mode 100644 index 00000000..7dc22e95 --- /dev/null +++ b/tests/llm/test_capture.py @@ -0,0 +1,488 @@ +"""Tests for the single-capture content layer (src/llm/capture.py).""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +import pytest + +from src.llm import capture +from src.llm.backend import CompletionResult, ToolCallResult +from src.llm.capture import ( + CapturedLLMCall, + build_captured_call, + build_captured_messages, + canonical_json, + clip_for_trace, + compute_content_hash, +) +from src.llm.types import ( + HonchoLLMCallStreamChunk, + LLMTelemetryContext, + StreamingResponseWithMetadata, +) + + +async def _chunks( + texts: list[str], *, raise_after: BaseException | None = None +) -> AsyncIterator[HonchoLLMCallStreamChunk]: + for text in texts: + yield HonchoLLMCallStreamChunk(content=text) + if raise_after is not None: + raise raise_after + + +def _wrapper( + stream: AsyncIterator[HonchoLLMCallStreamChunk], + recorder: list[tuple[str, str]], +): + return StreamingResponseWithMetadata( + stream=stream, + tool_calls_made=[], + input_tokens=0, + output_tokens=0, + cache_creation_input_tokens=0, + cache_read_input_tokens=0, + capture_finalizer=lambda text, reason: recorder.append((text, reason)), + ) + + +class TestStreamingCaptureFinalizer: + async def test_clean_drain_captures_stop(self): + recorded: list[tuple[str, str]] = [] + wrapper = _wrapper(_chunks(["hel", "lo"]), recorded) + async for _ in wrapper: + pass + assert recorded == [("hello", "stop")] + + async def test_error_drain_captures_error_and_partial_text(self): + recorded: list[tuple[str, str]] = [] + wrapper = _wrapper(_chunks(["par"], raise_after=RuntimeError("boom")), recorded) + with pytest.raises(RuntimeError): + async for _ in wrapper: + pass + # Partial text still captured, tagged error. + assert recorded == [("par", "error")] + + async def test_cancelled_drain_captures_cancelled(self): + import asyncio + + recorded: list[tuple[str, str]] = [] + wrapper = _wrapper( + _chunks(["x"], raise_after=asyncio.CancelledError()), recorded + ) + with pytest.raises(asyncio.CancelledError): + async for _ in wrapper: + pass + assert recorded == [("x", "cancelled")] + + +class TestContentHash: + def test_is_deterministic_and_prefixed(self): + h1 = compute_content_hash("user", "hello", None) + h2 = compute_content_hash("user", "hello", None) + assert h1 == h2 + assert h1.startswith("sha256:") + + def test_role_is_inside_the_hash(self): + # Identical text under different roles must never collide — role lives + # inside the hash, closing the role-in-hash collision bug. + assert compute_content_hash("user", "hi", None) != compute_content_hash( + "assistant", "hi", None + ) + + def test_tool_call_id_is_inside_the_hash(self): + assert compute_content_hash("tool", "ok", "call_1") != compute_content_hash( + "tool", "ok", "call_2" + ) + + def test_canonical_json_is_order_independent(self): + assert canonical_json({"a": 1, "b": 2}) == canonical_json({"b": 2, "a": 1}) + + +class TestClipForTrace: + def test_leaves_small_content_untouched(self): + content, truncated = clip_for_trace("short") + assert content == "short" + assert truncated is False + + def test_clips_oversized_string(self, monkeypatch: pytest.MonkeyPatch): + from src.config import settings + + monkeypatch.setattr(settings.TELEMETRY, "TRACE_MAX_BYTES", 32) + content, truncated = clip_for_trace("x" * 1000) + assert truncated is True + assert content.endswith("…[truncated]") + assert len(content.encode("utf-8")) <= settings.TELEMETRY.TRACE_MAX_BYTES + + def test_leaves_structured_content_intact(self, monkeypatch: pytest.MonkeyPatch): + from src.config import settings + + monkeypatch.setattr(settings.TELEMETRY, "TRACE_MAX_BYTES", 4) + blocks = [{"type": "text", "text": "a long block of structured content"}] + content, truncated = clip_for_trace(blocks) + assert content == blocks + assert truncated is False + + +class TestBuildCapturedMessages: + def test_hashes_each_message(self): + messages = [ + {"role": "user", "content": "q"}, + {"role": "assistant", "content": "a"}, + ] + captured, truncated = build_captured_messages(messages, memo=None) + assert [m.role for m in captured] == ["user", "assistant"] + assert all(m.content_hash.startswith("sha256:") for m in captured) + assert truncated is False + + def test_memo_makes_hashing_on(self, monkeypatch: pytest.MonkeyPatch): + # The conversation is append-only and message dicts are reused, so with + # a shared memo each message is hashed exactly once across iterations. + calls = {"n": 0} + real = compute_content_hash + + def counting( + role: str, + content: object, + tool_call_id: str | None, + tool_calls: list[dict[str, object]] | None = None, + ) -> str: + calls["n"] += 1 + return real(role, content, tool_call_id, tool_calls) + + monkeypatch.setattr(capture, "compute_content_hash", counting) + + m1 = {"role": "user", "content": "q1"} + m2 = {"role": "assistant", "content": "a1"} + m3 = {"role": "user", "content": "q2"} + memo: dict[int, capture.CapturedMessage] = {} + + build_captured_messages([m1, m2], memo) + assert calls["n"] == 2 # both hashed + build_captured_messages([m1, m2, m3], memo) + assert calls["n"] == 3 # only the newly-appended m3 hashed (not re-hashed) + + +class TestNormalizeToolCalls: + """Tool calls live outside `content` for openai/gemini — capture must lift + them into the unified `tool_calls` shape (the PR concern).""" + + def test_openai_assistant_tool_calls_captured(self): + msg = { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "search_memory", + "arguments": '{"query": "coffee"}', + }, + } + ], + } + captured, _ = build_captured_messages([msg], memo=None, transport="openai") + assert captured[0].tool_calls == [ + {"id": "call_1", "name": "search_memory", "input": {"query": "coffee"}} + ] + + def test_gemini_model_parts_captured(self): + msg = { + "role": "model", + "parts": [ + {"text": "let me look"}, + {"function_call": {"name": "grep_messages", "args": {"text": "x"}}}, + ], + } + captured, _ = build_captured_messages([msg], memo=None, transport="gemini") + assert captured[0].content == "let me look" + assert captured[0].tool_calls == [ + {"id": None, "name": "grep_messages", "input": {"text": "x"}} + ] + + def test_gemini_tool_result_recovered(self): + # Gemini tool results live in `parts` (no `content` key) and were dropped. + msg = { + "role": "user", + "parts": [ + { + "function_response": { + "name": "grep_messages", + "response": {"result": "3 hits"}, + } + } + ], + } + captured, _ = build_captured_messages([msg], memo=None, transport="gemini") + assert captured[0].content == "3 hits" + assert captured[0].tool_call_id == "grep_messages" + + def test_anthropic_tool_use_blocks_normalized(self): + msg = { + "role": "assistant", + "content": [ + {"type": "text", "text": "searching"}, + { + "type": "tool_use", + "id": "tu_1", + "name": "search_memory", + "input": {"q": "x"}, + }, + ], + } + captured, _ = build_captured_messages([msg], memo=None, transport="anthropic") + assert captured[0].content == "searching" + assert captured[0].tool_calls == [ + {"id": "tu_1", "name": "search_memory", "input": {"q": "x"}} + ] + + def test_hash_distinguishes_tool_calls(self): + # Two empty-content assistant turns with different tool calls must not + # collide in the dedup store (they did before tool_calls entered the hash). + base = {"role": "assistant", "content": None} + a = { + **base, + "tool_calls": [ + { + "id": "c1", + "type": "function", + "function": {"name": "search_memory", "arguments": "{}"}, + } + ], + } + b = { + **base, + "tool_calls": [ + { + "id": "c2", + "type": "function", + "function": {"name": "search_messages", "arguments": "{}"}, + } + ], + } + (ca,), _ = build_captured_messages([a], memo=None, transport="openai") + (cb,), _ = build_captured_messages([b], memo=None, transport="openai") + assert ca.content_hash != cb.content_hash + + +class TestBuildCapturedCall: + def test_maps_telemetry_and_result(self): + telemetry = LLMTelemetryContext( + workspace_name="ws", + call_purpose="dialectic.answer", + parent_category="dialectic", + run_id="r1", + trace_id="r1", + span_id="r1", + session_id="sess_abc", + iteration=2, + step_seq=2, + ) + result = CompletionResult( + content="answer", + input_tokens=10, + output_tokens=5, + finish_reason="stop", + tool_calls=[ToolCallResult(id="t1", name="search", input={"q": "x"})], + ) + call = build_captured_call( + telemetry=telemetry, + transport="anthropic", + provider_label=None, + model="claude-x", + messages=[{"role": "user", "content": "q"}], + tools=None, + tool_choice=None, + result=result, + attempt=1, + was_fallback=False, + was_stream=False, + finish_reason="stop", + ) + assert isinstance(call, CapturedLLMCall) + assert call.trace_id == "r1" and call.span_id == "r1" + assert call.iteration == 2 and call.step_seq == 2 + assert call.output_content == "answer" + assert call.output_tool_calls == [ + {"id": "t1", "name": "search", "input": {"q": "x"}} + ] + assert call.input_tokens == 10 and call.output_tokens == 5 + assert call.session_id == "sess_abc" + assert len(call.input_messages) == 1 + assert call.input_messages[0].content_hash.startswith("sha256:") + + def test_session_id_defaults_none_without_telemetry(self): + # Sessionless calls (and the no-telemetry path) carry session_id=None so + # the Langfuse projection emits no session grouping for them. + telemetry = LLMTelemetryContext(run_id="r1", trace_id="r1", span_id="r1") + call = build_captured_call( + telemetry=telemetry, + transport="anthropic", + provider_label=None, + model="claude-x", + messages=[{"role": "user", "content": "q"}], + tools=None, + tool_choice=None, + result=CompletionResult(content="a", finish_reason="stop"), + attempt=1, + was_fallback=False, + was_stream=False, + finish_reason="stop", + ) + assert call.session_id is None + + def test_self_parent_is_normalized_to_none(self): + # The tool loop sets parent_span_id == span_id on the run span (it + # doubles as the Langfuse "inside a run" signal). A span that is its own + # parent is a root, so the EXPORTED parent_span_id must be None — else + # span-tree consumers file the root as a child of itself. + telemetry = LLMTelemetryContext( + run_id="r1", trace_id="r1", span_id="r1", parent_span_id="r1" + ) + call = build_captured_call( + telemetry=telemetry, + transport="anthropic", + provider_label=None, + model="claude-x", + messages=[{"role": "user", "content": "q"}], + tools=None, + tool_choice=None, + result=CompletionResult(content="a", finish_reason="stop"), + attempt=1, + was_fallback=False, + was_stream=False, + finish_reason="stop", + ) + assert call.span_id == "r1" + assert call.parent_span_id is None + # A genuine distinct parent is preserved. + telemetry.parent_span_id = "parent-span" + assert telemetry.exported_parent_span_id() == "parent-span" + + def test_error_path_collapses_output(self): + call = build_captured_call( + telemetry=None, + transport="anthropic", + provider_label=None, + model="claude-x", + messages=[{"role": "user", "content": "q"}], + tools=None, + tool_choice=None, + result=None, + attempt=2, + was_fallback=True, + was_stream=False, + finish_reason="error", + ) + assert call.output_content is None + assert call.output_tool_calls == [] + assert call.finish_reason == "error" + assert call.attempt == 2 and call.was_fallback is True + + +class TestExporterRegistry: + def test_register_dispatch_and_clear(self): + capture.clear_exporters() + assert capture.has_exporters() is False + seen: list[CapturedLLMCall] = [] + + class _Spy: + def export(self, call: CapturedLLMCall) -> None: + seen.append(call) + + capture.register_exporter(_Spy()) + assert capture.has_exporters() is True + + call = build_captured_call( + telemetry=None, + transport="anthropic", + provider_label=None, + model="m", + messages=[], + tools=None, + tool_choice=None, + result=None, + attempt=1, + was_fallback=False, + was_stream=False, + finish_reason="stop", + ) + capture.dispatch_captured_call(call) + assert seen == [call] + capture.clear_exporters() + assert capture.has_exporters() is False + + def test_dispatch_swallows_exporter_errors(self): + capture.clear_exporters() + + class _Boom: + def export(self, call: CapturedLLMCall) -> None: + raise RuntimeError(f"nope: {call.model}") + + capture.register_exporter(_Boom()) + call = build_captured_call( + telemetry=None, + transport="anthropic", + provider_label=None, + model="m", + messages=[], + tools=None, + tool_choice=None, + result=None, + attempt=1, + was_fallback=False, + was_stream=False, + finish_reason="stop", + ) + # Must not raise — telemetry never breaks the LLM path. + capture.dispatch_captured_call(call) + capture.clear_exporters() + + +class TestThoughtSignatureSerialization: + """Gemini `thought_signature` is bytes; it must not break trace serialization.""" + + def test_bytes_signature_base64_encoded_and_serializes(self): + import json + + from src.telemetry.events.trace import LLMCallTracedEvent + + result = CompletionResult( + content=None, + finish_reason="STOP", + tool_calls=[ + ToolCallResult( + id="call_1", + name="grep_messages", + input={"text": "coffee"}, + thought_signature=b"\x0a\x1f\x88\xff\x00sig", + ) + ], + ) + call = build_captured_call( + telemetry=LLMTelemetryContext(trace_id="t1", span_id="s1"), + transport="gemini", + provider_label=None, + model="gemini-2.5-flash", + messages=[{"role": "user", "content": "q"}], + tools=None, + tool_choice=None, + result=result, + attempt=1, + was_fallback=False, + was_stream=False, + finish_reason="STOP", + ) + sig = call.output_tool_calls[0]["thought_signature"] + assert isinstance(sig, str) # base64, not raw bytes + + # The traced event must serialize to JSON without raising (the emit path + # calls model_dump(mode="json"), which threw UnicodeDecodeError on bytes). + event = LLMCallTracedEvent( + model="gemini-2.5-flash", + transport="gemini", + output_tool_calls=call.output_tool_calls, + ) + json.dumps(event.model_dump(mode="json")) diff --git a/tests/llm/test_langfuse_trace_annotation.py b/tests/llm/test_langfuse_trace_annotation.py index e94e5acb..2f1ec50a 100644 --- a/tests/llm/test_langfuse_trace_annotation.py +++ b/tests/llm/test_langfuse_trace_annotation.py @@ -95,25 +95,16 @@ def langfuse_client(monkeypatch: pytest.MonkeyPatch): @pytest.fixture def langfuse_enabled(monkeypatch: pytest.MonkeyPatch): - """Turn the integration on with a known NAMESPACE (the tenant / user_id).""" + """Turn the integration on with a known NAMESPACE (the tenant / user_id). + + Pins LANGFUSE_EXPORTER_MODE='inline' — this module tests the legacy inline + span machinery, which is gated to inline mode (the default is now 'exporter', + where these functions no-op in favor of the LangfuseExporter).""" monkeypatch.setattr(settings, "LANGFUSE_PUBLIC_KEY", "pk-test") + monkeypatch.setattr(settings, "LANGFUSE_EXPORTER_MODE", "inline") monkeypatch.setattr(settings, "NAMESPACE", "acme-tenant") -@contextlib.contextmanager -def _inside_agent_run(): - """Set the `_in_agent_run` ContextVar for the body, resetting it after. - - Simulates execution nested inside a run handle without opening one (which - would itself call propagate_attributes and pollute the capture). - """ - token = runtime._in_agent_run.set(True) - try: - yield - finally: - runtime._in_agent_run.reset(token) - - class TestAnnotateDisabled: def test_noop_when_key_unset( self, monkeypatch: pytest.MonkeyPatch, capture_propagate: dict[str, Any] @@ -130,10 +121,11 @@ class TestAnnotateDisabled: class TestAnnotateInsideRun: - """A generation nested inside an active run handle: the run owns the trace - attrs, so this call must NOT propagate. It still stamps model + per-step - metadata + name on the generation (the multi-turn regression fix — - provider/model used to be dropped on every iteration after the first).""" + """A generation nested under a run (it carries a `parent_span_id`): the run + owns the trace attrs, so this call must NOT propagate. It still stamps model + + per-step metadata + name on the generation (the multi-turn regression fix — + provider/model used to be dropped on every iteration after the first). + Nesting is derived from the explicit `parent_span_id`, not a contextvar.""" def test_nested_generation_does_not_propagate( self, @@ -146,15 +138,18 @@ class TestAnnotateInsideRun: call_purpose="dialectic.answer", agent_type="dialectic", run_id="run-abc", + span_id="run-abc", + # A non-null parent_span_id is what marks this generation as nested + # under the run span (replaces the old `_in_agent_run` contextvar). + parent_span_id="run-abc", iteration=2, peer_name="alice", track_name="Dialectic Agent", ) - with _inside_agent_run(): - runtime.annotate_current_langfuse_trace( - "anthropic", "claude-x", telemetry=telemetry - ) + runtime.annotate_current_langfuse_trace( + "anthropic", "claude-x", telemetry=telemetry + ) # Run handle owns user_id/session_id/trace_name — re-propagating here # would clobber the run's session, so we don't propagate at all. @@ -300,23 +295,45 @@ class TestAgentRun: finally: handle.end() - def test_marks_in_agent_run_for_nested_calls( + def test_run_keyed_on_span_id_and_nesting_via_parent_span_id( self, langfuse_enabled: None, langfuse_client: dict[str, dict[str, Any]], capture_propagate: dict[str, Any], ): - # While the run handle is live, nested generations see _in_agent_run - # set so they stay silent (the run owns the trace attrs); end() resets it. - assert runtime._in_agent_run.get() is False + # The `_in_agent_run` contextvar is retired — nesting is now derived + # from the explicit `parent_span_id` field on the telemetry context. + assert not hasattr(runtime, "_in_agent_run") + + # The run handle opens keyed on span_id (falling back to run_id). handle = runtime.start_langfuse_agent_run( "Dialectic Agent", - LLMTelemetryContext(run_id="r1", track_name="Dialectic Agent"), + LLMTelemetryContext( + run_id="r1", span_id="r1", track_name="Dialectic Agent" + ), ) assert handle is not None - assert runtime._in_agent_run.get() is True handle.end() - assert runtime._in_agent_run.get() is False + + # A root call (no parent_span_id) propagates trace attrs; a nested call + # (parent_span_id set) stays silent. + capture_propagate.clear() + runtime.annotate_current_langfuse_trace( + "anthropic", + "claude-x", + telemetry=LLMTelemetryContext(run_id="r2", span_id="r2"), + ) + assert capture_propagate.get("session_id") == "r2" + + capture_propagate.clear() + runtime.annotate_current_langfuse_trace( + "anthropic", + "claude-x", + telemetry=LLMTelemetryContext( + run_id="r2", span_id="r2", parent_span_id="r2" + ), + ) + assert capture_propagate == {} def test_end_is_idempotent( self, @@ -497,3 +514,49 @@ class TestStepIO: assert langfuse_client["run_span"]["output"] == { "tool_calls": ["grep_messages", "search_memory"] } + + +class TestAnnotateGenerationIOGating: + """`annotate_current_generation_io` writes to the ACTIVE @observe generation + span (the `conditional_observe` wrapper), which only exists in inline mode. + In exporter mode — the default — there is no active span, so calling + `update_current_generation()` would make the Langfuse SDK log "No active span + in current context" on every LLM call. The helper must therefore no-op in + exporter mode (the LangfuseExporter projects I/O from the captured stream). + Regression guard for the gate that was on LANGFUSE_PUBLIC_KEY instead of + langfuse_inline_enabled.""" + + def test_noops_in_exporter_mode_even_with_key( + self, + monkeypatch: pytest.MonkeyPatch, + langfuse_client: dict[str, dict[str, Any]], + ): + # Key present but exporter mode (the production default). + monkeypatch.setattr(settings, "LANGFUSE_PUBLIC_KEY", "pk-test") + monkeypatch.setattr(settings, "LANGFUSE_EXPORTER_MODE", "exporter") + + runtime.annotate_current_generation_io( + input=[{"role": "user", "content": "hi"}], + output="hello", + usage_details={"input": 1, "output": 1}, + ) + + # No active generation span in exporter mode → must not touch it. + assert langfuse_client["generation"] == {} + + def test_writes_in_inline_mode( + self, + langfuse_enabled: None, # pins inline mode + a key + langfuse_client: dict[str, dict[str, Any]], + ): + messages = [{"role": "user", "content": "hi"}] + runtime.annotate_current_generation_io( + input=messages, + output="hello", + usage_details={"input": 1, "output": 1}, + ) + + gen = langfuse_client["generation"] + assert gen["input"] == messages + assert gen["output"] == "hello" + assert gen["usage_details"] == {"input": 1, "output": 1} diff --git a/tests/llm/test_telemetry_agent_iteration.py b/tests/llm/test_telemetry_agent_iteration.py index 382cc2e3..71f5c4ba 100644 --- a/tests/llm/test_telemetry_agent_iteration.py +++ b/tests/llm/test_telemetry_agent_iteration.py @@ -45,7 +45,7 @@ def _response( class TestTelemetryForIteration: def test_returns_none_when_base_is_none(self): - assert _telemetry_for_iteration(None, 1) is None + assert _telemetry_for_iteration(None, 1, step_seq=1) is None def test_returns_fresh_copy_with_iteration_set(self): base = LLMTelemetryContext( @@ -54,12 +54,13 @@ class TestTelemetryForIteration: parent_category="dialectic", agent_type="dialectic", run_id="run-xyz", + span_id="run-xyz", iteration=None, peer_name="user_peer", ) - copy_a = _telemetry_for_iteration(base, 3) - copy_b = _telemetry_for_iteration(base, 4) + copy_a = _telemetry_for_iteration(base, 3, step_seq=3) + copy_b = _telemetry_for_iteration(base, 4, step_seq=4) assert copy_a is not None and copy_b is not None assert copy_a is not base and copy_b is not base @@ -67,6 +68,9 @@ class TestTelemetryForIteration: assert base.iteration is None assert copy_a.iteration == 3 assert copy_b.iteration == 4 + # Per-step correlation is set; parent_span_id is derived from the span. + assert copy_a.step_seq == 3 + assert copy_a.parent_span_id == "run-xyz" # All other fields round-trip. assert copy_a.run_id == "run-xyz" assert copy_a.peer_name == "user_peer" diff --git a/tests/telemetry/conftest.py b/tests/telemetry/conftest.py index f871e22d..442f6264 100644 --- a/tests/telemetry/conftest.py +++ b/tests/telemetry/conftest.py @@ -35,6 +35,42 @@ from src.telemetry.events.reconciliation import ( ) from src.telemetry.events.representation import RepresentationCompletedEvent +# ============================================================================= +# Global trace-state isolation +# ============================================================================= + + +@pytest.fixture(autouse=True) +def _isolate_trace_globals(): # pyright: ignore[reportUnusedFunction] + """Snapshot/restore process-global trace state around every telemetry test. + + `initialize_telemetry_events()` (exercised in test_emit_function) registers a + real ``TraceExporter`` into ``capture._EXPORTERS`` and starts a real trace + emitter (``emitter._trace_emitter``). Without cleanup that state bleeds into + the trace-exporter tests, which then fail — and because xdist schedules tests + across workers nondeterministically, the failure looks flaky (a different + trace test fails each run depending on who shared its worker). + + Snapshotting these globals (and resetting the per-run dedup) before/after + each test makes the trace tests hermetic regardless of neighbor ordering. + """ + from src.llm import capture + from src.telemetry import emitter as emitter_mod + from src.telemetry import langfuse_session, trace_session + + saved_exporters = list(capture._EXPORTERS) # pyright: ignore[reportPrivateUsage] + saved_trace_emitter = emitter_mod._trace_emitter # pyright: ignore[reportPrivateUsage] + trace_session.reset() + langfuse_session.reset() + try: + yield + finally: + capture._EXPORTERS[:] = saved_exporters # pyright: ignore[reportPrivateUsage] + emitter_mod._trace_emitter = saved_trace_emitter # pyright: ignore[reportPrivateUsage] + trace_session.reset() + langfuse_session.reset() + + # ============================================================================= # Fixed timestamp for deterministic tests # ============================================================================= diff --git a/tests/telemetry/test_cross_agent_trace.py b/tests/telemetry/test_cross_agent_trace.py new file mode 100644 index 00000000..b6364ed4 --- /dev/null +++ b/tests/telemetry/test_cross_agent_trace.py @@ -0,0 +1,215 @@ +# pyright: reportPrivateUsage=false, reportUnannotatedClassAttribute=false, reportUnusedFunction=false, reportUnknownLambdaType=false, reportUnknownArgumentType=false, reportArgumentType=false +"""Cross-agent trace-metadata contract. + +Verifies that each agent's telemetry produces a well-formed `CapturedLLMCall` +with the right correlation/session/identity fields, and that the SAME captured +call fans out to BOTH exporters (CloudEvents + Langfuse) — the "one data model, +two projections" invariant. + +This is the metadata-correctness bar across agents. It drives the real +`DialecticAgent` telemetry and the real `dispatch_captured_call`; the other +agents are represented by the telemetry contexts they construct (cited inline). +Full end-to-end capture through a live stack (real subprocesses feeding a trace +sink) is exercised separately, outside this unit suite. +""" + +from __future__ import annotations + +import pytest + +from src.config import settings +from src.dialectic.core import DialecticAgent +from src.llm import capture as capture_mod +from src.llm.backend import CompletionResult +from src.llm.capture import ( + CapturedLLMCall, + build_captured_call, + dispatch_captured_call, + register_exporter, +) +from src.llm.types import LLMTelemetryContext +from src.telemetry.langfuse_exporter import LangfuseExporter + + +class SpyExporter: + def __init__(self) -> None: + self.calls: list[CapturedLLMCall] = [] + + def export(self, call: CapturedLLMCall) -> None: + self.calls.append(call) + + +@pytest.fixture +def spy() -> SpyExporter: + """Clean exporter registry with a single spy (restored by the telemetry + conftest's _isolate_trace_globals).""" + capture_mod._EXPORTERS.clear() + exporter = SpyExporter() + register_exporter(exporter) + return exporter + + +def _dispatch(telemetry: LLMTelemetryContext, *, content: str = "answer") -> None: + call = build_captured_call( + telemetry=telemetry, + transport="anthropic", + provider_label=None, + model="claude-x", + messages=[{"role": "user", "content": "q"}], + tools=None, + tool_choice=None, + result=CompletionResult(content=content, finish_reason="stop"), + attempt=1, + was_fallback=False, + was_stream=False, + finish_reason="stop", + ) + dispatch_captured_call(call) + + +# --- Dialectic: real agent telemetry -------------------------------------- + + +def test_dialectic_with_session_sets_session_id(spy: SpyExporter): + agent = DialecticAgent( + workspace_name="ws", + session_name="my-session", + session_id="sess-nanoid", + observer="alice", + observed="bob", + ) + _dispatch(agent._telemetry_context("Dialectic Agent")) + + call = spy.calls[-1] + assert call.session_id == "sess-nanoid" + assert call.agent_type == "dialectic" + # Root of the invocation: trace_id == span_id == run_id, parent normalized off. + assert call.trace_id == call.span_id == agent._run_id + assert call.parent_span_id is None + assert call.track_name == "Dialectic Agent" + + +def test_dialectic_global_has_no_session(spy: SpyExporter): + agent = DialecticAgent( + workspace_name="ws", + session_name=None, + session_id=None, + observer="alice", + observed="alice", + ) + _dispatch(agent._telemetry_context("Dialectic Agent")) + assert spy.calls[-1].session_id is None + + +# --- Background agents: sessionless single-shot / shared-tree contracts ----- + + +@pytest.mark.parametrize( + ("call_purpose", "parent_category", "track_name"), + [ + ("deriver.representation", "representation", "Minimal Deriver"), + ("summary.short", "summary", None), + ], +) +def test_background_agents_are_sessionless_single_shot( + spy: SpyExporter, + call_purpose: str, + parent_category: str, + track_name: str | None, +): + # Deriver + summarizer mirror their src/ contexts: trace_id == span_id, no + # run_id/session_id, self-rooted. + tid = f"{parent_category}-trace" + _dispatch( + LLMTelemetryContext( + workspace_name="ws", + call_purpose=call_purpose, + parent_category=parent_category, + track_name=track_name, + trace_id=tid, + span_id=tid, + ) + ) + call = spy.calls[-1] + assert call.session_id is None + assert call.run_id is None + assert call.trace_id == call.span_id == tid + assert call.parent_span_id is None + + +def test_dreamer_specialists_share_one_tree(spy: SpyExporter): + # Single-dream-tree (this PR): both specialists reuse the orchestrator run_id + # as trace_id (src/dreamer/specialists.py), session_id None. + run_id = "dream-run" + for agent_type in ("deduction", "induction"): + _dispatch( + LLMTelemetryContext( + workspace_name="ws", + call_purpose=f"dream.{agent_type}", + parent_category="dream", + agent_type=agent_type, + run_id=run_id, + trace_id=run_id, + span_id=run_id, + observer="assistant", + observed="bob", + iteration=1, + ) + ) + ded, ind = spy.calls[-2], spy.calls[-1] + assert ded.trace_id == ind.trace_id == run_id # one shared tree + assert ded.session_id is None and ind.session_id is None + assert ded.agent_type == "deduction" and ind.agent_type == "induction" + + +# --- One data model, two projections --------------------------------------- + + +def test_same_call_reaches_both_exporters( + spy: SpyExporter, monkeypatch: pytest.MonkeyPatch +): + """A dispatched call fans out to the CloudEvents spy AND the LangfuseExporter.""" + monkeypatch.setattr(settings, "LANGFUSE_PUBLIC_KEY", "pk-test") + monkeypatch.setattr(settings, "LANGFUSE_EXPORTER_MODE", "exporter") + monkeypatch.setattr(settings, "NAMESPACE", "tenant1") + + created: list[dict[str, object]] = [] + + class FakeOtel: + def set_attribute(self, *_a: object) -> None: ... + + class FakeObs: + def __init__(self, **kwargs: object) -> None: + self.id = "obs" + self.kwargs = kwargs + self._otel_span = FakeOtel() + + def end(self) -> None: ... + + class FakeClient: + def create_trace_id(self, *, seed: str | None = None) -> str: + return f"lf-{seed}" + + def start_observation(self, **kwargs: object) -> FakeObs: + created.append(kwargs) + return FakeObs(**kwargs) + + import langfuse + + monkeypatch.setattr(langfuse, "get_client", lambda: FakeClient()) + register_exporter(LangfuseExporter()) + + agent = DialecticAgent( + workspace_name="ws", + session_name="s", + session_id="sess-1", + observer="alice", + observed="bob", + ) + _dispatch(agent._telemetry_context("Dialectic Agent")) + + # CloudEvents projection saw the raw captured call... + assert spy.calls and spy.calls[-1].session_id == "sess-1" + # ...and the Langfuse projection built observations from the SAME call. + assert created, "LangfuseExporter produced no observations" + assert any(o.get("as_type") == "generation" for o in created) diff --git a/tests/telemetry/test_embedding_trace.py b/tests/telemetry/test_embedding_trace.py new file mode 100644 index 00000000..a878e4f3 --- /dev/null +++ b/tests/telemetry/test_embedding_trace.py @@ -0,0 +1,111 @@ +# pyright: reportPrivateUsage=false, reportUnannotatedClassAttribute=false, reportUnusedFunction=false, reportUnknownLambdaType=false, reportUnknownArgumentType=false, reportArgumentType=false +"""Tests for embedding calls joining the trace stream. + +`_publish_embedding_event` emits an `EmbeddingCallTracedEvent` (gated on +TRACE_PAYLOADS_ENABLED) in addition to the metrics-grade completed event, carrying the +span-tree correlation from the embedding ContextVars so an embedding made inside +an agent run nests under that run's trace. +""" + +from __future__ import annotations + +import pytest + +import src.telemetry.events as events_mod +from src.config import settings +from src.embedding_client import _publish_embedding_event +from src.telemetry.events.trace import EmbeddingCallTracedEvent +from src.utils.types import embedding_call_purpose + + +@pytest.fixture +def capture_emits(monkeypatch: pytest.MonkeyPatch): + """Capture emit()/emit_trace() without a live emitter.""" + traced: list[object] = [] + monkeypatch.setattr(events_mod, "emit", lambda _e: None) + monkeypatch.setattr(events_mod, "emit_trace", lambda e: traced.append(e)) + return traced + + +def test_embedding_traced_event_carries_correlation( + capture_emits: list[object], monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(settings.TELEMETRY, "TRACE_PAYLOADS_ENABLED", True) + with embedding_call_purpose( + "dialectic.prefetch", + workspace_name="ws", + run_id="run-1", + parent_category="dialectic", + session_id="sess-1", + ): + _publish_embedding_event( + provider="openai", + model="text-embedding-3", + input_count=1, + input_tokens_estimate=7, + duration_ms=1.0, + outcome="success", + error=None, + is_final_attempt=True, + ) + + assert len(capture_emits) == 1 + ev = capture_emits[0] + assert isinstance(ev, EmbeddingCallTracedEvent) + # The embedding gets its own span under the run: trace_id/parent are the + # run_id, span_id is a fresh id so sibling embeddings don't collide. + assert ev.trace_id == "run-1" + assert ev.parent_span_id == "run-1" + assert ev.span_id and ev.span_id != "run-1" + assert ev.session_id == "sess-1" + assert ev.call_purpose == "dialectic.prefetch" + assert ev.parent_category == "dialectic" + assert ev.provider == "openai" and ev.model == "text-embedding-3" + assert ev.provider_input_tokens == 7 + assert ev.provider_output_tokens == 0 + assert ev.input_count == 1 + + +def test_no_trace_event_when_payloads_off( + capture_emits: list[object], monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(settings.TELEMETRY, "TRACE_PAYLOADS_ENABLED", False) + with embedding_call_purpose("dialectic.prefetch", run_id="run-1"): + _publish_embedding_event( + provider="openai", + model="m", + input_count=2, + input_tokens_estimate=3, + duration_ms=1.0, + outcome="success", + error=None, + is_final_attempt=True, + ) + assert capture_emits == [] + + +def test_sessionless_embedding_has_no_session( + capture_emits: list[object], monkeypatch: pytest.MonkeyPatch +): + # A deriver/reconciler embedding (no session scope) traces with session None. + monkeypatch.setattr(settings.TELEMETRY, "TRACE_PAYLOADS_ENABLED", True) + with embedding_call_purpose( + "deriver", workspace_name="ws", parent_category="deriver" + ): + _publish_embedding_event( + provider="gemini", + model="emb", + input_count=5, + input_tokens_estimate=20, + duration_ms=2.0, + outcome="success", + error=None, + is_final_attempt=True, + ) + assert len(capture_emits) == 1 + ev = capture_emits[0] + assert isinstance(ev, EmbeddingCallTracedEvent) + assert ev.session_id is None + # No run_id → the span self-roots (trace_id == span_id) with no parent. + assert ev.parent_span_id is None + assert ev.span_id and ev.trace_id == ev.span_id diff --git a/tests/telemetry/test_emit_function.py b/tests/telemetry/test_emit_function.py index c4e2c8ad..1e8f78f9 100644 --- a/tests/telemetry/test_emit_function.py +++ b/tests/telemetry/test_emit_function.py @@ -314,6 +314,11 @@ class TestInitializeTelemetryEvents: mock_settings.TELEMETRY.FLUSH_THRESHOLD = 50 mock_settings.TELEMETRY.MAX_RETRIES = 3 mock_settings.TELEMETRY.MAX_BUFFER_SIZE = 10000 + # This test only covers the primary emitter. Pin trace payloads off + # so we don't fall into the trace branch and start a *real* trace + # emitter + register a real TraceExporter (a MagicMock here is + # truthy) — that global state would leak into other tests. + mock_settings.TELEMETRY.TRACE_PAYLOADS_ENABLED = False mock_init.return_value = AsyncMock() await initialize_telemetry_events() @@ -378,8 +383,10 @@ class TestInitializeTelemetryAsync: mock_ce_init.assert_called_once() @pytest.mark.asyncio - async def test_skip_cloudevents_when_disabled(self): - """initialize_telemetry_async() skips CloudEvents when disabled.""" + async def test_skip_when_telemetry_disabled(self): + """TELEMETRY.ENABLED is the master switch — no init when it's off, even + with the Langfuse exporter configured (no traces for open-source users + who leave telemetry off).""" from src.telemetry import initialize_telemetry_async with ( @@ -390,6 +397,7 @@ class TestInitializeTelemetryAsync: ) as mock_ce_init, ): mock_settings.TELEMETRY.ENABLED = False + mock_settings.langfuse_exporter_enabled = True await initialize_telemetry_async() diff --git a/tests/telemetry/test_events.py b/tests/telemetry/test_events.py index ef6f4ffb..bce0a38c 100644 --- a/tests/telemetry/test_events.py +++ b/tests/telemetry/test_events.py @@ -759,10 +759,11 @@ class TestAgentToolSummaryCreatedEvent: def test_get_resource_id( self, sample_summary_created_event: AgentToolSummaryCreatedEvent ): - """get_resource_id() returns run_id:iteration:summary_created format.""" + """get_resource_id() keys on message_id:summary_type (run_id/iteration are + None for the non-agentic summarizer and can't identify the summary).""" assert ( sample_summary_created_event.get_resource_id() - == "ghi11111:1:summary_created" + == "msg_020:short:summary_created" ) def test_summary_type_values(self, fixed_timestamp: datetime): diff --git a/tests/telemetry/test_langfuse_exporter.py b/tests/telemetry/test_langfuse_exporter.py new file mode 100644 index 00000000..36f3c9c2 --- /dev/null +++ b/tests/telemetry/test_langfuse_exporter.py @@ -0,0 +1,438 @@ +# pyright: reportPrivateUsage=false, reportUnannotatedClassAttribute=false, reportUnusedFunction=false, reportUnknownLambdaType=false, reportUnknownArgumentType=false, reportArgumentType=false, reportIndexIssue=false +"""Tests for the Langfuse projection over the captured LLM stream. + +Exercises `LangfuseExporter` with a fake Langfuse client so we can assert the +reconstructed trace tree (trace ids, parent linkage, names, usage, trace-level +user attributes, session-as-metadata) without a real Langfuse backend. +""" + +from __future__ import annotations + +import pytest + +from src.config import settings +from src.llm.backend import CompletionResult, ToolCallResult +from src.llm.capture import build_captured_call +from src.llm.types import LLMTelemetryContext +from src.telemetry import langfuse_session +from src.telemetry.langfuse_exporter import LangfuseExporter + + +class FakeOtelSpan: + def __init__(self) -> None: + self.attributes: dict[str, object] = {} + + def set_attribute(self, key: str, value: object) -> None: + self.attributes[key] = value + + +class FakeObs: + _counter = 0 + + def __init__(self, **kwargs: object) -> None: + FakeObs._counter += 1 + self.id = f"obs-{FakeObs._counter}" + self.kwargs = kwargs + self._otel_span = FakeOtelSpan() + self.ended = False + + def end(self) -> None: + self.ended = True + + +class FakeClient: + def __init__(self) -> None: + self.observations: list[FakeObs] = [] + + def create_trace_id(self, *, seed: str | None = None) -> str: + return f"lf-{seed}" + + def start_observation(self, **kwargs: object) -> FakeObs: + obs = FakeObs(**kwargs) + self.observations.append(obs) + return obs + + +@pytest.fixture(autouse=True) +def _exporter_env(monkeypatch: pytest.MonkeyPatch): + """Enable the exporter and install a fake langfuse client + clean registry.""" + monkeypatch.setattr(settings, "LANGFUSE_PUBLIC_KEY", "pk-test") + monkeypatch.setattr(settings, "LANGFUSE_EXPORTER_MODE", "exporter") + monkeypatch.setattr(settings, "NAMESPACE", "tenant1") + client = FakeClient() + import langfuse + + monkeypatch.setattr(langfuse, "get_client", lambda: client) + langfuse_session.reset() + FakeObs._counter = 0 + yield client + langfuse_session.reset() + + +def _call( + *, + run_id: str | None, + trace_id: str, + iteration: int | None = None, + step_seq: int = 0, + attempt: int = 1, + session_id: str | None = None, + track_name: str | None = None, + agent_type: str = "dialectic", + parent_category: str = "dialectic", + tool_names: list[str] | None = None, + finish_reason: str = "stop", + content: str = "answer", +): + telemetry = LLMTelemetryContext( + workspace_name="ws", + call_purpose="dialectic.answer", + parent_category=parent_category, + agent_type=agent_type, + run_id=run_id, + trace_id=trace_id, + span_id=trace_id, + session_id=session_id, + track_name=track_name, + iteration=iteration, + step_seq=step_seq, + ) + result = CompletionResult( + content=content, + input_tokens=10, + output_tokens=5, + cache_read_input_tokens=2, + finish_reason=finish_reason, + tool_calls=[ + ToolCallResult(id=f"tc-{i}", name=name, input={"q": name}) + for i, name in enumerate(tool_names or []) + ], + ) + return build_captured_call( + telemetry=telemetry, + transport="anthropic", + provider_label=None, + model="claude-x", + messages=[{"role": "user", "content": "q"}], + tools=None, + tool_choice=None, + result=result, + attempt=attempt, + was_fallback=False, + was_stream=False, + finish_reason=finish_reason, + ) + + +def test_single_shot_generation_is_trace_root(_exporter_env: FakeClient): + # Deriver/summarizer style: run_id None → no run/step span, generation is root. + client = _exporter_env + LangfuseExporter().export( + _call(run_id=None, trace_id="t1", track_name="Minimal Deriver") + ) + + assert len(client.observations) == 1 + gen = client.observations[0] + assert gen.kwargs["as_type"] == "generation" + assert gen.kwargs["trace_context"] == {"trace_id": "lf-t1"} + assert gen.kwargs["model"] == "claude-x" + assert gen.kwargs["usage_details"] == { + "input": 10, + "output": 5, + "cache_read_input_tokens": 2, + "cache_creation_input_tokens": 0, + } + # Trace attrs stamped on the root generation; no session (session_id None). + assert gen._otel_span.attributes.get("user.id") == "tenant1" + assert "session.id" not in gen._otel_span.attributes + + +def test_agentic_run_builds_run_step_generation(_exporter_env: FakeClient): + client = _exporter_env + LangfuseExporter().export( + _call( + run_id="r1", + trace_id="r1", + iteration=1, + session_id="sess_abc", + track_name="Dialectic Agent", + ) + ) + + by_type: dict[str, list[FakeObs]] = {} + for obs in client.observations: + by_type.setdefault(str(obs.kwargs["as_type"]), []).append(obs) + assert len(by_type["span"]) == 2 # run span + step span + assert len(by_type["generation"]) == 1 + + run_span, step_span = by_type["span"] + gen = by_type["generation"][0] + assert run_span.kwargs["trace_context"] == {"trace_id": "lf-r1"} + assert step_span.kwargs["trace_context"] == { + "trace_id": "lf-r1", + "parent_span_id": run_span.id, + } + assert gen.kwargs["trace_context"] == { + "trace_id": "lf-r1", + "parent_span_id": step_span.id, + } + # Trace attrs stamped once, on the run span (the root). The Honcho session is + # NOT a Langfuse session (one-shot queries aren't a conversation thread) — it + # rides in metadata as a correlation key instead. + assert "session.id" not in run_span._otel_span.attributes + assert run_span._otel_span.attributes["user.id"] == "tenant1" + assert run_span._otel_span.attributes["langfuse.trace.name"] == "Dialectic Agent" + assert run_span.kwargs["metadata"]["honcho_session"] == "sess_abc" + + +def test_run_span_created_once_across_iterations(_exporter_env: FakeClient): + client = _exporter_env + exporter = LangfuseExporter() + exporter.export(_call(run_id="r1", trace_id="r1", iteration=1, session_id="s")) + exporter.export(_call(run_id="r1", trace_id="r1", iteration=2, session_id="s")) + + spans = [o for o in client.observations if o.kwargs["as_type"] == "span"] + gens = [o for o in client.observations if o.kwargs["as_type"] == "generation"] + # One run span shared, one step span per iteration, one generation per call. + assert len(gens) == 2 + assert len(spans) == 3 # 1 run + 2 step + # Trace attrs (user/name) stamped exactly once across the whole run. + stamped = [o for o in client.observations if "user.id" in o._otel_span.attributes] + assert len(stamped) == 1 + + +def test_langfuse_session_lru_evicts_least_recently_used( + monkeypatch: pytest.MonkeyPatch, +): + """Past _MAX_TRACES the least-recently-touched trace is evicted (not refused), + so an active trace keeps its remembered span ids no matter the run volume.""" + langfuse_session.reset() + monkeypatch.setattr(langfuse_session, "_MAX_TRACES", 2) + + langfuse_session.ensure_run_span("t1", "b", lambda _s: "t1-span") + langfuse_session.ensure_run_span("t2", "b", lambda _s: "t2-span") + # Touch t1 so t2 becomes the least-recently-used trace. + assert ( + langfuse_session.ensure_run_span("t1", "b", lambda _s: "ignored") == "t1-span" + ) + # A third trace evicts the LRU trace (t2), keeping t1. + langfuse_session.ensure_run_span("t3", "b", lambda _s: "t3-span") + + created: list[str] = [] + # t1 still tracked → remembered span returned, create NOT re-invoked. + assert ( + langfuse_session.ensure_run_span( + "t1", "b", lambda _s: created.append("t1") or "new" + ) + == "t1-span" + ) + assert created == [] + # t2 was evicted → fresh state, create IS re-invoked. + assert ( + langfuse_session.ensure_run_span( + "t2", "b", lambda _s: created.append("t2") or "t2-span2" + ) + == "t2-span2" + ) + assert created == ["t2"] + + +def test_error_finish_marks_generation_level(_exporter_env: FakeClient): + client = _exporter_env + LangfuseExporter().export( + _call(run_id=None, trace_id="t1", finish_reason="error", content="") + ) + gen = client.observations[0] + assert gen.kwargs["level"] == "ERROR" + + +@pytest.mark.parametrize( + ("attr", "value"), + [ + ("LANGFUSE_EXPORTER_MODE", "inline"), # exporter off in inline mode + ("LANGFUSE_PUBLIC_KEY", None), # exporter off without a public key + ], +) +def test_exporter_disabled_emits_nothing( + _exporter_env: FakeClient, + monkeypatch: pytest.MonkeyPatch, + attr: str, + value: object, +): + client = _exporter_env + monkeypatch.setattr(settings, attr, value) + LangfuseExporter().export(_call(run_id="r1", trace_id="r1", iteration=1)) + assert client.observations == [] + + +def test_generation_name_uses_generation_suffix(_exporter_env: FakeClient): + client = _exporter_env + LangfuseExporter().export( + _call(run_id="r1", trace_id="r1", iteration=1, track_name="Dialectic Agent") + ) + gen = [o for o in client.observations if o.kwargs["as_type"] == "generation"][0] + assert gen.kwargs["name"] == "Dialectic Agent generation" + step = [o for o in client.observations if o.kwargs["as_type"] == "span"][1] + assert step.kwargs["name"] == "Dialectic Agent step" + + +def test_tool_calls_become_spans_under_the_step(_exporter_env: FakeClient): + client = _exporter_env + LangfuseExporter().export( + _call( + run_id="r1", + trace_id="r1", + iteration=1, + track_name="Dialectic Agent", + tool_names=["search_memory", "search_messages"], + ) + ) + + spans = [o for o in client.observations if o.kwargs["as_type"] == "span"] + gen = [o for o in client.observations if o.kwargs["as_type"] == "generation"][0] + tools = [o for o in client.observations if o.kwargs["as_type"] == "tool"] + step_span = spans[1] # run span, then step span + + assert [t.kwargs["name"] for t in tools] == ["search_memory", "search_messages"] + # Tool spans are siblings of the generation: same parent (the step span). + for t in tools: + assert t.kwargs["trace_context"]["parent_span_id"] == step_span.id + assert gen.kwargs["trace_context"]["parent_span_id"] == step_span.id + # The model's requested input args ride on the tool span. + assert tools[0].kwargs["input"] == {"q": "search_memory"} + + +def test_only_the_root_span_keeps_as_root(_exporter_env: FakeClient): + # The SDK stamps AS_ROOT on every trace_context span; the exporter must + # demote children so exactly one root survives — otherwise Langfuse races to + # pick the trace name/root and names the trace after a child span. + from langfuse import LangfuseOtelSpanAttributes as Attr + + client = _exporter_env + LangfuseExporter().export( + _call( + run_id="r1", + trace_id="r1", + iteration=1, + track_name="Dialectic Agent", + tool_names=["search_memory"], + ) + ) + + def is_demoted(obs: FakeObs) -> bool: + return obs._otel_span.attributes.get(Attr.AS_ROOT) is False + + spans = [o for o in client.observations if o.kwargs["as_type"] == "span"] + run_span, step_span = spans[0], spans[1] + gen = [o for o in client.observations if o.kwargs["as_type"] == "generation"][0] + tools = [o for o in client.observations if o.kwargs["as_type"] == "tool"] + + # Exactly one root: the run span is never demoted; everything with a real + # parent is. + assert not is_demoted(run_span) + assert is_demoted(step_span) + assert is_demoted(gen) + assert all(is_demoted(t) for t in tools) + demoted = [o for o in client.observations if is_demoted(o)] + assert len(demoted) == len(client.observations) - 1 + + +def test_single_shot_generation_keeps_as_root(_exporter_env: FakeClient): + # No parent → the generation is the trace root and must not be demoted. + from langfuse import LangfuseOtelSpanAttributes as Attr + + client = _exporter_env + LangfuseExporter().export( + _call(run_id=None, trace_id="t1", track_name="Minimal Deriver") + ) + gen = client.observations[0] + assert gen._otel_span.attributes.get(Attr.AS_ROOT) is not False + + +def test_single_shot_tool_calls_are_skipped(_exporter_env: FakeClient): + # No step span to anchor to (deriver-style); tools don't orphan to the root. + client = _exporter_env + LangfuseExporter().export( + _call(run_id=None, trace_id="t1", tool_names=["search_memory"]) + ) + assert [o.kwargs["as_type"] for o in client.observations] == ["generation"] + + +def test_dreamer_specialists_nest_under_one_dream_root(_exporter_env: FakeClient): + # Both specialists share ONE dream trace (run_id) and both start at + # iteration 1. They must nest under a single synthetic "Dream" root (so the + # trace has one root, not one per specialist) while staying distinct + # sub-trees (no step-span collision). + from langfuse import LangfuseOtelSpanAttributes as Attr + + client = _exporter_env + exporter = LangfuseExporter() + for agent_type in ("deduction", "induction"): + exporter.export( + _call( + run_id="dream1", + trace_id="dream1", + iteration=1, + agent_type=agent_type, + parent_category="dream", + track_name=f"Dreamer/{agent_type}", + ) + ) + + by_name: dict[str, list[FakeObs]] = {} + for o in client.observations: + by_name.setdefault(str(o.kwargs["name"]), []).append(o) + gens = [o for o in client.observations if o.kwargs["as_type"] == "generation"] + + def is_demoted(o: FakeObs) -> bool: + return o._otel_span.attributes.get(Attr.AS_ROOT) is False + + # Exactly one trace root: the synthetic "Dream" span — no parent, not demoted. + roots = [ + o + for o in client.observations + if o.kwargs["trace_context"] == {"trace_id": "lf-dream1"} + ] + assert len(roots) == 1 + dream_root = roots[0] + assert dream_root.kwargs["name"] == "Dream" + assert dream_root.kwargs["as_type"] == "span" + assert not is_demoted(dream_root) + + # Both specialist run spans hang off the Dream root and are demoted. + run_dd = by_name["Dreamer/deduction"][0] + run_in = by_name["Dreamer/induction"][0] + assert len(by_name["Dreamer/deduction"]) == 1 + assert len(by_name["Dreamer/induction"]) == 1 + for rs in (run_dd, run_in): + assert rs.kwargs["trace_context"] == { + "trace_id": "lf-dream1", + "parent_span_id": dream_root.id, + } + assert is_demoted(rs) + + # One step span per specialist, parented to its own run span; no collapsing. + assert len(by_name["Dreamer/deduction step"]) == 1 + assert len(by_name["Dreamer/induction step"]) == 1 + assert ( + by_name["Dreamer/deduction step"][0].kwargs["trace_context"]["parent_span_id"] + == run_dd.id + ) + assert ( + by_name["Dreamer/induction step"][0].kwargs["trace_context"]["parent_span_id"] + == run_in.id + ) + + # Each generation nests under its OWN specialist's step. + assert len({g.kwargs["trace_context"]["parent_span_id"] for g in gens}) == 2 + + # Trace name is the branch-agnostic "Dream", stamped exactly once — on the + # Dream root, not on a specialist's run span. + named = [ + o + for o in client.observations + if o._otel_span.attributes.get("langfuse.trace.name") + ] + assert len(named) == 1 + assert named[0] is dream_root + assert named[0]._otel_span.attributes["langfuse.trace.name"] == "Dream" diff --git a/tests/telemetry/test_trace_events.py b/tests/telemetry/test_trace_events.py new file mode 100644 index 00000000..35da6492 --- /dev/null +++ b/tests/telemetry/test_trace_events.py @@ -0,0 +1,218 @@ +"""Tests for full-fidelity trace events, dedup, and the CloudEvents exporter.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from src.llm.backend import CompletionResult +from src.llm.capture import CapturedLLMCall, build_captured_call +from src.telemetry import trace_session +from src.telemetry.events.trace import LLMCallTracedEvent, TraceContentEvent + + +class TestLLMCallTracedEvent: + def test_metadata(self): + assert LLMCallTracedEvent.event_type() == "llm.call.traced" + assert LLMCallTracedEvent.category() == "trace" + # Ground-truth — never sampled (the system of record). + assert LLMCallTracedEvent.volume_class() == "ground_truth" + + def test_resource_id_format(self): + event = LLMCallTracedEvent( + span_id="s1", + iteration=2, + attempt=1, + step_seq=3, + transport="anthropic", + model="m", + ) + # {span_id}:{iteration}:{attempt}:{step_seq} — tool_call_seq dropped. + assert event.get_resource_id() == "s1:2:1:3" + + def test_keeps_default_evt_id(self): + event = LLMCallTracedEvent( + span_id="s1", + iteration=1, + attempt=1, + step_seq=1, + transport="anthropic", + model="m", + ) + event_id = event.generate_id() + assert event_id.startswith("evt_") + assert len(event_id) == 26 + + +class TestTraceContentEvent: + def test_metadata(self): + assert TraceContentEvent.event_type() == "trace.content" + assert TraceContentEvent.category() == "trace" + assert TraceContentEvent.volume_class() == "ground_truth" + + def test_resource_id_is_content_hash(self): + event = TraceContentEvent(content_hash="sha256:abc", role="user", content="hi") + assert event.get_resource_id() == "sha256:abc" + + def test_generate_id_is_content_addressed_and_timestamp_free(self): + # Two instances with the same hash but DIFFERENT timestamps must collide + # on id, so cross-process/retry re-sends dedupe at the transport layer. + import datetime + + a = TraceContentEvent( + content_hash="sha256:deadbeefdeadbeefdeadbeef", + role="user", + content="hi", + timestamp=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC), + ) + b = TraceContentEvent( + content_hash="sha256:deadbeefdeadbeefdeadbeef", + role="user", + content="hi", + timestamp=datetime.datetime(2026, 6, 22, tzinfo=datetime.UTC), + ) + assert a.generate_id() == b.generate_id() + assert a.generate_id().startswith("content_") + # Different content → different id. + c = TraceContentEvent(content_hash="sha256:other", role="user", content="hi") + assert c.generate_id() != a.generate_id() + + +class TestTraceSessionDedup: + def setup_method(self): + trace_session.reset() + + def teardown_method(self): + trace_session.reset() + + def test_first_emit_true_repeat_false(self): + assert trace_session.mark_emitted("run-1", "h1") is True + assert trace_session.mark_emitted("run-1", "h1") is False # already shipped + assert trace_session.mark_emitted("run-1", "h2") is True # new hash + + def test_runs_are_independent(self): + assert trace_session.mark_emitted("run-1", "h1") is True + assert trace_session.mark_emitted("run-2", "h1") is True # different run + + def test_lru_evicts_least_recently_used_run(self, monkeypatch: pytest.MonkeyPatch): + # Shrink the window so eviction is testable without _MAX_RUNS runs. + monkeypatch.setattr(trace_session, "_MAX_RUNS", 2) + trace_session.mark_emitted("run-1", "h1") + trace_session.mark_emitted("run-2", "h1") + # Touch run-1 so run-2 becomes the least-recently-used run. + trace_session.mark_emitted("run-1", "h2") + # A third run evicts the LRU run (run-2), keeping run-1. + trace_session.mark_emitted("run-3", "h1") + # run-1 is still tracked → its already-shipped hash stays deduped. + assert trace_session.mark_emitted("run-1", "h1") is False + # run-2 was evicted → its hash ships again as if a fresh run. + assert trace_session.mark_emitted("run-2", "h1") is True + + +class _FakeTraceEmitter: + """Stand-in for the trace emitter that records emitted events.""" + + def __init__(self) -> None: + self.events: list[object] = [] + + def emit(self, event: object) -> None: + self.events.append(event) + + +@pytest.fixture +def trace_on(monkeypatch: pytest.MonkeyPatch) -> Iterator[_FakeTraceEmitter]: + """Enable payload tracing and route emit_trace at a fake emitter.""" + from src.config import settings + from src.telemetry import emitter as emitter_mod + + monkeypatch.setattr(settings.TELEMETRY, "TRACE_PAYLOADS_ENABLED", True) + fake = _FakeTraceEmitter() + monkeypatch.setattr(emitter_mod, "_trace_emitter", fake) + trace_session.reset() + yield fake + trace_session.reset() + + +def _captured( + messages: list[dict[str, Any]], *, content: str = "answer", run: str = "r1" +) -> CapturedLLMCall: + from src.llm.types import LLMTelemetryContext + + return build_captured_call( + telemetry=LLMTelemetryContext( + workspace_name="ws", + call_purpose="dialectic.answer", + parent_category="dialectic", + run_id=run, + trace_id=run, + span_id=run, + iteration=1, + step_seq=1, + ), + transport="anthropic", + provider_label=None, + model="claude-x", + messages=messages, + tools=None, + tool_choice=None, + result=CompletionResult(content=content, finish_reason="stop"), + attempt=1, + was_fallback=False, + was_stream=False, + finish_reason="stop", + ) + + +class TestTraceExporter: + def test_refs_match_emitted_content(self, trace_on: _FakeTraceEmitter): + from src.telemetry.trace_exporter import TraceExporter + + call = _captured([{"role": "user", "content": "q"}]) + TraceExporter().export(call) + + traced = [e for e in trace_on.events if isinstance(e, LLMCallTracedEvent)] + contents = [e for e in trace_on.events if isinstance(e, TraceContentEvent)] + assert len(traced) == 1 + # input message + output content → two content events. + emitted_hashes = {c.content_hash for c in contents} + # Every input ref points at an emitted trace.content. + for ref in traced[0].input_message_refs: + assert ref in emitted_hashes + assert traced[0].output_content_ref in emitted_hashes + + def test_dedup_across_iterations(self, trace_on: _FakeTraceEmitter): + from src.telemetry.trace_exporter import TraceExporter + + exporter = TraceExporter() + shared = {"role": "user", "content": "system context"} + # Iteration 1: messages [shared]; iteration 2: [shared, follow-up]. + exporter.export(_captured([shared])) + before = sum(isinstance(e, TraceContentEvent) for e in trace_on.events) + exporter.export(_captured([shared, {"role": "user", "content": "more"}])) + after = sum(isinstance(e, TraceContentEvent) for e in trace_on.events) + # `shared` already shipped this run → only the new message (+ output if + # not already seen) emit again; `shared` is NOT re-emitted. + shared_hash = None + for e in trace_on.events: + if isinstance(e, TraceContentEvent) and e.content == "system context": + shared_hash = e.content_hash + emitted_shared = [ + e + for e in trace_on.events + if isinstance(e, TraceContentEvent) and e.content_hash == shared_hash + ] + assert len(emitted_shared) == 1 # shipped once across both iterations + assert after > before # the new message did ship + + def test_purpose_allowlist_filters( + self, trace_on: _FakeTraceEmitter, monkeypatch: pytest.MonkeyPatch + ): + from src.config import settings + from src.telemetry.trace_exporter import TraceExporter + + monkeypatch.setattr(settings.TELEMETRY, "TRACE_PURPOSES", ["summary.short"]) + TraceExporter().export(_captured([{"role": "user", "content": "q"}])) + # call_purpose is dialectic.answer, not in the allowlist → nothing emits. + assert trace_on.events == [] diff --git a/tests/utils/test_clients.py b/tests/utils/test_clients.py index 319e5f1f..49b2d75d 100644 --- a/tests/utils/test_clients.py +++ b/tests/utils/test_clients.py @@ -935,6 +935,7 @@ class TestMainLLMCallFunction: with ( patch.dict(CLIENTS, {"anthropic": mock_llm_client}), patch.object(settings, "LANGFUSE_PUBLIC_KEY", "test-public-key"), + patch.object(settings, "LANGFUSE_EXPORTER_MODE", "inline"), patch("langfuse.get_client", return_value=mock_langfuse_client), patch("langfuse.propagate_attributes", fake_propagate), ): @@ -1008,6 +1009,7 @@ class TestMainLLMCallFunction: with ( patch.dict(CLIENTS, {"anthropic": mock_llm_client}), patch.object(settings, "LANGFUSE_PUBLIC_KEY", "test-public-key"), + patch.object(settings, "LANGFUSE_EXPORTER_MODE", "inline"), patch("langfuse.get_client", return_value=mock_langfuse_client), patch("langfuse.propagate_attributes", fake_propagate), ): From 0cb0c9abf0d2fb466c6248607a0fd0e373ae3408 Mon Sep 17 00:00:00 2001 From: ajspig <46900795+ajspig@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:25:01 -0400 Subject: [PATCH 40/65] docs: adding codex doc (#879) --- docs/docs.json | 1 + docs/v3/guides/integrations/codex.mdx | 203 ++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 docs/v3/guides/integrations/codex.mdx diff --git a/docs/docs.json b/docs/docs.json index 889ea9e6..cbdd4e62 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -95,6 +95,7 @@ "pages": [ "v3/guides/integrations/claude-code", "v3/guides/integrations/opencode", + "v3/guides/integrations/codex", "v3/guides/integrations/vercel-ai-sdk", "v3/guides/integrations/crewai", "v3/guides/integrations/langgraph", diff --git a/docs/v3/guides/integrations/codex.mdx b/docs/v3/guides/integrations/codex.mdx new file mode 100644 index 00000000..8187ca6e --- /dev/null +++ b/docs/v3/guides/integrations/codex.mdx @@ -0,0 +1,203 @@ +--- +title: "Codex" +icon: 'square-terminal' +description: "Add AI-native memory to OpenAI Codex" +sidebarTitle: 'Codex' +--- + +Give Codex long-term memory that survives context resets, session restarts, and fresh conversations. Codex remembers what you're working on, your preferences, and the decisions you've made — across every project. Lifecycle hooks capture each session to Honcho and inject the relevant context back at session start, so you never have to repeat yourself. + +## Prerequisites + +- **[Codex](https://developers.openai.com/codex) ≥ 0.136.0** +- **[Node](https://nodejs.org)** on your `PATH` (runs the installer and the hooks) + +## Quick Start + +### Step 1: Get Your Honcho API Key + +1. Go to **[app.honcho.dev](https://app.honcho.dev)** +2. Sign up or log in +3. Copy your API key (starts with `hch-`) + +### Step 2: Save Your API Key + +Your key lives in **`~/.honcho/config.json`** — the single config file every Honcho integration reads. codex-honcho takes the key straight from there. + + +**Already have your key in `~/.honcho/config.json`?** If another Honcho integration already wrote it there, there's nothing to do — skip to Step 3 and `install` picks it up automatically. + + +**First time?** Create it with the Honcho CLI: + +```bash +honcho init # prompts for your key, writes ~/.honcho/config.json + # no CLI yet? uv tool install honcho-cli && honcho init +``` + +If you'd rather write the file yourself: + +```jsonc +// ~/.honcho/config.json +{ "apiKey": "hch-your-api-key-here" } +``` + +### Step 3: Install the Plugin + +```bash +npm install -g @honcho-ai/codex-honcho +codex-honcho install # registers hooks + MCP + skill in ~/.codex +``` + +`install` copies your resolved key into `~/.codex/config.toml` so the Honcho MCP server authenticates with no environment variable to set. If you ever rotate your key, re-run `codex-honcho install` to refresh it. + +### Step 4: Restart Codex + +Restart Codex (or start a new session) to load the hooks and the `[features].hooks` flag. On your next session start you'll see Honcho memory load into context. + +### Step 5: (Optional) Tell Codex to use its memory + +The bundled `honcho-memory` skill already nudges Codex to recall and save actively. To reinforce it, add a short directive to your global Codex instructions (`~/.codex/AGENTS.md`): + +```markdown +# Honcho Memory + +You have persistent memory via Honcho. Context about me is loaded at the start +of every session — trust it and act on it; don't ask me what you already know. +Use the Honcho MCP tools (`search`, `chat`) to recall more mid-task, and +`create_conclusions` to save new preferences, decisions, and patterns as you learn them. +``` + +## What You Get + +- **Persistent Memory** — Codex remembers your preferences, projects, and context across sessions +- **Survives Context Resets** — Memory persists through `/clear`, compaction, and restarts +- **Active Recall** — Codex can search your history and query what Honcho knows about you mid-task, not just at startup +- **Git Awareness** — Optionally scope memory per branch, so feature work keeps its own context +- **Flexible Sessions** — Map memory per directory, per git branch, or per chat instance +- **Local-First Capture** — Conversations are queued to disk instantly and uploaded in the background — capture never blocks your turn or hits the network mid-conversation +- **Cross-Tool Context** — Shares `~/.honcho/config.json` with other Honcho integrations (Claude Code, Cursor, …), so context can follow you between tools + +## Configuration + +All settings live in `~/.honcho/config.json` (shared with other Honcho integrations). Codex-specific settings go under `hosts.codex`, falling back to the root fields. The hooks only ever read this file; `install` is the only writer. + +```jsonc +{ + "apiKey": "hch-…", + "peerName": "alice", // your identity (default: $USER) + "hosts": { + "codex": { + "workspace": "codex", // Honcho workspace for Codex memory + "sessionStrategy": "per-directory", + "injectPerPrompt": false, // re-inject context every turn (off by default) + "saveMessages": true // false = read memory but never write + } + } +} +``` + +### Session Strategies + +Controls how Codex conversations map to Honcho sessions: + +| Strategy | Session name | Best for | +| --- | --- | --- | +| `per-directory` (default) | `my-app` | Most users — each project accumulates its own memory | +| `git-branch` | `my-app-main` | Feature-branch workflows where context per branch matters | +| `chat-instance` | `my-app-019ea7df` | Ephemeral usage — a clean slate per conversation | + +An explicit `sessions[cwd]` mapping overrides all strategies. Environment overrides: `HONCHO_API_KEY`, `HONCHO_PEER_NAME`, `HONCHO_CONFIG_DIR`. + +## Building with Teammates + +Because `~/.honcho/config.json` is shared across Honcho hosts, teammates can collaborate by pointing at the same workspace while keeping their own identities. Each person uses their own `peerName`, so their contributions are attributed to distinct peers even when they work in the same repo. + +**Alice** (`~/.honcho/config.json`): +```json +{ + "apiKey": "hch-team-key...", + "peerName": "alice", + "hosts": { + "codex": { "workspace": "team-acme" } + } +} +``` + +**Bob** (`~/.honcho/config.json`): +```json +{ + "apiKey": "hch-team-key...", + "peerName": "bob", + "hosts": { + "codex": { "workspace": "team-acme" } + } +} +``` + +Both Alice and Bob write to the `team-acme` workspace. Working in the same repo, they share a session (named by directory, e.g. `my-app`) but appear in it as separate peers — so Honcho's dialectic reasoning can draw on context from both. + +## MCP Tools + +Once installed, Codex can call these Honcho tools directly: + +| Tool | Description | +| --- | --- | +| `search` | Semantic search across your session messages | +| `chat` | Ask Honcho a natural-language question about you | +| `get_peer_context` | Fetch the current model of you (representation + peer card) | +| `get_representation` | Lightweight representation string | +| `create_conclusions` | Save durable insights to memory | +| `list_conclusions` | List saved conclusions | +| `query_conclusions` | Semantic search across derived conclusions | +| `delete_conclusion` | Remove a conclusion by ID | + +## Commands + +| Command | Effect | +| --- | --- | +| `codex-honcho install` | Install hooks + MCP + skill | +| `codex-honcho status` | Installed components, pending queue depth, GUI link | +| `codex-honcho remove` | Strip only what this installs | + +## What Install Writes + +| Path | Change | +| --- | --- | +| `~/.codex/honcho/` | staged copy of the bundle the hooks run (kept stable across npm/npx cache eviction) | +| `~/.codex/hooks.json` | adds the four hook entries (merged; your own hooks untouched) | +| `~/.codex/config.toml` | sets `[features].hooks = true`; registers `[mcp_servers.honcho]` → `mcp.honcho.dev` (native HTTP) | +| `~/.codex/skills/honcho-memory/` | the active-recall skill | +| `~/.honcho/config.json` | persists the resolved `apiKey` + `peerName` (other fields and `hosts.*` blocks preserved) | + +`codex-honcho remove` reverses exactly these. + +## Troubleshooting + +**No memory loading / MCP not registered.** Confirm your key is in `~/.honcho/config.json` (`codex-honcho status` shows `honcho config: found`). If it's missing, run `honcho init` (or add `{ "apiKey": "hch-…" }` to the file yourself), then re-run `codex-honcho install` — without a key, install registers the hooks and skill but skips the MCP server. + +**Hooks aren't firing.** Restart Codex after installing so it loads `hooks.json` and the `[features].hooks` flag. Check `codex-honcho status` for installed components and pending queue depth. + +**Memory not persisting.** Make sure `saveMessages` isn't set to `false` under `hosts.codex`. + +## Install from a GitHub Clone (no npm) + +```bash +git clone https://github.com/plastic-labs/codex-honcho +cd codex-honcho +./install.sh # bun install + bun run bin/codex-honcho.ts install +``` + +The clone path runs the TypeScript source directly and so requires **[bun](https://bun.sh)**; it wires the hooks to `bun run /bin/codex-honcho.ts`, so keep the clone in place. The npm install instead stages the bundled `dist/codex-honcho.mjs` to `~/.codex/honcho/` and wires hooks to `node` — node-only, and stable across `npm update`, npx cache eviction, or removing the package. + +## Next Steps + + + + Source code, issues, and README. + + + + Learn about peers, sessions, and dialectic reasoning. + + From 4536612b8fc212a4a3fb85ecd4dd5c28f3d274f8 Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Wed, 8 Jul 2026 11:58:13 -0400 Subject: [PATCH 41/65] add .omc to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index e6cbf1d7..efb1f8ce 100644 --- a/.gitignore +++ b/.gitignore @@ -193,3 +193,6 @@ metrics.jsonl AGENTS.md lancedb_data/ grafana-data/ + +# Claude Code addon stuff +.omc \ No newline at end of file From be26c859addb4b9baf7506b0ff9c32c2935f3e32 Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Wed, 8 Jul 2026 12:24:55 -0400 Subject: [PATCH 42/65] split config entry into 2 --- .env.template | 3 +- config.toml.example | 3 +- docs/v3/contributing/configuration.mdx | 3 +- docs/v3/contributing/troubleshooting.mdx | 2 +- src/config.py | 41 ++++++++++++++++-- src/deriver/consumer.py | 2 +- src/deriver/deriver.py | 2 +- src/deriver/queue_manager.py | 20 +++++---- src/telemetry/events/representation.py | 2 +- tests/bench/calculate_expected_events.py | 2 +- tests/deriver/test_queue_processing.py | 24 ++++++----- tests/test_config.py | 54 +++++++++++++++++++++++- 12 files changed, 126 insertions(+), 32 deletions(-) diff --git a/.env.template b/.env.template index 24014ee2..fbbd2a50 100644 --- a/.env.template +++ b/.env.template @@ -131,7 +131,8 @@ LLM_OPENAI_API_KEY=your-api-key-here # DERIVER_MAX_INPUT_TOKENS=25000 # DERIVER_MAX_CUSTOM_INSTRUCTIONS_TOKENS=2000 # DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100 -# DERIVER_REPRESENTATION_BATCH_MAX_TOKENS=1024 +# DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=512 # Min tokens a work unit accumulates before the deriver claims it; 0 disables the gate +# DERIVER_REPRESENTATION_BATCH_LLM_MAX_TOKENS=1024 # Max context-window tokens per deriver LLM call # DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS=1800 # DERIVER_FLUSH_ENABLED=false # Bypass batch token threshold, process work immediately # DERIVER_MODEL_CONFIG__FALLBACK__MODEL= diff --git a/config.toml.example b/config.toml.example index 54bf1ffd..60dfb323 100644 --- a/config.toml.example +++ b/config.toml.example @@ -109,7 +109,8 @@ LOG_OBSERVATIONS = false MAX_INPUT_TOKENS = 25000 MAX_CUSTOM_INSTRUCTIONS_TOKENS = 2000 WORKING_REPRESENTATION_MAX_OBSERVATIONS = 100 -REPRESENTATION_BATCH_MAX_TOKENS = 1024 +REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS = 512 # Min tokens a work unit accumulates before the deriver claims it; 0 disables the gate +REPRESENTATION_BATCH_LLM_MAX_TOKENS = 1024 # Max context-window tokens per deriver LLM call REPRESENTATION_BATCH_MAX_AGE_SECONDS = 1800 FLUSH_ENABLED = false # Bypass batch token threshold, process work immediately diff --git a/docs/v3/contributing/configuration.mdx b/docs/v3/contributing/configuration.mdx index e770e96b..eeda7b7a 100644 --- a/docs/v3/contributing/configuration.mdx +++ b/docs/v3/contributing/configuration.mdx @@ -408,7 +408,8 @@ DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000 # 30 days DERIVER_DEDUPLICATE=true DERIVER_LOG_OBSERVATIONS=false DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100 -DERIVER_REPRESENTATION_BATCH_MAX_TOKENS=1024 +DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=512 +DERIVER_REPRESENTATION_BATCH_LLM_MAX_TOKENS=1024 DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS=1800 ``` diff --git a/docs/v3/contributing/troubleshooting.mdx b/docs/v3/contributing/troubleshooting.mdx index 0443eff7..7db76b34 100644 --- a/docs/v3/contributing/troubleshooting.mdx +++ b/docs/v3/contributing/troubleshooting.mdx @@ -109,7 +109,7 @@ Messages are stored but no observations, summaries, or representations are being ```bash DERIVER_WORKERS=4 ``` -5. **Representation Batch Max** — By default the deriver buffers representation work until a session has enough tokens for that representation, set via `DERIVER_REPRESENTATION_BATCH_MAX_TOKENS`. Sub-threshold tails become eligible after `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800 seconds), so quiet sessions eventually flush without disabling batching globally. Set the age to `0` for legacy behavior where sub-threshold tails wait indefinitely. See [token batching](/v3/documentation/core-concepts/reasoning#token-batching) for more details +5. **Representation Batching** — By default the deriver buffers representation work until a work unit has accumulated enough tokens, set via `DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS` (`0` disables the accumulation gate). A separate setting, `DERIVER_REPRESENTATION_BATCH_LLM_MAX_TOKENS`, caps the conversation window fed to each deriver LLM call when draining a claimed work unit. Sub-threshold tails become eligible after `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800 seconds), so quiet sessions eventually flush without disabling batching globally. Set the age to `0` for legacy behavior where sub-threshold tails wait indefinitely. See [token batching](/v3/documentation/core-concepts/reasoning#token-batching) for more details ## Alternative Provider Issues diff --git a/src/config.py b/src/config.py index 0e39fdcc..977b02d7 100644 --- a/src/config.py +++ b/src/config.py @@ -857,7 +857,20 @@ class DeriverSettings(HonchoSettings): int, Field(default=100, gt=0, le=1000) ] = 100 - REPRESENTATION_BATCH_MAX_TOKENS: Annotated[ + # Minimum tokens a representation work unit must accumulate (summed over + # its own unprocessed messages) before it becomes claimable. Bypassed by + # FLUSH_ENABLED and by REPRESENTATION_BATCH_MAX_AGE_SECONDS age-flushing. + # 0 disables the accumulation gate entirely (equivalent to FLUSH_ENABLED + # for claiming): work units are claimable as soon as anything is pending. + REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS: Annotated[ + int, + Field(default=512, ge=0, le=16_384), + ] = 512 + # Cumulative-token cap on the conversation window (queued messages plus + # interleaved context) fed to a single deriver LLM call when draining a + # claimed work unit. The first unprocessed message is always included, + # even if it alone exceeds the cap. + REPRESENTATION_BATCH_LLM_MAX_TOKENS: Annotated[ int, Field(default=1024, ge=128, le=16_384), ] = 1024 @@ -881,11 +894,33 @@ class DeriverSettings(HonchoSettings): ) return data # pyright: ignore[reportUnknownVariableType] + @model_validator(mode="before") + @classmethod + def _reject_removed_batch_max_tokens(cls, data: Any) -> Any: + """Fail fast on the removed REPRESENTATION_BATCH_MAX_TOKENS setting. + + The old single setting was split into + REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS (claim gate) and + REPRESENTATION_BATCH_LLM_MAX_TOKENS (per-LLM-call window cap). + `extra="ignore"` would otherwise silently drop the old key and revert + both roles to defaults — an operator-hostile failure mode for a + batching knob — so reject it loudly instead. + """ + legacy_in_data = isinstance(data, dict) and any( + str(key).upper() == "REPRESENTATION_BATCH_MAX_TOKENS" + for key in cast(dict[str, Any], data) + ) + if legacy_in_data or "DERIVER_REPRESENTATION_BATCH_MAX_TOKENS" in os.environ: + raise ValueError( + "REPRESENTATION_BATCH_MAX_TOKENS has been split into REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS (minimum tokens a work unit must accumulate before it is claimed) and REPRESENTATION_BATCH_LLM_MAX_TOKENS (token cap on the context window per deriver LLM call). Set those instead." + ) + return data # pyright: ignore[reportUnknownVariableType] + @model_validator(mode="after") def validate_batch_tokens_vs_context_limit(self): - if self.REPRESENTATION_BATCH_MAX_TOKENS > self.MAX_INPUT_TOKENS: + if self.REPRESENTATION_BATCH_LLM_MAX_TOKENS > self.MAX_INPUT_TOKENS: raise ValueError( - f"REPRESENTATION_BATCH_MAX_TOKENS ({self.REPRESENTATION_BATCH_MAX_TOKENS}) cannot exceed max deriver input tokens ({self.MAX_INPUT_TOKENS})" + f"REPRESENTATION_BATCH_LLM_MAX_TOKENS ({self.REPRESENTATION_BATCH_LLM_MAX_TOKENS}) cannot exceed max deriver input tokens ({self.MAX_INPUT_TOKENS})" ) return self diff --git a/src/deriver/consumer.py b/src/deriver/consumer.py index 6e35d614..9a961df9 100644 --- a/src/deriver/consumer.py +++ b/src/deriver/consumer.py @@ -176,7 +176,7 @@ async def process_representation_batch( queue_item_message_ids: Message IDs from queue items hit_batch_token_cap: whether the queue batcher clamped this batch to fit was_flush_enabled: snapshot of DERIVER.FLUSH_ENABLED at fetch time - batch_max_tokens: DERIVER.REPRESENTATION_BATCH_MAX_TOKENS snapshot + batch_max_tokens: DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS snapshot """ if not messages or not messages[0]: logger.debug("process_representation_batch received no messages") diff --git a/src/deriver/deriver.py b/src/deriver/deriver.py index 0db9477d..93f18eb1 100644 --- a/src/deriver/deriver.py +++ b/src/deriver/deriver.py @@ -58,7 +58,7 @@ async def process_representation_tasks_batch( queue_item_message_ids: Message IDs from queue items being processed hit_batch_token_cap: queue batcher clamped this batch to fit was_flush_enabled: DERIVER.FLUSH_ENABLED snapshot at batch time - batch_max_tokens: DERIVER.REPRESENTATION_BATCH_MAX_TOKENS snapshot + batch_max_tokens: DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS snapshot """ if not messages: return diff --git a/src/deriver/queue_manager.py b/src/deriver/queue_manager.py index 93bed1a1..145a6252 100644 --- a/src/deriver/queue_manager.py +++ b/src/deriver/queue_manager.py @@ -331,16 +331,18 @@ class QueueManager: """ Get available work units that aren't being processed. For representation tasks, only returns work units whose accumulated - tokens reach REPRESENTATION_BATCH_MAX_TOKENS or whose oldest pending - item exceeds REPRESENTATION_BATCH_MAX_AGE_SECONDS, unless - FLUSH_ENABLED is True. + tokens reach REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS or whose + oldest pending item exceeds REPRESENTATION_BATCH_MAX_AGE_SECONDS, + unless FLUSH_ENABLED is True. Returns a dict mapping work_unit_key to aqs_id. """ limit: int = max(0, self.workers - self.get_total_owned_work_units()) if limit == 0: return {} - batch_max_tokens = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS + work_unit_target_tokens = ( + settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS + ) async with tracked_db("get_available_work_units") as db: representation_prefix = "representation:" @@ -396,11 +398,11 @@ class QueueManager: ) # Apply batch threshold filter (skip if FLUSH_ENABLED is True) - if not settings.DERIVER.FLUSH_ENABLED and batch_max_tokens > 0: + if not settings.DERIVER.FLUSH_ENABLED and work_unit_target_tokens > 0: max_age_seconds = settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS threshold_clause = ( func.coalesce(token_stats_subq.c.total_tokens, 0) - >= batch_max_tokens + >= work_unit_target_tokens ) if max_age_seconds > 0: threshold_clause = or_( @@ -426,13 +428,13 @@ class QueueManager: not settings.DERIVER.FLUSH_ENABLED and settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS > 0 and work_unit_key.startswith(representation_prefix) - and int(total_tokens or 0) < batch_max_tokens + and int(total_tokens or 0) < work_unit_target_tokens ): logger.info( "age-flushing work unit %s (tokens=%s < %s, oldest=%s)", work_unit_key, total_tokens or 0, - batch_max_tokens, + work_unit_target_tokens, oldest_created_at, ) if not available_units: @@ -816,7 +818,7 @@ class QueueManager: f"{task_type} tasks are not supported for get_queue_item_batch" ) - batch_max_tokens = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS + batch_max_tokens = settings.DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS was_flush_enabled = settings.DERIVER.FLUSH_ENABLED parsed_key = parse_work_unit_key(work_unit_key) messages_context: list[models.Message] = [] diff --git a/src/telemetry/events/representation.py b/src/telemetry/events/representation.py index 77d6b292..dcad8a12 100644 --- a/src/telemetry/events/representation.py +++ b/src/telemetry/events/representation.py @@ -104,7 +104,7 @@ class RepresentationCompletedEvent(BaseEvent): # Cap configuration + hit flags () batch_max_tokens: int = Field( default=0, - description="settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS at fetch time", + description="settings.DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS at fetch time", ) max_input_tokens: int = Field( default=0, description="settings.DERIVER.MAX_INPUT_TOKENS at call time" diff --git a/tests/bench/calculate_expected_events.py b/tests/bench/calculate_expected_events.py index 348cd95c..45eea82b 100644 --- a/tests/bench/calculate_expected_events.py +++ b/tests/bench/calculate_expected_events.py @@ -246,7 +246,7 @@ def calculate_question_events( # Calculate representation events # Each unique (session, observed) pair generates one representation event - # (assuming messages fit within REPRESENTATION_BATCH_MAX_TOKENS) + # (assuming messages fit within REPRESENTATION_BATCH_LLM_MAX_TOKENS) # When merge_sessions=True, all messages go into one session if merge_sessions: # One merged session = one representation event diff --git a/tests/deriver/test_queue_processing.py b/tests/deriver/test_queue_processing.py index 68d4852c..e860f6cd 100644 --- a/tests/deriver/test_queue_processing.py +++ b/tests/deriver/test_queue_processing.py @@ -359,7 +359,7 @@ class TestQueueProcessing: peer = peers[0] # Create messages with token counts that exceed batch limit - limit = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS + limit = settings.DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS token_counts = [limit // 2, limit // 2, limit // 2] # Create and save messages to the database first @@ -479,7 +479,7 @@ class TestQueueProcessing: session, peers = sample_session_with_peers peer = peers[0] - cap = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS + cap = settings.DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS # M1 + M2 sum to exactly the cap; M3 pushes over it. After SQL, # messages_context = [M1, M2]; the cap is genuinely binding *on the @@ -581,7 +581,7 @@ class TestQueueProcessing: session, peers = sample_session_with_peers peer_a = peers[0] peer_b = peers[1] if len(peers) > 1 else peers[0] - cap = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS + cap = settings.DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS # Layout: 4 messages, ordered. # M1 (peer_a, queue, 200) @@ -744,7 +744,9 @@ class TestQueueProcessing: qm = QueueManager() # Mock the token limit to 2000 for this test - with patch.object(settings.DERIVER, "REPRESENTATION_BATCH_MAX_TOKENS", 2000): + with patch.object( + settings.DERIVER, "REPRESENTATION_BATCH_LLM_MAX_TOKENS", 2000 + ): # Test alice's work unit alice_work_unit_key = alice_queue_items[0].work_unit_key alice_aqs = models.ActiveQueueSession(work_unit_key=alice_work_unit_key) @@ -919,7 +921,9 @@ class TestQueueProcessing: qm = QueueManager() # Mock the token limit to 1500 for this test - with patch.object(settings.DERIVER, "REPRESENTATION_BATCH_MAX_TOKENS", 1500): + with patch.object( + settings.DERIVER, "REPRESENTATION_BATCH_LLM_MAX_TOKENS", 1500 + ): # Test alice's work unit # With per-work-unit anchoring + preceding context: # Alice starts at message 3, includes preceding message 2 (steve) for context @@ -1133,7 +1137,7 @@ class TestQueueProcessing: peer = peers[0] # Create messages where first message exceeds the batch limit - limit = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS + limit = settings.DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS token_counts = [limit + 1000, 100, 200] # First message way over limit # Create and save messages to the database first @@ -1250,7 +1254,7 @@ class TestQueueProcessing: peer = peers[0] # Create messages that test the exact boundary - limit = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS + limit = settings.DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS token_counts = [ limit // 2, limit // 2, @@ -1381,7 +1385,7 @@ class TestQueueProcessing: peer = peers[0] # Create messages with tokens BELOW the threshold - limit = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS + limit = settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS token_counts = [100, 100, 100] # Total 300, way below 4096 messages: list[models.Message] = [] @@ -1652,7 +1656,7 @@ class TestQueueProcessing: session, peers = sample_session_with_peers peer = peers[0] - limit = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS + limit = settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS # Create a single message that exceeds the threshold message = models.Message( @@ -1758,7 +1762,7 @@ class TestQueueProcessing: session, peers = sample_session_with_peers peer = peers[0] - limit = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS + limit = settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS # Create messages that sum to exactly the threshold token_counts = [limit // 2, limit // 2] diff --git a/tests/test_config.py b/tests/test_config.py index 28b58d81..707b6e8a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,7 +7,8 @@ def _make_deriver_settings( *, MAX_INPUT_TOKENS: int = 25000, MAX_CUSTOM_INSTRUCTIONS_TOKENS: int = 2000, - REPRESENTATION_BATCH_MAX_TOKENS: int = 1024, + REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS: int = 512, + REPRESENTATION_BATCH_LLM_MAX_TOKENS: int = 1024, REPRESENTATION_BATCH_MAX_AGE_SECONDS: int = 1800, ) -> DeriverSettings: return DeriverSettings( @@ -17,7 +18,8 @@ def _make_deriver_settings( ), MAX_INPUT_TOKENS=MAX_INPUT_TOKENS, MAX_CUSTOM_INSTRUCTIONS_TOKENS=MAX_CUSTOM_INSTRUCTIONS_TOKENS, - REPRESENTATION_BATCH_MAX_TOKENS=REPRESENTATION_BATCH_MAX_TOKENS, + REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS, + REPRESENTATION_BATCH_LLM_MAX_TOKENS=REPRESENTATION_BATCH_LLM_MAX_TOKENS, REPRESENTATION_BATCH_MAX_AGE_SECONDS=REPRESENTATION_BATCH_MAX_AGE_SECONDS, ) @@ -50,3 +52,51 @@ def test_representation_batch_age_can_be_disabled_with_zero() -> None: def test_representation_batch_age_rejects_negative_values() -> None: with pytest.raises(ValueError, match="greater than or equal to 0"): _make_deriver_settings(REPRESENTATION_BATCH_MAX_AGE_SECONDS=-1) + + +def test_representation_batch_work_unit_target_can_be_disabled_with_zero() -> None: + settings = _make_deriver_settings(REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=0) + + assert settings.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS == 0 + + +def test_representation_batch_work_unit_target_rejects_negative_values() -> None: + with pytest.raises(ValueError, match="greater than or equal to 0"): + _make_deriver_settings(REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=-1) + + +def test_representation_batch_tokens_can_diverge() -> None: + settings = _make_deriver_settings( + REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=4096, + REPRESENTATION_BATCH_LLM_MAX_TOKENS=1024, + ) + + assert settings.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS == 4096 + assert settings.REPRESENTATION_BATCH_LLM_MAX_TOKENS == 1024 + + +def test_representation_batch_llm_max_cannot_exceed_max_input_tokens() -> None: + with pytest.raises(ValueError, match="cannot exceed max deriver input tokens"): + _make_deriver_settings( + MAX_INPUT_TOKENS=1000, + REPRESENTATION_BATCH_LLM_MAX_TOKENS=2048, + ) + + +def test_legacy_representation_batch_max_tokens_is_rejected() -> None: + with pytest.raises(ValueError, match="has been split into"): + DeriverSettings( + MODEL_CONFIG=ConfiguredModelSettings( + model="gpt-5.4-mini", + transport="openai", + ), + REPRESENTATION_BATCH_MAX_TOKENS=1024, # pyright: ignore[reportCallIssue] + ) + + +def test_legacy_representation_batch_max_tokens_env_var_is_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("DERIVER_REPRESENTATION_BATCH_MAX_TOKENS", "1024") + with pytest.raises(ValueError, match="has been split into"): + _make_deriver_settings() From c9bf53ac067aa9d133b20cd7c05d1e34f615da8a Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Wed, 8 Jul 2026 18:28:10 -0400 Subject: [PATCH 43/65] prevent hammering with message tasks --- src/config.py | 4 ++ src/reconciler/embed_now.py | 42 ++++++++++++++++++ src/routers/messages.py | 26 +++++++++--- src/telemetry/prometheus/metrics.py | 12 ++++++ tests/deriver/test_embed_now.py | 66 ++++++++++++++++++++++++++++- tests/routes/test_messages.py | 34 +++++++++++++-- 6 files changed, 172 insertions(+), 12 deletions(-) diff --git a/src/config.py b/src/config.py index 0e39fdcc..e8b8461e 100644 --- a/src/config.py +++ b/src/config.py @@ -754,6 +754,10 @@ class EmbeddingSettings(HonchoSettings): # Caps concurrent message-embedding fan-out on the API request path (the # immediate-embed background task). The reconciler is unaffected. MAX_CONCURRENT_EMBEDDINGS: Annotated[int, Field(default=10, gt=0, le=100)] = 10 + # Caps in-flight immediate-embed background tasks per API process. When + # saturated, message creation skips the fast path entirely and the + # reconciler embeds on its next cycle. 0 disables the fast path. + MAX_PENDING_EMBED_TASKS: Annotated[int, Field(default=50, ge=0)] = 50 @model_validator(mode="before") @classmethod diff --git a/src/reconciler/embed_now.py b/src/reconciler/embed_now.py index 12c71065..f958d3b9 100644 --- a/src/reconciler/embed_now.py +++ b/src/reconciler/embed_now.py @@ -23,6 +23,7 @@ import logging from dataclasses import dataclass from typing import Any +from fastapi import BackgroundTasks from sqlalchemy import and_, func, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -36,6 +37,7 @@ from src.reconciler.sync_vectors import ( build_message_vector_record, compute_chunk_positions, ) +from src.telemetry import prometheus_metrics from src.telemetry.events import EmbeddingCallPurpose from src.utils.types import embedding_call_purpose from src.vector_store import VectorRecord, VectorStore, get_external_vector_store @@ -66,6 +68,46 @@ def reset_embed_semaphore() -> None: _embed_semaphore = None +class EmbedTaskGate: + """Non-blocking admission gate for immediate-embed background tasks. + + Bounds the number of in-flight tasks per API process at + ``EMBEDDING.MAX_PENDING_EMBED_TASKS``. When saturated, nothing is scheduled: + the rows are already ``sync_state='pending'``, so the reconciler embeds them + on its next cycle. The count is taken at schedule time (not task start) + because background tasks only run after the response is sent — a request + burst would otherwise stack up unbounded scheduled-but-not-started tasks. + + ``in_flight`` is mutated only from the event loop (request handlers and the + tracked task), so a plain int is race-free. + """ + + def __init__(self) -> None: + self.in_flight: int = 0 + + def try_schedule( + self, background_tasks: BackgroundTasks, message_ids: list[str] + ) -> bool: + """Schedule ``embed_messages_now`` if the cap allows it; return whether + the task was scheduled.""" + if self.in_flight >= settings.EMBEDDING.MAX_PENDING_EMBED_TASKS: + if settings.METRICS.ENABLED: + prometheus_metrics.record_embed_now_task_shed() + return False + self.in_flight += 1 + background_tasks.add_task(self._run, message_ids) + return True + + async def _run(self, message_ids: list[str]) -> None: + try: + await embed_messages_now(message_ids) + finally: + self.in_flight -= 1 + + +embed_task_gate = EmbedTaskGate() + + @dataclass(frozen=True) class _ClaimedChunk: """Plain snapshot of a claimed ``MessageEmbedding`` row. diff --git a/src/routers/messages.py b/src/routers/messages.py index 9ace70cc..b0dd8752 100644 --- a/src/routers/messages.py +++ b/src/routers/messages.py @@ -21,7 +21,7 @@ from src.config import settings from src.dependencies import db, read_db from src.deriver import enqueue from src.exceptions import FileTooLargeError, ResourceNotFoundException -from src.reconciler.embed_now import embed_messages_now +from src.reconciler.embed_now import embed_task_gate from src.security import require_auth from src.telemetry import prometheus_metrics from src.telemetry.events import FileUploadedEvent, MessageCreatedEvent, emit @@ -161,11 +161,17 @@ async def create_messages_for_session( background_tasks.add_task(enqueue, payloads) # Embed immediately so messages are searchable within seconds; the - # reconciler is the fallback for anything left pending. + # reconciler is the fallback for anything left pending. Scheduling is + # capped per process — when saturated, the reconciler picks them up. if settings.EMBED_MESSAGES and created_messages: - background_tasks.add_task( - embed_messages_now, [m.public_id for m in created_messages] + scheduled = embed_task_gate.try_schedule( + background_tasks, [m.public_id for m in created_messages] ) + if not scheduled: + logger.debug( + "Immediate-embed tasks saturated; deferring %s message(s) to reconciler", + len(created_messages), + ) return created_messages except ValueError as e: @@ -240,11 +246,17 @@ async def create_messages_with_file( background_tasks.add_task(enqueue, payloads) # Embed immediately so messages are searchable within seconds; the - # reconciler is the fallback for anything left pending. + # reconciler is the fallback for anything left pending. Scheduling is + # capped per process — when saturated, the reconciler picks them up. if settings.EMBED_MESSAGES and created_messages: - background_tasks.add_task( - embed_messages_now, [m.public_id for m in created_messages] + scheduled = embed_task_gate.try_schedule( + background_tasks, [m.public_id for m in created_messages] ) + if not scheduled: + logger.debug( + "Immediate-embed tasks saturated; deferring %s message(s) to reconciler", + len(created_messages), + ) logger.debug( "Batch of %s messages created from file uploads and queued for processing", diff --git a/src/telemetry/prometheus/metrics.py b/src/telemetry/prometheus/metrics.py index 9038b9d0..7317c22a 100644 --- a/src/telemetry/prometheus/metrics.py +++ b/src/telemetry/prometheus/metrics.py @@ -87,6 +87,12 @@ messages_created_counter = NamespacedCounter( ["namespace", "workspace_name"], ) +embed_now_tasks_shed_counter = NamespacedCounter( + "embed_now_tasks_shed", + "Immediate-embed background tasks skipped because MAX_PENDING_EMBED_TASKS was reached", + ["namespace"], +) + dialectic_calls_counter = NamespacedCounter( "dialectic_calls", "Total dialectic calls", @@ -204,6 +210,12 @@ class PrometheusMetrics: except Exception as e: self._handle_metric_error("record_messages_created", e) + def record_embed_now_task_shed(self) -> None: + try: + embed_now_tasks_shed_counter.labels().inc() + except Exception as e: + self._handle_metric_error("record_embed_now_task_shed", e) + def record_dialectic_call( self, *, diff --git a/tests/deriver/test_embed_now.py b/tests/deriver/test_embed_now.py index f6acd1c4..dcc055fe 100644 --- a/tests/deriver/test_embed_now.py +++ b/tests/deriver/test_embed_now.py @@ -9,12 +9,18 @@ creates committed fixture rows and asserts on the result via the provided sessio from unittest.mock import AsyncMock, patch import pytest +from fastapi import BackgroundTasks from nanoid import generate as generate_nanoid from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from src import models -from src.reconciler.embed_now import embed_messages_now, reset_embed_semaphore +from src.config import settings +from src.reconciler.embed_now import ( + embed_messages_now, + embed_task_gate, + reset_embed_semaphore, +) from src.vector_store import VectorStore @@ -65,10 +71,66 @@ async def _create_message_with_pending_chunks( @pytest.fixture(autouse=True) def reset_semaphore_fixture(): - """Rebuild the module semaphore per test so it binds to the active loop.""" + """Rebuild the module semaphore per test so it binds to the active loop, + and clear the admission gate's in-flight count.""" reset_embed_semaphore() + embed_task_gate.in_flight = 0 yield reset_embed_semaphore() + embed_task_gate.in_flight = 0 + + +@pytest.mark.asyncio +class TestEmbedTaskGate: + """Admission gate for immediate-embed background tasks + (EMBEDDING.MAX_PENDING_EMBED_TASKS).""" + + async def test_admits_under_cap_and_releases_slot(self) -> None: + """Under the cap, the task is scheduled; running it embeds the given ids + and releases the slot.""" + tasks = BackgroundTasks() + with ( + patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 2), + patch( + "src.reconciler.embed_now.embed_messages_now", new=AsyncMock() + ) as mock_embed, + ): + assert embed_task_gate.try_schedule(tasks, ["msg_1"]) is True + assert embed_task_gate.in_flight == 1 + await tasks() + mock_embed.assert_awaited_once_with(["msg_1"]) + assert embed_task_gate.in_flight == 0 + + async def test_rejects_at_cap_without_scheduling(self) -> None: + """At the cap, nothing is scheduled and False is returned.""" + tasks = BackgroundTasks() + with patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 1): + assert embed_task_gate.try_schedule(tasks, ["msg_1"]) is True + assert embed_task_gate.try_schedule(tasks, ["msg_2"]) is False + assert len(tasks.tasks) == 1 + assert embed_task_gate.in_flight == 1 + + async def test_slot_released_when_task_raises(self) -> None: + """A failing task still releases its slot (finally path).""" + tasks = BackgroundTasks() + with ( + patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 1), + patch( + "src.reconciler.embed_now.embed_messages_now", + new=AsyncMock(side_effect=RuntimeError("boom")), + ), + ): + assert embed_task_gate.try_schedule(tasks, ["msg_1"]) is True + with pytest.raises(RuntimeError): + await tasks() + assert embed_task_gate.in_flight == 0 + + async def test_zero_cap_disables_fast_path(self) -> None: + """MAX_PENDING_EMBED_TASKS=0 rejects every schedule attempt.""" + tasks = BackgroundTasks() + with patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 0): + assert embed_task_gate.try_schedule(tasks, ["msg_1"]) is False + assert len(tasks.tasks) == 0 @pytest.mark.asyncio diff --git a/tests/routes/test_messages.py b/tests/routes/test_messages.py index 3539a735..e70c0f18 100644 --- a/tests/routes/test_messages.py +++ b/tests/routes/test_messages.py @@ -64,7 +64,7 @@ async def test_create_message_schedules_immediate_embed( with ( patch("src.config.settings.EMBED_MESSAGES", True), patch( - "src.routers.messages.embed_messages_now", new=AsyncMock() + "src.reconciler.embed_now.embed_messages_now", new=AsyncMock() ) as mock_embed_now, ): response = client.post( @@ -91,7 +91,35 @@ async def test_create_message_skips_embed_when_disabled( with ( patch("src.config.settings.EMBED_MESSAGES", False), patch( - "src.routers.messages.embed_messages_now", new=AsyncMock() + "src.reconciler.embed_now.embed_messages_now", new=AsyncMock() + ) as mock_embed_now, + ): + response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + json={"messages": [{"content": "hello", "peer_id": test_peer.name}]}, + ) + assert response.status_code == 201 + mock_embed_now.assert_not_called() + + +@pytest.mark.asyncio +async def test_create_message_defers_embed_when_saturated( + client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] +): + """When the immediate-embed task cap is saturated, message creation still + succeeds and no embed task runs — rows stay pending for the reconciler.""" + test_workspace, test_peer = sample_data + test_session = models.Session( + workspace_name=test_workspace.name, name=str(generate_nanoid()) + ) + db_session.add(test_session) + await db_session.commit() + + with ( + patch("src.config.settings.EMBED_MESSAGES", True), + patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 0), + patch( + "src.reconciler.embed_now.embed_messages_now", new=AsyncMock() ) as mock_embed_now, ): response = client.post( @@ -120,7 +148,7 @@ async def test_file_upload_schedules_immediate_embed( with ( patch("src.config.settings.EMBED_MESSAGES", True), patch( - "src.routers.messages.embed_messages_now", new=AsyncMock() + "src.reconciler.embed_now.embed_messages_now", new=AsyncMock() ) as mock_embed_now, ): files = {"file": ("note.txt", io.BytesIO(b"hello world"), "text/plain")} From 43d962d1609076f45e5b5edc9e937c76ada6b95b Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Thu, 9 Jul 2026 10:42:52 -0400 Subject: [PATCH 44/65] rename to REPRESENTATION_BATCH_TARGET_INPUT_TOKENS --- .env.template | 2 +- config.toml.example | 2 +- docs/v3/contributing/configuration.mdx | 2 +- docs/v3/contributing/troubleshooting.mdx | 2 +- src/config.py | 10 +++++----- src/deriver/consumer.py | 2 +- src/deriver/deriver.py | 2 +- src/deriver/queue_manager.py | 2 +- src/telemetry/events/representation.py | 2 +- tests/bench/calculate_expected_events.py | 2 +- tests/deriver/test_queue_processing.py | 14 +++++++------- tests/test_config.py | 12 ++++++------ 12 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.env.template b/.env.template index fbbd2a50..167fa857 100644 --- a/.env.template +++ b/.env.template @@ -132,7 +132,7 @@ LLM_OPENAI_API_KEY=your-api-key-here # DERIVER_MAX_CUSTOM_INSTRUCTIONS_TOKENS=2000 # DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100 # DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=512 # Min tokens a work unit accumulates before the deriver claims it; 0 disables the gate -# DERIVER_REPRESENTATION_BATCH_LLM_MAX_TOKENS=1024 # Max context-window tokens per deriver LLM call +# DERIVER_REPRESENTATION_BATCH_TARGET_INPUT_TOKENS=1024 # Max context-window tokens per deriver LLM call # DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS=1800 # DERIVER_FLUSH_ENABLED=false # Bypass batch token threshold, process work immediately # DERIVER_MODEL_CONFIG__FALLBACK__MODEL= diff --git a/config.toml.example b/config.toml.example index 60dfb323..e64d90f0 100644 --- a/config.toml.example +++ b/config.toml.example @@ -110,7 +110,7 @@ MAX_INPUT_TOKENS = 25000 MAX_CUSTOM_INSTRUCTIONS_TOKENS = 2000 WORKING_REPRESENTATION_MAX_OBSERVATIONS = 100 REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS = 512 # Min tokens a work unit accumulates before the deriver claims it; 0 disables the gate -REPRESENTATION_BATCH_LLM_MAX_TOKENS = 1024 # Max context-window tokens per deriver LLM call +REPRESENTATION_BATCH_TARGET_INPUT_TOKENS = 1024 # Max context-window tokens per deriver LLM call REPRESENTATION_BATCH_MAX_AGE_SECONDS = 1800 FLUSH_ENABLED = false # Bypass batch token threshold, process work immediately diff --git a/docs/v3/contributing/configuration.mdx b/docs/v3/contributing/configuration.mdx index eeda7b7a..8f4fb443 100644 --- a/docs/v3/contributing/configuration.mdx +++ b/docs/v3/contributing/configuration.mdx @@ -409,7 +409,7 @@ DERIVER_DEDUPLICATE=true DERIVER_LOG_OBSERVATIONS=false DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100 DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=512 -DERIVER_REPRESENTATION_BATCH_LLM_MAX_TOKENS=1024 +DERIVER_REPRESENTATION_BATCH_TARGET_INPUT_TOKENS=1024 DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS=1800 ``` diff --git a/docs/v3/contributing/troubleshooting.mdx b/docs/v3/contributing/troubleshooting.mdx index 7db76b34..d9b745a1 100644 --- a/docs/v3/contributing/troubleshooting.mdx +++ b/docs/v3/contributing/troubleshooting.mdx @@ -109,7 +109,7 @@ Messages are stored but no observations, summaries, or representations are being ```bash DERIVER_WORKERS=4 ``` -5. **Representation Batching** — By default the deriver buffers representation work until a work unit has accumulated enough tokens, set via `DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS` (`0` disables the accumulation gate). A separate setting, `DERIVER_REPRESENTATION_BATCH_LLM_MAX_TOKENS`, caps the conversation window fed to each deriver LLM call when draining a claimed work unit. Sub-threshold tails become eligible after `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800 seconds), so quiet sessions eventually flush without disabling batching globally. Set the age to `0` for legacy behavior where sub-threshold tails wait indefinitely. See [token batching](/v3/documentation/core-concepts/reasoning#token-batching) for more details +5. **Representation Batching** — By default the deriver buffers representation work until a work unit has accumulated enough tokens, set via `DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS` (`0` disables the accumulation gate). A separate setting, `DERIVER_REPRESENTATION_BATCH_TARGET_INPUT_TOKENS`, caps the conversation window fed to each deriver LLM call when draining a claimed work unit. Sub-threshold tails become eligible after `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800 seconds), so quiet sessions eventually flush without disabling batching globally. Set the age to `0` for legacy behavior where sub-threshold tails wait indefinitely. See [token batching](/v3/documentation/core-concepts/reasoning#token-batching) for more details ## Alternative Provider Issues diff --git a/src/config.py b/src/config.py index 977b02d7..a5cf9bf7 100644 --- a/src/config.py +++ b/src/config.py @@ -870,7 +870,7 @@ class DeriverSettings(HonchoSettings): # interleaved context) fed to a single deriver LLM call when draining a # claimed work unit. The first unprocessed message is always included, # even if it alone exceeds the cap. - REPRESENTATION_BATCH_LLM_MAX_TOKENS: Annotated[ + REPRESENTATION_BATCH_TARGET_INPUT_TOKENS: Annotated[ int, Field(default=1024, ge=128, le=16_384), ] = 1024 @@ -901,7 +901,7 @@ class DeriverSettings(HonchoSettings): The old single setting was split into REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS (claim gate) and - REPRESENTATION_BATCH_LLM_MAX_TOKENS (per-LLM-call window cap). + REPRESENTATION_BATCH_TARGET_INPUT_TOKENS (per-LLM-call window cap). `extra="ignore"` would otherwise silently drop the old key and revert both roles to defaults — an operator-hostile failure mode for a batching knob — so reject it loudly instead. @@ -912,15 +912,15 @@ class DeriverSettings(HonchoSettings): ) if legacy_in_data or "DERIVER_REPRESENTATION_BATCH_MAX_TOKENS" in os.environ: raise ValueError( - "REPRESENTATION_BATCH_MAX_TOKENS has been split into REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS (minimum tokens a work unit must accumulate before it is claimed) and REPRESENTATION_BATCH_LLM_MAX_TOKENS (token cap on the context window per deriver LLM call). Set those instead." + "REPRESENTATION_BATCH_MAX_TOKENS has been split into REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS (minimum tokens a work unit must accumulate before it is claimed) and REPRESENTATION_BATCH_TARGET_INPUT_TOKENS (token cap on the context window per deriver LLM call). Set those instead." ) return data # pyright: ignore[reportUnknownVariableType] @model_validator(mode="after") def validate_batch_tokens_vs_context_limit(self): - if self.REPRESENTATION_BATCH_LLM_MAX_TOKENS > self.MAX_INPUT_TOKENS: + if self.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS > self.MAX_INPUT_TOKENS: raise ValueError( - f"REPRESENTATION_BATCH_LLM_MAX_TOKENS ({self.REPRESENTATION_BATCH_LLM_MAX_TOKENS}) cannot exceed max deriver input tokens ({self.MAX_INPUT_TOKENS})" + f"REPRESENTATION_BATCH_TARGET_INPUT_TOKENS ({self.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS}) cannot exceed max deriver input tokens ({self.MAX_INPUT_TOKENS})" ) return self diff --git a/src/deriver/consumer.py b/src/deriver/consumer.py index 9a961df9..708d05f6 100644 --- a/src/deriver/consumer.py +++ b/src/deriver/consumer.py @@ -176,7 +176,7 @@ async def process_representation_batch( queue_item_message_ids: Message IDs from queue items hit_batch_token_cap: whether the queue batcher clamped this batch to fit was_flush_enabled: snapshot of DERIVER.FLUSH_ENABLED at fetch time - batch_max_tokens: DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS snapshot + batch_max_tokens: DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS snapshot """ if not messages or not messages[0]: logger.debug("process_representation_batch received no messages") diff --git a/src/deriver/deriver.py b/src/deriver/deriver.py index 93f18eb1..3e68d084 100644 --- a/src/deriver/deriver.py +++ b/src/deriver/deriver.py @@ -58,7 +58,7 @@ async def process_representation_tasks_batch( queue_item_message_ids: Message IDs from queue items being processed hit_batch_token_cap: queue batcher clamped this batch to fit was_flush_enabled: DERIVER.FLUSH_ENABLED snapshot at batch time - batch_max_tokens: DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS snapshot + batch_max_tokens: DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS snapshot """ if not messages: return diff --git a/src/deriver/queue_manager.py b/src/deriver/queue_manager.py index 145a6252..498372ed 100644 --- a/src/deriver/queue_manager.py +++ b/src/deriver/queue_manager.py @@ -818,7 +818,7 @@ class QueueManager: f"{task_type} tasks are not supported for get_queue_item_batch" ) - batch_max_tokens = settings.DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS + batch_max_tokens = settings.DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS was_flush_enabled = settings.DERIVER.FLUSH_ENABLED parsed_key = parse_work_unit_key(work_unit_key) messages_context: list[models.Message] = [] diff --git a/src/telemetry/events/representation.py b/src/telemetry/events/representation.py index dcad8a12..92db6806 100644 --- a/src/telemetry/events/representation.py +++ b/src/telemetry/events/representation.py @@ -104,7 +104,7 @@ class RepresentationCompletedEvent(BaseEvent): # Cap configuration + hit flags () batch_max_tokens: int = Field( default=0, - description="settings.DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS at fetch time", + description="settings.DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS at fetch time", ) max_input_tokens: int = Field( default=0, description="settings.DERIVER.MAX_INPUT_TOKENS at call time" diff --git a/tests/bench/calculate_expected_events.py b/tests/bench/calculate_expected_events.py index 45eea82b..b9696262 100644 --- a/tests/bench/calculate_expected_events.py +++ b/tests/bench/calculate_expected_events.py @@ -246,7 +246,7 @@ def calculate_question_events( # Calculate representation events # Each unique (session, observed) pair generates one representation event - # (assuming messages fit within REPRESENTATION_BATCH_LLM_MAX_TOKENS) + # (assuming messages fit within REPRESENTATION_BATCH_TARGET_INPUT_TOKENS) # When merge_sessions=True, all messages go into one session if merge_sessions: # One merged session = one representation event diff --git a/tests/deriver/test_queue_processing.py b/tests/deriver/test_queue_processing.py index e860f6cd..81dd0632 100644 --- a/tests/deriver/test_queue_processing.py +++ b/tests/deriver/test_queue_processing.py @@ -359,7 +359,7 @@ class TestQueueProcessing: peer = peers[0] # Create messages with token counts that exceed batch limit - limit = settings.DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS + limit = settings.DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS token_counts = [limit // 2, limit // 2, limit // 2] # Create and save messages to the database first @@ -479,7 +479,7 @@ class TestQueueProcessing: session, peers = sample_session_with_peers peer = peers[0] - cap = settings.DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS + cap = settings.DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS # M1 + M2 sum to exactly the cap; M3 pushes over it. After SQL, # messages_context = [M1, M2]; the cap is genuinely binding *on the @@ -581,7 +581,7 @@ class TestQueueProcessing: session, peers = sample_session_with_peers peer_a = peers[0] peer_b = peers[1] if len(peers) > 1 else peers[0] - cap = settings.DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS + cap = settings.DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS # Layout: 4 messages, ordered. # M1 (peer_a, queue, 200) @@ -745,7 +745,7 @@ class TestQueueProcessing: # Mock the token limit to 2000 for this test with patch.object( - settings.DERIVER, "REPRESENTATION_BATCH_LLM_MAX_TOKENS", 2000 + settings.DERIVER, "REPRESENTATION_BATCH_TARGET_INPUT_TOKENS", 2000 ): # Test alice's work unit alice_work_unit_key = alice_queue_items[0].work_unit_key @@ -922,7 +922,7 @@ class TestQueueProcessing: # Mock the token limit to 1500 for this test with patch.object( - settings.DERIVER, "REPRESENTATION_BATCH_LLM_MAX_TOKENS", 1500 + settings.DERIVER, "REPRESENTATION_BATCH_TARGET_INPUT_TOKENS", 1500 ): # Test alice's work unit # With per-work-unit anchoring + preceding context: @@ -1137,7 +1137,7 @@ class TestQueueProcessing: peer = peers[0] # Create messages where first message exceeds the batch limit - limit = settings.DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS + limit = settings.DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS token_counts = [limit + 1000, 100, 200] # First message way over limit # Create and save messages to the database first @@ -1254,7 +1254,7 @@ class TestQueueProcessing: peer = peers[0] # Create messages that test the exact boundary - limit = settings.DERIVER.REPRESENTATION_BATCH_LLM_MAX_TOKENS + limit = settings.DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS token_counts = [ limit // 2, limit // 2, diff --git a/tests/test_config.py b/tests/test_config.py index 707b6e8a..9b242e55 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,7 +8,7 @@ def _make_deriver_settings( MAX_INPUT_TOKENS: int = 25000, MAX_CUSTOM_INSTRUCTIONS_TOKENS: int = 2000, REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS: int = 512, - REPRESENTATION_BATCH_LLM_MAX_TOKENS: int = 1024, + REPRESENTATION_BATCH_TARGET_INPUT_TOKENS: int = 1024, REPRESENTATION_BATCH_MAX_AGE_SECONDS: int = 1800, ) -> DeriverSettings: return DeriverSettings( @@ -19,7 +19,7 @@ def _make_deriver_settings( MAX_INPUT_TOKENS=MAX_INPUT_TOKENS, MAX_CUSTOM_INSTRUCTIONS_TOKENS=MAX_CUSTOM_INSTRUCTIONS_TOKENS, REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS, - REPRESENTATION_BATCH_LLM_MAX_TOKENS=REPRESENTATION_BATCH_LLM_MAX_TOKENS, + REPRESENTATION_BATCH_TARGET_INPUT_TOKENS=REPRESENTATION_BATCH_TARGET_INPUT_TOKENS, REPRESENTATION_BATCH_MAX_AGE_SECONDS=REPRESENTATION_BATCH_MAX_AGE_SECONDS, ) @@ -68,18 +68,18 @@ def test_representation_batch_work_unit_target_rejects_negative_values() -> None def test_representation_batch_tokens_can_diverge() -> None: settings = _make_deriver_settings( REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=4096, - REPRESENTATION_BATCH_LLM_MAX_TOKENS=1024, + REPRESENTATION_BATCH_TARGET_INPUT_TOKENS=1024, ) assert settings.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS == 4096 - assert settings.REPRESENTATION_BATCH_LLM_MAX_TOKENS == 1024 + assert settings.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS == 1024 -def test_representation_batch_llm_max_cannot_exceed_max_input_tokens() -> None: +def test_representation_batch_target_input_cannot_exceed_max_input_tokens() -> None: with pytest.raises(ValueError, match="cannot exceed max deriver input tokens"): _make_deriver_settings( MAX_INPUT_TOKENS=1000, - REPRESENTATION_BATCH_LLM_MAX_TOKENS=2048, + REPRESENTATION_BATCH_TARGET_INPUT_TOKENS=2048, ) From dcde4b29075c4dfb316d08f913bc9c194b83e2f3 Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Thu, 9 Jul 2026 12:01:09 -0400 Subject: [PATCH 45/65] add prometheus metric for in_flight --- src/reconciler/embed_now.py | 4 ++++ src/telemetry/prometheus/metrics.py | 12 ++++++++++++ tests/deriver/test_embed_now.py | 22 ++++++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/src/reconciler/embed_now.py b/src/reconciler/embed_now.py index f958d3b9..f76fa760 100644 --- a/src/reconciler/embed_now.py +++ b/src/reconciler/embed_now.py @@ -95,6 +95,8 @@ class EmbedTaskGate: prometheus_metrics.record_embed_now_task_shed() return False self.in_flight += 1 + if settings.METRICS.ENABLED: + prometheus_metrics.set_embed_now_tasks_in_flight(self.in_flight) background_tasks.add_task(self._run, message_ids) return True @@ -103,6 +105,8 @@ class EmbedTaskGate: await embed_messages_now(message_ids) finally: self.in_flight -= 1 + if settings.METRICS.ENABLED: + prometheus_metrics.set_embed_now_tasks_in_flight(self.in_flight) embed_task_gate = EmbedTaskGate() diff --git a/src/telemetry/prometheus/metrics.py b/src/telemetry/prometheus/metrics.py index 7317c22a..01d7be4f 100644 --- a/src/telemetry/prometheus/metrics.py +++ b/src/telemetry/prometheus/metrics.py @@ -93,6 +93,12 @@ embed_now_tasks_shed_counter = NamespacedCounter( ["namespace"], ) +embed_now_tasks_in_flight_gauge = NamespacedGauge( + "embed_now_tasks_in_flight", + "Immediate-embed background tasks currently in flight for this process", + ["namespace"], +) + dialectic_calls_counter = NamespacedCounter( "dialectic_calls", "Total dialectic calls", @@ -216,6 +222,12 @@ class PrometheusMetrics: except Exception as e: self._handle_metric_error("record_embed_now_task_shed", e) + def set_embed_now_tasks_in_flight(self, count: int) -> None: + try: + embed_now_tasks_in_flight_gauge.labels().set(count) + except Exception as e: + self._handle_metric_error("set_embed_now_tasks_in_flight", e) + def record_dialectic_call( self, *, diff --git a/tests/deriver/test_embed_now.py b/tests/deriver/test_embed_now.py index dcc055fe..382571b8 100644 --- a/tests/deriver/test_embed_now.py +++ b/tests/deriver/test_embed_now.py @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, patch import pytest from fastapi import BackgroundTasks from nanoid import generate as generate_nanoid +from prometheus_client import REGISTRY from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker @@ -125,6 +126,27 @@ class TestEmbedTaskGate: await tasks() assert embed_task_gate.in_flight == 0 + async def test_gauge_mirrors_in_flight_count(self) -> None: + """With metrics enabled, the Prometheus gauge tracks the gate's + in-flight count through schedule and release.""" + + def gauge_value() -> float | None: + return REGISTRY.get_sample_value( + "embed_now_tasks_in_flight", {"namespace": "test"} + ) + + tasks = BackgroundTasks() + with ( + patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 2), + patch.object(settings.METRICS, "ENABLED", True), + patch.object(settings.METRICS, "NAMESPACE", "test"), + patch("src.reconciler.embed_now.embed_messages_now", new=AsyncMock()), + ): + assert embed_task_gate.try_schedule(tasks, ["msg_1"]) is True + assert gauge_value() == 1 + await tasks() + assert gauge_value() == 0 + async def test_zero_cap_disables_fast_path(self) -> None: """MAX_PENDING_EMBED_TASKS=0 rejects every schedule attempt.""" tasks = BackgroundTasks() From 73453f892d8a44e322447dfe06db969caeb200a4 Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Fri, 10 Jul 2026 10:45:11 -0400 Subject: [PATCH 46/65] instruct dreamer specialists to not output summaries (#894) --- src/dreamer/specialists.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/dreamer/specialists.py b/src/dreamer/specialists.py index b0d44ec8..f56057f7 100644 --- a/src/dreamer/specialists.py +++ b/src/dreamer/specialists.py @@ -602,7 +602,8 @@ Use `create_observations_deductive`. 3. Always include source_ids linking to the observations you're synthesizing 4. Empty or missing source_ids will be rejected 5. Delete outdated observations - don't leave duplicates -6. Quality over quantity - fewer good deductions beat many weak ones""" +6. Quality over quantity - fewer good deductions beat many weak ones +7. When you are finished, do not output a summary of what you did - output only the token DONE""" def build_user_prompt( self, @@ -733,7 +734,8 @@ Use `create_observations_inductive`. 3. Confidence based on evidence count: 2=low, 3-4=medium, 5+=high 4. Look for HOW things change over time, not just static facts 5. Include source_ids - always link back to evidence -6. Empty or missing source_ids will be rejected""" +6. Empty or missing source_ids will be rejected +7. When you are finished, do not output a summary of what you did - output only the token DONE""" def build_user_prompt( self, From de1b4101a6a7f1e1396e6d4aeb0ef2c5927aad11 Mon Sep 17 00:00:00 2001 From: Chris Caldwell <2266680+chris-cald@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:38:31 -0400 Subject: [PATCH 47/65] fix(llm): satisfy lowercase json_object prompt checks (#887) * fix(llm): satisfy lowercase json_object prompt checks * style(llm): preserve JSON acronym in prompt --- src/llm/backends/openai.py | 9 +++++---- tests/llm/test_backends/test_openai.py | 5 ++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/llm/backends/openai.py b/src/llm/backends/openai.py index 92f13385..8c0ca8a4 100644 --- a/src/llm/backends/openai.py +++ b/src/llm/backends/openai.py @@ -30,11 +30,12 @@ def _json_object_instruction(response_format: type[BaseModel]) -> str: instruction — the deriver issues one structured call per batch on the worker hot path and would otherwise re-walk the schema + re-serialize it every call. """ - # "JSON" must appear in the messages to satisfy the json_object contract. + # Some OpenAI-compatible providers enforce this JSON-object precondition with + # a case-sensitive substring check, so include lowercase "json" explicitly. return ( - "You must respond with a single JSON object that conforms exactly to " - "the following JSON schema. Do not include any text, markdown, or code " - "fences outside the JSON object.\n\nJSON schema:\n" + "You must respond with a single JSON object (json) that conforms " + "exactly to the following JSON schema. Do not include any text, " + "markdown, or code fences outside the JSON object.\n\nJSON schema:\n" f"{json.dumps(response_format.model_json_schema())}" ) diff --git a/tests/llm/test_backends/test_openai.py b/tests/llm/test_backends/test_openai.py index 4270ebd8..0ff6988f 100644 --- a/tests/llm/test_backends/test_openai.py +++ b/tests/llm/test_backends/test_openai.py @@ -808,6 +808,7 @@ async def test_structured_output_json_object_mode_request_shape() -> None: assert system_messages, "expected a system message carrying the schema" system_content = system_messages[0]["content"] assert "JSON" in system_content + assert "json" in system_content assert "answer" in system_content # schema property serialized in assert isinstance(result.content, _StructuredResponse) assert result.content.answer == "ok" @@ -943,4 +944,6 @@ async def test_stream_structured_output_json_object_mode() -> None: call = _await_kwargs(client.chat.completions.create) assert call["response_format"] == {"type": "json_object"} system_messages = [m for m in call["messages"] if m["role"] == "system"] - assert system_messages and "JSON" in system_messages[0]["content"] + assert system_messages + assert "JSON" in system_messages[0]["content"] + assert "json" in system_messages[0]["content"] From c7c1597d2c273c30de3935490d6601d49e7c8e1e Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Mon, 13 Jul 2026 10:36:34 -0400 Subject: [PATCH 48/65] Fix `unified-tests.yml` secrets (#895) * Structured outputs for dialectic * Fix unified-tests.yml secrets overrides * Revert "Structured outputs for dialectic" This reverts commit a221e2c2823f381a959d36039c9bcf01989af1a4. * remove throwing validator, document assumption that config overrides be backward compatible * fix typo * fix: ruff format config file * fix: update pyproject.toml to include exclude-newer --------- Co-authored-by: Rajat Ahuja --- .github/workflows/unified-tests.yml | 75 ++++++++++++++++++++++++++++- pyproject.toml | 3 ++ src/config.py | 22 --------- tests/test_config.py | 19 -------- 4 files changed, 76 insertions(+), 43 deletions(-) diff --git a/.github/workflows/unified-tests.yml b/.github/workflows/unified-tests.yml index 55b938e7..1ea2c539 100644 --- a/.github/workflows/unified-tests.yml +++ b/.github/workflows/unified-tests.yml @@ -6,14 +6,44 @@ on: paths: - 'src/**' - 'tests/**' + # Manual trigger for PRs: add the `run-unified-tests` label to run the suite + # against the PR's merge commit. The label is purged as soon as the run + # starts so it can be re-added to trigger another run. + pull_request: + types: [labeled] permissions: contents: read actions: read jobs: + # Purge the trigger label first thing. Best-effort: failing to remove the + # label (e.g. read-only token on a fork PR) doesn't block the tests. + remove-label: + name: Remove trigger label + if: github.event_name == 'pull_request' && github.event.label.name == 'run-unified-tests' + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Remove run-unified-tests label + env: + GH_TOKEN: ${{ github.token }} + run: | + if ! gh api --method DELETE \ + "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels/run-unified-tests"; then + echo "::warning::Could not remove the run-unified-tests label (it may have been removed already)" + fi + start-runner: name: Start Fly Runner + needs: remove-label + # always() lets this run on push events, where remove-label is skipped. + # Label adds other than run-unified-tests trigger the workflow but skip + # every job here. + if: >- + always() && + (github.event_name == 'push' || github.event.label.name == 'run-unified-tests') uses: ./.github/workflows/start-fly-runner.yml secrets: inherit @@ -97,11 +127,52 @@ jobs: ,${{ steps.resolve-secret.outputs.second-id }} parse-json-secrets: true + # Layer test-specific overrides on top of the staging secret. The staging + # dotenv tracks the deployed release and can drift from what main's config + # expects; the TESTING_SECRET_ID secret holds only the keys (flat JSON, + # exact env var names) the unified tests need to pin. The get-secrets + # action refuses to inject an env var that already exists, so the + # overrides are fetched under a prefix alias here and promoted over the + # staging values in the next step. + - name: Fetch testing secret overrides + uses: aws-actions/aws-secretsmanager-get-secrets@v2 + with: + secret-ids: | + HONCHO_TEST_OVERRIDE,${{ secrets.TESTING_SECRET_ID }} + parse-json-secrets: true + + # Re-export each HONCHO_TEST_OVERRIDE_* var under its real name; the + # later $GITHUB_ENV write wins over the value loaded from the staging + # secret. Values are already masked by the fetch step above. + - name: Apply testing secret overrides + run: | + set -euo pipefail + applied=0 + while IFS= read -r -d '' entry; do + name="${entry%%=*}" + value="${entry#*=}" + case "$name" in + HONCHO_TEST_OVERRIDE_*) + target="${name#HONCHO_TEST_OVERRIDE_}" + { + echo "${target}<<__HONCHO_OVERRIDE_EOF__" + printf '%s\n' "$value" + echo "__HONCHO_OVERRIDE_EOF__" + } >> "$GITHUB_ENV" + echo "Overriding ${target}" + applied=$((applied + 1)) + ;; + esac + done < <(env -0) + echo "Applied ${applied} override(s)" + # Configure the test environment. Disables auth/Sentry/CloudEvents telemetry # (their endpoints aren't reachable from CI), and points REASONING_TRACES_FILE # at a shared path so the API + deriver record full LLM I/O for auditing — the # runner uploads it to S3. Written after the fetch steps so these win over the - # values loaded from Secrets Manager (last $GITHUB_ENV write wins). + # values loaded from Secrets Manager (last $GITHUB_ENV write wins). Stale + # config keys loaded from the staging secret (e.g. settings that have since + # been renamed or removed on main) must always be ignored by the app config. - name: Configure test environment run: | echo "AUTH_USE_AUTH=false" >> "$GITHUB_ENV" @@ -168,7 +239,7 @@ jobs: exit 0 fi - RUNNER_ID=""  + RUNNER_ID="" if [ -n "$RUNNER_NAME" ]; then RUNNER_ID=$(echo "$RUNNERS_RESPONSE" | jq -r --arg name "$RUNNER_NAME" '.runners[]? | select(.name == $name) | .id') fi diff --git a/pyproject.toml b/pyproject.toml index a79a3d85..e6f700c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,9 @@ dev = [ "pytest-xdist>=3.8.0", ] +[tool.uv] +exclude-newer = "5 days" + [tool.uv.workspace] members = [ "sdks/python", diff --git a/src/config.py b/src/config.py index 773ffcd6..47520a6a 100644 --- a/src/config.py +++ b/src/config.py @@ -898,28 +898,6 @@ class DeriverSettings(HonchoSettings): ) return data # pyright: ignore[reportUnknownVariableType] - @model_validator(mode="before") - @classmethod - def _reject_removed_batch_max_tokens(cls, data: Any) -> Any: - """Fail fast on the removed REPRESENTATION_BATCH_MAX_TOKENS setting. - - The old single setting was split into - REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS (claim gate) and - REPRESENTATION_BATCH_TARGET_INPUT_TOKENS (per-LLM-call window cap). - `extra="ignore"` would otherwise silently drop the old key and revert - both roles to defaults — an operator-hostile failure mode for a - batching knob — so reject it loudly instead. - """ - legacy_in_data = isinstance(data, dict) and any( - str(key).upper() == "REPRESENTATION_BATCH_MAX_TOKENS" - for key in cast(dict[str, Any], data) - ) - if legacy_in_data or "DERIVER_REPRESENTATION_BATCH_MAX_TOKENS" in os.environ: - raise ValueError( - "REPRESENTATION_BATCH_MAX_TOKENS has been split into REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS (minimum tokens a work unit must accumulate before it is claimed) and REPRESENTATION_BATCH_TARGET_INPUT_TOKENS (token cap on the context window per deriver LLM call). Set those instead." - ) - return data # pyright: ignore[reportUnknownVariableType] - @model_validator(mode="after") def validate_batch_tokens_vs_context_limit(self): if self.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS > self.MAX_INPUT_TOKENS: diff --git a/tests/test_config.py b/tests/test_config.py index 9b242e55..7730c751 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -81,22 +81,3 @@ def test_representation_batch_target_input_cannot_exceed_max_input_tokens() -> N MAX_INPUT_TOKENS=1000, REPRESENTATION_BATCH_TARGET_INPUT_TOKENS=2048, ) - - -def test_legacy_representation_batch_max_tokens_is_rejected() -> None: - with pytest.raises(ValueError, match="has been split into"): - DeriverSettings( - MODEL_CONFIG=ConfiguredModelSettings( - model="gpt-5.4-mini", - transport="openai", - ), - REPRESENTATION_BATCH_MAX_TOKENS=1024, # pyright: ignore[reportCallIssue] - ) - - -def test_legacy_representation_batch_max_tokens_env_var_is_rejected( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setenv("DERIVER_REPRESENTATION_BATCH_MAX_TOKENS", "1024") - with pytest.raises(ValueError, match="has been split into"): - _make_deriver_settings() From 5ad22840d829878f9ac4d13e9538e5fef216c97e Mon Sep 17 00:00:00 2001 From: Rajat Ahuja Date: Mon, 13 Jul 2026 15:51:47 -0400 Subject: [PATCH 49/65] feat: add support for redis cluster (#905) --- .env.template | 2 ++ pyproject.toml | 2 +- src/cache/client.py | 6 +++++- src/config.py | 10 ++++++++++ src/crud/collection.py | 1 + src/crud/peer.py | 1 + src/crud/session.py | 1 + src/crud/workspace.py | 1 + uv.lock | 10 +++++----- 9 files changed, 27 insertions(+), 7 deletions(-) diff --git a/.env.template b/.env.template index 167fa857..2516a239 100644 --- a/.env.template +++ b/.env.template @@ -287,9 +287,11 @@ LLM_OPENAI_API_KEY=your-api-key-here # ============================================================================= # CACHE_ENABLED=false # CACHE_URL="redis://localhost:6379/0?suppress=true" +# CACHE_CLUSTER=false # true when CACHE_URL is a Redis Cluster (e.g. Memorystore for Redis Cluster) # CACHE_NAMESPACE="honcho" # Inherits from NAMESPACE if not set # CACHE_DEFAULT_TTL_SECONDS=300 # CACHE_DEFAULT_LOCK_TTL_SECONDS=5 +# CACHE_LOCK_WAIT_CHECK_INTERVAL_SECONDS=0.1 # ============================================================================= # CORS Settings diff --git a/pyproject.toml b/pyproject.toml index e6f700c1..d7905fc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ "lancedb>=0.25.3", "pyarrow>=19.0.0", "redis>=7.0.0,<8.0.0", - "cashews[redis]==7.4.4", + "cashews[redis]==7.5.0", "scikit-learn>=1.6.0", "prometheus_client>=0.21.0", "cloudevents>=1.12.0,<2.0", diff --git a/src/cache/client.py b/src/cache/client.py index 319fab6e..29d8c187 100644 --- a/src/cache/client.py +++ b/src/cache/client.py @@ -45,11 +45,15 @@ async def init_cache() -> None: cache.setup("mem://", pickle_type=PicklerType.SQLALCHEMY) return - # Setup cache with Redis backend + # Setup cache with Redis backend. CACHE_CLUSTER selects the + # cluster-aware client, which follows the MOVED redirects a Redis + # Cluster returns for keys hashed to another shard; the standalone + # client treats those as command errors. try: cache.setup( settings.CACHE.URL, pickle_type=PicklerType.SQLALCHEMY, + cluster=settings.CACHE.CLUSTER, ) except Exception as setup_err: diff --git a/src/config.py b/src/config.py index 47520a6a..84eedecd 100644 --- a/src/config.py +++ b/src/config.py @@ -1212,6 +1212,10 @@ class CacheSettings(HonchoSettings): ENABLED: bool = False URL: str = "redis://localhost:6379/0?suppress=true" + # URL points at a Redis Cluster (OSS cluster protocol, e.g. GCP Memorystore + # for Redis Cluster). A standalone client cannot follow the MOVED redirects + # such deployments return for keys hashed to another shard. + CLUSTER: bool = False NAMESPACE: str | None = None DEFAULT_TTL_SECONDS: Annotated[int, Field(default=300, ge=1, le=86_400)] = ( 300 # how long to keep items in cache @@ -1221,6 +1225,12 @@ class CacheSettings(HonchoSettings): 5 # how long to hold a lock on a resource when fetching DB after cache miss ) + # Polling interval while waiting for another worker's fetch lock. cashews + # defaults to 0, which busy-spins the event loop for the whole wait. + LOCK_WAIT_CHECK_INTERVAL_SECONDS: Annotated[ + float, Field(default=0.1, gt=0, le=5) + ] = 0.1 + class SurprisalSettings(BaseModel): """Settings for tree-based surprisal sampling during dreams.""" diff --git a/src/crud/collection.py b/src/crud/collection.py index 2790cec1..63a775e3 100644 --- a/src/crud/collection.py +++ b/src/crud/collection.py @@ -48,6 +48,7 @@ def collection_cache_key(workspace_name: str, observer: str, observed: str) -> s key=COLLECTION_CACHE_KEY_TEMPLATE, ttl=f"{settings.CACHE.DEFAULT_LOCK_TTL_SECONDS}s", prefix=COLLECTION_LOCK_PREFIX, + check_interval=settings.CACHE.LOCK_WAIT_CHECK_INTERVAL_SECONDS, ) async def _fetch_collection( db: AsyncSession, diff --git a/src/crud/peer.py b/src/crud/peer.py index 21792b0f..f7936761 100644 --- a/src/crud/peer.py +++ b/src/crud/peer.py @@ -152,6 +152,7 @@ async def get_or_create_peers( key=PEER_CACHE_KEY_TEMPLATE, ttl=f"{settings.CACHE.DEFAULT_LOCK_TTL_SECONDS}s", prefix=PEER_LOCK_PREFIX, + check_interval=settings.CACHE.LOCK_WAIT_CHECK_INTERVAL_SECONDS, ) async def _fetch_peer( db: AsyncSession, diff --git a/src/crud/session.py b/src/crud/session.py index 40cdacac..4310cb3a 100644 --- a/src/crud/session.py +++ b/src/crud/session.py @@ -72,6 +72,7 @@ def session_cache_key(workspace_name: str, session_name: str) -> str: key=SESSION_CACHE_KEY_TEMPLATE, ttl=f"{settings.CACHE.DEFAULT_LOCK_TTL_SECONDS}s", prefix=SESSION_LOCK_PREFIX, + check_interval=settings.CACHE.LOCK_WAIT_CHECK_INTERVAL_SECONDS, ) async def _fetch_session( db: AsyncSession, diff --git a/src/crud/workspace.py b/src/crud/workspace.py index 52662c79..c9040f55 100644 --- a/src/crud/workspace.py +++ b/src/crud/workspace.py @@ -60,6 +60,7 @@ def workspace_cache_key(workspace_name: str) -> str: key=WORKSPACE_CACHE_KEY_TEMPLATE, ttl=f"{settings.CACHE.DEFAULT_LOCK_TTL_SECONDS}s", prefix=WORKSPACE_LOCK_PREFIX, + check_interval=settings.CACHE.LOCK_WAIT_CHECK_INTERVAL_SECONDS, ) async def _fetch_workspace( db: AsyncSession, workspace_name: str diff --git a/uv.lock b/uv.lock index c16f23c4..bd4ffae4 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-27T20:44:32.746059Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P5D" [manifest] @@ -275,11 +275,11 @@ wheels = [ [[package]] name = "cashews" -version = "7.4.4" +version = "7.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/5d/26eb556824a7ac9e24f751645961d2078b7b15be105f7fc39eda5308896f/cashews-7.4.4.tar.gz", hash = "sha256:dca761c60192bfe354abd6e9eb98d6f62c817e675df3fbe7d1bdfaa4303d1320", size = 92948, upload-time = "2025-12-06T22:31:56.187Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/73/31598b352165cd0f0b777df1eb67e33f334e29fcd7eb4f4bb48a41b9affe/cashews-7.5.0.tar.gz", hash = "sha256:3f88b8c5ced0ea4826915a1ff67055b647252dd65ef25f4813316a6341f00b37", size = 97699, upload-time = "2026-03-02T22:28:52.462Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/65/29d94c27dfa3cdb213ae62a328c6efe6cd37b888d334e90ecaa22eadafe9/cashews-7.4.4-py3-none-any.whl", hash = "sha256:d5b8fc3cb590ed388823388b972947fd5659e2a94109af107cb508a3240f5ef0", size = 79893, upload-time = "2025-12-06T22:31:53.918Z" }, + { url = "https://files.pythonhosted.org/packages/0d/14/06cca741567a2ec458fb1db9d053e72477d9da2be387c1d705cb1060b2c6/cashews-7.5.0-py3-none-any.whl", hash = "sha256:e79cb4e5cc164d8f2d2856b166d45dcc2dd8d53b95874d2c6d07dfdb1c9ac3c4", size = 82413, upload-time = "2026-03-02T22:28:50.98Z" }, ] [package.optional-dependencies] @@ -1217,7 +1217,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.14.0" }, - { name = "cashews", extras = ["redis"], specifier = "==7.4.4" }, + { name = "cashews", extras = ["redis"], specifier = "==7.5.0" }, { name = "cloudevents", specifier = ">=1.12.0,<2.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.131.0" }, { name = "fastapi-pagination", specifier = ">=0.14.2" }, From 0842c8e21a26a85cadd78e885ff0f59857414264 Mon Sep 17 00:00:00 2001 From: ajspig <46900795+ajspig@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:36:48 -0400 Subject: [PATCH 50/65] fix: date dreamer conclusions to latest source observation (#890) * fix: date dreamer conclusions to latest source observation * fix: correct and normalize dreamer conclusion timestamps * fix: updating documentation --- src/utils/agent_tools.py | 63 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index d4df768b..ca9db2dd 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -24,7 +24,12 @@ from src.telemetry.events import ( emit, ) from src.utils import summarizer -from src.utils.formatting import format_new_turn_with_timestamp, utc_now_iso +from src.utils.formatting import ( + format_datetime_utc, + format_new_turn_with_timestamp, + parse_datetime_iso, + utc_now_iso, +) from src.utils.representation import Representation from src.utils.types import ToolResult, embedding_call_purpose, get_current_iteration @@ -1306,6 +1311,52 @@ def _normalize_observation_id(obs_id: str) -> str: return obs_id.strip() +async def _latest_source_timestamp( + ctx: ToolContext, + observations: list[schemas.ObservationInput], +) -> str | None: + """Latest ``message_created_at`` across all source observations in the batch. + + Dreamer conclusions (deductive/inductive) are derived from existing + observations referenced by ``source_ids`` rather than from live messages. + Their logical timestamp is the point when the conclusion became possible + from its evidence, not when the dreamer happened to run, so we date + ``internal_metadata["message_created_at"]`` to the most recent source + observation. The physical ``Document.created_at`` column remains the insert + time. Returns None if no source_ids resolve to a usable timestamp (caller + falls back to now). + """ + source_ids: list[str] = [] + for obs in observations: + if obs.source_ids: + source_ids.extend(obs.source_ids) + if not source_ids: + return None + + latest: datetime | None = None + async with tracked_db("create_observations.source_ts", read_only=True) as db: + docs = await crud.fetch_documents_by_ids( + db, + workspace_name=ctx.workspace_name, + observer=ctx.observer, + observed=ctx.observed, + document_ids=list(set(source_ids)), + ) + for doc in docs: + raw = doc.internal_metadata.get("message_created_at") + if not isinstance(raw, str): + continue + try: + # always tz-aware, so the comparison below can't crash on mixed formats + parsed = parse_datetime_iso(raw) + except ValueError: + continue + if latest is None or parsed > latest: + latest = parsed + + return format_datetime_utc(latest) if latest is not None else None + + async def _handle_create_observations_impl( ctx: ToolContext, tool_input: dict[str, Any], @@ -1368,10 +1419,16 @@ async def _handle_create_observations_impl( # Determine message context if ctx.current_messages: message_ids = [msg.id for msg in ctx.current_messages] - message_created_at = str(ctx.current_messages[-1].created_at) + # same ISO-8601 Z format as the dreamer path below + message_created_at = format_datetime_utc(ctx.current_messages[-1].created_at) else: + # Dreamer path: no current messages. Backdate the conclusion to the + # latest source observation, which is when the inference became possible. + message_ids = [] - message_created_at = utc_now_iso() + message_created_at = ( + await _latest_source_timestamp(ctx, observations) + ) or utc_now_iso() # Use lock to serialize database writes (prevents concurrent commit issues) async with ctx.db_lock: From 9e087e877118f86e769bb310b5d38eaf8268d086 Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Wed, 15 Jul 2026 11:47:49 -0400 Subject: [PATCH 51/65] feat(llm backend): enable combined tool calling + structured output in the LLM backend transport layer (#907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(llm): support combined tool calling and structured output across backends - OpenAI: parse() 500s on non-strict function tools; route tool-carrying structured requests through create() with an explicit json_schema response_format (mirrors the streaming path) - Anthropic: skip the '{' JSON prefill when tools are present so tool_use blocks stay reachable; make the schema instruction conditional and rely on parse + repair - Gemini: native response_schema + function calling is rejected before Gemini 3; with tools present, inject a schema instruction into the final turn instead and rely on parse + repair - All backends: tool-call turns carry no consumable content, so skip structured-output parsing on them Extracted from the dialectic structured-output branch (DEV-1652) so the transport layer can land independently. DEV-2035 Co-Authored-By: Claude Fable 5 * test(live_llm): exercise combined tools + structured output per provider Two-turn live flow per backend: a forced tool-call turn (structured parsing must be skipped) followed by a replay turn that must return a schema-conforming answer with tools still attached. Asserts the provider-specific request shaping: no parse() for OpenAI (500s on non-strict tools), no '{' prefill for Anthropic, no native response_schema for Gemini. Verified against live OpenAI (gpt-4.1, gpt-5, gpt-5.4, gpt-5.4-mini) and Gemini (gemini-2.5-flash). DEV-2035 Co-Authored-By: Claude Fable 5 * fix: some needed unrelated test failures * ci: add label-triggered live LLM test workflow Adding the run-live-llm label to a PR (or workflow_dispatch) runs tests/live_llm/ against real provider APIs — the only place the --live-llm suite runs in CI. Reuses the unified-tests environment and its Secrets Manager staging-dotenv resolution for provider keys; runs on ubuntu-latest (no Fly runner, no Docker — the suite only touches the LLM backends). Pins LIVE_LLM_ANTHROPIC_45_PLUS_MODELS=claude-sonnet-4-5 since the Anthropic family has no default models and would otherwise silently collect empty. Opt-in by design: live model behavior is variable, so this is a signal, not a required check. DEV-2035 Co-Authored-By: Claude Fable 5 * ci: run live LLM tests on main pushes touching the transport Mirrors unified-tests' push trigger, scoped to paths that can affect the live suite (src/llm/, config, the tests, deps, and the workflow itself) so provider API calls aren't spent on unrelated changes. DEV-2035 Co-Authored-By: Claude Fable 5 * ci: disable auth in live LLM test environment The staging dotenv sets AUTH_USE_AUTH=true without a usable JWT secret, and src/config.py validates the pair at import time — the same reason unified-tests overrides it. This suite never runs the API server. DEV-2035 Co-Authored-By: Claude Fable 5 * test(live_llm): fix gpt-5.4 reasoning_effort and gemini replay-turn flake - test_live_openai: gpt-5.4 dropped 'minimal' from the reasoning_effort vocabulary, so the gpt5 caching test 400'd — and the OpenAI backend's BadRequestError terminal swallowed it into an empty CompletionResult. Pick the effort per model generation. - test_live_tools_structured_output: use tool_choice='auto' on the replay turn, matching the production dialectic loop (which never forces 'none') — NONE mode is what provoked gemini-2.5-flash's empty candidates. Drop the temperature pin so retries actually resample, and treat a repeat tool call as a retryable attempt. Verified live: full suite green, gemini 4/4 consecutive passes. DEV-2035 Co-Authored-By: Claude Fable 5 * ci: fail live LLM run when no staging secret was loaded If the latest-tag fetch fails and no second tag exists, the fallback step is skipped rather than failed, and the job would proceed without provider keys — every test then skips via require_provider_key and the run goes green. Guard on both fetch outcomes so that path fails loudly. DEV-2035 Co-Authored-By: Claude Fable 5 * docs(live-llm-tests-GHA): remove extra comments * ci(CODEOWNERS): introduce CODEOWNERS and gate GHA heavy test runs behind being a CODEOWNER * ci(GHA-live-LLM-tests): consolidate common GHA steps * test(test_live_openai): fix reasoning level adjustment for gpt-5 * test(live-llm-tests): temporary removal of gate to test the workflow * test(live-llm-tests): revert removal of gate --------- Co-authored-by: Claude Fable 5 --- .github/CODEOWNERS | 19 ++ .../actions/load-staging-secrets/action.yml | 84 ++++++ .github/workflows/live-llm-tests.yml | 123 +++++++++ .github/workflows/manual-trigger-gate.yml | 81 ++++++ .github/workflows/unified-tests.yml | 110 +++----- src/llm/backends/anthropic.py | 24 +- src/llm/backends/gemini.py | 44 +++- src/llm/backends/openai.py | 42 ++- src/llm/structured_output.py | 16 ++ tests/live_llm/test_live_openai.py | 8 +- .../test_live_tools_structured_output.py | 246 ++++++++++++++++++ tests/llm/test_backends/test_anthropic.py | 204 +++++++++++++++ tests/llm/test_backends/test_gemini.py | 123 +++++++++ tests/llm/test_backends/test_openai.py | 120 +++++++++ tests/routes/test_messages.py | 2 + 15 files changed, 1150 insertions(+), 96 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/actions/load-staging-secrets/action.yml create mode 100644 .github/workflows/live-llm-tests.yml create mode 100644 .github/workflows/manual-trigger-gate.yml create mode 100644 tests/live_llm/test_live_tools_structured_output.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..039d4e6e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,19 @@ +# Code owners for Honcho. +# +# Beyond review routing, this file is the allowlist for manually triggering +# the live-llm-tests and unified-tests workflows (via their PR labels and +# workflow_dispatch). The gate jobs grep every @username in this file — +# regardless of which path pattern it sits on — and read it from `main`, +# never from the PR branch, so additions only take effect once merged. +# +# The workflow gates only understand individual @usernames (no @org/team +# entries). + +# Reviewers auto-requested on changes under .github/ (workflows, this file, +# templates). +/.github/ @akattelu @eisene @Rajat-Ahuja1997 @VVoruganti + +# CI-trigger allowlist only: this path matches no real file, so these people +# are never auto-requested for review, but the workflow gates still pick +# them up. +/ci-trigger-allowlist @3un01a @adavyas @ajspig @courtlandleer @erosika @lowyelling @matthewlanders @vintrocode diff --git a/.github/actions/load-staging-secrets/action.yml b/.github/actions/load-staging-secrets/action.yml new file mode 100644 index 00000000..64665ec5 --- /dev/null +++ b/.github/actions/load-staging-secrets/action.yml @@ -0,0 +1,84 @@ +name: Load staging secrets +description: >- + Resolve staging secret ids from the two newest v git tags, + then load the newest fetchable secret's keys into the job environment from + AWS Secrets Manager. Falls back to the second-latest tag when the latest + tag's secret isn't published yet, and fails the job loudly when neither can + be fetched. Requires the repository to be checked out and AWS credentials to + be configured beforehand. + +inputs: + secret-prefix: + description: >- + Secret-name prefix combined with a resolved tag version to form the full + secret id. Masked so it stays out of public CI logs. + required: true + +runs: + using: composite + steps: + - name: Resolve secret ids from latest git tags + id: resolve-secret + shell: bash + env: + SECRET_PREFIX: ${{ inputs.secret-prefix }} + run: | + set -euo pipefail + : "${SECRET_PREFIX:?secret-prefix input is empty — is the STAGING_SECRET_PREFIX secret set for this environment?}" + # Keep the secret-name prefix out of public CI logs. + echo "::add-mask::${SECRET_PREFIX}" + + # Two newest v tags, highest first (tags are public). + versions="$(git ls-remote --tags origin 'v*' \ + | sed -n 's#.*refs/tags/v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$#\1#p' \ + | sort -t. -k1,1nr -k2,2nr -k3,3nr -u)" + latest="$(printf '%s\n' "$versions" | sed -n '1p')" + second="$(printf '%s\n' "$versions" | sed -n '2p')" + if [ -z "${latest:-}" ]; then + echo "::error::No v git tags found to resolve a secret version" + exit 1 + fi + + latest_id="${SECRET_PREFIX}${latest}" + echo "::add-mask::${latest_id}" + echo "latest-id=${latest_id}" >> "$GITHUB_OUTPUT" + echo "Latest version: ${latest}" + if [ -n "${second:-}" ]; then + second_id="${SECRET_PREFIX}${second}" + echo "::add-mask::${second_id}" + echo "second-id=${second_id}" >> "$GITHUB_OUTPUT" + echo "Fallback version: ${second}" + fi + + # Fetch the latest tag's secret. continue-on-error so a not-yet-published + # latest falls through to the second-latest instead of failing the job. + - name: Fetch staging secret (latest) + id: fetch-latest + continue-on-error: true + uses: aws-actions/aws-secretsmanager-get-secrets@v2 + with: + secret-ids: | + ,${{ steps.resolve-secret.outputs.latest-id }} + parse-json-secrets: true + + # Runs only if the latest fetch failed; this one is NOT continue-on-error, + # so if the fallback also fails the job fails loudly. + - name: Fetch staging secret (fallback to second-latest) + id: fetch-fallback + if: steps.fetch-latest.outcome == 'failure' && steps.resolve-secret.outputs.second-id != '' + uses: aws-actions/aws-secretsmanager-get-secrets@v2 + with: + secret-ids: | + ,${{ steps.resolve-secret.outputs.second-id }} + parse-json-secrets: true + + # If the latest fetch failed and the fallback was skipped (no second tag), + # no secret keys were loaded — fail here instead of letting the job run + # without staging config (e.g. every live LLM test would silently skip via + # require_provider_key and the run would go green). + - name: Verify staging secrets were loaded + if: steps.fetch-latest.outcome != 'success' && steps.fetch-fallback.outcome != 'success' + shell: bash + run: | + echo "::error::No staging secret could be fetched (latest failed; fallback skipped or failed)" + exit 1 diff --git a/.github/workflows/live-llm-tests.yml b/.github/workflows/live-llm-tests.yml new file mode 100644 index 00000000..ee1b5b2b --- /dev/null +++ b/.github/workflows/live-llm-tests.yml @@ -0,0 +1,123 @@ +name: Live LLM Tests + +on: + # Runs on main pushes that can affect the LLM transport (narrower than + # unified-tests' src/** — live provider calls aren't worth burning on + # changes that can't reach the backends). + push: + branches: [main] + paths: + - 'src/llm/**' + - 'src/config.py' + - 'tests/live_llm/**' + - 'pyproject.toml' + - 'uv.lock' + - '.github/workflows/live-llm-tests.yml' + # Manual trigger for PRs: add the `run-live-llm` label to run the suite + # against the PR's merge commit. The label is purged as soon as the run + # starts so it can be re-added to trigger another run. + pull_request: + types: [labeled] + workflow_dispatch: + +# Cap spend: at most one active run per PR (per ref for push/dispatch). +# Re-triggering a PR run cancels the in-flight one instead of stacking live +# provider calls; pushes to main queue instead of cancelling so main CI +# results aren't lost. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name != 'push' }} + +permissions: + contents: read + +jobs: + # Only code owners (.github/CODEOWNERS) may trigger the suite manually via + # the label or workflow_dispatch; the gate also purges the trigger label so + # it can be re-added to trigger another run. + gate: + name: Gate manual trigger + permissions: + contents: read + pull-requests: write + uses: ./.github/workflows/manual-trigger-gate.yml + with: + label: run-live-llm + allow-workflow-dispatch: true + + live-llm-tests: + name: Run Live LLM Tests + needs: gate + # always() lets this run on push events, where the gate's jobs are skipped. + # Manual triggers (label / workflow_dispatch) additionally require the + # gate's CODEOWNERS check to have passed. + if: >- + always() && + (github.event_name == 'push' || + ((github.event_name == 'workflow_dispatch' || + github.event.label.name == 'run-live-llm') && + needs.gate.outputs.authorized == 'true')) + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: unified-tests + permissions: + id-token: write # Required for OIDC authentication with AWS + contents: read + env: + PYTHONUNBUFFERED: "1" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ vars.AWS_OIDC_ROLE_ARN }} + aws-region: us-east-1 + role-duration-seconds: 3600 + + # Resolves secret ids from the newest release tags, fetches the newest + # available staging secret into the job env, and fails if none loaded. + - name: Load staging secrets + uses: ./.github/actions/load-staging-secrets + with: + secret-prefix: ${{ secrets.STAGING_SECRET_PREFIX }} + + # Configure the test environment. Sentry/CloudEvents endpoints aren't + # reachable from CI. LIVE_LLM_ANTHROPIC_45_PLUS_MODELS must be set for + # the Anthropic tests to materialize — the claude_4_5_plus family has no + # default models, so with only the API key they'd silently collect as + # empty parameter sets. + - name: Configure test environment + run: | + { + # The staging dotenv carries AUTH_USE_AUTH=true without a usable + # JWT secret; src/config.py validates the pair at import time, so + # disable auth (this suite never runs the API server anyway). + echo "AUTH_USE_AUTH=false" + echo "SENTRY_ENABLED=false" + echo "TELEMETRY_ENABLED=false" + echo "LIVE_LLM_ANTHROPIC_45_PLUS_MODELS=claude-sonnet-4-5" + } >> "$GITHUB_ENV" + + - name: Install uv + uses: astral-sh/setup-uv@v2 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: "pyproject.toml" + + - name: Install the project + run: uv sync --all-extras + + # -n 0 overrides the `-n auto` xdist default from pyproject: ~15 short + # tests gain nothing from parallelism, serial execution avoids bursting + # every provider at once, and flake diagnosis gets ordered output. + - name: Run live LLM tests + run: uv run --frozen pytest tests/live_llm/ --live-llm -n 0 -v diff --git a/.github/workflows/manual-trigger-gate.yml b/.github/workflows/manual-trigger-gate.yml new file mode 100644 index 00000000..6cd8b209 --- /dev/null +++ b/.github/workflows/manual-trigger-gate.yml @@ -0,0 +1,81 @@ +name: Manual Trigger Gate + +# Shared gate for workflows that can be triggered manually on PRs by adding a +# label (and optionally via workflow_dispatch): verifies the actor is a code +# owner and purges the trigger label so it can be re-added for another run. +# +# Callers must grant `pull-requests: write` on the calling job so the +# remove-label job can delete the label, and should gate downstream jobs on +# the `authorized` output rather than this workflow's conclusion. + +on: + workflow_call: + inputs: + label: + description: PR label that triggers the calling workflow + required: true + type: string + allow-workflow-dispatch: + description: Whether workflow_dispatch events may pass the gate + required: false + default: false + type: boolean + outputs: + authorized: + description: >- + 'true' when the manual trigger's actor passed the CODEOWNERS check. + Empty on events where the check did not run (e.g. push). + value: ${{ jobs.check-actor.outputs.authorized }} + +jobs: + # Only code owners (.github/CODEOWNERS) may trigger the calling workflow + # manually. + check-actor: + name: Verify actor is a code owner + if: >- + (inputs.allow-workflow-dispatch && github.event_name == 'workflow_dispatch') || + (github.event_name == 'pull_request' && github.event.label.name == inputs.label) + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + authorized: ${{ steps.codeowners.outputs.authorized }} + steps: + - name: Check actor against CODEOWNERS on main + id: codeowners + env: + GH_TOKEN: ${{ github.token }} + ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + # Usernames are case-insensitive on GitHub; compare lowercased. + owners="$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/${{ github.repository }}/contents/.github/CODEOWNERS?ref=main" \ + | sed 's/#.*//' | grep -oE '@[A-Za-z0-9-]+' | tr -d '@' \ + | tr '[:upper:]' '[:lower:]' | sort -u)" + actor_lc="$(printf '%s' "$ACTOR" | tr '[:upper:]' '[:lower:]')" + if printf '%s\n' "$owners" | grep -qxF "$actor_lc"; then + echo "@${ACTOR} is a code owner; proceeding" + echo "authorized=true" >> "$GITHUB_OUTPUT" + else + echo "::error::@${ACTOR} is not listed in .github/CODEOWNERS on main — only code owners may trigger this workflow manually" + exit 1 + fi + + # Purge the trigger label first thing. Best-effort: failing to remove the + # label (e.g. read-only token on a fork PR) doesn't block the tests. + remove-label: + name: Remove trigger label + if: github.event_name == 'pull_request' && github.event.label.name == inputs.label + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Remove trigger label + env: + GH_TOKEN: ${{ github.token }} + run: | + if ! gh api --method DELETE \ + "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels/${{ inputs.label }}"; then + echo "::warning::Could not remove the ${{ inputs.label }} label (it may have been removed already)" + fi diff --git a/.github/workflows/unified-tests.yml b/.github/workflows/unified-tests.yml index 1ea2c539..4813a126 100644 --- a/.github/workflows/unified-tests.yml +++ b/.github/workflows/unified-tests.yml @@ -12,38 +12,44 @@ on: pull_request: types: [labeled] +# Cap spend: at most one active run per PR (per ref for push). Re-triggering +# a PR run cancels the in-flight one instead of stacking Fly machines; pushes +# to main queue instead of cancelling so main CI results aren't lost. The +# cleanup-machine job runs `if: always()`, which still executes on cancelled +# runs, so a cancelled run's Fly machine and runner are still torn down. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name != 'push' }} + permissions: contents: read actions: read jobs: - # Purge the trigger label first thing. Best-effort: failing to remove the - # label (e.g. read-only token on a fork PR) doesn't block the tests. - remove-label: - name: Remove trigger label - if: github.event_name == 'pull_request' && github.event.label.name == 'run-unified-tests' - runs-on: ubuntu-latest + # Only code owners (.github/CODEOWNERS) may trigger the suite manually via + # the label; the gate also purges the trigger label so it can be re-added + # to trigger another run. + gate: + name: Gate manual trigger permissions: + contents: read pull-requests: write - steps: - - name: Remove run-unified-tests label - env: - GH_TOKEN: ${{ github.token }} - run: | - if ! gh api --method DELETE \ - "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels/run-unified-tests"; then - echo "::warning::Could not remove the run-unified-tests label (it may have been removed already)" - fi + uses: ./.github/workflows/manual-trigger-gate.yml + with: + label: run-unified-tests start-runner: name: Start Fly Runner - needs: remove-label - # always() lets this run on push events, where remove-label is skipped. + needs: gate + # always() lets this run on push events, where the gate's jobs are skipped. # Label adds other than run-unified-tests trigger the workflow but skip - # every job here. + # every job here; the run-unified-tests label additionally requires the + # gate's CODEOWNERS check to have passed. if: >- always() && - (github.event_name == 'push' || github.event.label.name == 'run-unified-tests') + (github.event_name == 'push' || + (github.event.label.name == 'run-unified-tests' && + needs.gate.outputs.authorized == 'true')) uses: ./.github/workflows/start-fly-runner.yml secrets: inherit @@ -74,58 +80,12 @@ jobs: aws-region: us-east-1 role-duration-seconds: 43200 # 12 hours - - name: Resolve secret ids from latest git tags - id: resolve-secret - env: - SECRET_PREFIX: ${{ secrets.STAGING_SECRET_PREFIX }} - run: | - set -euo pipefail - : "${SECRET_PREFIX:?STAGING_SECRET_PREFIX secret is not set for this environment}" - # Keep the secret-name prefix out of public CI logs. - echo "::add-mask::${SECRET_PREFIX}" - - # Two newest v tags, highest first (tags are public). - versions="$(git ls-remote --tags origin 'v*' \ - | sed -n 's#.*refs/tags/v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$#\1#p' \ - | sort -t. -k1,1nr -k2,2nr -k3,3nr -u)" - latest="$(printf '%s\n' "$versions" | sed -n '1p')" - second="$(printf '%s\n' "$versions" | sed -n '2p')" - if [ -z "${latest:-}" ]; then - echo "::error::No v git tags found to resolve a secret version" - exit 1 - fi - - latest_id="${SECRET_PREFIX}${latest}" - echo "::add-mask::${latest_id}" - echo "latest-id=${latest_id}" >> "$GITHUB_OUTPUT" - echo "Latest version: ${latest}" - if [ -n "${second:-}" ]; then - second_id="${SECRET_PREFIX}${second}" - echo "::add-mask::${second_id}" - echo "second-id=${second_id}" >> "$GITHUB_OUTPUT" - echo "Fallback version: ${second}" - fi - - # Fetch the latest tag's secret. continue-on-error so a not-yet-published - # latest falls through to the second-latest instead of failing the job. - - name: Fetch staging secret (latest) - id: fetch-latest - continue-on-error: true - uses: aws-actions/aws-secretsmanager-get-secrets@v2 + # Resolves secret ids from the newest release tags, fetches the newest + # available staging secret into the job env, and fails if none loaded. + - name: Load staging secrets + uses: ./.github/actions/load-staging-secrets with: - secret-ids: | - ,${{ steps.resolve-secret.outputs.latest-id }} - parse-json-secrets: true - - # Runs only if the latest fetch failed; this one is NOT continue-on-error, - # so if the fallback also fails the job fails loudly. - - name: Fetch staging secret (fallback to second-latest) - if: steps.fetch-latest.outcome == 'failure' && steps.resolve-secret.outputs.second-id != '' - uses: aws-actions/aws-secretsmanager-get-secrets@v2 - with: - secret-ids: | - ,${{ steps.resolve-secret.outputs.second-id }} - parse-json-secrets: true + secret-prefix: ${{ secrets.STAGING_SECRET_PREFIX }} # Layer test-specific overrides on top of the staging secret. The staging # dotenv tracks the deployed release and can drift from what main's config @@ -175,10 +135,12 @@ jobs: # been renamed or removed on main) must always be ignored by the app config. - name: Configure test environment run: | - echo "AUTH_USE_AUTH=false" >> "$GITHUB_ENV" - echo "SENTRY_ENABLED=false" >> "$GITHUB_ENV" - echo "TELEMETRY_ENABLED=false" >> "$GITHUB_ENV" - echo "REASONING_TRACES_FILE=unified-reasoning-traces.jsonl" >> "$GITHUB_ENV" + { + echo "AUTH_USE_AUTH=false" + echo "SENTRY_ENABLED=false" + echo "TELEMETRY_ENABLED=false" + echo "REASONING_TRACES_FILE=unified-reasoning-traces.jsonl" + } >> "$GITHUB_ENV" - name: Verify Docker is available run: docker info diff --git a/src/llm/backends/anthropic.py b/src/llm/backends/anthropic.py index 673f4d03..614a2e32 100644 --- a/src/llm/backends/anthropic.py +++ b/src/llm/backends/anthropic.py @@ -10,7 +10,7 @@ from pydantic import BaseModel, ValidationError from src.llm.backend import CompletionResult, StreamChunk, ToolCallResult from src.llm.request_builder import apply_sdk_passthroughs -from src.llm.structured_output import repair_response_model_json +from src.llm.structured_output import repair_response_model_json, schema_instruction class AnthropicBackend: @@ -74,26 +74,28 @@ class AnthropicBackend: # from ModelConfig.provider_params. Shallow merge with operator-wins. apply_sdk_passthroughs(params, extra_params) + # The '{' prefill forces a JSON-first response, which suppresses + # tool_use blocks — skip it when tools are available and rely on the + # conditional instruction + repair fallback instead. use_json_prefill = ( bool(response_format or self._json_mode(extra_params)) and not thinking_budget_tokens + and not tools and self._supports_assistant_prefill(model) ) if use_json_prefill and params["messages"]: if response_format and isinstance(response_format, type): - schema_json = json.dumps(response_format.model_json_schema(), indent=2) self._append_text_to_last_message( params["messages"], - f"\n\nRespond with valid JSON matching this schema:\n{schema_json}", + schema_instruction(response_format, tools_present=False), ) params["messages"].append({"role": "assistant", "content": "{"}) elif ( response_format and isinstance(response_format, type) and params["messages"] ): - schema_json = json.dumps(response_format.model_json_schema(), indent=2) self._append_text_to_last_message( params["messages"], - f"\n\nRespond with valid JSON matching this schema:\n{schema_json}", + schema_instruction(response_format, tools_present=bool(tools)), ) response = await self._client.messages.create(**params) @@ -155,26 +157,27 @@ class AnthropicBackend: # Operator escape hatch: forward Anthropic SDK passthrough kwargs # from ModelConfig.provider_params. Shallow merge with operator-wins. apply_sdk_passthroughs(params, extra_params) + # See complete(): no '{' prefill when tools are available, so + # tool_use blocks stay reachable on the streamed path too. use_json_prefill = ( bool(response_format or is_json_mode) and not thinking_budget_tokens + and not tools and self._supports_assistant_prefill(model) ) if use_json_prefill and params["messages"]: if response_format and isinstance(response_format, type): - schema_json = json.dumps(response_format.model_json_schema(), indent=2) self._append_text_to_last_message( params["messages"], - f"\n\nRespond with valid JSON matching this schema:\n{schema_json}", + schema_instruction(response_format, tools_present=False), ) params["messages"].append({"role": "assistant", "content": "{"}) elif ( response_format and isinstance(response_format, type) and params["messages"] ): - schema_json = json.dumps(response_format.model_json_schema(), indent=2) self._append_text_to_last_message( params["messages"], - f"\n\nRespond with valid JSON matching this schema:\n{schema_json}", + schema_instruction(response_format, tools_present=bool(tools)), ) if thinking_budget_tokens: params["thinking"] = { @@ -251,7 +254,8 @@ class AnthropicBackend: ) content: Any = text_content - if response_format is not None: + # Tool-call turns carry no consumable content + if response_format is not None and not tool_calls: raw_content = f"{{{text_content}" if prefilled_json else text_content try: if prefilled_json: diff --git a/src/llm/backends/gemini.py b/src/llm/backends/gemini.py index 5c70268a..c114196e 100644 --- a/src/llm/backends/gemini.py +++ b/src/llm/backends/gemini.py @@ -15,7 +15,7 @@ from src.llm.caching import ( gemini_cache_store, ) from src.llm.request_builder import coerce_passthrough_mapping -from src.llm.structured_output import repair_response_model_json +from src.llm.structured_output import repair_response_model_json, schema_instruction GEMINI_BLOCKED_FINISH_REASONS = { "SAFETY", @@ -31,6 +31,29 @@ class GeminiBackend: def __init__(self, client: Any) -> None: self._client: Any = client + @staticmethod + def _append_schema_instruction( + contents: list[dict[str, Any]] | str, + response_format: type[BaseModel], + ) -> list[dict[str, Any]] | str: + """Append the schema instruction to the final turn. + + Used when tools accompany a response_format: native response_schema + + function calling is a Gemini 3 preview feature and earlier models + reject the pairing, so instruct the model and rely on parse + repair. + Returns a new list — _convert_messages shallow-copies parts-style + messages, so in-place appends would leak into the caller's history + and accumulate across tool-loop iterations. + """ + instruction = schema_instruction(response_format, tools_present=True) + if isinstance(contents, str): + return contents + instruction + if not contents: + return contents + last = contents[-1] + parts: list[Any] = [*(last.get("parts") or []), {"text": instruction}] + return [*contents[:-1], {**last, "parts": parts}] + async def complete( self, *, @@ -61,6 +84,10 @@ class GeminiBackend: ) if system_instruction: config["system_instruction"] = system_instruction + if tools and isinstance(response_format, type): + # The final turn is never part of the cached prefix, so this is + # safe to do before cache attachment. + contents = self._append_schema_instruction(contents, response_format) cache_policy = ( extra_params.get("cache_policy") @@ -130,6 +157,10 @@ class GeminiBackend: ) if system_instruction: config["system_instruction"] = system_instruction + if tools and isinstance(response_format, type): + # The final turn is never part of the cached prefix, so this is + # safe to do before cache attachment. + contents = self._append_schema_instruction(contents, response_format) cache_policy = ( extra_params.get("cache_policy") @@ -228,7 +259,11 @@ class GeminiBackend: config["tools"] = self._convert_tools(tools) if tool_choice: config["tool_config"] = self._convert_tool_choice(tool_choice) - if response_format is not None: + # Native structured output combined with function calling is a + # Gemini 3 preview feature; earlier models reject the pairing. With + # tools present, callers inject a schema instruction instead (see + # _append_schema_instruction) and rely on parse + repair downstream. + if response_format is not None and not tools: config["response_mime_type"] = "application/json" config["response_schema"] = response_format elif extra_params and extra_params.get("json_mode") and not tools: @@ -335,7 +370,10 @@ class GeminiBackend: ) content: Any = "\n".join(text_parts) if text_parts else "" - if response_format is not None: + # Tool-call turns carry no consumable content — the tool loop ignores + # it — and parsing their (empty) text would raise through the repair + # fallback, failing the iteration. + if response_format is not None and not tool_calls: parsed_response = getattr(response, "parsed", None) if isinstance(parsed_response, response_format): content = parsed_response diff --git a/src/llm/backends/openai.py b/src/llm/backends/openai.py index 8c0ca8a4..6d13f1fc 100644 --- a/src/llm/backends/openai.py +++ b/src/llm/backends/openai.py @@ -172,6 +172,24 @@ class OpenAIBackend: response, response_format, model, empty_on_missing=True ) return self._normalize_response(response, content_override=content) + if tools: + # parse() refuses non-strict function tools, and our agent tool + # schemas are deliberately non-strict (see _convert_tools), so + # tool-loop iterations use create() with an explicit json_schema + # response_format — same server-side schema enforcement, no + # strict-tools requirement — mirroring the streaming path. + params["response_format"] = self._json_schema_response_format( + response_format + ) + response = await self._client.chat.completions.create(**params) + # Tool-call turns carry no consumable content — the tool loop + # ignores it — and parsing their empty text would raise. + if getattr(response.choices[0].message, "tool_calls", None): + return self._normalize_response(response) + content = self._parse_or_repair_structured_content( + response, response_format, model, empty_on_missing=False + ) + return self._normalize_response(response, content_override=content) params["response_format"] = response_format try: response = await self._client.chat.completions.parse(**params) @@ -274,13 +292,9 @@ class OpenAIBackend: else: # Streaming create() can't take a BaseModel like parse() does; # convert to a json_schema dict. - params["response_format"] = { - "type": "json_schema", - "json_schema": { - "name": response_format.__name__, - "schema": response_format.model_json_schema(), - }, - } + params["response_format"] = self._json_schema_response_format( + response_format + ) elif response_format is not None: params["response_format"] = response_format elif extra_params and extra_params.get("json_mode"): @@ -422,6 +436,20 @@ class OpenAIBackend: raw_response=response, ) + @staticmethod + def _json_schema_response_format( + response_format: type[BaseModel], + ) -> dict[str, Any]: + """Build the response_format param for create() calls that can't use + parse(): streaming, and requests carrying non-strict function tools.""" + return { + "type": "json_schema", + "json_schema": { + "name": response_format.__name__, + "schema": response_format.model_json_schema(), + }, + } + @staticmethod def _structured_output_mode(extra_params: dict[str, Any] | None) -> str | None: # Threaded in via extra_params (see build_config_extra_params). diff --git a/src/llm/structured_output.py b/src/llm/structured_output.py index 0bb346ff..37d6b982 100644 --- a/src/llm/structured_output.py +++ b/src/llm/structured_output.py @@ -12,6 +12,22 @@ class StructuredOutputError(ValueError): """Raised when structured output cannot be validated or repaired.""" +def schema_instruction(response_format: type[BaseModel], *, tools_present: bool) -> str: + """Structured-output instruction appended to the conversation for + providers without native (or tools-compatible) schema enforcement. + + When tools are in play the wording is conditional so the model remains + free to emit tool calls; validation then relies on parse + repair. + """ + schema_json = json.dumps(response_format.model_json_schema(), indent=2) + if tools_present: + return ( + "\n\nIf not responding with a tool call, respond with valid JSON " + f"matching this schema:\n{schema_json}" + ) + return f"\n\nRespond with valid JSON matching this schema:\n{schema_json}" + + def repair_response_model_json( raw_content: str, response_model: type[BaseModel], diff --git a/tests/live_llm/test_live_openai.py b/tests/live_llm/test_live_openai.py index e5617ff0..8b0544ec 100644 --- a/tests/live_llm/test_live_openai.py +++ b/tests/live_llm/test_live_openai.py @@ -93,7 +93,11 @@ async def test_live_openai_gpt5_reasoning_structured_output_and_prefix_caching( monkeypatch: pytest.MonkeyPatch, ) -> None: require_provider_key(model_spec) - backend, config = make_backend(model_spec, reasoning_effort="minimal") + # Only the original gpt-5 generation accepts 'minimal'; gpt-5.1+ replaced + # it with 'none'. 'low' is valid everywhere else, including future models. + is_base_gpt5 = model_spec.model == "gpt-5" or model_spec.model.startswith("gpt-5-") + reasoning_effort = "minimal" if is_base_gpt5 else "low" + backend, config = make_backend(model_spec, reasoning_effort=reasoning_effort) parse_calls = wrap_async_method( monkeypatch, backend._client.chat.completions, @@ -136,7 +140,7 @@ async def test_live_openai_gpt5_reasoning_structured_output_and_prefix_caching( assert second.cache_read_input_tokens > 0 assert parse_calls[0]["kwargs"]["response_format"] is StructuredLiveResponse - assert parse_calls[0]["kwargs"]["reasoning_effort"] == "minimal" + assert parse_calls[0]["kwargs"]["reasoning_effort"] == reasoning_effort assert "max_completion_tokens" in parse_calls[0]["kwargs"] assert "max_tokens" not in parse_calls[0]["kwargs"] diff --git a/tests/live_llm/test_live_tools_structured_output.py b/tests/live_llm/test_live_tools_structured_output.py new file mode 100644 index 00000000..33aac21b --- /dev/null +++ b/tests/live_llm/test_live_tools_structured_output.py @@ -0,0 +1,246 @@ +"""Live coverage for combining tool calling with structured output. + +Each provider needs a different workaround when a request carries both +function tools and a response_format (see src/llm/backends/): + +- OpenAI: parse() rejects non-strict function tools with a 500, so + tool-carrying structured requests must go through create() with an + explicit json_schema response_format. +- Anthropic: the '{' assistant prefill suppresses tool_use blocks, so it + must be skipped when tools are present (conditional instruction + + parse/repair instead). +- Gemini: native response_schema + function calling is rejected before + Gemini 3, so a schema instruction is injected into the final turn. + +The flow below drives both halves of the combination against real APIs: +a first turn that must produce a tool call (structured parsing skipped), +and a replay turn that must produce a schema-conforming final answer +while tools are still attached. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import BaseModel, ValidationError + +from src.exceptions import LLMError +from src.llm.backend import CompletionResult +from src.llm.history_adapters import ( + AnthropicHistoryAdapter, + GeminiHistoryAdapter, + HistoryAdapter, + OpenAIHistoryAdapter, +) +from src.llm.request_builder import execute_completion +from src.llm.structured_output import StructuredOutputError + +from .conftest import ( + execute_local_tool, + favorite_prime_tools, + make_backend, + require_provider_key, + wrap_async_method, +) +from .model_matrix import LiveModelSpec, get_live_model_specs + +pytestmark = [pytest.mark.live_llm] + +_OPENAI_TOOL_SPECS = tuple( + spec + for spec in get_live_model_specs(provider="openai", feature="structured_output") + if spec.family in {"gpt_4_class", "gpt_5_class"} +) + + +class FavoritePrimeReport(BaseModel): + number: int + is_prime: bool + summary: str + + +_INITIAL_PROMPT = ( + "Before answering, call the get_favorite_prime tool exactly once. " + "Do not answer with plain text or JSON on this turn. " + "After you receive the tool result, answer with JSON where number is " + "the tool's number, is_prime says whether that number is prime, and " + "summary is one short sentence." +) + + +async def run_tool_then_structured_flow( + backend: Any, + config: Any, + adapter: HistoryAdapter, +) -> tuple[CompletionResult, CompletionResult]: + """First turn must tool-call (parsing skipped); replay turn must return + a schema-conforming answer with tools still attached.""" + initial_messages = [{"role": "user", "content": _INITIAL_PROMPT}] + tools = favorite_prime_tools() + + first = await execute_completion( + backend, + config, + messages=initial_messages, + max_tokens=4096, + tools=tools, + tool_choice="required", + response_format=FavoritePrimeReport, + ) + + assert first.tool_calls, "first turn should issue a tool call" + assert not isinstance( + first.content, FavoritePrimeReport + ), "tool-call turns carry no consumable content and must not be parsed" + + tool_call = first.tool_calls[0] + tool_result = execute_local_tool(tool_call.name, tool_call.input) + replay_messages = initial_messages + [ + adapter.format_assistant_tool_message(first), + *adapter.format_tool_results( + [ + { + "tool_id": tool_call.id, + "tool_name": tool_call.name, + "result": tool_result, + } + ] + ), + ] + + # tool_choice stays "auto" on the replay turn — the production tool loop + # (dialectic) never forces "none", and gemini-2.5-flash is prone to + # returning empty candidates under NONE mode. Retry the turn on empty / + # unparseable candidates (in production the executor's retry layer + # absorbs those; this calls the backend directly) and on the rare run + # where the model chooses to tool-call again instead of answering. + second: CompletionResult | None = None + last_error: Exception | None = None + for _ in range(3): + try: + candidate = await execute_completion( + backend, + config, + messages=replay_messages, + max_tokens=4096, + tools=tools, + tool_choice="auto", + response_format=FavoritePrimeReport, + ) + except (ValidationError, LLMError, StructuredOutputError) as exc: + last_error = exc + continue + if candidate.tool_calls: + last_error = AssertionError( + "model issued another tool call instead of answering" + ) + continue + second = candidate + break + if second is None: + raise AssertionError( + "structured replay turn failed on all attempts" + ) from last_error + + assert isinstance(second.content, FavoritePrimeReport) + assert second.content.number == 13 + assert second.content.is_prime is True + return first, second + + +@pytest.mark.asyncio +@pytest.mark.requires_anthropic +@pytest.mark.parametrize( + "model_spec", + get_live_model_specs(provider="anthropic", feature="structured_output"), + ids=lambda spec: spec.id, +) +async def test_live_anthropic_tools_with_structured_output( + model_spec: LiveModelSpec, + monkeypatch: pytest.MonkeyPatch, +) -> None: + require_provider_key(model_spec) + backend, config = make_backend(model_spec) + create_calls = wrap_async_method(monkeypatch, backend._client.messages, "create") + + await run_tool_then_structured_flow(backend, config, AnthropicHistoryAdapter()) + + assert len(create_calls) >= 2 + for call in create_calls: + messages = call["kwargs"]["messages"] + assert messages[-1] != { + "role": "assistant", + "content": "{", + }, "the '{' prefill would suppress tool_use blocks" + assert "If not responding with a tool call" in str( + messages[-1] + ), "schema instruction should use the conditional wording with tools" + + +@pytest.mark.asyncio +@pytest.mark.requires_openai +@pytest.mark.parametrize("model_spec", _OPENAI_TOOL_SPECS, ids=lambda spec: spec.id) +async def test_live_openai_tools_with_structured_output( + model_spec: LiveModelSpec, + monkeypatch: pytest.MonkeyPatch, +) -> None: + require_provider_key(model_spec) + # gpt-5.4 rejects function tools combined with any explicit + # reasoning_effort other than 'none' on /v1/chat/completions, so leave + # the parameter unset and let the server default apply. + backend, config = make_backend(model_spec) + parse_calls = wrap_async_method( + monkeypatch, backend._client.chat.completions, "parse" + ) + create_calls = wrap_async_method( + monkeypatch, backend._client.chat.completions, "create" + ) + + await run_tool_then_structured_flow(backend, config, OpenAIHistoryAdapter()) + + # favorite_prime_tools() is deliberately non-strict, the exact shape + # parse() refuses with a 500. + assert not parse_calls, "tool-carrying structured requests must avoid parse()" + assert len(create_calls) >= 2 + for call in create_calls: + response_format = call["kwargs"]["response_format"] + assert response_format["type"] == "json_schema" + assert response_format["json_schema"]["name"] == "FavoritePrimeReport" + + +@pytest.mark.asyncio +@pytest.mark.requires_gemini +@pytest.mark.parametrize( + "model_spec", + get_live_model_specs(provider="gemini", feature="structured_output"), + ids=lambda spec: spec.id, +) +async def test_live_gemini_tools_with_structured_output( + model_spec: LiveModelSpec, + monkeypatch: pytest.MonkeyPatch, +) -> None: + require_provider_key(model_spec) + # No temperature pin: gemini-2.5-flash occasionally returns an empty + # candidate on the replay turn, and at temperature=0 the retry re-sends + # a deterministic request — default sampling gives retries a real chance. + backend, config = make_backend(model_spec) + generate_calls = wrap_async_method( + monkeypatch, + backend._client.aio.models, + "generate_content", + ) + + await run_tool_then_structured_flow(backend, config, GeminiHistoryAdapter()) + + assert len(generate_calls) >= 2 + for call in generate_calls: + gen_config = call["kwargs"]["config"] + assert ( + "response_schema" not in gen_config + ), "native response_schema + function calling is rejected pre-Gemini 3" + assert "response_mime_type" not in gen_config + contents = call["kwargs"]["contents"] + assert "matching this schema" in str( + contents[-1] + ), "schema instruction should be injected into the final turn" diff --git a/tests/llm/test_backends/test_anthropic.py b/tests/llm/test_backends/test_anthropic.py index c7a4bb75..13255ec7 100644 --- a/tests/llm/test_backends/test_anthropic.py +++ b/tests/llm/test_backends/test_anthropic.py @@ -1,4 +1,5 @@ from types import SimpleNamespace +from typing import Any from unittest.mock import AsyncMock, Mock import pytest @@ -245,3 +246,206 @@ async def test_anthropic_backend_ignores_thinking_effort() -> None: call = await_args.kwargs assert "thinking" not in call assert "reasoning_effort" not in call + + +def _make_client(content_blocks: list[Any]) -> Mock: + client = Mock() + client.messages.create = AsyncMock( + return_value=SimpleNamespace( + content=content_blocks, + usage=SimpleNamespace( + input_tokens=10, + output_tokens=5, + cache_creation_input_tokens=0, + cache_read_input_tokens=0, + ), + stop_reason="end_turn", + ) + ) + return client + + +SEARCH_TOOL = { + "name": "search", + "description": "Search for information", + "input_schema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + }, +} + + +@pytest.mark.asyncio +async def test_anthropic_backend_no_prefill_when_tools_present() -> None: + """With tools + response_format, the '{' prefill must be skipped and the + schema instruction must be conditional, so tool_use blocks stay reachable.""" + client = _make_client([TextBlock(type="text", text='{"answer":"ok"}')]) + + backend = AnthropicBackend(client) + result = await backend.complete( + # claude-3-5 supports prefill, so only the tools guard prevents it here + model="claude-3-5-sonnet-latest", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + tools=[SEARCH_TOOL], + response_format=StructuredResponse, + ) + + assert isinstance(result.content, StructuredResponse) + call = client.messages.create.await_args.kwargs + assert call["messages"][-1]["role"] == "user" # no assistant '{' prefill + assert ( + "If not responding with a tool call, respond with valid JSON" + in call["messages"][0]["content"] + ) + + +@pytest.mark.asyncio +async def test_anthropic_backend_prefill_unchanged_without_tools() -> None: + """Tool-less structured calls keep the prefill + unconditional wording.""" + client = _make_client([TextBlock(type="text", text='"answer":"ok"}')]) + + backend = AnthropicBackend(client) + result = await backend.complete( + model="claude-3-5-sonnet-latest", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=StructuredResponse, + ) + + assert isinstance(result.content, StructuredResponse) + call = client.messages.create.await_args.kwargs + assert call["messages"][-1] == {"role": "assistant", "content": "{"} + instruction = call["messages"][0]["content"] + assert "\n\nRespond with valid JSON matching this schema:" in instruction + assert "If not responding with a tool call" not in instruction + + +@pytest.mark.asyncio +async def test_anthropic_backend_repairs_malformed_structured_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Structured output that fails JSON parsing falls back to + repair_response_model_json, whose result becomes the response content.""" + # After the '{' prefill is prepended this is still invalid JSON. + client = _make_client([TextBlock(type="text", text='"answer": not-json')]) + + repaired = StructuredResponse(answer="fixed") + repair_calls: list[tuple[str, type[BaseModel], str]] = [] + + def _fake_repair( + raw: str, response_format: type[BaseModel], model_name: str + ) -> StructuredResponse: + repair_calls.append((raw, response_format, model_name)) + return repaired + + monkeypatch.setattr( + "src.llm.backends.anthropic.repair_response_model_json", _fake_repair + ) + + backend = AnthropicBackend(client) + result = await backend.complete( + model="claude-3-5-sonnet-latest", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=StructuredResponse, + ) + + assert result.content is repaired + assert repair_calls == [ + ('{"answer": not-json', StructuredResponse, "claude-3-5-sonnet-latest") + ] + + +@pytest.mark.asyncio +async def test_anthropic_backend_skips_parsing_on_tool_call_turns( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A tool-call response with response_format set must not attempt JSON + parsing — the repair fallback raises on the empty text of tool-call + turns, which would fail every intermediate tool iteration.""" + client = _make_client( + [ + ToolUseBlock( + type="tool_use", + id="tool_1", + name="search", + input={"query": "honcho"}, + ) + ] + ) + + def _fail_repair(*_args: object, **_kwargs: object) -> None: + raise AssertionError("repair must not run for tool-call turns") + + monkeypatch.setattr( + "src.llm.backends.anthropic.repair_response_model_json", _fail_repair + ) + + backend = AnthropicBackend(client) + result = await backend.complete( + model="claude-3-5-sonnet-latest", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + tools=[SEARCH_TOOL], + response_format=StructuredResponse, + ) + + assert result.content == "" # raw (empty) text, not a parsed model + assert result.tool_calls[0].name == "search" + + +@pytest.mark.asyncio +async def test_anthropic_backend_stream_no_prefill_when_tools_present() -> None: + """The streaming path applies the same tools guard.""" + + class _FakeStream: + def __init__(self) -> None: + self._chunks: list[SimpleNamespace] = [ + SimpleNamespace( + type="content_block_delta", + delta=SimpleNamespace(text='{"answer":"ok"}'), + ) + ] + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args: object) -> bool: + return False + + def __aiter__(self): + return self + + async def __anext__(self): + if self._chunks: + return self._chunks.pop(0) + raise StopAsyncIteration + + async def get_final_message(self): + return SimpleNamespace( + stop_reason="end_turn", usage=SimpleNamespace(output_tokens=5) + ) + + client = Mock() + client.messages.stream = Mock(return_value=_FakeStream()) + + backend = AnthropicBackend(client) + chunks = [ + chunk + async for chunk in backend.stream( + model="claude-3-5-sonnet-latest", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + tools=[SEARCH_TOOL], + response_format=StructuredResponse, + ) + ] + + assert chunks[0].content == '{"answer":"ok"}' + call = client.messages.stream.call_args.kwargs + assert call["messages"][-1]["role"] == "user" # no assistant '{' prefill + assert ( + "If not responding with a tool call, respond with valid JSON" + in call["messages"][0]["content"] + ) diff --git a/tests/llm/test_backends/test_gemini.py b/tests/llm/test_backends/test_gemini.py index ca4fce97..1e3933b4 100644 --- a/tests/llm/test_backends/test_gemini.py +++ b/tests/llm/test_backends/test_gemini.py @@ -502,3 +502,126 @@ def test_gemini_convert_tools_sanitizes_parameters_schema() -> None: params = converted[0]["function_declarations"][0]["parameters"] assert "additionalProperties" not in params assert "additionalProperties" not in params["properties"]["observations"]["items"] + + +class _GeminiStructured(BaseModel): + answer: str + + +GEMINI_AGENT_TOOL = { + "name": "search", + "description": "Search for information", + "input_schema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + }, +} + + +def _gemini_response( + parts: list[SimpleNamespace], parsed: object = None +) -> SimpleNamespace: + return SimpleNamespace( + candidates=[ + SimpleNamespace( + finish_reason=SimpleNamespace(name="STOP"), + content=SimpleNamespace(parts=parts), + ) + ], + usage_metadata=SimpleNamespace( + prompt_token_count=12, + candidates_token_count=6, + ), + parsed=parsed, + ) + + +@pytest.mark.asyncio +async def test_gemini_backend_structured_with_tools_skips_native_schema() -> None: + """Native response_schema + function calling is Gemini-3-preview-only, so + with tools present the schema must be delivered as an instruction on the + final turn instead, and the answer parsed from raw text.""" + client = Mock() + client.aio.models.generate_content = AsyncMock( + return_value=_gemini_response([SimpleNamespace(text='{"answer":"ok"}')]) + ) + + backend = GeminiBackend(client) + result = await backend.complete( + model="gemini-2.5-flash", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + tools=[GEMINI_AGENT_TOOL], + response_format=_GeminiStructured, + ) + + assert isinstance(result.content, _GeminiStructured) + assert result.content.answer == "ok" + await_args = client.aio.models.generate_content.await_args + if await_args is None: + raise AssertionError("Expected Gemini generate_content call") + call = await_args.kwargs + assert "response_schema" not in call["config"] + assert "response_mime_type" not in call["config"] + assert "tools" in call["config"] + last_part_text = call["contents"][-1]["parts"][-1]["text"] + assert "If not responding with a tool call" in last_part_text + + +@pytest.mark.asyncio +async def test_gemini_backend_structured_tool_call_turn_not_parsed() -> None: + """A tool-call turn under tools + response_format must not attempt JSON + parsing (its text is empty and the repair fallback raises on that).""" + client = Mock() + client.aio.models.generate_content = AsyncMock( + return_value=_gemini_response( + [ + SimpleNamespace( + function_call=SimpleNamespace( + name="search", args={"query": "honcho"} + ), + thought_signature=None, + ) + ] + ) + ) + + backend = GeminiBackend(client) + result = await backend.complete( + model="gemini-2.5-flash", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + tools=[GEMINI_AGENT_TOOL], + response_format=_GeminiStructured, + ) + + assert result.content == "" # raw (empty) text, not a parsed model + assert result.tool_calls[0].name == "search" + assert result.tool_calls[0].input == {"query": "honcho"} + + +@pytest.mark.asyncio +async def test_gemini_backend_structured_without_tools_uses_native_schema() -> None: + """Tool-less structured calls keep native response_schema enforcement.""" + client = Mock() + client.aio.models.generate_content = AsyncMock( + return_value=_gemini_response( + [SimpleNamespace(text='{"answer":"ok"}')], + parsed={"answer": "ok"}, + ) + ) + + backend = GeminiBackend(client) + result = await backend.complete( + model="gemini-2.5-flash", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=_GeminiStructured, + ) + + assert isinstance(result.content, _GeminiStructured) + call = client.aio.models.generate_content.await_args.kwargs # pyright: ignore + assert call["config"]["response_schema"] is _GeminiStructured + assert call["config"]["response_mime_type"] == "application/json" + # No instruction injected on the tool-less path. + assert call["contents"][-1]["parts"][-1]["text"] == "Hello" diff --git a/tests/llm/test_backends/test_openai.py b/tests/llm/test_backends/test_openai.py index 0ff6988f..89572f25 100644 --- a/tests/llm/test_backends/test_openai.py +++ b/tests/llm/test_backends/test_openai.py @@ -947,3 +947,123 @@ async def test_stream_structured_output_json_object_mode() -> None: assert system_messages assert "JSON" in system_messages[0]["content"] assert "json" in system_messages[0]["content"] + + +AGENT_TOOL = { + "name": "search", + "description": "Search for information", + "input_schema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + }, +} + + +@pytest.mark.asyncio +async def test_openai_backend_structured_with_tools_uses_create_not_parse() -> None: + """With tools + response_format, complete() must route through create() + with an explicit json_schema response_format: parse() raises client-side + on non-strict function tools, and our agent tools are deliberately + non-strict (see _convert_tools).""" + client = Mock() + client.chat.completions.create = AsyncMock( + return_value=_structured_create_return('{"answer":"ok"}') + ) + client.chat.completions.parse = AsyncMock( + side_effect=AssertionError("parse() must not be called with tools") + ) + + backend = OpenAIBackend(client) + result = await backend.complete( + model="gpt-5.4-mini", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + tools=[AGENT_TOOL], + response_format=_StructuredResponse, + ) + + assert isinstance(result.content, _StructuredResponse) + assert result.content.answer == "ok" + client.chat.completions.parse.assert_not_awaited() + call = _await_kwargs(client.chat.completions.create) + assert call["response_format"]["type"] == "json_schema" + assert ( + call["response_format"]["json_schema"]["schema"] + == _StructuredResponse.model_json_schema() + ) + # Tools stay non-strict; strictness was the whole reason to avoid parse(). + assert "strict" not in call["tools"][0]["function"] + + +@pytest.mark.asyncio +async def test_openai_backend_structured_with_tools_skips_parsing_tool_call_turn() -> ( + None +): + """A tool-call turn under tools + response_format must not attempt JSON + parsing (its content is empty and _parse_or_repair raises on that).""" + client = Mock() + client.chat.completions.create = AsyncMock( + return_value=SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason="tool_calls", + message=SimpleNamespace( + content=None, + tool_calls=[ + SimpleNamespace( + id="tool_1", + function=SimpleNamespace( + name="search", + arguments='{"query": "honcho"}', + ), + ) + ], + reasoning_details=[], + refusal=None, + ), + ) + ], + usage=SimpleNamespace( + prompt_tokens=10, + completion_tokens=5, + prompt_tokens_details=None, + ), + ) + ) + + backend = OpenAIBackend(client) + result = await backend.complete( + model="gpt-5.4-mini", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + tools=[AGENT_TOOL], + response_format=_StructuredResponse, + ) + + assert result.content == "" # raw empty text, not a parsed model + assert result.tool_calls[0].name == "search" + assert result.tool_calls[0].input == {"query": "honcho"} + + +@pytest.mark.asyncio +async def test_openai_backend_structured_without_tools_still_uses_parse() -> None: + """Tool-less structured calls (deriver, final synthesis) keep parse().""" + parsed = _StructuredResponse(answer="ok") + client = Mock() + client.chat.completions.parse = AsyncMock( + return_value=_structured_create_return('{"answer":"ok"}', parsed=parsed) + ) + client.chat.completions.create = AsyncMock( + side_effect=AssertionError("create() must not be called without tools") + ) + + backend = OpenAIBackend(client) + result = await backend.complete( + model="gpt-5.4-mini", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=_StructuredResponse, + ) + + assert result.content is parsed + client.chat.completions.create.assert_not_awaited() diff --git a/tests/routes/test_messages.py b/tests/routes/test_messages.py index e70c0f18..a0186adc 100644 --- a/tests/routes/test_messages.py +++ b/tests/routes/test_messages.py @@ -63,6 +63,7 @@ async def test_create_message_schedules_immediate_embed( with ( patch("src.config.settings.EMBED_MESSAGES", True), + patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 50), patch( "src.reconciler.embed_now.embed_messages_now", new=AsyncMock() ) as mock_embed_now, @@ -147,6 +148,7 @@ async def test_file_upload_schedules_immediate_embed( with ( patch("src.config.settings.EMBED_MESSAGES", True), + patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 50), patch( "src.reconciler.embed_now.embed_messages_now", new=AsyncMock() ) as mock_embed_now, From 68c46cde8119dc1645f5cd4fee75ead8fecc8f1d Mon Sep 17 00:00:00 2001 From: Talha Abdur Rahman Date: Wed, 15 Jul 2026 22:53:29 +0530 Subject: [PATCH 52/65] CI/CD Workflow file Added (#909) * CI/CD Workflow file Added * Updated Tag & SA Key * update input Tag * fix: pass workflow inputs to shell via env to prevent command injection Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Rajat Ahuja Co-authored-by: Claude Fable 5 --- .github/workflows/push-gcp-registry-prod.yml | 56 +++++++++++++++++++ .../workflows/push-gcp-registry-staging.yml | 56 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 .github/workflows/push-gcp-registry-prod.yml create mode 100644 .github/workflows/push-gcp-registry-staging.yml diff --git a/.github/workflows/push-gcp-registry-prod.yml b/.github/workflows/push-gcp-registry-prod.yml new file mode 100644 index 00000000..d5d9fc42 --- /dev/null +++ b/.github/workflows/push-gcp-registry-prod.yml @@ -0,0 +1,56 @@ +name: Build and Push to GCP Artifact Registry (production) + +permissions: + contents: read + +on: + push: + tags: + - v* + workflow_dispatch: + inputs: + version: + description: "Version to deploy (without v prefix)" + required: true + type: string + default: "manual" + +env: + GCP_PROJECT_ID: ${{ secrets.PROD_GCP_PROJECT_ID }} + GCP_AR_LOCATION: ${{ secrets.PROD_GCP_AR_LOCATION }} + GCP_AR_REPO: ${{ secrets.PROD_GCP_AR_REPO }} + IMAGE_NAME: ${{ secrets.PROD_IMAGE_NAME }} + GCP_SA_KEY: ${{ secrets.PROD_GCP_SA_KEY }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Authenticate to GCP + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ env.GCP_SA_KEY }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Configure Docker for Artifact Registry + run: gcloud auth configure-docker ${{ env.GCP_AR_LOCATION }}-docker.pkg.dev --quiet + + - name: Build and push image + env: + VERSION: ${{ github.event.inputs.version }} + run: | + # Determine the image label based on trigger type + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + IMAGE_LABEL="deployment-${VERSION}" + else + IMAGE_LABEL="deployment-${GITHUB_REF_NAME}" + fi + BASE="${{ env.GCP_AR_LOCATION }}-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/${{ env.GCP_AR_REPO }}/${{ env.IMAGE_NAME }}" + TAG="$BASE:$IMAGE_LABEL" + docker build -t "$TAG" . + docker push "$TAG" diff --git a/.github/workflows/push-gcp-registry-staging.yml b/.github/workflows/push-gcp-registry-staging.yml new file mode 100644 index 00000000..f41a38de --- /dev/null +++ b/.github/workflows/push-gcp-registry-staging.yml @@ -0,0 +1,56 @@ +name: Build and Push to GCP Artifact Registry (Staging) + +permissions: + contents: read + +on: + push: + tags: + - v* + workflow_dispatch: + inputs: + version: + description: "Version to deploy (without v prefix)" + required: true + type: string + default: "manual" + +env: + GCP_PROJECT_ID: ${{ secrets.STAGING_GCP_PROJECT_ID }} + GCP_AR_LOCATION: ${{ secrets.STAGING_GCP_AR_LOCATION }} + GCP_AR_REPO: ${{ secrets.STAGING_GCP_AR_REPO }} + IMAGE_NAME: ${{ secrets.STAGING_IMAGE_NAME }} + GCP_SA_KEY: ${{ secrets.STAGING_GCP_SA_KEY }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Authenticate to GCP + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ env.GCP_SA_KEY }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Configure Docker for Artifact Registry + run: gcloud auth configure-docker ${{ env.GCP_AR_LOCATION }}-docker.pkg.dev --quiet + + - name: Build and push image + env: + VERSION: ${{ github.event.inputs.version }} + run: | + # Determine the image label based on trigger type + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + IMAGE_LABEL="deployment-${VERSION}" + else + IMAGE_LABEL="deployment-${GITHUB_REF_NAME}" + fi + BASE="${{ env.GCP_AR_LOCATION }}-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/${{ env.GCP_AR_REPO }}/${{ env.IMAGE_NAME }}" + TAG="$BASE:$IMAGE_LABEL" + docker build -t "$TAG" . + docker push "$TAG" From d2d397f14aa37b8b37c89701baff3f9d0250ec86 Mon Sep 17 00:00:00 2001 From: Ulysse Pence Date: Thu, 16 Jul 2026 14:30:55 -0200 Subject: [PATCH 53/65] Counts documents deduped during representation, exact and semantically similar --- src/crud/__init__.py | 2 + src/crud/document.py | 52 ++++- src/crud/representation.py | 21 +- src/deriver/deriver.py | 31 ++- src/telemetry/events/representation.py | 27 ++- src/utils/agent_tools.py | 18 +- tests/crud/test_document.py | 182 +++++++++++++----- tests/crud/test_representation_manager.py | 17 +- tests/deriver/test_deriver_processing.py | 80 +++++++- .../test_representation_v2_fields.py | 41 +++- tests/utils/test_agent_tools.py | 12 +- 11 files changed, 379 insertions(+), 104 deletions(-) diff --git a/src/crud/__init__.py b/src/crud/__init__.py index 58bcd9f0..b34e4d17 100644 --- a/src/crud/__init__.py +++ b/src/crud/__init__.py @@ -5,6 +5,7 @@ from .collection import ( ) from .deriver import get_deriver_status, get_queue_status from .document import ( + CreateDocumentsResult, create_documents, create_observations, delete_document, @@ -83,6 +84,7 @@ __all__ = [ "get_deriver_status", "get_queue_status", # Document + "CreateDocumentsResult", "create_documents", "create_observations", "fetch_documents_by_ids", diff --git a/src/crud/document.py b/src/crud/document.py index ed381340..e46f311a 100644 --- a/src/crud/document.py +++ b/src/crud/document.py @@ -1,5 +1,7 @@ import datetime from collections.abc import Sequence +from dataclasses import dataclass, field +from enum import Enum from logging import getLogger from typing import Any, cast @@ -441,6 +443,15 @@ def _normalize_content(content: str) -> str: return content.strip().lower() +@dataclass +class CreateDocumentsResult: + created_documents: list[schemas.DocumentCreate] = field(default_factory=list) + exact_dup_in_batch_count: int = 0 + exact_dup_existing_count: int = 0 + semantic_dup_rejected_count: int = 0 + semantic_dup_replaced_count: int = 0 + + async def create_documents( db: AsyncSession, documents: list[schemas.DocumentCreate], @@ -449,7 +460,7 @@ async def create_documents( observer: str, observed: str, deduplicate: bool = False, -) -> list[schemas.DocumentCreate]: +) -> CreateDocumentsResult: """ Create multiple documents with optional duplicate detection. @@ -517,6 +528,10 @@ async def create_documents( # duplicates within a single inference call collapse to one document. seen_in_batch: set[str] = set() + exact_dup_existing_count = 0 + exact_dup_in_batch_count = 0 + semantic_dup_rejected_count = 0 + semantic_dup_replaced_count = 0 for doc in documents: try: normalized_content = _normalize_content(doc.content) @@ -524,6 +539,7 @@ async def create_documents( # Exact-match dedup, always on: # 1) collapse exact duplicates within this batch (drop silently). if normalized_content in seen_in_batch: + exact_dup_in_batch_count += 1 continue seen_in_batch.add(normalized_content) @@ -542,16 +558,22 @@ async def create_documents( doc.times_derived, ) await db.flush() + exact_dup_existing_count += 1 continue # for each document, if deduplicate is True, perform a process # that checks against existing documents and either rejects this document # as a duplicate OR deletes an existing document that is a duplicate. if deduplicate: - is_duplicate = await is_rejected_duplicate( + duplicate_result = await is_rejected_duplicate( db, doc, workspace_name, observer=observer, observed=observed ) - if is_duplicate: + if duplicate_result is SemanticRejectionResult.REPLACED_EXISTING: + # Existing doc was soft-deleted in favor of this one; the + # new doc still gets inserted below. + semantic_dup_replaced_count += 1 + elif duplicate_result is SemanticRejectionResult.REJECTED: + semantic_dup_rejected_count += 1 continue metadata_dict = doc.metadata.model_dump(exclude_none=True) @@ -703,7 +725,13 @@ async def create_documents( "Failed to create documents due to integrity constraint violation" ) from e - return accepted_documents + return CreateDocumentsResult( + created_documents=accepted_documents, + exact_dup_existing_count=exact_dup_existing_count, + exact_dup_in_batch_count=exact_dup_in_batch_count, + semantic_dup_rejected_count=semantic_dup_rejected_count, + semantic_dup_replaced_count=semantic_dup_replaced_count, + ) async def delete_document( @@ -1053,6 +1081,12 @@ async def create_observations( return honcho_documents +class SemanticRejectionResult(Enum): + NOT_DUPLICATE = 0 + REPLACED_EXISTING = 1 + REJECTED = 2 + + async def is_rejected_duplicate( db: AsyncSession, doc: schemas.DocumentCreate, @@ -1060,7 +1094,7 @@ async def is_rejected_duplicate( *, observer: str, observed: str, -) -> bool: +) -> SemanticRejectionResult: """ Check if a document is a duplicate of an existing document. @@ -1094,7 +1128,7 @@ async def is_rejected_duplicate( ) if not similar_docs: - return False + return SemanticRejectionResult.NOT_DUPLICATE existing_doc = similar_docs[0] @@ -1121,7 +1155,9 @@ async def is_rejected_duplicate( # Soft-delete the existing document - reconciliation will clean up vectors and hard-delete existing_doc.deleted_at = datetime.datetime.now(datetime.timezone.utc) await db.flush() - return False # Don't reject the new document + return ( + SemanticRejectionResult.REPLACED_EXISTING + ) # Don't reject the new document # Existing document has more information, reject the new one but record the # reinforcement: a semantic duplicate was derived again. greatest(...) keeps @@ -1138,7 +1174,7 @@ async def is_rejected_duplicate( doc.content, existing_doc.content, ) - return True + return SemanticRejectionResult.REJECTED async def cleanup_soft_deleted_documents( diff --git a/src/crud/representation.py b/src/crud/representation.py index 558a5cce..3061c2d9 100644 --- a/src/crud/representation.py +++ b/src/crud/representation.py @@ -64,7 +64,7 @@ class RepresentationManager: session_name: str, message_created_at: datetime.datetime, message_level_configuration: ResolvedConfiguration, - ) -> int: + ) -> crud.CreateDocumentsResult: """ Save Representation objects to the collection as a set of documents. @@ -75,14 +75,15 @@ class RepresentationManager: message_created_at: Timestamp when the message was created Returns: - The number of *new documents saved* + The result of document creation, including saved documents and + deduplication counts. """ - new_documents = 0 + empty_result = crud.CreateDocumentsResult() if not representation.deductive and not representation.explicit: logger.debug("No observations to save") - return new_documents + return empty_result all_observations = [ _normalized_observation(obs) @@ -91,7 +92,7 @@ class RepresentationManager: ] if not all_observations: logger.debug("No non-empty observations to save") - return new_documents + return empty_result # Batch embed all observations batch_embed_start = time.perf_counter() @@ -123,7 +124,7 @@ class RepresentationManager: # Batch create document objects create_document_start = time.perf_counter() async with tracked_db("representation_manager.save_representation") as db: - new_documents = await self._save_representation_internal( + new_documents_result = await self._save_representation_internal( db, all_observations, embeddings, @@ -141,7 +142,7 @@ class RepresentationManager: "ms", ) - return new_documents + return new_documents_result async def _save_representation_internal( self, @@ -152,7 +153,7 @@ class RepresentationManager: session_name: str, message_created_at: datetime.datetime, message_level_configuration: ResolvedConfiguration, - ) -> int: + ) -> crud.CreateDocumentsResult: # get_or_create_collection already handles IntegrityError with rollback and a retry collection = await crud.get_or_create_collection( db, @@ -191,7 +192,7 @@ class RepresentationManager: ) # Use bulk creation with optional duplicate detection - accepted_documents = await crud.create_documents( + accepted_documents_result = await crud.create_documents( db, documents_to_create, self.workspace_name, @@ -206,7 +207,7 @@ class RepresentationManager: except Exception as e: logger.warning(f"Failed to check dream scheduling: {e}") - return len(accepted_documents) + return accepted_documents_result async def get_working_representation( self, diff --git a/src/deriver/deriver.py b/src/deriver/deriver.py index 3e68d084..2ad7d14f 100644 --- a/src/deriver/deriver.py +++ b/src/deriver/deriver.py @@ -194,6 +194,7 @@ async def process_representation_tasks_batch( latest_message.created_at, ) + agg_representation_result = crud.CreateDocumentsResult() successful_observer_count = 0 if observations.is_empty() or not message_ids: logger.warning( @@ -213,12 +214,26 @@ async def process_representation_tasks_batch( ) try: - await representation_manager.save_representation( - observations, - message_ids, - latest_message.session_name, - latest_message.created_at, - message_level_configuration, + representation_result = ( + await representation_manager.save_representation( + observations, + message_ids, + latest_message.session_name, + latest_message.created_at, + message_level_configuration, + ) + ) + agg_representation_result.exact_dup_existing_count += ( + representation_result.exact_dup_existing_count + ) + agg_representation_result.exact_dup_in_batch_count += ( + representation_result.exact_dup_in_batch_count + ) + agg_representation_result.semantic_dup_rejected_count += ( + representation_result.semantic_dup_rejected_count + ) + agg_representation_result.semantic_dup_replaced_count += ( + representation_result.semantic_dup_replaced_count ) successful_observer_count += 1 except Exception as e: @@ -318,5 +333,9 @@ async def process_representation_tasks_batch( hit_batch_token_cap=hit_batch_token_cap, hit_input_token_cap=response.hit_input_token_cap, observer_count=successful_observer_count, + exact_dup_existing_count=agg_representation_result.exact_dup_existing_count, + exact_dup_in_batch_count=agg_representation_result.exact_dup_in_batch_count, + semantic_dup_rejected_count=agg_representation_result.semantic_dup_rejected_count, + semantic_dup_replaced_count=agg_representation_result.semantic_dup_replaced_count, ) ) diff --git a/src/telemetry/events/representation.py b/src/telemetry/events/representation.py index 92db6806..882bfd50 100644 --- a/src/telemetry/events/representation.py +++ b/src/telemetry/events/representation.py @@ -22,7 +22,7 @@ class RepresentationCompletedEvent(BaseEvent): """ _event_type: ClassVar[str] = "representation.completed" - _schema_version: ClassVar[int] = 2 + _schema_version: ClassVar[int] = 3 _category: ClassVar[str] = "representation" # Workspace context @@ -100,6 +100,31 @@ class RepresentationCompletedEvent(BaseEvent): default=0, description="Estimated tokens for the system/scaffold portion of the prompt", ) + exact_dup_in_batch_count: int = Field( + default=0, + description="Number of documents produced in this representation that had the same normalized content", + ) + exact_dup_existing_count: int = Field( + default=0, + description=( + "Number of documents previously written that had a representation that had the same normalized " + "content as a document in this representation" + ), + ) + semantic_dup_rejected_count: int = Field( + default=0, + description=( + "Number of documents in this representation rejected because their cosine-similarity was high " + "for an existing document but were worse than the corresponding existing document" + ), + ) + semantic_dup_replaced_count: int = Field( + default=0, + description=( + "Number of documents in this representation that replaced existing documents because their " + "cosine-similarity was high and they were better than the corresponding existing document" + ), + ) # Cap configuration + hit flags () batch_max_tokens: int = Field( diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index ca9db2dd..b0cf3051 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -987,14 +987,16 @@ async def create_observations( accepted: list[schemas.DocumentCreate] = [] if documents: async with tracked_db("create_observations.save") as db: - accepted = await crud.create_documents( - db, - documents=documents, - workspace_name=workspace_name, - observer=observer, - observed=observed, - deduplicate=True, - ) + accepted = ( + await crud.create_documents( + db, + documents=documents, + workspace_name=workspace_name, + observer=observer, + observed=observed, + deduplicate=True, + ) + ).created_documents logger.info( "Created %d observations in %s/%s/%s", len(accepted), diff --git a/tests/crud/test_document.py b/tests/crud/test_document.py index 1258b522..65768c83 100644 --- a/tests/crud/test_document.py +++ b/tests/crud/test_document.py @@ -6,7 +6,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src import crud, models, schemas -from src.crud.document import is_rejected_duplicate +from src.crud.document import SemanticRejectionResult, is_rejected_duplicate from src.exceptions import ResourceNotFoundException @@ -381,7 +381,7 @@ class TestDocumentCRUD: observed=test_peer2.name, ) - assert rejected is True + assert rejected is SemanticRejectionResult.REJECTED surviving = ( await db_session.execute( select(models.Document).where( @@ -445,7 +445,7 @@ class TestDocumentCRUD: observed=test_peer2.name, ) - assert rejected is False + assert rejected is SemanticRejectionResult.REPLACED_EXISTING # Count carried forward onto the replacement (3 -> 4), not reset to 1. assert new_doc.times_derived == 4 live = ( @@ -509,7 +509,7 @@ class TestDocumentCRUD: ), ] - accepted = await crud.create_documents( + result = await crud.create_documents( db_session, documents=doc_schemas, workspace_name=test_workspace.name, @@ -517,8 +517,13 @@ class TestDocumentCRUD: observed=test_peer2.name, deduplicate=False, ) + accepted = result.created_documents assert len(accepted) == 1 + assert result.exact_dup_in_batch_count == 2 + assert result.exact_dup_existing_count == 0 + assert result.semantic_dup_rejected_count == 0 + assert result.semantic_dup_replaced_count == 0 live = ( ( await db_session.execute( @@ -571,7 +576,7 @@ class TestDocumentCRUD: ) # Case/whitespace variant of the existing content -> exact match. - accepted = await crud.create_documents( + result = await crud.create_documents( db_session, [ schemas.DocumentCreate( @@ -590,8 +595,13 @@ class TestDocumentCRUD: observed=test_peer2.name, deduplicate=False, ) + accepted = result.created_documents assert len(accepted) == 0 + assert result.exact_dup_existing_count == 1 + assert result.exact_dup_in_batch_count == 0 + assert result.semantic_dup_rejected_count == 0 + assert result.semantic_dup_replaced_count == 0 surviving = ( ( await db_session.execute( @@ -663,25 +673,27 @@ class TestDocumentCRUD: # Incoming exact match claims more accumulated reinforcement (5) than # existing + 1 (3) -> incoming wins. - accepted = await crud.create_documents( - db_session, - [ - schemas.DocumentCreate( - content="user likes coffee ", - embedding=[0.9] * 1536, - session_name=test_session.name, - times_derived=5, - metadata=schemas.DocumentMetadata( - message_ids=[2], - message_created_at="2026-01-02T00:00:00Z", - ), - ) - ], - workspace_name=test_workspace.name, - observer=test_peer.name, - observed=test_peer2.name, - deduplicate=False, - ) + accepted = ( + await crud.create_documents( + db_session, + [ + schemas.DocumentCreate( + content="user likes coffee ", + embedding=[0.9] * 1536, + session_name=test_session.name, + times_derived=5, + metadata=schemas.DocumentMetadata( + message_ids=[2], + message_created_at="2026-01-02T00:00:00Z", + ), + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + deduplicate=False, + ) + ).created_documents assert len(accepted) == 0 live = await _live() assert len(live) == 1 @@ -689,24 +701,26 @@ class TestDocumentCRUD: # A normal re-derivation (times_derived defaults to 1) now bumps by one: # greatest(existing + 1, 1) -> existing + 1. - accepted = await crud.create_documents( - db_session, - [ - schemas.DocumentCreate( - content="USER LIKES COFFEE", - embedding=[0.4] * 1536, - session_name=test_session.name, - metadata=schemas.DocumentMetadata( - message_ids=[3], - message_created_at="2026-01-03T00:00:00Z", - ), - ) - ], - workspace_name=test_workspace.name, - observer=test_peer.name, - observed=test_peer2.name, - deduplicate=False, - ) + accepted = ( + await crud.create_documents( + db_session, + [ + schemas.DocumentCreate( + content="USER LIKES COFFEE", + embedding=[0.4] * 1536, + session_name=test_session.name, + metadata=schemas.DocumentMetadata( + message_ids=[3], + message_created_at="2026-01-03T00:00:00Z", + ), + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + deduplicate=False, + ) + ).created_documents assert len(accepted) == 0 live = await _live() assert len(live) == 1 @@ -746,7 +760,7 @@ class TestDocumentCRUD: ) db_session.autoflush = False - accepted = await crud.create_documents( + result = await crud.create_documents( db_session, [ schemas.DocumentCreate( @@ -775,9 +789,14 @@ class TestDocumentCRUD: observed=test_peer2.name, deduplicate=True, ) + accepted = result.created_documents assert len(accepted) == 1 assert accepted[0].content == "User likes coffee and tea" + assert result.exact_dup_existing_count == 1 + assert result.semantic_dup_replaced_count == 1 + assert result.exact_dup_in_batch_count == 0 + assert result.semantic_dup_rejected_count == 0 surviving = ( ( @@ -797,6 +816,65 @@ class TestDocumentCRUD: assert surviving[0].content == "User likes coffee and tea" assert surviving[0].times_derived == 3 + @pytest.mark.asyncio + async def test_semantic_dedup_rejected_counts( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """A semantically-similar doc with less information than the existing one + is rejected, and the rejection is counted on the result.""" + test_workspace, test_peer = sample_data + test_peer2, test_session, _ = await self._setup_test_data( + db_session, test_workspace, test_peer + ) + + await crud.create_documents( + db_session, + [ + schemas.DocumentCreate( + content="eri loves cats and dogs and birds and snakes", + embedding=[0.5] * 1536, + session_name=test_session.name, + times_derived=1, + metadata=schemas.DocumentMetadata( + message_ids=[1], + message_created_at="2026-01-01T00:00:00Z", + ), + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + + # Fewer unique tokens -> existing wins -> new doc is rejected. + result = await crud.create_documents( + db_session, + [ + schemas.DocumentCreate( + content="eri loves cats", + embedding=[0.5] * 1536, + session_name=test_session.name, + times_derived=1, + metadata=schemas.DocumentMetadata( + message_ids=[2], + message_created_at="2026-01-02T00:00:00Z", + ), + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + deduplicate=True, + ) + + assert len(result.created_documents) == 0 + assert result.semantic_dup_rejected_count == 1 + assert result.exact_dup_in_batch_count == 0 + assert result.exact_dup_existing_count == 0 + assert result.semantic_dup_replaced_count == 0 + @pytest.mark.asyncio async def test_delete_document_success( self, @@ -902,15 +980,17 @@ class TestDocumentCRUD: ] # Create documents - count = await crud.create_documents( - db_session, - documents=doc_schemas, - workspace_name=test_workspace.name, - observer=test_peer.name, - observed=test_peer2.name, - ) + created_documents = ( + await crud.create_documents( + db_session, + documents=doc_schemas, + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + ).created_documents - assert len(count) == 2 + assert len(created_documents) == 2 # Verify documents were created stmt = select(models.Document).where( diff --git a/tests/crud/test_representation_manager.py b/tests/crud/test_representation_manager.py index 7744e763..f4be22dd 100644 --- a/tests/crud/test_representation_manager.py +++ b/tests/crud/test_representation_manager.py @@ -1,6 +1,6 @@ from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from nanoid import generate as generate_nanoid @@ -8,6 +8,7 @@ from sqlalchemy import func, update from sqlalchemy.ext.asyncio import AsyncSession from src import models +from src.crud.document import CreateDocumentsResult from src.crud.representation import RepresentationManager from src.schemas.configuration import ( ResolvedConfiguration, @@ -268,7 +269,9 @@ class TestRepresentationManagerSave: patch.object( manager, "_save_representation_internal", - new=AsyncMock(return_value=1), + new=AsyncMock( + return_value=CreateDocumentsResult(created_documents=[MagicMock()]) + ), ) as mock_save, ): saved = await manager.save_representation( @@ -279,7 +282,7 @@ class TestRepresentationManagerSave: message_level_configuration=_resolved_config(), ) - assert saved == 1 + assert len(saved.created_documents) == 1 mock_embed.assert_awaited_once_with(["useful observation"]) saved_observations = _saved_observations(mock_save) assert len(saved_observations) == 1 @@ -322,7 +325,9 @@ class TestRepresentationManagerSave: patch.object( manager, "_save_representation_internal", - new=AsyncMock(return_value=1), + new=AsyncMock( + return_value=CreateDocumentsResult(created_documents=[MagicMock()]) + ), ) as mock_save, ): saved = await manager.save_representation( @@ -333,7 +338,7 @@ class TestRepresentationManagerSave: message_level_configuration=_resolved_config(), ) - assert saved == 1 + assert len(saved.created_documents) == 1 mock_embed.assert_awaited_once_with(["inferred conclusion"]) saved_observations = _saved_observations(mock_save) assert len(saved_observations) == 1 @@ -384,6 +389,6 @@ class TestRepresentationManagerSave: message_level_configuration=_resolved_config(), ) - assert saved == 0 + assert len(saved.created_documents) == 0 mock_embed.assert_not_awaited() mock_save.assert_not_awaited() diff --git a/tests/deriver/test_deriver_processing.py b/tests/deriver/test_deriver_processing.py index a2058bf9..6785d159 100644 --- a/tests/deriver/test_deriver_processing.py +++ b/tests/deriver/test_deriver_processing.py @@ -5,11 +5,15 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from src import models +from src import crud, models from src.config import settings from src.deriver.deriver import process_representation_tasks_batch from src.llm import HonchoLLMCallResponse -from src.utils.representation import PromptRepresentation, Representation +from src.utils.representation import ( + ExplicitObservationBase, + PromptRepresentation, + Representation, +) from src.utils.work_unit import construct_work_unit_key, parse_work_unit_key @@ -316,6 +320,78 @@ class TestDeriverProcessing: for record in caplog.records ) + async def test_emits_dedup_counts_summed_across_observers(self) -> None: + """RepresentationCompletedEvent dedup counts must be the sum across all + observer collections, not the last observer's result.""" + message = Mock( + id=1, + public_id="msg_dedup", + session_name="session-1", + workspace_name="workspace-1", + peer_name="alice", + content="hello", + token_count=5, + created_at=datetime.now(timezone.utc), + ) + configuration = Mock() + configuration.reasoning.enabled = True + + mock_response = HonchoLLMCallResponse( + content=PromptRepresentation( + explicit=[ExplicitObservationBase(content="alice says hello")] + ), + input_tokens=10, + output_tokens=5, + finish_reasons=["STOP"], + ) + + manager = Mock() + manager.save_representation = AsyncMock( + side_effect=[ + crud.CreateDocumentsResult( + exact_dup_in_batch_count=1, + exact_dup_existing_count=2, + semantic_dup_rejected_count=3, + semantic_dup_replaced_count=4, + ), + crud.CreateDocumentsResult( + exact_dup_in_batch_count=10, + exact_dup_existing_count=20, + semantic_dup_rejected_count=30, + semantic_dup_replaced_count=40, + ), + ] + ) + emitted: list[Any] = [] + + with ( + patch( + "src.deriver.deriver.honcho_llm_call", + new_callable=AsyncMock, + return_value=mock_response, + ), + patch( + "src.deriver.deriver.RepresentationManager", + return_value=manager, + ), + patch("src.deriver.deriver.emit", side_effect=emitted.append), + ): + await process_representation_tasks_batch( + messages=[message], + message_level_configuration=configuration, + observers=["bob", "carol"], + observed="alice", + queue_item_message_ids=[1], + ) + + assert len(emitted) == 1 + event = emitted[0] + assert event.observer_count == 2 + assert event.exact_dup_in_batch_count == 11 + assert event.exact_dup_existing_count == 22 + assert event.semantic_dup_rejected_count == 33 + assert event.semantic_dup_replaced_count == 44 + class TestBackwardsCompatibility: """Test backwards compatibility for queue items created before the deduplication change.""" diff --git a/tests/telemetry/test_representation_v2_fields.py b/tests/telemetry/test_representation_v2_fields.py index 669da96b..a41bed41 100644 --- a/tests/telemetry/test_representation_v2_fields.py +++ b/tests/telemetry/test_representation_v2_fields.py @@ -2,8 +2,6 @@ """tests for RepresentationCompletedEvent additive fields + truncation. Targets: -- Schema stays at v2 (additive, no bump). Existing `input_tokens` semantics - unchanged. - fields are defaultable (no breakage for callers that ignore them) and round-trip through Pydantic serialization. - `HonchoLLMCallResponse.hit_input_token_cap` defaults to False but can be @@ -17,10 +15,6 @@ from src.telemetry.events.representation import RepresentationCompletedEvent class TestRepresentationV2AdditiveFields: - def test_schema_stays_at_v2(self): - """is additive — schema_version must NOT bump to 3.""" - assert RepresentationCompletedEvent.schema_version() == 2 - def test_new_fields_are_optional(self): """Existing callers must keep working without supplying any new fields. All new fields default.""" @@ -53,6 +47,10 @@ class TestRepresentationV2AdditiveFields: assert event.hit_batch_token_cap is False assert event.hit_input_token_cap is False assert event.observer_count == 0 + assert event.exact_dup_in_batch_count == 0 + assert event.exact_dup_existing_count == 0 + assert event.semantic_dup_rejected_count == 0 + assert event.semantic_dup_replaced_count == 0 def test_input_tokens_semantics_preserved(self): """The downstream metering key must remain 'queued-message tokens'. @@ -159,10 +157,41 @@ class TestRepresentationV2AdditiveFields: "hit_batch_token_cap", "hit_input_token_cap", "observer_count", + "exact_dup_in_batch_count", + "exact_dup_existing_count", + "semantic_dup_rejected_count", + "semantic_dup_replaced_count", ): assert field in data, f"missing field: {field}" assert data["hit_batch_token_cap"] is True + def test_dedup_count_fields_round_trip(self): + event = RepresentationCompletedEvent( + workspace_name="ws", + session_name="s", + observed="user", + queue_items_processed=1, + earliest_message_id="m1", + latest_message_id="m1", + message_count=1, + explicit_conclusion_count=0, + context_preparation_ms=10.0, + llm_call_ms=100.0, + total_duration_ms=110.0, + input_tokens=100, + total_input_tokens=200, + output_tokens=50, + exact_dup_in_batch_count=2, + exact_dup_existing_count=3, + semantic_dup_rejected_count=4, + semantic_dup_replaced_count=5, + ) + data = event.model_dump(mode="json") + assert data["exact_dup_in_batch_count"] == 2 + assert data["exact_dup_existing_count"] == 3 + assert data["semantic_dup_rejected_count"] == 4 + assert data["semantic_dup_replaced_count"] == 5 + class TestHitInputTokenCapFlag: """`HonchoLLMCallResponse.hit_input_token_cap` is the bridge between the diff --git a/tests/utils/test_agent_tools.py b/tests/utils/test_agent_tools.py index 12037bf7..3f2dad6b 100644 --- a/tests/utils/test_agent_tools.py +++ b/tests/utils/test_agent_tools.py @@ -318,10 +318,10 @@ class TestCreateObservations: observer: str, observed: str, deduplicate: bool = False, - ) -> list[Any]: + ) -> crud.CreateDocumentsResult: _ = (workspace_name, observer, observed, deduplicate) created_documents.extend(documents) - return documents + return crud.CreateDocumentsResult(created_documents=documents) monkeypatch.setattr( "src.utils.agent_tools.embedding_client.simple_batch_embed", @@ -379,10 +379,10 @@ class TestCreateObservations: observer: str, observed: str, deduplicate: bool = False, - ) -> list[Any]: + ) -> crud.CreateDocumentsResult: _ = (workspace_name, observer, observed, deduplicate) created_documents.extend(documents) - return documents + return crud.CreateDocumentsResult(created_documents=documents) monkeypatch.setattr( "src.utils.agent_tools.embedding_client.simple_batch_embed", @@ -438,10 +438,10 @@ class TestCreateObservations: observer: str, observed: str, deduplicate: bool = False, - ) -> list[Any]: + ) -> crud.CreateDocumentsResult: _ = (workspace_name, observer, observed, deduplicate) created_documents.extend(documents) - return documents + return crud.CreateDocumentsResult(created_documents=documents) monkeypatch.setattr( "src.utils.agent_tools.embedding_client.simple_batch_embed", From 055d73b580fa0437e1a17cecf3a5c88247ce9209 Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Fri, 17 Jul 2026 13:16:55 -0700 Subject: [PATCH 54/65] feat(cli): add device code oauth login for honcho servers (#891) * feat(cli): add device code oauth login for honcho servers * fix(cli): harden device-auth input validation and transport errors Reject zero/negative auth-method choices instead of letting Python negative indexing wrap to the tail of the options list, and wrap httpx transport failures in the OAuth POST helpers as OAuthFlowError so connection errors surface through existing caller handling rather than escaping as an uncaught traceback. Co-Authored-By: Claude Opus 4.8 * feat: add HONCHO_CONFIG_DIR env var for cli * refactor(cli): address review nits on device-auth PR - split `init` into manual-key and interactive helpers - document that access_valid checks persisted expiry, not the token - rename single-letter local in redacted() - cover 500 alongside 404 in supports_device_login metadata probe - add config edge-case tests: stale apiKey drop, garbage/string accessExpiresAt, empty-env-var popping, refresh-rotation fallback, missing-token access_valid Co-Authored-By: Claude Opus 4.8 * fix(cli): stop deleting shared apiKey on device login; bind oauth grant to its host apiKey is shared with sibling tools that read it from the same config file, so device login must not remove it. Precedence flips to a live OAuth token over apiKey; a dead grant now degrades to the saved key with a warning instead of aborting. The oauth block records the host it was minted against and is ignored (no use, no refresh) when base_url points elsewhere, so a staging grant is never sent to prod. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- honcho-cli/src/honcho_cli/commands/setup.py | 174 +++++++++++-- honcho-cli/src/honcho_cli/common.py | 49 +++- honcho-cli/src/honcho_cli/config.py | 166 ++++++++++++- honcho-cli/src/honcho_cli/oauth.py | 260 ++++++++++++++++++++ honcho-cli/tests/conftest.py | 21 ++ honcho-cli/tests/test_common.py | 126 ++++++++++ honcho-cli/tests/test_config.py | 155 +++++++++++- honcho-cli/tests/test_oauth.py | 232 +++++++++++++++++ 8 files changed, 1155 insertions(+), 28 deletions(-) create mode 100644 honcho-cli/src/honcho_cli/oauth.py create mode 100644 honcho-cli/tests/conftest.py create mode 100644 honcho-cli/tests/test_common.py create mode 100644 honcho-cli/tests/test_oauth.py diff --git a/honcho-cli/src/honcho_cli/commands/setup.py b/honcho-cli/src/honcho_cli/commands/setup.py index 6073b3e4..85dc4511 100644 --- a/honcho-cli/src/honcho_cli/commands/setup.py +++ b/honcho-cli/src/honcho_cli/commands/setup.py @@ -7,6 +7,8 @@ from __future__ import annotations import json +import time +import webbrowser import typer from honcho import ( @@ -19,13 +21,14 @@ from honcho import ( from rich.console import Console from rich.panel import Panel -from honcho_cli import __version__ +from honcho_cli import __version__, oauth from honcho_cli.branding import BANNER, BRAND, ICON_FAIL, ICON_OK, ICON_RUN -from honcho_cli.common import get_resolved_config +from honcho_cli.common import get_resolved_config, maybe_refresh_token from honcho_cli.config import ( CONFIG_FILE, DEFAULT_BASE_URL, CLIConfig, + OAuthTokens, ) from honcho_cli.output import print_error, print_result, set_json_mode, use_json @@ -117,21 +120,142 @@ def init( _console.print() _console.print() + # Non-interactive (JSON/piped) or an explicit --api-key: manual-key path. + # Device login needs a human at a browser, so it's TTY-only. + if use_json() or api_key: + _init_manual_key(key_val, url_val, file_key, file_url) + else: + _init_interactive(key_val, url_val, file_url) + + +def _init_manual_key(key_val: str, url_val: str, file_key: str, file_url: str) -> None: + """Non-interactive path: confirm/save apiKey + URL, no device login.""" final_key = _prompt_api_key(key_val) final_url = _prompt_url(url_val) - - # Persist if anything changed or if the value came from env/flag. if final_key != file_key or final_url != file_url: CLIConfig(base_url=final_url, api_key=final_key).save() if not use_json(): _console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]") - _check_connection(final_url, final_key) - if use_json(): print_result({"apiKey": _redact(final_key), "baseUrl": final_url}) +def _init_interactive(key_val: str, url_val: str, file_url: str) -> None: + """Interactive path: URL first (device flow needs the host), then auth method.""" + final_url = _prompt_url(url_val) + existing = CLIConfig.load() + has_creds = bool(key_val) or bool(existing.oauth and existing.oauth.access_token) + # only offer browser login if the host advertises the device grant (managed) + device_available = oauth.supports_device_login(final_url) + method = _prompt_auth_method(has_creds, device_available) + + if method == "keep": + if final_url != file_url: + existing.base_url = final_url + existing.save() + _console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]") + # refresh an expired token so "keep" behaves like every live command; + # a failed refresh surfaces as the connectivity check below, not an abort + try: + maybe_refresh_token(existing) + except typer.Exit: + pass + _check_connection(final_url, existing.resolved_api_key()) + return + + if method == "device": + tokens = _device_login(final_url) + CLIConfig(base_url=final_url, oauth=tokens).save() + _console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]") + _check_connection(final_url, tokens.access_token) + return + + # paste a key + final_key = _prompt_api_key("") + CLIConfig(base_url=final_url, api_key=final_key).save() + _console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]") + _check_connection(final_url, final_key) + + +def _prompt_auth_method(has_creds: bool, device_available: bool) -> str: + """Ask how to authenticate. Returns ``device`` / ``key`` / ``keep``. + + ``device`` is only offered when the host advertises the device grant; when + it doesn't, pasting a key is the only login path. + """ + _console.print(" [dim]How do you want to authenticate?[/dim]") + options: list[str] = [] + if device_available: + options.append("device") + _console.print(f" [dim]({len(options)})[/dim] Log in with your browser (device code)") + options.append("key") + _console.print(f" [dim]({len(options)})[/dim] Paste an API key") + if has_creds: + options.append("keep") + _console.print(f" [dim]({len(options)})[/dim] Keep current credentials") + # default to keeping existing creds so a returning user pressing Enter doesn't + # get dropped into an unwanted browser login that overwrites them + default = str(options.index("keep") + 1) if "keep" in options else "1" + choice = typer.prompt(" Choice", default=default, show_default=True, prompt_suffix=": ").strip() + try: + idx = int(choice) + except ValueError: + return options[0] + # explicit 1..len bounds — bare `options[idx - 1]` would let "0"/negatives + # wrap to the tail of the list via Python's negative indexing + if 1 <= idx <= len(options): + return options[idx - 1] + return options[0] + + +def _device_login(base_url: str) -> OAuthTokens: + """Run the device-authorization flow and return the minted tokens. + + Prints the user code + verification URL, opens the browser best-effort, and + blocks on the poll loop until the user approves. Exits non-zero on denial, + expiry, or interrupt. + """ + endpoints = oauth.resolve_endpoints(base_url) + try: + device = oauth.request_device_code(endpoints) + except oauth.OAuthFlowError as e: + _console.print(f" {ICON_FAIL} [red]Could not start device login[/red]: {e}") + raise typer.Exit(1) + + _console.print() + _console.print(f" Enter this code to authorize: [bold {BRAND}]{device.user_code}[/bold {BRAND}]") + _console.print(f" [dim]at[/dim] {device.verification_uri}") + _console.print() + try: + webbrowser.open(device.verification_uri_complete) + except Exception: + pass # headless is expected — the URL is printed above + + try: + with _console.status("Waiting for approval…", spinner="dots"): + tokens = oauth.poll_for_token(endpoints, device) + except oauth.AccessDenied: + _console.print(f" {ICON_FAIL} [red]Authorization denied[/red]") + raise typer.Exit(1) + except (oauth.DeviceCodeExpired, oauth.AuthorizationTimeout): + _console.print(f" {ICON_FAIL} [red]Code expired[/red] — run `honcho init` to try again") + raise typer.Exit(1) + except oauth.OAuthFlowError as e: + _console.print(f" {ICON_FAIL} [red]Login failed[/red]: {e}") + raise typer.Exit(1) + except KeyboardInterrupt: + _console.print(f" {ICON_FAIL} [red]Cancelled[/red]") + raise typer.Exit(1) + + return OAuthTokens.from_response( + tokens, + client_id=endpoints.client_id, + scope_fallback=endpoints.scope, + host=base_url, + ) + + def _prompt_api_key(value: str) -> str: """Prompt for API key. @@ -206,6 +330,21 @@ def _check_connection(base_url: str, api_key: str) -> None: # --------------------------------------------------------------------------- # # honcho doctor +def _auth_mode_detail(config: CLIConfig) -> str: + """Human summary of which credential the CLI will use.""" + tokens = config.usable_oauth() + if tokens is not None: + if tokens.access_valid(): + secs = max(int(tokens.access_expires_at - time.time()), 0) + return f"OAuth device token (expires in {secs // 60}m)" + if config.api_key: + return "API key (OAuth token expired)" + return "OAuth device token (expired — will refresh)" + if config.api_key: + return "API key" + return "missing — run `honcho init`" + + def doctor( json_output: bool = typer.Option(False, "--json", help="Force JSON output"), ) -> None: @@ -230,23 +369,30 @@ def doctor( _console.print(f"\n[bold {BRAND}]Honcho Doctor[/bold {BRAND}]\n") config = get_resolved_config() + # Refresh an expired OAuth token if we can; a failure surfaces as a failed + # connectivity check below rather than aborting the diagnostic. + try: + maybe_refresh_token(config) + except typer.Exit: + pass + key = config.resolved_api_key() + _add("Config file", CONFIG_FILE.exists(), str(CONFIG_FILE) if CONFIG_FILE.exists() else f"{CONFIG_FILE} not found") - _add("API key configured", bool(config.api_key), - "set" if config.api_key else "missing — run `honcho init`") + _add("Credentials configured", bool(key), _auth_mode_detail(config)) - if config.base_url and config.api_key: - _add("API connectivity", *_test_connection(config.base_url, config.api_key)) + if config.base_url and key: + _add("API connectivity", *_test_connection(config.base_url, key)) else: - _add("API connectivity", False, "skipped — no base_url or api_key") + _add("API connectivity", False, "skipped — no base_url or credentials") # Workspace / peer / queue run only when scoped via -w / -p. ws_ok, client = False, None - if config.workspace_id and config.api_key: + if config.workspace_id and key: try: - client = Honcho(base_url=config.base_url, api_key=config.api_key, workspace_id=config.workspace_id) + client = Honcho(base_url=config.base_url, api_key=key, workspace_id=config.workspace_id) client.get_configuration() ws_ok = True _add("Workspace reachable", True, config.workspace_id) @@ -280,7 +426,7 @@ def doctor( _console.print(f"\n [{color}]{passed}/{total}[/{color}] checks passed{hint}\n") # Config file + API connectivity are hard requirements. - critical = {"Config file", "API key configured", "API connectivity"} + critical = {"Config file", "Credentials configured", "API connectivity"} if config.workspace_id: critical.add("Workspace reachable") if any(not c["ok"] for c in checks if c["check"] in critical): diff --git a/honcho-cli/src/honcho_cli/common.py b/honcho-cli/src/honcho_cli/common.py index 2680a869..d87a4be7 100644 --- a/honcho-cli/src/honcho_cli/common.py +++ b/honcho-cli/src/honcho_cli/common.py @@ -12,13 +12,15 @@ no-op if the same flag was already set at an outer level. from __future__ import annotations +from dataclasses import replace from typing import Optional import typer from honcho import Honcho -from honcho_cli.config import CLIConfig, get_client_kwargs +from honcho_cli import oauth +from honcho_cli.config import CLIConfig, OAuthTokens, get_client_kwargs from honcho_cli.output import print_error, set_json_mode from honcho_cli.validation import validate_resource_id @@ -50,6 +52,50 @@ def get_resolved_config(): return config +def maybe_refresh_token(config: CLIConfig) -> None: + """Refresh an expired OAuth access token in place and persist it. + + No-op when there is no grant for the current host or the token is still + valid. A dead grant degrades to the saved apiKey with a warning; exits + only when nothing is left to authenticate with. + """ + tokens = config.usable_oauth() + if tokens is None or tokens.access_valid(): + return + + if tokens.refresh_token: + endpoints = oauth.resolve_endpoints(config.base_url) + if tokens.client_id: + endpoints = replace(endpoints, client_id=tokens.client_id) + try: + refreshed = oauth.refresh_access_token(endpoints, tokens.refresh_token) + except oauth.OAuthFlowError: + refreshed = None + if refreshed is not None: + # rotation-safe: persist the (possibly new) refresh token before + # it's reused; keep the old one if the server didn't rotate + # (refresh_token is optional) + config.oauth = OAuthTokens.from_response( + refreshed, + client_id=tokens.client_id, + scope_fallback=tokens.scope, + refresh_fallback=tokens.refresh_token, + host=tokens.host, + ) + config.save() + return + + if config.api_key: + typer.echo( + "OAuth session expired; using the saved API key. " + "Run `honcho init` to log in again.", + err=True, + ) + return + print_error("SESSION_EXPIRED", "OAuth session expired. Run `honcho init` to log in again.") + raise typer.Exit(1) + + def get_client(*, require_workspace: bool = True): """Create a Honcho client from resolved config. @@ -65,6 +111,7 @@ def get_client(*, require_workspace: bool = True): "No workspace scoped. Pass --workspace/-w or set HONCHO_WORKSPACE_ID.", ) raise typer.Exit(1) + maybe_refresh_token(config) return Honcho(**get_client_kwargs(config)), config diff --git a/honcho-cli/src/honcho_cli/config.py b/honcho-cli/src/honcho_cli/config.py index a0c64864..7cd103bd 100644 --- a/honcho-cli/src/honcho_cli/config.py +++ b/honcho-cli/src/honcho_cli/config.py @@ -1,11 +1,18 @@ """Configuration management for Honcho CLI. -Config stored at ``~/.honcho/config.json`` with env var overrides. +Config stored at ``~/.honcho/config.json`` with env var overrides. The config +directory defaults to ``~/.honcho`` and can be relocated with `HONCHO_CONFIG_DIR` -The CLI owns exactly two top-level keys in that file: +The CLI owns these top-level keys in that file: - apiKey -- Honcho admin JWT environmentUrl -- Honcho API URL (full URL, e.g. https://api.honcho.dev) + oauth -- OAuth device-grant tokens (accessToken, refreshToken, + accessExpiresAt, clientId, scope, host), written by + device login + +``apiKey`` (manual admin JWT) is shared with sibling tools: the CLI writes it +on paste-key login and reads it as a fallback, but never deletes it. A live +OAuth token takes precedence over ``apiKey`` for the CLI's own calls. All other top-level keys (``hosts``, ``sessions``, ``saveMessages``, ``sessionStrategy``, …) are written by sibling Honcho tools and are @@ -20,14 +27,44 @@ from __future__ import annotations import json import os +import time from dataclasses import dataclass, fields from pathlib import Path +from typing import TYPE_CHECKING -CONFIG_DIR = Path.home() / ".honcho" +if TYPE_CHECKING: + from honcho_cli.oauth import TokenResponse + +def _config_dir() -> Path: + """Config directory: ``$HONCHO_CONFIG_DIR`` if set, else ``~/.honcho``.""" + override = os.environ.get("HONCHO_CONFIG_DIR") + return Path(override).expanduser() if override else Path.home() / ".honcho" + + +CONFIG_DIR = _config_dir() CONFIG_FILE = CONFIG_DIR / "config.json" DEFAULT_BASE_URL = "https://api.honcho.dev" + +def _redact_token(token: str) -> str: + """Show ``***`` — enough to compare tokens without leaking the body.""" + if not token: + return "" + return "***" + token[-4:] if len(token) > 4 else "***" + + +def _coerce_epoch(value: object) -> float: + """Parse a persisted epoch-seconds value, treating garbage as expired (0).""" + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + return 0.0 + return 0.0 + # Env var mapping for runtime overrides. # # Resolution order: flag > env var > config file > default. @@ -40,6 +77,59 @@ ENV_MAP: dict[str, str] = { } +@dataclass +class OAuthTokens: + """Device-grant tokens persisted under the config ``oauth`` key.""" + + access_token: str = "" + refresh_token: str = "" + access_expires_at: float = 0.0 # epoch seconds + client_id: str = "" + scope: str = "" + host: str = "" # base_url the grant was minted against + + def matches_host(self, base_url: str) -> bool: + """True when the grant belongs to ``base_url``. + + Tokens are host-scoped — a staging grant must not be sent to prod. + Legacy blocks with no recorded host are trusted. + """ + return not self.host or self.host.rstrip("/") == base_url.rstrip("/") + + def access_valid(self, skew: int = 60) -> bool: + """True while the access token is present and not within ``skew`` of expiry. + + Checks the expiry timestamp recorded at mint time, not the token + itself — the server is the real authority, so a wrong answer here + costs at most an extra refresh or a 401. + """ + return bool(self.access_token) and time.time() < self.access_expires_at - skew + + @classmethod + def from_response( + cls, + resp: TokenResponse, + *, + client_id: str, + scope_fallback: str = "", + refresh_fallback: str = "", + host: str = "", + ) -> OAuthTokens: + """Build persisted tokens from a token response. + + ``refresh_fallback`` keeps the prior refresh token when the server + doesn't rotate one (optional on the refresh grant, RFC 6749 §5.1). + """ + return cls( + access_token=resp.access_token, + refresh_token=resp.refresh_token or refresh_fallback, + access_expires_at=time.time() + resp.expires_in, + client_id=client_id, + scope=resp.scope or scope_fallback, + host=host, + ) + + @dataclass class CLIConfig: """CLI configuration with layered resolution: flag > env > file > default. @@ -54,6 +144,33 @@ class CLIConfig: workspace_id: str = "" peer_id: str = "" session_id: str = "" + oauth: OAuthTokens | None = None + + def usable_oauth(self) -> OAuthTokens | None: + """The OAuth grant, if present and bound to the current host.""" + if ( + self.oauth + and self.oauth.access_token + and self.oauth.matches_host(self.base_url) + ): + return self.oauth + return None + + def resolved_api_key(self) -> str: + """The key handed to the SDK: a live OAuth token wins, else apiKey. + + An expired grant loses to a saved apiKey (a dead grant degrades to the + shared key) but still wins over nothing, since the server is the final + judge. + """ + tokens = self.usable_oauth() + if tokens and tokens.access_valid(): + return tokens.access_token + if self.api_key: + return self.api_key + if tokens: + return tokens.access_token + return "" @classmethod def load(cls) -> CLIConfig: @@ -74,6 +191,16 @@ class CLIConfig: key = data.get("apiKey") if isinstance(key, str): config.api_key = key + oauth = data.get("oauth") + if isinstance(oauth, dict) and oauth.get("accessToken"): + config.oauth = OAuthTokens( + access_token=str(oauth.get("accessToken", "")), + refresh_token=str(oauth.get("refreshToken", "")), + access_expires_at=_coerce_epoch(oauth.get("accessExpiresAt")), + client_id=str(oauth.get("clientId", "")), + scope=str(oauth.get("scope", "")), + host=str(oauth.get("host", "")), + ) for fld_name, env_var in ENV_MAP.items(): val = os.environ.get(env_var) @@ -88,10 +215,12 @@ class CLIConfig: return config def save(self) -> None: - """Write ``apiKey`` + ``environmentUrl`` to config.json. + """Write ``environmentUrl`` + credentials to config.json. Preserves unrelated top-level keys (``hosts``, ``sessions``, ``saveMessages``, ``sessionStrategy``, …) that other tools write. + ``apiKey`` is written when set but never removed — sibling tools read + it. The ``oauth`` block is CLI-owned and dropped when empty. """ CONFIG_DIR.mkdir(parents=True, exist_ok=True) @@ -108,8 +237,18 @@ class CLIConfig: data["environmentUrl"] = self.base_url if self.api_key: data["apiKey"] = self.api_key + + if self.oauth and self.oauth.access_token: + data["oauth"] = { + "accessToken": self.oauth.access_token, + "refreshToken": self.oauth.refresh_token, + "accessExpiresAt": self.oauth.access_expires_at, + "clientId": self.oauth.client_id, + "scope": self.oauth.scope, + "host": self.oauth.host, + } else: - data.pop("apiKey", None) + data.pop("oauth", None) CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n") # API key in plaintext — restrict to the owner on multi-user hosts. @@ -124,7 +263,7 @@ class CLIConfig: Only includes fields that have a value set — per-command fields (workspace_id, peer_id, session_id) are omitted when empty. """ - d: dict[str, str] = {} + result: dict[str, str] = {} for fld in fields(self): val = getattr(self, fld.name) if not val: @@ -132,10 +271,12 @@ class CLIConfig: if fld.name == "api_key": # Show ``***`` only — enough to compare keys without # leaking the header or body of the JWT. - d[fld.name] = "***" + val[-4:] if len(val) > 4 else "***" + result[fld.name] = _redact_token(val) + elif fld.name == "oauth": + result[fld.name] = _redact_token(val.access_token) else: - d[fld.name] = val - return d + result[fld.name] = val + return result def get_client_kwargs(config: CLIConfig) -> dict: @@ -143,8 +284,9 @@ def get_client_kwargs(config: CLIConfig) -> dict: kwargs: dict = {} if config.base_url: kwargs["base_url"] = config.base_url - if config.api_key: - kwargs["api_key"] = config.api_key + api_key = config.resolved_api_key() + if api_key: + kwargs["api_key"] = api_key if config.workspace_id: kwargs["workspace_id"] = config.workspace_id return kwargs diff --git a/honcho-cli/src/honcho_cli/oauth.py b/honcho-cli/src/honcho_cli/oauth.py new file mode 100644 index 00000000..37e50d36 --- /dev/null +++ b/honcho-cli/src/honcho_cli/oauth.py @@ -0,0 +1,260 @@ +"""OAuth 2.0 Device Authorization Grant (RFC 8628) client for the CLI. + +Transport-only: HTTP calls plus the poll loop, no Typer or config writes, so it +can be unit-tested by mocking httpx. +""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +import httpx + +DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" + +DEFAULT_CLIENT_ID = "honcho-cli" +DEFAULT_SCOPE = "write" + +# self-declared requesting surface; tells the consent screen not to offer config +# delivery (a CLI has nowhere to write it) +DEVICE_SOURCE = "honcho-cli" + +# extra seconds added to the poll interval on a slow_down response (RFC 8628 §3.5) +SLOW_DOWN_STEP = 5 + + +class OAuthFlowError(Exception): + """A device-flow request failed. ``error`` is the RFC error code when known.""" + + def __init__(self, error: str, description: str | None = None): + self.error: str = error + self.description: str | None = description + super().__init__(description or error) + + +class AccessDenied(OAuthFlowError): + """The user denied the authorization request.""" + + +class DeviceCodeExpired(OAuthFlowError): + """The device code expired before the user approved it.""" + + +class AuthorizationTimeout(OAuthFlowError): + """Polling ran past the device code's lifetime with no decision.""" + + +@dataclass(frozen=True) +class Endpoints: + """Resolved authorization-server URLs and client identity.""" + + device_auth_url: str + token_url: str + client_id: str + scope: str + + +@dataclass(frozen=True) +class DeviceCode: + """RFC 8628 §3.2 device authorization response.""" + + device_code: str + user_code: str + verification_uri: str + verification_uri_complete: str + expires_in: int + interval: int + + +@dataclass(frozen=True) +class TokenResponse: + """An access/refresh token pair minted for a grant.""" + + access_token: str + refresh_token: str + expires_in: int + scope: str + config: dict[str, Any] = field(default_factory=dict) + + +def resolve_endpoints(base_url: str) -> Endpoints: + """Derive OAuth endpoints and client identity from the API ``base_url``.""" + host = base_url.rstrip("/") + return Endpoints( + device_auth_url=f"{host}/oauth/device_authorization", + token_url=f"{host}/oauth/token", + client_id=DEFAULT_CLIENT_ID, + scope=DEFAULT_SCOPE, + ) + + +# RFC 8414 authorization-server metadata; presence of the device grant tells us +# whether this host can do browser login at all (managed only, not core) +AUTH_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server" + + +def supports_device_login(base_url: str, *, timeout: float = 5.0) -> bool: + """Whether the host advertises the device grant in its RFC 8414 metadata. + + Fails closed: any connection error, non-200, unparseable body, or missing + capability returns False, so self-hosted / non-managed instances simply + don't offer device login. + """ + host = base_url.rstrip("/") + try: + resp = httpx.get(f"{host}{AUTH_SERVER_METADATA_PATH}", timeout=timeout) + except httpx.HTTPError: + return False + if resp.status_code != 200: + return False + try: + body = resp.json() + except ValueError: + return False + grants = body.get("grant_types_supported") if isinstance(body, dict) else None + return isinstance(grants, list) and DEVICE_GRANT_TYPE in grants + + +def _post(url: str, data: dict[str, str]) -> httpx.Response: + """POST form data, surfacing transport failures as ``OAuthFlowError``. + + Connection refusals, DNS failures, and timeouts would otherwise escape as + raw ``httpx.HTTPError`` past callers that only catch ``OAuthFlowError``. + """ + try: + return httpx.post(url, data=data) + except httpx.HTTPError as e: + raise OAuthFlowError("connection_error", f"could not reach {url}: {e}") from e + + +def _error_from_response(resp: httpx.Response) -> tuple[str, str | None]: + """Pull ``(error, error_description)`` out of an OAuth error body.""" + try: + body = resp.json() + except ValueError: + return "invalid_response", resp.text[:200] or None + if isinstance(body, dict) and body.get("error"): + return str(body["error"]), body.get("error_description") + return "invalid_response", None + + +def request_device_code(endpoints: Endpoints) -> DeviceCode: + """Request a device + user code pair (RFC 8628 §3.1).""" + resp = _post( + endpoints.device_auth_url, + { + "client_id": endpoints.client_id, + "scope": endpoints.scope, + "source": DEVICE_SOURCE, + }, + ) + if resp.status_code != 200: + error, desc = _error_from_response(resp) + raise OAuthFlowError(error, desc) + try: + body = resp.json() + return DeviceCode( + device_code=body["device_code"], + user_code=body["user_code"], + verification_uri=body["verification_uri"], + verification_uri_complete=body.get( + "verification_uri_complete", body["verification_uri"] + ), + expires_in=int(body["expires_in"]), + interval=int(body["interval"]), + ) + except (KeyError, TypeError, ValueError) as e: + raise OAuthFlowError( + "invalid_response", f"malformed device authorization response: {e}" + ) from e + + +def _token_from_body(body: dict[str, Any]) -> TokenResponse: + # refresh_token is optional on the refresh grant (RFC 6749 §5.1); a + # malformed/missing field is a server fault, surfaced as OAuthFlowError so + # callers' existing handling catches it instead of a raw KeyError/ValueError + try: + return TokenResponse( + access_token=body["access_token"], + refresh_token=body.get("refresh_token", ""), + expires_in=int(body["expires_in"]), + scope=body.get("scope", ""), + config=body.get("config") or {}, + ) + except (KeyError, TypeError, ValueError) as e: + raise OAuthFlowError("invalid_response", f"malformed token response: {e}") from e + + +def poll_for_token( + endpoints: Endpoints, + device: DeviceCode, + *, + sleep: Callable[[float], None] = time.sleep, + monotonic: Callable[[], float] = time.monotonic, +) -> TokenResponse: + """Poll the token endpoint until the grant is approved (RFC 8628 §3.4/§3.5). + + Sleeps ``interval`` between polls, bumping it on ``slow_down``. Raises + ``AccessDenied`` / ``DeviceCodeExpired`` / ``AuthorizationTimeout`` on the + terminal outcomes. ``sleep`` / ``monotonic`` are injectable for tests. + """ + interval = device.interval + deadline = monotonic() + device.expires_in + while True: + if monotonic() >= deadline: + raise AuthorizationTimeout("expired_token", "Timed out waiting for approval") + sleep(interval) + resp = _post( + endpoints.token_url, + { + "grant_type": DEVICE_GRANT_TYPE, + "device_code": device.device_code, + "client_id": endpoints.client_id, + }, + ) + if resp.status_code == 200: + try: + body = resp.json() + except ValueError as e: + raise OAuthFlowError("invalid_response", "non-JSON token response") from e + return _token_from_body(body) + + error, desc = _error_from_response(resp) + if error == "authorization_pending": + continue + if error == "slow_down": + interval += SLOW_DOWN_STEP + continue + if error == "access_denied": + raise AccessDenied(error, desc) + if error == "expired_token": + raise DeviceCodeExpired(error, desc) + raise OAuthFlowError(error, desc) + + +def refresh_access_token(endpoints: Endpoints, refresh_token: str) -> TokenResponse: + """Exchange a refresh token for a fresh access/refresh pair. + + The response may rotate the refresh token; the caller must persist the + returned ``refresh_token`` before reusing it — replaying a superseded one + revokes the grant. + """ + resp = _post( + endpoints.token_url, + { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": endpoints.client_id, + }, + ) + if resp.status_code != 200: + error, desc = _error_from_response(resp) + raise OAuthFlowError(error, desc) + try: + body = resp.json() + except ValueError as e: + raise OAuthFlowError("invalid_response", "non-JSON token response") from e + return _token_from_body(body) diff --git a/honcho-cli/tests/conftest.py b/honcho-cli/tests/conftest.py new file mode 100644 index 00000000..5f121748 --- /dev/null +++ b/honcho-cli/tests/conftest.py @@ -0,0 +1,21 @@ +"""Shared test fixtures.""" + +from __future__ import annotations + +import pytest + +from honcho_cli import common +from honcho_cli.output import set_json_mode + + +@pytest.fixture(autouse=True) +def _reset_cli_globals(): + """Reset process-global CLI state between tests. + + ``_global_overrides`` (set by ``-w``/``-p``/``-s`` flags) and the JSON-mode + flag are module globals that leak across tests otherwise — a workspace set + by one test would silently satisfy the next test's workspace check. + """ + yield + common._global_overrides.update(workspace=None, peer=None, session=None) + set_json_mode(False) diff --git a/honcho-cli/tests/test_common.py b/honcho-cli/tests/test_common.py new file mode 100644 index 00000000..f5b2ec27 --- /dev/null +++ b/honcho-cli/tests/test_common.py @@ -0,0 +1,126 @@ +"""Tests for the client factory's transparent OAuth refresh.""" + +from __future__ import annotations + +import json +import os +import time +from unittest.mock import patch + +import pytest +import typer +from honcho_cli import common +from honcho_cli.config import CLIConfig, OAuthTokens +from honcho_cli.oauth import OAuthFlowError, TokenResponse + + +@pytest.fixture +def cfg_path(tmp_path, monkeypatch): + f = tmp_path / "config.json" + monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f) + monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path) + for k in [k for k in os.environ if k.startswith("HONCHO_")]: + monkeypatch.delenv(k) + return f + + +def _cfg(expires_at: float) -> CLIConfig: + return CLIConfig( + base_url="http://localhost:8000", + oauth=OAuthTokens( + access_token="old-at", + refresh_token="old-rt", + access_expires_at=expires_at, + client_id="honcho-cli", + scope="write", + ), + ) + + +def test_valid_token_is_not_refreshed(cfg_path): + config = _cfg(time.time() + 3600) + with patch("honcho_cli.oauth.refresh_access_token") as refresh: + common.maybe_refresh_token(config) + refresh.assert_not_called() + + +def test_expired_token_refreshes_despite_manual_key(cfg_path): + """OAuth wins over apiKey now, so the grant is kept alive even with a key set.""" + config = _cfg(time.time() - 100) + config.api_key = "manual" + rotated = TokenResponse( + access_token="new-at", refresh_token="new-rt", expires_in=3600, scope="write" + ) + with patch("honcho_cli.oauth.refresh_access_token", return_value=rotated) as refresh: + common.maybe_refresh_token(config) + refresh.assert_called_once() + assert config.resolved_api_key() == "new-at" + + +def test_host_mismatch_skips_refresh(cfg_path): + """A grant minted for another host is ignored — no refresh, apiKey covers this one.""" + config = _cfg(time.time() - 100) + config.oauth.host = "https://staging.example.com" + config.api_key = "manual" + with patch("honcho_cli.oauth.refresh_access_token") as refresh: + common.maybe_refresh_token(config) + refresh.assert_not_called() + assert config.resolved_api_key() == "manual" + + +def test_expired_token_refreshes_and_persists(cfg_path): + config = _cfg(time.time() - 100) + rotated = TokenResponse( + access_token="new-at", + refresh_token="new-rt", + expires_in=3600, + scope="write", + ) + with patch("honcho_cli.oauth.refresh_access_token", return_value=rotated) as refresh: + common.maybe_refresh_token(config) + + # used the stored refresh token + client_id + _endpoints, sent_rt = refresh.call_args.args + assert sent_rt == "old-rt" + assert _endpoints.client_id == "honcho-cli" + + # in-memory config updated with the rotated pair + assert config.oauth.access_token == "new-at" + assert config.oauth.refresh_token == "new-rt" + assert config.oauth.access_valid() + + # rotation persisted to disk before reuse + on_disk = json.loads(cfg_path.read_text())["oauth"] + assert on_disk["accessToken"] == "new-at" + assert on_disk["refreshToken"] == "new-rt" + + +def test_refresh_failure_exits(cfg_path): + config = _cfg(time.time() - 100) + with patch("honcho_cli.oauth.refresh_access_token", side_effect=OAuthFlowError("invalid_grant")): + with pytest.raises(typer.Exit): + common.maybe_refresh_token(config) + + +def test_refresh_failure_falls_back_to_api_key(cfg_path): + """A dead grant degrades to the saved apiKey instead of aborting.""" + config = _cfg(time.time() - 100) + config.api_key = "manual" + with patch("honcho_cli.oauth.refresh_access_token", side_effect=OAuthFlowError("invalid_grant")): + common.maybe_refresh_token(config) # must not raise + assert config.resolved_api_key() == "manual" + + +def test_missing_refresh_token_exits(cfg_path): + config = _cfg(time.time() - 100) + config.oauth.refresh_token = "" + with pytest.raises(typer.Exit): + common.maybe_refresh_token(config) + + +def test_missing_refresh_token_falls_back_to_api_key(cfg_path): + config = _cfg(time.time() - 100) + config.oauth.refresh_token = "" + config.api_key = "manual" + common.maybe_refresh_token(config) # must not raise + assert config.resolved_api_key() == "manual" diff --git a/honcho-cli/tests/test_config.py b/honcho-cli/tests/test_config.py index bdac2bc4..5b90087d 100644 --- a/honcho-cli/tests/test_config.py +++ b/honcho-cli/tests/test_config.py @@ -2,9 +2,12 @@ import json import os +import time +from pathlib import Path import pytest -from honcho_cli.config import CLIConfig +from honcho_cli.config import CLIConfig, OAuthTokens, _config_dir +from honcho_cli.oauth import TokenResponse @pytest.fixture @@ -18,6 +21,20 @@ def cfg_path(tmp_path, monkeypatch): return f +class TestConfigDir: + def test_defaults_to_dot_honcho(self, monkeypatch): + monkeypatch.delenv("HONCHO_CONFIG_DIR", raising=False) + assert _config_dir() == Path.home() / ".honcho" + + def test_honcho_config_dir_override(self, monkeypatch, tmp_path): + monkeypatch.setenv("HONCHO_CONFIG_DIR", str(tmp_path / "profile")) + assert _config_dir() == tmp_path / "profile" + + def test_expands_user_in_override(self, monkeypatch): + monkeypatch.setenv("HONCHO_CONFIG_DIR", "~/.honcho-test") + assert _config_dir() == Path.home() / ".honcho-test" + + class TestLoad: def test_defaults_when_no_file(self, cfg_path): loaded = CLIConfig.load() @@ -44,6 +61,31 @@ class TestLoad: assert loaded.api_key == "env-key" assert loaded.base_url == "http://localhost:8000" + def test_empty_env_var_popped_from_environ(self, cfg_path, monkeypatch): + """Empty HONCHO_* vars are removed so the SDK doesn't crash on them.""" + cfg_path.write_text(json.dumps({"apiKey": "file-key"})) + monkeypatch.setenv("HONCHO_API_KEY", "") + loaded = CLIConfig.load() + assert "HONCHO_API_KEY" not in os.environ + assert loaded.api_key == "file-key" + + def test_garbage_access_expires_at_treated_as_expired(self, cfg_path): + """Hand-edited/corrupt expiry degrades to the refresh path, not a crash.""" + cfg_path.write_text(json.dumps( + {"oauth": {"accessToken": "x", "accessExpiresAt": "not-a-number"}} + )) + loaded = CLIConfig.load() + assert loaded.oauth is not None + assert loaded.oauth.access_valid() is False + + def test_numeric_string_access_expires_at_parses(self, cfg_path): + cfg_path.write_text(json.dumps( + {"oauth": {"accessToken": "x", "accessExpiresAt": "12345"}} + )) + loaded = CLIConfig.load() + assert loaded.oauth is not None + assert loaded.oauth.access_expires_at == 12345.0 + class TestSave: def test_writes_only_cli_owned_keys(self, cfg_path): @@ -104,6 +146,117 @@ def test_api_key_redaction_empty_omitted(): assert "api_key" not in CLIConfig(api_key="").redacted() +class TestOAuth: + def _tokens(self, expires_at: float) -> OAuthTokens: + return OAuthTokens( + access_token="hch-at-x", + refresh_token="hch-rt-x", + access_expires_at=expires_at, + client_id="honcho-cli", + scope="write", + ) + + def test_round_trips_oauth_block(self, cfg_path): + CLIConfig(base_url="http://localhost:8000", oauth=self._tokens(9999999999)).save() + loaded = CLIConfig.load() + assert loaded.oauth is not None + assert loaded.oauth.access_token == "hch-at-x" + assert loaded.oauth.refresh_token == "hch-rt-x" + assert loaded.oauth.client_id == "honcho-cli" + + def test_oauth_persists_camelcase_keys(self, cfg_path): + CLIConfig(base_url="http://localhost:8000", oauth=self._tokens(1234)).save() + on_disk = json.loads(cfg_path.read_text())["oauth"] + assert set(on_disk) == {"accessToken", "refreshToken", "accessExpiresAt", "clientId", "scope", "host"} + + def test_save_preserves_foreign_keys_with_oauth(self, cfg_path): + cfg_path.write_text(json.dumps({"hosts": {"claude_code": {"peerName": "u"}}})) + CLIConfig(base_url="http://localhost:8000", oauth=self._tokens(1234)).save() + on_disk = json.loads(cfg_path.read_text()) + assert on_disk["hosts"] == {"claude_code": {"peerName": "u"}} + assert "oauth" in on_disk + + def test_empty_oauth_is_dropped(self, cfg_path): + cfg_path.write_text(json.dumps({"oauth": {"accessToken": "old"}})) + CLIConfig(base_url="http://localhost:8000").save() + assert "oauth" not in json.loads(cfg_path.read_text()) + + def test_api_key_preserved_on_device_login(self, cfg_path): + """apiKey is shared with sibling tools — device login must not delete it.""" + cfg_path.write_text(json.dumps({"apiKey": "shared-key"})) + CLIConfig(base_url="http://localhost:8000", oauth=self._tokens(9999999999)).save() + on_disk = json.loads(cfg_path.read_text()) + assert on_disk["apiKey"] == "shared-key" + assert on_disk["oauth"]["accessToken"] == "hch-at-x" + + def test_resolved_api_key_prefers_live_oauth(self, cfg_path): + cfg = CLIConfig(api_key="manual", oauth=self._tokens(9999999999)) + assert cfg.resolved_api_key() == "hch-at-x" + + def test_resolved_api_key_expired_oauth_falls_back_to_api_key(self, cfg_path): + cfg = CLIConfig(api_key="manual", oauth=self._tokens(time.time() - 100)) + assert cfg.resolved_api_key() == "manual" + + def test_resolved_api_key_host_mismatch_falls_back_to_api_key(self, cfg_path): + tokens = self._tokens(9999999999) + tokens.host = "https://staging.example.com" + cfg = CLIConfig( + base_url="https://api.honcho.dev", api_key="manual", oauth=tokens + ) + assert cfg.resolved_api_key() == "manual" + + def test_resolved_api_key_expired_oauth_wins_over_nothing(self, cfg_path): + cfg = CLIConfig(oauth=self._tokens(time.time() - 100)) + assert cfg.resolved_api_key() == "hch-at-x" + + def test_resolved_api_key_falls_back_to_oauth(self, cfg_path): + cfg = CLIConfig(oauth=self._tokens(9999999999)) + assert cfg.resolved_api_key() == "hch-at-x" + + def test_access_valid_expiry_and_skew(self): + assert self._tokens(time.time() + 3600).access_valid() + assert not self._tokens(time.time() - 10).access_valid() + # inside the default 60s skew window → treated as invalid + assert not self._tokens(time.time() + 30).access_valid() + + def test_access_valid_false_without_token(self): + """A missing token is invalid even with a far-future expiry.""" + tokens = OAuthTokens(access_token="", access_expires_at=time.time() + 3600) + assert tokens.access_valid() is False + + def test_from_response_keeps_prior_refresh_token_when_not_rotated(self): + """Refresh-token rotation is optional (RFC 6749 §5.1) — keep the old one.""" + resp = TokenResponse( + access_token="new-at", refresh_token="", expires_in=3600, scope="" + ) + tokens = OAuthTokens.from_response( + resp, + client_id="honcho-cli", + scope_fallback="write", + refresh_fallback="prior-rt", + host="https://staging.example.com", + ) + assert tokens.refresh_token == "prior-rt" + assert tokens.scope == "write" + assert tokens.host == "https://staging.example.com" + + def test_host_round_trips_and_legacy_matches_all(self, cfg_path): + tokens = self._tokens(9999999999) + tokens.host = "https://staging.example.com" + CLIConfig(base_url="https://staging.example.com", oauth=tokens).save() + loaded = CLIConfig.load() + assert loaded.oauth is not None + assert loaded.oauth.host == "https://staging.example.com" + # trailing-slash normalization + legacy blocks (no host) trust any host + assert loaded.oauth.matches_host("https://staging.example.com/") + assert not loaded.oauth.matches_host("https://api.honcho.dev") + assert OAuthTokens(access_token="x").matches_host("https://anything.dev") + + def test_redacted_masks_oauth_token(self): + red = CLIConfig(oauth=self._tokens(1234)).redacted() + assert red["oauth"] == "***at-x" + + def test_save_sets_600_permissions(cfg_path): """Config with plaintext API key must be owner-readable only on POSIX.""" import stat diff --git a/honcho-cli/tests/test_oauth.py b/honcho-cli/tests/test_oauth.py new file mode 100644 index 00000000..45121130 --- /dev/null +++ b/honcho-cli/tests/test_oauth.py @@ -0,0 +1,232 @@ +"""Tests for the device-authorization OAuth engine (transport-only).""" + +from __future__ import annotations + +from unittest.mock import patch + +import httpx +import pytest +from honcho_cli import oauth +from honcho_cli.oauth import ( + AccessDenied, + AuthorizationTimeout, + DeviceCode, + DeviceCodeExpired, + Endpoints, + OAuthFlowError, +) + + +class FakeResponse: + def __init__(self, status_code: int, body): + self.status_code = status_code + self._body = body + self.text = str(body) + + def json(self): + if isinstance(self._body, Exception): + raise self._body + return self._body + + +def _endpoints() -> Endpoints: + return Endpoints( + device_auth_url="https://api.honcho.dev/oauth/device_authorization", + token_url="https://api.honcho.dev/oauth/token", + client_id="honcho-cli", + scope="write", + ) + + +DEVICE = DeviceCode( + device_code="dev-abc", + user_code="WXYZ-1234", + verification_uri="https://app.honcho.dev/device", + verification_uri_complete="https://app.honcho.dev/device?user_code=WXYZ-1234", + expires_in=600, + interval=5, +) + + +# --------------------------------------------------------------------------- # +# resolve_endpoints + +class TestResolveEndpoints: + def test_derives_urls_from_base_url(self): + ep = oauth.resolve_endpoints("https://api.honcho.dev") + assert ep.device_auth_url == "https://api.honcho.dev/oauth/device_authorization" + assert ep.token_url == "https://api.honcho.dev/oauth/token" + assert ep.client_id == "honcho-cli" + assert ep.scope == "write" + + def test_strips_trailing_slash(self): + ep = oauth.resolve_endpoints("http://localhost:8000/") + assert ep.token_url == "http://localhost:8000/oauth/token" + + +# --------------------------------------------------------------------------- # +# supports_device_login + +class TestSupportsDeviceLogin: + def test_true_when_device_grant_advertised(self): + body = {"grant_types_supported": ["authorization_code", oauth.DEVICE_GRANT_TYPE]} + with patch("honcho_cli.oauth.httpx.get", return_value=FakeResponse(200, body)): + assert oauth.supports_device_login("https://api.honcho.dev") is True + + def test_false_when_device_grant_absent(self): + body = {"grant_types_supported": ["authorization_code", "refresh_token"]} + with patch("honcho_cli.oauth.httpx.get", return_value=FakeResponse(200, body)): + assert oauth.supports_device_login("https://api.honcho.dev") is False + + @pytest.mark.parametrize("status", [404, 500]) + def test_false_on_non_200(self, status): + with patch("honcho_cli.oauth.httpx.get", return_value=FakeResponse(status, "")): + assert oauth.supports_device_login("http://localhost:8000") is False + + def test_false_on_connection_error(self): + with patch("honcho_cli.oauth.httpx.get", side_effect=httpx.ConnectError("no route")): + assert oauth.supports_device_login("http://localhost:8000") is False + + def test_false_on_unparseable_body(self): + with patch("honcho_cli.oauth.httpx.get", return_value=FakeResponse(200, ValueError())): + assert oauth.supports_device_login("https://api.honcho.dev") is False + + +# --------------------------------------------------------------------------- # +# request_device_code + +class TestRequestDeviceCode: + def test_success(self): + body = { + "device_code": "dev-abc", + "user_code": "WXYZ-1234", + "verification_uri": "https://app.honcho.dev/device", + "verification_uri_complete": "https://app.honcho.dev/device?user_code=WXYZ-1234", + "expires_in": 600, + "interval": 5, + } + with patch("honcho_cli.oauth.httpx.post", return_value=FakeResponse(200, body)) as post: + dc = oauth.request_device_code(_endpoints()) + assert dc.device_code == "dev-abc" + assert dc.user_code == "WXYZ-1234" + assert dc.interval == 5 + assert post.call_args.kwargs["data"]["source"] == "honcho-cli" + + def test_error_raises(self): + body = {"error": "invalid_client", "error_description": "unknown client"} + with patch("honcho_cli.oauth.httpx.post", return_value=FakeResponse(401, body)): + with pytest.raises(OAuthFlowError) as exc: + oauth.request_device_code(_endpoints()) + assert exc.value.error == "invalid_client" + + def test_transport_failure_wrapped(self): + with patch("honcho_cli.oauth.httpx.post", side_effect=httpx.ConnectError("no route")): + with pytest.raises(OAuthFlowError) as exc: + oauth.request_device_code(_endpoints()) + assert exc.value.error == "connection_error" + + +# --------------------------------------------------------------------------- # +# poll_for_token + +class TestPollForToken: + def _run(self, responses, monotonic_vals=None): + """Poll with a scripted response sequence, capturing sleep durations.""" + sleeps: list[float] = [] + clock = iter(monotonic_vals or [0.0] * (len(responses) + 2)) + with patch("honcho_cli.oauth.httpx.post", side_effect=responses): + token = oauth.poll_for_token( + _endpoints(), + DEVICE, + sleep=sleeps.append, + monotonic=lambda: next(clock), + ) + return token, sleeps + + def test_pending_then_slowdown_then_success(self): + success = { + "access_token": "hch-at-1", + "refresh_token": "hch-rt-1", + "expires_in": 3600, + "scope": "write", + "config": {"k": "v"}, + } + responses = [ + FakeResponse(400, {"error": "authorization_pending"}), + FakeResponse(400, {"error": "slow_down"}), + FakeResponse(200, success), + ] + token, sleeps = self._run(responses) + assert token.access_token == "hch-at-1" + assert token.refresh_token == "hch-rt-1" + assert token.config == {"k": "v"} + # interval starts at 5, bumps by 5 after slow_down → third sleep is 10 + assert sleeps == [5, 5, 10] + + def test_access_denied(self): + responses = [FakeResponse(400, {"error": "access_denied"})] + with pytest.raises(AccessDenied): + self._run(responses) + + def test_expired_token(self): + responses = [FakeResponse(400, {"error": "expired_token"})] + with pytest.raises(DeviceCodeExpired): + self._run(responses) + + def test_unexpected_error_raises_generic(self): + responses = [FakeResponse(400, {"error": "invalid_grant"})] + with pytest.raises(OAuthFlowError) as exc: + self._run(responses) + assert exc.value.error == "invalid_grant" + + def test_transport_failure_wrapped(self): + # an exception in the side_effect list is raised on that poll + responses = [httpx.ReadTimeout("timed out")] + with pytest.raises(OAuthFlowError) as exc: + self._run(responses) + assert exc.value.error == "connection_error" + + def test_times_out_past_deadline(self): + # monotonic jumps past deadline (0 + expires_in) on the first check + with patch("honcho_cli.oauth.httpx.post") as post: + with pytest.raises(AuthorizationTimeout): + oauth.poll_for_token( + _endpoints(), + DEVICE, + sleep=lambda _s: None, + monotonic=iter([0.0, 9999.0]).__next__, + ) + post.assert_not_called() + + +# --------------------------------------------------------------------------- # +# refresh_access_token + +class TestRefresh: + def test_success_returns_rotated_pair(self): + body = { + "access_token": "hch-at-2", + "refresh_token": "hch-rt-2", + "expires_in": 3600, + "scope": "write", + } + with patch("honcho_cli.oauth.httpx.post", return_value=FakeResponse(200, body)) as post: + token = oauth.refresh_access_token(_endpoints(), "hch-rt-1") + assert token.access_token == "hch-at-2" + assert token.refresh_token == "hch-rt-2" + sent = post.call_args.kwargs["data"] + assert sent["grant_type"] == "refresh_token" + assert sent["refresh_token"] == "hch-rt-1" + + def test_error_raises(self): + body = {"error": "invalid_grant", "error_description": "revoked"} + with patch("honcho_cli.oauth.httpx.post", return_value=FakeResponse(400, body)): + with pytest.raises(OAuthFlowError) as exc: + oauth.refresh_access_token(_endpoints(), "stale") + assert exc.value.error == "invalid_grant" + + def test_transport_failure_wrapped(self): + with patch("honcho_cli.oauth.httpx.post", side_effect=httpx.ConnectError("no route")): + with pytest.raises(OAuthFlowError) as exc: + oauth.refresh_access_token(_endpoints(), "hch-rt-1") + assert exc.value.error == "connection_error" From 1684b8434474143a46fbb3c0f99ad01aed69fb65 Mon Sep 17 00:00:00 2001 From: ajspig <46900795+ajspig@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:05:04 -0400 Subject: [PATCH 55/65] chore: updating cli version 0.1.2 (#921) --- honcho-cli/CHANGELOG.md | 6 ++++++ honcho-cli/pyproject.toml | 2 +- honcho-cli/src/honcho_cli/__init__.py | 2 +- uv.lock | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/honcho-cli/CHANGELOG.md b/honcho-cli/CHANGELOG.md index 7d56f768..3c2c383a 100644 --- a/honcho-cli/CHANGELOG.md +++ b/honcho-cli/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.1.2] - 2026-07-20 + +### Added + +- Device-code OAuth login for managed Honcho servers. `honcho init` now offers browser-based login (RFC 8628 device authorization grant) when the host advertises the device grant in its OAuth authorization-server metadata; tokens are persisted to `~/.honcho/config.json` and auto-refreshed (#891) + ## [0.1.1] - 2026-06-15 ### Fixed diff --git a/honcho-cli/pyproject.toml b/honcho-cli/pyproject.toml index b3293a1a..5eb859fd 100644 --- a/honcho-cli/pyproject.toml +++ b/honcho-cli/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho-cli" -version = "0.1.1" +version = "0.1.2" description = "A terminal for Honcho — memory that reasons." readme = "README.md" requires-python = ">=3.11" diff --git a/honcho-cli/src/honcho_cli/__init__.py b/honcho-cli/src/honcho_cli/__init__.py index f38546a9..81efe6f5 100644 --- a/honcho-cli/src/honcho_cli/__init__.py +++ b/honcho-cli/src/honcho_cli/__init__.py @@ -1,3 +1,3 @@ """Honcho CLI — a terminal for Honcho.""" -__version__ = "0.1.0" +__version__ = "0.1.2" diff --git a/uv.lock b/uv.lock index bd4ffae4..c27d054c 100644 --- a/uv.lock +++ b/uv.lock @@ -1302,7 +1302,7 @@ dev = [{ name = "ruff", specifier = ">=0.11.13" }] [[package]] name = "honcho-cli" -version = "0.1.1" +version = "0.1.2" source = { editable = "honcho-cli" } dependencies = [ { name = "click" }, From b7a00d5f6b4302b2d20c23e09cd505c999af54f1 Mon Sep 17 00:00:00 2001 From: Ulysse Pence Date: Mon, 20 Jul 2026 17:50:40 -0200 Subject: [PATCH 56/65] Reverts RepresentationCompletedEvent version increment --- src/telemetry/events/representation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/telemetry/events/representation.py b/src/telemetry/events/representation.py index 882bfd50..ad6dfca4 100644 --- a/src/telemetry/events/representation.py +++ b/src/telemetry/events/representation.py @@ -22,7 +22,7 @@ class RepresentationCompletedEvent(BaseEvent): """ _event_type: ClassVar[str] = "representation.completed" - _schema_version: ClassVar[int] = 3 + _schema_version: ClassVar[int] = 2 _category: ClassVar[str] = "representation" # Workspace context From 063aaa97a6247bfeb9edf832f249f2399f21e6cc Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Mon, 20 Jul 2026 18:46:49 -0400 Subject: [PATCH 57/65] feat(dialectic): optional structured outputs with limited schema for Dialectic calls (#896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Structured outputs for dialectic * cleanup * rename json_schema_to_pydantic to clarify it's not a general schema converter * clean up schema DoS guards * simplification and cleanup of schema conversion * chore: ruff and pyproject toml * chore: basedpyright cleanup in test * fix: some needed unrelated test failures * test(schema_conversion-and-anthropic-backend): expand test coverage include table tests * fix(llm): support combined tool calling and structured output across backends - OpenAI: parse() 500s on non-strict function tools; route tool-carrying structured requests through create() with an explicit json_schema response_format (mirrors the streaming path) - Anthropic: skip the '{' JSON prefill when tools are present so tool_use blocks stay reachable; make the schema instruction conditional and rely on parse + repair - Gemini: native response_schema + function calling is rejected before Gemini 3; with tools present, inject a schema instruction into the final turn instead and rely on parse + repair - All backends: tool-call turns carry no consumable content, so skip structured-output parsing on them Extracted from the dialectic structured-output branch (DEV-1652) so the transport layer can land independently. DEV-2035 Co-Authored-By: Claude Fable 5 * test(live_llm): exercise combined tools + structured output per provider Two-turn live flow per backend: a forced tool-call turn (structured parsing must be skipped) followed by a replay turn that must return a schema-conforming answer with tools still attached. Asserts the provider-specific request shaping: no parse() for OpenAI (500s on non-strict tools), no '{' prefill for Anthropic, no native response_schema for Gemini. Verified against live OpenAI (gpt-4.1, gpt-5, gpt-5.4, gpt-5.4-mini) and Gemini (gemini-2.5-flash). DEV-2035 Co-Authored-By: Claude Fable 5 * test(unified): dialectic chat with response_format schema under tool use Adds response_format pass-through to the unified runner's chat query and a test case that forces the dialectic tool loop (reasoning off + global enumeration question) while requiring a schema-conforming JSON answer — end-to-end coverage of the combined tools + structured output transport path on whichever provider each level is configured with. Verified locally against a full harness run (json_match assertions pass; the llm_judge assertion additionally runs in CI where the Anthropic key is available). Co-Authored-By: Claude Fable 5 * fix: some needed unrelated test failures * ci: add label-triggered live LLM test workflow Adding the run-live-llm label to a PR (or workflow_dispatch) runs tests/live_llm/ against real provider APIs — the only place the --live-llm suite runs in CI. Reuses the unified-tests environment and its Secrets Manager staging-dotenv resolution for provider keys; runs on ubuntu-latest (no Fly runner, no Docker — the suite only touches the LLM backends). Pins LIVE_LLM_ANTHROPIC_45_PLUS_MODELS=claude-sonnet-4-5 since the Anthropic family has no default models and would otherwise silently collect empty. Opt-in by design: live model behavior is variable, so this is a signal, not a required check. DEV-2035 Co-Authored-By: Claude Fable 5 * ci: run live LLM tests on main pushes touching the transport Mirrors unified-tests' push trigger, scoped to paths that can affect the live suite (src/llm/, config, the tests, deps, and the workflow itself) so provider API calls aren't spent on unrelated changes. DEV-2035 Co-Authored-By: Claude Fable 5 * ci: disable auth in live LLM test environment The staging dotenv sets AUTH_USE_AUTH=true without a usable JWT secret, and src/config.py validates the pair at import time — the same reason unified-tests overrides it. This suite never runs the API server. DEV-2035 Co-Authored-By: Claude Fable 5 * test(live_llm): fix gpt-5.4 reasoning_effort and gemini replay-turn flake - test_live_openai: gpt-5.4 dropped 'minimal' from the reasoning_effort vocabulary, so the gpt5 caching test 400'd — and the OpenAI backend's BadRequestError terminal swallowed it into an empty CompletionResult. Pick the effort per model generation. - test_live_tools_structured_output: use tool_choice='auto' on the replay turn, matching the production dialectic loop (which never forces 'none') — NONE mode is what provoked gemini-2.5-flash's empty candidates. Drop the temperature pin so retries actually resample, and treat a repeat tool call as a retryable attempt. Verified live: full suite green, gemini 4/4 consecutive passes. DEV-2035 Co-Authored-By: Claude Fable 5 * ci: fail live LLM run when no staging secret was loaded If the latest-tag fetch fails and no second tag exists, the fallback step is skipped rather than failed, and the job would proceed without provider keys — every test then skips via require_provider_key and the run goes green. Guard on both fetch outcomes so that path fails loudly. DEV-2035 Co-Authored-By: Claude Fable 5 * docs(live-llm-tests-GHA): remove extra comments * feat(structured-output): enable non-recursive schema references * docs(structured-outputs): clean up new doc * test(structured-output): fix caching refs memory leak, add tests --------- Co-authored-by: Claude Fable 5 --- .gitignore | 2 +- docs/docs.json | 1 + .../features/advanced/structured-outputs.mdx | 239 +++++ docs/v3/documentation/features/chat.mdx | 37 + sdks/python/src/honcho/aio.py | 56 +- sdks/python/src/honcho/api_types.py | 1 + sdks/python/src/honcho/peer.py | 66 +- sdks/typescript/__tests__/peer.test.ts | 39 + sdks/typescript/__tests__/validation.test.ts | 20 +- sdks/typescript/src/peer.ts | 53 +- sdks/typescript/src/types/api.ts | 1 + sdks/typescript/src/validation.ts | 5 + src/dialectic/chat.py | 13 +- src/dialectic/core.py | 56 +- src/llm/backends/openai.py | 23 +- src/routers/peers.py | 18 +- src/schemas/api.py | 10 + src/utils/schema_conversion.py | 505 ++++++++++ tests/conftest.py | 11 +- tests/dialectic/test_structured_output.py | 126 +++ .../test_live_structured_output_unions.py | 162 ++++ tests/llm/test_backends/test_openai.py | 72 +- tests/routes/test_peers.py | 112 +++ tests/sdk/test_peer.py | 164 ++++ tests/unified/runner.py | 1 + tests/unified/schema.py | 3 + .../dialectic_structured_output.json | 135 +++ tests/utils/test_schema_conversion.py | 897 ++++++++++++++++++ 28 files changed, 2788 insertions(+), 40 deletions(-) create mode 100644 docs/v3/documentation/features/advanced/structured-outputs.mdx create mode 100644 src/utils/schema_conversion.py create mode 100644 tests/dialectic/test_structured_output.py create mode 100644 tests/live_llm/test_live_structured_output_unions.py create mode 100644 tests/unified/test_cases/dialectic_structured_output.json create mode 100644 tests/utils/test_schema_conversion.py diff --git a/.gitignore b/.gitignore index efb1f8ce..9fa90b3e 100644 --- a/.gitignore +++ b/.gitignore @@ -195,4 +195,4 @@ lancedb_data/ grafana-data/ # Claude Code addon stuff -.omc \ No newline at end of file +.omc diff --git a/docs/docs.json b/docs/docs.json index cbdd4e62..e5bb31e8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -67,6 +67,7 @@ "v3/documentation/features/advanced/queue-status", "v3/documentation/features/advanced/search", "v3/documentation/features/advanced/using-filters", + "v3/documentation/features/advanced/structured-outputs", "v3/documentation/features/advanced/streaming-response", "v3/documentation/features/advanced/file-uploads" ] diff --git a/docs/v3/documentation/features/advanced/structured-outputs.mdx b/docs/v3/documentation/features/advanced/structured-outputs.mdx new file mode 100644 index 00000000..8e8f7e9a --- /dev/null +++ b/docs/v3/documentation/features/advanced/structured-outputs.mdx @@ -0,0 +1,239 @@ +--- +title: "Structured Outputs" +description: "Get chat endpoint answers as typed, machine-readable JSON" +icon: "brackets-curly" +--- + +By default, the [chat endpoint](/v3/documentation/features/chat) returns a free-form natural language answer. When your application needs machine-readable output, parsing that string yourself can be fragile and model-dependent. In this case you can use structured Dialectic outputs: pass a schema with your query, and the answer is guaranteed to conform to it. The agent still runs its full reasoning loop and only the final synthesized answer is formatted to your schema. + +## Basic Usage + +Pass a Pydantic model (Python) or Zod schema (TypeScript) as `response_format`, and the SDK returns a parsed, typed instance: + + +```python Python +from typing import Literal +from pydantic import BaseModel, Field +from honcho import Honcho + +class FoodPreference(BaseModel): + food: str + sentiment: Literal["loves", "likes", "neutral", "dislikes", "hates"] + confidence: float = Field(description="0-1, how certain the evidence is") + +class FoodPreferences(BaseModel): + preferences: list[FoodPreference] + summary: str + +honcho = Honcho() +peer = honcho.peer("user-123") + +result = peer.chat( + "What are this user's top 3 food preferences?", + response_format=FoodPreferences, +) + +# result is a FoodPreferences instance (or None if no relevant information) +if result: + for pref in result.preferences: + print(f"{pref.food}: {pref.sentiment} ({pref.confidence})") +``` + +```typescript TypeScript +import { z } from 'zod'; +import { Honcho } from '@honcho-ai/sdk'; + +const FoodPreferences = z.object({ + preferences: z.array(z.object({ + food: z.string(), + sentiment: z.enum(["loves", "likes", "neutral", "dislikes", "hates"]), + confidence: z.number(), + })), + summary: z.string(), +}); + +const honcho = new Honcho({}); +const peer = await honcho.peer("user-123"); + +const result = await peer.chat( + "What are this user's top 3 food preferences?", + { responseFormat: FoodPreferences }, +); + +// result is typed as z.infer (or null) +if (result) { + console.log(result.summary); +} +``` + + +## Using a Raw JSON Schema + +You can also pass a plain JSON Schema object instead of a Pydantic/Zod schema. In that case the SDK returns the answer as a JSON **string** and leaves parsing to you. This is also the shape the REST API accepts directly: + + +```python Python +result = peer.chat( + "What are this user's food preferences?", + response_format={ + "type": "object", + "properties": { + "foods": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["foods"], + }, +) +# result is a JSON string, e.g. '{"foods": ["dark roast coffee", "sushi"]}' +``` + +```bash cURL +curl -X POST "$HONCHO_URL/v3/workspaces/my-app/peers/user-123/chat" \ + -H "Authorization: Bearer $HONCHO_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "What are this user'\''s food preferences?", + "response_format": { + "type": "object", + "properties": { + "foods": { "type": "array", "items": { "type": "string" } } + }, + "required": ["foods"] + } + }' +``` + + +At the API level, `content` in the response is always a string. When `response_format` is set, it is a JSON-encoded object conforming to your schema. + +## Streaming + +`response_format` works with streaming. The stream emits the JSON answer incrementally as raw text chunks; the accumulated text is a valid JSON string once the stream completes. To enable streaming the SDKs cannot parse streamed responses for you, you parse the final string yourself: + + +```python Python +response_stream = peer.chat( + "What are this user's food preferences?", + stream=True, + response_format=FoodPreferences, +) + +chunks = [] +for chunk in response_stream.iter_text(): + chunks.append(chunk) + +result = FoodPreferences.model_validate_json("".join(chunks)) +``` + +```typescript TypeScript +const responseStream = await peer.chat( + "What are this user's food preferences?", + { stream: true, responseFormat: FoodPreferences }, +); + +let text = ""; +for await (const chunk of responseStream.iter_text()) { + text += chunk; +} + +const result = FoodPreferences.parse(JSON.parse(text)); +``` + + +## Supported Schema Subset + +Honcho supports a conservative subset of JSON Schema that enables the kind of Pydantic models used for structured LLM outputs. Schemas outside this subset are rejected with a `422` validation error before any reasoning runs. + +The root of the schema must be `"type": "object"`. + +| Construct | Support | +|-----------|---------| +| `string`, `number`, `integer`, `boolean`, `null` | Supported | +| `object` with `properties` (nested recursively) | Supported | +| `array` with `items` (missing `items` yields an untyped list) | Supported | +| `enum` of strings, integers, booleans, or null | Supported | +| `anyOf` / `oneOf` unions (a `null` member makes the field optional) | Supported | +| `type` given as a list (e.g. `["string", "null"]`) | Supported | +| `required`, `default`, `description` | Supported | +| Boolean `additionalProperties` | Accepted and ignored | +| `$ref` into root-level `$defs` / `definitions` | Supported — resolved by inlining (this is what Pydantic and Zod emit) | +| Recursive `$ref` (a definition that references itself, directly or indirectly) | Rejected (422) — the error will identify the cycle | +| Other `$ref` forms (external URLs, arbitrary JSON pointers) | Rejected (422) | +| `allOf`, `not`, `if` / `then` / `else` | Rejected (422) | +| `patternProperties`, schema-valued `additionalProperties` | Rejected (422) | + +Schemas may nest at most 20 levels deep and contain at most 500 total nodes. + + +Constraint keywords like `minItems`, `maxLength`, `minimum`, `pattern`, and `format` are passed through to the model as hints but are **not enforced server-side**. If you need hard guarantees on these, validate the returned object in your application. + + + +**Recursive schemas are not supported.** A self-referential Pydantic model (`Node.children: list[Node]`) or a recursive Zod schema (`z.lazy(...)`) produces a recursive `$ref`, which is rejected with a 422 naming the cycle. Restructure recursive shapes as explicit nesting with a fixed depth. + + +## Optional Fields and Unions + +Two distinct mechanisms control "optionality" in a raw JSON Schema: + +- **Omission** is controlled by `required`. A property not listed in `required` may be left out by the model entirely; the parsed answer will contain it as `null`. +- **Nullability** is controlled by the field's type. An `anyOf`/`oneOf` with a `{"type": "null"}` member (or the shorthand `"type": ["string", "null"]`) means the field's *value* may be `null` even when the field itself is required. + +`anyOf` and `oneOf` are treated identically: a plain union of the member schemas. Unions of non-null types (e.g. a string-or-integer field) are also supported. + +```json +{ + "type": "object", + "properties": { + "favorite_food": { "type": "string" }, + "dietary_restriction": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "description": "The user's dietary restriction, or null if none is known" + }, + "years_vegetarian": { "type": ["integer", "null"] }, + "confidence": { "type": "number" } + }, + "required": ["favorite_food", "dietary_restriction", "years_vegetarian"] +} +``` + +In this schema: + +- `favorite_food` is required and must be a string. +- `dietary_restriction` is required but **nullable**: the key is always present in the answer, and the model can answer `null` when it has no evidence. This is the recommended way to give the model an escape hatch (see [Best Practices](#model-uncertainty-explicitly)). +- `years_vegetarian` is the same thing written with the `type`-list shorthand (`["integer", "null"]` is equivalent to an `anyOf` of the two). +- `confidence` is not in `required`, so the model may omit it; if it does, the field comes back as `null`. + +If a property declares a `default`, that default is used whenever the model omits the field _even if_ the property is listed in `required`. + +Pydantic and Zod produce these shapes for you: `str | None` in Pydantic emits the `anyOf` form above, and `z.string().nullable()` does the same in Zod (`z.string().optional()` controls presence in `required`). + +## Error Handling + +| Condition | Result | +|-----------|--------| +| `response_format` is not a valid JSON Schema object | `422` validation error | +| Root type is not `"object"` | `422` validation error | +| Schema uses an unsupported construct | `422` identifying the construct and its path | +| Schema contains a recursive `$ref` | `422` identifying the cycle (e.g. `cycle: Node -> Node`) | +| Model fails to produce valid structured output after retries | `500`, same as any LLM failure | + +## How It Works + +Structured output constrains the final synthesis step. The reasoning itself works the same in both settings. + +1. The dialectic agent runs its normal tool loop in free-form text. It will search conclusions, grep messages, and traverse reasoning chains +2. Once the agent has gathered enough context, the final answer generation is constrained to your schema using the provider's native structured output support. +3. The conforming JSON is returned as the response `content` and parsed into a typed object by the SDK when you passed a Pydantic model or Zod schema. + +This means answer *quality* is unaffected by the schema: the agent reasons exactly as it would for a free-form answer, and reasoning levels (`minimal` through `max`) work the same way alongside `response_format`. + +## Best Practices + +### Add descriptions to your fields +Field `description`s are visible to the model when it formats the answer. `confidence: float` with a provided description of "score how certain the evidence is from 0-5" gets meaningfully better output than a bare field. + +### Model uncertainty explicitly +The chat endpoint returns `None`/`null` when it has no relevant information. With a schema, you can force an answer even when evidence is thin. To avoid hallucinations, consider including an escape hatch as an optional field, a `"confidence"` score, or an enum member like `"unknown"` so the model isn't forced to fabricate. + +### Keep schemas focused +A schema with three well-described fields outperforms one with twenty. If you need many distinct insights, consider making separate chat calls. diff --git a/docs/v3/documentation/features/chat.mdx b/docs/v3/documentation/features/chat.mdx index 7493e791..6aab6996 100644 --- a/docs/v3/documentation/features/chat.mdx +++ b/docs/v3/documentation/features/chat.mdx @@ -94,6 +94,43 @@ for await (const chunk of responseStream.iter_text()) { Streaming is useful for displaying real-time responses in chat interfaces or when asking complex questions that require longer answers. +## Structured Outputs + +When your application needs a machine-readable answer instead of prose, pass a schema as `response_format` and the answer is guaranteed to conform to it: + + +```python Python +from pydantic import BaseModel + +class OnboardingStatus(BaseModel): + completed: bool + remaining_steps: list[str] + +status = peer.chat( + "Has the user completed the onboarding flow?", + response_format=OnboardingStatus, +) +# status is a parsed OnboardingStatus instance +``` + +```typescript TypeScript +import { z } from 'zod'; + +const OnboardingStatus = z.object({ + completed: z.boolean(), + remainingSteps: z.array(z.string()), +}); + +const status = await peer.chat( + "Has the user completed the onboarding flow?", + { responseFormat: OnboardingStatus }, +); +// status is typed as z.infer +``` + + +The agent runs its full reasoning loop either way — only the final answer is formatted to your schema. See [Structured Outputs](/v3/documentation/features/advanced/structured-outputs) for the supported schema subset, streaming behavior, and best practices. + ## Integration Patterns ### Dynamic Prompt Enhancement diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py index 2cf39ab1..51797c72 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -26,9 +26,9 @@ import logging import warnings from collections.abc import AsyncGenerator from datetime import datetime -from typing import TYPE_CHECKING, Any, ClassVar, Literal +from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload -from pydantic import ConfigDict, Field, validate_call +from pydantic import BaseModel, ConfigDict, Field, validate_call from .api_types import ( ConclusionResponse, @@ -71,7 +71,7 @@ if TYPE_CHECKING: from .conclusions import ConclusionScope from .conclusions import ConclusionCreateParams -from .peer import Peer +from .peer import Peer, TResponseFormat, serialize_response_format from .session import Session logger = logging.getLogger(__name__) @@ -577,6 +577,30 @@ class PeerAio(AsyncMetadataConfigMixin): ) self._peer._configuration = configuration + @overload + async def chat( + self, + query: str, + *, + target: str | PeerBase | None = None, + session: str | SessionBase | None = None, + reasoning_level: Literal["minimal", "low", "medium", "high", "max"] + | None = None, + response_format: type[TResponseFormat], + ) -> TResponseFormat | None: ... + + @overload + async def chat( + self, + query: str, + *, + target: str | PeerBase | None = None, + session: str | SessionBase | None = None, + reasoning_level: Literal["minimal", "low", "medium", "high", "max"] + | None = None, + response_format: dict[str, Any] | None = None, + ) -> str | None: ... + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) async def chat( self, @@ -586,8 +610,14 @@ class PeerAio(AsyncMetadataConfigMixin): session: str | SessionBase | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, - ) -> str | None: - """Query the peer's representation asynchronously.""" + response_format: type[BaseModel] | dict[str, Any] | None = None, + ) -> BaseModel | str | None: + """Query the peer's representation asynchronously. + + See Peer.chat for parameter details. When response_format is a Pydantic + model class, the answer is parsed into an instance of it; when it is a + JSON Schema dict, the answer is a JSON string. + """ await self._peer._honcho._ensure_workspace_async() target_id = resolve_id(target) resolved_session_id = resolve_id(session) @@ -599,6 +629,9 @@ class PeerAio(AsyncMetadataConfigMixin): body["session_id"] = resolved_session_id if reasoning_level: body["reasoning_level"] = reasoning_level + response_format_schema = serialize_response_format(response_format) + if response_format_schema is not None: + body["response_format"] = response_format_schema data = await self._peer._honcho._async_http_client.post( routes.peer_chat(self._peer.workspace_id, self._peer.id), @@ -607,6 +640,8 @@ class PeerAio(AsyncMetadataConfigMixin): content = data.get("content") if not content: return None + if isinstance(response_format, type): + return response_format.model_validate_json(content) return content @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) @@ -618,8 +653,14 @@ class PeerAio(AsyncMetadataConfigMixin): session: str | SessionBase | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, ) -> AsyncDialecticStreamResponse: - """Query the peer's representation with streaming asynchronously.""" + """Query the peer's representation with streaming asynchronously. + + See Peer.chat_stream for parameter details. With response_format set, + chunks stay raw text that accumulates to a JSON string; parse it after + the stream completes. + """ await self._peer._honcho._ensure_workspace_async() target_id = resolve_id(target) resolved_session_id = resolve_id(session) @@ -631,6 +672,9 @@ class PeerAio(AsyncMetadataConfigMixin): body["session_id"] = resolved_session_id if reasoning_level: body["reasoning_level"] = reasoning_level + response_format_schema = serialize_response_format(response_format) + if response_format_schema is not None: + body["response_format"] = response_format_schema async def stream_response() -> AsyncGenerator[str, None]: async for content in parse_sse_astream( diff --git a/sdks/python/src/honcho/api_types.py b/sdks/python/src/honcho/api_types.py index 64ee7b65..f626c5a6 100644 --- a/sdks/python/src/honcho/api_types.py +++ b/sdks/python/src/honcho/api_types.py @@ -503,6 +503,7 @@ class DialecticParams(BaseModel): query: str = Field(min_length=1, max_length=10000) stream: bool = False reasoning_level: ReasoningLevel = "low" + response_format: dict[str, Any] | None = None class DialecticResponse(BaseModel): diff --git a/sdks/python/src/honcho/peer.py b/sdks/python/src/honcho/peer.py index f24357bb..e004db6a 100644 --- a/sdks/python/src/honcho/peer.py +++ b/sdks/python/src/honcho/peer.py @@ -7,9 +7,9 @@ import datetime import logging import warnings from collections.abc import Generator -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload -from pydantic import ConfigDict, Field, PrivateAttr, validate_call +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call from .api_types import ( MessageCreateParams, @@ -38,6 +38,19 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +TResponseFormat = TypeVar("TResponseFormat", bound=BaseModel) + + +def serialize_response_format( + response_format: type[BaseModel] | dict[str, Any] | None, +) -> dict[str, Any] | None: + """Convert a chat response_format argument to a JSON Schema dict.""" + if response_format is None: + return None + if isinstance(response_format, type): + return response_format.model_json_schema() + return response_format + class Peer(PeerBase, MetadataConfigMixin): """ @@ -221,6 +234,30 @@ class Peer(PeerBase, MetadataConfigMixin): self._configuration = configuration # pyright: ignore[reportIncompatibleVariableOverride] self._created_at = created_at + @overload + def chat( + self, + query: str, + *, + target: str | PeerBase | None = None, + session: str | SessionBase | None = None, + reasoning_level: Literal["minimal", "low", "medium", "high", "max"] + | None = None, + response_format: type[TResponseFormat], + ) -> TResponseFormat | None: ... + + @overload + def chat( + self, + query: str, + *, + target: str | PeerBase | None = None, + session: str | SessionBase | None = None, + reasoning_level: Literal["minimal", "low", "medium", "high", "max"] + | None = None, + response_format: dict[str, Any] | None = None, + ) -> str | None: ... + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def chat( self, @@ -230,7 +267,8 @@ class Peer(PeerBase, MetadataConfigMixin): session: str | SessionBase | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, - ) -> str | None: + response_format: type[BaseModel] | dict[str, Any] | None = None, + ) -> BaseModel | str | None: """ Query the peer's representation with a natural language question. @@ -249,9 +287,15 @@ class Peer(PeerBase, MetadataConfigMixin): ID string or a Session object. reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium", "high", or "max". Defaults to "low" if not provided. + response_format: Optional structure for the answer. Pass a Pydantic + model class to get a parsed instance back, or a raw + JSON Schema dict (root type "object") to get the + answer as a JSON string. Returns: - Response string containing the answer, or None if no relevant information + Response string containing the answer (a JSON string when a schema + dict was given), a parsed model instance when a Pydantic model class + was given, or None if no relevant information. """ self._honcho._ensure_workspace() target_id = resolve_id(target) @@ -264,6 +308,9 @@ class Peer(PeerBase, MetadataConfigMixin): body["session_id"] = resolved_session_id if reasoning_level: body["reasoning_level"] = reasoning_level + response_format_schema = serialize_response_format(response_format) + if response_format_schema is not None: + body["response_format"] = response_format_schema data = self._honcho._http.post( routes.peer_chat(self.workspace_id, self.id), @@ -272,6 +319,8 @@ class Peer(PeerBase, MetadataConfigMixin): content = data.get("content") if not content: return None + if isinstance(response_format, type): + return response_format.model_validate_json(content) return content @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) @@ -283,6 +332,7 @@ class Peer(PeerBase, MetadataConfigMixin): session: str | SessionBase | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, ) -> DialecticStreamResponse: """ Query the peer's representation with a natural language question, streaming the response. @@ -302,6 +352,11 @@ class Peer(PeerBase, MetadataConfigMixin): ID string or a Session object. reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium", "high", or "max". Defaults to "low" if not provided. + response_format: Optional structure for the answer: a Pydantic model + class or a JSON Schema dict (root type "object"). + Streamed chunks stay raw text that accumulates to a + JSON string; parse it yourself (e.g. with + Model.model_validate_json) once the stream completes. Returns: DialecticStreamResponse object that can be iterated over and provides final response @@ -317,6 +372,9 @@ class Peer(PeerBase, MetadataConfigMixin): body["session_id"] = resolved_session_id if reasoning_level: body["reasoning_level"] = reasoning_level + response_format_schema = serialize_response_format(response_format) + if response_format_schema is not None: + body["response_format"] = response_format_schema def stream_response() -> Generator[str, None, None]: yield from parse_sse_stream( diff --git a/sdks/typescript/__tests__/peer.test.ts b/sdks/typescript/__tests__/peer.test.ts index d8a3750c..fca85910 100644 --- a/sdks/typescript/__tests__/peer.test.ts +++ b/sdks/typescript/__tests__/peer.test.ts @@ -16,6 +16,7 @@ */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { z } from 'zod' import { Honcho, Peer } from '../src' import { createTestClient, generateId, requireServer } from './setup' import { @@ -624,6 +625,44 @@ describe('Peer', () => { expect(response === null || typeof response === 'string').toBe(true) }) + test('chat with responseFormat as JSON schema object returns JSON string', async () => { + const peer = await client.peer('chat-rf-peer') + + const response = await peer.chat('What do you know?', { + responseFormat: { + type: 'object', + properties: { summary: { type: 'string' } }, + }, + }) + + expect(response === null || typeof response === 'string').toBe(true) + if (response !== null) { + expect(() => JSON.parse(response)).not.toThrow() + } + }) + + test('chat with responseFormat as Zod schema returns parsed object', async () => { + const peer = await client.peer('chat-rf-zod-peer') + const ResultSchema = z.object({ summary: z.string().optional() }) + + const response = await peer.chat('What do you know?', { + responseFormat: ResultSchema, + }) + + expect(response === null || typeof response === 'object').toBe(true) + }) + + test('chat with unsupported responseFormat is rejected by the server', async () => { + const peer = await client.peer('chat-rf-invalid-peer') + + // Non-object root is rejected with 422 + await expect( + peer.chat('What do you know?', { + responseFormat: { type: 'string' }, + }) + ).rejects.toThrow() + }) + // Streaming tests are in streaming.test.ts }) diff --git a/sdks/typescript/__tests__/validation.test.ts b/sdks/typescript/__tests__/validation.test.ts index 42d03cf7..97d71f71 100644 --- a/sdks/typescript/__tests__/validation.test.ts +++ b/sdks/typescript/__tests__/validation.test.ts @@ -5,7 +5,7 @@ */ import { describe, test, expect } from 'bun:test' -import { ZodError } from 'zod' +import { z, ZodError } from 'zod' import { ChatQuerySchema, ContextParamsSchema, @@ -67,6 +67,24 @@ describe('ChatQuerySchema', () => { } ) + test('responseFormat as plain JSON schema object is valid', () => { + const schema = { type: 'object', properties: { a: { type: 'string' } } } + const result = ChatQuerySchema.parse({ query: 'hello', responseFormat: schema }) + expect(result.responseFormat).toEqual(schema) + }) + + test('responseFormat as Zod schema instance is valid and passed through', () => { + const schema = z.object({ a: z.string() }) + const result = ChatQuerySchema.parse({ query: 'hello', responseFormat: schema }) + expect(result.responseFormat).toBe(schema) + }) + + test('responseFormat as a non-object throws', () => { + expect(() => + ChatQuerySchema.parse({ query: 'hello', responseFormat: 'not-a-schema' }) + ).toThrow(ZodError) + }) + // --- Missing required fields --- test('missing query throws', () => { diff --git a/sdks/typescript/src/peer.ts b/sdks/typescript/src/peer.ts index b4c1d756..adb67cb2 100644 --- a/sdks/typescript/src/peer.ts +++ b/sdks/typescript/src/peer.ts @@ -1,3 +1,4 @@ +import { ZodType, z } from 'zod' import { API_VERSION } from './api-version' import { ConclusionScope } from './conclusions' import type { HonchoHTTPClient } from './http/client' @@ -229,12 +230,29 @@ export class Peer { ) } + /** + * Convert a responseFormat option (Zod schema or raw JSON Schema object) + * to the JSON Schema dict the API expects. + */ + private static toResponseFormatSchema( + responseFormat: ZodType | Record | undefined + ): Record | undefined { + if (!responseFormat) { + return undefined + } + if (responseFormat instanceof ZodType) { + return z.toJSONSchema(responseFormat) as Record + } + return responseFormat + } + private async _chat(params: { query: string stream?: boolean target?: string session_id?: string reasoning_level?: string + response_format?: Record }): Promise { await this._ensureWorkspace() return this._http.post( @@ -248,6 +266,7 @@ export class Peer { target?: string session_id?: string reasoning_level?: string + response_format?: Record }): Promise { await this._ensureWorkspace() return this._http.stream( @@ -362,14 +381,33 @@ export class Peer { * }) * ``` */ + async chat( + query: string, + options: { + target?: string | Peer + session?: string | Session + reasoningLevel?: string + responseFormat: ZodType + } + ): Promise async chat( query: string, options?: { target?: string | Peer session?: string | Session reasoningLevel?: string + responseFormat?: Record } - ): Promise { + ): Promise + async chat( + query: string, + options?: { + target?: string | Peer + session?: string | Session + reasoningLevel?: string + responseFormat?: ZodType | Record + } + ): Promise { const targetId = options?.target ? typeof options.target === 'string' ? options.target @@ -386,18 +424,28 @@ export class Peer { target: targetId, session: resolvedSessionId, reasoningLevel: options?.reasoningLevel, + responseFormat: options?.responseFormat, }) + const zodSchema = + options?.responseFormat instanceof ZodType + ? options.responseFormat + : undefined + const response = await this._chat({ query: chatParams.query, stream: false, target: chatParams.target, session_id: chatParams.session, reasoning_level: chatParams.reasoningLevel, + response_format: Peer.toResponseFormatSchema(options?.responseFormat), }) if (!response.content) { return null } + if (zodSchema) { + return zodSchema.parse(JSON.parse(response.content)) + } return response.content } @@ -442,6 +490,7 @@ export class Peer { target?: string | Peer session?: string | Session reasoningLevel?: string + responseFormat?: ZodType | Record } ): Promise { const targetId = options?.target @@ -460,6 +509,7 @@ export class Peer { target: targetId, session: resolvedSessionId, reasoningLevel: options?.reasoningLevel, + responseFormat: options?.responseFormat, }) const response = await this._chatStream({ @@ -467,6 +517,7 @@ export class Peer { target: chatParams.target, session_id: chatParams.session, reasoning_level: chatParams.reasoningLevel, + response_format: Peer.toResponseFormatSchema(options?.responseFormat), }) return createDialecticStream(response) diff --git a/sdks/typescript/src/types/api.ts b/sdks/typescript/src/types/api.ts index dda25ad1..dbe4378d 100644 --- a/sdks/typescript/src/types/api.ts +++ b/sdks/typescript/src/types/api.ts @@ -74,6 +74,7 @@ export interface PeerChatParams { session_id?: string target?: string reasoning_level?: 'minimal' | 'low' | 'medium' | 'high' | 'max' + response_format?: Record } export interface PeerChatResponse { diff --git a/sdks/typescript/src/validation.ts b/sdks/typescript/src/validation.ts index fed1b10b..48255833 100644 --- a/sdks/typescript/src/validation.ts +++ b/sdks/typescript/src/validation.ts @@ -312,6 +312,11 @@ export const ChatQuerySchema = z reasoningLevel: z .enum(['minimal', 'low', 'medium', 'high', 'max']) .optional(), + // A Zod schema (checked first — it is itself an object) or a raw JSON + // Schema object describing the desired response structure. + responseFormat: z + .union([z.instanceof(z.ZodType), z.record(z.string(), z.unknown())]) + .optional(), }) .strict() diff --git a/src/dialectic/chat.py b/src/dialectic/chat.py index 9c118803..40d991b4 100644 --- a/src/dialectic/chat.py +++ b/src/dialectic/chat.py @@ -8,6 +8,8 @@ using the DialecticAgent. import logging from collections.abc import AsyncIterator +from pydantic import BaseModel + from src import crud, schemas from src.config import ReasoningLevel from src.dependencies import tracked_db @@ -24,6 +26,7 @@ async def agentic_chat( observer: str, observed: str, reasoning_level: ReasoningLevel = "low", + response_model: type[BaseModel] | None = None, ) -> str: """ Answer a query about a peer using the agentic dialectic. @@ -35,6 +38,8 @@ async def agentic_chat( observer: The peer making the query observed: The peer being queried about reasoning_level: Level of reasoning to apply + response_model: Optional Pydantic model the answer must conform to. + When set, the returned string is JSON matching the model's schema. Returns: The synthesized answer string @@ -79,7 +84,7 @@ async def agentic_chat( reasoning_level=reasoning_level, ) - return await agent.answer(query) + return await agent.answer(query, response_model=response_model) async def agentic_chat_stream( @@ -89,6 +94,7 @@ async def agentic_chat_stream( observer: str, observed: str, reasoning_level: ReasoningLevel = "low", + response_model: type[BaseModel] | None = None, ) -> AsyncIterator[str]: """ Stream an answer to a query about a peer using the agentic dialectic. @@ -100,6 +106,9 @@ async def agentic_chat_stream( observer: The peer making the query observed: The peer being queried about reasoning_level: Level of reasoning to apply + response_model: Optional Pydantic model the answer must conform to. + When set, the streamed text accumulates to JSON matching the + model's schema. Yields: Chunks of the response text as they are generated @@ -144,5 +153,5 @@ async def agentic_chat_stream( reasoning_level=reasoning_level, ) - async for chunk in agent.answer_stream(query): + async for chunk in agent.answer_stream(query, response_model=response_model): yield chunk diff --git a/src/dialectic/core.py b/src/dialectic/core.py index 6f5b17ab..757423e0 100644 --- a/src/dialectic/core.py +++ b/src/dialectic/core.py @@ -11,6 +11,7 @@ from collections.abc import AsyncIterator, Callable from typing import Any, cast from nanoid import generate as generate_nanoid +from pydantic import BaseModel from src import crud from src.config import ConfiguredModelSettings, ReasoningLevel, settings @@ -413,7 +414,9 @@ class DialecticAgent: ) ) - async def answer(self, query: str) -> str: + async def answer( + self, query: str, response_model: type[BaseModel] | None = None + ) -> str: """ Answer a query about the peer using agentic tool calling. @@ -424,6 +427,8 @@ class DialecticAgent: Args: query: The question to answer about the peer + response_model: Optional Pydantic model the final synthesis must + conform to. When set, the returned string is JSON. Returns: The synthesized answer string @@ -446,25 +451,38 @@ class DialecticAgent: else settings.DIALECTIC.MAX_OUTPUT_TOKENS ) - response: HonchoLLMCallResponse[str] = await honcho_llm_call( - model_config=_get_dialectic_level_model_config(self.reasoning_level), - prompt="", # Ignored since we pass messages - max_tokens=max_tokens, - tools=tools, - tool_choice=level_settings.TOOL_CHOICE, - tool_executor=tool_executor, - max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS, - messages=self.messages, - max_input_tokens=settings.DIALECTIC.MAX_INPUT_TOKENS, - trace_name="dialectic_chat", - telemetry=self._telemetry_context(track_name="Dialectic Agent"), + # cast: `type[BaseModel] | None` matches neither the parsed nor the + # plain-text overload statically, so pyright resolves the stream + # overload — but without stream=True the call is non-streaming. + response = cast( # pyright: ignore[reportInvalidCast] + HonchoLLMCallResponse[Any], + await honcho_llm_call( + model_config=_get_dialectic_level_model_config(self.reasoning_level), + prompt="", # Ignored since we pass messages + max_tokens=max_tokens, + tools=tools, + tool_choice=level_settings.TOOL_CHOICE, + tool_executor=tool_executor, + max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS, + messages=self.messages, + max_input_tokens=settings.DIALECTIC.MAX_INPUT_TOKENS, + trace_name="dialectic_chat", + telemetry=self._telemetry_context(track_name="Dialectic Agent"), + response_model=response_model, + ), ) + # With response_model, the backend parses content into a model + # instance; the API contract is a JSON string. + content = response.content + if isinstance(content, BaseModel): + content = content.model_dump_json(by_alias=True) + self._log_response_metrics( task_name=task_name, run_id=run_id, start_time=start_time, - response_content=response.content, + response_content=content, input_tokens=response.input_tokens, output_tokens=response.output_tokens, cache_read_input_tokens=response.cache_read_input_tokens, @@ -475,9 +493,11 @@ class DialecticAgent: hit_input_token_cap=response.hit_input_token_cap, ) - return response.content + return content - async def answer_stream(self, query: str) -> AsyncIterator[str]: + async def answer_stream( + self, query: str, response_model: type[BaseModel] | None = None + ) -> AsyncIterator[str]: """ Answer a query about the peer using agentic tool calling, streaming the response. @@ -488,6 +508,9 @@ class DialecticAgent: Args: query: The question to answer about the peer + response_model: Optional Pydantic model the final synthesis must + conform to. When set, the streamed text accumulates to JSON + (chunks are raw text; no parsing happens on the stream path). Yields: Chunks of the response text as they are generated @@ -526,6 +549,7 @@ class DialecticAgent: max_input_tokens=settings.DIALECTIC.MAX_INPUT_TOKENS, trace_name="dialectic_chat", telemetry=self._telemetry_context(track_name="Dialectic Agent Stream"), + response_model=response_model, ), ) diff --git a/src/llm/backends/openai.py b/src/llm/backends/openai.py index 6d13f1fc..d5d0ed73 100644 --- a/src/llm/backends/openai.py +++ b/src/llm/backends/openai.py @@ -2,8 +2,8 @@ from __future__ import annotations import json import logging +import weakref from collections.abc import AsyncIterator -from functools import cache from typing import Any, cast from openai import BadRequestError, LengthFinishReasonError @@ -22,22 +22,35 @@ from src.llm.structured_output import ( logger = logging.getLogger(__name__) -@cache +# The point of this being a WeakKeyDictionary, as opposed to a regular dict, is that it +# does not hold a reference to the keyed BaseModel so that when a dynamically created +# type is no longer referenced it becomes eligible for garbage collection. This avoids a +# memory leak. +_json_object_instruction_cache: weakref.WeakKeyDictionary[type[BaseModel], str] = ( + weakref.WeakKeyDictionary() +) + + def _json_object_instruction(response_format: type[BaseModel]) -> str: """Schema-injection instruction for json_object mode. The JSON schema is static per response_format class, so cache the serialized - instruction — the deriver issues one structured call per batch on the worker - hot path and would otherwise re-walk the schema + re-serialize it every call. + instruction — the deriver would otherwise re-walk the schema + re-serialize + it every call. """ + cached = _json_object_instruction_cache.get(response_format) + if cached is not None: + return cached # Some OpenAI-compatible providers enforce this JSON-object precondition with # a case-sensitive substring check, so include lowercase "json" explicitly. - return ( + instruction = ( "You must respond with a single JSON object (json) that conforms " "exactly to the following JSON schema. Do not include any text, " "markdown, or code fences outside the JSON object.\n\nJSON schema:\n" f"{json.dumps(response_format.model_json_schema())}" ) + _json_object_instruction_cache[response_format] = instruction + return instruction def _uses_max_completion_tokens(model: str) -> bool: diff --git a/src/routers/peers.py b/src/routers/peers.py index 90cdc1a5..b61bf442 100644 --- a/src/routers/peers.py +++ b/src/routers/peers.py @@ -10,6 +10,7 @@ from fastapi import APIRouter, Body, Depends, Path, Query, Response from fastapi.responses import StreamingResponse from fastapi_pagination import Page from fastapi_pagination.ext.sqlalchemy import apaginate +from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from src import crud, schemas @@ -18,10 +19,15 @@ from src.crud.session import is_peer_in_session from src.dependencies import db, read_db, tracked_db from src.dialectic.chat import agentic_chat, agentic_chat_stream from src.embedding_client import embedding_client -from src.exceptions import AuthenticationException, ResourceNotFoundException +from src.exceptions import ( + AuthenticationException, + ResourceNotFoundException, + ValidationException, +) from src.security import JWTParams, require_auth from src.telemetry import prometheus_metrics from src.telemetry.events import EmbeddingCallPurpose, GetContextEvent, emit +from src.utils.schema_conversion import json_response_schema_to_pydantic from src.utils.search import search from src.utils.types import embedding_call_purpose @@ -193,6 +199,14 @@ async def chat( ): raise AuthenticationException("JWT not permissioned for this resource") + # Convert the caller's JSON Schema so malformed schemas fail immediately with 422 + response_model: type[BaseModel] | None = None + if options.response_format is not None: + try: + response_model = json_response_schema_to_pydantic(options.response_format) + except ValueError as e: + raise ValidationException(f"Invalid response_format: {e}") from None + # Get or create the peer to ensure it exists async with tracked_db("peers.chat.get_or_create_peer") as peer_db: peers_result = await crud.get_or_create_peers( @@ -230,6 +244,7 @@ async def chat( observer=peer_id, observed=options.target if options.target is not None else peer_id, reasoning_level=options.reasoning_level, + response_model=response_model, ) ), media_type="text/event-stream", @@ -244,6 +259,7 @@ async def chat( # and it's answered from the omniscient Honcho perspective observed=options.target if options.target is not None else peer_id, reasoning_level=options.reasoning_level, + response_model=response_model, ) # Prometheus metrics diff --git a/src/schemas/api.py b/src/schemas/api.py index a276c4b7..ef3ebbc1 100644 --- a/src/schemas/api.py +++ b/src/schemas/api.py @@ -574,6 +574,16 @@ class DialecticOptions(BaseModel): default="low", description="Level of reasoning to apply: minimal, low, medium, high, or max", ) + response_format: dict[str, Any] | None = Field( + None, + description=( + "Optional JSON Schema (root type 'object') the response must conform" + " to. When provided, `content` is a JSON string matching this schema." + " Only a conservative subset of JSON Schema is supported; unsupported" + " schemas are rejected with 422. Constraint keywords (minItems, " + " maxLength, ...) are hints to the model, not enforced server-side." + ), + ) @field_validator("query", mode="after") @classmethod diff --git a/src/utils/schema_conversion.py b/src/utils/schema_conversion.py new file mode 100644 index 00000000..3e715f4b --- /dev/null +++ b/src/utils/schema_conversion.py @@ -0,0 +1,505 @@ +"""Convert caller-supplied JSON Schema objects into dynamic Pydantic models. + +Used by the dialectic chat endpoint's ``response_format`` option: the caller +sends a JSON Schema dict, and the resulting model is passed as +``response_model`` to ``honcho_llm_call()`` so providers return conforming +JSON. + +Only a conservative subset of JSON Schema is supported (see +``json_response_schema_to_pydantic``). Conversion doubles as validation: any +unsupported construct raises ``ValueError`` with the offending path, which the +router surfaces as a 422. +""" + +import re +from dataclasses import dataclass, field +from typing import Any, Literal, NoReturn, cast + +from pydantic import BaseModel, ConfigDict, Field, create_model + +# "$defs"/"definitions" are extracted at the root before the walk and are +# rejected anywhere else. "$ref" nodes are resolved by _resolve_ref before +# node validation, so they never reach this check. +_UNSUPPORTED_KEYS = ( + "$defs", + "definitions", + "allOf", + "not", + "if", + "then", + "else", + "patternProperties", +) + +_REF_PREFIXES = ("#/$defs/", "#/definitions/") + +_PRIMITIVE_TYPES: dict[str, Any] = { + "string": str, + "number": float, + "integer": int, + "boolean": bool, + "null": type(None), +} + +# Constraint keywords are forwarded to the model's json_schema_extra so the +# LLM sees them, but Pydantic does not enforce them (they are hints only). +_HINT_KEYS = ( + "minItems", + "maxItems", + "minLength", + "maxLength", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "pattern", + "format", + "minProperties", + "maxProperties", + "uniqueItems", +) + +_IDENTIFIER_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$") + + +@dataclass +class _Ctx: + """Mutable state shared across one conversion walk.""" + + max_depth: int + max_nodes: int + defs: dict[str, Any] = field(default_factory=dict) + used_names: set[str] = field(default_factory=set) + ref_stack: list[str] = field(default_factory=list) + node_count: int = 0 + + +def json_response_schema_to_pydantic( + schema: dict[str, Any], + *, + model_name: str = "ResponseFormat", + max_depth: int = 20, + max_nodes: int = 500, +) -> type[BaseModel]: + """Convert a JSON Schema dict (root type ``object``) into a Pydantic model. + + Supported constructs: primitive types (``string``/``number``/``integer``/ + ``boolean``/``null``), nested ``object`` with ``properties``, ``array`` + with ``items`` (missing ``items`` yields ``list[Any]``), ``enum`` of + strings/integers/booleans/null, ``anyOf``/``oneOf`` unions (a + ``{"type": "null"}`` member yields an optional), ``type`` given as a list, + ``required``, ``default``, and ``description``. A root-level ``$schema`` + key is ignored. Boolean ``additionalProperties`` is accepted and ignored; + extra keys in LLM output are silently dropped (``extra="ignore"``). + + ``$ref`` is supported for references of the form ``#/$defs/`` or + ``#/definitions/`` into root-level ``$defs``/``definitions`` (this + is what Pydantic's ``model_json_schema()`` and Zod's ``toJSONSchema`` + emit). References are resolved by inlining; sibling keys next to ``$ref`` + overlay the referenced definition (siblings win). Recursive references + are rejected — the error names the cycle. Unreferenced definitions are + ignored without validation. + + Constraint keywords (``minItems``, ``maxLength``, ``minimum``, + ``pattern``, ...) are passed through to the generated schema as hints but + are not enforced by Pydantic. + + Args: + schema: The JSON Schema object. Root must resolve to type ``object``. + model_name: Name for the generated root model class. + max_depth: Maximum nesting depth. Guard against excessive or + malicious schemas (e.g. pathologically deep nesting); exceeding + it raises ``ValueError``. + max_nodes: Maximum total nodes visited across the whole schema. + Guard against excessive or malicious schemas (e.g. enormous + property fan-out); exceeding it raises ``ValueError``. + + Returns: + A dynamically created Pydantic model class. + + Raises: + ValueError: If the schema is malformed or uses an unsupported + construct (``allOf``, ``not``, ``if``/``then``/``else``, + ``patternProperties``, schema-valued ``additionalProperties``, + boolean schemas, unknown types, a non-object root, a ``$ref`` + that is recursive, malformed, or targets an unknown definition, + or ``$defs``/``definitions`` anywhere but the root). The message + names the construct and its path. + """ + schema_obj: Any = schema + if not isinstance(schema_obj, dict): + raise ValueError("response_format must be a JSON Schema object") + + root = {k: v for k, v in schema.items() if k != "$schema"} + defs = _extract_defs(root) + root_type = root.get("type") + # A "$ref" root is allowed through here; the post-conversion check below + # still enforces that it resolves to an object. + is_object_root = root_type == "object" or ( + root_type is None and ("properties" in root or "$ref" in root) + ) + if not is_object_root: + raise ValueError("root schema must have type 'object'") + + ctx = _Ctx(max_depth=max_depth, max_nodes=max_nodes, defs=defs) + annotation = _convert_schema(root, "", model_name, ctx, depth=0) + # An object root always converts to a model class; this is a safety net. + if not (isinstance(annotation, type) and issubclass(annotation, BaseModel)): + raise ValueError("root schema must have type 'object'") + return annotation + + +def _fail(msg: str, path: str) -> NoReturn: + raise ValueError(f"{msg} at {path or 'root'}") + + +def _union(members: tuple[Any, ...]) -> Any: + """Build ``A | B | ...`` from a dynamic tuple of annotations.""" + result: Any = members[0] + for member in members[1:]: + result = result | member + return result + + +def _convert_schema( + raw_node: Any, path: str, name_hint: str, ctx: _Ctx, depth: int +) -> Any: + """Convert one schema node into a type annotation. + + Dispatch order matters: $ref resolution comes first (a ref node is + replaced by its target before anything else looks at it), then enum (a + value constraint) wins over unions, which win over "type"-based + conversion. + """ + if isinstance(raw_node, dict) and "$ref" in raw_node: + return _resolve_ref(cast(dict[str, Any], raw_node), path, ctx, depth) + + node = _validate_node(raw_node, path, ctx, depth) + + if "enum" in node: + return _convert_enum(node["enum"], path) + + if "anyOf" in node or "oneOf" in node: + return _convert_union(node, path, name_hint, ctx, depth) + + node_type = node.get("type") + if isinstance(node_type, list): + return _convert_type_list( + node, cast(list[Any], node_type), path, name_hint, ctx, depth + ) + + # Tolerate an omitted "type" when "properties" makes the intent clear. + if node_type is None and "properties" in node: + node_type = "object" + + if node_type == "object": + return _build_object_model(node, path, name_hint, ctx, depth) + + if node_type == "array": + return _convert_array(node, path, name_hint, ctx, depth) + + if node_type in _PRIMITIVE_TYPES: + return _PRIMITIVE_TYPES[node_type] + + if node_type is None: + _fail("schema has no recognizable type", path) + _fail(f"unsupported type '{node_type}'", path) + + +def _extract_defs(root: dict[str, Any]) -> dict[str, Any]: + """Pop root-level ``$defs``/``definitions`` and merge them into one + registry. Entries are validated lazily, when (and only when) referenced.""" + defs: dict[str, Any] = {} + for key in ("$defs", "definitions"): + raw = root.pop(key, None) + if raw is None: + continue + if not isinstance(raw, dict): + _fail(f"'{key}' must be an object", "") + for name, definition in cast(dict[Any, Any], raw).items(): + if not isinstance(name, str) or not name: + _fail(f"'{key}' definition names must be non-empty strings", "") + if name in defs: + _fail( + f"definition '{name}' appears in both '$defs' and 'definitions'", + "", + ) + defs[name] = definition + return defs + + +def _resolve_ref(node: dict[str, Any], path: str, ctx: _Ctx, depth: int) -> Any: + """Inline a ``$ref`` node: resolve the target definition, overlay any + sibling keys (siblings win), and convert the result in place. + + Only root-relative refs into ``$defs``/``definitions`` are supported. + Cycles are rejected — recursion cannot be inlined. The resolved node is + converted at the same depth (replacement semantics); the node budget in + ``_validate_node`` still counts every expansion, so a definition that is + referenced many times cannot blow up the walk. + """ + ref: Any = node["$ref"] + if not isinstance(ref, str): + _fail("'$ref' must be a string", path) + name: str | None = None + for prefix in _REF_PREFIXES: + if ref.startswith(prefix): + name = ref[len(prefix) :] + break + # "/", "~", and "%" would make the remainder a deeper or escaped JSON + # pointer (e.g. "#/$defs/a/b", "~1" escapes, %-encoding) rather than a + # plain definition name, so their presence means an unsupported form. + if not name or "/" in name or "~" in name or "%" in name: + _fail( + f"unsupported $ref '{ref}': only '#/$defs/' or " + + "'#/definitions/' references are supported", + path, + ) + if name not in ctx.defs: + _fail(f"$ref '{ref}' points to an unknown definition", path) + # ref_stack holds the definitions currently being expanded on this branch + # of the walk, so membership means the definition (transitively) + # references itself. Slicing from the first occurrence yields the cycle + # for the error message, e.g. stack [A, B] + name A -> "A -> B -> A". + if name in ctx.ref_stack: + cycle = " -> ".join([*ctx.ref_stack[ctx.ref_stack.index(name) :], name]) + _fail( + f"recursive $ref is not supported (cycle: {cycle}); " + + "restructure the schema so definitions do not reference themselves", + path, + ) + target: Any = ctx.defs[name] + siblings = {k: v for k, v in node.items() if k != "$ref"} + resolved: Any = target + if siblings and isinstance(target, dict): + resolved = {**cast(dict[str, Any], target), **siblings} + # Pop after converting (not just on success) so the stack tracks only the + # current branch: a diamond — the same definition referenced from two + # sibling nodes — is legitimate reuse, not a cycle. + ctx.ref_stack.append(name) + try: + return _convert_schema(resolved, path, name, ctx, depth) + finally: + ctx.ref_stack.pop() + + +def _validate_node(raw_node: Any, path: str, ctx: _Ctx, depth: int) -> dict[str, Any]: + """Enforce size budgets and node shape; reject unsupported constructs.""" + # node_count is cumulative across the whole walk; depth tracks only the + # current branch. + ctx.node_count += 1 + if ctx.node_count > ctx.max_nodes: + raise ValueError(f"schema exceeds the maximum of {ctx.max_nodes} nodes") + if depth > ctx.max_depth: + raise ValueError(f"schema nesting exceeds the maximum depth of {ctx.max_depth}") + if isinstance(raw_node, bool): + # A special case of the object requirement, with its own message: + # boolean schemas are legal JSON Schema, just deliberately unsupported. + _fail("boolean schemas are not supported", path) + if not isinstance(raw_node, dict): + _fail("schema must be an object", path) + node = cast(dict[str, Any], raw_node) + for key in _UNSUPPORTED_KEYS: + if key in node: + _fail(f"unsupported construct '{key}'", path) + if isinstance(node.get("additionalProperties"), dict): + _fail("additionalProperties with a schema is not supported", path) + return node + + +def _convert_union( + node: dict[str, Any], path: str, name_hint: str, ctx: _Ctx, depth: int +) -> Any: + """Convert anyOf/oneOf, treated identically: a plain union of the member + schemas (a {"type": "null"} member makes the union optional).""" + union_key = "anyOf" if "anyOf" in node else "oneOf" + members = node[union_key] + if not isinstance(members, list) or not members: + _fail(f"'{union_key}' must be a non-empty array", path) + converted = tuple( + _convert_schema( + member, + _child_path(path, f"{union_key}[{i}]"), + f"{name_hint}Option{i}", + ctx, + depth + 1, + ) + for i, member in enumerate(cast(list[Any], members)) + ) + return _union(converted) + + +def _convert_type_list( + node: dict[str, Any], + types: list[Any], + path: str, + name_hint: str, + ctx: _Ctx, + depth: int, +) -> Any: + """Convert the type: ["string", "null"] sugar — re-convert the node once + per entry (keeping sibling keys, at the same depth since it's the same + source node) and union the results.""" + if not types: + _fail("'type' array must not be empty", path) + variants = tuple( + _convert_schema( + {**{k: v for k, v in node.items() if k != "type"}, "type": t}, + path, + name_hint, + ctx, + depth, + ) + for t in types + ) + return _union(variants) + + +def _convert_array( + node: dict[str, Any], path: str, name_hint: str, ctx: _Ctx, depth: int +) -> Any: + """Convert an array schema into a list annotation.""" + items = node.get("items") + # A missing "items" constraint means any element type is allowed. + if items is None: + return list[Any] + item_annotation = _convert_schema( + items, _child_path(path, "items"), f"{name_hint}Item", ctx, depth + 1 + ) + return list[item_annotation] + + +def _convert_enum(raw_values: Any, path: str) -> Any: + if not isinstance(raw_values, list) or not raw_values: + _fail("'enum' must be a non-empty array", path) + literal_values: list[Any] = [] + # None is not Literal-legal, so collect it separately and union NoneType + # back in at the end (an all-null enum degenerates to NoneType). + has_null = False + for value in cast(list[Any], raw_values): + if value is None: + has_null = True + elif isinstance(value, str | int | bool): + literal_values.append(value) + else: + _fail("enum values must be strings, integers, booleans, or null", path) + if not literal_values: + return type(None) + annotation: Any = Literal[tuple(literal_values)] + return _union((annotation, type(None))) if has_null else annotation + + +def _build_object_model( + node: dict[str, Any], path: str, name_hint: str, ctx: _Ctx, depth: int +) -> type[BaseModel]: + raw_properties: Any = node.get("properties", {}) + if not isinstance(raw_properties, dict): + _fail("'properties' must be an object", path) + properties = cast(dict[Any, Any], raw_properties) + raw_required: Any = node.get("required", []) + if not isinstance(raw_required, list) or not all( + isinstance(entry, str) for entry in cast(list[Any], raw_required) + ): + _fail("'required' must be an array of strings", path) + # Entries naming properties that don't exist are tolerated; they just + # have no effect. + required = set(cast(list[str], raw_required)) + + fields: dict[str, tuple[Any, Any]] = {} + for prop_key, prop_schema in properties.items(): + if not isinstance(prop_key, str) or not prop_key: + _fail("property names must be non-empty strings", path) + annotation = _convert_schema( + prop_schema, + _child_path(path, f"properties.{prop_key}"), + f"{name_hint} {prop_key}", + ctx, + depth + 1, + ) + # Non-identifier keys ("my-key") get a sanitized field name, with the + # original key preserved as the alias for validation/serialization. + field_name = _field_name(prop_key, fields) + alias = prop_key if field_name != prop_key else None + fields[field_name] = _make_field( + cast(dict[str, Any], prop_schema) if isinstance(prop_schema, dict) else {}, + annotation, + is_required=prop_key in required, + alias=alias, + ) + + # create_model's overloads can't type dynamic **fields; the values are + # (annotation, FieldInfo) tuples, which is the documented calling form. + model: Any = create_model( # pyright: ignore[reportCallIssue, reportUnknownVariableType] + _unique_model_name(name_hint, ctx), + __config__=ConfigDict(extra="ignore", populate_by_name=True), + **fields, # pyright: ignore[reportArgumentType] + ) + return cast(type[BaseModel], model) + + +def _make_field( + prop_schema: dict[str, Any], + annotation: Any, + *, + is_required: bool, + alias: str | None, +) -> tuple[Any, Any]: + kwargs: dict[str, Any] = {} + if alias is not None: + kwargs["alias"] = alias + description = prop_schema.get("description") + if isinstance(description, str): + kwargs["description"] = description + hints = {key: prop_schema[key] for key in _HINT_KEYS if key in prop_schema} + if hints: + kwargs["json_schema_extra"] = hints + + # Precedence: an explicit default wins (even for required fields), then + # required, then optional — which widens to `T | None` defaulting to None. + if "default" in prop_schema: + return annotation, Field(default=prop_schema["default"], **kwargs) + if is_required: + return annotation, Field(**kwargs) + return annotation | None, Field(default=None, **kwargs) + + +def _child_path(path: str, segment: str) -> str: + return f"{path}.{segment}" if path else segment + + +def _field_name(prop_key: str, existing: dict[str, Any]) -> str: + """Return a valid, unique Python field name for a JSON property key. + + Keys that aren't valid identifiers (or would be Pydantic-private via a + leading underscore) are sanitized; the original key is preserved as the + field alias by the caller. + """ + if _IDENTIFIER_RE.match(prop_key) and prop_key not in existing: + return prop_key + sanitized = re.sub(r"[^A-Za-z0-9_]", "_", prop_key).lstrip("_") + if not sanitized or sanitized[0].isdigit(): + sanitized = f"field_{sanitized}" + candidate = sanitized + suffix = 2 + while candidate in existing: + candidate = f"{sanitized}_{suffix}" + suffix += 1 + return candidate + + +def _unique_model_name(name_hint: str, ctx: _Ctx) -> str: + # PascalCase the hint (e.g. "ResponseFormat address geo" -> "ResponseFormatAddressGeo"). + parts = re.split(r"[^A-Za-z0-9]+", name_hint) + name = "".join(part[:1].upper() + part[1:] for part in parts if part) + # Class names can't be empty or start with a digit. + if not name or name[0].isdigit(): + name = f"Model{name}" + # Distinct hints can sanitize to the same name; suffix _2, _3, ... to disambiguate. + candidate = name + suffix = 2 + while candidate in ctx.used_names: + candidate = f"{name}_{suffix}" + suffix += 1 + ctx.used_names.add(candidate) + return candidate diff --git a/tests/conftest.py b/tests/conftest.py index af35c999..b3697242 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -672,8 +672,15 @@ def mock_llm_call_functions(request: pytest.FixtureRequest): mock_short_summary.return_value = "Test short summary content" mock_long_summary.return_value = "Test long summary content" - # Mock agentic_chat to return a string (matching actual return type) - mock_agentic_chat.return_value = "Test dialectic response" + # Mock agentic_chat to return a string (matching actual return type). + # With a response_model (structured output) the real function returns + # a JSON string, so mirror that for SDK clients that parse content. + async def _agentic_chat_response(*_args: object, **kwargs: object) -> str: + if kwargs.get("response_model") is not None: + return "{}" + return "Test dialectic response" + + mock_agentic_chat.side_effect = _agentic_chat_response yield { "short_summary": mock_short_summary, diff --git a/tests/dialectic/test_structured_output.py b/tests/dialectic/test_structured_output.py new file mode 100644 index 00000000..10b32da8 --- /dev/null +++ b/tests/dialectic/test_structured_output.py @@ -0,0 +1,126 @@ +"""Tests for response_model threading through the DialecticAgent.""" + +import time +from unittest.mock import AsyncMock, patch + +import pytest +from pydantic import BaseModel + +from src.dialectic.core import DialecticAgent +from src.llm import ( + HonchoLLMCallResponse, + HonchoLLMCallStreamChunk, + StreamingResponseWithMetadata, +) + + +class FoodPreferences(BaseModel): + favorite: str + confidence: float + + +def _make_agent() -> DialecticAgent: + return DialecticAgent( + workspace_name="workspace", + session_name="session", + observer="observer", + observed="observed", + reasoning_level="low", + ) + + +def _patches(mock_llm_call: AsyncMock): + return ( + patch.object( + DialecticAgent, + "_prepare_query", + new=AsyncMock( + return_value=(AsyncMock(), "task", "run", time.perf_counter()) + ), + ), + patch.object(DialecticAgent, "_log_response_metrics"), + patch("src.dialectic.core.honcho_llm_call", new=mock_llm_call), + ) + + +@pytest.mark.asyncio +async def test_answer_passes_response_model_and_serializes() -> None: + """answer() threads response_model to the LLM call and serializes the + parsed model instance back to a JSON string.""" + agent = _make_agent() + parsed = FoodPreferences(favorite="sushi", confidence=0.9) + mock_llm_call = AsyncMock( + return_value=HonchoLLMCallResponse( + content=parsed, + input_tokens=10, + output_tokens=5, + finish_reasons=["stop"], + ) + ) + + p1, p2, p3 = _patches(mock_llm_call) + with p1, p2, p3: + result = await agent.answer("query", response_model=FoodPreferences) + + kwargs = mock_llm_call.await_args.kwargs # pyright: ignore + assert kwargs["response_model"] is FoodPreferences + assert isinstance(result, str) + assert FoodPreferences.model_validate_json(result) == parsed + + +@pytest.mark.asyncio +async def test_answer_without_response_model_returns_plain_text() -> None: + agent = _make_agent() + mock_llm_call = AsyncMock( + return_value=HonchoLLMCallResponse( + content="plain answer", + input_tokens=10, + output_tokens=5, + finish_reasons=["stop"], + ) + ) + + p1, p2, p3 = _patches(mock_llm_call) + with p1, p2, p3: + result = await agent.answer("query") + + assert result == "plain answer" + assert mock_llm_call.await_args.kwargs["response_model"] is None # pyright: ignore + + +@pytest.mark.asyncio +async def test_answer_stream_passes_response_model() -> None: + """answer_stream() threads response_model; chunks stay raw text.""" + agent = _make_agent() + + async def _stream(): + yield HonchoLLMCallStreamChunk(content='{"favorite":"sushi",') + yield HonchoLLMCallStreamChunk(content='"confidence":0.9}') + yield HonchoLLMCallStreamChunk(content="", is_done=True) + + mock_llm_call = AsyncMock( + return_value=StreamingResponseWithMetadata( + _stream(), + tool_calls_made=[], + input_tokens=10, + output_tokens=5, + cache_creation_input_tokens=0, + cache_read_input_tokens=0, + iterations=1, + ) + ) + + p1, p2, p3 = _patches(mock_llm_call) + with p1, p2, p3: + chunks = [ + chunk + async for chunk in agent.answer_stream( + "query", response_model=FoodPreferences + ) + ] + + kwargs = mock_llm_call.await_args.kwargs # pyright: ignore + assert kwargs["response_model"] is FoodPreferences + assert kwargs["stream_final_only"] is True + accumulated = "".join(chunks) + assert FoodPreferences.model_validate_json(accumulated).favorite == "sushi" diff --git a/tests/live_llm/test_live_structured_output_unions.py b/tests/live_llm/test_live_structured_output_unions.py new file mode 100644 index 00000000..bd69f8f1 --- /dev/null +++ b/tests/live_llm/test_live_structured_output_unions.py @@ -0,0 +1,162 @@ +"""Live coverage for union-bearing structured output without tools. + +The dialectic's final synthesis call carries response_format but tools=None +(see src/llm/tool_loop.py), and the model class is created dynamically from a +caller-supplied JSON Schema (src/utils/schema_conversion.py). That call shape +differs from test_live_tools_structured_output.py in one important way: with +no tools attached, the Gemini backend uses its NATIVE response_schema config +instead of injecting a schema instruction — and Gemini's response_schema +historically rejected anyOf. These tests drive a schema that exercises every +union-ish construct the converter supports (anyOf with null, a type list, an +enum, a $defs reference) through that exact call shape on all three +providers. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import BaseModel, ValidationError + +from src.exceptions import LLMError +from src.llm.backend import CompletionResult +from src.llm.request_builder import execute_completion +from src.llm.structured_output import StructuredOutputError +from src.utils.schema_conversion import json_response_schema_to_pydantic + +from .conftest import make_backend, require_provider_key, wrap_async_method +from .model_matrix import LiveModelSpec, get_live_model_specs + +pytestmark = [pytest.mark.live_llm] + +_USER_FACTS_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "favorite_food": {"$ref": "#/$defs/Food"}, + "sentiment": {"enum": ["loves", "likes", "neutral", "dislikes", "hates"]}, + "years_vegetarian": {"type": ["integer", "null"]}, + "salient_fact": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "One short salient fact about the user, or null", + }, + }, + "required": ["favorite_food", "sentiment", "years_vegetarian"], + "$defs": { + "Food": { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + }, +} + +_PROMPT = ( + "The user said: 'I love sushi. I've been vegetarian for 3 years.' " + "Report the user's favorite food, their sentiment toward it, how many " + "years they have been vegetarian, and optionally one salient fact." +) + + +async def run_union_structured_flow(backend: Any, config: Any) -> CompletionResult: + """One no-tools turn that must return a schema-conforming answer. + + A fresh model class is created per call, matching how the dialectic + converts the caller's schema on every request. Retries mirror + test_live_tools_structured_output.py: empty/unparseable candidates are + absorbed by the executor's retry layer in production, but these tests + call the backend directly. + """ + response_model = json_response_schema_to_pydantic( + _USER_FACTS_SCHEMA, model_name="UserFactsReport" + ) + + result: CompletionResult | None = None + last_error: Exception | None = None + for _ in range(3): + try: + result = await execute_completion( + backend, + config, + messages=[{"role": "user", "content": _PROMPT}], + max_tokens=4096, + response_format=response_model, + ) + except (ValidationError, LLMError, StructuredOutputError) as exc: + last_error = exc + continue + break + if result is None: + raise AssertionError( + "union structured turn failed on all attempts" + ) from last_error + + content = result.content + assert isinstance(content, BaseModel), f"expected parsed model, got {content!r}" + # The model class is dynamic, so field access is untyped by construction. + report: Any = content + assert "sushi" in report.favorite_food.name.lower() + assert report.sentiment == "loves" + assert report.years_vegetarian == 3 + assert report.salient_fact is None or isinstance(report.salient_fact, str) + return result + + +@pytest.mark.asyncio +@pytest.mark.requires_anthropic +@pytest.mark.parametrize( + "model_spec", + get_live_model_specs(provider="anthropic", feature="structured_output"), + ids=lambda spec: spec.id, +) +async def test_live_anthropic_union_structured_output( + model_spec: LiveModelSpec, +) -> None: + require_provider_key(model_spec) + backend, config = make_backend(model_spec) + await run_union_structured_flow(backend, config) + + +@pytest.mark.asyncio +@pytest.mark.requires_openai +@pytest.mark.parametrize( + "model_spec", + get_live_model_specs(provider="openai", feature="structured_output"), + ids=lambda spec: spec.id, +) +async def test_live_openai_union_structured_output( + model_spec: LiveModelSpec, +) -> None: + require_provider_key(model_spec) + backend, config = make_backend(model_spec) + await run_union_structured_flow(backend, config) + + +@pytest.mark.asyncio +@pytest.mark.requires_gemini +@pytest.mark.parametrize( + "model_spec", + get_live_model_specs(provider="gemini", feature="structured_output"), + ids=lambda spec: spec.id, +) +async def test_live_gemini_union_structured_output( + model_spec: LiveModelSpec, + monkeypatch: pytest.MonkeyPatch, +) -> None: + require_provider_key(model_spec) + backend, config = make_backend(model_spec) + generate_calls = wrap_async_method( + monkeypatch, + backend._client.aio.models, + "generate_content", + ) + + await run_union_structured_flow(backend, config) + + # The point of this test: with no tools, Gemini must take the NATIVE + # response_schema path (the one that historically rejected anyOf), not + # the prompt-injection workaround used when tools are attached. + assert generate_calls + gen_config = generate_calls[-1]["kwargs"]["config"] + assert "response_schema" in gen_config + assert gen_config["response_mime_type"] == "application/json" diff --git a/tests/llm/test_backends/test_openai.py b/tests/llm/test_backends/test_openai.py index 89572f25..8567e034 100644 --- a/tests/llm/test_backends/test_openai.py +++ b/tests/llm/test_backends/test_openai.py @@ -1,4 +1,6 @@ +import gc import json +import weakref from collections.abc import AsyncIterator from types import SimpleNamespace from typing import Any @@ -10,8 +12,12 @@ from openai import BadRequestError from pydantic import BaseModel from src.exceptions import ValidationException -from src.llm.backends.openai import OpenAIBackend +from src.llm.backends.openai import ( + OpenAIBackend, + _json_object_instruction, # pyright: ignore[reportPrivateUsage] +) from src.utils.representation import PromptRepresentation +from src.utils.schema_conversion import json_response_schema_to_pydantic def _await_kwargs(mock_method: Any) -> dict[str, Any]: @@ -838,6 +844,70 @@ async def test_structured_output_json_object_mode_repairs_markdown() -> None: assert isinstance(result.content, PromptRepresentation) +@pytest.mark.asyncio +async def test_structured_output_json_object_mode_with_dynamic_model() -> None: + """json_object mode composes with a caller-supplied schema converted at + request time (the dialectic's response_format path): the generated class's + schema — unions included — is injected into the prompt and the JSON body + parses back through the class.""" + response_model = json_response_schema_to_pydantic( + { + "type": "object", + "properties": { + "answer": {"type": "string"}, + "years": {"type": ["integer", "null"]}, + }, + "required": ["answer", "years"], + } + ) + client = Mock() + client.chat.completions.parse = AsyncMock() + client.chat.completions.create = AsyncMock( + return_value=_structured_create_return('{"answer": "ok", "years": 3}') + ) + + backend = OpenAIBackend(client) + result = await backend.complete( + model="glm-4.6", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=response_model, + extra_params={"structured_output_mode": "json_object"}, + ) + + assert client.chat.completions.parse.await_count == 0 + call = _await_kwargs(client.chat.completions.create) + assert call["response_format"] == {"type": "json_object"} + system_messages = [m for m in call["messages"] if m["role"] == "system"] + assert system_messages, "expected a system message carrying the schema" + # The dynamic model's schema (union field included) made it into the prompt. + assert "years" in system_messages[0]["content"] + assert "anyOf" in system_messages[0]["content"] + + assert isinstance(result.content, response_model) + # The model class is dynamic, so field access is untyped by construction. + content: Any = result.content + assert content.answer == "ok" + assert content.years == 3 + + +def test_json_object_instruction_does_not_pin_dynamic_models() -> None: + """The instruction cache must hold its model-class keys weakly: the + dialectic creates a fresh response_format class per request (see + src/utils/schema_conversion.py), and a strong-keyed cache would grow by + one pinned class per structured chat call.""" + model = json_response_schema_to_pydantic( + {"type": "object", "properties": {"answer": {"type": "string"}}} + ) + first = _json_object_instruction(model) + assert _json_object_instruction(model) is first # cached while alive + + ref = weakref.ref(model) + del model + gc.collect() + assert ref() is None, "instruction cache must not keep dynamic classes alive" + + @pytest.mark.asyncio async def test_structured_output_json_object_empty_content_returns_empty() -> None: """An empty body with no refusal must produce a graceful empty result, not diff --git a/tests/routes/test_peers.py b/tests/routes/test_peers.py index 847c24e0..f4b54aa3 100644 --- a/tests/routes/test_peers.py +++ b/tests/routes/test_peers.py @@ -4,6 +4,7 @@ from typing import Any import pytest from fastapi.testclient import TestClient from nanoid import generate as generate_nanoid +from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from src import crud, models @@ -1270,3 +1271,114 @@ def test_set_peer_card(client: TestClient, sample_data: tuple[Workspace, Peer]): ) assert response.status_code == 200 assert response.json()["peer_card"] == target_card + + +FOOD_PREFS_SCHEMA = { + "type": "object", + "properties": { + "preferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "food": {"type": "string"}, + "sentiment": {"enum": ["loves", "likes", "dislikes"]}, + }, + "required": ["food", "sentiment"], + }, + }, + "summary": {"type": "string"}, + }, + "required": ["preferences", "summary"], +} + + +def test_chat_with_response_format( + client: TestClient, + sample_data: tuple[Workspace, Peer], + mock_llm_call_functions: dict[str, Any], +): + """A valid response_format converts to a Pydantic model and is passed to + the dialectic as response_model.""" + test_workspace, test_peer = sample_data + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat", + json={ + "query": "What are this user's food preferences?", + "stream": False, + "response_format": FOOD_PREFS_SCHEMA, + }, + ) + assert response.status_code == 200 + assert "content" in response.json() + + kwargs = mock_llm_call_functions["agentic_chat"].await_args.kwargs + response_model = kwargs["response_model"] + assert isinstance(response_model, type) + assert issubclass(response_model, BaseModel) + # The converted model enforces the caller's schema. + instance = response_model.model_validate( + {"preferences": [{"food": "sushi", "sentiment": "loves"}], "summary": "s"} + ) + assert instance.summary == "s" # pyright: ignore + + +def test_chat_with_response_format_streaming( + client: TestClient, + sample_data: tuple[Workspace, Peer], + mock_llm_call_functions: dict[str, Any], +): + test_workspace, test_peer = sample_data + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat", + json={ + "query": "What are this user's food preferences?", + "stream": True, + "response_format": FOOD_PREFS_SCHEMA, + }, + ) + assert response.status_code == 200 + assert "data:" in response.text + + kwargs = mock_llm_call_functions["agentic_chat_stream"].call_args.kwargs + response_model = kwargs["response_model"] + assert isinstance(response_model, type) + assert issubclass(response_model, BaseModel) + + +@pytest.mark.parametrize( + "bad_schema", + [ + {"type": "string"}, # non-object root + {"type": "object", "properties": {"a": {"$ref": "#/x"}}}, + {"type": "object", "properties": {"a": {"allOf": [{"type": "string"}]}}}, + { + "type": "object", + "properties": { + "m": {"type": "object", "additionalProperties": {"type": "string"}} + }, + }, + ], +) +def test_chat_with_invalid_response_format( + client: TestClient, + sample_data: tuple[Workspace, Peer], + mock_llm_call_functions: dict[str, Any], + bad_schema: dict[str, Any], +): + """Unsupported schemas are rejected with 422 before the dialectic runs.""" + test_workspace, test_peer = sample_data + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat", + json={ + "query": "Hello?", + "stream": False, + "response_format": bad_schema, + }, + ) + assert response.status_code == 422 + assert "Invalid response_format" in response.json()["detail"] + mock_llm_call_functions["agentic_chat"].assert_not_awaited() diff --git a/tests/sdk/test_peer.py b/tests/sdk/test_peer.py index 258fb989..0c019d9b 100644 --- a/tests/sdk/test_peer.py +++ b/tests/sdk/test_peer.py @@ -2,6 +2,7 @@ from collections.abc import AsyncIterator, Iterator from unittest.mock import patch import pytest +from pydantic import BaseModel from sdks.python.src.honcho.client import Honcho from sdks.python.src.honcho.peer import Peer @@ -626,3 +627,166 @@ async def test_peer_representation_with_all_params( max_conclusions=5, ) assert isinstance(result, str) + + +class ChatFoodPreferences(BaseModel): + favorite: str + confidence: float + + +CHAT_SCHEMA_DICT = { + "type": "object", + "properties": {"items": {"type": "array", "items": {"type": "string"}}}, + "required": ["items"], +} + + +@pytest.mark.asyncio +async def test_peer_chat_response_format_pydantic(client_fixture: tuple[Honcho, str]): + """A Pydantic model class is sent as JSON Schema and the response content + is parsed back into a model instance.""" + honcho_client, client_type = client_fixture + content = '{"favorite": "sushi", "confidence": 0.9}' + + if client_type == "async": + peer = await honcho_client.aio.peer(id="test-rf-async-peer") + + async def mock_post(*_args: object, **_kwargs: object) -> dict[str, object]: + return {"content": content} + + with patch.object( + peer._honcho._async_http_client, # pyright: ignore[reportPrivateUsage] + "post", + side_effect=mock_post, + ) as mock: + result = await peer.aio.chat( + "What do I like?", response_format=ChatFoodPreferences + ) + else: + peer = honcho_client.peer(id="test-rf-peer") + with patch.object( + peer._honcho._http, # pyright: ignore[reportPrivateUsage] + "post", + return_value={"content": content}, + ) as mock: + result = peer.chat("What do I like?", response_format=ChatFoodPreferences) + + body = mock.call_args.kwargs["body"] + assert body["response_format"] == ChatFoodPreferences.model_json_schema() + assert isinstance(result, ChatFoodPreferences) + assert result.favorite == "sushi" + + +@pytest.mark.asyncio +async def test_peer_chat_response_format_dict(client_fixture: tuple[Honcho, str]): + """A raw JSON Schema dict is sent as-is and the response stays a string.""" + honcho_client, client_type = client_fixture + content = '{"items": ["sushi"]}' + + if client_type == "async": + peer = await honcho_client.aio.peer(id="test-rf-dict-async-peer") + + async def mock_post(*_args: object, **_kwargs: object) -> dict[str, object]: + return {"content": content} + + with patch.object( + peer._honcho._async_http_client, # pyright: ignore[reportPrivateUsage] + "post", + side_effect=mock_post, + ) as mock: + result = await peer.aio.chat( + "What do I like?", response_format=CHAT_SCHEMA_DICT + ) + else: + peer = honcho_client.peer(id="test-rf-dict-peer") + with patch.object( + peer._honcho._http, # pyright: ignore[reportPrivateUsage] + "post", + return_value={"content": content}, + ) as mock: + result = peer.chat("What do I like?", response_format=CHAT_SCHEMA_DICT) + + body = mock.call_args.kwargs["body"] + assert body["response_format"] == CHAT_SCHEMA_DICT + assert result == content + + +@pytest.mark.asyncio +async def test_peer_chat_response_format_empty_content( + client_fixture: tuple[Honcho, str], +): + """Empty/None content returns None even when a Pydantic class was given.""" + honcho_client, client_type = client_fixture + + if client_type == "async": + peer = await honcho_client.aio.peer(id="test-rf-empty-async-peer") + + async def mock_post(*_args: object, **_kwargs: object) -> dict[str, object]: + return {"content": None} + + with patch.object( + peer._honcho._async_http_client, # pyright: ignore[reportPrivateUsage] + "post", + side_effect=mock_post, + ): + result = await peer.aio.chat( + "What do I like?", response_format=ChatFoodPreferences + ) + else: + peer = honcho_client.peer(id="test-rf-empty-peer") + with patch.object( + peer._honcho._http, # pyright: ignore[reportPrivateUsage] + "post", + return_value={"content": None}, + ): + result = peer.chat("What do I like?", response_format=ChatFoodPreferences) + + assert result is None + + +@pytest.mark.asyncio +async def test_peer_chat_stream_response_format(client_fixture: tuple[Honcho, str]): + """chat_stream sends the schema in the body; chunks stay raw text.""" + honcho_client, client_type = client_fixture + + if client_type == "async": + peer = await honcho_client.aio.peer(id="test-rf-stream-async-peer") + + async def mock_astream( + *_args: object, **_kwargs: object + ) -> AsyncIterator[bytes]: + yield b'data: {"delta": {"content": "{\\"favorite\\":"}}\n' + yield b'data: {"delta": {"content": "\\"sushi\\",\\"confidence\\":0.9}"}}\n' + yield b'data: {"done": true}\n' + + with patch.object( + peer._honcho._async_http_client, # pyright: ignore[reportPrivateUsage] + "stream", + side_effect=mock_astream, + ) as mock: + result = await peer.aio.chat_stream( + "What do I like?", response_format=ChatFoodPreferences + ) + chunks = [chunk async for chunk in result] + else: + peer = honcho_client.peer(id="test-rf-stream-peer") + + def mock_stream(*_args: object, **_kwargs: object) -> Iterator[bytes]: + yield b'data: {"delta": {"content": "{\\"favorite\\":"}}\n' + yield b'data: {"delta": {"content": "\\"sushi\\",\\"confidence\\":0.9}"}}\n' + yield b'data: {"done": true}\n' + + with patch.object( + peer._honcho._http, # pyright: ignore[reportPrivateUsage] + "stream", + side_effect=mock_stream, + ) as mock: + result = peer.chat_stream( + "What do I like?", response_format=ChatFoodPreferences + ) + chunks = list(result) + + body = mock.call_args.kwargs["body"] + assert body["response_format"] == ChatFoodPreferences.model_json_schema() + accumulated = "".join(chunks) + assert ChatFoodPreferences.model_validate_json(accumulated).favorite == "sushi" diff --git a/tests/unified/runner.py b/tests/unified/runner.py index eaa78409..b99cd37a 100644 --- a/tests/unified/runner.py +++ b/tests/unified/runner.py @@ -357,6 +357,7 @@ class UnifiedTestExecutor: session=step.session_id, target=step.observed_peer_id, reasoning_level=step.reasoning_level, + response_format=step.response_format, ) return response diff --git a/tests/unified/schema.py b/tests/unified/schema.py index aa4c78ad..161d4f08 100644 --- a/tests/unified/schema.py +++ b/tests/unified/schema.py @@ -149,6 +149,9 @@ class QueryAction(TestStep): # for chat - reasoning level reasoning_level: ReasoningLevel | None = None + # for chat - optional JSON Schema the response must conform to + response_format: dict[str, Any] | None = None + assertions: list[ LLMJudgeAssertion | ContainsAssertion diff --git a/tests/unified/test_cases/dialectic_structured_output.json b/tests/unified/test_cases/dialectic_structured_output.json new file mode 100644 index 00000000..59cc1365 --- /dev/null +++ b/tests/unified/test_cases/dialectic_structured_output.json @@ -0,0 +1,135 @@ +{ + "description": "Dialectic chat with a response_format JSON Schema while the agent must use tools (reasoning off + enumeration question forces grep/search calls). Exercises the transport-layer combination of tool calling and structured output on every provider: OpenAI must avoid parse() for non-strict tools, Anthropic must skip the '{' prefill, Gemini must fall back to a schema instruction. The final answer must be a JSON string conforming to the schema.", + "workspace_config": {}, + "steps": [ + { + "step_type": "create_session", + "session_id": "structured_output_test", + "config": { + "reasoning": { + "enabled": false + } + }, + "peer_configs": { + "user": { + "observe_me": true, + "observe_others": false + }, + "assistant": { + "observe_me": false, + "observe_others": true + } + } + }, + { + "step_type": "add_messages", + "session_id": "structured_output_test", + "messages": [ + { + "peer_id": "user", + "content": "Monday I grabbed a $5 latte at Starbucks before my standup.", + "created_at": "2024-03-04T08:30:00" + }, + { + "peer_id": "assistant", + "content": "Nice, a classic way to start the week.", + "created_at": "2024-03-04T08:31:00" + }, + { + "peer_id": "user", + "content": "Tuesday I tried a $4 cold brew from Blue Bottle, really smooth.", + "created_at": "2024-03-05T09:15:00" + }, + { + "peer_id": "assistant", + "content": "Blue Bottle makes a solid cold brew.", + "created_at": "2024-03-05T09:16:00" + }, + { + "peer_id": "user", + "content": "Wednesday was a $6 oat-milk mocha at a little place downtown.", + "created_at": "2024-03-06T08:45:00" + }, + { + "peer_id": "assistant", + "content": "Oat milk mochas are underrated.", + "created_at": "2024-03-06T08:46:00" + }, + { + "peer_id": "user", + "content": "Thursday I skipped coffee and just had tea at home.", + "created_at": "2024-03-07T08:20:00" + }, + { + "peer_id": "assistant", + "content": "A calm morning, sounds good.", + "created_at": "2024-03-07T08:21:00" + }, + { + "peer_id": "user", + "content": "Friday I splurged on a $7 pour-over at the roastery near the office.", + "created_at": "2024-03-08T08:50:00" + }, + { + "peer_id": "assistant", + "content": "Ending the week strong!", + "created_at": "2024-03-08T08:51:00" + } + ] + }, + { + "step_type": "wait", + "target": "queue_empty", + "timeout": 180, + "flush": true + }, + { + "step_type": "query", + "description": "Global query + empty prefetch forces tool calls; response_format forces structured output on the same LLM calls", + "target": "chat", + "observer_peer_id": "assistant", + "observed_peer_id": "user", + "reasoning_level": "max", + "input": "How many separate coffees did I buy this week, and exactly how much did I spend in total across all of them?", + "response_format": { + "type": "object", + "properties": { + "coffee_count": { + "type": "integer", + "description": "How many separate coffees the user bought during the week" + }, + "total_spent_usd": { + "type": "number", + "description": "Total amount in US dollars the user spent on coffee" + }, + "purchases": { + "type": "array", + "items": { + "type": "string" + }, + "description": "One short entry per coffee purchase, including its price" + }, + "summary": { + "type": "string", + "description": "One-sentence answer to the question" + } + }, + "required": ["coffee_count", "total_spent_usd", "purchases", "summary"] + }, + "assertions": [ + { + "assertion_type": "json_match", + "key_value_pairs": { + "coffee_count": 4, + "total_spent_usd": 22 + } + }, + { + "assertion_type": "llm_judge", + "prompt": "The result must be a JSON object whose 'purchases' array enumerates the $5 latte, $4 cold brew, $6 mocha, and $7 pour-over (wording may vary), and whose 'summary' answers that the user bought 4 coffees for $22 total.", + "pass_if": true + } + ] + } + ] +} diff --git a/tests/utils/test_schema_conversion.py b/tests/utils/test_schema_conversion.py new file mode 100644 index 00000000..e6c607f4 --- /dev/null +++ b/tests/utils/test_schema_conversion.py @@ -0,0 +1,897 @@ +"""Unit tests for src/utils/schema_conversion.py.""" + +import json +import re +from typing import Any + +import pytest +from pydantic import BaseModel, ValidationError + +from src.utils.schema_conversion import json_response_schema_to_pydantic + + +def _object(properties: dict[str, Any], **extra: Any) -> dict[str, Any]: + return {"type": "object", "properties": properties, **extra} + + +class TestPrimitives: + def test_flat_object_with_primitives(self): + model = json_response_schema_to_pydantic( + _object( + { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "score": {"type": "number"}, + "active": {"type": "boolean"}, + }, + required=["name", "age"], + ) + ) + instance = model.model_validate( + {"name": "ada", "age": 36, "score": 9.5, "active": True} + ) + assert instance.name == "ada" # pyright: ignore + assert instance.age == 36 # pyright: ignore + + def test_required_field_missing_fails(self): + model = json_response_schema_to_pydantic( + _object({"name": {"type": "string"}}, required=["name"]) + ) + with pytest.raises(ValidationError): + model.model_validate({}) + + def test_optional_field_defaults_to_none(self): + model = json_response_schema_to_pydantic( + _object({"nickname": {"type": "string"}}) + ) + instance = model.model_validate({}) + assert instance.nickname is None # pyright: ignore + + def test_default_value(self): + model = json_response_schema_to_pydantic( + _object({"count": {"type": "integer", "default": 3}}) + ) + assert model.model_validate({}).count == 3 # pyright: ignore + + def test_null_type(self): + model = json_response_schema_to_pydantic( + _object({"nothing": {"type": "null"}}, required=["nothing"]) + ) + assert model.model_validate({"nothing": None}).nothing is None # pyright: ignore + + def test_default_wins_over_required(self): + model = json_response_schema_to_pydantic( + _object({"count": {"type": "integer", "default": 3}}, required=["count"]) + ) + assert model.model_validate({}).count == 3 # pyright: ignore + + +class TestNesting: + def test_nested_object(self): + model = json_response_schema_to_pydantic( + _object( + { + "address": _object( + { + "city": {"type": "string"}, + "geo": _object( + {"lat": {"type": "number"}}, required=["lat"] + ), + }, + required=["city", "geo"], + ) + }, + required=["address"], + ) + ) + instance = model.model_validate( + {"address": {"city": "oakland", "geo": {"lat": 37.8}}} + ) + assert instance.address.geo.lat == 37.8 # pyright: ignore + + def test_array_of_objects(self): + model = json_response_schema_to_pydantic( + _object( + { + "items": { + "type": "array", + "items": _object( + {"food": {"type": "string"}}, required=["food"] + ), + } + }, + required=["items"], + ) + ) + instance = model.model_validate({"items": [{"food": "sushi"}]}) + assert instance.items[0].food == "sushi" # pyright: ignore + + def test_array_without_items_accepts_anything(self): + model = json_response_schema_to_pydantic( + _object({"stuff": {"type": "array"}}, required=["stuff"]) + ) + instance = model.model_validate({"stuff": [1, "two", {"three": 3}]}) + assert len(instance.stuff) == 3 # pyright: ignore + + def test_nested_model_name_collision(self): + # Two sibling objects whose name hints collide must not clash. + model = json_response_schema_to_pydantic( + _object( + { + "a": _object({"x b": _object({"v": {"type": "string"}})}), + "a_x": _object({"b": _object({"v": {"type": "integer"}})}), + } + ) + ) + instance = model.model_validate( + {"a": {"x b": {"v": "s"}}, "a_x": {"b": {"v": 1}}} + ) + assert instance.a_x.b.v == 1 # pyright: ignore + + +class TestEnumsAndUnions: + def test_string_enum(self): + model = json_response_schema_to_pydantic( + _object( + {"sentiment": {"enum": ["loves", "hates"]}}, + required=["sentiment"], + ) + ) + assert model.model_validate({"sentiment": "loves"}).sentiment == "loves" # pyright: ignore + with pytest.raises(ValidationError): + model.model_validate({"sentiment": "meh"}) + + def test_int_enum_and_null_member(self): + model = json_response_schema_to_pydantic( + _object({"level": {"enum": [1, 2, None]}}, required=["level"]) + ) + assert model.model_validate({"level": None}).level is None # pyright: ignore + assert model.model_validate({"level": 2}).level == 2 # pyright: ignore + + def test_invalid_enum_value_type(self): + with pytest.raises(ValueError, match="enum values"): + json_response_schema_to_pydantic(_object({"bad": {"enum": [[1]]}})) + + def test_anyof_with_null_is_optional(self): + model = json_response_schema_to_pydantic( + _object( + {"maybe": {"anyOf": [{"type": "string"}, {"type": "null"}]}}, + required=["maybe"], + ) + ) + assert model.model_validate({"maybe": None}).maybe is None # pyright: ignore + assert model.model_validate({"maybe": "x"}).maybe == "x" # pyright: ignore + + def test_oneof_union(self): + model = json_response_schema_to_pydantic( + _object( + {"value": {"oneOf": [{"type": "integer"}, {"type": "string"}]}}, + required=["value"], + ) + ) + assert model.model_validate({"value": 5}).value == 5 # pyright: ignore + + def test_type_list_form(self): + model = json_response_schema_to_pydantic( + _object({"name": {"type": ["string", "null"]}}, required=["name"]) + ) + assert model.model_validate({"name": None}).name is None # pyright: ignore + + def test_all_null_enum_degenerates_to_none(self): + model = json_response_schema_to_pydantic( + _object({"nothing": {"enum": [None]}}, required=["nothing"]) + ) + assert model.model_validate({"nothing": None}).nothing is None # pyright: ignore + with pytest.raises(ValidationError): + model.model_validate({"nothing": "x"}) + + def test_union_of_objects(self): + model = json_response_schema_to_pydantic( + _object( + { + "pet": { + "anyOf": [ + _object({"meows": {"type": "boolean"}}, required=["meows"]), + _object({"barks": {"type": "boolean"}}, required=["barks"]), + ] + } + }, + required=["pet"], + ) + ) + instance = model.model_validate({"pet": {"barks": True}}) + assert instance.pet.barks is True # pyright: ignore + + +class TestRejections: + @pytest.mark.parametrize( + "construct,schema", + [ + ("$defs", _object({"a": {"type": "object", "$defs": {}}})), + ("definitions", _object({"a": {"type": "object", "definitions": {}}})), + ("allOf", _object({"a": {"allOf": [{"type": "string"}]}})), + ("not", _object({"a": {"not": {"type": "string"}}})), + ("if", _object({"a": {"if": {"type": "string"}}})), + ( + "patternProperties", + _object({"a": {"type": "object", "patternProperties": {}}}), + ), + ], + ) + def test_unsupported_constructs(self, construct: str, schema: dict[str, Any]): + with pytest.raises(ValueError, match=re.escape(construct)): + json_response_schema_to_pydantic(schema) + + def test_error_message_includes_path(self): + with pytest.raises( + ValueError, match=r"unsupported \$ref '#/x'.*at properties\.address" + ): + json_response_schema_to_pydantic(_object({"address": {"$ref": "#/x"}})) + + def test_schema_valued_additional_properties(self): + with pytest.raises(ValueError, match="additionalProperties"): + json_response_schema_to_pydantic( + _object( + { + "map": { + "type": "object", + "additionalProperties": {"type": "string"}, + } + } + ) + ) + + def test_boolean_schema(self): + with pytest.raises(ValueError, match="boolean schemas"): + json_response_schema_to_pydantic(_object({"anything": True})) + + def test_unknown_type(self): + with pytest.raises(ValueError, match="unsupported type 'date'"): + json_response_schema_to_pydantic(_object({"when": {"type": "date"}})) + + +class TestRefs: + def test_ref_into_defs(self): + model = json_response_schema_to_pydantic( + _object( + {"address": {"$ref": "#/$defs/Address"}}, + required=["address"], + **{ + "$defs": { + "Address": _object( + {"city": {"type": "string"}}, required=["city"] + ) + } + }, + ) + ) + instance = model.model_validate({"address": {"city": "Berlin"}}) + assert instance.address.city == "Berlin" # pyright: ignore + + def test_ref_into_definitions_alias(self): + model = json_response_schema_to_pydantic( + _object( + {"item": {"$ref": "#/definitions/Item"}}, + required=["item"], + definitions={"Item": {"type": "string"}}, + ) + ) + assert model.model_validate({"item": "x"}).item == "x" # pyright: ignore + + def test_pydantic_nested_model_schema(self): + """The real-world motivation: model_json_schema() of a nested Pydantic + model emits $defs/$ref and must convert cleanly.""" + + class Preference(BaseModel): + food: str + confidence: float + + class Preferences(BaseModel): + preferences: list[Preference] + summary: str + + model = json_response_schema_to_pydantic(Preferences.model_json_schema()) + instance = model.model_validate( + { + "preferences": [{"food": "sushi", "confidence": 0.9}], + "summary": "likes sushi", + } + ) + assert instance.preferences[0].food == "sushi" # pyright: ignore + + def test_root_ref(self): + model = json_response_schema_to_pydantic( + { + "$ref": "#/$defs/Root", + "$defs": { + "Root": _object({"ok": {"type": "boolean"}}, required=["ok"]) + }, + } + ) + assert model.model_validate({"ok": True}).ok is True # pyright: ignore + + def test_ref_sibling_keys_overlay_target(self): + model = json_response_schema_to_pydantic( + _object( + {"count": {"$ref": "#/$defs/Count", "default": 3}}, + **{"$defs": {"Count": {"type": "integer"}}}, + ) + ) + assert model.model_validate({}).count == 3 # pyright: ignore + + def test_same_def_referenced_twice(self): + model = json_response_schema_to_pydantic( + _object( + { + "home": {"$ref": "#/$defs/Address"}, + "work": {"$ref": "#/$defs/Address"}, + }, + required=["home", "work"], + **{"$defs": {"Address": _object({"city": {"type": "string"}})}}, + ) + ) + instance = model.model_validate( + {"home": {"city": "Berlin"}, "work": {"city": "Kyiv"}} + ) + assert instance.work.city == "Kyiv" # pyright: ignore + + def test_chained_refs(self): + model = json_response_schema_to_pydantic( + _object( + {"a": {"$ref": "#/$defs/A"}}, + required=["a"], + **{"$defs": {"A": {"$ref": "#/$defs/B"}, "B": {"type": "string"}}}, + ) + ) + assert model.model_validate({"a": "x"}).a == "x" # pyright: ignore + + def test_unreferenced_invalid_def_is_ignored(self): + model = json_response_schema_to_pydantic( + _object( + {"name": {"type": "string"}}, + **{"$defs": {"Broken": {"allOf": [{"type": "string"}]}}}, + ) + ) + assert model.model_validate({"name": "x"}).name == "x" # pyright: ignore + + @pytest.mark.parametrize( + "ref", + ["#", "#/x", "#/$defs/a/b", "#/properties/a", "https://x.dev/s.json#/$defs/X"], + ) + def test_unsupported_ref_forms(self, ref: str): + with pytest.raises(ValueError, match=r"unsupported \$ref"): + json_response_schema_to_pydantic( + _object( + {"a": {"$ref": ref}}, + **{"$defs": {"a": {"type": "string"}}}, + ) + ) + + def test_unknown_definition(self): + with pytest.raises(ValueError, match="unknown definition"): + json_response_schema_to_pydantic( + _object({"a": {"$ref": "#/$defs/Missing"}}, **{"$defs": {}}) + ) + + def test_direct_recursion_rejected(self): + with pytest.raises(ValueError, match=r"recursive \$ref.*cycle: Node -> Node"): + json_response_schema_to_pydantic( + _object( + {"tree": {"$ref": "#/$defs/Node"}}, + **{ + "$defs": { + "Node": _object( + { + "children": { + "type": "array", + "items": {"$ref": "#/$defs/Node"}, + } + } + ) + } + }, + ) + ) + + def test_mutual_recursion_rejected(self): + with pytest.raises(ValueError, match=r"cycle: A -> B -> A"): + json_response_schema_to_pydantic( + _object( + {"a": {"$ref": "#/$defs/A"}}, + **{ + "$defs": { + "A": _object({"b": {"$ref": "#/$defs/B"}}), + "B": _object({"a": {"$ref": "#/$defs/A"}}), + } + }, + ) + ) + + def test_recursive_pydantic_model_rejected(self): + class Node(BaseModel): + value: str + children: list["Node"] = [] + + with pytest.raises(ValueError, match=r"recursive \$ref"): + json_response_schema_to_pydantic(Node.model_json_schema()) + + def test_ref_expansion_counts_against_node_budget(self): + """A doubling ref chain (billion laughs) is stopped by max_nodes.""" + defs = { + f"L{i}": _object( + { + "a": {"$ref": f"#/$defs/L{i + 1}"}, + "b": {"$ref": f"#/$defs/L{i + 1}"}, + } + ) + for i in range(10) + } + defs["L10"] = {"type": "string"} + with pytest.raises(ValueError, match="maximum of .* nodes"): + json_response_schema_to_pydantic( + _object({"root": {"$ref": "#/$defs/L0"}}, **{"$defs": defs}) + ) + + def test_duplicate_name_across_defs_and_definitions(self): + with pytest.raises(ValueError, match="appears in both"): + json_response_schema_to_pydantic( + _object( + {"a": {"$ref": "#/$defs/X"}}, + **{ + "$defs": {"X": {"type": "string"}}, + "definitions": {"X": {"type": "integer"}}, + }, + ) + ) + + def test_root_must_be_object(self): + with pytest.raises(ValueError, match="root schema"): + json_response_schema_to_pydantic({"type": "string"}) + + def test_root_must_be_dict(self): + with pytest.raises(ValueError, match="JSON Schema object"): + json_response_schema_to_pydantic(["not", "a", "schema"]) # pyright: ignore + + def test_no_recognizable_type(self): + with pytest.raises(ValueError, match="no recognizable type"): + json_response_schema_to_pydantic(_object({"mystery": {}})) + + def test_depth_limit(self): + schema: dict[str, Any] = {"type": "string"} + for _ in range(25): + schema = _object({"inner": schema}) + with pytest.raises(ValueError, match="maximum depth"): + json_response_schema_to_pydantic(schema) + + def test_node_limit(self): + schema = _object({f"field_{i}": {"type": "string"} for i in range(600)}) + with pytest.raises(ValueError, match="maximum of 500 nodes"): + json_response_schema_to_pydantic(schema) + + def test_property_schema_not_an_object(self): + with pytest.raises(ValueError, match="schema must be an object"): + json_response_schema_to_pydantic(_object({"a": "string"})) + + @pytest.mark.parametrize("members", [[], "not-a-list"]) + def test_malformed_anyof(self, members: Any): + with pytest.raises(ValueError, match="'anyOf' must be a non-empty array"): + json_response_schema_to_pydantic(_object({"a": {"anyOf": members}})) + + def test_empty_type_list(self): + with pytest.raises(ValueError, match="'type' array must not be empty"): + json_response_schema_to_pydantic(_object({"a": {"type": []}})) + + @pytest.mark.parametrize("values", [[], "loves"]) + def test_malformed_enum(self, values: Any): + with pytest.raises(ValueError, match="'enum' must be a non-empty array"): + json_response_schema_to_pydantic(_object({"a": {"enum": values}})) + + def test_properties_not_an_object(self): + with pytest.raises(ValueError, match="'properties' must be an object"): + json_response_schema_to_pydantic({"type": "object", "properties": []}) + + @pytest.mark.parametrize("required", ["a", [1]]) + def test_malformed_required(self, required: Any): + with pytest.raises(ValueError, match="'required' must be an array of strings"): + json_response_schema_to_pydantic( + _object({"a": {"type": "string"}}, required=required) + ) + + def test_empty_property_name(self): + with pytest.raises(ValueError, match="property names"): + json_response_schema_to_pydantic(_object({"": {"type": "string"}})) + + +class TestLenientAcceptance: + def test_additional_properties_false_ignored(self): + model = json_response_schema_to_pydantic( + _object( + {"known": {"type": "string"}}, + required=["known"], + additionalProperties=False, + ) + ) + instance = model.model_validate({"known": "x", "extra": "dropped"}) + assert instance.model_dump() == {"known": "x"} + + def test_root_dollar_schema_ignored(self): + model = json_response_schema_to_pydantic( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {"a": {"type": "string"}}, + } + ) + assert issubclass(model, BaseModel) + + def test_empty_properties(self): + model = json_response_schema_to_pydantic({"type": "object", "properties": {}}) + assert model.model_validate({}).model_dump() == {} + + def test_missing_properties_with_object_type(self): + model = json_response_schema_to_pydantic({"type": "object"}) + assert model.model_validate({"anything": 1}).model_dump() == {} + + def test_missing_type_with_properties_treated_as_object(self): + model = json_response_schema_to_pydantic( + {"properties": {"a": {"type": "string"}}, "required": ["a"]} + ) + assert model.model_validate({"a": "x"}).a == "x" # pyright: ignore + + def test_required_naming_unknown_property_ignored(self): + model = json_response_schema_to_pydantic( + _object({"a": {"type": "string"}}, required=["a", "ghost"]) + ) + assert model.model_validate({"a": "x"}).a == "x" # pyright: ignore + + +class TestFieldMetadata: + def test_description_propagates(self): + model = json_response_schema_to_pydantic( + _object({"food": {"type": "string", "description": "A food item"}}) + ) + generated = model.model_json_schema() + assert generated["properties"]["food"]["description"] == "A food item" + + def test_constraint_hints_pass_through_unenforced(self): + model = json_response_schema_to_pydantic( + _object( + { + "tags": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 3, + } + }, + required=["tags"], + ) + ) + generated = model.model_json_schema() + assert generated["properties"]["tags"]["maxItems"] == 3 + # Not enforced: more than maxItems still validates. + instance = model.model_validate({"tags": ["a", "b", "c", "d"]}) + assert len(instance.tags) == 4 # pyright: ignore + + def test_non_identifier_key_alias_round_trip(self): + model = json_response_schema_to_pydantic( + _object( + {"my-key": {"type": "string"}, "_private": {"type": "integer"}}, + required=["my-key"], + ) + ) + instance = model.model_validate({"my-key": "v", "_private": 7}) + dumped = instance.model_dump_json(by_alias=True) + assert '"my-key":"v"' in dumped + assert '"_private":7' in dumped + + def test_digit_leading_key_gets_field_prefix(self): + model = json_response_schema_to_pydantic( + _object({"123": {"type": "integer"}}, required=["123"]) + ) + instance = model.model_validate({"123": 7}) + assert instance.model_dump(by_alias=True) == {"123": 7} + + def test_sanitized_key_collision_round_trip(self): + # "my-key" sanitizes to "my_key", which then collides with the real + # "my_key" property; both must survive with their original JSON keys. + model = json_response_schema_to_pydantic( + _object( + {"my-key": {"type": "string"}, "my_key": {"type": "integer"}}, + required=["my-key", "my_key"], + ) + ) + instance = model.model_validate({"my-key": "v", "my_key": 7}) + assert instance.model_dump(by_alias=True) == {"my-key": "v", "my_key": 7} + + def test_digit_leading_model_name(self): + model = json_response_schema_to_pydantic( + _object({"a": {"type": "string"}}), model_name="123" + ) + assert model.__name__ == "Model123" + + +class TestZodCompatibility: + def test_zod4_tojsonschema_output_converts(self): + # Captured shape of zod 4's z.toJSONSchema() for + # z.object({ preferences: z.array(z.object({ food: z.string(), + # sentiment: z.enum(["loves","hates"]) })), summary: z.string(), + # note: z.string().optional() }) + schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "preferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "food": {"type": "string"}, + "sentiment": { + "type": "string", + "enum": ["loves", "hates"], + }, + }, + "required": ["food", "sentiment"], + "additionalProperties": False, + }, + }, + "summary": {"type": "string"}, + "note": {"type": "string"}, + }, + "required": ["preferences", "summary"], + "additionalProperties": False, + } + model = json_response_schema_to_pydantic(schema) + instance = model.model_validate( + { + "preferences": [{"food": "sushi", "sentiment": "loves"}], + "summary": "likes sushi", + } + ) + assert instance.preferences[0].sentiment == "loves" # pyright: ignore + assert instance.note is None # pyright: ignore + + +class TestCustomGuardLimits: + def test_custom_max_depth(self): + schema: dict[str, Any] = {"type": "string"} + for _ in range(5): + schema = _object({"inner": schema}) + with pytest.raises(ValueError, match="maximum depth of 3"): + json_response_schema_to_pydantic(schema, max_depth=3) + + def test_custom_max_nodes(self): + schema = _object({f"f{i}": {"type": "string"} for i in range(20)}) + with pytest.raises(ValueError, match="maximum of 10 nodes"): + json_response_schema_to_pydantic(schema, max_nodes=10) + + def test_depth_exactly_at_limit_allowed(self): + # Leaf sits at depth == max_depth; only depth > max_depth must fail. + schema: dict[str, Any] = {"type": "string"} + for _ in range(3): + schema = _object({"inner": schema}) + model = json_response_schema_to_pydantic(schema, max_depth=3) + assert issubclass(model, BaseModel) + + +# The wiki spec's own request example (dialectic-enhancements §3.A.1). +SPEC_EXAMPLE_SCHEMA = _object( + { + "preferences": { + "type": "array", + "items": _object( + { + "food": {"type": "string"}, + "sentiment": { + "type": "string", + "enum": ["loves", "likes", "neutral", "dislikes", "hates"], + }, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + }, + required=["food", "sentiment"], + ), + "maxItems": 3, + }, + "summary": {"type": "string"}, + }, + required=["preferences", "summary"], +) + + +class TestEndToEnd: + """Table tests running the full pipeline the server runs: convert the + caller's schema, validate a payload against the generated model, and + serialize it back with model_dump_json(by_alias=True).""" + + @pytest.mark.parametrize( + "schema,payload,expected", + [ + pytest.param( + SPEC_EXAMPLE_SCHEMA, + { + "preferences": [ + { + "food": "dark roast coffee", + "sentiment": "loves", + "confidence": 0.95, + }, + {"food": "sushi", "sentiment": "likes"}, + ], + "summary": "Coffee enthusiast.", + }, + { + "preferences": [ + { + "food": "dark roast coffee", + "sentiment": "loves", + "confidence": 0.95, + }, + {"food": "sushi", "sentiment": "likes", "confidence": None}, + ], + "summary": "Coffee enthusiast.", + }, + id="spec-example", + ), + pytest.param( + _object( + { + "user": _object( + { + "name": {"type": "string"}, + "location": _object( + { + "lat": {"type": "number"}, + "lon": {"type": "number"}, + }, + required=["lat", "lon"], + ), + }, + required=["name", "location"], + ) + }, + required=["user"], + ), + {"user": {"name": "ada", "location": {"lat": 37.8, "lon": -122.3}}}, + {"user": {"name": "ada", "location": {"lat": 37.8, "lon": -122.3}}}, + id="nested-three-levels", + ), + pytest.param( + _object( + {"my-key": {"type": "string"}, "first name": {"type": "string"}}, + required=["my-key"], + ), + {"my-key": "v", "first name": "Ada"}, + {"my-key": "v", "first name": "Ada"}, + id="alias-keys-round-trip", + ), + pytest.param( + _object( + { + "count": {"type": "integer", "default": 3}, + "tag": {"type": "string", "default": "none"}, + } + ), + {}, + {"count": 3, "tag": "none"}, + id="defaults-fill-omitted-fields", + ), + pytest.param( + _object( + { + "a": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "b": {"type": ["integer", "null"]}, + }, + required=["a", "b"], + ), + {"a": None, "b": 2}, + {"a": None, "b": 2}, + id="nullable-via-anyof-and-type-list", + ), + pytest.param( + _object( + {"value": {"oneOf": [{"type": "integer"}, {"type": "string"}]}}, + required=["value"], + ), + {"value": "five"}, + {"value": "five"}, + id="oneof-union-string-member", + ), + pytest.param( + _object({"level": {"enum": [1, 2, None]}}, required=["level"]), + {"level": None}, + {"level": None}, + id="enum-with-null-member", + ), + pytest.param( + _object({"stuff": {"type": "array"}}, required=["stuff"]), + {"stuff": [1, "two", {"three": 3}, None]}, + {"stuff": [1, "two", {"three": 3}, None]}, + id="array-without-items-accepts-anything", + ), + pytest.param( + _object( + { + "tags": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 2, + } + }, + required=["tags"], + ), + {"tags": ["a", "b", "c", "d"]}, + {"tags": ["a", "b", "c", "d"]}, + id="constraint-hints-not-enforced", + ), + pytest.param( + _object({"known": {"type": "string"}}, required=["known"]), + {"known": "x", "hallucinated": "dropped"}, + {"known": "x"}, + id="extra-keys-dropped", + ), + pytest.param( + {"type": "object", "properties": {}}, + {}, + {}, + id="empty-object", + ), + ], + ) + def test_construct_validate_serialize( + self, + schema: dict[str, Any], + payload: dict[str, Any], + expected: dict[str, Any], + ): + model = json_response_schema_to_pydantic(schema) + instance = model.model_validate(payload) + # by_alias=True mirrors DialecticAgent.answer's serialization. + assert json.loads(instance.model_dump_json(by_alias=True)) == expected + + @pytest.mark.parametrize( + "schema,payload", + [ + pytest.param( + SPEC_EXAMPLE_SCHEMA, + {"preferences": [{"food": "sushi"}], "summary": "s"}, + id="missing-required-in-array-item", + ), + pytest.param( + SPEC_EXAMPLE_SCHEMA, + { + "preferences": [{"food": "sushi", "sentiment": "adores"}], + "summary": "s", + }, + id="invalid-enum-value", + ), + pytest.param( + SPEC_EXAMPLE_SCHEMA, + {"preferences": [{"food": "sushi", "sentiment": "likes"}]}, + id="missing-required-top-level", + ), + pytest.param( + _object( + {"user": _object({"name": {"type": "string"}}, required=["name"])}, + required=["user"], + ), + {"user": {}}, + id="missing-required-nested", + ), + pytest.param( + _object({"a": {"type": "string"}}, required=["a"]), + {"a": None}, + id="null-for-non-nullable", + ), + pytest.param( + _object({"n": {"type": "integer"}}, required=["n"]), + {"n": {"nested": "dict"}}, + id="wrong-type-for-integer", + ), + ], + ) + def test_rejects_nonconforming_payloads( + self, schema: dict[str, Any], payload: dict[str, Any] + ): + model = json_response_schema_to_pydantic(schema) + with pytest.raises(ValidationError): + model.model_validate(payload) From 4f9a41360a853e520ddc6a8aecf851ca04137da3 Mon Sep 17 00:00:00 2001 From: ajspig <46900795+ajspig@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:31:09 -0400 Subject: [PATCH 58/65] feat: support OAuth for MCP clients. (#923) * feat: support OAuth for MCP clients. * fix: scheme is case-insensitive. * fix: expose WWW-Authenticate header for cross-origin clients. --- mcp/README.md | 10 +++------- mcp/src/config.ts | 20 ++++---------------- mcp/src/index.ts | 34 ++++++++++++++++++++++++++++++++-- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index ba90710b..7ec5963d 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -16,13 +16,10 @@ A Cloudflare Worker that implements the [Model Context Protocol (MCP)](https://m "mcp-remote", "https://mcp.honcho.dev", "--header", - "Authorization:${AUTH_HEADER}", - "--header", - "X-Honcho-User-Name:${USER_NAME}" + "Authorization:${AUTH_HEADER}" ], "env": { - "AUTH_HEADER": "Bearer ", - "USER_NAME": "" + "AUTH_HEADER": "Bearer " } } } @@ -115,8 +112,7 @@ bun run tsc --noEmit ```bash bunx mcp-remote http://localhost:8787 \ - --header "Authorization:Bearer " \ - --header "X-Honcho-User-Name:test" + --header "Authorization:Bearer " ``` ### Deploy diff --git a/mcp/src/config.ts b/mcp/src/config.ts index 58e4307e..98cf8760 100644 --- a/mcp/src/config.ts +++ b/mcp/src/config.ts @@ -2,8 +2,6 @@ import { Honcho } from "@honcho-ai/sdk"; export interface HonchoConfig { apiKey: string; - userName: string; - assistantName: string; baseUrl: string; workspaceId: string; } @@ -14,7 +12,7 @@ export interface Env { /** * Parse configuration from request headers and Worker env bindings. - * Throws on missing required fields so callers get clear errors. + * Throws only when the Authorization bearer token is missing/empty. * * The Honcho API URL is read from the `HONCHO_API_URL` env var when set, * allowing operators to run this Worker alongside a self-hosted Honcho @@ -24,29 +22,19 @@ export interface Env { */ export function parseConfig(request: Request, env: Env = {}): HonchoConfig { const authHeader = request.headers.get("Authorization"); - const trimmedAuthHeader = authHeader?.trim(); - if (!trimmedAuthHeader?.startsWith("Bearer ")) { + const bearerMatch = authHeader?.trim().match(/^Bearer\s+(.*)$/i); + if (!bearerMatch) { throw new Error( "Missing Authorization header. Provide 'Authorization: Bearer '.", ); } - const apiKey = trimmedAuthHeader.substring(7).trim(); + const apiKey = bearerMatch[1].trim(); if (!apiKey) { throw new Error("Authorization header is empty after 'Bearer '."); } - const rawUserName = request.headers.get("X-Honcho-User-Name"); - const userName = rawUserName?.trim(); - if (!userName) { - throw new Error( - "Missing X-Honcho-User-Name header. Provide 'X-Honcho-User-Name: '.", - ); - } - return { apiKey, - userName, - assistantName: request.headers.get("X-Honcho-Assistant-Name")?.trim() || "Assistant", baseUrl: env.HONCHO_API_URL?.trim() || "https://api.honcho.dev", workspaceId: request.headers.get("X-Honcho-Workspace-ID")?.trim() || "default", }; diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 4e895198..a7e0f107 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -5,14 +5,25 @@ import { createServer } from "./server.js"; const CORS_ORIGIN = "*"; const CORS_METHODS = "GET, POST, DELETE, OPTIONS"; const CORS_ALLOWED_HEADERS = - "Content-Type, Authorization, X-Honcho-User-Name, X-Honcho-Workspace-ID, X-Honcho-Assistant-Name"; + "Content-Type, Authorization, X-Honcho-Workspace-ID"; const CORS_HEADERS = { "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": CORS_METHODS, "Access-Control-Allow-Headers": CORS_ALLOWED_HEADERS, + "Access-Control-Expose-Headers": "WWW-Authenticate", }; +const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"; + +function resourceUrl(request: Request): string { + return new URL(request.url).origin; +} + +function authorizationServer(env: Env): string { + return env.HONCHO_API_URL?.trim() || "https://api.honcho.dev"; +} + export default { async fetch( request: Request, @@ -23,15 +34,34 @@ export default { return new Response(null, { status: 204, headers: CORS_HEADERS }); } + // Protected Resource Metadata (RFC 9728) — served without auth so clients + // can discover the authorization server. + if (new URL(request.url).pathname === PROTECTED_RESOURCE_PATH) { + return Response.json( + { + resource: resourceUrl(request), + authorization_servers: [authorizationServer(env)], + bearer_methods_supported: ["header"], + }, + { headers: CORS_HEADERS }, + ); + } + let config; try { config = parseConfig(request, env); } catch (e) { const message = e instanceof Error ? e.message : "Invalid request"; + // WWW-Authenticate points clients at the metadata so they start the OAuth flow. + const resourceMetadata = `${resourceUrl(request)}${PROTECTED_RESOURCE_PATH}`; return new Response(JSON.stringify({ error: message }), { status: 401, - headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + headers: { + "Content-Type": "application/json", + "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadata}"`, + ...CORS_HEADERS, + }, }); } From a15c782985af50949a89b50068d691bd81254ac7 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:22:07 -0400 Subject: [PATCH 59/65] Session-purity invariant + card_refresh dream type (DEV-2000) (#883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: enforce explicit-document session purity in dedup/merge paths Audit for DEV-2000 (Scopes RFC prerequisite): explicit-level documents must stay session-pure so scope memory can be built by copying explicit documents between collections. Two classes of violation were possible: - Exact-content and semantic dedup in crud/document.py matched candidates with no level or session scoping, so an explicit document could be reinforced by — or soft-deleted in favor of — a same-content document from a different session or a different level (silently merging cross-session derivations into one row). - The generic create_observations tool handler accepted level='explicit' from agents with no message context (dreamer/dialectic), which would mint session-less explicit documents. Enforcement (refuse, never rewrite): - create_documents refuses explicit documents with a null session_name - exact dedup keys on (content, level, session-for-explicit); derived levels keep cross-session consolidation - is_rejected_duplicate scopes candidate search to the same level, and the same session for explicit documents - the create_observations tool rejects explicit-level input outside message ingestion (deriver) context Co-Authored-By: Claude Fable 5 * feat: add card_refresh dream type for event-driven peer-card updates Adds a lightweight dream variant (DEV-2000, Scopes RFC prerequisite) that runs ONLY the peer-card update — for event-driven refreshes such as scope membership changes and cold starts: - DreamType.CARD_REFRESH alongside OMNI; dispatched by process_dream to a new run_card_refresh_dream orchestration - CardRefreshSpecialist: restricted to get_recent_observations, search_memory, and update_peer_card (no observation-mutating tools), with a low tool-iteration cap of min(6, DREAM.MAX_TOOL_ITERATIONS) - rebuild=True mode carried in the dream payload: the existing card is NOT injected into the prompt and the specialist rebuilds it solely from observations present in the collection (for use after removals) - enqueue-able via the manual enqueue_dream path (bypasses volume gates); the work-unit key already embeds the dream type so a card refresh never collides with a pending omni dream. POST /v3/workspaces/{id}/schedule_dream accepts dream_type=card_refresh plus the rebuild flag - card refreshes never advance the omni dream guard pair (last_dream_at / last_dream_document_count) - shared PEER CARD prompt section extracted (verbatim) from DeductionSpecialist for reuse; CallPurpose gains dream.card_refresh Co-Authored-By: Claude Fable 5 * chore: fix tests --------- Co-authored-by: Claude Fable 5 --- src/crud/document.py | 81 ++++++- src/deriver/enqueue.py | 8 + src/dreamer/orchestrator.py | 173 ++++++++++++++- src/dreamer/specialists.py | 232 +++++++++++++++----- src/routers/workspaces.py | 1 + src/schemas/api.py | 8 + src/schemas/configuration.py | 4 + src/telemetry/events/llm.py | 1 + src/utils/agent_tools.py | 27 +++ src/utils/queue_payload.py | 7 + tests/crud/test_document.py | 330 ++++++++++++++++++++++++++++ tests/dreamer/test_card_refresh.py | 335 +++++++++++++++++++++++++++++ tests/routes/test_workspaces.py | 50 +++++ tests/telemetry/test_events.py | 1 + tests/utils/test_agent_tools.py | 30 +++ 15 files changed, 1219 insertions(+), 69 deletions(-) create mode 100644 tests/dreamer/test_card_refresh.py diff --git a/src/crud/document.py b/src/crud/document.py index e46f311a..6b7810ca 100644 --- a/src/crud/document.py +++ b/src/crud/document.py @@ -443,6 +443,30 @@ def _normalize_content(content: str) -> str: return content.strip().lower() +def _dedup_key( + content: str, level: str, session_name: str | None +) -> tuple[str, str, str | None]: + """Build the exact-match dedup key for a document. + + Dedup never crosses levels: a same-content document at a different level is + a different kind of record (an explicit fact is not interchangeable with a + deductive conclusion that happens to share its text). + + For **explicit** documents dedup additionally never crosses sessions. + Explicit documents are session-pure records of what was derived from that + session's messages — the Scopes copy-by-session model depends on this — so + a repeat of the same fact in a different session must produce a new + document in that session rather than reinforce another session's row. + Derived levels (deductive/inductive/contradiction) are consolidations and + may still dedup across sessions. + """ + return ( + _normalize_content(content), + level, + session_name if level == "explicit" else None, + ) + + @dataclass class CreateDocumentsResult: created_documents: list[schemas.DocumentCreate] = field(default_factory=list) @@ -488,9 +512,10 @@ async def create_documents( # exact-content dedup (independent of `deduplicate`): pre-fetch # existing live documents whose normalized content matches anything in this # batch, scoped to (workspace, observer, observed). The SQL normalization must - # mirror _normalize_content. + # mirror _normalize_content. Matching is further scoped per-document by + # level (always) and session (for explicit documents) via _dedup_key. batch_normalized: set[str] = {_normalize_content(d.content) for d in documents} - existing_by_normalized: dict[str, models.Document] = {} + existing_by_key: dict[tuple[str, str, str | None], models.Document] = {} if batch_normalized: # The `normalized_content_sql.in_(...)` filter below narrows to the # (workspace, observer, observed) partition via the single-column indexes, @@ -518,15 +543,20 @@ async def create_documents( ) ) for existing_doc in existing_result.scalars(): - # If multiple historical rows share normalized content, reinforcing + # If multiple historical rows share a dedup key, reinforcing # one is sufficient; keep the first. - existing_by_normalized.setdefault( - _normalize_content(existing_doc.content), existing_doc + existing_by_key.setdefault( + _dedup_key( + existing_doc.content, + existing_doc.level, + existing_doc.session_name, + ), + existing_doc, ) - # Tracks normalized content already accepted from this batch so exact + # Tracks dedup keys already accepted from this batch so exact # duplicates within a single inference call collapse to one document. - seen_in_batch: set[str] = set() + seen_in_batch: set[tuple[str, str, str | None]] = set() exact_dup_existing_count = 0 exact_dup_in_batch_count = 0 @@ -534,18 +564,33 @@ async def create_documents( semantic_dup_replaced_count = 0 for doc in documents: try: - normalized_content = _normalize_content(doc.content) + # Session-purity invariant: an explicit document must always carry + # the session it was derived from. Refuse to write session-less + # explicit documents rather than silently minting global explicit + # memory (the Scopes copy-by-session model depends on explicit + # documents staying session-pure). + if doc.level == "explicit" and doc.session_name is None: + logger.error( + "Refusing to create explicit document without session_name in %s/%s/%s (session-purity invariant): %r", + workspace_name, + observer, + observed, + doc.content[:80], + ) + continue + + dedup_key = _dedup_key(doc.content, doc.level, doc.session_name) # Exact-match dedup, always on: # 1) collapse exact duplicates within this batch (drop silently). - if normalized_content in seen_in_batch: + if dedup_key in seen_in_batch: exact_dup_in_batch_count += 1 continue - seen_in_batch.add(normalized_content) + seen_in_batch.add(dedup_key) # 2) drop exact duplicates of an existing live document, recording # the re-derivation as reinforcement on the existing row. - existing_match = existing_by_normalized.get(normalized_content) + existing_match = existing_by_key.get(dedup_key) if existing_match is not None: # Reinforce the existing row. greatest(...) keeps the bump atomic # server-side (concurrent workers can't lose an increment) while @@ -1114,7 +1159,20 @@ async def is_rejected_duplicate( If the document is a duplicate AND the existing document is superior, increments the existing document's ``times_derived`` to record the reinforcement, then returns True. + + Merges are scoped so they never cross document levels, and never cross + sessions for explicit-level documents (session-purity invariant: an + explicit document records what was derived from exactly one session, so + a near-duplicate from another session must not reinforce or replace it). """ + filters: dict[str, Any] = {"level": doc.level} + if doc.level == "explicit": + if doc.session_name is None: + # create_documents refuses session-less explicit documents; if one + # reaches here anyway it has no valid merge partner. + return SemanticRejectionResult.NOT_DUPLICATE + filters["session_name"] = doc.session_name + # Step 1: Find potential duplicates using cosine similarity similar_docs = await query_documents( db=db, @@ -1122,6 +1180,7 @@ async def is_rejected_duplicate( query=doc.content, observer=observer, observed=observed, + filters=filters, max_distance=0.05, top_k=1, embedding=doc.embedding, diff --git a/src/deriver/enqueue.py b/src/deriver/enqueue.py index 05883306..7cd42aa3 100644 --- a/src/deriver/enqueue.py +++ b/src/deriver/enqueue.py @@ -403,6 +403,7 @@ def create_dream_record( delay_reason: str | None = None, documents_since_last_dream_at_schedule: int | None = None, document_threshold: int | None = None, + rebuild: bool = False, ) -> dict[str, Any]: """ Create a queue record for a dream task. @@ -417,6 +418,7 @@ def create_dream_record( delay_reason: what governed when it fires documents_since_last_dream_at_schedule: count snapshot at schedule time document_threshold: DOCUMENT_THRESHOLD snapshot at schedule time + rebuild: card_refresh only — rebuild the card without the prior card Returns: Queue record dictionary with workspace_name and other fields @@ -430,6 +432,7 @@ def create_dream_record( delay_reason=delay_reason, documents_since_last_dream_at_schedule=documents_since_last_dream_at_schedule, document_threshold=document_threshold, + rebuild=rebuild, ) return { @@ -452,6 +455,7 @@ async def enqueue_dream( delay_reason: str | None = None, documents_since_last_dream_at_schedule: int | None = None, document_threshold: int | None = None, + rebuild: bool = False, ) -> None: """ Enqueue a dream task for immediate processing by the deriver. @@ -461,6 +465,8 @@ async def enqueue_dream( Deduplication: If a dream with the same work_unit_key is already in-progress (has an ActiveQueueSession) or pending in the queue, the enqueue is skipped. + The work unit key includes the dream type, so e.g. a card_refresh dream + never collides with a pending omni dream for the same collection. Args: workspace_name: Name of the workspace @@ -468,6 +474,7 @@ async def enqueue_dream( observed: Name of the observed peer dream_type: Type of dream to execute session_name: Name of the session to scope the dream to if specified + rebuild: card_refresh only — rebuild the card without the prior card """ async with tracked_db("dream_enqueue") as db_session: try: @@ -481,6 +488,7 @@ async def enqueue_dream( delay_reason=delay_reason, documents_since_last_dream_at_schedule=documents_since_last_dream_at_schedule, document_threshold=document_threshold, + rebuild=rebuild, ) work_unit_key = dream_record["work_unit_key"] diff --git a/src/dreamer/orchestrator.py b/src/dreamer/orchestrator.py index 0b529cc6..00f001f9 100644 --- a/src/dreamer/orchestrator.py +++ b/src/dreamer/orchestrator.py @@ -26,7 +26,11 @@ from sqlalchemy import func, select from src import crud, models from src.config import settings from src.dependencies import tracked_db -from src.dreamer.specialists import SPECIALISTS, SpecialistResult +from src.dreamer.specialists import ( + SPECIALISTS, + CardRefreshSpecialist, + SpecialistResult, +) from src.dreamer.surprisal import SurprisalScore # type: ignore from src.exceptions import SurprisalError from src.schemas import DreamType @@ -307,6 +311,151 @@ async def run_dream( ) +async def run_card_refresh_dream( + workspace_name: str, + observer: str, + observed: str, + session_name: str | None = None, + *, + rebuild: bool = False, + dream_type: str | None = None, + trigger_reason: str | None = None, + delay_reason: str | None = None, +) -> DreamResult | None: + """ + Run a lightweight card-only refresh dream. + + Runs a single CardRefreshSpecialist restricted to peer-card tools + (get_recent_observations, search_memory, update_peer_card) with a low + tool-iteration cap. It never creates or deletes observations. + + Args: + workspace_name: Workspace identifier + observer: Observer peer name + observed: Observed peer name + session_name: Session identifier if specified + rebuild: When True the existing card is NOT injected into the prompt + and the specialist rebuilds it solely from observations present in + the collection (used after removals). + """ + if not settings.DREAM.ENABLED: + return None + + run_id = generate_nanoid() + task_name = f"dream_orchestrator_{run_id}" + start_time = time.perf_counter() + + logger.info( + f"[{run_id}] Starting card-refresh dream for {workspace_name}/{observer}/{observed} (rebuild={rebuild})" + ) + + # Short-lived DB session for config resolution + async with tracked_db("dream.config") as db: + if session_name is not None: + session = await crud.get_session( + db, workspace_name=workspace_name, session_name=session_name + ) + else: + session = None + + workspace = await crud.get_workspace(db, workspace_name=workspace_name) + configuration = get_configuration(None, session, workspace) + if not configuration.dream.enabled: + logger.info( + f"[{run_id}] Dreams disabled for {workspace_name}/{session_name}, skipping card refresh" + ) + return None + if not configuration.peer_card.create: + logger.info( + f"[{run_id}] Peer card creation disabled for {workspace_name}, skipping card refresh" + ) + return None + + specialist_success = False + specialist_result: SpecialistResult | None = None + duration_ms = 0.0 + try: + specialist = CardRefreshSpecialist(rebuild=rebuild) + try: + specialist_result = await specialist.run( + workspace_name=workspace_name, + observer=observer, + observed=observed, + session_name=session_name, + configuration=configuration, + parent_run_id=run_id, + ) + logger.info( + f"[{run_id}] Card refresh completed: {specialist_result.content[:200]}..." + ) + accumulate_metric( + task_name, "card_refresh_result", specialist_result.content, "blob" + ) + specialist_success = specialist_result.success + except Exception as e: + # Exception (not BaseException) — CancelledError must propagate so + # the worker can shut down; the finally still emits the run event. + logger.error( + f"[{run_id}] Card refresh specialist failed: {e}", exc_info=True + ) + accumulate_metric(task_name, "card_refresh_error", str(e), "blob") + + duration_ms = (time.perf_counter() - start_time) * 1000 + accumulate_metric(task_name, "total_duration", duration_ms, "ms") + logger.info(f"[{run_id}] Card-refresh dream completed in {duration_ms:.0f}ms") + log_performance_metrics("dream_orchestrator", run_id) + finally: + # Emit DreamRunEvent unconditionally so analytics see a parent for the + # specialist event, mirroring run_dream. Card refresh is a + # deduction-family run, so its outcome rides on deduction_success. + if duration_ms == 0.0: + duration_ms = (time.perf_counter() - start_time) * 1000 + try: + emit( + DreamRunEvent( + run_id=run_id, + workspace_name=workspace_name, + session_name=session_name, + observer=observer, + observed=observed, + specialists_run=["card_refresh"], + deduction_success=specialist_success, + induction_success=False, + surprisal_enabled=False, + surprisal_conclusion_count=0, + total_iterations=( + specialist_result.iterations if specialist_result else 0 + ), + total_input_tokens=( + specialist_result.input_tokens if specialist_result else 0 + ), + total_output_tokens=( + specialist_result.output_tokens if specialist_result else 0 + ), + total_duration_ms=duration_ms, + dream_type=dream_type, + enabled_types_count=len(settings.DREAM.ENABLED_TYPES), + trigger_reason=trigger_reason, + delay_reason=delay_reason, + ) + ) + except Exception: # pragma: no cover - telemetry must not raise + logger.debug("Failed to emit DreamRunEvent", exc_info=True) + + return DreamResult( + run_id=run_id, + specialists_run=["card_refresh"], + deduction_success=specialist_success, + induction_success=False, + surprisal_enabled=False, + surprisal_conclusion_count=0, + total_iterations=specialist_result.iterations if specialist_result else 0, + total_duration_ms=duration_ms, + input_tokens=specialist_result.input_tokens if specialist_result else 0, + output_tokens=specialist_result.output_tokens if specialist_result else 0, + ) + + def _create_queries_from_surprisal( high_surprisal_obs: list[SurprisalScore], ) -> list[str]: @@ -401,6 +550,28 @@ DREAM: {payload.dream_type} documents for {workspace_name}/{payload.observer}/{p update_data={"dream": dream_meta}, ) + case DreamType.CARD_REFRESH: + # Card-only refresh: never touches observations and never + # advances the omni dream guard pair (last_dream_at / + # last_dream_document_count) — a card refresh must not delay + # or satisfy consolidation scheduling. + result = await run_card_refresh_dream( + workspace_name=workspace_name, + observer=payload.observer, + observed=payload.observed, + session_name=payload.session_name, + rebuild=payload.rebuild, + dream_type=payload.dream_type.value, + trigger_reason=payload.trigger_reason, + delay_reason=payload.delay_reason, + ) + if result is not None: + logger.info( + f"Card-refresh dream completed: run_id={result.run_id}, " + + f"iterations={result.total_iterations}, " + + f"duration={result.total_duration_ms:.0f}ms" + ) + except Exception as e: logger.error( f"Error processing dream task {payload.dream_type} for {payload.observer}/{payload.observed}: {str(e)}", diff --git a/src/dreamer/specialists.py b/src/dreamer/specialists.py index f56057f7..61e8a9d3 100644 --- a/src/dreamer/specialists.py +++ b/src/dreamer/specialists.py @@ -32,6 +32,7 @@ from src.telemetry.events import DreamSpecialistEvent, emit from src.telemetry.logging import accumulate_metric, log_performance_metrics from src.telemetry.prometheus.metrics import TokenTypes from src.utils.agent_tools import ( + CARD_REFRESH_SPECIALIST_TOOLS, DEDUCTION_SPECIALIST_TOOLS, INDUCTION_SPECIALIST_TOOLS, create_tool_executor, @@ -70,6 +71,64 @@ class SpecialistResult: # Tool names to exclude when peer card creation is disabled PEER_CARD_TOOL_NAMES = {"update_peer_card"} +# Shared PEER CARD system-prompt section (identity-store taxonomy + rules). +# Used verbatim by DeductionSpecialist and CardRefreshSpecialist. +PEER_CARD_SYSTEM_SECTION = """ + +## PEER CARD (REQUIRED) + +The peer card is the target observee's identity store: stable identity markers that distinguish this entity from others and persist across interactions. Behavior, tendencies, transient state, and episodic facts belong in observations, not on the peer card. + +A peer can be anything with identity that changes over time — a human, an agent, a codebase, a team, an organization. Do not assume the target observee is human. Do not require any field; empty is the correct output when evidence is absent. + +### Allowed entry kinds + +Each entry must start with one of these four prefixes (exact case, followed by a space): + +- `IDENTITY: ...` — canonical name, kind, aliases, IDs + - `IDENTITY: Name: Alice` + - `IDENTITY: Kind: Python monorepo` + - `IDENTITY: Version: 4.2` + - `IDENTITY: Aliases: alice@example.com` +- `ATTRIBUTE: ...` — stable durable property of the entity (including explicitly stated standing preferences) + - `ATTRIBUTE: Location: NYC` + - `ATTRIBUTE: Language: Python` + - `ATTRIBUTE: Prefers tea` + - `ATTRIBUTE: Charter: ship Honcho infrastructure` +- `RELATIONSHIP: ...` — durable link to another entity + - `RELATIONSHIP: Spouse: Bob` + - `RELATIONSHIP: Maintainer: vineeth` + - `RELATIONSHIP: Members: vineeth, rajat` +- `INSTRUCTION: ...` — standing rule of engagement that the target observee has explicitly stated (do/don't for the observer). Only when explicit; never inferred from behavior. + - `INSTRUCTION: Call me Vee` + - `INSTRUCTION: Never push to main without review` + +### Rules + +1. **Stable.** If the value plausibly changes within six months absent a deliberate announcement, it does not belong on the card. Prefer leaving the card empty over filling it with volatile content. +2. **Subject is the target observee.** Every entry must be a fact about the target observee, not about another participant in the session. Never write facts about co-occurring peers into the card, no matter how frequently they appear in the messages. +3. **Evidence-grounded.** Only write what the target observee has explicitly stated, or what another participant has explicitly stated about the target observee with the target observee's assent. No "general knowledge" inferences (`"co-founder"` does not imply an age; mentioning a colleague does not imply a family relationship). +4. **Type-agnostic.** The target observee may not be human. Do not require name/age/location/family/occupation fields. +5. **No behavioral content.** TRAITs, behavioral tendencies, patterns, and inferred preferences belong in observations, not on the peer card. Do not write `TRAIT:` entries or behavioral `PREFERENCE:` entries — they will be rejected. +6. **No evidence bundles.** Each entry is one concise fact. No `e.g.` clauses, no parenthetical example lists, no semicolon-separated value dumps. + +### Migrating an existing peer card + +The CURRENT PEER CARD shown in the user message may contain entries from an older format that do not start with an allowed prefix (e.g. `Name: Alice`, `Lives in NYC`, `TRAIT: Analytical`, `PREFERENCE: Detailed explanations`). When you call `update_peer_card`, you are responsible for re-emitting the entries you want to keep — entries you omit are dropped, and entries without an allowed prefix are silently rejected. + +For each legacy entry: + +- If it is still a valid identity marker, re-emit it under the correct prefix and keep the original content where reasonable. Examples: + - `Name: Alice` → `IDENTITY: Name: Alice` + - `Lives in NYC` → `ATTRIBUTE: Location: NYC` + - `Works at Google` → `ATTRIBUTE: Employer: Google` + - `INSTRUCTION: Call me Vee` → keep as is (already correctly prefixed) +- Drop entries that violate the rules above: behavioral `TRAIT:` lines, inferred behavioral `PREFERENCE:` lines, one-off events, transient state, evidence bundles. Do not re-prefix them — they are not identity markers. + +When in doubt about a specific legacy entry, prefer migrating it (so valid info isn't lost) over dropping it. Splitting one dense legacy entry into multiple correctly-prefixed entries is fine and encouraged (e.g. a semicolon-separated `Tech Stack:` dump can become several `ATTRIBUTE:` lines, one per durable tool/platform). + +Call `update_peer_card` with the complete deduplicated list when there is a durable identity update to record, or when the existing card needs migration. Entries that do not start with one of the four allowed prefixes will be rejected. Keep concise (max 40 entries).""" + class BaseSpecialist(ABC): """Base class for agentic specialists.""" @@ -78,6 +137,10 @@ class BaseSpecialist(ABC): # Whether this specialist is allowed to write to the peer card. Defaults to True; # specialists that should never touch the card (e.g., induction) override to False. can_update_peer_card: bool = True + # Whether the current peer card is fetched and injected into the user prompt. + # Card-refresh runs in rebuild mode set this to False so the card is + # reconstructed solely from observations present in the collection. + inject_peer_card: bool = True # Subclasses can override to customize the peer card update instruction peer_card_update_instruction: str = ( "Only update this with durable identity markers via `update_peer_card`." @@ -218,9 +281,11 @@ If you update it, send the full deduplicated list and remove stale entries. configuration is None or configuration.peer_card.create ) - # Fetch current peer card to inject into prompt (saves a tool call) + # Fetch current peer card to inject into prompt (saves a tool call). + # Skipped when inject_peer_card is False (card-refresh rebuild + # mode): the card must be reconstructed from observations only. current_peer_card: list[str] | None = None - if peer_card_enabled: + if peer_card_enabled and self.inject_peer_card: current_peer_card = await crud.get_peer_card( db, workspace_name=workspace_name, @@ -490,61 +555,7 @@ class DeductionSpecialist(BaseSpecialist): _ = observed peer_card_section = "" if peer_card_enabled: - peer_card_section = """ - -## PEER CARD (REQUIRED) - -The peer card is the target observee's identity store: stable identity markers that distinguish this entity from others and persist across interactions. Behavior, tendencies, transient state, and episodic facts belong in observations, not on the peer card. - -A peer can be anything with identity that changes over time — a human, an agent, a codebase, a team, an organization. Do not assume the target observee is human. Do not require any field; empty is the correct output when evidence is absent. - -### Allowed entry kinds - -Each entry must start with one of these four prefixes (exact case, followed by a space): - -- `IDENTITY: ...` — canonical name, kind, aliases, IDs - - `IDENTITY: Name: Alice` - - `IDENTITY: Kind: Python monorepo` - - `IDENTITY: Version: 4.2` - - `IDENTITY: Aliases: alice@example.com` -- `ATTRIBUTE: ...` — stable durable property of the entity (including explicitly stated standing preferences) - - `ATTRIBUTE: Location: NYC` - - `ATTRIBUTE: Language: Python` - - `ATTRIBUTE: Prefers tea` - - `ATTRIBUTE: Charter: ship Honcho infrastructure` -- `RELATIONSHIP: ...` — durable link to another entity - - `RELATIONSHIP: Spouse: Bob` - - `RELATIONSHIP: Maintainer: vineeth` - - `RELATIONSHIP: Members: vineeth, rajat` -- `INSTRUCTION: ...` — standing rule of engagement that the target observee has explicitly stated (do/don't for the observer). Only when explicit; never inferred from behavior. - - `INSTRUCTION: Call me Vee` - - `INSTRUCTION: Never push to main without review` - -### Rules - -1. **Stable.** If the value plausibly changes within six months absent a deliberate announcement, it does not belong on the card. Prefer leaving the card empty over filling it with volatile content. -2. **Subject is the target observee.** Every entry must be a fact about the target observee, not about another participant in the session. Never write facts about co-occurring peers into the card, no matter how frequently they appear in the messages. -3. **Evidence-grounded.** Only write what the target observee has explicitly stated, or what another participant has explicitly stated about the target observee with the target observee's assent. No "general knowledge" inferences (`"co-founder"` does not imply an age; mentioning a colleague does not imply a family relationship). -4. **Type-agnostic.** The target observee may not be human. Do not require name/age/location/family/occupation fields. -5. **No behavioral content.** TRAITs, behavioral tendencies, patterns, and inferred preferences belong in observations, not on the peer card. Do not write `TRAIT:` entries or behavioral `PREFERENCE:` entries — they will be rejected. -6. **No evidence bundles.** Each entry is one concise fact. No `e.g.` clauses, no parenthetical example lists, no semicolon-separated value dumps. - -### Migrating an existing peer card - -The CURRENT PEER CARD shown in the user message may contain entries from an older format that do not start with an allowed prefix (e.g. `Name: Alice`, `Lives in NYC`, `TRAIT: Analytical`, `PREFERENCE: Detailed explanations`). When you call `update_peer_card`, you are responsible for re-emitting the entries you want to keep — entries you omit are dropped, and entries without an allowed prefix are silently rejected. - -For each legacy entry: - -- If it is still a valid identity marker, re-emit it under the correct prefix and keep the original content where reasonable. Examples: - - `Name: Alice` → `IDENTITY: Name: Alice` - - `Lives in NYC` → `ATTRIBUTE: Location: NYC` - - `Works at Google` → `ATTRIBUTE: Employer: Google` - - `INSTRUCTION: Call me Vee` → keep as is (already correctly prefixed) -- Drop entries that violate the rules above: behavioral `TRAIT:` lines, inferred behavioral `PREFERENCE:` lines, one-off events, transient state, evidence bundles. Do not re-prefix them — they are not identity markers. - -When in doubt about a specific legacy entry, prefer migrating it (so valid info isn't lost) over dropping it. Splitting one dense legacy entry into multiple correctly-prefixed entries is fine and encouraged (e.g. a semicolon-separated `Tech Stack:` dump can become several `ATTRIBUTE:` lines, one per durable tool/platform). - -Call `update_peer_card` with the complete deduplicated list when there is a durable identity update to record, or when the existing card needs migration. Entries that do not start with one of the four allowed prefixes will be rejected. Keep concise (max 40 entries).""" + peer_card_section = PEER_CARD_SYSTEM_SECTION return f"""You are a deductive reasoning agent analyzing observations about the target observee. @@ -765,6 +776,113 @@ Remember: patterns need 2+ sources. Look for tendencies, preferences, and behavi Go.""" +class CardRefreshSpecialist(BaseSpecialist): + """ + Card-only maintenance specialist for the ``card_refresh`` dream type. + + Restricted to peer-card work: it may discover observations + (get_recent_observations, search_memory) and rewrite the peer card + (update_peer_card). It has NO observation-mutating tools — a card refresh + must never create or delete observations. + + Two modes: + - refresh (default): the current card is injected into the prompt and the + specialist folds in new identity markers. + - rebuild: the current card is NOT injected; the specialist reconstructs + the card solely from observations present in the collection. Used after + removals, where the old card may contain facts whose support was deleted. + + Not a singleton — instantiated per run because ``rebuild`` is per-dream + state. + """ + + name: str = "card_refresh" + peer_card_update_instruction: str = "Update this with `update_peer_card`. See the PEER CARD section in the system prompt for the allowed entry kinds and rules." + + # Low iteration ceiling for this lightweight, single-purpose run. + MAX_ITERATIONS_CEILING: int = 6 + + def __init__(self, *, rebuild: bool = False) -> None: + self.rebuild: bool = rebuild + # In rebuild mode the existing card is withheld from the prompt. + self.inject_peer_card: bool = not rebuild + + def get_tools(self, *, peer_card_enabled: bool = True) -> list[dict[str, Any]]: + if peer_card_enabled: + return CARD_REFRESH_SPECIALIST_TOOLS + # Defensive: a card refresh without card write access is a no-op, and + # the orchestrator skips the run entirely when peer cards are disabled. + return [ + t + for t in CARD_REFRESH_SPECIALIST_TOOLS + if t["name"] not in PEER_CARD_TOOL_NAMES + ] + + def get_model_config(self) -> ConfiguredModelSettings: + # Card refresh is a deduction-family task; reuse its model config. + return _require_specialist_model_config( + settings.DREAM.DEDUCTION_MODEL_CONFIG, + specialist_name="DREAM CARD_REFRESH", + ) + + def get_max_tokens(self) -> int: + return 8192 + + def get_max_iterations(self) -> int: + return min(self.MAX_ITERATIONS_CEILING, settings.DREAM.MAX_TOOL_ITERATIONS) + + def build_system_prompt( + self, observed: str, *, peer_card_enabled: bool = True + ) -> str: + _ = observed + _ = peer_card_enabled + rebuild_section = "" + if self.rebuild: + rebuild_section = """ + +## REBUILD MODE + +The existing peer card is deliberately NOT shown to you: it may contain entries whose supporting observations have since been removed. Build the card solely from the observations you find in the collection right now. Do not carry over or guess at prior card content — if an identity marker is not supported by a current observation, it does not go on the card.""" + + return f"""You are a peer-card maintenance agent for the target observee. + +## YOUR JOB + +Refresh the peer card and nothing else. You cannot create or delete observations — you have no tools for that. Your only write operation is `update_peer_card`. + +## PROCESS + +1. Survey the observation space: start with `get_recent_observations`, then use `search_memory` for targeted follow-ups (names, roles, locations, standing instructions). +2. Extract stable identity markers supported by the observations you found. +3. Call `update_peer_card` once with the complete deduplicated list. + +Keep it short — a handful of tool calls at most.{rebuild_section} +{PEER_CARD_SYSTEM_SECTION}""" + + def build_user_prompt( + self, + observed: str, + hints: list[str] | None, + peer_card: list[str] | None = None, + ) -> str: + _ = hints + target_observee_context = self._build_target_observee_context(observed) + peer_card_context = self._build_peer_card_context(peer_card) + + if self.rebuild: + return f"""{target_observee_context}Rebuild the peer card from scratch. + +The previous card is not shown and must not be assumed: reconstruct the card solely from observations currently in the collection. Start with `get_recent_observations`, verify with `search_memory` where needed, then call `update_peer_card` with the complete list. + +Go.""" + + return f"""{target_observee_context}{peer_card_context}Refresh the peer card. + +Review recent observations with `get_recent_observations` (and `search_memory` for targeted checks), then call `update_peer_card` with the complete deduplicated list if there is anything to add, correct, or migrate. If the card is already accurate and complete, finish without updating it. + +Go.""" + + # Singleton instances SPECIALISTS: dict[str, BaseSpecialist] = { "deduction": DeductionSpecialist(), diff --git a/src/routers/workspaces.py b/src/routers/workspaces.py index 8b19fdfd..b7e15a71 100644 --- a/src/routers/workspaces.py +++ b/src/routers/workspaces.py @@ -231,6 +231,7 @@ async def schedule_dream( observed=observed, dream_type=dream_type, session_name=request.session_id, + rebuild=request.rebuild, # Manual route — explicit sentinels for the DreamRunEvent # scheduling-context fields. Auto-schedule threads concrete # threshold/delay reasons (see src/dreamer/dream_scheduler.py); diff --git a/src/schemas/api.py b/src/schemas/api.py index ef3ebbc1..8c8c2cbe 100644 --- a/src/schemas/api.py +++ b/src/schemas/api.py @@ -670,6 +670,14 @@ class ScheduleDreamRequest(BaseModel): session_id: str | None = Field( None, description="Session ID to scope the dream to if specified" ) + rebuild: bool = Field( + False, + description=( + "card_refresh dreams only: rebuild the peer card solely from " + "observations currently in the collection, without injecting the " + "existing card (use after removals)" + ), + ) # --------------------------------------------------------------------------- diff --git a/src/schemas/configuration.py b/src/schemas/configuration.py index b8291cab..53a8c11a 100644 --- a/src/schemas/configuration.py +++ b/src/schemas/configuration.py @@ -17,6 +17,10 @@ class DreamType(str, Enum): """Types of dreams that can be triggered.""" OMNI = "omni" + # Lightweight card-only refresh: runs a single specialist restricted to + # peer-card tools. Used for event-driven refreshes (scope membership + # changes, cold starts) — never creates or deletes observations. + CARD_REFRESH = "card_refresh" class ReasoningConfiguration(BaseModel): diff --git a/src/telemetry/events/llm.py b/src/telemetry/events/llm.py index 8d74d2f6..1761e333 100644 --- a/src/telemetry/events/llm.py +++ b/src/telemetry/events/llm.py @@ -34,6 +34,7 @@ class CallPurpose(str, Enum): DIALECTIC_ANSWER = "dialectic.answer" DREAM_DEDUCTION = "dream.deduction" DREAM_INDUCTION = "dream.induction" + DREAM_CARD_REFRESH = "dream.card_refresh" SUMMARY_SHORT = "summary.short" SUMMARY_LONG = "summary.long" diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index b0cf3051..c5a99207 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -850,6 +850,18 @@ INDUCTION_SPECIALIST_TOOLS: list[dict[str, Any]] = [ TOOLS["create_observations_inductive"], ] +# Tools for the card-refresh specialist (card_refresh dream type). +# Card-only maintenance: discovery plus update_peer_card. Deliberately +# excludes every observation-mutating tool (create_observations*, +# delete_observations) — a card refresh must never touch observations. +CARD_REFRESH_SPECIALIST_TOOLS: list[dict[str, Any]] = [ + # Discovery tools + TOOLS["get_recent_observations"], + TOOLS["search_memory"], + # Action tool + TOOLS["update_peer_card"], +] + async def create_observations( observations: list[schemas.ObservationInput], @@ -1410,6 +1422,21 @@ async def _handle_create_observations_impl( ) ) continue + # Session-purity invariant: explicit observations record what was + # directly derived from a session's messages. Agents that are not + # processing messages (dreamer specialists, dialectic) must not mint + # them — consolidation output belongs at a derived level. + if not ctx.current_messages and validated.level == "explicit": + validation_failures.append( + ObservationFailure( + content_preview=validated.content[:50], + error=( + "Only message ingestion can create 'explicit' observations; " + "use a derived level (deductive/inductive/contradiction)" + ), + ) + ) + continue observations.append(validated) if not observations: diff --git a/src/utils/queue_payload.py b/src/utils/queue_payload.py index 605cf615..5d616b1e 100644 --- a/src/utils/queue_payload.py +++ b/src/utils/queue_payload.py @@ -66,6 +66,11 @@ class DreamPayload(BasePayload): delay_reason: str | None = None documents_since_last_dream_at_schedule: int | None = None document_threshold: int | None = None + # card_refresh only: when True the existing peer card is NOT injected into + # the specialist prompt and the card is rebuilt solely from observations + # currently in the collection (used after removals, where the old card may + # contain facts whose support was deleted). + rebuild: bool = False class DeletionPayload(BasePayload): @@ -103,6 +108,7 @@ def create_dream_payload( delay_reason: str | None = None, documents_since_last_dream_at_schedule: int | None = None, document_threshold: int | None = None, + rebuild: bool = False, ) -> dict[str, Any]: """Create a dream payload.""" return DreamPayload( @@ -114,6 +120,7 @@ def create_dream_payload( delay_reason=delay_reason, documents_since_last_dream_at_schedule=documents_since_last_dream_at_schedule, document_threshold=document_threshold, + rebuild=rebuild, ).model_dump(mode="json", exclude_none=True) diff --git a/tests/crud/test_document.py b/tests/crud/test_document.py index 65768c83..5f03c80b 100644 --- a/tests/crud/test_document.py +++ b/tests/crud/test_document.py @@ -1,4 +1,5 @@ import datetime +from unittest.mock import AsyncMock, patch import pytest from nanoid import generate as generate_nanoid @@ -1004,3 +1005,332 @@ class TestDocumentCRUD: assert len(documents) == 2 assert documents[0].content in ["Observation 1", "Observation 2"] assert documents[1].content in ["Observation 1", "Observation 2"] + + +class TestSessionPurityInvariant: + """Regression tests for the explicit-document session-purity invariant. + + Explicit documents are session-pure records of what was derived from one + session's messages (the Scopes copy-by-session model depends on this): + + - an explicit document must always carry a non-null session_name + - dedup/merge (exact and semantic) must never cross document levels + - dedup/merge must never cross sessions for explicit documents + """ + + async def _setup( + self, + db_session: AsyncSession, + test_workspace: models.Workspace, + test_peer: models.Peer, + ) -> tuple[models.Peer, models.Session, models.Session]: + """Create an observed peer, two sessions, and the collection.""" + test_peer2 = models.Peer( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add(test_peer2) + session_a = models.Session( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + session_b = models.Session( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add_all([session_a, session_b]) + await db_session.flush() + + collection = models.Collection( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + db_session.add(collection) + await db_session.flush() + return test_peer2, session_a, session_b + + def _doc( + self, + content: str, + *, + session_name: str | None, + level: str = "explicit", + message_id: int = 1, + ) -> schemas.DocumentCreate: + return schemas.DocumentCreate( + content=content, + embedding=[0.1] * 1536, + session_name=session_name, + level=level, # pyright: ignore[reportArgumentType] + metadata=schemas.DocumentMetadata( + message_ids=[message_id], + message_created_at="2026-01-01T00:00:00Z", + ), + ) + + async def _live_docs( + self, + db_session: AsyncSession, + workspace_name: str, + observer: str, + observed: str, + ) -> list[models.Document]: + return list( + ( + await db_session.execute( + select(models.Document).where( + models.Document.workspace_name == workspace_name, + models.Document.observer == observer, + models.Document.observed == observed, + models.Document.deleted_at.is_(None), + ) + ) + ) + .scalars() + .all() + ) + + @pytest.mark.asyncio + async def test_explicit_without_session_is_refused( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """An explicit document with session_name=None must not be written; + derived levels remain allowed without a session (dream output).""" + test_workspace, test_peer = sample_data + test_peer2, _, _ = await self._setup(db_session, test_workspace, test_peer) + + accepted = ( + await crud.create_documents( + db_session, + [ + self._doc("Global explicit fact", session_name=None), + self._doc( + "Dream-derived conclusion", + session_name=None, + level="deductive", + message_id=2, + ), + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + ).created_documents + + assert [d.content for d in accepted] == ["Dream-derived conclusion"] + live = await self._live_docs( + db_session, test_workspace.name, test_peer.name, test_peer2.name + ) + assert len(live) == 1 + assert live[0].level == "deductive" + + @pytest.mark.asyncio + async def test_exact_dedup_never_merges_explicit_across_sessions( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """The same explicit fact stated in two sessions produces two + session-pure documents; the other session's row is not reinforced.""" + test_workspace, test_peer = sample_data + test_peer2, session_a, session_b = await self._setup( + db_session, test_workspace, test_peer + ) + + await crud.create_documents( + db_session, + [self._doc("User likes coffee", session_name=session_a.name)], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + accepted = ( + await crud.create_documents( + db_session, + [ + self._doc( + "user likes coffee ", session_name=session_b.name, message_id=2 + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + ).created_documents + + assert len(accepted) == 1 + live = await self._live_docs( + db_session, test_workspace.name, test_peer.name, test_peer2.name + ) + assert len(live) == 2 + assert {doc.session_name for doc in live} == {session_a.name, session_b.name} + assert all(doc.times_derived == 1 for doc in live) + + @pytest.mark.asyncio + async def test_exact_dedup_never_merges_across_levels( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """An explicit fact must not be dropped/reinforced against a derived + document that happens to share its content.""" + test_workspace, test_peer = sample_data + test_peer2, session_a, _ = await self._setup( + db_session, test_workspace, test_peer + ) + + await crud.create_documents( + db_session, + [ + self._doc( + "User likes coffee", session_name=session_a.name, level="deductive" + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + accepted = ( + await crud.create_documents( + db_session, + [ + self._doc( + "User likes coffee", session_name=session_a.name, message_id=2 + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + ).created_documents + + assert len(accepted) == 1 + live = await self._live_docs( + db_session, test_workspace.name, test_peer.name, test_peer2.name + ) + assert len(live) == 2 + assert {doc.level for doc in live} == {"explicit", "deductive"} + assert all(doc.times_derived == 1 for doc in live) + + @pytest.mark.asyncio + async def test_exact_dedup_still_merges_derived_levels_across_sessions( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """Derived levels are consolidations, not session-pure records: + cross-session exact dedup still reinforces the existing row.""" + test_workspace, test_peer = sample_data + test_peer2, session_a, session_b = await self._setup( + db_session, test_workspace, test_peer + ) + + await crud.create_documents( + db_session, + [ + self._doc( + "Probably a morning person", + session_name=session_a.name, + level="deductive", + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + accepted = ( + await crud.create_documents( + db_session, + [ + self._doc( + "probably a morning person", + session_name=session_b.name, + level="deductive", + message_id=2, + ) + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + ).created_documents + + assert len(accepted) == 0 + live = await self._live_docs( + db_session, test_workspace.name, test_peer.name, test_peer2.name + ) + assert len(live) == 1 + assert live[0].times_derived == 2 + + @pytest.mark.asyncio + async def test_semantic_dedup_scoped_to_level_and_session( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """is_rejected_duplicate must constrain candidate search to the same + level, and to the same session for explicit documents.""" + test_workspace, test_peer = sample_data + test_peer2, session_a, _ = await self._setup( + db_session, test_workspace, test_peer + ) + + explicit_doc = self._doc("User likes coffee", session_name=session_a.name) + with patch( + "src.crud.document.query_documents", new=AsyncMock(return_value=[]) + ) as mock_query: + rejected = await is_rejected_duplicate( + db_session, + explicit_doc, + test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + assert rejected is SemanticRejectionResult.NOT_DUPLICATE + assert mock_query.await_args is not None + assert mock_query.await_args.kwargs["filters"] == { + "level": "explicit", + "session_name": session_a.name, + } + + deductive_doc = self._doc( + "User likes coffee", session_name=None, level="deductive" + ) + with patch( + "src.crud.document.query_documents", new=AsyncMock(return_value=[]) + ) as mock_query: + rejected = await is_rejected_duplicate( + db_session, + deductive_doc, + test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + assert rejected is SemanticRejectionResult.NOT_DUPLICATE + assert mock_query.await_args is not None + assert mock_query.await_args.kwargs["filters"] == {"level": "deductive"} + + @pytest.mark.asyncio + async def test_semantic_dedup_refuses_sessionless_explicit( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """A session-less explicit document has no valid merge partner: it is + never treated as a duplicate and no candidate search runs.""" + test_workspace, test_peer = sample_data + test_peer2, _, _ = await self._setup(db_session, test_workspace, test_peer) + + doc = self._doc("User likes coffee", session_name=None) + with patch( + "src.crud.document.query_documents", new=AsyncMock(return_value=[]) + ) as mock_query: + rejected = await is_rejected_duplicate( + db_session, + doc, + test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + assert rejected is SemanticRejectionResult.NOT_DUPLICATE + mock_query.assert_not_awaited() diff --git a/tests/dreamer/test_card_refresh.py b/tests/dreamer/test_card_refresh.py new file mode 100644 index 00000000..43d1edea --- /dev/null +++ b/tests/dreamer/test_card_refresh.py @@ -0,0 +1,335 @@ +"""Tests for the card_refresh dream type (DEV-2000, Scopes RFC prerequisite). + +Covers: +- queue plumbing: payload roundtrip, work-unit key isolation from omni, + enqueue alongside a pending omni dream +- process_dream dispatch of DreamType.CARD_REFRESH (and that it does NOT + advance the omni dream guard pair) +- specialist tool restriction (no observation-mutating tools) +- the low tool-iteration cap +- rebuild mode omitting the prior peer card from the prompt +""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +import pytest_asyncio +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src import models +from src.config import settings +from src.deriver.enqueue import enqueue_dream +from src.dreamer.orchestrator import DreamResult, process_dream +from src.dreamer.specialists import CardRefreshSpecialist +from src.llm import HonchoLLMCallResponse +from src.schemas import DreamType +from src.utils.queue_payload import DreamPayload, create_dream_payload +from src.utils.work_unit import construct_work_unit_key, parse_work_unit_key + +OBSERVATION_MUTATION_TOOLS = { + "create_observations", + "create_observations_deductive", + "create_observations_inductive", + "delete_observations", +} + + +@pytest_asyncio.fixture +async def seeded_collection( + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], +) -> models.Collection: + """Create a Collection with an empty dream metadata dict.""" + workspace, peer = sample_data + collection = models.Collection( + observer=peer.name, + observed=peer.name, + workspace_name=workspace.name, + internal_metadata={}, + ) + db_session.add(collection) + await db_session.commit() + await db_session.refresh(collection) + return collection + + +def _make_card_refresh_result() -> DreamResult: + return DreamResult( + run_id="test_run_card", + specialists_run=["card_refresh"], + deduction_success=True, + induction_success=False, + surprisal_enabled=False, + surprisal_conclusion_count=0, + total_iterations=2, + total_duration_ms=42.0, + input_tokens=10, + output_tokens=5, + ) + + +class TestQueuePlumbing: + def test_payload_roundtrip_carries_rebuild(self): + payload_dict = create_dream_payload( + DreamType.CARD_REFRESH, + observer="alice", + observed="bob", + rebuild=True, + ) + validated = DreamPayload(**payload_dict) + assert validated.dream_type == DreamType.CARD_REFRESH + assert validated.rebuild is True + + # Default is False, including for older payloads missing the field. + assert ( + DreamPayload(dream_type=DreamType.OMNI, observer="a", observed="b").rebuild + is False + ) + + def test_work_unit_key_does_not_collide_with_omni(self): + base = {"task_type": "dream", "observer": "alice", "observed": "bob"} + omni_key = construct_work_unit_key("ws", {**base, "dream_type": "omni"}) + card_key = construct_work_unit_key("ws", {**base, "dream_type": "card_refresh"}) + + assert omni_key != card_key + parsed = parse_work_unit_key(card_key) + assert parsed.task_type == "dream" + assert parsed.dream_type == "card_refresh" + assert parsed.observer == "alice" + assert parsed.observed == "bob" + + @pytest.mark.asyncio + async def test_enqueue_alongside_pending_omni( + self, + db_session: AsyncSession, + seeded_collection: models.Collection, + ): + """A pending omni dream must not dedupe away a card_refresh enqueue — + the work-unit keys differ by dream type.""" + await enqueue_dream( + seeded_collection.workspace_name, + observer=seeded_collection.observer, + observed=seeded_collection.observed, + dream_type=DreamType.OMNI, + ) + await enqueue_dream( + seeded_collection.workspace_name, + observer=seeded_collection.observer, + observed=seeded_collection.observed, + dream_type=DreamType.CARD_REFRESH, + rebuild=True, + ) + + items = ( + ( + await db_session.execute( + select(models.QueueItem).where( + models.QueueItem.workspace_name + == seeded_collection.workspace_name, + models.QueueItem.task_type == "dream", + models.QueueItem.processed == False, # noqa: E712 + ) + ) + ) + .scalars() + .all() + ) + assert len(items) == 2 + dream_types = {item.payload["dream_type"] for item in items} + assert dream_types == {"omni", "card_refresh"} + card_item = next( + item for item in items if item.payload["dream_type"] == "card_refresh" + ) + assert card_item.payload["rebuild"] is True + + +class TestProcessDreamDispatch: + @pytest.mark.asyncio + async def test_dispatches_card_refresh( + self, + seeded_collection: models.Collection, + ): + payload = DreamPayload( + dream_type=DreamType.CARD_REFRESH, + observer=seeded_collection.observer, + observed=seeded_collection.observed, + rebuild=True, + trigger_reason="manual", + ) + + with patch( + "src.dreamer.orchestrator.run_card_refresh_dream", + new=AsyncMock(return_value=_make_card_refresh_result()), + ) as mock_run: + await process_dream(payload, seeded_collection.workspace_name) + + assert mock_run.await_args is not None + kwargs = mock_run.await_args.kwargs + assert kwargs["workspace_name"] == seeded_collection.workspace_name + assert kwargs["observer"] == seeded_collection.observer + assert kwargs["observed"] == seeded_collection.observed + assert kwargs["rebuild"] is True + assert kwargs["dream_type"] == "card_refresh" + assert kwargs["trigger_reason"] == "manual" + + @pytest.mark.asyncio + async def test_card_refresh_does_not_advance_dream_guard( + self, + db_session: AsyncSession, + seeded_collection: models.Collection, + ): + """The omni guard pair (last_dream_at / last_dream_document_count) + must not move on a card refresh — it would delay real consolidation.""" + payload = DreamPayload( + dream_type=DreamType.CARD_REFRESH, + observer=seeded_collection.observer, + observed=seeded_collection.observed, + ) + + with patch( + "src.dreamer.orchestrator.run_card_refresh_dream", + new=AsyncMock(return_value=_make_card_refresh_result()), + ): + await process_dream(payload, seeded_collection.workspace_name) + + await db_session.refresh(seeded_collection) + dream_meta: dict[str, Any] = seeded_collection.internal_metadata.get( + "dream", {} + ) + assert "last_dream_at" not in dream_meta + assert "last_dream_document_count" not in dream_meta + + +class TestCardRefreshSpecialist: + def test_tools_exclude_observation_mutation(self): + for rebuild in (False, True): + specialist = CardRefreshSpecialist(rebuild=rebuild) + tool_names = {t["name"] for t in specialist.get_tools()} + assert tool_names == { + "get_recent_observations", + "search_memory", + "update_peer_card", + } + assert not tool_names & OBSERVATION_MUTATION_TOOLS + + def test_tools_without_peer_card_strip_update(self): + specialist = CardRefreshSpecialist() + tool_names = {t["name"] for t in specialist.get_tools(peer_card_enabled=False)} + assert "update_peer_card" not in tool_names + assert not tool_names & OBSERVATION_MUTATION_TOOLS + + def test_low_iteration_cap(self, monkeypatch: pytest.MonkeyPatch): + specialist = CardRefreshSpecialist() + assert specialist.get_max_iterations() == min( + 6, settings.DREAM.MAX_TOOL_ITERATIONS + ) + + monkeypatch.setattr(settings.DREAM, "MAX_TOOL_ITERATIONS", 4) + assert specialist.get_max_iterations() == 4 + + monkeypatch.setattr(settings.DREAM, "MAX_TOOL_ITERATIONS", 30) + assert specialist.get_max_iterations() == 6 + + def test_rebuild_flag_controls_card_injection(self): + assert CardRefreshSpecialist(rebuild=False).inject_peer_card is True + assert CardRefreshSpecialist(rebuild=True).inject_peer_card is False + + def test_rebuild_prompts_instruct_observation_only_build(self): + specialist = CardRefreshSpecialist(rebuild=True) + system_prompt = specialist.build_system_prompt("alice") + assert "REBUILD MODE" in system_prompt + assert "solely from the observations" in system_prompt + + user_prompt = specialist.build_user_prompt("alice", hints=None, peer_card=None) + assert "Rebuild the peer card" in user_prompt + assert "CURRENT PEER CARD" not in user_prompt + + async def _run_specialist( + self, specialist: CardRefreshSpecialist, stored_card: list[str] + ) -> tuple[AsyncMock, AsyncMock]: + """Run the specialist with a fully mocked LLM layer; returns the + (get_peer_card, honcho_llm_call) mocks for inspection.""" + mock_response = HonchoLLMCallResponse( + content="done", + input_tokens=10, + output_tokens=5, + finish_reasons=["stop"], + ) + mock_get_peer_card = AsyncMock(return_value=stored_card) + mock_llm_call = AsyncMock(return_value=mock_response) + + with ( + patch("src.dreamer.specialists.crud.get_peer", new=AsyncMock()), + patch( + "src.dreamer.specialists.crud.get_peer_card", + new=mock_get_peer_card, + ), + patch( + "src.dreamer.specialists.create_tool_executor", + new=AsyncMock(return_value=AsyncMock()), + ), + patch( + "src.dreamer.specialists.honcho_llm_call", + new=mock_llm_call, + ), + ): + result = await specialist.run( + workspace_name="workspace", + observer="alice", + observed="alice", + session_name=None, + ) + assert result.success is True + return mock_get_peer_card, mock_llm_call + + # Sentinel card entry that cannot collide with the prompt's own examples + # (the shared PEER CARD section contains e.g. "IDENTITY: Name: Alice"). + STORED_CARD: list[str] = [ + "IDENTITY: Name: Zorblax-Prime", + "ATTRIBUTE: Location: Ganymede", + ] + + @pytest.mark.asyncio + async def test_refresh_mode_injects_existing_card( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr(settings.METRICS, "ENABLED", False) + + mock_get_peer_card, mock_llm_call = await self._run_specialist( + CardRefreshSpecialist(rebuild=False), self.STORED_CARD + ) + + mock_get_peer_card.assert_awaited_once() + assert mock_llm_call.await_args is not None + kwargs = mock_llm_call.await_args.kwargs + user_message = kwargs["messages"][1]["content"] + assert "IDENTITY: Name: Zorblax-Prime" in user_message + assert "CURRENT PEER CARD" in user_message + # Restricted tool offering and low iteration cap reach the LLM call. + tool_names = {t["name"] for t in kwargs["tools"]} + assert not tool_names & OBSERVATION_MUTATION_TOOLS + assert kwargs["max_tool_iterations"] == min( + 6, settings.DREAM.MAX_TOOL_ITERATIONS + ) + + @pytest.mark.asyncio + async def test_rebuild_mode_omits_existing_card( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr(settings.METRICS, "ENABLED", False) + + mock_get_peer_card, mock_llm_call = await self._run_specialist( + CardRefreshSpecialist(rebuild=True), self.STORED_CARD + ) + + # The stored card is never even fetched, let alone injected. + mock_get_peer_card.assert_not_awaited() + assert mock_llm_call.await_args is not None + kwargs = mock_llm_call.await_args.kwargs + for message in kwargs["messages"]: + assert "IDENTITY: Name: Zorblax-Prime" not in message["content"] + # No CURRENT PEER CARD block in the user prompt (the system prompt's + # shared taxonomy section legitimately mentions the phrase). + assert "CURRENT PEER CARD" not in kwargs["messages"][1]["content"] diff --git a/tests/routes/test_workspaces.py b/tests/routes/test_workspaces.py index 0350b729..5934ccfd 100644 --- a/tests/routes/test_workspaces.py +++ b/tests/routes/test_workspaces.py @@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import models from src.models import Peer, Workspace +from src.schemas import DreamType def test_get_or_create_workspace(client: TestClient): @@ -758,3 +759,52 @@ async def test_schedule_dream_invokes_enqueue_dream( "Loop 4: enqueue_dream no longer accepts document_count; the baseline " "is written atomically with last_dream_at in process_dream." ) + + +@pytest.mark.asyncio +async def test_schedule_dream_card_refresh_forwards_rebuild( + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], +): + """POST /schedule_dream accepts dream_type=card_refresh and forwards the + rebuild flag to enqueue_dream (manual/event-driven card refreshes bypass + the volume gates by design).""" + workspace, peer = sample_data + + collection = models.Collection( + observer=peer.name, + observed=peer.name, + workspace_name=workspace.name, + internal_metadata={}, + ) + db_session.add(collection) + await db_session.commit() + + captured: dict[str, Any] = {} + + async def fake_enqueue_dream(*args: Any, **kwargs: Any) -> None: + captured["args"] = args + captured["kwargs"] = kwargs + + with ( + patch("src.routers.workspaces.settings.DREAM.ENABLED", True), + patch( + "src.routers.workspaces.enqueue_dream", + new=AsyncMock(side_effect=fake_enqueue_dream), + ), + ): + response = client.post( + f"/v3/workspaces/{workspace.name}/schedule_dream", + json={ + "observer": peer.name, + "observed": peer.name, + "dream_type": "card_refresh", + "rebuild": True, + }, + ) + + assert response.status_code == 204, response.text + assert "kwargs" in captured, "enqueue_dream was not called" + assert captured["kwargs"]["dream_type"] == DreamType.CARD_REFRESH + assert captured["kwargs"]["rebuild"] is True diff --git a/tests/telemetry/test_events.py b/tests/telemetry/test_events.py index bce0a38c..a5d959e5 100644 --- a/tests/telemetry/test_events.py +++ b/tests/telemetry/test_events.py @@ -239,6 +239,7 @@ class TestLLMCallCompletedEvent: assert CallPurpose.DIALECTIC_ANSWER.value == "dialectic.answer" assert CallPurpose.DREAM_DEDUCTION.value == "dream.deduction" assert CallPurpose.DREAM_INDUCTION.value == "dream.induction" + assert CallPurpose.DREAM_CARD_REFRESH.value == "dream.card_refresh" assert CallPurpose.SUMMARY_SHORT.value == "summary.short" assert CallPurpose.SUMMARY_LONG.value == "summary.long" diff --git a/tests/utils/test_agent_tools.py b/tests/utils/test_agent_tools.py index 3f2dad6b..9bfb3990 100644 --- a/tests/utils/test_agent_tools.py +++ b/tests/utils/test_agent_tools.py @@ -248,6 +248,36 @@ class TestCreateObservations: assert doc.level == "deductive" assert doc.source_ids == ["premise1", "premise2"] + async def test_non_deriver_context_rejects_explicit( + self, + db_session: AsyncSession, + make_tool_context: Callable[..., ToolContext], + ): + """Session-purity invariant: agents without current_messages (dreamer + specialists, dialectic) must not create explicit-level observations, + even when they pass level='explicit' to the generic tool.""" + ctx = make_tool_context(current_messages=None) + + result = await _handle_create_observations( + ctx, + { + "observations": [ + {"content": "Claims to be a doctor", "level": "explicit"}, + ] + }, + ) + + assert isinstance(result, str) + assert "ERROR" in result + assert "explicit" in result + + # Verify nothing landed in the DB + stmt = select(models.Document).where( + models.Document.content == "Claims to be a doctor" + ) + doc = (await db_session.execute(stmt)).scalar_one_or_none() + assert doc is None + async def test_source_ids_display_prefix_is_stripped( self, db_session: AsyncSession, From 672f4c66374f10714e62252e1c207b427a3e5fb3 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:41:34 -0400 Subject: [PATCH 60/65] fix: apply session scoping to all working-representation query paths (#881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: apply session scoping to all working-representation query paths session_name was only applied to the recent-documents query in RepresentationManager; the semantic and most-derived paths ignored it, so limit_to_session leaked cross-session conclusions into perspectives. - Thread a session allowlist (session_names) uniformly through all three query paths; pushed down to pgvector and external vector stores - Accept a list so the upcoming session-allowlist API reuses this path - Fail closed on an empty allowlist (downstream stores drop empty IN clauses, which would silently widen scope) Fixes DEV-1994 Co-Authored-By: Claude Fable 5 * feat: bare-list membership sugar in the filter DSL {"session_id": ["s1", "s2"]} is now shorthand for {"session_id": {"in": [...]}} on regular columns, generically (peer_id, etc.). JSONB metadata columns are excluded — a bare list there keeps JSONB containment semantics, unchanged. Previously a bare list on a regular column compiled to a type-mismatched equality that matched nothing, so this is strictly additive. Also translates the same shape in the turbopuffer/lancedb filter builders, and fixes lancedb dropping empty IN clauses (fail-open) — an empty membership list now emits an always-false condition. Groundwork for DEV-1995 (session allowlist via the existing filters DSL, no new API params) Co-Authored-By: Claude Fable 5 * fix: fail closed on empty session allowlist across all filter builders Empty session allowlists relied solely on the early-return guard in _get_working_representation_internal. The layers below it were inconsistent, so a future direct caller (the DEV-1995 allowlist API) would silently widen scope instead of failing closed: - _build_filter_conditions used a truthiness check; an empty list was treated like None and dropped the filter. Now uses `is not None`, matching the recent/most-derived SQL paths. - turbopuffer emitted a bare `In []` with undocumented (possibly fail-open) semantics. Now emits an explicit always-false predicate, mirroring lancedb's `1 = 0`. Also extract the duplicated JSONB column tuple in filter.py to a JSONB_COLUMNS constant. Tests exercise each fail-closed guarantee at the layer it lives, rather than masking it behind the early-return guard. --------- Co-authored-by: Claude Fable 5 --- src/crud/representation.py | 59 ++++-- src/routers/peers.py | 6 +- src/routers/sessions.py | 8 +- src/utils/filter.py | 17 +- src/vector_store/lancedb.py | 18 +- src/vector_store/turbopuffer.py | 28 ++- tests/crud/test_representation_manager.py | 218 ++++++++++++++++++++++ tests/test_advanced_filters.py | 80 ++++++++ tests/vector_store/test_lancedb.py | 21 +++ tests/vector_store/test_turbopuffer.py | 24 +++ 10 files changed, 446 insertions(+), 33 deletions(-) diff --git a/src/crud/representation.py b/src/crud/representation.py index 3061c2d9..69c9edc9 100644 --- a/src/crud/representation.py +++ b/src/crud/representation.py @@ -213,7 +213,7 @@ class RepresentationManager: self, *, db: AsyncSession | None = None, - session_name: str | None = None, + session_names: list[str] | None = None, include_semantic_query: str | None = None, embedding: list[float] | None = None, semantic_search_top_k: int | None = None, @@ -229,7 +229,10 @@ class RepresentationManager: Args: db: Optional database session. If provided, uses it directly; otherwise creates a new session via tracked_db. - session_name: Optional session to filter by + session_names: Optional session allowlist to filter by. Applied + uniformly to every query path (semantic, most-derived, and + recent). None means no session restriction; an empty list + fail-closes to an empty representation. include_semantic_query: Query for semantic search embedding: Pre-computed embedding for the semantic query. semantic_search_top_k: Number of semantic results @@ -267,7 +270,7 @@ class RepresentationManager: if db is not None: return await self._get_working_representation_internal( db, - session_name=session_name, + session_names=session_names, include_semantic_query=include_semantic_query, embedding=embedding, semantic_search_top_k=semantic_search_top_k, @@ -281,7 +284,7 @@ class RepresentationManager: ) as new_db: return await self._get_working_representation_internal( new_db, - session_name=session_name, + session_names=session_names, include_semantic_query=include_semantic_query, embedding=embedding, semantic_search_top_k=semantic_search_top_k, @@ -296,7 +299,7 @@ class RepresentationManager: self, db: AsyncSession, *, - session_name: str | None = None, + session_names: list[str] | None = None, include_semantic_query: str | None = None, embedding: list[float] | None = None, semantic_search_top_k: int | None = None, @@ -305,6 +308,12 @@ class RepresentationManager: max_observations: int = settings.DERIVER.WORKING_REPRESENTATION_MAX_OBSERVATIONS, ) -> Representation: """Internal implementation of get_working_representation.""" + # Fail closed on an empty allowlist. This must short-circuit before + # any query: downstream stores drop an `IN ()` clause with an empty + # list (lancedb), which would silently widen the scope instead. + if session_names is not None and not session_names: + return Representation() + total = max_observations # Calculate how many observations to get from each source @@ -345,6 +354,7 @@ class RepresentationManager: top_k=semantic_observations, max_distance=semantic_search_max_distance, embedding=embedding, + session_names=session_names, ) representation.merge_representation( Representation.from_documents(semantic_docs) @@ -353,7 +363,7 @@ class RepresentationManager: # Get most derived observations if requested if include_most_derived: derived_docs = await self._query_documents_most_derived( - db, top_k=top_observations + db, top_k=top_observations, session_names=session_names ) representation.merge_representation( Representation.from_documents(derived_docs) @@ -361,7 +371,7 @@ class RepresentationManager: # Get recent observations recent_docs = await self._query_documents_recent( - db, top_k=recent_observations, session_name=session_name + db, top_k=recent_observations, session_names=session_names ) representation.merge_representation(Representation.from_documents(recent_docs)) @@ -376,6 +386,7 @@ class RepresentationManager: max_distance: float | None = None, level: str | None = None, embedding: list[float] | None = None, + session_names: list[str] | None = None, ) -> list[models.Document]: """Query documents by semantic similarity.""" try: @@ -387,6 +398,7 @@ class RepresentationManager: top_k, max_distance, embedding=embedding, + session_names=session_names, ) else: documents = await crud.query_documents( @@ -398,6 +410,8 @@ class RepresentationManager: max_distance=max_distance, top_k=top_k, embedding=embedding, + filters=self._build_filter_conditions(session_names=session_names) + or None, ) db.expunge_all() return list(documents) @@ -407,7 +421,7 @@ class RepresentationManager: return [] async def _query_documents_recent( - self, db: AsyncSession, top_k: int, session_name: str | None = None + self, db: AsyncSession, top_k: int, session_names: list[str] | None = None ) -> list[models.Document]: """Query most recent documents.""" stmt = ( @@ -419,8 +433,8 @@ class RepresentationManager: models.Document.observed == self.observed, models.Document.deleted_at.is_(None), *( - [models.Document.session_name == session_name] - if session_name is not None + [models.Document.session_name.in_(session_names)] + if session_names is not None else [] ), ) @@ -433,7 +447,7 @@ class RepresentationManager: return list(documents) async def _query_documents_most_derived( - self, db: AsyncSession, top_k: int + self, db: AsyncSession, top_k: int, session_names: list[str] | None = None ) -> list[models.Document]: """Query most derived documents.""" stmt = ( @@ -444,6 +458,11 @@ class RepresentationManager: models.Document.observer == self.observer, models.Document.observed == self.observed, models.Document.deleted_at.is_(None), + *( + [models.Document.session_name.in_(session_names)] + if session_names is not None + else [] + ), ) .order_by( models.Document.times_derived.desc(), @@ -480,6 +499,7 @@ class RepresentationManager: count: int, max_distance: float | None = None, embedding: list[float] | None = None, + session_names: list[str] | None = None, ) -> list[models.Document]: """Query documents for a specific level.""" documents = await crud.query_documents( @@ -490,7 +510,7 @@ class RepresentationManager: query=query, max_distance=max_distance, top_k=count, - filters=self._build_filter_conditions(level), + filters=self._build_filter_conditions(level, session_names=session_names), embedding=embedding, ) @@ -503,17 +523,28 @@ class RepresentationManager: def _build_filter_conditions( self, level: str | None = None, + session_names: list[str] | None = None, ) -> dict[str, Any]: """ Build filter conditions for document queries. Returns a flat dict of key-value pairs for vector store filtering. + Callers must not pass an empty session_names list — empty allowlists + fail closed before any query is issued (see + _get_working_representation_internal). """ filters: dict[str, Any] = {} if level: filters["level"] = level + # `is not None` (not truthiness): an explicit empty allowlist must emit + # an empty `in` so downstream stores fail closed, matching + # _query_documents_recent / _query_documents_most_derived. Truthiness + # here would silently drop the filter and widen scope. + if session_names is not None: + filters["session_name"] = {"in": session_names} + return filters @@ -526,7 +557,7 @@ async def get_working_representation( db: AsyncSession | None = None, observer: str, observed: str, - session_name: str | None = None, + session_names: list[str] | None = None, include_semantic_query: str | None = None, embedding: list[float] | None = None, semantic_search_top_k: int | None = None, @@ -559,7 +590,7 @@ async def get_working_representation( ) return await manager.get_working_representation( db=db, - session_name=session_name, + session_names=session_names, include_semantic_query=include_semantic_query, embedding=embedding, semantic_search_top_k=semantic_search_top_k, diff --git a/src/routers/peers.py b/src/routers/peers.py index b61bf442..d6e35269 100644 --- a/src/routers/peers.py +++ b/src/routers/peers.py @@ -312,7 +312,9 @@ async def get_representation( workspace_id, observer=peer_id, observed=options.target if options.target is not None else peer_id, - session_name=options.session_id, + session_names=[options.session_id] + if options.session_id is not None + else None, include_semantic_query=options.search_query, embedding=embedding, semantic_search_top_k=options.search_top_k, @@ -475,7 +477,7 @@ async def get_peer_context( workspace_id, observer=peer_id, observed=observed, - session_name=None, # Peer context is global, not session-scoped + session_names=None, # Peer context is global, not session-scoped include_semantic_query=search_query, embedding=embedding, semantic_search_top_k=search_top_k, diff --git a/src/routers/sessions.py b/src/routers/sessions.py index 89ce665c..3cc34043 100644 --- a/src/routers/sessions.py +++ b/src/routers/sessions.py @@ -43,7 +43,7 @@ async def _get_working_representation_task( *, observer: str, observed: str, - session_name: str | None, + session_names: list[str] | None, search_top_k: int | None, search_max_distance: float | None, include_most_derived: bool, @@ -59,7 +59,7 @@ async def _get_working_representation_task( last_message: Optional last message for semantic query observer: Name of the observer peer observed: Name of the observed peer - session_name: Optional session to filter by + session_names: Optional session allowlist to filter by search_top_k: Number of semantic-search-retrieved observations to include in the representation search_max_distance: Maximum distance to search for semantically relevant observations include_most_derived: Whether to include the most derived observations in the representation @@ -74,7 +74,7 @@ async def _get_working_representation_task( db=db, observer=observer, observed=observed, - session_name=session_name, + session_names=session_names, include_semantic_query=last_message, semantic_search_top_k=search_top_k, semantic_search_max_distance=search_max_distance, @@ -765,7 +765,7 @@ async def get_session_context( search_query, observer=observer, observed=observed, - session_name=session_id if limit_to_session else None, + session_names=[session_id] if limit_to_session else None, search_top_k=search_top_k, search_max_distance=search_max_distance, include_most_derived=include_most_frequent, diff --git a/src/utils/filter.py b/src/utils/filter.py index 1394a8c6..36e34f4d 100644 --- a/src/utils/filter.py +++ b/src/utils/filter.py @@ -1,7 +1,8 @@ import datetime -from collections.abc import Callable +from collections.abc import Callable, Sequence from logging import getLogger from typing import Any, TypeVar +from typing import cast as typing_cast from sqlalchemy import ColumnElement, Select, and_, case, cast, literal, not_, or_ from sqlalchemy.types import Numeric @@ -28,6 +29,10 @@ COMPARISON_OPERATORS = { NUMERIC_OPERATORS = {"gte", "lte", "gt", "lt", "ne"} +# JSONB columns keep containment semantics: bare lists are not membership +# sugar, and dict values map to nested-metadata conditions rather than IN/Eq. +JSONB_COLUMNS = ("h_metadata", "configuration", "internal_metadata") + ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING = { "id": "name", "created_at": "created_at", @@ -238,6 +243,12 @@ def _build_field_condition( if value == "*": return None + # Bare-list sugar on regular columns: {"session_id": ["a", "b"]} is + # shorthand for {"session_id": {"in": ["a", "b"]}}. JSONB columns are + # excluded — a bare list there keeps JSONB containment semantics. + if isinstance(value, list | tuple | set) and column_name not in JSONB_COLUMNS: + value = {"in": list(typing_cast(Sequence[Any], value))} + # Handle comparison operators vs regular values if isinstance(value, dict): # Check if this is a comparison operators dict by looking for known operators @@ -248,12 +259,12 @@ def _build_field_condition( else: # This is a regular value that happens to be a dict # For JSONB fields (metadata, configuration), check if it contains nested comparison operators - if column_name in ("h_metadata", "configuration", "internal_metadata"): + if column_name in JSONB_COLUMNS: return _build_nested_metadata_conditions(column, value) # pyright: ignore else: return column == value else: - if column_name in ("h_metadata", "configuration", "internal_metadata"): + if column_name in JSONB_COLUMNS: return column.contains(value) else: return column == value diff --git a/src/vector_store/lancedb.py b/src/vector_store/lancedb.py index 0b621971..1b4c4880 100644 --- a/src/vector_store/lancedb.py +++ b/src/vector_store/lancedb.py @@ -298,10 +298,15 @@ class LanceDBVectorStore(VectorStore): if not _VALID_IDENTIFIER_PATTERN.match(key): raise ValueError(f"Invalid filter key: {key!r}") - # Check if value is a dict with "in" operator - if isinstance(value, dict) and "in" in value: - # IN clause for list membership - in_values = cast(Sequence[Any], value["in"]) + # Membership: dict form {"in": [...]} or bare-list sugar + if (isinstance(value, dict) and "in" in value) or isinstance( + value, list | tuple | set + ): + in_values = ( + cast(Sequence[Any], value["in"]) + if isinstance(value, dict) + else list(cast(Sequence[Any], value)) + ) if in_values: escaped_values = [ f"'{str(v).replace(chr(39), chr(39) + chr(39))}'" @@ -310,6 +315,11 @@ class LanceDBVectorStore(VectorStore): for v in in_values ] conditions.append(f"{key} IN ({', '.join(escaped_values)})") + else: + # An empty membership list matches nothing. Emitting no + # condition would silently widen the result set + # (fail-open); force an always-false condition instead. + conditions.append("1 = 0") # Handle string values with proper quoting elif isinstance(value, str): # Escape single quotes in the value diff --git a/src/vector_store/turbopuffer.py b/src/vector_store/turbopuffer.py index ded0c691..e7825310 100644 --- a/src/vector_store/turbopuffer.py +++ b/src/vector_store/turbopuffer.py @@ -20,9 +20,7 @@ from . import VectorQueryResult, VectorRecord, VectorStore logger = logging.getLogger(__name__) -# Type aliases for Turbopuffer's filter formats -EqFilter = tuple[str, Literal["Eq"], Any] -InFilter = tuple[str, Literal["In"], Sequence[Any]] +# Type alias for Turbopuffer's AND filter format AndFilter = tuple[Literal["And"], Sequence[Filter]] DISTANCE_METRIC = "cosine_distance" @@ -245,13 +243,17 @@ class TurbopufferVectorStore(VectorStore): if not filters: return None - filter_list: list[EqFilter | InFilter] = [] + filter_list: list[Filter] = [] for key, value in filters.items(): # Check if value is a dict with "in" operator if isinstance(value, dict) and "in" in value: # Membership filter using "In" operator - in_values = cast(Sequence[Any], value["in"]) - filter_list.append((key, "In", in_values)) + in_values = list(cast(Sequence[Any], value["in"])) + filter_list.append(self._membership_filter(key, in_values)) + elif isinstance(value, list | tuple | set): + # Bare-list sugar: same membership semantics as {"in": [...]} + in_values = list(cast(Sequence[Any], value)) + filter_list.append(self._membership_filter(key, in_values)) else: # Simple equality filter using "Eq" operator filter_list.append((key, "Eq", cast(Any, value))) @@ -266,6 +268,20 @@ class TurbopufferVectorStore(VectorStore): and_filter: AndFilter = ("And", filter_list) return and_filter + @staticmethod + def _membership_filter(key: str, values: list[Any]) -> Filter: + """Build an "In" membership filter, failing closed on an empty list. + + Turbopuffer's empty-"In" semantics are undocumented, so an empty + allowlist emits an explicit contradiction (`Eq(x) AND NotEq(x)` is + false for every document) rather than risk a fail-open widening. + Mirrors lancedb's `1 = 0` guard. + """ + if not values: + never: AndFilter = ("And", [(key, "Eq", ""), (key, "NotEq", "")]) + return never + return (key, "In", values) + async def delete_many(self, namespace: str, ids: list[str]) -> None: """ Delete multiple vectors from Turbopuffer. diff --git a/tests/crud/test_representation_manager.py b/tests/crud/test_representation_manager.py index f4be22dd..b1c24c01 100644 --- a/tests/crud/test_representation_manager.py +++ b/tests/crud/test_representation_manager.py @@ -233,6 +233,224 @@ class TestRepresentationManagerSoftDelete: assert contents[1:] == ["tie 2", "tie 1", "tie 0"] +class TestRepresentationManagerSessionScoping: + """Tests that the session allowlist is applied uniformly to every query path. + + Regression for DEV-1994: session_name used to be applied only to the + recent-documents query; the semantic and most-derived paths ignored it, + so limit_to_session leaked cross-session conclusions. + """ + + async def _setup( + self, + db_session: AsyncSession, + test_workspace: models.Workspace, + test_peer: models.Peer, + ) -> tuple[models.Session, models.Session, RepresentationManager]: + """Create two sessions and documents in each, plus a session-less doc.""" + test_peer2 = models.Peer( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add(test_peer2) + await db_session.flush() + + session_a = models.Session( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + session_b = models.Session( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add_all([session_a, session_b]) + await db_session.flush() + + collection = models.Collection( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + db_session.add(collection) + await db_session.flush() + + db_session.add_all( + [ + models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content="in-scope observation", + session_name=session_a.name, + times_derived=1, + ), + models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content="out-of-scope observation", + session_name=session_b.name, + times_derived=100, + ), + # Dream-produced documents have no session_name; a session + # allowlist must exclude them (fail-closed). + models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content="sessionless dream observation", + session_name=None, + times_derived=50, + ), + ] + ) + await db_session.flush() + + manager = RepresentationManager( + test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + return session_a, session_b, manager + + @pytest.mark.asyncio + async def test_recent_respects_session_allowlist( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + test_workspace, test_peer = sample_data + session_a, _, manager = await self._setup(db_session, test_workspace, test_peer) + + results = await manager._query_documents_recent( # pyright: ignore[reportPrivateUsage] + db_session, top_k=10, session_names=[session_a.name] + ) + + contents = [doc.content for doc in results] + assert contents == ["in-scope observation"] + + @pytest.mark.asyncio + async def test_most_derived_respects_session_allowlist( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """The out-of-scope doc has far higher times_derived; it must still be excluded.""" + test_workspace, test_peer = sample_data + session_a, _, manager = await self._setup(db_session, test_workspace, test_peer) + + results = await manager._query_documents_most_derived( # pyright: ignore[reportPrivateUsage] + db_session, top_k=10, session_names=[session_a.name] + ) + + contents = [doc.content for doc in results] + assert contents == ["in-scope observation"] + + @pytest.mark.asyncio + async def test_semantic_passes_session_allowlist_as_filters( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """The semantic path must push the allowlist down to query_documents.""" + test_workspace, test_peer = sample_data + session_a, _, manager = await self._setup(db_session, test_workspace, test_peer) + + with patch( + "src.crud.query_documents", new=AsyncMock(return_value=[]) + ) as mock_query: + await manager._query_documents_semantic( # pyright: ignore[reportPrivateUsage] + db_session, + query="anything", + top_k=5, + embedding=[0.1], + session_names=[session_a.name], + ) + + assert mock_query.await_args is not None + assert mock_query.await_args.kwargs["filters"] == { + "session_name": {"in": [session_a.name]} + } + + @pytest.mark.asyncio + async def test_semantic_passes_no_filters_when_unscoped( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + test_workspace, test_peer = sample_data + _, _, manager = await self._setup(db_session, test_workspace, test_peer) + + with patch( + "src.crud.query_documents", new=AsyncMock(return_value=[]) + ) as mock_query: + await manager._query_documents_semantic( # pyright: ignore[reportPrivateUsage] + db_session, + query="anything", + top_k=5, + embedding=[0.1], + ) + + assert mock_query.await_args is not None + assert mock_query.await_args.kwargs["filters"] is None + + @pytest.mark.asyncio + async def test_working_representation_scoped_end_to_end( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """All blended paths active: only in-scope content may appear.""" + test_workspace, test_peer = sample_data + session_a, _, manager = await self._setup(db_session, test_workspace, test_peer) + + representation = await manager.get_working_representation( + db=db_session, + session_names=[session_a.name], + include_most_derived=True, + ) + + contents = [obs.content for obs in representation.explicit] + assert "in-scope observation" in contents + assert "out-of-scope observation" not in contents + assert "sessionless dream observation" not in contents + + @pytest.mark.asyncio + async def test_empty_allowlist_fails_closed( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """An empty allowlist must return an empty representation, not fall + back to unscoped behavior (downstream stores drop empty IN clauses).""" + test_workspace, test_peer = sample_data + _, _, manager = await self._setup(db_session, test_workspace, test_peer) + + representation = await manager.get_working_representation( + db=db_session, + session_names=[], + include_most_derived=True, + ) + + assert representation.explicit == [] + assert representation.deductive == [] + + def test_build_filter_conditions_empty_allowlist_fails_closed(self): + """The filter-builder layer itself must fail closed, independent of the + early-return guard in _get_working_representation_internal. An empty + allowlist emits an empty `in` (renders as always-false downstream), not + an omitted filter.""" + manager = RepresentationManager( + "workspace", observer="observer", observed="observed" + ) + + assert manager._build_filter_conditions(session_names=[]) == { # pyright: ignore[reportPrivateUsage] + "session_name": {"in": []} + } + # None means unscoped — no session filter emitted. + assert manager._build_filter_conditions(session_names=None) == {} # pyright: ignore[reportPrivateUsage] + assert manager._build_filter_conditions(session_names=["s1"]) == { # pyright: ignore[reportPrivateUsage] + "session_name": {"in": ["s1"]} + } + + class TestRepresentationManagerSave: @pytest.mark.asyncio async def test_save_representation_filters_blank_observations_before_embedding( diff --git a/tests/test_advanced_filters.py b/tests/test_advanced_filters.py index 768f29bc..3b187470 100644 --- a/tests/test_advanced_filters.py +++ b/tests/test_advanced_filters.py @@ -235,6 +235,86 @@ async def test_comparison_operators_filters( ), f"Unexpected message '{message_config['content']}' found in results for {description}" +@pytest.mark.asyncio +async def test_bare_list_membership_sugar( + client: TestClient, + sample_data: tuple[Workspace, Peer], +): + """A bare list on a regular column is shorthand for {"in": [...]}. + + JSONB metadata columns are excluded from the sugar: a bare list there + keeps JSONB containment semantics. + """ + test_workspace, test_peer = sample_data + + # Second peer so peer_id membership has something to exclude + peer2_name = str(generate_nanoid()) + client.post( + f"/v3/workspaces/{test_workspace.name}/peers", + json={"id": peer2_name}, + ) + + session_id = str(generate_nanoid()) + session_response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions", + json={ + "id": session_id, + "peer_names": {test_peer.name: {}, peer2_name: {}}, + }, + ) + assert session_response.status_code == 201 + + message_configs = [ + { + "content": "From peer one", + "peer_id": test_peer.name, + "metadata": {"tags": ["important", "urgent"]}, + }, + { + "content": "From peer two", + "peer_id": peer2_name, + "metadata": {"tags": ["normal"]}, + }, + ] + messages_response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + json={"messages": message_configs}, + ) + assert messages_response.status_code == 201 + + def list_contents(filter_config: dict[str, Any]) -> list[str]: + response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + json={"filters": filter_config}, + ) + assert response.status_code == 200 + return [item["content"] for item in response.json()["items"]] + + # Bare list == membership on a regular column + assert list_contents({"peer_id": [test_peer.name]}) == ["From peer one"] + + # Multiple values + assert sorted(list_contents({"peer_id": [test_peer.name, peer2_name]})) == [ + "From peer one", + "From peer two", + ] + + # Equivalent to the explicit {"in": [...]} form + assert list_contents({"peer_id": [test_peer.name]}) == list_contents( + {"peer_id": {"in": [test_peer.name]}} + ) + + # Empty list matches nothing (fail-closed), never everything + assert list_contents({"peer_id": []}) == [] + + # JSONB metadata keeps containment semantics for bare lists: + # matches arrays containing ALL listed elements, not membership. + assert list_contents({"metadata": {"tags": ["important", "urgent"]}}) == [ + "From peer one" + ] + assert list_contents({"metadata": {"tags": ["important", "missing"]}}) == [] + + @pytest.mark.asyncio async def test_wildcard_filters( client: TestClient, sample_data: tuple[Workspace, Peer] diff --git a/tests/vector_store/test_lancedb.py b/tests/vector_store/test_lancedb.py index 0c502e4c..9b52708a 100644 --- a/tests/vector_store/test_lancedb.py +++ b/tests/vector_store/test_lancedb.py @@ -37,6 +37,27 @@ def store() -> LanceDBVectorStore: return LanceDBVectorStore() +def test_build_where_clause_membership(store: LanceDBVectorStore) -> None: + """Both the dict `in` form and the bare-list sugar produce an IN clause.""" + assert ( + store._build_where_clause({"session_name": {"in": ["s1", "s2"]}}) # pyright: ignore[reportPrivateUsage] + == "session_name IN ('s1', 's2')" + ) + assert ( + store._build_where_clause({"session_name": ["s1", "s2"]}) # pyright: ignore[reportPrivateUsage] + == "session_name IN ('s1', 's2')" + ) + + +def test_build_where_clause_empty_membership_fails_closed( + store: LanceDBVectorStore, +) -> None: + """An empty membership list must emit an always-false predicate, never an + omitted condition that would widen scope (fail-open).""" + assert store._build_where_clause({"session_name": {"in": []}}) == "1 = 0" # pyright: ignore[reportPrivateUsage] + assert store._build_where_clause({"session_name": []}) == "1 = 0" # pyright: ignore[reportPrivateUsage] + + @pytest.mark.asyncio async def test_query_returns_empty_when_table_missing( store: LanceDBVectorStore, diff --git a/tests/vector_store/test_turbopuffer.py b/tests/vector_store/test_turbopuffer.py index ca8c4ff3..cdae44d5 100644 --- a/tests/vector_store/test_turbopuffer.py +++ b/tests/vector_store/test_turbopuffer.py @@ -53,6 +53,30 @@ async def test_upsert_many_raises_vector_store_error_on_5xx( namespace_mock.write.assert_awaited_once() +def test_build_filters_membership(store: TurbopufferVectorStore) -> None: + """Both the dict `in` form and the bare-list sugar produce an In filter.""" + assert store._build_filters({"session_name": {"in": ["s1", "s2"]}}) == ( # pyright: ignore[reportPrivateUsage] + "session_name", + "In", + ["s1", "s2"], + ) + assert store._build_filters({"session_name": ["s1", "s2"]}) == ( # pyright: ignore[reportPrivateUsage] + "session_name", + "In", + ["s1", "s2"], + ) + + +def test_build_filters_empty_membership_fails_closed( + store: TurbopufferVectorStore, +) -> None: + """An empty membership list must produce an always-false filter, never an + omitted/empty In that could widen scope (fail-open).""" + never = ("And", [("session_name", "Eq", ""), ("session_name", "NotEq", "")]) + assert store._build_filters({"session_name": {"in": []}}) == never # pyright: ignore[reportPrivateUsage] + assert store._build_filters({"session_name": []}) == never # pyright: ignore[reportPrivateUsage] + + @pytest.mark.asyncio async def test_upsert_many_short_circuits_on_empty( store: TurbopufferVectorStore, From 24f7a2cbd24ad4638d7d6b5dfec51e792869455e Mon Sep 17 00:00:00 2001 From: papesy384 Date: Fri, 24 Jul 2026 12:36:55 -0400 Subject: [PATCH 61/65] fix(dev): skip lancedb on macOS Intel and guard optional import (#496) Add a PEP 508 marker so lancedb is not installed on darwin/x86_64, wrap the LanceDB vector store import in try/except for a clear config error, and regenerate uv.lock. Branch rebased onto upstream/main; prior src/utils/clients.py CI tweak is obsolete because LLM wiring moved under src/llm/. Co-authored-by: Cursor --- pyproject.toml | 2 +- src/vector_store/__init__.py | 11 ++++++++++- uv.lock | 4 ++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d7905fc6..586f0c45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "typing-extensions>=4.11.0", "json-repair>=0.49.0", "turbopuffer>=1.8.1", - "lancedb>=0.25.3", + "lancedb>=0.25.3; sys_platform != \"darwin\" or platform_machine != \"x86_64\"", "pyarrow>=19.0.0", "redis>=7.0.0,<8.0.0", "cashews[redis]==7.5.0", diff --git a/src/vector_store/__init__.py b/src/vector_store/__init__.py index f0fccb63..85fdd9ff 100644 --- a/src/vector_store/__init__.py +++ b/src/vector_store/__init__.py @@ -202,7 +202,16 @@ def _create_store_by_type(store_type: str) -> VectorStore: return TurbopufferVectorStore() elif store_type == "lancedb": - from src.vector_store.lancedb import LanceDBVectorStore + try: + from src.vector_store.lancedb import LanceDBVectorStore + except ImportError as exc: + raise RuntimeError( + "VECTOR_STORE.TYPE is set to 'lancedb', but the 'lancedb' package " + "is not installed (for example on macOS Intel, where it is omitted " + "from dependencies because PyPI has no wheel). " + "Use TYPE 'pgvector' or 'turbopuffer', or install lancedb manually. " + f"Original import error: {exc}" + ) from exc return LanceDBVectorStore() else: diff --git a/uv.lock b/uv.lock index c27d054c..ffaa6255 100644 --- a/uv.lock +++ b/uv.lock @@ -1171,7 +1171,7 @@ dependencies = [ { name = "greenlet" }, { name = "httpx" }, { name = "json-repair" }, - { name = "lancedb" }, + { name = "lancedb", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "langfuse" }, { name = "nanoid" }, { name = "openai" }, @@ -1225,7 +1225,7 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.0.3" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "json-repair", specifier = ">=0.49.0" }, - { name = "lancedb", specifier = ">=0.25.3" }, + { name = "lancedb", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'", specifier = ">=0.25.3" }, { name = "langfuse", specifier = ">=3.3.2" }, { name = "nanoid", specifier = ">=2.0.0" }, { name = "openai", specifier = ">=1.99.7" }, From d7b64116accfdd7eb621f87fd84813c1b4e910e6 Mon Sep 17 00:00:00 2001 From: Leonardo Baray Date: Fri, 24 Jul 2026 13:54:31 -0300 Subject: [PATCH 62/65] fix: redact Redis password from cache connection logs (#869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: redact Redis password from cache connection logs The cache client logged the full Redis URL — including the password — at INFO and WARNING levels on every connection attempt and failure. This exposed the live Redis credential in stdout/container logs and any downstream log aggregation. Add _redact_cache_url() to mask the password component before logging. URLs without a password are returned unchanged. Closes #866 * fix: handle malformed URLs and IPv6 in _redact_cache_url Address review feedback from VVoruganti and CodeRabbit: - Wrap urlparse/urlunparse in try/except so malformed URLs (e.g. invalid port) don't raise ValueError inside except blocks, which would crash startup instead of degrading gracefully - Preserve IPv6 brackets (e.g. [::1]) in reconstructed URLs - Add Google-style Args/Returns docstring sections - Add unit tests for password masking, no-password URLs, IPv6, malformed inputs, and the invalid-port regression * test: use real secrets in redaction test fixtures Three fixtures were weakened by copy-paste mangling: literal '***' placeholders instead of real passwords (assertions trivially true), an unescaped '#' that truncated netloc parsing via the URL fragment, and a no-password case that actually contained userinfo. Restore inputs that genuinely exercise the masking paths. * fix: never leak password through malformed-URL fallback The catch-all fallback returned the original URL when parsing failed, so a Redis URL with a password and an invalid port (typo, out-of-range) was logged in clear text - the exact leak #866 exists to fix. Narrow the handling: .port access gets its own try/except (invalid port is omitted from the output; userinfo/hostname masking never raises), and the outer fallback now returns a generic placeholder instead of the raw input. Tightened the invalid-port test to assert the password is absent and added out-of-range-port and unparseable-URL cases. * fix: redact secrets in query params and scheme-less URLs _redact_cache_url only masked userinfo, but a credential can reach the URL through two other real configuration paths: redis-py accepts ?password= (all querystring options become client kwargs) and cashews accepts ?secret= (its HMAC signing key) - and honcho's own default CACHE.URL already uses a query param (?suppress=true), so this is the expected configuration style. Separately, a URL missing its scheme (':pass@host:6379/0') parses with an empty netloc, making the password invisible to .password and echoing it back verbatim. Mask sensitive query values in place on the raw query string (no decode/re-encode, so non-secret params are preserved byte-for-byte) and return the generic placeholder for @-carrying strings with no parseable authority. Verified with a 20k-case randomized fuzz run in addition to the unit tests: no functional credential reaches the output. --- src/cache/client.py | 103 +++++++++++++++++++++++++-- tests/test_cache_redaction.py | 126 ++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 tests/test_cache_redaction.py diff --git a/src/cache/client.py b/src/cache/client.py index 29d8c187..1ad8e3f5 100644 --- a/src/cache/client.py +++ b/src/cache/client.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import logging from typing import Any, cast +from urllib.parse import urlparse, urlunparse import sentry_sdk from cashews import cache @@ -20,10 +21,99 @@ from src.config import settings logger = logging.getLogger(__name__) - _cache_lock = asyncio.Lock() +# Query parameters that carry secrets when configured via URL: +# redis-py accepts ``?password=`` (all querystring options become client +# kwargs) and cashews accepts ``?secret=`` (HMAC key for value signing). +_SENSITIVE_QUERY_PARAMS = frozenset({"password", "secret"}) + + +def _mask_sensitive_query(query: str) -> str: + """Mask values of secret-bearing query parameters. + + Operates on the raw query string (no decode/re-encode round trip) + so non-secret parameters are preserved byte-for-byte. + + Args: + query: The raw query string from a parsed URL. + + Returns: + The query string with sensitive values replaced by ``***``, or + the original string if no sensitive parameter is present. + """ + if not query: + return query + parts: list[str] = [] + changed = False + for part in query.split("&"): + name, sep, _value = part.partition("=") + if sep and name.lower() in _SENSITIVE_QUERY_PARAMS: + parts.append(f"{name}=***") + changed = True + else: + parts.append(part) + return "&".join(parts) if changed else query + + +def _redact_cache_url(url: str) -> str: + """Mask credentials in a Redis connection URL before logging. + + Given ``redis://:password@host:port/db`` returns + ``redis://:***@host:port/db``; secret-bearing query parameters + (``?password=``, ``?secret=``) are masked as well. A URL carrying + no credentials is returned unchanged. This function never raises + and never returns a credential: an invalid port is omitted from + the output, and a URL that cannot be parsed at all is replaced by + a generic placeholder rather than echoed back, so that logging + inside ``except`` blocks can neither crash startup nor leak the + secrets this helper exists to hide. + + Args: + url: The Redis connection URL to redact. + + Returns: + The URL with its credentials masked, the original URL if it + carries none, or ``""`` if parsing + fails entirely. + """ + try: + parsed = urlparse(url) + query = _mask_sensitive_query(parsed.query) + # .password only splits netloc and never raises, unlike .port + if parsed.password is None and query == parsed.query: + # A string with an "@" but no parsed authority (e.g. a URL + # missing its scheme, ":pass@host:6379/0") may still carry + # userinfo that urlparse could not see — never echo it. + if "@" in url and not parsed.netloc: + return "" + return url + netloc = parsed.netloc + if parsed.password is not None: + userinfo = parsed.username or "" + hostname = parsed.hostname or "" + # Preserve IPv6 brackets (urlparse strips them from .hostname) + if hostname and ":" in hostname and not hostname.startswith("["): + hostname = f"[{hostname}]" + netloc = f"{userinfo}:***@{hostname}" + try: + port = parsed.port + except ValueError: + # Invalid or out-of-range port: omit it rather than let + # the outer fallback echo the raw URL (and its password) + # back. + port = None + if port is not None: + netloc += f":{port}" + parsed = parsed._replace(netloc=netloc, query=query) + return urlunparse(parsed) + except (ValueError, TypeError): + # Unparseable URL: never return the raw input — it may contain + # the very password this helper exists to hide. + return "" + + def is_cache_enabled() -> bool: return settings.CACHE.ENABLED @@ -59,7 +149,7 @@ async def init_cache() -> None: except Exception as setup_err: logger.warning( "Cache setup failed for %s: %s. Falling back to in-memory cache", - settings.CACHE.URL, + _redact_cache_url(settings.CACHE.URL), setup_err, ) if settings.SENTRY.ENABLED: @@ -87,7 +177,10 @@ async def init_cache() -> None: with attempt: async with asyncio.timeout(2): await cache.ping() - logger.info("Connected to cache at %s", settings.CACHE.URL) + logger.info( + "Connected to cache at %s", + _redact_cache_url(settings.CACHE.URL), + ) except ( redis_exc.TimeoutError, redis_exc.ConnectionError, @@ -96,7 +189,7 @@ async def init_cache() -> None: ) as e: logger.warning( "Failed to connect to cache at %s: %s. Falling back to in-memory cache", - settings.CACHE.URL, + _redact_cache_url(settings.CACHE.URL), e, ) if settings.SENTRY.ENABLED: @@ -107,7 +200,7 @@ async def init_cache() -> None: except Exception as e: logger.warning( "Unexpected cache error at %s: %s. Falling back to in-memory cache", - settings.CACHE.URL, + _redact_cache_url(settings.CACHE.URL), e, ) if settings.SENTRY.ENABLED: diff --git a/tests/test_cache_redaction.py b/tests/test_cache_redaction.py new file mode 100644 index 00000000..0ff2bbc7 --- /dev/null +++ b/tests/test_cache_redaction.py @@ -0,0 +1,126 @@ +"""Unit tests for the cache client's _redact_cache_url helper.""" + +import pytest + +from src.cache.client import _redact_cache_url + + +class TestRedactCacheUrl: + """Tests for _redact_cache_url — a security-relevant logging helper + that must never raise and must never leak a password.""" + + # --- Password masking --- + + def test_password_only_userinfo(self): + assert ( + _redact_cache_url("redis://:secret@localhost:6379/0") + == "redis://:***@localhost:6379/0" + ) + + def test_user_and_password(self): + result = _redact_cache_url("redis://user:s3cret@10.0.0.1:6380/2") + assert "***" in result + assert "s3cret" not in result + assert "user" in result + + def test_rediss_protocol(self): + result = _redact_cache_url("rediss://:secret@redis.example.com:6380") + assert result.startswith("rediss://") + assert "***" in result + assert "secret" not in result + + def test_complex_password(self): + result = _redact_cache_url("redis://:p%40ssw0rd!%24@host:6379/0") + assert "***" in result + assert "p%40ssw0rd" not in result + + def test_password_never_leaked(self): + """The original password must never appear in the redacted output.""" + for url in [ + "redis://:hunter2@localhost:6379/0", + "redis://admin:hunter2@localhost:6379/0", + "rediss://:hunter2@[::1]:6380/1", + ]: + assert "hunter2" not in _redact_cache_url(url) + + # --- Secrets in query parameters --- + # redis-py accepts ?password= (querystring options become client + # kwargs) and cashews accepts ?secret= (HMAC signing key), so both + # are real configuration paths that must not reach the logs. + + @pytest.mark.parametrize("param", ["password", "secret", "PASSWORD"]) + def test_query_param_secret_masked(self, param: str): + result = _redact_cache_url(f"redis://host:6379/0?{param}=s3cret") + assert "s3cret" not in result + assert f"{param}=***" in result + + def test_query_param_masking_preserves_other_params(self): + result = _redact_cache_url("redis://host:6379/0?db=1&password=s3cret&ssl=true") + assert "s3cret" not in result + assert "db=1" in result + assert "ssl=true" in result + + def test_userinfo_and_query_secret_both_masked(self): + result = _redact_cache_url("redis://:hunter2@host:6379/0?secret=s3cret") + assert "hunter2" not in result + assert "s3cret" not in result + + def test_non_secret_query_params_unchanged(self): + url = "redis://localhost:6379/0?suppress=true" + assert _redact_cache_url(url) == url + + # --- No-password URLs (returned unchanged) --- + + def test_user_without_password_unchanged(self): + assert ( + _redact_cache_url("redis://user@localhost:6379/0") + == "redis://user@localhost:6379/0" + ) + + def test_in_memory_url_unchanged(self): + assert _redact_cache_url("mem://") == "mem://" + + # --- IPv6 --- + + def test_ipv6_brackets_preserved(self): + result = _redact_cache_url("rediss://:secret@[::1]:6380/1") + assert "[::1]" in result + assert "***" in result + assert "secret" not in result + + # --- Malformed URLs (must NOT raise) --- + + def test_invalid_port_redacts_password(self): + """Regression test for two review findings: accessing + ``parsed.port`` on a URL with a non-numeric port raises + ``ValueError`` (must not crash startup inside an except block), + and the fallback must never echo the raw URL back — the + password has to be masked even when the port is unparseable. + """ + result = _redact_cache_url("redis://:pass@host:notaport/0") + assert "pass" not in result + assert "***" in result + + def test_out_of_range_port_redacts_password(self): + result = _redact_cache_url("redis://:supersecret@host:99999/0") + assert "supersecret" not in result + assert "***" in result + + def test_unparseable_url_never_echoed(self): + # Unbalanced IPv6 bracket makes urlparse itself raise; the + # fallback must return a placeholder, not the raw input. + result = _redact_cache_url("redis://:secret@[::1:6379/0") + assert "secret" not in result + + def test_missing_scheme_never_echoed(self): + # Without "redis://" urlparse sees no netloc, so the userinfo + # (and its password) is invisible to .password — the string + # must not be echoed back. + result = _redact_cache_url(":hunter2@host:6379/0") + assert "hunter2" not in result + + def test_garbage_input_does_not_raise(self): + assert isinstance(_redact_cache_url("not a url at all"), str) + + def test_empty_string_does_not_raise(self): + assert isinstance(_redact_cache_url(""), str) From 3ee890fa6f55388abd23b7660fb726e14d83459d Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:42:41 -0400 Subject: [PATCH 63/65] Vineeth/sentry filter consolidation (#934) * fix: centralize sentry before_send filter config * chore: comply with linter * fix: default sentry filters --- src/main.py | 29 +------- src/telemetry/sentry.py | 46 +++++++++++- tests/telemetry/test_sentry_before_send.py | 82 ++++++++++++++++++++++ 3 files changed, 126 insertions(+), 31 deletions(-) create mode 100644 tests/telemetry/test_sentry_before_send.py diff --git a/src/main.py b/src/main.py index b0611f59..3c8bf3e8 100644 --- a/src/main.py +++ b/src/main.py @@ -4,15 +4,12 @@ import time import uuid from collections.abc import Awaitable, Callable from contextlib import asynccontextmanager -from typing import TYPE_CHECKING import sentry_sdk from fastapi import FastAPI, Request, Response -from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi_pagination import add_pagination -from pydantic import ValidationError from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.starlette import StarletteIntegration @@ -42,9 +39,6 @@ from src.telemetry import ( from src.telemetry.logging import get_route_template from src.telemetry.sentry import initialize_sentry -if TYPE_CHECKING: - from sentry_sdk._types import Event, Hint - def get_log_level() -> int: """ @@ -88,30 +82,10 @@ class MetricsAccessFilter(logging.Filter): logging.getLogger("uvicorn.access").addFilter(MetricsAccessFilter()) -def before_send(event: "Event", hint: "Hint | None") -> "Event | None": - """Filter out events raised from known non-actionable exceptions before Sentry sees them.""" - if not hint: - return event - - exc_info = hint.get("exc_info") - if not exc_info: - return event - - _, exc_value, _ = exc_info - if isinstance(exc_value, HonchoException): - return None - - # Filters out ValidationErrors and RequestValidationErrors (typically coming from Pydantic) - if isinstance(exc_value, ValidationError | RequestValidationError): - logger.info(f"Filtering out validation error from Sentry: {exc_value}") - return None - - return event - - # Sentry Setup SENTRY_ENABLED = settings.SENTRY.ENABLED if SENTRY_ENABLED: + # before_send defaults to sentry.default_before_send (shared with the deriver). initialize_sentry( integrations=[ StarletteIntegration( @@ -123,7 +97,6 @@ if SENTRY_ENABLED: # Explicit so DB-query spans are not reliant on auto-enabling. SqlalchemyIntegration(), ], - before_send=before_send, ) diff --git a/src/telemetry/sentry.py b/src/telemetry/sentry.py index d84837ed..7b17e156 100644 --- a/src/telemetry/sentry.py +++ b/src/telemetry/sentry.py @@ -9,19 +9,58 @@ from functools import wraps from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast import sentry_sdk +from fastapi.exceptions import RequestValidationError +from pydantic import ValidationError +from sqlalchemy.exc import OperationalError from src.config import settings +from src.exceptions import HonchoException P = ParamSpec("P") T = TypeVar("T") if TYPE_CHECKING: - from sentry_sdk._types import EventProcessor + from sentry_sdk._types import Event, EventProcessor, Hint from sentry_sdk.integrations import Integration logger = logging.getLogger(__name__) +def default_before_send(event: Event, hint: Hint | None) -> Event | None: + """Filter/regroup known non-actionable events before Sentry ingests them. + + Shared by every entrypoint (API + deriver) so filtering is process-agnostic. + """ + if not hint: + return event + + exc_info = hint.get("exc_info") + if not exc_info: + return event + + _, exc_value, _ = exc_info + if isinstance(exc_value, HonchoException): + return None + + # Filters out ValidationErrors and RequestValidationErrors (typically from Pydantic) + if isinstance(exc_value, ValidationError | RequestValidationError): + logger.info(f"Filtering out validation error from Sentry: {exc_value}") + return None + + # DB connection-pool checkout timeouts are a fleet-wide saturation symptom, not a + # per-transaction bug. Collapse every occurrence into one issue (Sentry would otherwise + # split by transaction/endpoint) and drop to warning so it stops tripping error alerts. + # Watch it via a rate/spike metric alert instead. Root cause tracked in DEV-1852. + if isinstance(exc_value, OperationalError) and "connection timeout expired" in str( + exc_value + ): + event["fingerprint"] = ["honcho-db-connection-timeout"] + event["level"] = "warning" + return event + + return event + + # Paths whose transactions carry no debugging value but are hit constantly # (health checks, Prometheus scrapes, OpenAPI schema, docs). Tracing them at the # same rate as real traffic drowns the signal and burns tracing/profiling quota. @@ -81,13 +120,14 @@ def traces_sampler(sampling_context: dict[str, Any]) -> float: def initialize_sentry( *, integrations: Sequence[Integration], - before_send: EventProcessor | None = None, + before_send: EventProcessor | None = default_before_send, ) -> None: """Initialize Sentry SDK with project settings. Args: integrations: Sentry SDK integrations to enable (e.g., Starlette, FastAPI). - before_send: Optional event filter callback to suppress specific exceptions. + before_send: Event filter override. Defaults to ``default_before_send`` so + every entrypoint gets the shared filters; pass ``None`` to opt out. """ sentry_sdk.init( dsn=settings.SENTRY.DSN, diff --git a/tests/telemetry/test_sentry_before_send.py b/tests/telemetry/test_sentry_before_send.py new file mode 100644 index 00000000..7e63b555 --- /dev/null +++ b/tests/telemetry/test_sentry_before_send.py @@ -0,0 +1,82 @@ +"""Tests for the shared Sentry before_send filter. + +default_before_send runs in every entrypoint (API + deriver). It drops known +non-actionable exceptions and collapses DB connection-pool checkout timeouts +into a single warning-level issue so they stop spawning a fresh error issue per +transaction (fleet-wide saturation symptom, tracked in DEV-1852). +""" + +from typing import TYPE_CHECKING, cast + +import pytest +import sentry_sdk +from fastapi.exceptions import RequestValidationError +from pydantic import ValidationError +from sqlalchemy.exc import OperationalError + +from src.exceptions import ResourceNotFoundException +from src.telemetry.sentry import default_before_send, initialize_sentry + +if TYPE_CHECKING: + from sentry_sdk._types import Event, Hint + + +def _hint(exc: BaseException) -> "Hint": + return cast("Hint", {"exc_info": (type(exc), exc, None)}) + + +def _event(**kwargs: object) -> "Event": + return cast("Event", cast(object, dict(kwargs))) + + +def test_connection_timeout_is_consolidated_and_downgraded() -> None: + exc = OperationalError("SELECT 1", {}, Exception("connection timeout expired")) + out = default_before_send({}, _hint(exc)) + assert out == { + "fingerprint": ["honcho-db-connection-timeout"], + "level": "warning", + } + + +def test_unrelated_operational_error_passes_through() -> None: + exc = OperationalError("SELECT 1", {}, Exception("some other db failure")) + event = _event(level="error") + assert default_before_send(event, _hint(exc)) == {"level": "error"} + + +def test_honcho_and_validation_errors_are_dropped() -> None: + assert default_before_send({}, _hint(ResourceNotFoundException("nope"))) is None + assert ( + default_before_send({}, _hint(ValidationError.from_exception_data("x", []))) + is None + ) + assert default_before_send({}, _hint(RequestValidationError([]))) is None + + +def test_events_without_exc_info_pass_through() -> None: + event = _event(release="1.0") + assert default_before_send(event, None) == {"release": "1.0"} + assert default_before_send(event, cast("Hint", {})) == {"release": "1.0"} + + +def _captured_before_send(monkeypatch: pytest.MonkeyPatch, **kwargs: object) -> object: + captured: dict[str, object] = {} + + def fake_init(**init_kwargs: object) -> None: + captured.update(init_kwargs) + + monkeypatch.setattr(sentry_sdk, "init", fake_init) + initialize_sentry(integrations=[], **kwargs) # pyright: ignore[reportArgumentType] + return captured["before_send"] + + +def test_initialize_sentry_defaults_to_shared_filter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + assert _captured_before_send(monkeypatch) is default_before_send + + +def test_initialize_sentry_explicit_none_bypasses_shared_filter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + assert _captured_before_send(monkeypatch, before_send=None) is None From e7cbcc8432b696caf5648a679923dc4d4f370918 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:55:02 -0400 Subject: [PATCH 64/65] feat: session allowlist on dialectic and representation via filters (#882) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: apply session scoping to all working-representation query paths session_name was only applied to the recent-documents query in RepresentationManager; the semantic and most-derived paths ignored it, so limit_to_session leaked cross-session conclusions into perspectives. - Thread a session allowlist (session_names) uniformly through all three query paths; pushed down to pgvector and external vector stores - Accept a list so the upcoming session-allowlist API reuses this path - Fail closed on an empty allowlist (downstream stores drop empty IN clauses, which would silently widen scope) Fixes DEV-1994 Co-Authored-By: Claude Fable 5 * feat: bare-list membership sugar in the filter DSL {"session_id": ["s1", "s2"]} is now shorthand for {"session_id": {"in": [...]}} on regular columns, generically (peer_id, etc.). JSONB metadata columns are excluded — a bare list there keeps JSONB containment semantics, unchanged. Previously a bare list on a regular column compiled to a type-mismatched equality that matched nothing, so this is strictly additive. Also translates the same shape in the turbopuffer/lancedb filter builders, and fixes lancedb dropping empty IN clauses (fail-open) — an empty membership list now emits an always-false condition. Groundwork for DEV-1995 (session allowlist via the existing filters DSL, no new API params) Co-Authored-By: Claude Fable 5 * feat: session allowlist on dialectic and representation via filters Adds a constrained 'filters' body to peer.chat and /representation — the same DSL search and conclusions already accept, supporting only the session_id key (a session id, a bare list, or {"in": [...]}). Unsupported keys and shapes are rejected with 422, never silently ignored. Composes with session_id (must be included in the allowlist when both are given). Capped at 1,000 sessions per request. Enforcement is uniform at every recall chokepoint, fail-closed: - dialectic prefetch + search_memory: conclusion recall restricted to the allowlist; dream docs (session_name IS NULL) excluded - message tools (search/grep/date-range/temporal/context/history): strict intersection of allowlist and observer session membership - get_reasoning_chain: unavailable under an allowlist (chains traverse provenance across sessions and cannot be scoped without leaking) - empty allowlist short-circuits to empty results everywhere Auth: workspace keys pass the allowlist as-given; peer-scoped JWTs must be a member of every allowlisted session (403 otherwise), mirroring the existing single-session check. Fixes DEV-1995 Co-Authored-By: Claude Fable 5 * fix: fail closed on empty session allowlist across all filter builders Empty session allowlists relied solely on the early-return guard in _get_working_representation_internal. The layers below it were inconsistent, so a future direct caller (the DEV-1995 allowlist API) would silently widen scope instead of failing closed: - _build_filter_conditions used a truthiness check; an empty list was treated like None and dropped the filter. Now uses `is not None`, matching the recent/most-derived SQL paths. - turbopuffer emitted a bare `In []` with undocumented (possibly fail-open) semantics. Now emits an explicit always-false predicate, mirroring lancedb's `1 = 0`. Also extract the duplicated JSONB column tuple in filter.py to a JSONB_COLUMNS constant. Tests exercise each fail-closed guarantee at the layer it lives, rather than masking it behind the early-return guard. * fix: address tests * fix(crud): fail closed when session_name is outside the allowlist search/grep/history helpers scoped to a single session_name ignored the session_names allowlist entirely — a caller could read a session the allowlist forbids. The API routes guarded this with a 422, but the dialectic tools call these CRUD functions directly and bypassed it. Enforce it at the boundary: return [] when session_name is set and not in the allowlist, across _semantic_search_messages (covers search_messages + search_messages_temporal), grep_messages, get_messages_by_date_range, get_recent_history, and get_observation_context. Also rename the public param allowed_sessions -> session_names for consistency with representation.py / chat.py / peers.py; the resolved intersection keeps its distinct name allowed_session_names. * fix: test * fix(scopes): tighten and consolidate session allowlist per review Addresses review feedback on the session allowlist (DEV-1995). Behavior changes: - Auth gate on peers.chat now uses active membership (left_at IS NULL) via get_peer_session_names(active_only=True), matching the adjacent is_peer_in_session check on options.session_id. Previously a peer that had left a session was denied when naming it directly but permitted when naming it in filters.session_id. - Scoped conclusion recall is restricted to level == "explicit" (ALLOWLIST_SAFE_LEVELS). Dream-derived conclusions are stamped with a single session_name but synthesized across all sessions, so that stamp can't be scoped on. Applied at all four recall paths. Unscoped recall is unchanged. Follow-up to give conclusions an authoritative source-session set is tracked in DEV-2201. - The allowlist gate checks `is not None` rather than truthiness, so filters={"session_id": []} reaches it instead of being skipped. Refactors: - New crud.message.resolve_session_scope replaces four near-identical copies of the allowlist-membership intersection. Returns (allowlist, deny) and never returns an empty list, so the None vs [] distinction that external stores fail open on lives in one tested place. Takes db=None and opens its own short-lived session only when distinction that external stores fail open on lives in one tested place. Takes db=None and opens its own short-lived session only when an observer lookup is needed, preserving external-lookup-first ordering on the vector-store path. - extract_session_allowlist takes must_include, collapsing the session_id-in-allowlist check duplicated across both peer routes. - DialecticAgent._select_tools dedupes the two toolset-selection blocks and drops get_reasoning_chain under an allowlist, rather than paying for the schema plus a wasted turn to return a refusal. - Rename session_names -> session_allowlist across crud, agent tools, dialectic and routes, to remove the one-character ambiguity with session_name. Internal only; the public filters.session_id surface is unchanged. Docs: - session_allowlist documented across all message and recall entry points, including the None / [] / populated contract. - session_name marked deprecated for scoping. Not removed and not aliased: it also pins the query to one session, bypasses observer scoping, and drives session-history injection into the dialectic prompt, so it has no drop-in replacement. - Note at the Document branch in utils/filter.py that the raw-key fallback is load-bearing for session scoping. Tests: 20 -> 39 in tests/test_session_allowlist.py, covering the peer-scoped JWT gate (member, non-member, left-session, workspace-key bypass, empty allowlist), the resolve_session_scope tri-state including the no-DB-checkout path, must_include, and the level narrowing. --------- Co-authored-by: Claude Fable 5 --- src/crud/message.py | 181 +++++- src/crud/representation.py | 72 ++- src/dialectic/chat.py | 6 + src/dialectic/core.py | 40 +- src/routers/peers.py | 35 +- src/routers/sessions.py | 8 +- src/schemas/api.py | 19 + src/utils/agent_tools.py | 125 +++- src/utils/filter.py | 84 +++ src/utils/representation.py | 29 + tests/crud/test_representation_manager.py | 43 +- tests/test_session_allowlist.py | 679 ++++++++++++++++++++++ tests/utils/test_agent_tools.py | 65 +++ 13 files changed, 1276 insertions(+), 110 deletions(-) create mode 100644 tests/test_session_allowlist.py diff --git a/src/crud/message.py b/src/crud/message.py index ddd6df38..684ddb4d 100644 --- a/src/crud/message.py +++ b/src/crud/message.py @@ -55,11 +55,28 @@ async def get_peer_session_names( db: AsyncSession, workspace_name: str, peer_name: str, + *, + active_only: bool = False, ) -> list[str]: - """Get all session names where a peer has any membership record. + """Get all session names where a peer has a membership record. - Any membership record (regardless of joined_at/left_at) grants visibility - to all messages in that session. + By default any membership record (regardless of joined_at/left_at) grants + visibility to all messages in that session — this is the loose definition + recall scoping uses. + + Pass ``active_only=True`` for the strict definition (``left_at IS NULL``), + matching :func:`src.crud.session.is_peer_in_session`. The auth layer must + use the strict one so that a single peer-scoped key gets the same answer + whether it names a session directly or via a filter allowlist. + + Args: + db: Database session + workspace_name: Name of the workspace + peer_name: Name of the peer + active_only: Restrict to sessions the peer has not left + + Returns: + Distinct session names the peer has a matching membership record in. """ stmt = ( select(models.session_peers_table.c.session_name) @@ -67,10 +84,80 @@ async def get_peer_session_names( .where(models.session_peers_table.c.peer_name == peer_name) .distinct() ) + if active_only: + stmt = stmt.where(models.session_peers_table.c.left_at.is_(None)) result = await db.execute(stmt) return [row[0] for row in result.all()] +async def resolve_session_scope( + db: AsyncSession | None, + workspace_name: str, + session_name: str | None, + session_allowlist: list[str] | None, + observer: str | None, + *, + operation_name: str = "resolve_session_scope", +) -> tuple[list[str] | None, bool]: + """Resolve the effective session scope for a message query. + + Returns ``(allowed_session_names, deny)``: + + - ``allowed_session_names is None`` — apply no allowlist filter. Either the + query is unrestricted, or ``session_name`` already pins it to one session. + - a populated list — restrict the query to exactly these sessions. + - ``deny=True`` — the caller must return an empty result *without* querying. + + The distinction between ``None`` and an empty list is load-bearing: the + external vector stores drop an empty ``IN`` clause rather than matching + nothing, so collapsing the two would fail open. This function therefore + never returns an empty list — it returns ``deny=True`` instead. + + Touches the database only when an observer lookup is actually required, so + callers on the external-vector-store path don't check out a connection + before their network call. + + Args: + db: Database session to reuse. Pass None to let this function open its + own short-lived read-only session if (and only if) it needs one. + workspace_name: Name of the workspace + session_name: A single pinned session, if the caller named one + session_allowlist: Optional session allowlist. ``None`` is unrestricted; + an empty list fails closed. + observer: When set, scope is limited to this peer's sessions and then + intersected with ``session_allowlist`` + operation_name: Label for the self-managed DB session, when one is opened + + Returns: + Tuple of (allowlist to filter on or None, whether to deny outright). + """ + if session_name: + # A specific session was requested. Fail closed when the allowlist + # forbids it — routes guard this too, but other CRUD callers (the + # dialectic tools) don't, so enforce it at the boundary. + if session_allowlist is not None and session_name not in session_allowlist: + return None, True + return None, False + + if observer is None: + if session_allowlist is None: + return None, False + allowed = list(session_allowlist) + return (allowed, False) if allowed else (None, True) + + if db is not None: + allowed = await get_peer_session_names(db, workspace_name, observer) + else: + async with tracked_db(f"{operation_name}.peer_scope", read_only=True) as own_db: + allowed = await get_peer_session_names(own_db, workspace_name, observer) + + if session_allowlist is not None: + scope = set(session_allowlist) + allowed = [s for s in allowed if s in scope] + + return (allowed, False) if allowed else (None, True) + + def _apply_token_limit( base_conditions: list[ColumnElement[Any]], token_limit: int ) -> Select[tuple[models.Message]]: @@ -689,21 +776,29 @@ async def _semantic_search_messages( after_date: datetime | None = None, before_date: datetime | None = None, observer: str | None = None, + session_allowlist: list[str] | None = None, ) -> list[tuple[list[models.Message], list[models.Message]]]: """Run semantic message search with optional temporal filters. When observer is provided and session_name is None, results are - scoped to sessions the observer has any membership record in. + scoped to sessions the observer has any membership record in. When + session_allowlist is provided, that membership scope is further + intersected with the allowlist (fail-closed: empty result on empty + intersection). """ - # Pre-fetch peer session scope if needed (short-lived DB session) - allowed_session_names: list[str] | None = None - if observer and not session_name: - async with tracked_db(f"{operation_name}.peer_scope", read_only=True) as db: - allowed_session_names = await get_peer_session_names( - db, workspace_name, observer - ) - if not allowed_session_names: - return [] + # db=None: the helper opens its own short-lived session only if it needs + # an observer lookup, so the external-store path below stays the first + # thing that happens when no observer scoping applies. + allowed_session_names, deny = await resolve_session_scope( + None, + workspace_name, + session_name, + session_allowlist, + observer, + operation_name=operation_name, + ) + if deny: + return [] if settings.VECTOR_STORE.TYPE != "pgvector" and settings.VECTOR_STORE.MIGRATED: message_ids = await _search_messages_external( @@ -758,6 +853,7 @@ async def search_messages( context_window: int = 2, embedding: list[float] | None = None, observer: str | None = None, + session_allowlist: list[str] | None = None, ) -> list[tuple[list[models.Message], list[models.Message]]]: """ Search for messages using semantic similarity and return conversation snippets. @@ -768,12 +864,19 @@ async def search_messages( Args: workspace_name: Name of the workspace session_name: Name of the session (optional) + Deprecated for *scoping*: prefer session_allowlist, which + intersects with observer membership. This parameter also pins + the query to one session and bypasses observer scoping, so it + is not a drop-in equivalent and is not removed. query: Search query text limit: Maximum number of matching messages to return context_window: Number of messages before/after each match to include embedding: Optional pre-computed embedding observer: When provided and session_name is None, scope results to sessions this peer belongs to + session_allowlist: Optional session allowlist. None is unrestricted; an + empty list fails closed (empty result); a populated list is + intersected with the observer's session scope when observer is set Returns: List of tuples: (matched_messages, context_messages) @@ -799,6 +902,7 @@ async def search_messages( context_window=context_window, operation_name="message.search_messages", observer=observer, + session_allowlist=session_allowlist, ) @@ -846,6 +950,7 @@ async def grep_messages( limit: int = 10, context_window: int = 2, observer: str | None = None, + session_allowlist: list[str] | None = None, ) -> list[tuple[list[models.Message], list[models.Message]]]: """ Search for messages containing specific text (case-insensitive substring match). @@ -856,25 +961,29 @@ async def grep_messages( Args: workspace_name: Name of the workspace session_name: Name of the session (optional - searches all sessions if None) + Deprecated for *scoping*: prefer session_allowlist, which + intersects with observer membership. This parameter also pins + the query to one session and bypasses observer scoping, so it + is not a drop-in equivalent and is not removed. text: Text to search for (case-insensitive) limit: Maximum number of matching messages to return context_window: Number of messages before/after each match to include observer: When provided and session_name is None, scope results to sessions this peer belongs to + session_allowlist: Optional session allowlist. None is unrestricted; an + empty list fails closed (empty result); a populated list is + intersected with the observer's session scope when observer is set Returns: List of tuples: (matched_messages, context_messages) Each snippet may contain multiple matches if they were close together. """ async with tracked_db("message.grep_messages", read_only=True) as db: - # Pre-fetch peer session scope if needed - allowed_session_names = None - if observer and not session_name: - allowed_session_names = await get_peer_session_names( - db, workspace_name, observer - ) - if not allowed_session_names: - return [] + allowed_session_names, deny = await resolve_session_scope( + db, workspace_name, session_name, session_allowlist, observer + ) + if deny: + return [] snippets = await _grep_messages_internal( db, @@ -898,6 +1007,7 @@ async def get_messages_by_date_range( limit: int = 20, order: str = "desc", observer: str | None = None, + session_allowlist: list[str] | None = None, ) -> list[models.Message]: """ Get messages within a date range. @@ -906,24 +1016,28 @@ async def get_messages_by_date_range( db: Database session workspace_name: Name of the workspace session_name: Name of the session (optional - searches all sessions if None) + Deprecated for *scoping*: prefer session_allowlist, which + intersects with observer membership. This parameter also pins + the query to one session and bypasses observer scoping, so it + is not a drop-in equivalent and is not removed. after_date: Return messages after this datetime before_date: Return messages before this datetime limit: Maximum messages to return order: Sort order - 'asc' for oldest first, 'desc' for newest first observer: When provided and session_name is None, scope results to sessions this peer belongs to + session_allowlist: Optional session allowlist. None is unrestricted; an + empty list fails closed (empty result); a populated list is + intersected with the observer's session scope when observer is set Returns: List of messages within the date range """ - # Pre-fetch peer session scope if needed - allowed_session_names = None - if observer and not session_name: - allowed_session_names = await get_peer_session_names( - db, workspace_name, observer - ) - if not allowed_session_names: - return [] + allowed_session_names, deny = await resolve_session_scope( + db, workspace_name, session_name, session_allowlist, observer + ) + if deny: + return [] stmt = select(models.Message).where(models.Message.workspace_name == workspace_name) @@ -957,6 +1071,7 @@ async def search_messages_temporal( context_window: int = 2, embedding: list[float] | None = None, observer: str | None = None, + session_allowlist: list[str] | None = None, ) -> list[tuple[list[models.Message], list[models.Message]]]: """ Search for messages using semantic similarity with optional date filtering. @@ -967,6 +1082,10 @@ async def search_messages_temporal( Args: workspace_name: Name of the workspace session_name: Name of the session (optional) + Deprecated for *scoping*: prefer session_allowlist, which + intersects with observer membership. This parameter also pins + the query to one session and bypasses observer scoping, so it + is not a drop-in equivalent and is not removed. query: Search query text after_date: Only return messages after this datetime before_date: Only return messages before this datetime @@ -975,6 +1094,9 @@ async def search_messages_temporal( embedding: Optional pre-computed embedding for the query observer: When provided and session_name is None, scope results to sessions this peer belongs to + session_allowlist: Optional session allowlist. None is unrestricted; an + empty list fails closed (empty result); a populated list is + intersected with the observer's session scope when observer is set Returns: List of tuples: (matched_messages, context_messages) @@ -1001,4 +1123,5 @@ async def search_messages_temporal( context_window=context_window, operation_name="message.search_messages_temporal", observer=observer, + session_allowlist=session_allowlist, ) diff --git a/src/crud/representation.py b/src/crud/representation.py index 69c9edc9..d4b86ffb 100644 --- a/src/crud/representation.py +++ b/src/crud/representation.py @@ -19,9 +19,11 @@ from src.telemetry.events import EmbeddingCallPurpose from src.telemetry.logging import accumulate_metric from src.utils.formatting import format_datetime_utc from src.utils.representation import ( + ALLOWLIST_SAFE_LEVELS, DeductiveObservation, ExplicitObservation, Representation, + allowlist_safe_levels, ) from src.utils.types import embedding_call_purpose @@ -213,7 +215,7 @@ class RepresentationManager: self, *, db: AsyncSession | None = None, - session_names: list[str] | None = None, + session_allowlist: list[str] | None = None, include_semantic_query: str | None = None, embedding: list[float] | None = None, semantic_search_top_k: int | None = None, @@ -229,7 +231,7 @@ class RepresentationManager: Args: db: Optional database session. If provided, uses it directly; otherwise creates a new session via tracked_db. - session_names: Optional session allowlist to filter by. Applied + session_allowlist: Optional session allowlist to filter by. Applied uniformly to every query path (semantic, most-derived, and recent). None means no session restriction; an empty list fail-closes to an empty representation. @@ -270,7 +272,7 @@ class RepresentationManager: if db is not None: return await self._get_working_representation_internal( db, - session_names=session_names, + session_allowlist=session_allowlist, include_semantic_query=include_semantic_query, embedding=embedding, semantic_search_top_k=semantic_search_top_k, @@ -284,7 +286,7 @@ class RepresentationManager: ) as new_db: return await self._get_working_representation_internal( new_db, - session_names=session_names, + session_allowlist=session_allowlist, include_semantic_query=include_semantic_query, embedding=embedding, semantic_search_top_k=semantic_search_top_k, @@ -299,7 +301,7 @@ class RepresentationManager: self, db: AsyncSession, *, - session_names: list[str] | None = None, + session_allowlist: list[str] | None = None, include_semantic_query: str | None = None, embedding: list[float] | None = None, semantic_search_top_k: int | None = None, @@ -311,7 +313,7 @@ class RepresentationManager: # Fail closed on an empty allowlist. This must short-circuit before # any query: downstream stores drop an `IN ()` clause with an empty # list (lancedb), which would silently widen the scope instead. - if session_names is not None and not session_names: + if session_allowlist is not None and not session_allowlist: return Representation() total = max_observations @@ -354,7 +356,7 @@ class RepresentationManager: top_k=semantic_observations, max_distance=semantic_search_max_distance, embedding=embedding, - session_names=session_names, + session_allowlist=session_allowlist, ) representation.merge_representation( Representation.from_documents(semantic_docs) @@ -363,7 +365,7 @@ class RepresentationManager: # Get most derived observations if requested if include_most_derived: derived_docs = await self._query_documents_most_derived( - db, top_k=top_observations, session_names=session_names + db, top_k=top_observations, session_allowlist=session_allowlist ) representation.merge_representation( Representation.from_documents(derived_docs) @@ -371,7 +373,7 @@ class RepresentationManager: # Get recent observations recent_docs = await self._query_documents_recent( - db, top_k=recent_observations, session_names=session_names + db, top_k=recent_observations, session_allowlist=session_allowlist ) representation.merge_representation(Representation.from_documents(recent_docs)) @@ -386,7 +388,7 @@ class RepresentationManager: max_distance: float | None = None, level: str | None = None, embedding: list[float] | None = None, - session_names: list[str] | None = None, + session_allowlist: list[str] | None = None, ) -> list[models.Document]: """Query documents by semantic similarity.""" try: @@ -398,7 +400,7 @@ class RepresentationManager: top_k, max_distance, embedding=embedding, - session_names=session_names, + session_allowlist=session_allowlist, ) else: documents = await crud.query_documents( @@ -410,7 +412,9 @@ class RepresentationManager: max_distance=max_distance, top_k=top_k, embedding=embedding, - filters=self._build_filter_conditions(session_names=session_names) + filters=self._build_filter_conditions( + session_allowlist=session_allowlist + ) or None, ) db.expunge_all() @@ -421,7 +425,7 @@ class RepresentationManager: return [] async def _query_documents_recent( - self, db: AsyncSession, top_k: int, session_names: list[str] | None = None + self, db: AsyncSession, top_k: int, session_allowlist: list[str] | None = None ) -> list[models.Document]: """Query most recent documents.""" stmt = ( @@ -433,8 +437,13 @@ class RepresentationManager: models.Document.observed == self.observed, models.Document.deleted_at.is_(None), *( - [models.Document.session_name.in_(session_names)] - if session_names is not None + [ + models.Document.session_name.in_(session_allowlist), + # Only levels with a trustworthy session stamp are + # scopeable — see ALLOWLIST_SAFE_LEVELS. + models.Document.level.in_(ALLOWLIST_SAFE_LEVELS), + ] + if session_allowlist is not None else [] ), ) @@ -447,7 +456,7 @@ class RepresentationManager: return list(documents) async def _query_documents_most_derived( - self, db: AsyncSession, top_k: int, session_names: list[str] | None = None + self, db: AsyncSession, top_k: int, session_allowlist: list[str] | None = None ) -> list[models.Document]: """Query most derived documents.""" stmt = ( @@ -459,8 +468,13 @@ class RepresentationManager: models.Document.observed == self.observed, models.Document.deleted_at.is_(None), *( - [models.Document.session_name.in_(session_names)] - if session_names is not None + [ + models.Document.session_name.in_(session_allowlist), + # Only levels with a trustworthy session stamp are + # scopeable — see ALLOWLIST_SAFE_LEVELS. + models.Document.level.in_(ALLOWLIST_SAFE_LEVELS), + ] + if session_allowlist is not None else [] ), ) @@ -499,7 +513,7 @@ class RepresentationManager: count: int, max_distance: float | None = None, embedding: list[float] | None = None, - session_names: list[str] | None = None, + session_allowlist: list[str] | None = None, ) -> list[models.Document]: """Query documents for a specific level.""" documents = await crud.query_documents( @@ -510,7 +524,9 @@ class RepresentationManager: query=query, max_distance=max_distance, top_k=count, - filters=self._build_filter_conditions(level, session_names=session_names), + filters=self._build_filter_conditions( + level, session_allowlist=session_allowlist + ), embedding=embedding, ) @@ -523,13 +539,13 @@ class RepresentationManager: def _build_filter_conditions( self, level: str | None = None, - session_names: list[str] | None = None, + session_allowlist: list[str] | None = None, ) -> dict[str, Any]: """ Build filter conditions for document queries. Returns a flat dict of key-value pairs for vector store filtering. - Callers must not pass an empty session_names list — empty allowlists + Callers must not pass an empty session_allowlist list — empty allowlists fail closed before any query is issued (see _get_working_representation_internal). """ @@ -542,8 +558,12 @@ class RepresentationManager: # an empty `in` so downstream stores fail closed, matching # _query_documents_recent / _query_documents_most_derived. Truthiness # here would silently drop the filter and widen scope. - if session_names is not None: - filters["session_name"] = {"in": session_names} + if session_allowlist is not None: + filters["session_name"] = {"in": session_allowlist} + # Only levels with a trustworthy session stamp are scopeable. This + # overrides any narrower `level` above; an empty intersection emits + # `{"in": []}`, which matches nothing rather than everything. + filters["level"] = {"in": allowlist_safe_levels([level] if level else None)} return filters @@ -557,7 +577,7 @@ async def get_working_representation( db: AsyncSession | None = None, observer: str, observed: str, - session_names: list[str] | None = None, + session_allowlist: list[str] | None = None, include_semantic_query: str | None = None, embedding: list[float] | None = None, semantic_search_top_k: int | None = None, @@ -590,7 +610,7 @@ async def get_working_representation( ) return await manager.get_working_representation( db=db, - session_names=session_names, + session_allowlist=session_allowlist, include_semantic_query=include_semantic_query, embedding=embedding, semantic_search_top_k=semantic_search_top_k, diff --git a/src/dialectic/chat.py b/src/dialectic/chat.py index 40d991b4..d2f7e741 100644 --- a/src/dialectic/chat.py +++ b/src/dialectic/chat.py @@ -26,6 +26,7 @@ async def agentic_chat( observer: str, observed: str, reasoning_level: ReasoningLevel = "low", + session_allowlist: list[str] | None = None, response_model: type[BaseModel] | None = None, ) -> str: """ @@ -38,6 +39,7 @@ async def agentic_chat( observer: The peer making the query observed: The peer being queried about reasoning_level: Level of reasoning to apply + session_allowlist: Optional session allowlist restricting all recall response_model: Optional Pydantic model the answer must conform to. When set, the returned string is JSON matching the model's schema. @@ -82,6 +84,7 @@ async def agentic_chat( observer_peer_card=observer_peer_card, observed_peer_card=observed_peer_card, reasoning_level=reasoning_level, + session_allowlist=session_allowlist, ) return await agent.answer(query, response_model=response_model) @@ -94,6 +97,7 @@ async def agentic_chat_stream( observer: str, observed: str, reasoning_level: ReasoningLevel = "low", + session_allowlist: list[str] | None = None, response_model: type[BaseModel] | None = None, ) -> AsyncIterator[str]: """ @@ -106,6 +110,7 @@ async def agentic_chat_stream( observer: The peer making the query observed: The peer being queried about reasoning_level: Level of reasoning to apply + session_allowlist: Optional session allowlist restricting all recall response_model: Optional Pydantic model the answer must conform to. When set, the streamed text accumulates to JSON matching the model's schema. @@ -151,6 +156,7 @@ async def agentic_chat_stream( observer_peer_card=observer_peer_card, observed_peer_card=observed_peer_card, reasoning_level=reasoning_level, + session_allowlist=session_allowlist, ) async for chunk in agent.answer_stream(query, response_model=response_model): diff --git a/src/dialectic/core.py b/src/dialectic/core.py index 757423e0..9580b94c 100644 --- a/src/dialectic/core.py +++ b/src/dialectic/core.py @@ -70,6 +70,7 @@ class DialecticAgent: metric_key: str | None = None, reasoning_level: ReasoningLevel = "low", session_id: str | None = None, + session_allowlist: list[str] | None = None, ): """ Initialize the dialectic agent. @@ -84,9 +85,13 @@ class DialecticAgent: metric_key: Optional key for logging metrics (if provided, agent won't log separately) reasoning_level: Level of reasoning to apply session_id: ID used for grouping traces (not session_name) + session_allowlist: Optional session allowlist restricting all recall + (conclusions and messages) to these sessions; empty list + fails closed """ self.workspace_name: str = workspace_name self.session_name: str | None = session_name + self.session_allowlist: list[str] | None = session_allowlist self.session_id: str | None = session_id self.observer: str = observer self.observed: str = observed @@ -108,6 +113,24 @@ class DialecticAgent: self._prefetched_conclusion_count: int = 0 self._run_id: str = generate_nanoid() # Always generate for event correlation + def _select_tools(self) -> list[dict[str, Any]]: + """Pick the toolset for this query. + + Minimal reasoning uses a reduced set to reduce cost. Under a session + allowlist `get_reasoning_chain` is dropped entirely rather than left in + to fail at call time: chains traverse provenance across sessions, so it + can't be scoped, and offering it costs both the schema in context and a + wasted turn when the model tries it. + """ + tools = ( + DIALECTIC_TOOLS_MINIMAL + if self.reasoning_level == "minimal" + else DIALECTIC_TOOLS + ) + if self.session_allowlist is not None: + tools = [t for t in tools if t.get("name") != "get_reasoning_chain"] + return tools + async def _initialize_session_history(self) -> None: """Fetch and inject session history into the system prompt if configured.""" if self._session_history_initialized: @@ -197,6 +220,7 @@ class DialecticAgent: limit=prefetch_limit, levels=["explicit"], embedding=query_embedding, + session_allowlist=self.session_allowlist, ) derived_repr = await search_memory( @@ -207,6 +231,7 @@ class DialecticAgent: limit=prefetch_limit, levels=["deductive", "inductive", "contradiction"], embedding=query_embedding, + session_allowlist=self.session_allowlist, ) if explicit_repr.is_empty() and derived_repr.is_empty(): @@ -296,6 +321,7 @@ class DialecticAgent: ] = await create_tool_executor( workspace_name=self.workspace_name, session_name=self.session_name, + session_allowlist=self.session_allowlist, observer=self.observer, observed=self.observed, history_token_limit=settings.DIALECTIC.HISTORY_TOKEN_LIMIT, @@ -438,12 +464,7 @@ class DialecticAgent: # Get level-specific settings level_settings = settings.DIALECTIC.LEVELS[self.reasoning_level] - # Use minimal tools for minimal reasoning to reduce cost - tools = ( - DIALECTIC_TOOLS_MINIMAL - if self.reasoning_level == "minimal" - else DIALECTIC_TOOLS - ) + tools = self._select_tools() # Use level-specific max_output_tokens if set, otherwise global default max_tokens = ( level_settings.MAX_OUTPUT_TOKENS @@ -520,12 +541,7 @@ class DialecticAgent: # Get level-specific settings level_settings = settings.DIALECTIC.LEVELS[self.reasoning_level] - # Use minimal tools for minimal reasoning to reduce cost - tools = ( - DIALECTIC_TOOLS_MINIMAL - if self.reasoning_level == "minimal" - else DIALECTIC_TOOLS - ) + tools = self._select_tools() # Use level-specific max_output_tokens if set, otherwise global default max_tokens = ( level_settings.MAX_OUTPUT_TOKENS diff --git a/src/routers/peers.py b/src/routers/peers.py index d6e35269..bccb2dd0 100644 --- a/src/routers/peers.py +++ b/src/routers/peers.py @@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import crud, schemas from src.config import settings +from src.crud.message import get_peer_session_names from src.crud.session import is_peer_in_session from src.dependencies import db, read_db, tracked_db from src.dialectic.chat import agentic_chat, agentic_chat_stream @@ -27,6 +28,7 @@ from src.exceptions import ( from src.security import JWTParams, require_auth from src.telemetry import prometheus_metrics from src.telemetry.events import EmbeddingCallPurpose, GetContextEvent, emit +from src.utils.filter import extract_session_allowlist from src.utils.schema_conversion import json_response_schema_to_pydantic from src.utils.search import search from src.utils.types import embedding_call_purpose @@ -199,6 +201,25 @@ async def chat( ): raise AuthenticationException("JWT not permissioned for this resource") + # Parse the session allowlist from filters (422 on unsupported keys/shapes, + # and on a session_id the allowlist doesn't cover). + session_allowlist = extract_session_allowlist( + options.filters, must_include=options.session_id + ) + # A peer-scoped key may only name sessions its peer belongs to — the + # allowlist reaches message recall the same way session_id does above. + # `active_only` matches the is_peer_in_session check above, so both gates + # answer the same question for a peer that has left a session. + if jwt_params.p is not None and session_allowlist is not None: + async with tracked_db("peers.chat.session_scope_auth", read_only=True) as s_db: + member_sessions = set( + await get_peer_session_names( + s_db, workspace_id, jwt_params.p, active_only=True + ) + ) + if not set(session_allowlist) <= member_sessions: + raise AuthenticationException("JWT not permissioned for this resource") + # Convert the caller's JSON Schema so malformed schemas fail immediately with 422 response_model: type[BaseModel] | None = None if options.response_format is not None: @@ -244,6 +265,7 @@ async def chat( observer=peer_id, observed=options.target if options.target is not None else peer_id, reasoning_level=options.reasoning_level, + session_allowlist=session_allowlist, response_model=response_model, ) ), @@ -259,6 +281,7 @@ async def chat( # and it's answered from the omniscient Honcho perspective observed=options.target if options.target is not None else peer_id, reasoning_level=options.reasoning_level, + session_allowlist=session_allowlist, response_model=response_model, ) @@ -294,6 +317,12 @@ async def get_representation( If a target is provided, we get the Representation of the target from the perspective of the Peer. If no target is provided, we get the omniscient Honcho Representation of the Peer. """ + # Parse the session allowlist from filters (422 on unsupported keys/shapes, + # and on a session_id the allowlist doesn't cover). + session_allowlist = extract_session_allowlist( + options.filters, must_include=options.session_id + ) + try: embedding: list[float] | None = None if options.search_query: @@ -312,9 +341,9 @@ async def get_representation( workspace_id, observer=peer_id, observed=options.target if options.target is not None else peer_id, - session_names=[options.session_id] + session_allowlist=[options.session_id] if options.session_id is not None - else None, + else session_allowlist, include_semantic_query=options.search_query, embedding=embedding, semantic_search_top_k=options.search_top_k, @@ -477,7 +506,7 @@ async def get_peer_context( workspace_id, observer=peer_id, observed=observed, - session_names=None, # Peer context is global, not session-scoped + session_allowlist=None, # Peer context is global, not session-scoped include_semantic_query=search_query, embedding=embedding, semantic_search_top_k=search_top_k, diff --git a/src/routers/sessions.py b/src/routers/sessions.py index 3cc34043..191748d4 100644 --- a/src/routers/sessions.py +++ b/src/routers/sessions.py @@ -43,7 +43,7 @@ async def _get_working_representation_task( *, observer: str, observed: str, - session_names: list[str] | None, + session_allowlist: list[str] | None, search_top_k: int | None, search_max_distance: float | None, include_most_derived: bool, @@ -59,7 +59,7 @@ async def _get_working_representation_task( last_message: Optional last message for semantic query observer: Name of the observer peer observed: Name of the observed peer - session_names: Optional session allowlist to filter by + session_allowlist: Optional session allowlist to filter by search_top_k: Number of semantic-search-retrieved observations to include in the representation search_max_distance: Maximum distance to search for semantically relevant observations include_most_derived: Whether to include the most derived observations in the representation @@ -74,7 +74,7 @@ async def _get_working_representation_task( db=db, observer=observer, observed=observed, - session_names=session_names, + session_allowlist=session_allowlist, include_semantic_query=last_message, semantic_search_top_k=search_top_k, semantic_search_max_distance=search_max_distance, @@ -765,7 +765,7 @@ async def get_session_context( search_query, observer=observer, observed=observed, - session_names=[session_id] if limit_to_session else None, + session_allowlist=[session_id] if limit_to_session else None, search_top_k=search_top_k, search_max_distance=search_max_distance, include_most_derived=include_most_frequent, diff --git a/src/schemas/api.py b/src/schemas/api.py index 8c8c2cbe..78e5a125 100644 --- a/src/schemas/api.py +++ b/src/schemas/api.py @@ -177,6 +177,15 @@ class PeerRepresentationGet(BaseModel): session_id: str | None = Field( None, description="Optional session ID within which to scope the representation" ) + filters: dict[str, Any] | None = Field( + None, + description=( + "Optional filters to scope the representation. This endpoint " + "supports only the 'session_id' key: a session id, a list of " + 'session ids, or {"in": [...]}. When session_id is also set, it ' + "must be included in the allowlist." + ), + ) target: str | None = Field( None, description="Optional peer ID to get the representation for, from the perspective of this peer", @@ -562,6 +571,16 @@ class DialecticOptions(BaseModel): session_id: str | None = Field( None, description="ID of the session to scope the representation to" ) + filters: dict[str, Any] | None = Field( + None, + description=( + "Optional filters to scope recall. This endpoint supports only the " + "'session_id' key: a session id, a list of session ids, or " + '{"in": [...]}. Recall (conclusions and messages) is restricted to ' + "the allowlist; unsupported keys are rejected. When session_id is " + "also set, it must be included in the allowlist." + ), + ) target: str | None = Field( None, description="Optional peer to get the representation for, from the perspective of this peer", diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index c5a99207..9f214009 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -1,7 +1,7 @@ import asyncio import logging import weakref -from collections.abc import Callable +from collections.abc import Callable, Sequence from dataclasses import dataclass from datetime import datetime from typing import Any, cast @@ -30,7 +30,11 @@ from src.utils.formatting import ( parse_datetime_iso, utc_now_iso, ) -from src.utils.representation import Representation +from src.utils.representation import ( + ALLOWLIST_SAFE_LEVELS, + Representation, + allowlist_safe_levels, +) from src.utils.types import ToolResult, embedding_call_purpose, get_current_iteration logger = logging.getLogger(__name__) @@ -1030,6 +1034,7 @@ async def get_recent_history( session_name: str | None, observed: str | None = None, token_limit: int = 8192, + session_allowlist: list[str] | None = None, ) -> list[models.Message]: """ Retrieve recent conversation history. @@ -1042,6 +1047,10 @@ async def get_recent_history( db: Database session workspace_name: Workspace identifier session_name: Session identifier (optional) + Deprecated for *scoping*: prefer session_allowlist, which + intersects with observer membership. This parameter also pins + the query to one session and bypasses observer scoping, so it + is not a drop-in equivalent and is not removed. observed: Peer name to filter by when no session specified (optional) token_limit: Maximum tokens to retrieve (default: 8192) @@ -1049,6 +1058,9 @@ async def get_recent_history( List of messages in chronological order """ if session_name: + # Fail closed: a specific session outside the allowlist is not readable. + if session_allowlist is not None and session_name not in session_allowlist: + return [] # Get messages from a specific session messages_stmt = await crud.get_messages( workspace_name=workspace_name, @@ -1061,7 +1073,11 @@ async def get_recent_history( # Return in chronological order return list(reversed(messages)) elif observed: + # Fail closed on an empty allowlist + if session_allowlist is not None and not session_allowlist: + return [] # Get recent messages from the observed peer across all sessions + # (restricted to the session allowlist when one is provided) stmt = ( select(models.Message) .where(models.Message.workspace_name == workspace_name) @@ -1069,6 +1085,8 @@ async def get_recent_history( .order_by(models.Message.created_at.desc()) .limit(50) # Limit to recent messages ) + if session_allowlist is not None: + stmt = stmt.where(models.Message.session_name.in_(session_allowlist)) result = await db.execute(stmt) messages = list(result.scalars().all()) # Return in chronological order @@ -1086,6 +1104,7 @@ async def search_memory( limit: int, levels: list[str] | None = None, embedding: list[float] | None = None, + session_allowlist: list[str] | None = None, ) -> Representation: """ Search for observations in memory using semantic similarity. @@ -1106,10 +1125,22 @@ async def search_memory( Returns: Representation object containing relevant observations """ - # Build filter for levels if specified - filters: dict[str, Any] | None = None + # Fail closed on an empty allowlist — downstream stores drop empty IN + # clauses, which would silently widen scope. + if session_allowlist is not None and not session_allowlist: + return Representation() + + if session_allowlist is not None: + levels = allowlist_safe_levels(levels) + if not levels: + return Representation() + + # Build filters for levels / session allowlist if specified + filters: dict[str, Any] = {} if levels: - filters = {"level": {"in": levels}} + filters["level"] = {"in": levels} + if session_allowlist is not None: + filters["session_name"] = {"in": session_allowlist} documents = await crud.query_documents( db=None, @@ -1118,7 +1149,7 @@ async def search_memory( observed=observed, query=query, top_k=limit, - filters=filters, + filters=filters or None, embedding=embedding, ) @@ -1131,6 +1162,7 @@ async def get_observation_context( session_name: str | None, message_ids: list[str], observer: str | None = None, + session_allowlist: list[str] | None = None, ) -> list[models.Message]: """ Retrieve messages for given message IDs along with surrounding context. @@ -1143,9 +1175,16 @@ async def get_observation_context( db: Database session workspace_name: Workspace identifier session_name: Session identifier (optional) + Deprecated for *scoping*: prefer session_allowlist, which + intersects with observer membership. This parameter also pins + the query to one session and bypasses observer scoping, so it + is not a drop-in equivalent and is not removed. message_ids: List of message IDs to retrieve observer: When provided and session_name is None, scope results to sessions this peer belongs to + session_allowlist: Optional session allowlist. None is unrestricted; an + empty list fails closed (empty result); a populated list is + intersected with the observer's session scope when observer is set Returns: List of messages in chronological order, including the requested messages and surrounding context @@ -1153,16 +1192,13 @@ async def get_observation_context( if not message_ids: return [] - # Pre-fetch peer session scope if needed - allowed_session_names: list[str] | None = None - if observer and not session_name: - from src.crud.message import get_peer_session_names + from src.crud.message import resolve_session_scope - allowed_session_names = await get_peer_session_names( - db, workspace_name, observer - ) - if not allowed_session_names: - return [] + allowed_session_names, deny = await resolve_session_scope( + db, workspace_name, session_name, session_allowlist, observer + ) + if deny: + return [] # Use a CTE to get seq_in_session values for target messages stmt = ( @@ -1221,9 +1257,16 @@ async def extract_preferences( Args: workspace_name: Workspace identifier session_name: Session identifier (optional) + Deprecated for *scoping*: prefer session_allowlist, which + intersects with observer membership. This parameter also pins + the query to one session and bypasses observer scoping, so it + is not a drop-in equivalent and is not removed. observed: The peer whose preferences to extract observer: When provided and session_name is None, scope results to sessions this peer belongs to + session_allowlist: Optional session allowlist. None is unrestricted; an + empty list fails closed (empty result); a populated list is + intersected with the observer's session scope when observer is set Returns: Dict with 'messages' list containing potentially relevant messages @@ -1303,6 +1346,10 @@ class ToolContext: db_lock: asyncio.Lock # Optional resolved configuration for checking feature flags configuration: ResolvedConfiguration | None = None + # Optional session allowlist (dialectic filters). When set, message and + # conclusion recall is restricted to these sessions (intersected with + # observer membership); empty list fails closed. + session_allowlist: list[str] | None = None # Telemetry context fields run_id: str | None = None agent_type: str | None = None # "dialectic", "deriver", "dreamer" @@ -1724,6 +1771,7 @@ async def _handle_get_recent_history( session_name=ctx.session_name, observed=ctx.observed, token_limit=ctx.history_token_limit, + session_allowlist=ctx.session_allowlist, ) if not history: return "No conversation history available" @@ -1769,15 +1817,28 @@ async def _handle_search_memory( "query_tokens": _estimate_tokens_safe(query), } - documents = await crud.query_documents( - db=None, - workspace_name=ctx.workspace_name, - observer=ctx.observer, - observed=ctx.observed, - query=query, - top_k=top_k, - embedding=query_embedding, - ) + # Restrict conclusion recall to the session allowlist when one is set. + # Empty allowlist fails closed (downstream stores drop empty IN clauses), + # and only levels with a trustworthy session stamp are served. + documents: Sequence[models.Document] + if ctx.session_allowlist is not None and not ctx.session_allowlist: + documents = [] + else: + documents = await crud.query_documents( + db=None, + workspace_name=ctx.workspace_name, + observer=ctx.observer, + observed=ctx.observed, + query=query, + top_k=top_k, + embedding=query_embedding, + filters={ + "session_name": {"in": ctx.session_allowlist}, + "level": {"in": list(ALLOWLIST_SAFE_LEVELS)}, + } + if ctx.session_allowlist is not None + else None, + ) mem = Representation.from_documents(documents) total_count = mem.len() if total_count == 0: @@ -1799,6 +1860,7 @@ async def _handle_search_memory( context_window=0, embedding=query_embedding, observer=ctx.observer, + session_allowlist=ctx.session_allowlist, ) if snippets: message_output = _format_message_snippets( @@ -1840,6 +1902,7 @@ async def _handle_get_observation_context( session_name=ctx.session_name, message_ids=tool_input["message_ids"], observer=ctx.observer, + session_allowlist=ctx.session_allowlist, ) if not messages: return f"No messages found for IDs {tool_input['message_ids']}" @@ -1882,6 +1945,7 @@ async def _handle_search_messages( context_window=2, embedding=query_embedding, observer=ctx.observer, + session_allowlist=ctx.session_allowlist, ) search_meta: dict[str, Any] = { "top_k": limit, @@ -1918,6 +1982,7 @@ async def _handle_grep_messages( limit=limit, context_window=context_window, observer=ctx.observer, + session_allowlist=ctx.session_allowlist, ) if not snippets: return f"No messages found containing '{text}'" @@ -1982,6 +2047,7 @@ async def _handle_get_messages_by_date_range( limit=limit, order=order, observer=ctx.observer, + session_allowlist=ctx.session_allowlist, ) msg_count = len(messages) messages_text = ( @@ -2054,6 +2120,7 @@ async def _handle_search_messages_temporal( before_date=before_date, limit=limit, context_window=context_window, + session_allowlist=ctx.session_allowlist, embedding=query_embedding, observer=ctx.observer, ) @@ -2310,6 +2377,14 @@ async def _handle_get_reasoning_chain( ctx: ToolContext, tool_input: dict[str, Any] ) -> str: """Handle get_reasoning_chain tool.""" + # Reasoning chains traverse provenance across sessions by design, so a + # session allowlist cannot be enforced on the traversal without exposing + # out-of-scope premises/conclusions. Fail closed rather than leak. + if ctx.session_allowlist is not None: + return ( + "Reasoning-chain traversal is unavailable for session-scoped " + "queries. Use search_memory and message tools instead." + ) observation_id = tool_input.get("observation_id") if not observation_id: return "ERROR: 'observation_id' is required" @@ -2435,6 +2510,7 @@ async def create_tool_executor( run_id: str | None = None, agent_type: str | None = None, parent_category: str | None = None, + session_allowlist: list[str] | None = None, ) -> Callable[[str, dict[str, Any]], Any]: """ Create a unified tool executor function for all agent operations. @@ -2475,6 +2551,7 @@ async def create_tool_executor( history_token_limit=history_token_limit, db_lock=shared_lock, configuration=configuration, + session_allowlist=session_allowlist, run_id=run_id, agent_type=agent_type, parent_category=parent_category, diff --git a/src/utils/filter.py b/src/utils/filter.py index 36e34f4d..8df3590c 100644 --- a/src/utils/filter.py +++ b/src/utils/filter.py @@ -61,6 +61,83 @@ ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_DOCUMENTS = { } +MAX_SESSION_ALLOWLIST_ENTRIES = 1000 + + +def extract_session_allowlist( + filters: dict[str, Any] | None, + must_include: str | None = None, +) -> list[str] | None: + """Parse a recall-path ``filters`` body into a session allowlist. + + The dialectic and representation endpoints accept a constrained subset of + the filter DSL: only the ``session_id`` key, valued as a single id, a bare + list of ids, or ``{"in": [...]}``. Unsupported keys or shapes raise + FilterError (422) rather than being silently ignored — a dropped filter + on these endpoints would widen recall scope. + + Args: + filters: The raw ``filters`` body, or None. + must_include: A session id that must appear in the parsed allowlist — + used by routes that also accept a top-level ``session_id``, so the + two can't contradict each other. Ignored when filters is None. + + Returns: + None when filters is None. An explicit empty list is preserved so + downstream consumers fail closed. + + Raises: + FilterError: On an unsupported key or shape, an over-cap list, or a + ``must_include`` session missing from the allowlist. + """ + if filters is None: + return None + + unsupported = set(filters) - {"session_id"} + if unsupported: + raise FilterError( + f"Unsupported filter key(s) for this endpoint: {sorted(unsupported)}. Only 'session_id' is supported." + ) + if "session_id" not in filters: + raise FilterError("filters must contain 'session_id'") + + value = filters["session_id"] + entries: list[Any] + if isinstance(value, str): + entries = [value] + elif isinstance(value, list): + entries = list(typing_cast(Sequence[Any], value)) + elif ( + isinstance(value, dict) + and set(typing_cast("dict[str, Any]", value)) == {"in"} + and isinstance(value["in"], list) + ): + entries = list(typing_cast(Sequence[Any], value["in"])) + else: + raise FilterError( + 'filters.session_id must be a session id, a list of session ids, or {"in": [...]}' + ) + + if len(entries) > MAX_SESSION_ALLOWLIST_ENTRIES: + raise FilterError( + f"filters.session_id supports at most {MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request" + ) + + allowlist: list[str] = [] + seen: set[str] = set() + for entry in entries: + if not isinstance(entry, str) or not entry: + raise FilterError("filters.session_id entries must be non-empty strings") + if entry not in seen: + seen.add(entry) + allowlist.append(entry) + + if must_include is not None and must_include not in seen: + raise FilterError("session_id must be included in filters.session_id") + + return allowlist + + def apply_filter( stmt: Select[tuple[T]], model_class: type[T], filters: dict[str, Any] | None = None ) -> Select[tuple[T]]: @@ -221,6 +298,13 @@ def _build_field_condition( if model_class.__name__ == "Message": column_name = ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES.get(key) elif model_class.__name__ == "Document": + # NOTE: unlike Message/Workspace, Document falls back to the raw key so + # internal callers can filter on internal column names. The session + # allowlist depends on this: recall passes {"session_name": {"in": ...}} + # (see search_memory in utils/agent_tools.py and RepresentationManager), + # and "session_name" is deliberately absent from the mapping below. + # Tightening this to a strict allowlist would break session scoping — + # fail-closed, since an unmapped key raises, but silently. column_name = ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_DOCUMENTS.get( key, key, # fallback to the key itself if not found in the mapping for internal use here diff --git a/src/utils/representation.py b/src/utils/representation.py index 674b25bb..01e4b70a 100644 --- a/src/utils/representation.py +++ b/src/utils/representation.py @@ -7,6 +7,35 @@ from pydantic import BaseModel, Field, field_validator from src import models from src.utils.formatting import parse_datetime_iso +# Conclusion levels whose `session_name` stamp is trustworthy enough to scope on. +# +# Explicit conclusions come from the deriver over a single session's message +# batch, so their stamp is authoritative. Deductive/inductive conclusions are +# produced by the dreamer, which reads across *all* sessions (its discovery +# tools default to session_only=False) but stamps its output with one session — +# whichever holds the most recent explicit conclusion, see +# dreamer/dream_scheduler.py. Serving those under a session allowlist would leak +# conclusions synthesized from sessions outside it. +# +# ponytail: whole-level exclusion rather than per-conclusion provenance. The +# reasoning trees already link each conclusion to its premises, so the real fix +# is an authoritative source-session set per conclusion; until that exists this +# fails closed. Tracked in DEV-2201. +ALLOWLIST_SAFE_LEVELS = ("explicit",) + + +def allowlist_safe_levels(levels: list[str] | None) -> list[str]: + """Narrow a level filter to those safe to serve under a session allowlist. + + Returns the intersection with :data:`ALLOWLIST_SAFE_LEVELS`; ``None`` means + "no level filter requested" and yields the full safe set. An empty result + means the caller asked only for levels we can't scope, and should receive + nothing rather than unscoped conclusions. + """ + if levels is None: + return list(ALLOWLIST_SAFE_LEVELS) + return [level for level in levels if level in ALLOWLIST_SAFE_LEVELS] + def _strip_microseconds_and_timezone(timestamp: datetime) -> datetime: """ diff --git a/tests/crud/test_representation_manager.py b/tests/crud/test_representation_manager.py index b1c24c01..f551de76 100644 --- a/tests/crud/test_representation_manager.py +++ b/tests/crud/test_representation_manager.py @@ -320,7 +320,7 @@ class TestRepresentationManagerSessionScoping: session_a, _, manager = await self._setup(db_session, test_workspace, test_peer) results = await manager._query_documents_recent( # pyright: ignore[reportPrivateUsage] - db_session, top_k=10, session_names=[session_a.name] + db_session, top_k=10, session_allowlist=[session_a.name] ) contents = [doc.content for doc in results] @@ -337,7 +337,7 @@ class TestRepresentationManagerSessionScoping: session_a, _, manager = await self._setup(db_session, test_workspace, test_peer) results = await manager._query_documents_most_derived( # pyright: ignore[reportPrivateUsage] - db_session, top_k=10, session_names=[session_a.name] + db_session, top_k=10, session_allowlist=[session_a.name] ) contents = [doc.content for doc in results] @@ -361,12 +361,15 @@ class TestRepresentationManagerSessionScoping: query="anything", top_k=5, embedding=[0.1], - session_names=[session_a.name], + session_allowlist=[session_a.name], ) assert mock_query.await_args is not None assert mock_query.await_args.kwargs["filters"] == { - "session_name": {"in": [session_a.name]} + "session_name": {"in": [session_a.name]}, + # Scoped recall serves only levels with a trustworthy session + # stamp (ALLOWLIST_SAFE_LEVELS / DEV-2201). + "level": {"in": ["explicit"]}, } @pytest.mark.asyncio @@ -403,7 +406,7 @@ class TestRepresentationManagerSessionScoping: representation = await manager.get_working_representation( db=db_session, - session_names=[session_a.name], + session_allowlist=[session_a.name], include_most_derived=True, ) @@ -425,7 +428,7 @@ class TestRepresentationManagerSessionScoping: representation = await manager.get_working_representation( db=db_session, - session_names=[], + session_allowlist=[], include_most_derived=True, ) @@ -441,13 +444,29 @@ class TestRepresentationManagerSessionScoping: "workspace", observer="observer", observed="observed" ) - assert manager._build_filter_conditions(session_names=[]) == { # pyright: ignore[reportPrivateUsage] - "session_name": {"in": []} + # Scoping also narrows to levels whose session stamp is trustworthy + # (see ALLOWLIST_SAFE_LEVELS / DEV-2201). + assert manager._build_filter_conditions(session_allowlist=[]) == { # pyright: ignore[reportPrivateUsage] + "session_name": {"in": []}, + "level": {"in": ["explicit"]}, } - # None means unscoped — no session filter emitted. - assert manager._build_filter_conditions(session_names=None) == {} # pyright: ignore[reportPrivateUsage] - assert manager._build_filter_conditions(session_names=["s1"]) == { # pyright: ignore[reportPrivateUsage] - "session_name": {"in": ["s1"]} + # None means unscoped — no session filter and no level narrowing. + assert manager._build_filter_conditions(session_allowlist=None) == {} # pyright: ignore[reportPrivateUsage] + assert manager._build_filter_conditions(session_allowlist=["s1"]) == { # pyright: ignore[reportPrivateUsage] + "session_name": {"in": ["s1"]}, + "level": {"in": ["explicit"]}, + } + # A requested level outside the safe set yields an empty `in`, which + # matches nothing rather than falling back to unscoped recall. + assert manager._build_filter_conditions( # pyright: ignore[reportPrivateUsage] + level="inductive", session_allowlist=["s1"] + ) == { + "session_name": {"in": ["s1"]}, + "level": {"in": []}, + } + # ...while an unscoped level filter is left exactly as asked. + assert manager._build_filter_conditions(level="inductive") == { # pyright: ignore[reportPrivateUsage] + "level": "inductive" } diff --git a/tests/test_session_allowlist.py b/tests/test_session_allowlist.py new file mode 100644 index 00000000..aa6077cb --- /dev/null +++ b/tests/test_session_allowlist.py @@ -0,0 +1,679 @@ +""" +Tests for the session allowlist (DEV-1995). + +Covers the constrained `filters` surface on dialectic/representation +(extract_session_allowlist), fail-closed conclusion recall (search_memory), +and the strict allowlist ∩ membership intersection in message cruds. +""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient +from nanoid import generate as generate_nanoid +from sqlalchemy.ext.asyncio import AsyncSession + +from src import crud, models +from src.config import settings +from src.crud.message import resolve_session_scope +from src.exceptions import FilterError +from src.models import Peer, Workspace +from src.security import JWTParams, create_jwt +from src.utils.agent_tools import search_memory +from src.utils.filter import ( + MAX_SESSION_ALLOWLIST_ENTRIES, + extract_session_allowlist, +) + + +class TestExtractSessionAllowlist: + def test_none_passthrough(self): + assert extract_session_allowlist(None) is None + + def test_single_id(self): + assert extract_session_allowlist({"session_id": "s1"}) == ["s1"] + + def test_bare_list(self): + assert extract_session_allowlist({"session_id": ["s1", "s2"]}) == ["s1", "s2"] + + def test_in_operator(self): + assert extract_session_allowlist({"session_id": {"in": ["s1"]}}) == ["s1"] + + def test_dedupes_preserving_order(self): + assert extract_session_allowlist({"session_id": ["s2", "s1", "s2"]}) == [ + "s2", + "s1", + ] + + def test_empty_list_preserved_for_fail_closed(self): + assert extract_session_allowlist({"session_id": []}) == [] + + def test_unsupported_key_rejected(self): + with pytest.raises(FilterError, match="Unsupported filter key"): + extract_session_allowlist({"peer_id": ["a"], "session_id": ["s1"]}) + + def test_missing_session_id_rejected(self): + with pytest.raises(FilterError, match="must contain"): + extract_session_allowlist({}) + + def test_bad_shapes_rejected(self): + for bad in [123, {"gte": "x"}, {"in": "s1"}, [1, 2], [""], None]: + with pytest.raises(FilterError): + extract_session_allowlist({"session_id": bad}) + + def test_cap_enforced(self): + too_many = [f"s{i}" for i in range(MAX_SESSION_ALLOWLIST_ENTRIES + 1)] + with pytest.raises(FilterError, match="at most"): + extract_session_allowlist({"session_id": too_many}) + + def test_must_include_satisfied(self): + assert extract_session_allowlist( + {"session_id": ["s1", "s2"]}, must_include="s2" + ) == ["s1", "s2"] + + def test_must_include_missing_rejected(self): + with pytest.raises(FilterError, match="must be included"): + extract_session_allowlist({"session_id": ["s1"]}, must_include="s2") + + def test_must_include_ignored_without_filters(self): + assert extract_session_allowlist(None, must_include="s1") is None + + def test_must_include_none_is_no_constraint(self): + assert extract_session_allowlist({"session_id": ["s1"]}, must_include=None) == [ + "s1" + ] + + +class TestSearchMemoryAllowlist: + @pytest.mark.asyncio + async def test_allowlist_pushed_down_as_filters(self): + with patch( + "src.crud.query_documents", new=AsyncMock(return_value=[]) + ) as mock_query: + await search_memory( + workspace_name="w", + observer="o", + observed="o", + query="q", + limit=5, + levels=["explicit"], + embedding=[0.1], + session_allowlist=["s1", "s2"], + ) + assert mock_query.await_args is not None + assert mock_query.await_args.kwargs["filters"] == { + "level": {"in": ["explicit"]}, + "session_name": {"in": ["s1", "s2"]}, + } + + @pytest.mark.asyncio + async def test_allowlist_narrows_levels_to_allowlist_safe(self): + """Only levels with a trustworthy session stamp survive scoping. + + Dream-derived levels are stamped with one session but synthesized + across many (DEV-2201), so they can't be served under an allowlist. + """ + with patch( + "src.crud.query_documents", new=AsyncMock(return_value=[]) + ) as mock_query: + await search_memory( + workspace_name="w", + observer="o", + observed="o", + query="q", + limit=5, + levels=["explicit", "inductive"], + embedding=[0.1], + session_allowlist=["s1"], + ) + assert mock_query.await_args is not None + assert mock_query.await_args.kwargs["filters"]["level"] == {"in": ["explicit"]} + + @pytest.mark.asyncio + async def test_allowlist_defaults_to_explicit_when_no_levels_requested(self): + with patch( + "src.crud.query_documents", new=AsyncMock(return_value=[]) + ) as mock_query: + await search_memory( + workspace_name="w", + observer="o", + observed="o", + query="q", + limit=5, + embedding=[0.1], + session_allowlist=["s1"], + ) + assert mock_query.await_args is not None + assert mock_query.await_args.kwargs["filters"]["level"] == {"in": ["explicit"]} + + @pytest.mark.asyncio + async def test_derived_only_request_under_allowlist_returns_empty(self): + """The dialectic's derived prefetch short-circuits instead of querying.""" + with patch( + "src.crud.query_documents", new=AsyncMock(return_value=[]) + ) as mock_query: + result = await search_memory( + workspace_name="w", + observer="o", + observed="o", + query="q", + limit=5, + levels=["deductive", "inductive", "contradiction"], + embedding=[0.1], + session_allowlist=["s1"], + ) + mock_query.assert_not_awaited() + assert result.is_empty() + + @pytest.mark.asyncio + async def test_levels_untouched_without_allowlist(self): + """No allowlist means no level narrowing — unscoped recall is unchanged.""" + with patch( + "src.crud.query_documents", new=AsyncMock(return_value=[]) + ) as mock_query: + await search_memory( + workspace_name="w", + observer="o", + observed="o", + query="q", + limit=5, + levels=["deductive", "inductive"], + embedding=[0.1], + ) + assert mock_query.await_args is not None + assert mock_query.await_args.kwargs["filters"] == { + "level": {"in": ["deductive", "inductive"]} + } + + @pytest.mark.asyncio + async def test_empty_allowlist_fails_closed_without_querying(self): + with patch( + "src.crud.query_documents", new=AsyncMock(return_value=[]) + ) as mock_query: + result = await search_memory( + workspace_name="w", + observer="o", + observed="o", + query="q", + limit=5, + embedding=[0.1], + session_allowlist=[], + ) + mock_query.assert_not_awaited() + assert result.is_empty() + + +class TestMessageCrudAllowlistIntersection: + """allowlist ∩ observer-membership, fail-closed on empty intersection.""" + + async def _setup_two_sessions( + self, + client: TestClient, + workspace: Workspace, + peer: Peer, + ) -> tuple[str, str]: + ids: list[str] = [] + for marker in ("alpha", "beta"): + session_id = str(generate_nanoid()) + resp = client.post( + f"/v3/workspaces/{workspace.name}/sessions", + json={"id": session_id, "peer_names": {peer.name: {}}}, + ) + assert resp.status_code == 201 + resp = client.post( + f"/v3/workspaces/{workspace.name}/sessions/{session_id}/messages", + json={ + "messages": [ + { + "content": f"needle in {marker}", + "peer_id": peer.name, + } + ] + }, + ) + assert resp.status_code == 201 + ids.append(session_id) + return ids[0], ids[1] + + @pytest.mark.asyncio + async def test_grep_messages_intersects_allowlist( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + ): + workspace, peer = sample_data + session_a, session_b = await self._setup_two_sessions(client, workspace, peer) + + snippets = await crud.grep_messages( + workspace_name=workspace.name, + session_name=None, + text="needle", + observer=peer.name, + session_allowlist=[session_a], + ) + contents = [m.content for matches, _ in snippets for m in matches] + assert contents == ["needle in alpha"] + + # A session the observer is NOT a member of contributes nothing, + # even when allowlisted (strict intersection). + foreign = str(generate_nanoid()) + snippets = await crud.grep_messages( + workspace_name=workspace.name, + session_name=None, + text="needle", + observer=peer.name, + session_allowlist=[foreign], + ) + assert snippets == [] + + # Both sessions allowlisted -> both found + snippets = await crud.grep_messages( + workspace_name=workspace.name, + session_name=None, + text="needle", + observer=peer.name, + session_allowlist=[session_a, session_b], + ) + assert len(snippets) == 2 + + @pytest.mark.asyncio + async def test_get_messages_by_date_range_intersects_allowlist( + self, + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + workspace, peer = sample_data + session_a, _session_b = await self._setup_two_sessions(client, workspace, peer) + + messages = await crud.get_messages_by_date_range( + db_session, + workspace_name=workspace.name, + session_name=None, + observer=peer.name, + session_allowlist=[session_a], + ) + assert [m.content for m in messages] == ["needle in alpha"] + + # Empty allowlist fails closed + messages = await crud.get_messages_by_date_range( + db_session, + workspace_name=workspace.name, + session_name=None, + observer=peer.name, + session_allowlist=[], + ) + assert messages == [] + + +class TestPeerScopedJWTAllowlistGate: + """A peer-scoped key may only allowlist sessions its peer actively belongs to. + + The gate uses `active_only=True` so it agrees with the `is_peer_in_session` + check on `options.session_id` — a peer that has left a session is denied by + both, not just one. + """ + + @pytest.fixture(autouse=True) + def _enable_auth(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(settings.AUTH, "USE_AUTH", True) + monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret") + + def _chat_as( + self, + client: TestClient, + workspace: Workspace, + peer: Peer, + token: str, + body: dict[str, Any], + ): + return client.post( + f"/v3/workspaces/{workspace.name}/peers/{peer.name}/chat", + json={"query": "what do you know?", **body}, + headers={"Authorization": f"Bearer {token}"}, + ) + + async def _session_with( + self, client: TestClient, workspace: Workspace, peer: Peer + ) -> str: + session_id = str(generate_nanoid()) + resp = client.post( + f"/v3/workspaces/{workspace.name}/sessions", + json={"id": session_id, "peer_names": {peer.name: {}}}, + ) + assert resp.status_code == 201 + return session_id + + @pytest.mark.asyncio + async def test_member_sessions_allowed( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + session_id = await self._session_with(client, workspace, peer) + token = create_jwt(JWTParams(w=workspace.name, p=peer.name)) + + with patch( + "src.routers.peers.agentic_chat", new=AsyncMock(return_value="ok") + ) as mock_chat: + resp = self._chat_as( + client, + workspace, + peer, + token, + {"filters": {"session_id": [session_id]}}, + ) + assert resp.status_code == 200 + # The allowlist reaches the agent rather than being dropped at the gate. + assert mock_chat.await_args is not None + assert mock_chat.await_args.kwargs["session_allowlist"] == [session_id] + + @pytest.mark.asyncio + async def test_non_member_session_denied( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + session_id = await self._session_with(client, workspace, peer) + token = create_jwt(JWTParams(w=workspace.name, p=peer.name)) + + # One allowlisted session the peer belongs to, one it doesn't: + # membership must hold for *every* entry. + resp = self._chat_as( + client, + workspace, + peer, + token, + {"filters": {"session_id": [session_id, str(generate_nanoid())]}}, + ) + assert resp.status_code == 401 + + @pytest.mark.asyncio + async def test_left_session_denied( + self, + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + """The regression this gate's `active_only` flag exists to prevent. + + With the loose membership definition a peer that left a session still + passed here, while the adjacent `session_id` check rejected it. + """ + workspace, peer = sample_data + session_id = await self._session_with(client, workspace, peer) + token = create_jwt(JWTParams(w=workspace.name, p=peer.name)) + + await crud.remove_peers_from_session( + db_session, + workspace_name=workspace.name, + session_name=session_id, + peer_names={peer.name}, + ) + await db_session.commit() + + resp = self._chat_as( + client, workspace, peer, token, {"filters": {"session_id": [session_id]}} + ) + assert resp.status_code == 401 + + # ...and the single-session gate agrees, which is the whole point. + resp = self._chat_as(client, workspace, peer, token, {"session_id": session_id}) + assert resp.status_code == 401 + + @pytest.mark.asyncio + async def test_workspace_scoped_key_bypasses_gate( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + """Workspace keys are trusted callers — the allowlist passes as given.""" + workspace, peer = sample_data + token = create_jwt(JWTParams(w=workspace.name)) + foreign = str(generate_nanoid()) + + with patch("src.routers.peers.agentic_chat", new=AsyncMock(return_value="ok")): + resp = self._chat_as( + client, workspace, peer, token, {"filters": {"session_id": [foreign]}} + ) + assert resp.status_code == 200 + + @pytest.mark.asyncio + async def test_empty_allowlist_still_gated( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + """`filters={"session_id": []}` is a real allowlist, not an absent one. + + It must reach the gate (and pass trivially, since the empty set is a + subset of anything) rather than being skipped by a truthiness check. + """ + workspace, peer = sample_data + token = create_jwt(JWTParams(w=workspace.name, p=peer.name)) + + with patch( + "src.routers.peers.agentic_chat", new=AsyncMock(return_value="ok") + ) as mock_chat: + resp = self._chat_as( + client, workspace, peer, token, {"filters": {"session_id": []}} + ) + assert resp.status_code == 200 + assert mock_chat.await_args is not None + assert mock_chat.await_args.kwargs["session_allowlist"] == [] + + +class TestResolveSessionScope: + """The tri-state contract the four message-crud call sites depend on.""" + + @pytest.mark.asyncio + async def test_unrestricted_when_no_observer_and_no_allowlist( + self, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] + ): + workspace, _ = sample_data + assert await resolve_session_scope( + db_session, workspace.name, None, None, None + ) == (None, False) + + @pytest.mark.asyncio + async def test_pinned_session_inside_allowlist_passes_through( + self, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] + ): + workspace, _ = sample_data + # None (not [s1]) — the query filters on session_name directly. + assert await resolve_session_scope( + db_session, workspace.name, "s1", ["s1", "s2"], None + ) == (None, False) + + @pytest.mark.asyncio + async def test_pinned_session_outside_allowlist_denies( + self, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] + ): + workspace, _ = sample_data + assert await resolve_session_scope( + db_session, workspace.name, "s3", ["s1", "s2"], None + ) == (None, True) + + @pytest.mark.asyncio + async def test_empty_allowlist_denies_rather_than_returning_empty_list( + self, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] + ): + """Never returns [] — downstream stores drop an empty IN clause.""" + workspace, _ = sample_data + allowed, deny = await resolve_session_scope( + db_session, workspace.name, None, [], None + ) + assert (allowed, deny) == (None, True) + + @pytest.mark.asyncio + async def test_no_db_touched_when_no_observer_lookup_needed(self): + """Callers pass db=None on the external-vector-store path. + + The helper must not open a session of its own unless it actually needs + an observer lookup, or the external semantic lookup stops being the + first thing that happens (see + tests/integration/test_message_embeddings.py). + """ + with patch("src.crud.message.tracked_db") as mock_tracked_db: + # No observer: pinned session, unrestricted, and plain allowlist. + assert await resolve_session_scope(None, "w", "s1", None, None) == ( + None, + False, + ) + assert await resolve_session_scope(None, "w", None, None, None) == ( + None, + False, + ) + assert await resolve_session_scope(None, "w", None, ["s1"], None) == ( + ["s1"], + False, + ) + mock_tracked_db.assert_not_called() + + @pytest.mark.asyncio + async def test_observer_scope_intersected_with_allowlist( + self, + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + workspace, peer = sample_data + session_id = str(generate_nanoid()) + resp = client.post( + f"/v3/workspaces/{workspace.name}/sessions", + json={"id": session_id, "peer_names": {peer.name: {}}}, + ) + assert resp.status_code == 201 + + allowed, deny = await resolve_session_scope( + db_session, workspace.name, None, [session_id], peer.name + ) + assert (allowed, deny) == ([session_id], False) + + # Allowlisting only a session the observer isn't in denies outright. + allowed, deny = await resolve_session_scope( + db_session, workspace.name, None, [str(generate_nanoid())], peer.name + ) + assert (allowed, deny) == (None, True) + + +class TestChatRouteFilterValidation: + """Filter validation happens before any LLM work — safe to exercise.""" + + def _chat( + self, + client: TestClient, + workspace: Workspace, + peer: Peer, + body: dict[str, Any], + ): + return client.post( + f"/v3/workspaces/{workspace.name}/peers/{peer.name}/chat", + json={"query": "what do you know?", **body}, + ) + + def test_unsupported_filter_key_422( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + resp = self._chat(client, workspace, peer, {"filters": {"peer_id": ["x"]}}) + assert resp.status_code == 422 + + def test_bad_filter_shape_422( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + resp = self._chat(client, workspace, peer, {"filters": {"session_id": 42}}) + assert resp.status_code == 422 + + def test_session_id_not_in_allowlist_422( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + resp = self._chat( + client, + workspace, + peer, + {"session_id": "s-outside", "filters": {"session_id": ["s1", "s2"]}}, + ) + assert resp.status_code == 422 + + def test_allowlist_cap_422( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + too_many = [f"s{i}" for i in range(MAX_SESSION_ALLOWLIST_ENTRIES + 1)] + resp = self._chat( + client, workspace, peer, {"filters": {"session_id": too_many}} + ) + assert resp.status_code == 422 + + +class TestRepresentationRouteFilters: + @pytest.mark.asyncio + async def test_representation_scoped_by_filters( + self, + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + workspace, peer = sample_data + + session_a = models.Session( + name=str(generate_nanoid()), workspace_name=workspace.name + ) + session_b = models.Session( + name=str(generate_nanoid()), workspace_name=workspace.name + ) + db_session.add_all([session_a, session_b]) + await db_session.flush() + + collection = models.Collection( + workspace_name=workspace.name, + observer=peer.name, + observed=peer.name, + ) + db_session.add(collection) + await db_session.flush() + + db_session.add_all( + [ + models.Document( + workspace_name=workspace.name, + observer=peer.name, + observed=peer.name, + content="fact from session a", + session_name=session_a.name, + ), + models.Document( + workspace_name=workspace.name, + observer=peer.name, + observed=peer.name, + content="fact from session b", + session_name=session_b.name, + ), + models.Document( + workspace_name=workspace.name, + observer=peer.name, + observed=peer.name, + content="sessionless dream fact", + session_name=None, + ), + ] + ) + await db_session.commit() + + resp = client.post( + f"/v3/workspaces/{workspace.name}/peers/{peer.name}/representation", + json={"filters": {"session_id": [session_a.name]}}, + ) + assert resp.status_code == 200 + representation = resp.json()["representation"] + assert "fact from session a" in representation + assert "fact from session b" not in representation + assert "sessionless dream fact" not in representation + + def test_session_id_not_in_allowlist_422( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + resp = client.post( + f"/v3/workspaces/{workspace.name}/peers/{peer.name}/representation", + json={"session_id": "s-out", "filters": {"session_id": ["s-in"]}}, + ) + assert resp.status_code == 422 diff --git a/tests/utils/test_agent_tools.py b/tests/utils/test_agent_tools.py index 9bfb3990..71b93eb0 100644 --- a/tests/utils/test_agent_tools.py +++ b/tests/utils/test_agent_tools.py @@ -39,6 +39,8 @@ from src.utils.agent_tools import ( create_observations, create_tool_executor, extract_preferences, + get_observation_context, + get_recent_history, ) # ============================================================================= @@ -801,6 +803,7 @@ class TestSearchMemory: context_window: int = 2, embedding: list[float] | None = None, observer: str | None = None, + **_kwargs: Any, ) -> list[tuple[list[models.Message], list[models.Message]]]: _ = (workspace_name, session_name, query, limit, context_window, observer) fallback_embeddings.append(embedding) @@ -909,6 +912,7 @@ class TestSearchMessagesTemporal: context_window: int = 2, embedding: list[float] | None = None, observer: str | None = None, + **_kwargs: Any, ) -> list[tuple[list[models.Message], list[models.Message]]]: _ = ( workspace_name, @@ -1502,6 +1506,7 @@ class TestExtractPreferences: context_window: int, embedding: list[float] | None, observer: str | None = None, + **_kwargs: Any, ) -> list[tuple[list[models.Message], list[models.Message]]]: _ = (limit, context_window, observer) embedding_args.append(embedding) @@ -1848,3 +1853,63 @@ class TestObserverPeerNameWiring: await _handle_get_messages_by_date_range(ctx, {"after_date": "2024-01-01"}) assert captured_kwargs["observer"] == ctx.observer + + +@pytest.mark.asyncio +class TestSessionAllowlistFailClosed: + """A specific session_name outside the session_allowlist allowlist must fail closed. + + Routes guard this too, but these CRUD/tool functions are reachable directly + from the dialectic loop, so the allowlist is enforced at the boundary. + """ + + async def test_get_recent_history_respects_allowlist( + self, db_session: AsyncSession, tool_test_data: Any + ): + workspace, _peer1, peer2, session, _messages, _ = tool_test_data + + # session IS in the allowlist -> history returned + allowed = await get_recent_history( + db_session, + workspace_name=workspace.name, + session_name=session.name, + observed=peer2.name, + session_allowlist=[session.name], + ) + assert allowed # non-empty + + # session is NOT in the allowlist -> fail closed + blocked = await get_recent_history( + db_session, + workspace_name=workspace.name, + session_name=session.name, + observed=peer2.name, + session_allowlist=["some-other-session"], + ) + assert blocked == [] + + async def test_get_observation_context_fails_closed( + self, db_session: AsyncSession, tool_test_data: Any + ): + workspace, peer1, _peer2, session, messages, _ = tool_test_data + blocked = await get_observation_context( + db_session, + workspace_name=workspace.name, + session_name=session.name, + message_ids=[messages[0].id], + observer=peer1.name, + session_allowlist=["some-other-session"], + ) + assert blocked == [] + + async def test_get_messages_by_date_range_fails_closed( + self, db_session: AsyncSession, tool_test_data: Any + ): + workspace, _peer1, _peer2, session, _messages, _ = tool_test_data + blocked = await crud.get_messages_by_date_range( + db_session, + workspace_name=workspace.name, + session_name=session.name, + session_allowlist=["some-other-session"], + ) + assert blocked == [] From 4d3ab1c36b4f805af02db3ef1feb1a049efe422f Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:19:41 -0400 Subject: [PATCH 65/65] fix: increase throughput of unit tests by changing behavior db teardown (#949) * fix: increase throughput of unit tests by changing behavior db teardown * fix: address review comments --- scripts/dialectic_cost_calculator.py | 6 +- scripts/test_reasoning_levels.py | 5 +- src/vector_store/__init__.py | 8 +- tests/conftest.py | 148 ++++++++++++++++++++++++--- tests/test_cache_redaction.py | 2 +- tests/utils/test_agent_tools.py | 2 +- 6 files changed, 148 insertions(+), 23 deletions(-) diff --git a/scripts/dialectic_cost_calculator.py b/scripts/dialectic_cost_calculator.py index 0c014628..af0e3275 100644 --- a/scripts/dialectic_cost_calculator.py +++ b/scripts/dialectic_cost_calculator.py @@ -170,10 +170,10 @@ def calculate_level_cost( realistic_final_answer=realistic_final, ) - model = level_config.MODEL + model = level_config.MODEL_CONFIG.model max_iterations = level_config.MAX_TOOL_ITERATIONS - thinking_budget = level_config.THINKING_BUDGET_TOKENS - provider = level_config.PROVIDER + thinking_budget = level_config.MODEL_CONFIG.thinking_budget_tokens or 0 + provider = level_config.MODEL_CONFIG.transport # Get pricing for this model pricing = MODEL_PRICING.get(model, {"input": 0, "output": 0, "cached": 0}) diff --git a/scripts/test_reasoning_levels.py b/scripts/test_reasoning_levels.py index 699b5f18..3fd38752 100755 --- a/scripts/test_reasoning_levels.py +++ b/scripts/test_reasoning_levels.py @@ -6,6 +6,7 @@ import json import os import time from datetime import datetime, timedelta, timezone +from typing import Any import httpx from dotenv import load_dotenv @@ -106,7 +107,7 @@ def load_locomo( print(f" Created session: {session_id}") # Build message batch - msg_batch = [] + msg_batch: list[dict[str, Any]] = [] for i, msg in enumerate(messages): msg_time = base_time + timedelta(seconds=i * 2) msg_batch.append( @@ -134,7 +135,7 @@ def load_locomo( def chat( client: httpx.Client, workspace_id: str, peer_id: str, query: str, level: str -) -> dict: +) -> dict[str, Any]: """Call the chat endpoint with a specific reasoning level.""" resp = client.post( f"{BASE_URL}/workspaces/{workspace_id}/peers/{peer_id}/chat", diff --git a/src/vector_store/__init__.py b/src/vector_store/__init__.py index 85fdd9ff..17eeec34 100644 --- a/src/vector_store/__init__.py +++ b/src/vector_store/__init__.py @@ -207,10 +207,10 @@ def _create_store_by_type(store_type: str) -> VectorStore: except ImportError as exc: raise RuntimeError( "VECTOR_STORE.TYPE is set to 'lancedb', but the 'lancedb' package " - "is not installed (for example on macOS Intel, where it is omitted " - "from dependencies because PyPI has no wheel). " - "Use TYPE 'pgvector' or 'turbopuffer', or install lancedb manually. " - f"Original import error: {exc}" + + "is not installed (for example on macOS Intel, where it is omitted " + + "from dependencies because PyPI has no wheel). " + + "Use TYPE 'pgvector' or 'turbopuffer', or install lancedb manually. " + + f"Original import error: {exc}" ) from exc return LanceDBVectorStore() diff --git a/tests/conftest.py b/tests/conftest.py index b3697242..1ec64055 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,8 @@ import logging +import os +import re +import time +import uuid from collections.abc import AsyncGenerator, Callable from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -13,7 +17,7 @@ from fastapi import Request from fastapi.responses import JSONResponse from fastapi.testclient import TestClient from nanoid import generate as generate_nanoid -from sqlalchemy import text +from sqlalchemy import create_engine, text from sqlalchemy.engine.url import URL, make_url from sqlalchemy.exc import OperationalError, ProgrammingError from sqlalchemy.ext.asyncio import ( @@ -25,7 +29,6 @@ from sqlalchemy.ext.asyncio import ( from sqlalchemy_utils import ( create_database, # pyright: ignore[reportUnknownVariableType] database_exists, # pyright: ignore[reportUnknownVariableType] - drop_database, # pyright: ignore[reportUnknownVariableType] ) from src import models @@ -128,11 +131,122 @@ def pytest_collection_modifyitems( item.add_marker(skip_live) +_RUN_ID_ENV_VAR = "HONCHO_TEST_RUN_ID" +_RUN_ID_TIME_FORMAT = "%Y%m%d%H%M%S" + +# Only a database whose name carries a run-id timestamp this old is swept. Long +# enough that no live suite is ever this stale, short enough that a leak from the +# morning is gone by the afternoon. +_STALE_DB_AGE_SECONDS = 2 * 60 * 60 + +# test_db_<14-digit timestamp>_<4 hex>[_gwN] -- only names this function minted. +# A pinned HONCHO_TEST_RUN_ID deliberately won't match, so it's never swept. +_SWEEPABLE_DB_NAME = re.compile(r"^test_db_(\d{14})_[0-9a-f]{4}(?:_gw\d+)?$") + + +def pytest_configure(config: pytest.Config) -> None: # pyright: ignore[reportUnusedParameter] + """Stamp this pytest run with an id so its databases can't collide with another run's. + + The xdist controller runs this first and its environment is inherited by the + workers it spawns, so `setdefault` gives every worker in a run the same id + while separate runs (concurrent worktrees, two agents, a local run alongside + CI) each get their own. Set the env var yourself to pin a stable name. + + The id leads with a sortable local-time timestamp so leaked databases can be + aged out (see `_sweep_stale_test_databases`); the random tail keeps two runs + starting in the same second apart. + """ + + os.environ.setdefault( + _RUN_ID_ENV_VAR, + f"{time.strftime(_RUN_ID_TIME_FORMAT)}_{uuid.uuid4().hex[:4]}", + ) + + # Workers inherit the controller's env and would each redo this. + if os.environ.get("PYTEST_XDIST_WORKER") is None: + _sweep_stale_test_databases() + + def _get_test_db_url(worker_id: str) -> URL: """Get a worker-specific test database URL for pytest-xdist parallelism.""" - db_name = "test_db" if worker_id == "master" else f"test_db_{worker_id}" - return CONNECTION_URI.set(database=db_name) + run_id = os.environ.get(_RUN_ID_ENV_VAR, "local") + suffix = "" if worker_id == "master" else f"_{worker_id}" + return CONNECTION_URI.set(database=f"test_db_{run_id}{suffix}") + + +def _drop_database(db_url: URL) -> None: + """Drop a test database, evicting any connections still holding it open. + + WITH (FORCE) (pg13+) is what makes this reliable: a pooled connection that + outlives engine disposal, or an xdist worker killed mid-query, otherwise + leaves the drop failing with "database is being accessed by other users". + """ + + name = db_url.database + if not name: + return + + # Maintenance connection: you cannot drop the database you're connected to. + engine = create_engine( + db_url.set(database="postgres"), isolation_level="AUTOCOMMIT" + ) + try: + with engine.connect() as conn: + conn.exec_driver_sql(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)') + finally: + engine.dispose() + + +def _sweep_stale_test_databases() -> None: + """Reclaim test databases left behind by runs that died before teardown. + + A run killed by SIGKILL, an IDE stop button, an OOM'd worker or `-x` on a hang + never reaches the `db_engine` teardown, and since every run mints its own + database name nothing later reuses (and thus cleans) it. + + Two guards keep this from touching a suite that is currently running, which is + the whole point of per-run names: + + - the run-id timestamp in the name must be older than `_STALE_DB_AGE_SECONDS` + - the database must have no backends connected to it right now + + Each covers the other's blind spot: the age check is immune to the race where + a database has been created but its first worker hasn't connected yet, and the + connection check catches a genuinely long-running suite. Failure to sweep is + logged and ignored -- it must never fail a test session. + """ + + cutoff = time.strftime( + _RUN_ID_TIME_FORMAT, time.localtime(time.time() - _STALE_DB_AGE_SECONDS) + ) + + try: + engine = create_engine( + CONNECTION_URI.set(database="postgres"), isolation_level="AUTOCOMMIT" + ) + try: + with engine.connect() as conn: + names = [ + row[0] + for row in conn.exec_driver_sql( + "SELECT datname FROM pg_database d " + + "WHERE NOT EXISTS (" + + " SELECT 1 FROM pg_stat_activity WHERE datname = d.datname" + + ")" + ) + ] + finally: + engine.dispose() + + for name in names: + match = _SWEEPABLE_DB_NAME.match(name) + if match is None or match.group(1) >= cutoff: + continue + logger.info(f"Dropping stale test database: {name}") + _drop_database(CONNECTION_URI.set(database=name)) + except Exception as e: + logger.warning(f"Could not sweep stale test databases: {e}") # Test API authorization - no longer needed as module-level constants @@ -193,11 +307,21 @@ async def setup_test_database(db_url: URL): return engine -async def _truncate_all_tables(engine: AsyncEngine) -> None: - """Remove all data from every mapped table while resetting identities.""" +async def _clear_all_tables(engine: AsyncEngine) -> None: + """Remove all data from every mapped table between tests. + + Uses DELETE rather than TRUNCATE: TRUNCATE rewrites the relfilenode of every + table and index it touches, so it costs a flat ~33ms for this schema's 11 + tables / 41 indexes no matter how few rows a test actually wrote. DELETE of + the same (near-empty) tables, batched into one round trip, is ~3ms. Tables go + in reverse dependency order so foreign keys are satisfied without CASCADE. + + This does not reset identity sequences, so tests must not assert on absolute + generated id values -- compare against the ids the test itself created. + """ table_names: list[str] = [] - for table in Base.metadata.sorted_tables: + for table in reversed(Base.metadata.sorted_tables): if table.schema: table_names.append(f'"{table.schema}"."{table.name}"') else: @@ -206,9 +330,9 @@ async def _truncate_all_tables(engine: AsyncEngine) -> None: if not table_names: return - joined_names = ", ".join(table_names) + statement = "; ".join(f"DELETE FROM {name}" for name in table_names) async with engine.begin() as conn: - await conn.execute(text(f"TRUNCATE {joined_names} RESTART IDENTITY CASCADE")) + await conn.exec_driver_sql(statement) @pytest_asyncio.fixture(scope="session") @@ -242,7 +366,7 @@ async def db_engine(worker_id: str): for table in Base.metadata.tables.values(): table.schema = original_schema - drop_database(test_db_url) + _drop_database(test_db_url) @pytest_asyncio.fixture(scope="function") @@ -256,7 +380,7 @@ async def db_session(db_engine: AsyncEngine): finally: await session.rollback() finally: - await _truncate_all_tables(db_engine) + await _clear_all_tables(db_engine) @pytest_asyncio.fixture(scope="session") @@ -430,7 +554,7 @@ async def sample_data( db_session.add(test_peer) # Commit so data is visible to independent tracked_db sessions. - # _truncate_all_tables handles cleanup between tests. + # _clear_all_tables handles cleanup between tests. await db_session.commit() yield test_workspace, test_peer diff --git a/tests/test_cache_redaction.py b/tests/test_cache_redaction.py index 0ff2bbc7..3a8e6891 100644 --- a/tests/test_cache_redaction.py +++ b/tests/test_cache_redaction.py @@ -2,7 +2,7 @@ import pytest -from src.cache.client import _redact_cache_url +from src.cache.client import _redact_cache_url # pyright: ignore[reportPrivateUsage] class TestRedactCacheUrl: diff --git a/tests/utils/test_agent_tools.py b/tests/utils/test_agent_tools.py index 71b93eb0..c13fb1e7 100644 --- a/tests/utils/test_agent_tools.py +++ b/tests/utils/test_agent_tools.py @@ -129,7 +129,7 @@ async def tool_test_data( # Commit so data is visible to independent tracked_db sessions. # Tool handlers no longer share the test's db_session — they open # their own short-lived sessions via tracked_db. - # _truncate_all_tables handles cleanup between tests. + # _clear_all_tables handles cleanup between tests. await db_session.commit() yield workspace, peer1, peer2, session, messages, documents