From 7bafee5de1b77a619f56c32ba59d9dcc0e115449 Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Wed, 19 Aug 2026 16:18:47 -0400 Subject: [PATCH 01/50] chore: add pr template and pre-pre skill (#1031) --- .github/pull_request_template.md | 13 +++++++ skills/pre-pr/SKILL.md | 63 ++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 .github/pull_request_template.md create mode 100644 skills/pre-pr/SKILL.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..5463b6d0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Description + + + +## Proofs + + + +## Checklist + +- [ ] This PR is correlated to an existing issue, and I understand it will be closed if that issue does not have the `maintainer-approved` label. + + diff --git a/skills/pre-pr/SKILL.md b/skills/pre-pr/SKILL.md new file mode 100644 index 00000000..0c1e1797 --- /dev/null +++ b/skills/pre-pr/SKILL.md @@ -0,0 +1,63 @@ +--- +name: pre-pr +description: Prepare a Honcho change for a pull request to plastic-labs/honcho. Invoke before opening a PR, when drafting a PR body, when asked if a branch is PR-ready, or when filling the pull request template. Checks the linked issue, required tests and docs, then writes Description / Proofs / Fixes. +--- + +# Pre-PR checklist + +Do this after the change works, before anyone opens the GitHub PR. Output is a filled template body — do not create the PR. + +The template lives at `.github/pull_request_template.md`. Do not add extra sections. + +## 1. Issue gate (hard stop) + +A PR without a maintainer-approved issue will be closed. + +```bash +gh issue view --repo plastic-labs/honcho --json number,title,labels,state +``` + +Stop if any of these fail: + +- no issue number, or the issue is not in `plastic-labs/honcho` +- issue is closed (unless this PR is explicitly reopening it) +- labels do not include `maintainer-approved` + +Say which check failed. Do not draft a PR body around it. + +## 2. Classify the diff + +```bash +git diff main...HEAD --stat +``` + +Pick one primary kind: bug, feature, docs. Then decide layers: + +| Surface touched | Required | +| --- | --- | +| `src/` (non-prompt) | unit tests under the matching `tests/` tree | +| deriver / dialectic / dreamer / LLM path | unit + consider live-llm (`tests/live_llm`) | +| queue, config hierarchy, multi-turn, SDK contract | unified (`uv run python -m tests.unified.run`) | +| `/v3` HTTP or deriver queue behavior | `/verify` skill (runtime, not just pytest) | +| public API, SDK exports, `config.toml` / settings, mintlify `docs/` | documentation in the matching file | + +Skip a layer only with a one-line reason (e.g. "docs-only", "comment-only"). "When appropriate" is not a skip. + +Invoke `/verify` when the runtime surface moved. Do not restate that skill here. + +Lint/type before claiming tests are green: `uv run ruff check src/` → `uv run basedpyright` → the pytest command for the layer. + +## 3. Proofs + +Collect evidence that belongs in the PR, not in the commit: + +- command + pass/fail for what you ran +- a log snippet, screenshot, or file path that shows the new behavior +- for bugs: the failing case before vs after, if you have it + +If `/verify` ran, the proofs *are* that session's output. Do not invent green runs. + +## 4. Write the body + +Fill the description, proofs, checklist portion of the pull request description template. +Make sure to link the related github issue, otherwise the PR will be auto-closed. From 47974892812cbe755a7970940f565e151767a20f Mon Sep 17 00:00:00 2001 From: Phil Date: Thu, 20 Aug 2026 10:24:49 -0400 Subject: [PATCH 02/50] telemetry: zero-initialize bounded-label metrics so an absent series means a broken scrape (#927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * telemetry: materialize dropped-event counter children at 0 A labeled Prometheus counter exports no series until its first labels() call, so telemetry_events_dropped stayed invisible until an event was actually dropped — impossible to alert on or graph, and "no drops" was indistinguishable from "metric missing / scrape broken". Pre-create the (namespace, reason) children at 0 on emitter start, for each reason the emitter can emit, so the metric is always present. Co-Authored-By: Claude Opus 4.8 (1M context) * telemetry: generalize counter zero-init to all bounded-label counters Extends #927 (which zero-inited telemetry_events_dropped) to every counter whose label domain is bounded and known at startup, so metrics are present in Prometheus before their first event — a missing series then signals a broken scrape rather than "nothing happened yet". - add initialize_bounded_metrics(instance_type) on PrometheusMetrics; call it per-process from main.py (api) and deriver/__main__.py (deriver). - extract a shared _touch() helper; refactor initialize_telemetry_dropped_metrics onto it (that one stays per-emitter in start() — it's prefix-dependent). - explicit ALL_EVENT_TYPES / HIGH_VOLUME_EVENT_TYPES registry in telemetry.events, drift-guarded by tests that walk BaseEvent subclasses. - only VALID (task_type, token_type, component) tuples for deriver_tokens (the cartesian product would fabricate impossible always-0 series); only high-volume event types for sampled_out; high-cardinality labels (endpoint, workspace_name) left open. - gauges: zero-init embed_now_tasks_in_flight + telemetry_buffer_size; add a new message_embeddings_pending backlog gauge, set each reconciliation cycle and zero-inited at deriver startup (Rajat's pending/in-flight ask). - backfills the tests #927 shipped without. Co-Authored-By: Claude Opus 4.8 (1M context) * review: task-aware deriver combos + fail-soft gauge zero-init I1: _DERIVER_TOKEN_COMBOS was factored task-independently, materializing the impossible (ingestion, input, previous_summary) series — previous_summary is summary-only. Make combos task-aware (_DERIVER_TOKEN_COMBOS_BY_TASK) so no always-0 impossible series is fabricated, matching the PR's own goal. Tests tightened to assert the ingestion/previous_summary series is absent. I2: the three gauge .set(0) zero-inits were bare while the counter inits go through the fail-soft _touch. Add _set_gauge_zero() so a gauge init can't propagate an exception into process startup either. Co-Authored-By: Claude Opus 4.8 (1M context) * test(telemetry): isolate zero-init namespaces, add deriver-to-api guard Global-REGISTRY assertions used a fixed "test" namespace, which several other suites also pin, so another test's materialized children could satisfy a presence assertion or break an absence one. Each test now runs under a unique namespace resolved from settings at read time. Adds the inverse per-process isolation test: deriver-only init must not materialize API-only series (dialectic tokens, embed_now). Co-Authored-By: Claude * review: per-replica backlog gauge, drop duplicated constants and .meta refs Addresses Vineeth's review on #927. Blocking: - message_embeddings_pending is a DB-global count, so drive it from ReconcilerScheduler._scheduler_loop (runs on every replica, every interval) instead of run_vector_reconciliation_cycle (runs off the queue behind work-unit dedup, so one replica per cycle). Combined with the zero-init, the old placement made every replica that never won the work unit export a confident permanent 0. Help string now names the owner so dashboards don't reach for sum(). - guard initialize_telemetry_dropped_metrics on METRICS.ENABLED, matching its sibling initializer. - drop the duplicate REASONING_LEVELS; import the one in src/config. Non-blocking: - walk BaseSpecialist recursively via a shared utils.types.walk_subclasses (replaces the direct-children-only __subclasses__() and the test's private copy of the same helper). - derive the specialist assertion from the subclasses instead of hardcoding two names — the hardcoded pair kept passing after CardRefreshSpecialist landed, leaving it uncovered. - inline the zero-init rationale and the multi-instance bucket taxonomy; removes both pointers to a .meta design doc that is not in the repo. Tests: new tests/reconciler/test_pending_backlog_gauge.py pins both halves of the relocation (verified it fails when reverted). * review: fix inert test guard, stale comments, and the REASONING_LEVELS drift claim Second review pass on the branch. Findings, most severe first: - tests/reconciler/test_pending_backlog_gauge.py: the _try_enqueue_task stub was patched onto the class but declared without `self`, so calling it raised TypeError — which _scheduler_loop swallows. The guard was inert and the test passed for the wrong reason. Fixed the arity. - metrics.py still commented that the backlog gauge is "set live each reconciliation cycle". That is the exact claim the previous commit overturned; it now contradicted the help string, the bucket-3 docstring and sync_vectors.py. - metrics.py claimed REASONING_LEVELS is "derived from the config Literal so it never drifts", but config.py hand-listed it, so the earlier dedup had quietly traded away the guarantee the original get_args() call provided. Made it true instead: config.REASONING_LEVELS = list(get_args(...)), which keeps the dedup and restores the invariant. - dropped _set_gauge_zero: all three gauges it zeroed already have identical fail-soft setters, so it was a second way to do one thing. Using the setters also makes _handle_metric_error name the actual gauge. - record_pending_embeddings_backlog's docstring oversold the covering index as making the COUNT "negligible". The index makes cost proportional to the pending backlog, not to the table — which is worst precisely when the backlog matters. Stated honestly. - _scheduler_loop's docstring said it only enqueues; it also refreshes the gauge, at a cadence set by the shortest task interval. - comment reconciliation: stripped #927 / "the generalization" temporal anchoring, a CardRefreshSpecialist change-narration clause, and reviewer-directed phrasing from the test file; disambiguated the src/utils/summarizer.py path. - CLAUDE.md had no Prometheus section at all, so the new "add a BaseEvent subclass -> update ALL_EVENT_TYPES" obligation and the never-sum() rule for non-additive gauges were undiscoverable from the architecture doc. Verified: ruff + basedpyright clean (0 errors), tests/telemetry + reconciler + dialectic + llm 497 passed, full suite 1768 passed with only the 4 pre-existing test_document failures (OpenAI key required, reproduced on clean origin/main). Re-confirmed the relocation guard fails when reverted. * fix: silence the two basedpyright warnings inherited from main CI runs `uv run basedpyright` bare, and basedpyright exits non-zero on any warning — so these two have been failing the staticanalysis job on every branch cut from current main, not just this one: - src/vector_store/__init__.py:209 implicit string concatenation (#496) - tests/test_cache_redaction.py:5 private import (#869) Both predate this branch and are unrelated to the telemetry work; fixed here only because they block this PR from going green. Verified: clean origin/main also reports "0 errors, 2 warnings" and exits 1. basedpyright now 0 errors, 0 warnings, exit 0. * docs(telemetry): make the bucket-3 aggregation rule precise The multi-instance taxonomy said a service-scoped non-additive metric has "no aggregation correct once they disagree", then immediately mandated that every instance refresh on its own timer. Those undercut each other: staggered timers ALWAYS disagree slightly, so as written the rule reads as "ensure they don't", which is unachievable, and it leaves the reader unsure whether max() and avg() survived the fix. The actual rule is bounded disagreement plus a scale-preserving aggregator. Instances are N witnesses to one fact, not N parts of one whole, so sum() can never be correct (it scales with replica count) while max()/avg()/quantiles are correct precisely because the per-instance timer bounds the spread. Wording only; no behavior change. The gauge help string already said "max() or avg(), never sum()" — this makes the normative docstring agree with it. Surfaced walking Vineeth's comment 3668208059 for comprehension. * refactor(bench): import REASONING_LEVELS from config instead of re-listing Third copy of the constant, missed when ee781c0/694e07f deduped the other two. This one re-declared the ReasoningLevel Literal as well as the list, so the type alias could diverge from config's with nothing to catch it — and the list was hand-written, the variant that typechecks clean while missing a member. No import barrier justified it: this module already imports from src, as do seven of its siblings in tests/bench. Concrete effect of the drift was that a newly added sixth reasoning level would be rejected by the bench CLI's argparse choices=. src.config.REASONING_LEVELS is now the single definition repo-wide. * test(telemetry): pin the METRICS.ENABLED guard on the per-emitter initializer initialize_telemetry_dropped_metrics gained a METRICS.ENABLED guard in ee781c0, addressing Vineeth's asymmetry comment, but nothing asserted it — it had only the enabled half of the pair its sibling has. Deleting the guard left the suite green, so the fix closed the asymmetry in the guards and reproduced it one level up in the tests. Mirrors test_init_noop_when_metrics_disabled. Verified live rather than assumed: deleting the two guard lines turns this test red. Uses a unique namespace, without which the absence assertion would be satisfied by the enabled test's children rather than by the guard. * docs(telemetry): fold zero-init why-prose behind # region ai markers Comment/docstring-only pass over the changed files, per the groudon comment-marker standard: the terse human-facing "what" stays visible, and load-bearing "why" (the zero-init / absent-series-means-broken-scrape rationale, gotchas, receipts) folds into # region ai / # ai: blocks. Behavior-preserving: AST-identical modulo docstrings/comments vs the pre-pass merge; ruff, ruff format --check, and basedpyright all clean. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CLAUDE.md | 3 + src/config.py | 17 +- src/deriver/__main__.py | 7 + src/main.py | 6 + src/reconciler/scheduler.py | 16 +- src/reconciler/sync_vectors.py | 37 ++ src/telemetry/emitter.py | 15 + src/telemetry/events/__init__.py | 65 ++++ src/telemetry/prometheus/metrics.py | 193 +++++++++- src/utils/types.py | 16 +- tests/bench/runner_common.py | 7 +- .../reconciler/test_pending_backlog_gauge.py | 86 +++++ tests/telemetry/test_metric_zero_init.py | 357 ++++++++++++++++++ 13 files changed, 807 insertions(+), 18 deletions(-) create mode 100644 tests/reconciler/test_pending_backlog_gauge.py create mode 100644 tests/telemetry/test_metric_zero_init.py diff --git a/CLAUDE.md b/CLAUDE.md index c9862b59..6a83066d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,6 +187,9 @@ The Dreamer is an orchestrated multi-specialist system that runs during schedule - **LLM subsystem** (`src/llm/`): provider-agnostic `honcho_llm_call()`. Backends in `src/llm/backends/` (`anthropic.py`, `gemini.py`, `openai.py`). Includes prompt caching (`caching.py`), structured output (`structured_output.py`), tool loop (`tool_loop.py`), history adapters for cross-provider message formats, and a model registry. Per-retry provider selection is pinned via an `AttemptPlan` so stream-final retries don't bounce back to primary after the tool loop has settled on fallback. - **Per-agent model config**: each agent has its own `MODEL_CONFIG` in `src/config.py` with fallback chains (see `ConfiguredModelSettings`, `FallbackModelSettings`). - **Telemetry**: cloudevents in `src/telemetry/events/` cover API routes, dialectic, dream, deletion, reconciliation, representation, and per-call LLM accounting (`llm.py` — `LLMCallCompletedEvent` fires once per provider hit with full cost-attribution context). High-volume events are sampled deterministically per `run_id` via `TelemetrySettings.HIGH_VOLUME_SAMPLE_RATE`. +- **Prometheus metrics** (`src/telemetry/prometheus/`): every metric carries a `namespace` label and every recorder is fail-soft (a metrics error never propagates into a request or a worker loop). Counter children with a *bounded* label domain are zero-initialized per process at startup — `initialize_bounded_metrics(instance_type=...)`, called from the `src/main.py` lifespan (`api`) and `src/deriver/__main__.py` (`deriver`) — so an absent series means a broken scrape rather than "nothing happened". Two consequences worth knowing before touching telemetry: + - **Adding a `BaseEvent` subclass requires adding its `_event_type` to `ALL_EVENT_TYPES`** in `src/telemetry/events/__init__.py` (and to `HIGH_VOLUME_EVENT_TYPES` if `_volume_class == "high_volume"`). Enforced by the drift guards in `tests/telemetry/test_metric_zero_init.py`, which assert set-equality against the discovered subclasses. + - **A service-wide, non-additive gauge must be refreshed by every replica on its own timer**, and aggregated with `max()`/`avg()`, never `sum()`. `message_embeddings_pending` is the example: it reports a DB-global count, so it is driven from `ReconcilerScheduler._scheduler_loop` (runs on all replicas) rather than from the work-unit-deduped reconciliation cycle — otherwise, combined with the zero-init, every replica that never won the work unit would export a confident permanent `0`. ### Project Structure diff --git a/src/config.py b/src/config.py index 092cb6ee..993f9bfb 100644 --- a/src/config.py +++ b/src/config.py @@ -2,7 +2,7 @@ import logging import math import os from pathlib import Path -from typing import Annotated, Any, ClassVar, Literal, cast +from typing import Annotated, Any, ClassVar, Literal, cast, get_args from urllib.parse import urlparse import tomllib @@ -998,15 +998,14 @@ class PeerCardSettings(HonchoSettings): ENABLED: bool = True -# Reasoning levels for dialectic - defined here to avoid circular imports with schemas +# Reasoning levels for dialectic - defined here to avoid circular imports with schemas. +# region ai +# REASONING_LEVELS is derived from the Literal, not hand-listed: the annotation +# rejects an invalid member but not a MISSING one, so a hand-written copy could +# silently drop a level and still typecheck. +# endregion ReasoningLevel = Literal["minimal", "low", "medium", "high", "max"] -REASONING_LEVELS: list[ReasoningLevel] = [ - "minimal", - "low", - "medium", - "high", - "max", -] +REASONING_LEVELS: list[ReasoningLevel] = list(get_args(ReasoningLevel)) class DialecticLevelSettings(BaseModel): diff --git a/src/deriver/__main__.py b/src/deriver/__main__.py index c56ed6a0..ce3969b5 100644 --- a/src/deriver/__main__.py +++ b/src/deriver/__main__.py @@ -10,6 +10,7 @@ from src.db import engine, register_db_query_instrumentation from src.startup import validate_embedding_schema from src.telemetry import ( initialize_telemetry_async, + prometheus_metrics, register_db_pool_collector, shutdown_telemetry, ) @@ -25,6 +26,12 @@ def start_metrics_server() -> None: # Expose DB connection-pool stats for this deriver instance. register_db_pool_collector("deriver") register_db_query_instrumentation("deriver") + + # region ai + # Zero-init bounded-label counters so a missing series signals a broken scrape, + # not "no events" — see initialize_bounded_metrics. No-op if metrics off. + # endregion + prometheus_metrics.initialize_bounded_metrics(instance_type="deriver") logger.info("Prometheus metrics server started on port 9090") diff --git a/src/main.py b/src/main.py index 75eac455..6930d4c1 100644 --- a/src/main.py +++ b/src/main.py @@ -110,6 +110,12 @@ async def lifespan(_: FastAPI): register_db_pool_collector("api") register_db_query_instrumentation("api") + # region ai + # Zero-init bounded-label counters so a missing series signals a broken scrape, + # not "no events" — see initialize_bounded_metrics. No-op if metrics off. + # endregion + prometheus_metrics.initialize_bounded_metrics(instance_type="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/reconciler/scheduler.py b/src/reconciler/scheduler.py index e171c4fa..08941970 100644 --- a/src/reconciler/scheduler.py +++ b/src/reconciler/scheduler.py @@ -21,6 +21,7 @@ from src import models from src.config import settings from src.dependencies import tracked_db from src.models import QueueItem +from src.reconciler.sync_vectors import record_pending_embeddings_backlog logger = logging.getLogger(__name__) @@ -145,15 +146,26 @@ class ReconcilerScheduler: async def _scheduler_loop(self) -> None: """ - Main scheduler loop that enqueues tasks based on their intervals. + Main scheduler loop that enqueues tasks based on their intervals, and + refreshes the service-wide pending-embeddings backlog gauge each pass. Each task has its own interval and the loop checks all tasks on each - iteration, enqueueing any that are due. + iteration, enqueueing any that are due. The loop sleeps until the next + task is due, so the gauge's refresh cadence tracks the SHORTEST task + interval. """ try: while not self._shutdown_event.is_set(): now = datetime.now(timezone.utc) + # region ai + # Refresh on EVERY replica, not just whichever wins the sync_vectors + # work unit: the count is DB-global, so a replica that never ran a + # cycle would otherwise export a stale (or zero-initialized) value + # forever. Full rationale in record_pending_embeddings_backlog. + # endregion + await record_pending_embeddings_backlog() + # Check each task and enqueue if due for task_name, task in RECONCILER_TASKS.items(): next_run = self._next_run.get(task_name, now) diff --git a/src/reconciler/sync_vectors.py b/src/reconciler/sync_vectors.py index 68e9ac59..1ff94542 100644 --- a/src/reconciler/sync_vectors.py +++ b/src/reconciler/sync_vectors.py @@ -23,6 +23,7 @@ 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 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 @@ -700,6 +701,42 @@ async def _cleanup_pgvector_batch( return True +async def record_pending_embeddings_backlog() -> None: + """Set the pending-embeddings backlog gauge to the current count of + MessageEmbedding rows awaiting a vector (sync_state='pending').""" + # region ai + # Called from ``ReconcilerScheduler._scheduler_loop``, deliberately NOT from + # ``run_vector_reconciliation_cycle``: the cycle runs off the queue behind + # work-unit dedup, so exactly one deriver replica executes it. Driving the gauge + # from there would leave every other replica exporting a stale value — or, since + # this metric is zero-initialized, a confident permanent 0 it never measured. The + # count is a property of the database, not the process, so every replica must + # refresh it on its own timer for ``max()``/``avg()`` to mean anything. + # + # Cost: one COUNT per replica per scheduler interval (~5 min by default). + # ``ix_message_embeddings_sync_state_last_sync_at`` keeps the scan proportional to + # the pending backlog, not the whole table — which is not the same as cheap: after + # an embedding outage the backlog is exactly what is large. Still a small duty + # cycle, and the cost shrinks as the reconciler drains. + # + # Best-effort: a metrics/DB hiccup here must never break the scheduler loop. + # endregion + if not settings.METRICS.ENABLED: + return + try: + async with tracked_db("reconciler_pending_count", read_only=True) as db: + count = await db.scalar( + select(func.count()) + .select_from(models.MessageEmbedding) + .where(models.MessageEmbedding.sync_state == "pending") + ) + prometheus_metrics.set_message_embeddings_pending(count=count or 0) + except Exception: + logger.warning( + "Failed to record pending-embeddings backlog gauge", exc_info=True + ) + + async def run_vector_reconciliation_cycle() -> ReconciliationMetrics: """ Run a complete reconciliation cycle. diff --git a/src/telemetry/emitter.py b/src/telemetry/emitter.py index 201d16aa..397ecd90 100644 --- a/src/telemetry/emitter.py +++ b/src/telemetry/emitter.py @@ -172,6 +172,21 @@ class TelemetryEmitter: ) self._running = True self._flush_task = asyncio.create_task(self._periodic_flush()) + + # region ai + # Pre-create the dropped-event counter children at 0: a labeled counter + # exports nothing until its first observation, so this makes the metric + # visible before any drop and lets us tell "no drops" from "metric missing". + # endregion + from src.telemetry.prometheus.metrics import prometheus_metrics + + prometheus_metrics.initialize_telemetry_dropped_metrics( + reasons=[ + f"{self.drop_reason_prefix}buffer_full", + f"{self.drop_reason_prefix}send_failed", + ] + ) + logger.info("Telemetry emitter started, endpoint: %s", self.endpoint) async def shutdown(self) -> None: diff --git a/src/telemetry/events/__init__.py b/src/telemetry/events/__init__.py index 000e70b4..f8950d0f 100644 --- a/src/telemetry/events/__init__.py +++ b/src/telemetry/events/__init__.py @@ -138,9 +138,74 @@ __all__ = [ # Lifecycle "initialize_telemetry_events", "shutdown_telemetry_events", + # Zero-init registry + "ALL_EVENT_TYPES", + "HIGH_VOLUME_EVENT_TYPES", ] +# Explicit registry of CloudEvents `type` values, used to zero-initialize the +# telemetry_events_emitted / telemetry_events_sampled_out counter children. +# region ai +# See metrics.py:initialize_bounded_metrics for why absent and zero are worth +# distinguishing. Explicit literal, not a set derived from BaseEvent subclasses: a +# derived set would follow whatever happens to be imported at init time, so a type +# could drop out of the registry with no code change. A hand-maintained list plus a +# drift-guard test fails loud at the right moment instead. +# +# When you add a BaseEvent subclass, add its `_event_type` here (and to +# HIGH_VOLUME_EVENT_TYPES if `_volume_class == "high_volume"`). The drift-guard test +# tests/telemetry/test_metric_zero_init.py fails until you do — it asserts this +# registry equals the set discovered by walking BaseEvent subclasses. +# endregion +ALL_EVENT_TYPES: tuple[str, ...] = ( + # api + "message.created", + "file.uploaded", + "context.retrieved", + # agent + "agent.iteration", + "agent.tool.conclusions.created", + "agent.tool.conclusions.deleted", + "agent.tool.peer_card.updated", + "agent.tool.summary.created", + "agent.tool.call.completed", + # deletion / dialectic / dream / representation + "deletion.completed", + "dialectic.completed", + "dream.run", + "dream.specialist", + "representation.completed", + # llm / embedding + "llm.call.completed", + "embedding.call.completed", + # reconciliation + "reconciliation.sync_vectors.completed", + "reconciliation.cleanup_stale_items.completed", + # trace stream + # region ai + # Only emitted when TELEMETRY.TRACE_PAYLOADS_ENABLED, but they flow through the + # same emit() path and increment the same counters, so they belong in the set. + # endregion + "llm.call.traced", + "embedding.call.traced", + "trace.content", +) + +# Subset of ALL_EVENT_TYPES whose `_volume_class == "high_volume"`. +# region ai +# Only these can ever be counted by ``telemetry_events_sampled_out``; ground_truth +# events skip the sampler entirely (pre-creating their sampled_out series would be a +# permanently-misleading 0). +# endregion +HIGH_VOLUME_EVENT_TYPES: tuple[str, ...] = ( + "agent.iteration", + "agent.tool.call.completed", + "llm.call.completed", + "embedding.call.completed", +) + + def emit(event: BaseEvent) -> None: """Queue an event for emission to the telemetry backend. diff --git a/src/telemetry/prometheus/metrics.py b/src/telemetry/prometheus/metrics.py index 01d7be4f..749591d9 100644 --- a/src/telemetry/prometheus/metrics.py +++ b/src/telemetry/prometheus/metrics.py @@ -20,7 +20,8 @@ from prometheus_client.core import GaugeMetricFamily from starlette.requests import Request from starlette.responses import Response -from src.config import settings +from src.config import REASONING_LEVELS, settings +from src.utils.types import walk_subclasses disable_created_metrics() @@ -66,6 +67,32 @@ class DialecticComponents(Enum): TOTAL = "total" +# Valid (token_type, component) pairs for deriver_tokens_processed, per task_type, +# used to zero-initialize counter children (see initialize_bounded_metrics). +# region ai +# NOT the cartesian product: input tokens only pair with input components, output +# only with OUTPUT_TOTAL, and PREVIOUS_SUMMARY occurs only for summary tasks +# (ingestion has no previous summary). Enumerating anything broader would fabricate +# impossible always-0 series (e.g. output/prompt, or ingestion/previous_summary). +# Explicit literal, drift-guarded by tests/telemetry/test_metric_zero_init.py. +# Sources: track_deriver_input_tokens (src/utils/tokens.py) + the OUTPUT_TOTAL sites +# in src/deriver/deriver.py and src/utils/summarizer.py. +# endregion +_DERIVER_TOKEN_COMBOS_BY_TASK: dict[str, tuple[tuple[str, str], ...]] = { + DeriverTaskTypes.INGESTION.value: ( + (TokenTypes.INPUT.value, DeriverComponents.PROMPT.value), + (TokenTypes.INPUT.value, DeriverComponents.MESSAGES.value), + (TokenTypes.OUTPUT.value, DeriverComponents.OUTPUT_TOTAL.value), + ), + DeriverTaskTypes.SUMMARY.value: ( + (TokenTypes.INPUT.value, DeriverComponents.PROMPT.value), + (TokenTypes.INPUT.value, DeriverComponents.MESSAGES.value), + (TokenTypes.INPUT.value, DeriverComponents.PREVIOUS_SUMMARY.value), + (TokenTypes.OUTPUT.value, DeriverComponents.OUTPUT_TOTAL.value), + ), +} + + api_requests_counter = NamespacedCounter( "api_requests", "Total API requests", @@ -155,6 +182,23 @@ telemetry_buffer_size_gauge = NamespacedGauge( ["namespace"], ) +# Embedding backlog: MessageEmbedding rows still awaiting a vector +# (sync_state='pending'). +# region ai +# Distinct from embed_now_tasks_in_flight (in-flight fast-path work in the API +# process) — this is the durable, DB-wide backlog the reconciler drains. Every +# deriver replica refreshes it on its own timer from +# ReconcilerScheduler._scheduler_loop, so replicas disagree by at most one interval. +# Service-wide, not per-process — hence the help string's "never sum()". +# endregion +message_embeddings_pending_gauge = NamespacedGauge( + "message_embeddings_pending", + "MessageEmbedding rows awaiting embedding (sync_state='pending'). " + + "Service-wide DB count, reported independently by every replica — " + + "aggregate with max() or avg(), never sum()", + ["namespace"], +) + # 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). @@ -325,12 +369,159 @@ class PrometheusMetrics: except Exception as e: self._handle_metric_error("record_telemetry_event_dropped", e) + def _touch(self, counter: NamespacedCounter, **labels: str) -> None: + """Pre-create a counter child series at 0 without incrementing it.""" + # region ai + # A labeled Prometheus counter exports no time series until its first + # ``labels(...)`` call, so pre-touching a child keeps it present at 0 — a + # missing series then signals a broken scrape rather than "no events". + # Fail-soft (like the recorders): a bad init must never crash startup. + # endregion + try: + counter.labels(**labels) + except Exception as e: + self._handle_metric_error("_touch", e) + + def initialize_telemetry_dropped_metrics(self, *, reasons: list[str]) -> None: + """Pre-create telemetry_events_dropped ``(namespace, reason)`` children at 0. + + Args: + reasons: The reason label values the calling emitter can produce. + """ + # region ai + # The metric stays invisible in Prometheus/Grafana until an event is actually + # dropped, so materializing the children at startup keeps it present at 0 — a + # missing series then means a broken scrape, not "no drops" (see _touch). + # + # Called per-emitter from ``TelemetryEmitter.start()`` rather than hoisted into + # the process-level ``initialize_bounded_metrics``: the trace emitter (whose + # reasons carry a ``trace_`` prefix) only exists when ``TRACE_PAYLOADS_ENABLED`` + # is set, so hoisting would fabricate ``trace_*`` series on deployments that run + # with tracing off. + # endregion + if not settings.METRICS.ENABLED: + return + + for reason in reasons: + self._touch(telemetry_events_dropped_counter, reason=reason) + + def initialize_bounded_metrics(self, *, instance_type: str) -> None: + """Pre-create bounded-label counter children at 0 for this process, so an + absent series means a broken scrape rather than "nothing happened". + + Args: + instance_type: "api" or "deriver" — selects the process-specific + counters. Event-type and buffer metrics are initialized in both. + """ + # region ai + # A Prometheus counter does not exist until its first increment, so a + # never-yet-incremented metric is indistinguishable from a broken scrape: + # you cannot graph or alert on a series that is absent. Materializing the + # children at 0 inverts that — a missing series now means something is wrong, + # and "no events" reads as a flat 0 instead of a gap. + # + # That only holds for label sets we can enumerate honestly, so a metric is + # initialized here only when its full label domain is bounded, enumerable at + # startup, and actually emitted by THIS process. High-cardinality labels + # (endpoint, workspace_name) and impossible label tuples are deliberately left + # absent — fabricating a permanently-0 series that no code path can ever + # increment is the same lie in the other direction. + # + # Multi-instance safety splits the metrics here into three buckets: + # + # 1. instance-scoped (``telemetry_buffer_size``, ``embed_now_tasks_in_flight``) + # — per-process by nature, so any aggregation is meaningful and zero-init is + # unambiguously right. + # 2. service-scoped additive (the token counters, ``telemetry_events_emitted``) + # — each instance holds a partial count and ``sum()`` reconstructs the whole, + # so multi-instance safe. + # 3. service-scoped non-additive — every instance reports the whole service's + # value, so the instances are N witnesses to one fact rather than N parts of + # one whole. ``sum()`` is therefore never correct here: it scales with the + # replica count. Scale-preserving aggregations (``max()``, ``avg()``, + # quantiles) ARE correct, but only while the witnesses disagree by a bounded + # amount — which requires every instance to refresh on its own timer (see + # ``message_embeddings_pending``, refreshed per replica from + # ``ReconcilerScheduler._scheduler_loop``). A bucket-3 metric that cannot + # meet that bar does not belong in the app at all — it belongs in an exporter + # that yields exactly one series. + # + # Prometheus stamps ``instance``/``job`` at scrape time, which is why buckets 1 + # and 2 need no special handling. ``telemetry_events_dropped`` is handled + # separately, per-emitter, in ``TelemetryEmitter.start()`` (prefix-dependent). + # endregion + if not settings.METRICS.ENABLED: + return + + # ai: lazy import avoids an import-time cycle (metrics is imported widely) + from src.telemetry.events import ALL_EVENT_TYPES, HIGH_VOLUME_EVENT_TYPES + + # region ai + # Common: both processes run a TelemetryEmitter, so both emit their own subset + # of event types. The domain is bounded/low-cardinality (~21 types), so init + # the full set in each process rather than maintain a fragile + # per-event-type -> process map. + # endregion + for event_type in ALL_EVENT_TYPES: + self._touch(telemetry_events_emitted_counter, type=event_type) + for event_type in HIGH_VOLUME_EVENT_TYPES: + self._touch(telemetry_events_sampled_out_counter, type=event_type) + self.set_telemetry_buffer_size(size=0) + + if instance_type == "api": + # dialectic tokens: token_type x component(total) x reasoning_level + for token_type in TokenTypes: + for level in REASONING_LEVELS: + self._touch( + dialectic_tokens_processed_counter, + token_type=token_type.value, + component=DialecticComponents.TOTAL.value, + reasoning_level=level, + ) + # ai: embed_now fast path runs as an API-process background task + self._touch(embed_now_tasks_shed_counter) + self.set_embed_now_tasks_in_flight(0) + + elif instance_type == "deriver": + # deriver tokens: only the valid (token_type, component) tuples per + # task_type (see _DERIVER_TOKEN_COMBOS_BY_TASK). + for task_type_value, combos in _DERIVER_TOKEN_COMBOS_BY_TASK.items(): + for token_type_value, component_value in combos: + self._touch( + deriver_tokens_processed_counter, + task_type=task_type_value, + token_type=token_type_value, + component=component_value, + ) + # dreamer tokens: specialist_name x token_type. + # region ai + # Names come from the concrete BaseSpecialist subclasses (walked recursively + # via walk_subclasses) so a new specialist can't silently miss init. + # endregion + from src.dreamer.specialists import BaseSpecialist + + for specialist in walk_subclasses(BaseSpecialist): + for token_type in TokenTypes: + self._touch( + dreamer_tokens_processed_counter, + specialist_name=specialist.name, + token_type=token_type.value, + ) + # ai: init at 0 so the gauge is visible before its first per-replica refresh + self.set_message_embeddings_pending(count=0) + def set_telemetry_buffer_size(self, *, size: int) -> None: try: telemetry_buffer_size_gauge.labels().set(size) except Exception as e: self._handle_metric_error("set_telemetry_buffer_size", e) + def set_message_embeddings_pending(self, *, count: int) -> None: + try: + message_embeddings_pending_gauge.labels().set(count) + except Exception as e: + self._handle_metric_error("set_message_embeddings_pending", e) + prometheus_metrics = PrometheusMetrics() diff --git a/src/utils/types.py b/src/utils/types.py index 24a13ae6..0bfada75 100644 --- a/src/utils/types.py +++ b/src/utils/types.py @@ -1,4 +1,4 @@ -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable, Generator, Iterator from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass, field @@ -6,6 +6,20 @@ from typing import Any, Generic, Literal, TypeVar T = TypeVar("T") + +def walk_subclasses(cls: type[T]) -> Iterator[type[T]]: + """Yield every subclass of ``cls``, recursively.""" + # region ai + # ``type.__subclasses__()`` is direct-children-only, so a grandchild class is + # silently invisible to it. Any registry that enumerates subclasses to decide + # what to initialize or validate wants the transitive closure — otherwise + # subclassing a concrete class is enough to slip past the check. + # endregion + for subclass in cls.__subclasses__(): + yield subclass + yield from walk_subclasses(subclass) + + # Context variable for tracking current iteration in tool execution loop # This is used for telemetry to associate tool calls with their iteration _current_iteration: ContextVar[int] = ContextVar("current_iteration", default=0) diff --git a/tests/bench/runner_common.py b/tests/bench/runner_common.py index be262cb0..b150a3f8 100644 --- a/tests/bench/runner_common.py +++ b/tests/bench/runner_common.py @@ -17,21 +17,18 @@ from dataclasses import dataclass, field from datetime import datetime from logging import Logger from pathlib import Path -from typing import Any, Generic, Literal, TypeVar, cast +from typing import Any, Generic, TypeVar, cast from anthropic import AsyncAnthropic from honcho import Honcho from honcho.api_types import SessionConfiguration, SummaryConfiguration from openai import AsyncOpenAI +from src.config import REASONING_LEVELS, ReasoningLevel from src.telemetry.metrics_collector import MetricsCollector _logger = logging.getLogger(__name__) -# Valid reasoning levels for dialectic chat -ReasoningLevel = Literal["minimal", "low", "medium", "high", "max"] -REASONING_LEVELS: list[str] = ["minimal", "low", "medium", "high", "max"] - # Type variable for result types ResultT = TypeVar("ResultT") diff --git a/tests/reconciler/test_pending_backlog_gauge.py b/tests/reconciler/test_pending_backlog_gauge.py new file mode 100644 index 00000000..226a71e8 --- /dev/null +++ b/tests/reconciler/test_pending_backlog_gauge.py @@ -0,0 +1,86 @@ +"""The pending-embeddings backlog gauge must be refreshed per-replica. + +These tests pin both halves: the scheduler loop drives the refresh, and the +queue-driven reconciliation cycle does not. +""" +# region ai +# ``message_embeddings_pending`` reports a DB-global count, so it is the one gauge +# here whose value is service-wide rather than per-process. It is also zero- +# initialized at startup, which makes a missing refresh actively harmful: a replica +# that never measured the backlog would export a confident, permanently-healthy 0. +# So the count is driven from ``ReconcilerScheduler._scheduler_loop`` (runs on every +# replica, every interval), NOT from ``run_vector_reconciliation_cycle`` (runs off +# the queue behind work-unit dedup, so exactly one replica per cycle executes it). +# endregion + +import asyncio + +import pytest + +from src.reconciler import scheduler as scheduler_module +from src.reconciler import sync_vectors +from src.reconciler.scheduler import ReconcilerScheduler + + +@pytest.fixture(autouse=True) +def _reset_scheduler_singleton(): # pyright: ignore[reportUnusedFunction] + ReconcilerScheduler.reset_singleton() + yield + ReconcilerScheduler.reset_singleton() + + +async def test_scheduler_loop_refreshes_backlog_gauge( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Every scheduler iteration refreshes the gauge, on every replica. + + Patched at the scheduler's own reference so this asserts the call site, not + just that the function exists. + """ + calls = 0 + refreshed = asyncio.Event() + + async def _fake_refresh() -> None: + nonlocal calls + calls += 1 + refreshed.set() + + # region ai + # Patched onto the class, so it is invoked as a bound method — it needs the + # ``self`` parameter or the call raises TypeError, which ``_scheduler_loop`` would + # then swallow, leaving this guard silently inert. + # endregion + async def _never_enqueue(_self: object, _task: object) -> bool: + return False + + monkeypatch.setattr( + scheduler_module, "record_pending_embeddings_backlog", _fake_refresh + ) + monkeypatch.setattr( + ReconcilerScheduler, "_try_enqueue_task", _never_enqueue, raising=True + ) + + scheduler = ReconcilerScheduler() + await scheduler.start() + try: + await asyncio.wait_for(refreshed.wait(), timeout=5.0) + finally: + await scheduler.shutdown() + + assert calls >= 1, "scheduler loop never refreshed the backlog gauge" + + +def test_reconciliation_cycle_does_not_drive_the_gauge() -> None: + """The queue-driven cycle must not be the thing that sets the gauge.""" + # region ai + # If the refresh moves back into ``run_vector_reconciliation_cycle``, only the + # replica that wins the ``sync_vectors`` work unit would ever measure the backlog, + # and the zero-init would go back to lying on all the others. + # + # Structural guard: the cycle is a long DB-driven coroutine, so this inspects the + # global names it references rather than executing it. + # endregion + assert hasattr(sync_vectors, "record_pending_embeddings_backlog") + + referenced = sync_vectors.run_vector_reconciliation_cycle.__code__.co_names + assert "record_pending_embeddings_backlog" not in referenced diff --git a/tests/telemetry/test_metric_zero_init.py b/tests/telemetry/test_metric_zero_init.py new file mode 100644 index 00000000..e69f50df --- /dev/null +++ b/tests/telemetry/test_metric_zero_init.py @@ -0,0 +1,357 @@ +"""Tests for startup zero-initialization of bounded-label metrics. + +Asserts that: +- bounded-label counter children are materialized at 0 before any event, +- high-cardinality / impossible label combinations are deliberately NOT, +- per-process init doesn't materialize the other process's counters, +- the explicit registries stay in sync with the source of truth (drift guards). +""" +# region ai +# Reads use ``REGISTRY.get_sample_value`` (returns the value if a series exists, +# ``None`` if it does not) rather than ``counter.labels(...)``, because ``.labels`` +# would itself materialize the child and destroy the presence/absence signal. +# endregion + +from collections.abc import Iterator +from typing import cast +from uuid import uuid4 + +import pytest +from prometheus_client import REGISTRY + +from src.config import REASONING_LEVELS, settings +from src.dreamer.specialists import BaseSpecialist +from src.telemetry.events import ALL_EVENT_TYPES, HIGH_VOLUME_EVENT_TYPES +from src.telemetry.events.base import BaseEvent +from src.telemetry.prometheus.metrics import ( + _DERIVER_TOKEN_COMBOS_BY_TASK, # pyright: ignore[reportPrivateUsage] + DeriverComponents, + DeriverTaskTypes, + DialecticComponents, + TokenTypes, + prometheus_metrics, +) +from src.utils.types import walk_subclasses + + +def unique_ns(tag: str) -> str: + """A ``namespace`` label value no other test can have materialized under.""" + # region ai + # Every assertion here reads the process-global ``REGISTRY``, which keeps a child + # series for the rest of the session once anything materializes it. A shared + # namespace (several other suites pin ``"test"``) would let another test's children + # satisfy a presence assertion, or break an absence assertion, independently of + # what the initializer under test actually did. + # endregion + return f"test_metric_zero_init_{tag}_{uuid4().hex[:8]}" + + +@pytest.fixture +def metrics_enabled(monkeypatch: pytest.MonkeyPatch) -> Iterator[str]: + """Enable metrics under a namespace unique to the requesting test.""" + ns = unique_ns("enabled") + monkeypatch.setattr("src.config.settings.METRICS.ENABLED", True) + monkeypatch.setattr("src.config.settings.METRICS.NAMESPACE", ns) + yield ns + + +def sample(name: str, **labels: str) -> float | None: + """Value of a series if it exists, else None. Never materializes it. + + Resolves the namespace from settings, so it always reads the unique one the + active test pinned. + """ + ns = cast(str, settings.METRICS.NAMESPACE) + return REGISTRY.get_sample_value(name, {"namespace": ns, **labels}) + + +# --------------------------------------------------------------------------- +# Drift guards (pure logic — no registry). Adding an event type / token component +# without updating the registry fails here, with a pointer to what to fix. +# --------------------------------------------------------------------------- + + +def test_all_event_types_registry_matches_subclasses(): + """ALL_EVENT_TYPES must equal every BaseEvent subclass's _event_type. + + If this fails you added/removed a BaseEvent subclass without updating + ALL_EVENT_TYPES in src/telemetry/events/__init__.py — its Prometheus counter + would not be zero-initialized. Update the registry. + """ + discovered = { + event_type + for cls in walk_subclasses(BaseEvent) + if (event_type := getattr(cls, "_event_type", None)) is not None + } + assert set(ALL_EVENT_TYPES) == discovered + assert len(ALL_EVENT_TYPES) == len(set(ALL_EVENT_TYPES)), "duplicate event types" + + +def test_high_volume_registry_matches_subclasses(): + """HIGH_VOLUME_EVENT_TYPES must equal the high_volume-classed subclasses.""" + discovered = { + event_type + for cls in walk_subclasses(BaseEvent) + if (event_type := getattr(cls, "_event_type", None)) is not None + and getattr(cls, "_volume_class", None) == "high_volume" + } + assert set(HIGH_VOLUME_EVENT_TYPES) == discovered + assert set(HIGH_VOLUME_EVENT_TYPES) <= set(ALL_EVENT_TYPES) + + +def test_deriver_token_combos_are_valid_and_complete(): + """Every combo uses real enum values; the union across tasks covers every + DeriverComponent; and no task enumerates an impossible pair. + + Fails if a DeriverComponent/DeriverTaskType is added without deciding which + task_type + token_type it pairs with in _DERIVER_TOKEN_COMBOS_BY_TASK. + """ + valid_token_types = {t.value for t in TokenTypes} + valid_components = {c.value for c in DeriverComponents} + valid_task_types = {t.value for t in DeriverTaskTypes} + + assert set(_DERIVER_TOKEN_COMBOS_BY_TASK) == valid_task_types + all_components: set[str] = set() + for task_type, combos in _DERIVER_TOKEN_COMBOS_BY_TASK.items(): + assert task_type in valid_task_types + for token_type, component in combos: + assert token_type in valid_token_types + assert component in valid_components + # each task enumerates fewer than its cartesian product (no impossible pairs) + assert len(combos) < len(valid_token_types) * len(valid_components) + all_components.update(comp for _, comp in combos) + + # every component is reachable via some task + assert all_components == valid_components + # previous_summary is summary-only: ingestion must NOT enumerate it + ingestion = _DERIVER_TOKEN_COMBOS_BY_TASK[DeriverTaskTypes.INGESTION.value] + assert ( + TokenTypes.INPUT.value, + DeriverComponents.PREVIOUS_SUMMARY.value, + ) not in ingestion + + +# --------------------------------------------------------------------------- +# API-process zero-init +# --------------------------------------------------------------------------- + + +@pytest.mark.usefixtures("metrics_enabled") +def test_api_init_materializes_event_type_children(): + prometheus_metrics.initialize_bounded_metrics(instance_type="api") + for event_type in ALL_EVENT_TYPES: + assert sample("telemetry_events_emitted_total", type=event_type) is not None + for event_type in HIGH_VOLUME_EVENT_TYPES: + assert sample("telemetry_events_sampled_out_total", type=event_type) is not None + + +@pytest.mark.usefixtures("metrics_enabled") +def test_api_init_materializes_dialectic_and_embed(): + prometheus_metrics.initialize_bounded_metrics(instance_type="api") + for token_type in TokenTypes: + for level in REASONING_LEVELS: + assert ( + sample( + "dialectic_tokens_processed_total", + token_type=token_type.value, + component=DialecticComponents.TOTAL.value, + reasoning_level=level, + ) + is not None + ) + assert sample("embed_now_tasks_shed_total") is not None + assert sample("embed_now_tasks_in_flight") == 0.0 # gauge, explicit .set(0) + + +@pytest.mark.usefixtures("metrics_enabled") +def test_sampled_out_excludes_ground_truth_event_types(): + """Ground-truth events can never be sampled out, so their sampled_out series + must NOT be pre-created (they'd be permanently misleading zeros).""" + prometheus_metrics.initialize_bounded_metrics(instance_type="api") + ground_truth = set(ALL_EVENT_TYPES) - set(HIGH_VOLUME_EVENT_TYPES) + for event_type in ground_truth: + assert sample("telemetry_events_sampled_out_total", type=event_type) is None + + +# --------------------------------------------------------------------------- +# Deriver-process zero-init +# --------------------------------------------------------------------------- + + +@pytest.mark.usefixtures("metrics_enabled") +def test_deriver_init_materializes_token_and_backlog(): + prometheus_metrics.initialize_bounded_metrics(instance_type="deriver") + for task_type, combos in _DERIVER_TOKEN_COMBOS_BY_TASK.items(): + for token_type, component in combos: + assert ( + sample( + "deriver_tokens_processed_total", + task_type=task_type, + token_type=token_type, + component=component, + ) + is not None + ) + # region ai + # Specialist names are derived from the concrete BaseSpecialist subclasses here + # too, rather than hardcoded: a hardcoded list would keep passing when a new + # specialist is added (it only asserts presence), silently leaving it uncovered. + # endregion + specialist_names = { + name + for cls in walk_subclasses(BaseSpecialist) + if (name := getattr(cls, "name", None)) is not None + } + assert {"deduction", "induction", "card_refresh"} <= specialist_names + for specialist_name in specialist_names: + assert ( + sample( + "dreamer_tokens_processed_total", + specialist_name=specialist_name, + token_type=TokenTypes.INPUT.value, + ) + is not None + ), f"specialist {specialist_name!r} was not zero-initialized" + assert sample("message_embeddings_pending") == 0.0 # gauge zero-init + + +@pytest.mark.usefixtures("metrics_enabled") +def test_deriver_init_omits_impossible_token_combos(): + """The cartesian product includes combos that never occur (e.g. output tokens + with an input component). Those must not be materialized.""" + prometheus_metrics.initialize_bounded_metrics(instance_type="deriver") + # output tokens never pair with an input component + assert ( + sample( + "deriver_tokens_processed_total", + task_type=DeriverTaskTypes.INGESTION.value, + token_type=TokenTypes.OUTPUT.value, + component=DeriverComponents.PROMPT.value, + ) + is None + ) + # previous_summary is summary-only — ingestion must not materialize it + assert ( + sample( + "deriver_tokens_processed_total", + task_type=DeriverTaskTypes.INGESTION.value, + token_type=TokenTypes.INPUT.value, + component=DeriverComponents.PREVIOUS_SUMMARY.value, + ) + is None + ) + # base specialist is abstract and never emits — must not be materialized + assert ( + sample( + "dreamer_tokens_processed_total", + specialist_name="base", + token_type=TokenTypes.INPUT.value, + ) + is None + ) + + +# --------------------------------------------------------------------------- +# High-cardinality counters are left open, and per-process isolation holds +# --------------------------------------------------------------------------- + + +@pytest.mark.usefixtures("metrics_enabled") +def test_high_cardinality_counters_not_materialized(): + prometheus_metrics.initialize_bounded_metrics(instance_type="api") + prometheus_metrics.initialize_bounded_metrics(instance_type="deriver") + # no endpoint/workspace_name series fabricated + assert ( + sample( + "api_requests_total", + method="GET", + endpoint="/v3/does-not-exist", + status_code="200", + ) + is None + ) + assert sample("messages_created_total", workspace_name="nope_ws") is None + + +@pytest.mark.usefixtures("metrics_enabled") +def test_api_init_does_not_touch_deriver_counters(): + """api-only init must not materialize or change a deriver-only counter. + + Delta-based (before == after) so it's robust to prior tests having + materialized the series. + """ + labels = dict( + task_type=DeriverTaskTypes.INGESTION.value, + token_type=TokenTypes.INPUT.value, + component=DeriverComponents.PROMPT.value, + ) + before = sample("deriver_tokens_processed_total", **labels) + prometheus_metrics.initialize_bounded_metrics(instance_type="api") + after = sample("deriver_tokens_processed_total", **labels) + assert before == after + + +@pytest.mark.usefixtures("metrics_enabled") +def test_deriver_init_does_not_touch_api_counters(): + """The inverse: deriver-only init must not materialize an API-only counter. + + Without this, a deriver-startup regression could silently fabricate API + series (permanently-0 dialectic tokens on a process that never serves chat). + """ + labels = dict( + token_type=TokenTypes.INPUT.value, + component=DialecticComponents.TOTAL.value, + reasoning_level=REASONING_LEVELS[0], + ) + before = sample("dialectic_tokens_processed_total", **labels) + prometheus_metrics.initialize_bounded_metrics(instance_type="deriver") + after = sample("dialectic_tokens_processed_total", **labels) + assert before == after + # the API-process embed_now counters are equally off-limits + assert sample("embed_now_tasks_shed_total") is None + assert sample("embed_now_tasks_in_flight") is None + + +# --------------------------------------------------------------------------- +# telemetry_events_dropped: per-emitter child materialization +# --------------------------------------------------------------------------- + + +@pytest.mark.usefixtures("metrics_enabled") +def test_dropped_counter_children_materialized(): + prometheus_metrics.initialize_telemetry_dropped_metrics( + reasons=["buffer_full", "send_failed"] + ) + assert sample("telemetry_events_dropped_total", reason="buffer_full") is not None + assert sample("telemetry_events_dropped_total", reason="send_failed") is not None + + +def test_dropped_counter_init_noop_when_metrics_disabled( + monkeypatch: pytest.MonkeyPatch, +): + """The per-emitter initializer must no-op when metrics are disabled.""" + # region ai + # The enabled/disabled pair above and below this line exists for + # ``initialize_bounded_metrics`` (see ``test_init_noop_when_metrics_disabled``); + # without this test the sibling initializer had only the enabled half, so its + # ``METRICS.ENABLED`` guard could be deleted with the suite staying green. The + # unique namespace is what makes the absence assertion mean anything — the enabled + # test above materializes these same two reason values under a different one. + # endregion + monkeypatch.setattr("src.config.settings.METRICS.ENABLED", False) + monkeypatch.setattr( + "src.config.settings.METRICS.NAMESPACE", unique_ns("dropped_disabled") + ) + prometheus_metrics.initialize_telemetry_dropped_metrics( + reasons=["buffer_full", "send_failed"] + ) + assert sample("telemetry_events_dropped_total", reason="buffer_full") is None + assert sample("telemetry_events_dropped_total", reason="send_failed") is None + + +def test_init_noop_when_metrics_disabled(monkeypatch: pytest.MonkeyPatch): + """With metrics disabled, init must not fabricate series for a fresh label.""" + monkeypatch.setattr("src.config.settings.METRICS.ENABLED", False) + monkeypatch.setattr("src.config.settings.METRICS.NAMESPACE", unique_ns("disabled")) + prometheus_metrics.initialize_bounded_metrics(instance_type="api") + assert sample("telemetry_events_emitted_total", type="message.created") is None From 67f4dbf23ffba5bf8f7f5197ff8eba11f75993da Mon Sep 17 00:00:00 2001 From: Daniel Peng <97350516+original4422@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:12:47 +0800 Subject: [PATCH 03/50] fix(llm): preserve reasoning content across tool turns (#1034) --- src/llm/history_adapters.py | 2 + src/llm/tool_loop.py | 3 + tests/llm/test_history_adapters.py | 48 ++++++++ tests/llm/test_tool_loop_reasoning_content.py | 104 ++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 tests/llm/test_tool_loop_reasoning_content.py diff --git a/src/llm/history_adapters.py b/src/llm/history_adapters.py index 02d2ea05..8e3b837e 100644 --- a/src/llm/history_adapters.py +++ b/src/llm/history_adapters.py @@ -121,6 +121,8 @@ class OpenAIHistoryAdapter: } if result.reasoning_details: message["reasoning_details"] = result.reasoning_details + elif result.thinking_content: + message["reasoning_content"] = result.thinking_content return message def format_tool_results( diff --git a/src/llm/tool_loop.py b/src/llm/tool_loop.py index 0feec1d8..783d4965 100644 --- a/src/llm/tool_loop.py +++ b/src/llm/tool_loop.py @@ -212,6 +212,7 @@ def format_assistant_tool_message( tool_calls: list[dict[str, Any]], thinking_blocks: list[dict[str, Any]] | None = None, reasoning_details: list[dict[str, Any]] | None = None, + thinking_content: str | None = None, ) -> dict[str, Any]: """Format an assistant message with tool calls in provider-native shape.""" from .backend import CompletionResult as BackendCompletionResult @@ -229,6 +230,7 @@ def format_assistant_tool_message( ) for tool_call in tool_calls ], + thinking_content=thinking_content, thinking_blocks=thinking_blocks or [], reasoning_details=reasoning_details or [], ) @@ -573,6 +575,7 @@ async def execute_tool_loop( response.tool_calls_made, response.thinking_blocks, response.reasoning_details, + response.thinking_content, ) conversation_messages.append(assistant_message) diff --git a/tests/llm/test_history_adapters.py b/tests/llm/test_history_adapters.py index 6881df6a..a992398d 100644 --- a/tests/llm/test_history_adapters.py +++ b/tests/llm/test_history_adapters.py @@ -1,3 +1,5 @@ +import pytest + from src.llm.backend import CompletionResult, ToolCallResult from src.llm.history_adapters import ( AnthropicHistoryAdapter, @@ -65,3 +67,49 @@ def test_openai_history_adapter_preserves_reasoning_details() -> None: assert message["role"] == "assistant" assert message["reasoning_details"] == [{"type": "reasoning", "content": "step 1"}] assert message["tool_calls"][0]["function"]["name"] == "search" + + +def test_openai_history_adapter_preserves_thinking_content() -> None: + adapter = OpenAIHistoryAdapter() + result = CompletionResult( + content="Calling a tool", + thinking_content="step 1", + tool_calls=[ + ToolCallResult(id="tool_1", name="search", input={"query": "honcho"}) + ], + ) + + message = adapter.format_assistant_tool_message(result) + + assert message["reasoning_content"] == "step 1" + assert "reasoning_details" not in message + + +def test_openai_history_adapter_prefers_reasoning_details() -> None: + adapter = OpenAIHistoryAdapter() + reasoning_details = [{"type": "reasoning", "content": "step 1"}] + result = CompletionResult( + content="Calling a tool", + thinking_content="duplicate step 1", + reasoning_details=reasoning_details, + ) + + message = adapter.format_assistant_tool_message(result) + + assert message["reasoning_details"] == reasoning_details + assert "reasoning_content" not in message + + +@pytest.mark.parametrize("thinking_content", [None, ""]) +def test_openai_history_adapter_omits_empty_thinking_content( + thinking_content: str | None, +) -> None: + adapter = OpenAIHistoryAdapter() + result = CompletionResult( + content="Calling a tool", + thinking_content=thinking_content, + ) + + message = adapter.format_assistant_tool_message(result) + + assert "reasoning_content" not in message diff --git a/tests/llm/test_tool_loop_reasoning_content.py b/tests/llm/test_tool_loop_reasoning_content.py new file mode 100644 index 00000000..cd4ec91b --- /dev/null +++ b/tests/llm/test_tool_loop_reasoning_content.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any, cast +from unittest.mock import patch + +import pytest + +from src.config import ModelConfig +from src.llm import tool_loop +from src.llm.runtime import AttemptPlan +from src.llm.tool_loop import execute_tool_loop +from src.llm.types import HonchoLLMCallResponse, ProviderClient + + +def _make_plan() -> AttemptPlan: + return AttemptPlan( + provider="openai", + model="deepseek-v4-pro", + client=cast(ProviderClient, object()), + thinking_budget_tokens=None, + reasoning_effort=None, + selected_config=ModelConfig( + model="deepseek-v4-pro", + transport="openai", + ), + attempt=1, + retry_attempts=1, + is_fallback=False, + ) + + +@pytest.mark.asyncio +async def test_tool_loop_replays_reasoning_content_on_continuation() -> None: + calls: list[list[dict[str, Any]]] = [] + responses = iter( + [ + HonchoLLMCallResponse( + content="", + output_tokens=5, + finish_reasons=["tool_calls"], + tool_calls_made=[ + { + "id": "call_1", + "name": "search", + "input": {"query": "honcho"}, + } + ], + thinking_content="DeepSeek reasoning", + ), + HonchoLLMCallResponse( + content="done", + output_tokens=3, + finish_reasons=["stop"], + tool_calls_made=[], + ), + ] + ) + + async def fake_call(*_args: Any, **kwargs: Any) -> HonchoLLMCallResponse[Any]: + calls.append(deepcopy(kwargs["messages"])) + return next(responses) + + async def execute_search(_name: str, _input: dict[str, Any]) -> str: + return "result" + + with patch.object(tool_loop, "honcho_llm_call_inner", new=fake_call): + result = await execute_tool_loop( + prompt="hi", + max_tokens=64, + messages=[{"role": "user", "content": "hi"}], + tools=[ + { + "name": "search", + "description": "Search", + "input_schema": {"type": "object"}, + } + ], + tool_choice="auto", + tool_executor=execute_search, + max_tool_iterations=5, + response_model=None, + json_mode=False, + temperature=None, + stop_seqs=None, + verbosity=None, + enable_retry=False, + retry_attempts=1, + max_input_tokens=None, + get_attempt_plan=_make_plan, + before_retry_callback=lambda _retry_state: None, + stream_final=False, + telemetry=None, + ) + + assert isinstance(result, HonchoLLMCallResponse) + assert len(calls) == 2 + assert calls[1][1]["reasoning_content"] == "DeepSeek reasoning" + assert calls[1][1]["tool_calls"][0]["function"]["name"] == "search" + assert calls[1][2] == { + "role": "tool", + "tool_call_id": "call_1", + "content": "result", + } From ddbb90e36f2d148c7982f6ed85b09d31cabf5944 Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Thu, 20 Aug 2026 11:42:38 -0400 Subject: [PATCH 04/50] fix(embedding): truncate in batch embed and return results breakdown (#1019) * fix(deriver): truncate oversize observations so one cannot drop the batch simple_batch_embed raised ValueError when any input exceeded the per-input token cap, which failed the entire deriver save when a single observation was over-length. Add on_oversize="truncate": oversize inputs are embedded from a token-capped prefix (re-encoded until it fits, with a warning), preserving one vector per input. Default stays "raise" so existing callers are unchanged. RepresentationManager opts into truncate. Also add a live embedding test that fails on main (raise / missing kwarg) and passes once a mixed short+oversize batch survives. Refs #569 * fix(deriver): surface failure when all observer saves fail When every observer's save_representation failed (e.g. embedding retries exhausted under a sustained 429), the deriver logged the error and returned normally, so the queue marked the work unit processed with zero documents saved. Collect per-observer errors and, after telemetry is emitted, raise RepresentationSaveError when no observer succeeded. Partial failures stay processed (saved observers must not be discarded) and are recorded via an additive failed_observer_count on RepresentationCompletedEvent. Refs #728 * fix(embedding): guarantee truncation progress and truncate on re-embed The retry slice in _truncate_to_token_limit always recomputed the same keep count, so a slice whose re-encode grew past the cap could oscillate. Decrement keep after each unsuccessful retry. Document re-embed in the reconciler used the default on_oversize="raise", so one oversize document failed every other document in the batch. Co-Authored-By: Claude Opus 5 * chore: drop ticket ids and shrink comments to one sentence Comments and docstrings describe current behavior, not the PR that introduced them. Ticket numbers stay in the commit/PR. * chore: annotate RepresentationSaveError and assert truncate on re-embed * fix(embedding): truncate on conclusion create paths and document BPE loop Storage callers in create_observations (API + agent tools) now pass on_oversize="truncate" so a single oversize item cannot drop the batch. Docstring on _truncate_to_token_limit notes why decode/re-encode is load-bearing. --------- Co-authored-by: Claude Opus 5 --- src/crud/document.py | 4 +- src/crud/representation.py | 2 +- src/deriver/deriver.py | 20 ++- src/embedding_client.py | 72 ++++++++-- src/exceptions.py | 8 ++ src/reconciler/sync_vectors.py | 4 +- src/telemetry/events/representation.py | 4 + src/utils/agent_tools.py | 4 +- tests/conftest.py | 4 +- tests/crud/test_document.py | 40 ++++++ tests/crud/test_representation_manager.py | 63 ++++++++- tests/deriver/test_deriver_processing.py | 112 ++++++++++++++++ tests/deriver/test_vector_reconciliation.py | 5 +- tests/live_llm/README.md | 2 +- tests/live_llm/test_live_embeddings.py | 20 +++ tests/llm/test_embedding_client.py | 126 ++++++++++++++++++ .../test_representation_v2_fields.py | 2 + tests/utils/test_agent_tools.py | 66 ++++++++- 18 files changed, 530 insertions(+), 28 deletions(-) diff --git a/src/crud/document.py b/src/crud/document.py index ba80712d..0cec85c9 100644 --- a/src/crud/document.py +++ b/src/crud/document.py @@ -984,7 +984,9 @@ async def create_observations( # Generate embeddings in batch contents = [obs.content for obs in observations] try: - embeddings = await embedding_client.simple_batch_embed(contents) + embeddings = await embedding_client.simple_batch_embed( + contents, on_oversize="truncate" + ) except ValueError as e: raise ValidationException(str(e)) from e diff --git a/src/crud/representation.py b/src/crud/representation.py index 57e6e8e1..3b7070e8 100644 --- a/src/crud/representation.py +++ b/src/crud/representation.py @@ -107,7 +107,7 @@ class RepresentationManager: parent_category="representation", ): embeddings = await embedding_client.simple_batch_embed( - observation_texts + observation_texts, on_oversize="truncate" ) except ValueError as e: raise exceptions.ValidationException( diff --git a/src/deriver/deriver.py b/src/deriver/deriver.py index 2ad7d14f..f76c4d52 100644 --- a/src/deriver/deriver.py +++ b/src/deriver/deriver.py @@ -7,6 +7,7 @@ from src import crud from src.config import ConfiguredModelSettings, settings from src.crud.representation import RepresentationManager from src.dependencies import tracked_db +from src.exceptions import RepresentationSaveError from src.llm import honcho_llm_call from src.llm.types import LLMTelemetryContext from src.models import Message @@ -196,6 +197,7 @@ async def process_representation_tasks_batch( agg_representation_result = crud.CreateDocumentsResult() successful_observer_count = 0 + save_errors: list[tuple[str, Exception]] = [] if observations.is_empty() or not message_ids: logger.warning( "Deriver generated zero observations for messages %s:%s in %s/%s!", @@ -236,10 +238,11 @@ async def process_representation_tasks_batch( representation_result.semantic_dup_replaced_count ) successful_observer_count += 1 - except Exception as e: - logger.error( - "Failed to save representation for observer %s: %s", observer, e + except Exception as e: # noqa: BLE001 + logger.exception( + "Failed to save representation for observer %s", observer ) + save_errors.append((observer, e)) # Log metrics overall_duration = (time.perf_counter() - overall_start) * 1000 @@ -337,5 +340,16 @@ async def process_representation_tasks_batch( 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, + failed_observer_count=len(save_errors), ) ) + + if save_errors and successful_observer_count == 0: + details = "; ".join( + f"{observer}: {exc.__class__.__name__}: {exc}" + for observer, exc in save_errors + ) + raise RepresentationSaveError( + f"save_representation failed for all {len(save_errors)} observer(s): " + + details + ) from save_errors[0][1] diff --git a/src/embedding_client.py b/src/embedding_client.py index 3cee4484..e55e09fd 100644 --- a/src/embedding_client.py +++ b/src/embedding_client.py @@ -321,39 +321,78 @@ class _EmbeddingClient: fn=_call_openai, ) - async def simple_batch_embed(self, texts: list[str]) -> list[list[float]]: + def _truncate_to_token_limit(self, text: str) -> tuple[str, int]: + """Return a prefix of `text` whose re-encoded token count fits the cap. + + Decode/re-encode after slicing: BPE boundaries can re-expand past the cap. """ - Batch-embed a list of text strings. Each input must already fit within - `max_embedding_tokens`; this method does not sub-chunk oversized inputs. + token_ids = self.encoding.encode(text) + keep = self.max_embedding_tokens + while len(token_ids) > self.max_embedding_tokens: + keep = min(keep, len(token_ids) - 1) + if keep < 1: + return "", 0 + text = self.encoding.decode(token_ids[:keep]) + token_ids = self.encoding.encode(text) + keep -= 1 + return text, len(token_ids) + + async def simple_batch_embed( + self, + texts: list[str], + *, + on_oversize: Literal["raise", "truncate"] = "raise", + ) -> list[list[float]]: + """ + Batch-embed a list of text strings. 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 + on_oversize: ``"raise"`` (default) errors; ``"truncate"`` embeds a + token-capped prefix. Returns: List of embedding vectors, one per input text (in order) Raises: - ValueError: If any text exceeds token limits + ValueError: If any text exceeds token limits and `on_oversize` is + ``"raise"`` """ if not texts: return [] - # Validate per-input token limit and collect token counts for batching + # Validate / cap per-input token limit and collect counts for batching + prepared_texts: list[str] = [] 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)" - ) + token_ids = self.encoding.encode(text) + if len(token_ids) > self.max_embedding_tokens: + if on_oversize == "truncate": + original_count = len(token_ids) + text, tokens = self._truncate_to_token_limit(text) + logger.warning( + "truncated oversize embedding input at idx %d: %d->%d tokens", + idx, + original_count, + tokens, + ) + else: + raise ValueError( + f"Text at index {idx} exceeds maximum token limit of " + + f"{self.max_embedding_tokens} tokens (got {len(token_ids)} tokens)" + ) + else: + tokens = len(token_ids) + prepared_texts.append(text) token_counts.append(tokens) # 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) + str(i): [(prepared_texts[i], token_counts[i])] + for i in range(len(prepared_texts)) } batches = self._create_batches(text_chunks) @@ -695,9 +734,16 @@ class EmbeddingClient: """Embed a single query string.""" return await self._get_client().embed(query) - async def simple_batch_embed(self, texts: list[str]) -> list[list[float]]: + async def simple_batch_embed( + self, + texts: list[str], + *, + on_oversize: Literal["raise", "truncate"] = "raise", + ) -> list[list[float]]: """Batch embed a list of text strings (each must fit token limit).""" - return await self._get_client().simple_batch_embed(texts) + return await self._get_client().simple_batch_embed( + texts, on_oversize=on_oversize + ) 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).""" diff --git a/src/exceptions.py b/src/exceptions.py index 129324b6..41d775fe 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -133,6 +133,14 @@ class VectorStoreError(HonchoException): detail = "Vector store operation failed" +@final +class RepresentationSaveError(HonchoException): + """Raised when every observer's representation save fails in a batch.""" + + status_code: int = 500 + detail: str = "Representation save failed for all observers" + + class LLMError(Exception): """Exception raised when an LLM call fails. diff --git a/src/reconciler/sync_vectors.py b/src/reconciler/sync_vectors.py index 1ff94542..1a8e99b5 100644 --- a/src/reconciler/sync_vectors.py +++ b/src/reconciler/sync_vectors.py @@ -302,7 +302,9 @@ async def _sync_documents( EmbeddingCallPurpose.VECTOR_SYNC.value, parent_category="reconciliation", ): - new_embeddings = await embedding_client.simple_batch_embed(contents) + new_embeddings = await embedding_client.simple_batch_embed( + contents, on_oversize="truncate" + ) if len(new_embeddings) != len(docs_needing_embed): logger.warning( diff --git a/src/telemetry/events/representation.py b/src/telemetry/events/representation.py index ad6dfca4..c4291092 100644 --- a/src/telemetry/events/representation.py +++ b/src/telemetry/events/representation.py @@ -154,6 +154,10 @@ class RepresentationCompletedEvent(BaseEvent): default=0, description="Number of observers this representation was saved against", ) + failed_observer_count: int = Field( + default=0, + description="Number of observers whose save_representation failed (partial or total)", + ) def get_resource_id(self) -> str: """Resource ID includes workspace, session, and latest message for uniqueness.""" diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index 7f094154..b8af9eee 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -930,7 +930,9 @@ async def create_observations( run_id=run_id, parent_category=parent_category, ): - embeddings = await embedding_client.simple_batch_embed(contents) + embeddings = await embedding_client.simple_batch_embed( + contents, on_oversize="truncate" + ) embeddings_by_index = dict( zip(range(len(normalized_observations)), embeddings, strict=True) ) diff --git a/tests/conftest.py b/tests/conftest.py index f99859b3..8a078652 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -604,7 +604,9 @@ def mock_openai_embeddings(request: pytest.FixtureRequest): mock_embed.side_effect = embed_side_effect - async def mock_simple_batch_embed_func(texts: list[str]) -> list[list[float]]: + async def mock_simple_batch_embed_func( + texts: list[str], **_kwargs: object + ) -> list[list[float]]: return [_content_to_embedding(text) for text in texts] mock_simple_batch_embed.side_effect = mock_simple_batch_embed_func diff --git a/tests/crud/test_document.py b/tests/crud/test_document.py index 5f03c80b..6686e688 100644 --- a/tests/crud/test_document.py +++ b/tests/crud/test_document.py @@ -1006,6 +1006,46 @@ class TestDocumentCRUD: assert documents[0].content in ["Observation 1", "Observation 2"] assert documents[1].content in ["Observation 1", "Observation 2"] + @pytest.mark.asyncio + async def test_create_observations_embeds_with_truncate_on_oversize( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """API conclusion creates must opt into truncation on oversize content.""" + test_workspace, test_peer = sample_data + test_peer2, test_session, _ = await self._setup_test_data( + db_session, test_workspace, test_peer + ) + + with patch( + "src.crud.document.embedding_client.simple_batch_embed", + new=AsyncMock(return_value=[[0.1] * 1536, [0.2] * 1536]), + ) as mock_embed: + created = await crud.create_observations( + db_session, + observations=[ + schemas.ConclusionCreate( + content="short conclusion", + observer_id=test_peer.name, + observed_id=test_peer2.name, + session_id=test_session.name, + ), + schemas.ConclusionCreate( + content="another conclusion", + observer_id=test_peer.name, + observed_id=test_peer2.name, + session_id=test_session.name, + ), + ], + workspace_name=test_workspace.name, + ) + + assert len(created) == 2 + mock_embed.assert_awaited_once_with( + ["short conclusion", "another conclusion"], on_oversize="truncate" + ) + class TestSessionPurityInvariant: """Regression tests for the explicit-document session-purity invariant. diff --git a/tests/crud/test_representation_manager.py b/tests/crud/test_representation_manager.py index 6c5e094c..3c2f57e4 100644 --- a/tests/crud/test_representation_manager.py +++ b/tests/crud/test_representation_manager.py @@ -520,7 +520,9 @@ class TestRepresentationManagerSave: ) assert len(saved.created_documents) == 1 - mock_embed.assert_awaited_once_with(["useful observation"]) + mock_embed.assert_awaited_once_with( + ["useful observation"], on_oversize="truncate" + ) saved_observations = _saved_observations(mock_save) assert len(saved_observations) == 1 assert saved_observations[0].content == "useful observation" @@ -576,7 +578,9 @@ class TestRepresentationManagerSave: ) assert len(saved.created_documents) == 1 - mock_embed.assert_awaited_once_with(["inferred conclusion"]) + mock_embed.assert_awaited_once_with( + ["inferred conclusion"], on_oversize="truncate" + ) saved_observations = _saved_observations(mock_save) assert len(saved_observations) == 1 assert isinstance(saved_observations[0], DeductiveObservation) @@ -630,6 +634,61 @@ class TestRepresentationManagerSave: mock_embed.assert_not_awaited() mock_save.assert_not_awaited() + @pytest.mark.asyncio + async def test_save_representation_embeds_with_truncate_on_oversize(self): + """One oversize observation must not drop the rest of the batch.""" + manager = RepresentationManager( + "workspace", + observer="observer", + observed="observed", + ) + representation = Representation( + explicit=[ + ExplicitObservation( + content="short fact", + created_at=datetime.now(timezone.utc), + message_ids=[1], + session_name="session", + ) + ], + deductive=[ + DeductiveObservation( + conclusion="inferred fact", + premises=["premise"], + source_ids=["doc-a"], + created_at=datetime.now(timezone.utc), + message_ids=[1], + session_name="session", + ) + ], + ) + + with ( + patch("src.crud.representation.tracked_db", _fake_tracked_db), + patch( + "src.crud.representation.embedding_client.simple_batch_embed", + new=AsyncMock(return_value=[[0.1], [0.2]]), + ) as mock_embed, + patch.object( + manager, + "_save_representation_internal", + new=AsyncMock( + return_value=CreateDocumentsResult(created_documents=[MagicMock()]) + ), + ), + ): + await manager.save_representation( + representation, + message_ids=[1], + session_name="session", + message_created_at=datetime.now(timezone.utc), + message_level_configuration=_resolved_config(), + ) + + mock_embed.assert_awaited_once_with( + ["inferred fact", "short fact"], on_oversize="truncate" + ) + class TestVectorQueryTopKFloor: """Regression for HONCHO-19Q / HONCHO-4Q4. diff --git a/tests/deriver/test_deriver_processing.py b/tests/deriver/test_deriver_processing.py index 6785d159..29a983b5 100644 --- a/tests/deriver/test_deriver_processing.py +++ b/tests/deriver/test_deriver_processing.py @@ -7,7 +7,9 @@ import pytest from src import crud, models from src.config import settings +from src.crud.representation import RepresentationManager from src.deriver.deriver import process_representation_tasks_batch +from src.exceptions import RepresentationSaveError from src.llm import HonchoLLMCallResponse from src.utils.representation import ( ExplicitObservationBase, @@ -70,6 +72,116 @@ class TestDeriverProcessing: assert kwargs["model_config"].stop_sequences == expected_config.stop_sequences assert "llm_settings" not in kwargs + async def test_all_observer_saves_failing_surfaces_failure(self): + """When every observer's save_representation fails, the batch must raise.""" + message = Mock( + id=1, + public_id="msg_1", + 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="The user has a dog named Rover") + ] + ), + input_tokens=10, + output_tokens=5, + finish_reasons=["STOP"], + ) + + failing_save = AsyncMock(side_effect=RuntimeError("429 RESOURCE_EXHAUSTED")) + emitted: list[Any] = [] + with ( + patch( + "src.deriver.deriver.honcho_llm_call", + new_callable=AsyncMock, + return_value=mock_response, + ), + patch.object(RepresentationManager, "save_representation", failing_save), + patch("src.deriver.deriver.emit", side_effect=emitted.append), + pytest.raises(RepresentationSaveError, match="save_representation failed"), + ): + await process_representation_tasks_batch( + messages=[message], + message_level_configuration=configuration, + observers=["bob"], + observed="alice", + queue_item_message_ids=[1], + ) + + # Telemetry must fire *before* the raise so a total save failure is still + # visible to metrics. Guards against emit() being moved after the raise. + assert emitted, "expected telemetry to be emitted before the raised failure" + assert emitted[-1].observer_count == 0 + assert emitted[-1].failed_observer_count == 1 + + async def test_partial_observer_failure_is_processed_and_surfaced(self): + """When some observers save and one fails, the batch does NOT raise + (saved observers are kept) and the failure is visible via telemetry. + """ + message = Mock( + id=1, + public_id="msg_1", + 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="The user has a dog named Rover") + ] + ), + input_tokens=10, + output_tokens=5, + finish_reasons=["STOP"], + ) + + # bob succeeds, carol fails. + partial_save = AsyncMock( + side_effect=[ + crud.CreateDocumentsResult(), + RuntimeError("429 RESOURCE_EXHAUSTED"), + ] + ) + emitted: list[Any] = [] + with ( + patch( + "src.deriver.deriver.honcho_llm_call", + new_callable=AsyncMock, + return_value=mock_response, + ), + patch.object(RepresentationManager, "save_representation", partial_save), + 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 emitted, "expected a telemetry event to be emitted" + event = emitted[-1] + assert event.observer_count == 1 + assert event.failed_observer_count == 1 + async def test_process_representation_tasks_batch_passes_custom_instructions_into_prompt( self, ) -> None: diff --git a/tests/deriver/test_vector_reconciliation.py b/tests/deriver/test_vector_reconciliation.py index 5e2e4712..0ecd4d5c 100644 --- a/tests/deriver/test_vector_reconciliation.py +++ b/tests/deriver/test_vector_reconciliation.py @@ -551,9 +551,12 @@ class TestReEmbedding: # Mock embedding client to track batch calls batch_call_count = 0 - async def track_batch_embed(contents: list[str]) -> list[list[float]]: + async def track_batch_embed( + contents: list[str], *, on_oversize: str, **_kwargs: object + ) -> list[list[float]]: nonlocal batch_call_count batch_call_count += 1 + assert on_oversize == "truncate" return [[1.0] * 1536 for _ in contents] with patch("src.reconciler.sync_vectors.embedding_client") as mock_embed_client: diff --git a/tests/live_llm/README.md b/tests/live_llm/README.md index 442049bc..5cf39e4b 100644 --- a/tests/live_llm/README.md +++ b/tests/live_llm/README.md @@ -68,5 +68,5 @@ Coverage by provider: - OpenAI transport → OpenRouter non-reasoning models (e.g. `inception/mercury-2`): non-chat / diffusion architectures must stay on `max_tokens`, no `reasoning_effort`, tool-calling parameter-schema compatibility is the canary for exotic OR-served providers - Gemini 2.5/3.0 classes: structured outputs, cached-content reuse, thought signatures, multi-turn tool replay - Gemini 3.1 class: thinking and tool replay coverage by default; structured-output/caching coverage should only be added once Google documents support for that path -- Embeddings (`test_live_embeddings.py`): single embed, batched embed, batch-vs-single alignment, and chunk-to-id mapping for every configured embedding model. `gemini-embedding-2*` is the reason this exists — those models collapse a list of bare strings into one document (#745), and only a live call catches it. Also covers first-class `EmbeddingModelConfig.timeout` plumbing (one representative model per transport): configured timeout lands on the SDK client, and a near-zero timeout aborts before the provider answers +- Embeddings (`test_live_embeddings.py`): single embed, batched embed, batch-vs-single alignment, chunk-to-id mapping, and oversize-truncate survival (`on_oversize="truncate"`) for every configured embedding model. `gemini-embedding-2*` is the reason this exists — those models collapse a list of bare strings into one document (#745), and only a live call catches it. Also covers first-class `EmbeddingModelConfig.timeout` plumbing (one representative model per transport): configured timeout lands on the SDK client, and a near-zero timeout aborts before the provider answers - OpenAI-compatible embedding providers (e.g. OpenRouter's `google/gemini-embedding-001`): the #932 surface. Those providers reject a base64 embedding request outright (HTTP 400) or answer HTTP 200 with empty data, so the whole matrix fails without `encoding_format="float"`. Real OpenAI accepts base64 happily, so only a third-party provider catches it. Note that OpenRouter load-balances across upstreams, so the base64 failure is per-attempt rather than guaranteed: a retry can land on an endpoint that accepts it. `test_live_openai_float_encoding_matches_base64` covers the other side, that the float switch must not move vectors on real OpenAI diff --git a/tests/live_llm/test_live_embeddings.py b/tests/live_llm/test_live_embeddings.py index 6ef2b048..7c57a2e3 100644 --- a/tests/live_llm/test_live_embeddings.py +++ b/tests/live_llm/test_live_embeddings.py @@ -197,6 +197,26 @@ async def test_live_openai_float_encoding_matches_base64( ), f"{spec.id}: float encoding diverges from base64 (cosine={similarity:.8f})" +@pytest.mark.asyncio +@pytest.mark.parametrize("spec", ALL_SPECS, ids=lambda spec: spec.id) +async def test_live_batch_embed_truncates_oversize_instead_of_dropping_batch( + spec: LiveEmbeddingSpec, +) -> None: + """on_oversize='truncate' keeps one vector per input when an item exceeds the cap.""" + # Tiny cap so the oversize input stays cheap to tokenize and send. + client = make_embedding_client(spec, max_input_tokens=32) + oversize = " ".join(f"oversize-token-{index}" for index in range(200)) + assert len(client.encoding.encode(oversize)) > client.max_embedding_tokens + + texts = [BATCH_TEXTS[0], oversize, BATCH_TEXTS[1]] + embeddings = await client.simple_batch_embed(texts, on_oversize="truncate") + + assert len(embeddings) == len(texts) + assert all(len(embedding) == spec.dimensions for embedding in embeddings) + # A collapsed or dropped batch would reuse a vector or return fewer. + assert len({tuple(embedding) for embedding in embeddings}) == len(texts) + + @pytest.mark.asyncio @pytest.mark.parametrize("spec", GEMINI_SPECS, ids=lambda spec: spec.id) async def test_live_gemini_batch_embed_survives_batch_split( diff --git a/tests/llm/test_embedding_client.py b/tests/llm/test_embedding_client.py index 9df80be1..fc2ff411 100644 --- a/tests/llm/test_embedding_client.py +++ b/tests/llm/test_embedding_client.py @@ -13,6 +13,7 @@ from src.config import ( ) from src.embedding_client import ( BatchItem, + EmbeddingClient, _EmbeddingClient, # pyright: ignore[reportPrivateUsage] ) @@ -910,6 +911,131 @@ async def test_simple_batch_embed_rejects_oversized_input( await client.simple_batch_embed([too_long]) +@pytest.mark.asyncio +async def test_simple_batch_embed_truncates_oversize_when_requested( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_oversize='truncate' embeds a prefix instead of failing the batch.""" + 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("openai.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 = "hello" + too_long = ("word " * 50).strip() + assert len(client.encoding.encode(too_long)) > client.max_embedding_tokens + + out = await client.simple_batch_embed([short, too_long], on_oversize="truncate") + + assert len(out) == 2 + assert fake_embeddings.calls, "expected a provider call after truncation" + received = fake_embeddings.calls[0]["input"] + assert received[0] == short + truncated = received[1] + assert isinstance(truncated, str) + assert truncated != too_long + assert len(client.encoding.encode(truncated)) <= client.max_embedding_tokens + + +@pytest.mark.asyncio +async def test_simple_batch_embed_truncate_reencodes_until_under_cap( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """decode(ids[:n]) can re-encode past n; truncate must re-verify the count.""" + 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("openai.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, + ) + + encode_calls = {"n": 0} + + def encode(text: str) -> list[int]: + encode_calls["n"] += 1 + if text.startswith("LONG"): + # 1: original oversize; 2: still over after first slice; 3+: fits. + if encode_calls["n"] == 1: + return list(range(20)) + if encode_calls["n"] == 2: + return list(range(12)) + return list(range(8)) + return [1] + + def decode(ids: list[int]) -> str: + return "LONG" + "x" * len(ids) + + monkeypatch.setattr(client.encoding, "encode", encode) + monkeypatch.setattr(client.encoding, "decode", decode) + + out = await client.simple_batch_embed(["LONG-input"], on_oversize="truncate") + + assert len(out) == 1 + received = fake_embeddings.calls[0]["input"][0] + assert isinstance(received, str) + # The provider must see the post-loop text, which encodes to 8 (<= cap). + assert encode(received) == list(range(8)) + assert encode_calls["n"] >= 3 + + +@pytest.mark.asyncio +async def test_public_embedding_client_forwards_on_oversize( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The singleton wrapper must forward on_oversize to the inner client.""" + captured: dict[str, object] = {} + + class FakeInner: + async def simple_batch_embed( + self, + texts: list[str], + *, + on_oversize: str = "raise", + ) -> list[list[float]]: + captured["texts"] = texts + captured["on_oversize"] = on_oversize + return [[0.1]] + + wrapper = EmbeddingClient() + monkeypatch.setattr(wrapper, "_get_client", lambda: FakeInner()) + + out = await wrapper.simple_batch_embed(["hi"], on_oversize="truncate") + + assert out == [[0.1]] + assert captured["texts"] == ["hi"] + assert captured["on_oversize"] == "truncate" + + def test_prepare_chunks_returns_ordered_chunks( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/telemetry/test_representation_v2_fields.py b/tests/telemetry/test_representation_v2_fields.py index a41bed41..6f2251ea 100644 --- a/tests/telemetry/test_representation_v2_fields.py +++ b/tests/telemetry/test_representation_v2_fields.py @@ -51,6 +51,7 @@ class TestRepresentationV2AdditiveFields: assert event.exact_dup_existing_count == 0 assert event.semantic_dup_rejected_count == 0 assert event.semantic_dup_replaced_count == 0 + assert event.failed_observer_count == 0 def test_input_tokens_semantics_preserved(self): """The downstream metering key must remain 'queued-message tokens'. @@ -161,6 +162,7 @@ class TestRepresentationV2AdditiveFields: "exact_dup_existing_count", "semantic_dup_rejected_count", "semantic_dup_replaced_count", + "failed_observer_count", ): assert field in data, f"missing field: {field}" assert data["hit_batch_token_cap"] is True diff --git a/tests/utils/test_agent_tools.py b/tests/utils/test_agent_tools.py index e53598fa..ac45cabf 100644 --- a/tests/utils/test_agent_tools.py +++ b/tests/utils/test_agent_tools.py @@ -334,7 +334,9 @@ class TestCreateObservations: """If batch embedding fails but individual embeds succeed, all observations are created.""" workspace, peer1, peer2, session, _, _ = tool_test_data - async def fail_batch_embed(_texts: list[str]) -> list[list[float]]: + async def fail_batch_embed( + _texts: list[str], **_kwargs: object + ) -> list[list[float]]: raise RuntimeError("embedding provider timeout") async def succeed_single_embed(_content: str) -> list[float]: @@ -393,7 +395,9 @@ class TestCreateObservations: """If batch embedding fails and some individual embeds also fail, only successful ones are created.""" workspace, peer1, peer2, session, _, _ = tool_test_data - async def fail_batch_embed(_texts: list[str]) -> list[list[float]]: + async def fail_batch_embed( + _texts: list[str], **_kwargs: object + ) -> list[list[float]]: raise RuntimeError("embedding provider timeout") async def embed_per_observation(content: str) -> list[float]: @@ -458,7 +462,9 @@ class TestCreateObservations: workspace, peer1, peer2, session, _, _ = tool_test_data created_documents: list[Any] = [] - async def fake_batch_embed(texts: list[str]) -> list[list[float]]: + async def fake_batch_embed( + texts: list[str], **_kwargs: object + ) -> list[list[float]]: assert texts == ["trimmed observation"] return [[0.4, 0.5, 0.6]] @@ -504,6 +510,60 @@ class TestCreateObservations: assert len(created_documents) == 1 assert created_documents[0].content == "trimmed observation" + async def test_create_observations_embeds_with_truncate_on_oversize( + self, + tool_test_data: Any, + monkeypatch: pytest.MonkeyPatch, + ): + """Storage path must opt into truncation so one long obs cannot drop the batch.""" + workspace, peer1, peer2, session, _, _ = tool_test_data + captured: dict[str, object] = {} + + async def fake_batch_embed( + texts: list[str], *, on_oversize: str = "raise", **_kwargs: object + ) -> list[list[float]]: + captured["texts"] = texts + captured["on_oversize"] = on_oversize + return [[0.1] for _ in texts] + + async def fake_create_documents( + _db: AsyncSession, + documents: list[Any], + workspace_name: str, + *, + observer: str, + observed: str, + deduplicate: bool = False, + ) -> crud.CreateDocumentsResult: + _ = (workspace_name, observer, observed, deduplicate) + return crud.CreateDocumentsResult(created_documents=documents) + + monkeypatch.setattr( + "src.utils.agent_tools.embedding_client.simple_batch_embed", + fake_batch_embed, + ) + monkeypatch.setattr( + "src.utils.agent_tools.crud.create_documents", fake_create_documents + ) + + result = await create_observations( + observations=[ + schemas.ObservationInput(content="short fact", level="explicit"), + schemas.ObservationInput(content="long fact", level="explicit"), + ], + observer=peer1.name, + observed=peer2.name, + session_name=session.name, + workspace_name=workspace.name, + message_ids=[], + message_created_at=str(datetime.now(timezone.utc)), + ) + + assert isinstance(result, ObservationsCreatedResult) + assert result.created_count == 2 + assert captured["on_oversize"] == "truncate" + assert captured["texts"] == ["short fact", "long fact"] + async def test_create_observations_skips_all_blank_content( self, tool_test_data: Any, From 3e73c6f2876db199085ac9c780fa7e1fb49b2827 Mon Sep 17 00:00:00 2001 From: Serhii Zghama <20826225+serhiizghama@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:33:08 +0700 Subject: [PATCH 05/50] fix(filter): make ne on jsonb metadata keys null-safe (#1036) * fix(filter): make ne on jsonb metadata keys null-safe * test(filter): cover null-safe ne on nested metadata keys * test(filter): count actual rows for nested-metadata ne null-safety Compile-only checks lock the operator map entry but don't catch wrong row sets under three-valued logic. Adds a live messages/list case with a message missing the key and one with empty metadata, following the scalar-column pattern in test_negation_includes_conclusions_with_no_session. * test(filter): type message_configs with a TypedDict basedpyright couldn't narrow the heterogeneous metadata dict literals, so indexing message_configs["content"] came back partially unknown and broke the sorted() calls under type checking. --- src/utils/filter.py | 4 +- tests/test_advanced_filters.py | 85 +++++++++++++++++++++++++++++++++- tests/utils/test_filter.py | 14 ++++++ 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/utils/filter.py b/src/utils/filter.py index 22d648af..78b7569f 100644 --- a/src/utils/filter.py +++ b/src/utils/filter.py @@ -689,7 +689,9 @@ def _build_comparison_condition( "lte": lambda a, v: a <= v, "gt": lambda a, v: a > v, "lt": lambda a, v: a < v, - "ne": lambda a, v: a != v, + # IS DISTINCT FROM, not <>: (metadata ->> key) is NULL for an + # absent key, and `NULL <> v` is NULL, so <> drops those rows. + "ne": lambda a, v: a.is_distinct_from(v), } return operator_map[operator](safe_accessor, safe_value) except Exception as e: diff --git a/tests/test_advanced_filters.py b/tests/test_advanced_filters.py index 3b187470..0ed12e20 100644 --- a/tests/test_advanced_filters.py +++ b/tests/test_advanced_filters.py @@ -4,7 +4,7 @@ comparison operators, and wildcards across multiple models. """ from datetime import datetime, timedelta, timezone -from typing import Any +from typing import Any, TypedDict import pytest from fastapi.testclient import TestClient @@ -13,6 +13,12 @@ from nanoid import generate as generate_nanoid from src.models import Peer, Workspace +class MessageConfig(TypedDict): + content: str + peer_id: str + metadata: dict[str, Any] + + @pytest.mark.parametrize( "filter_config,expected_peer_indices,description", [ @@ -235,6 +241,83 @@ async def test_comparison_operators_filters( ), f"Unexpected message '{message_config['content']}' found in results for {description}" +@pytest.mark.asyncio +async def test_nested_metadata_ne_includes_missing_and_empty_metadata( + client: TestClient, + sample_data: tuple[Workspace, Peer], +): + """`ne` on a nested metadata key must not silently drop rows where the + key is absent. Under SQL's three-valued logic, comparing NULL (a missing + key or empty metadata) with `<>` yields NULL, which excludes the row — + the filter builds and executes cleanly either way, so this has to be + checked by counting the rows actually returned. + """ + test_workspace, test_peer = sample_data + + 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: {}}}, + ) + assert session_response.status_code == 201 + + message_configs: list[MessageConfig] = [ + { + "content": "High priority, score 10", + "peer_id": test_peer.name, + "metadata": {"priority": "high", "score": 10}, + }, + { + "content": "Low priority, score 5", + "peer_id": test_peer.name, + "metadata": {"priority": "low", "score": 5}, + }, + { + "content": "Metadata present, no priority or score key", + "peer_id": test_peer.name, + "metadata": {"other": "value"}, + }, + { + "content": "Empty metadata", + "peer_id": test_peer.name, + "metadata": {}, + }, + ] + 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"]] + + # String value: excludes only the row where priority actually equals "high" + string_ne_contents = list_contents({"metadata": {"priority": {"ne": "high"}}}) + assert sorted(string_ne_contents) == sorted( + [ + message_configs[1]["content"], + message_configs[2]["content"], + message_configs[3]["content"], + ] + ) + + # Numeric value: excludes only the row where score actually equals 5 + numeric_ne_contents = list_contents({"metadata": {"score": {"ne": 5}}}) + assert sorted(numeric_ne_contents) == sorted( + [ + message_configs[0]["content"], + message_configs[2]["content"], + message_configs[3]["content"], + ] + ) + + @pytest.mark.asyncio async def test_bare_list_membership_sugar( client: TestClient, diff --git a/tests/utils/test_filter.py b/tests/utils/test_filter.py index c90c8442..3473e827 100644 --- a/tests/utils/test_filter.py +++ b/tests/utils/test_filter.py @@ -228,6 +228,20 @@ def test_ne_is_null_safe(): assert "IS DISTINCT FROM" in where +def test_nested_metadata_ne_string_is_null_safe(): + """`ne` on a JSONB metadata key went through the operator map as plain <>, + unlike the scalar path, so a row missing that key was silently dropped.""" + where = _where(Document, {"metadata": {"priority": {"ne": "high"}}}) + assert "IS DISTINCT FROM" in where + assert "!=" not in where + + +def test_nested_metadata_ne_numeric_is_null_safe(): + where = _where(Document, {"metadata": {"score": {"ne": 5}}}) + assert "IS DISTINCT FROM" in where + assert "!=" not in where + + def test_not_is_null_safe_over_a_compound_condition(): """Negation has to survive nesting, not just single comparisons.""" where = _where( From fbb4a8ef0cf6593b25571255184c5e0eae149349 Mon Sep 17 00:00:00 2001 From: Phil Date: Mon, 24 Aug 2026 14:35:51 -0400 Subject: [PATCH 06/50] feat(telemetry): add physical DB-connection metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add db_connections_open (gauge) and db_connections_established (counter), driven by SQLAlchemy connection-lifecycle events so they report real connections under every pool class — including NullPool, where the pool-object collector (db_pool_connections) reads zero. Under NullPool the establishment rate approximates request rate. DBConnectionTracker mirrors DBQueryInflightTracker: a ConnectionRecord.info marker makes each physical connection increment once and decrement at most once (no leak, no negative). Registered per-process in the API and deriver; zero-init via the pre-resolved labeled children, matching db_queries_in_flight_gauge. Co-Authored-By: Claude Opus 4.8 --- src/db.py | 77 ++++++++++++++- src/deriver/__main__.py | 7 +- src/main.py | 8 +- src/telemetry/prometheus/metrics.py | 19 ++++ tests/telemetry/test_db_connection_metrics.py | 94 +++++++++++++++++++ 5 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 tests/telemetry/test_db_connection_metrics.py diff --git a/src/db.py b/src/db.py index 005d52eb..6f775c87 100644 --- a/src/db.py +++ b/src/db.py @@ -12,7 +12,11 @@ from sqlalchemy.orm import declarative_base from sqlalchemy.pool import NullPool, QueuePool from src.config import settings -from src.telemetry.prometheus.metrics import db_queries_in_flight_gauge +from src.telemetry.prometheus.metrics import ( + db_connections_established_counter, + db_connections_open_gauge, + db_queries_in_flight_gauge, +) logger = logging.getLogger(__name__) @@ -220,6 +224,77 @@ def register_db_query_instrumentation(instance_type: str) -> None: _db_query_instrumentation_registered = True +class DBConnectionTracker: + """Tracks physical DB connections open on this engine via pool lifecycle events. + + Drift-proof, mirroring ``DBQueryInflightTracker``: marks the ``ConnectionRecord`` + on ``connect`` and decrements only if that mark is still present on + ``close``/``invalidate``, so each physical connection increments the gauge + exactly once and decrements at most once — it can't leak upward or go negative + when both events fire during invalidation cleanup. Works for every pool class, + including ``NullPool`` (whose pool keeps no records, so the scrape-time + ``db_pool_connections`` collector reads zero). + """ + + # Marker on ConnectionRecord.info recording that we incremented for this + # connection, so we decrement exactly once across close/invalidate. + OPEN_KEY: str = "_honcho_conn_open" + + def __init__(self, open_child: Any, established_child: Any) -> None: + self._open: Any = open_child + self._established: Any = established_child + + def on_connect(self, _dbapi_connection: Any, connection_record: Any) -> None: + try: + connection_record.info[self.OPEN_KEY] = True + self._established.inc() + self._open.inc() + except Exception: + logger.debug("db-connection gauge inc failed", exc_info=True) + + def on_close(self, _dbapi_connection: Any, connection_record: Any, *_: Any) -> None: + try: + if connection_record is not None and connection_record.info.pop( + self.OPEN_KEY, False + ): + self._open.dec() + except Exception: + logger.debug("db-connection gauge dec failed", exc_info=True) + + +# Process-wide tracker, created at registration (None until then / if metrics off). +_connection_tracker: DBConnectionTracker | None = None + + +_db_connection_instrumentation_registered = False + + +def register_db_connection_instrumentation(instance_type: str) -> None: + """Attach physical-connection tracking to the engine (no-op if metrics off). + + Counts connections via pool lifecycle events, so it reports real numbers under + any pool class — unlike the pool-object collector, which reads zero under + ``NullPool``. Pre-resolving the labeled children materializes both series at 0, + so an absent series signals a broken scrape rather than "no connections" (the + zero-init convention). Idempotent: repeated calls won't attach duplicate + listeners, which would double-count connections. + """ + global _connection_tracker, _db_connection_instrumentation_registered + if not settings.METRICS.ENABLED or _db_connection_instrumentation_registered: + return + open_child = db_connections_open_gauge.labels(instance_type=instance_type) + established_child = db_connections_established_counter.labels( + instance_type=instance_type + ) + _connection_tracker = DBConnectionTracker(open_child, established_child) + sync_engine = engine.sync_engine + event.listen(sync_engine, "connect", _connection_tracker.on_connect) + # close AND invalidate both tear a connection down; the marker dedupes them. + for teardown_event in ("close", "invalidate"): + event.listen(sync_engine, teardown_event, _connection_tracker.on_close) + _db_connection_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/deriver/__main__.py b/src/deriver/__main__.py index ce3969b5..9f5e4d91 100644 --- a/src/deriver/__main__.py +++ b/src/deriver/__main__.py @@ -6,7 +6,11 @@ import uvloop from prometheus_client import start_http_server from src.config import settings -from src.db import engine, register_db_query_instrumentation +from src.db import ( + engine, + register_db_connection_instrumentation, + register_db_query_instrumentation, +) from src.startup import validate_embedding_schema from src.telemetry import ( initialize_telemetry_async, @@ -26,6 +30,7 @@ def start_metrics_server() -> None: # Expose DB connection-pool stats for this deriver instance. register_db_pool_collector("deriver") register_db_query_instrumentation("deriver") + register_db_connection_instrumentation("deriver") # region ai # Zero-init bounded-label counters so a missing series signals a broken scrape, diff --git a/src/main.py b/src/main.py index 6930d4c1..a1ec9765 100644 --- a/src/main.py +++ b/src/main.py @@ -17,7 +17,12 @@ 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, register_db_query_instrumentation, request_context +from src.db import ( + engine, + register_db_connection_instrumentation, + register_db_query_instrumentation, + request_context, +) from src.exceptions import HonchoException from src.routers import ( conclusions, @@ -109,6 +114,7 @@ async def lifespan(_: FastAPI): # Expose DB connection-pool stats for this API instance (no-op if metrics off) register_db_pool_collector("api") register_db_query_instrumentation("api") + register_db_connection_instrumentation("api") # region ai # Zero-init bounded-label counters so a missing series signals a broken scrape, diff --git a/src/telemetry/prometheus/metrics.py b/src/telemetry/prometheus/metrics.py index 749591d9..9c0da950 100644 --- a/src/telemetry/prometheus/metrics.py +++ b/src/telemetry/prometheus/metrics.py @@ -208,6 +208,25 @@ db_queries_in_flight_gauge = NamespacedGauge( ["namespace", "instance_type"], ) +# Physical DB connections, tracked via SQLAlchemy connection-lifecycle events +# (see DBConnectionTracker in src/db.py) rather than the pool object, so they are +# visible under EVERY pool class — including NullPool, whose pool holds no records +# for the scrape-time db_pool_connections collector to read. +db_connections_open_gauge = NamespacedGauge( + "db_connections_open", + "Physical DB connections currently open by this instance, across all pool " + + "classes (tracks concurrency of DB work under NullPool, pool occupancy " + + "under QueuePool)", + ["namespace", "instance_type"], +) + +db_connections_established_counter = NamespacedCounter( + "db_connections_established", + "Physical DB connections established since process start. Under NullPool, " + + "rate() approximates request rate (one connect per DB checkout)", + ["namespace", "instance_type"], +) + @final class PrometheusMetrics: diff --git a/tests/telemetry/test_db_connection_metrics.py b/tests/telemetry/test_db_connection_metrics.py new file mode 100644 index 00000000..1753b0da --- /dev/null +++ b/tests/telemetry/test_db_connection_metrics.py @@ -0,0 +1,94 @@ +"""Tests for the physical-DB-connection metrics. + +``db_connections_open`` (gauge) and ``db_connections_established`` (counter) are +driven by SQLAlchemy connection-lifecycle events rather than the pool object, so +they report real connections under EVERY pool class — including ``NullPool``, whose +pool keeps no records for the scrape-time ``db_pool_connections`` collector to read. + +Asserts two properties: +- zero-init — resolving a labeled child materializes it at 0, so an absent series + means a broken scrape rather than "no connections" (the #927 convention); +- ``DBConnectionTracker`` semantics — increment once per connect, decrement at most + once per connection (marker-guarded, so it can't leak upward or go negative), and + the establishment counter is monotonic (closes never decrement it). +""" + +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from prometheus_client import REGISTRY + +from src.db import DBConnectionTracker +from src.telemetry.prometheus.metrics import ( + db_connections_established_counter, + db_connections_open_gauge, +) + + +@pytest.fixture +def ns(monkeypatch: pytest.MonkeyPatch) -> str: + """Enable metrics under a namespace unique to this test. + + The process-global REGISTRY keeps a materialized child for the rest of the + session, so a shared namespace would let one test satisfy another's + presence/absence assertions independently of the code under test. + """ + namespace = f"test_db_conn_{uuid4().hex[:8]}" + monkeypatch.setattr("src.config.settings.METRICS.ENABLED", True) + monkeypatch.setattr("src.config.settings.METRICS.NAMESPACE", namespace) + return namespace + + +def sample(name: str, namespace: str, **labels: str) -> float | None: + """Value of a series if it exists, else None. Never materializes it.""" + return REGISTRY.get_sample_value(name, {"namespace": namespace, **labels}) + + +def test_connection_children_zero_init(ns: str) -> None: + """Resolving the labeled children materializes both series at 0.""" + db_connections_open_gauge.labels(instance_type="api") + db_connections_established_counter.labels(instance_type="api") + + assert sample("db_connections_open", ns, instance_type="api") == 0.0 + # prometheus_client appends _total to counter names + assert sample("db_connections_established_total", ns, instance_type="api") == 0.0 + + +def test_tracker_inc_dec_and_counter_monotonic(ns: str) -> None: + """connect increments both metrics; close decrements only the gauge.""" + open_child = db_connections_open_gauge.labels(instance_type="api") + established_child = db_connections_established_counter.labels(instance_type="api") + tracker = DBConnectionTracker(open_child, established_child) + + rec1, rec2 = SimpleNamespace(info={}), SimpleNamespace(info={}) + tracker.on_connect(None, rec1) + tracker.on_connect(None, rec2) + assert sample("db_connections_open", ns, instance_type="api") == 2.0 + assert sample("db_connections_established_total", ns, instance_type="api") == 2.0 + + tracker.on_close(None, rec1) + tracker.on_close(None, rec2) + assert sample("db_connections_open", ns, instance_type="api") == 0.0 + # the counter is monotonic: closes never decrement it + assert sample("db_connections_established_total", ns, instance_type="api") == 2.0 + + +def test_marker_prevents_double_dec_and_negative(ns: str) -> None: + """The ConnectionRecord marker bounds each connection to one dec.""" + open_child = db_connections_open_gauge.labels(instance_type="api") + established_child = db_connections_established_counter.labels(instance_type="api") + tracker = DBConnectionTracker(open_child, established_child) + + # a close with no matching connect must not drive the gauge negative + tracker.on_close(None, SimpleNamespace(info={})) + assert sample("db_connections_open", ns, instance_type="api") == 0.0 + + # connect, then close AND invalidate on the same record (both fire during + # invalidation cleanup): the marker ensures exactly one decrement. The third + # positional arg is invalidate's exception, absorbed by on_close's *_. + rec = SimpleNamespace(info={}) + tracker.on_connect(None, rec) + tracker.on_close(None, rec) + tracker.on_close(None, rec, ValueError("invalidated")) + assert sample("db_connections_open", ns, instance_type="api") == 0.0 From 3f0ba03d4eeeb9e20e4003552d5f88eab3930fff Mon Sep 17 00:00:00 2001 From: Phil Date: Mon, 24 Aug 2026 14:37:20 -0400 Subject: [PATCH 07/50] review: fix db_connections_open leak on the detach path; comment/doc polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug (verified vs SQLAlchemy 2.0.49): on GC-cleanup of an abandoned async connection, _finalize_fairy routes through fairy.detach(), which nulls the record's dbapi_connection so NullPool's close is a no-op (the `close` event never fires) then emits `detach` with the record. Listening only to close/invalidate left the marker unpopped, so db_connections_open leaked upward and never reset until restart. Listen to `detach` too — it carries the ConnectionRecord and the marker dedupes, so exactly one decrement occurs. Also: strip a bare PR-number provenance tag from the test docstring (plastic-labs comment-reconciliation rule); disambiguate db_connections_open from the existing db_pool_connections; note in initialize_bounded_metrics that DB-instrumentation metrics zero-init in their registrar. Co-Authored-By: Claude Opus 4.8 --- src/db.py | 8 ++++++-- src/telemetry/prometheus/metrics.py | 9 ++++++++- tests/telemetry/test_db_connection_metrics.py | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/db.py b/src/db.py index 6f775c87..633f6f06 100644 --- a/src/db.py +++ b/src/db.py @@ -289,8 +289,12 @@ def register_db_connection_instrumentation(instance_type: str) -> None: _connection_tracker = DBConnectionTracker(open_child, established_child) sync_engine = engine.sync_engine event.listen(sync_engine, "connect", _connection_tracker.on_connect) - # close AND invalidate both tear a connection down; the marker dedupes them. - for teardown_event in ("close", "invalidate"): + # A connection is torn down by close (normal return / recycle discard), + # invalidate (broken connection), or detach — the last fires on GC-cleanup of an + # abandoned async connection, where NullPool's close is a no-op so `close` never + # fires. All three carry the ConnectionRecord; the marker dedupes if more than + # one fires for the same connection. + for teardown_event in ("close", "invalidate", "detach"): event.listen(sync_engine, teardown_event, _connection_tracker.on_close) _db_connection_instrumentation_registered = True diff --git a/src/telemetry/prometheus/metrics.py b/src/telemetry/prometheus/metrics.py index 9c0da950..cead893a 100644 --- a/src/telemetry/prometheus/metrics.py +++ b/src/telemetry/prometheus/metrics.py @@ -211,7 +211,9 @@ db_queries_in_flight_gauge = NamespacedGauge( # Physical DB connections, tracked via SQLAlchemy connection-lifecycle events # (see DBConnectionTracker in src/db.py) rather than the pool object, so they are # visible under EVERY pool class — including NullPool, whose pool holds no records -# for the scrape-time db_pool_connections collector to read. +# for the scrape-time db_pool_connections collector to read. Under QueuePool this +# roughly equals db_pool_connections{checked_in} + {checked_out}; its unique value +# is under NullPool, where that collector reads zero. db_connections_open_gauge = NamespacedGauge( "db_connections_open", "Physical DB connections currently open by this instance, across all pool " @@ -428,6 +430,11 @@ class PrometheusMetrics: """Pre-create bounded-label counter children at 0 for this process, so an absent series means a broken scrape rather than "nothing happened". + Note: the DB-instrumentation metrics (db_queries_in_flight, + db_connections_open, db_connections_established) are NOT initialized here — + they zero-init via the pre-resolved labeled children in their register_db_* + functions in src/db.py, so an auditor should not read them as forgotten. + Args: instance_type: "api" or "deriver" — selects the process-specific counters. Event-type and buffer metrics are initialized in both. diff --git a/tests/telemetry/test_db_connection_metrics.py b/tests/telemetry/test_db_connection_metrics.py index 1753b0da..c9f4cfb8 100644 --- a/tests/telemetry/test_db_connection_metrics.py +++ b/tests/telemetry/test_db_connection_metrics.py @@ -7,7 +7,7 @@ pool keeps no records for the scrape-time ``db_pool_connections`` collector to r Asserts two properties: - zero-init — resolving a labeled child materializes it at 0, so an absent series - means a broken scrape rather than "no connections" (the #927 convention); + means a broken scrape rather than "no connections"; - ``DBConnectionTracker`` semantics — increment once per connect, decrement at most once per connection (marker-guarded, so it can't leak upward or go negative), and the establishment counter is monotonic (closes never decrement it). From c73f6a0b7a28d571d07af8a4804e0e799ad5e833 Mon Sep 17 00:00:00 2001 From: adavyas <121313528+adavyas@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:54:23 -0700 Subject: [PATCH 08/50] feat: Add workspace-level chat (#931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add workspace-level chat (DEV-1326) POST /v3/workspaces/{workspace_id}/chat: agentic dialectic over the whole workspace instead of a single (observer, observed) pair. Salvaged from plastic-labs/honcho#373 and re-grown on today's DialecticAgent: - WorkspaceDialecticAgent subclasses DialecticAgent via four new seams (_get_tools, _create_tool_executor, _prefetch_intro, _trace_name) instead of a base-class extraction; observer/observed use empty-string sentinels. - Routing-accelerated prefetch: workspace stats + top-5 active peers with their self peer-cards (pure DB, ~7ms measured) so routing-obvious queries resolve without a discovery tool round. - Observation search stays pair-scoped (matches per-pair vector namespaces; avoids workspace-flat top-k dilution): search_memory/get_peer_card take observer/observed as tool arguments, with pair attribution in results. - workspace_chat / workspace_chat_stream orchestrators, WorkspaceChatOptions schema (scope param seam left for the #897 scopes facade), SSE streaming, structured output via response_format. - crud: get_workspace_stats, get_active_peers; format_documents_with_attribution. - SDKs: Python Honcho.chat/chat_stream + HonchoAio mirrors; TypeScript honcho.chat/chatStream. - 46 tests (route, orchestrator preflight, tool handlers, executor routing, attribution formatting) + unified test cases + docs. Co-Authored-By: doria <93405247+dr-frmr@users.noreply.github.com> Co-Authored-By: Benjamin McCormick Co-Authored-By: Claude Fable 5 * fix: type SSE stream wrapper as AsyncIterator (basedpyright) Co-Authored-By: Claude Fable 5 * chore: silence unused db_session fixture warnings (basedpyright failOnWarnings) Co-Authored-By: Claude Fable 5 * docs: drop docs changes from this PR (defer to follow-up) Restores docs/v3/documentation/features/chat.mdx to main's version. This also puts back the peer-chat Structured Outputs section (#896) that the workspace-chat commit removed as a rebase artifact. Co-Authored-By: Claude Fable 5 * fix: workspace message tools deny-all under rebased session scoping The #882 rebase changed the unscoped-observer contract from falsy to 'observer is None': resolve_session_scope looked up the workspace executor's observer='' sentinel as a real peer with no session memberships and denied every workspace-flat message read (search, grep, date-range, temporal, observation context) whenever no session was pinned — the primary workspace-chat shape. Normalize the sentinel to None at the five read-handler crud boundaries and add regression tests that run the tools unpinned (verified to fail without the fix). Also from review: - wrap the workspace prefetch in the same degrade-to-None protection the base agent has (an overview query error no longer 500s the request or kills the SSE stream after headers) - thread session_allowlist through create_workspace_tool_executor so the agent-level allowlist seam is honored end to end when scopes (#897) wire it up; allowlisted grep is covered by a test - deterministic name tie-break in get_active_peers ordering Co-Authored-By: Claude Fable 5 * review: SDK response_format parity, shared query sanitizer, annotations - TS SDK: WorkspaceChatParams gains response_format; _workspaceChat/ _workspaceChatStream consume the shared interface instead of inline duplicates; chat/chatStream expose responseFormat. - Consolidate the three identical sanitize_query validators into one NulStripped annotation. - workspace_chat_stream: return annotation + full docstring. Co-Authored-By: Claude Fable 5 * review: fold active peers into workspace stats; trace + query bounds - Merge get_active_peers into get_workspace_stats (one discovery round instead of two); minimal loadout keeps a discovery tool via the merged stats tool. Fixed top-10 by recent activity; deeper discovery routes through search_messages. - get_active_peers CRUD now aggregates over a trailing 90-day window so the chat-path prefetch never scans a workspace's full message history. - Workspace agent inherits the "dialectic_chat" trace name; scope stays distinguished by agent_type/track_name (workspace name was already in telemetry context). - Prefetch failure logs carry workspace + traceback; prompt no longer contrasts against a peer-level agent the model has no concept of; drop ticket identifiers from comments. Co-Authored-By: Claude Fable 5 * feat: add `scope` to workspace chat and exclude scope peers from stats Workspace chat is peer-unanchored, so `scope` is always a session-union allowlist (single name or list), fail-closed when empty. Stats and active-peer prefetch drop scope-kind peers and honor the same allowlist. * test: teach the unified runner `workspace_chat` and parse every case QueryAction now accepts target=workspace_chat (SDK path, including scope). A pytest over tests/unified/test_cases/*.json keeps the four existing workspace-chat cases — and a new scoped one — from rotting against the schema again. * docs: tighten workspace-chat scope docs and judge prompt Scoped workspace_chat uses the SDK, not raw HTTP. The scope fixture's judge now requires the in-scope tea fact, not merely the absence of the leak. format_sse_stream matches the peer-chat one-liner. * fix(dialectic): restore the empty-memory fallback for workspace chat `search_memory` auto-searches messages when a pair has no observations, but the gate only admitted `agent_type == "dialectic"`. The workspace executor passes `workspace_dialectic`, so workspace chat got a bare "No observations found" and answered that it knew nothing rather than falling through to message search. Also fixes the two unified cases that never ran: `deriver` is not a field on `WorkspaceConfiguration`, so both aborted at load with `extra_forbidden`. `workspace_chat_scope` additionally enables reasoning, since it asserts scope isolation and has no reason to depend on the fallback path. Co-Authored-By: Claude Opus 5 * fix(tests/unified): fail CI when unified tests fail `runner.run()` tallied failures into `failed_count` and printed them, but returned nothing, and both entrypoints ignored the result. The workflow invokes `python -m tests.unified.run` bare, so the job has gone green on failing and unrunnable cases since it was wired up in #291. Return the count and exit non-zero on it. `INVALID SCHEMA` already counts toward the tally, so a malformed case now fails the job instead of being skipped silently. Co-Authored-By: Claude Opus 5 * test(unified): assert scope peers stay out of workspace chat answers Scope peers are real peer rows, so a regression in the `scope_peer_clause` exclusion would surface `scope.therapy` through workspace stats or the routing prefetch. Nothing asserted against that. Adds the check to the existing scoped query and a new unscoped one, since the two exercise different `get_active_peers` branches. Verified by removing the exclusion, which fails the unscoped query. Co-Authored-By: Claude Opus 5 * fix: Remove dead code references --------- Co-authored-by: doria <93405247+dr-frmr@users.noreply.github.com> Co-authored-by: Benjamin McCormick Co-authored-by: Claude Fable 5 Co-authored-by: Aakash Kattelu Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> --- CHANGELOG.md | 4 +- sdks/python/src/honcho/aio.py | 73 + sdks/python/src/honcho/client.py | 104 +- sdks/python/src/honcho/http/routes.py | 4 + sdks/typescript/src/client.ts | 135 ++ sdks/typescript/src/index.ts | 1 + sdks/typescript/src/types/api.ts | 13 + src/crud/__init__.py | 10 + src/crud/scope.py | 20 + src/crud/workspace.py | 205 +++ src/dialectic/chat.py | 59 + src/dialectic/core.py | 19 +- src/dialectic/prompts.py | 74 + src/dialectic/workspace.py | 180 +++ src/routers/peers.py | 44 +- src/routers/workspaces.py | 98 ++ src/schemas/__init__.py | 2 + src/schemas/api.py | 57 +- src/utils/agent_tools.py | 289 +++- src/utils/scopes.py | 20 +- tests/conftest.py | 15 + tests/routes/test_scope_reads.py | 111 ++ tests/test_security.py | 2 +- tests/test_workspace_chat.py | 1222 +++++++++++++++++ tests/unified/README.md | 22 +- tests/unified/run.py | 4 +- tests/unified/runner.py | 18 +- tests/unified/schema.py | 14 +- .../test_cases/peer_isolation_test.json | 148 ++ .../test_cases/workspace_chat_cross_peer.json | 88 ++ .../workspace_chat_from_messages.json | 61 + .../workspace_chat_from_observations.json | 85 ++ .../test_cases/workspace_chat_scope.json | 97 ++ tests/unified/test_schema.py | 15 + 34 files changed, 3226 insertions(+), 87 deletions(-) create mode 100644 src/dialectic/workspace.py create mode 100644 tests/test_workspace_chat.py create mode 100644 tests/unified/test_cases/peer_isolation_test.json create mode 100644 tests/unified/test_cases/workspace_chat_cross_peer.json create mode 100644 tests/unified/test_cases/workspace_chat_from_messages.json create mode 100644 tests/unified/test_cases/workspace_chat_from_observations.json create mode 100644 tests/unified/test_cases/workspace_chat_scope.json create mode 100644 tests/unified/test_schema.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e314f867..522c0985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -176,7 +176,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Vector store queries no longer fetch embedding vectors — only document metadata is returned, reducing payload size and DB load (pgvector, lancedb, turbopuffer) (#682) - Langfuse trace metadata now includes `namespace`, `model`, and `provider` so traces can be filtered by deployment slice (#565) - Deriver: model-aware tokenizer (replaces the previously hardcoded encoding) and explicit guard on empty message content (#647) -- Dialectic level defaults now merge correctly with per-level overrides in `src/config` (DEV-1733) (#656) +- Dialectic level defaults now merge correctly with per-level overrides in `src/config` (#656) - Default dialectic tool choice switched from forced/required to `auto` (#630) - Vector sync given a substantial retry budget to tolerate transient embedding provider outages (#604) - `AgentToolConclusionsDeletedEvent` payload now carries `levels` for parity with the rest of the conclusion event surface (#612) @@ -194,7 +194,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Removed strict parameter validation for thinking params on Anthropic and OpenAI transports — was rejecting valid per-transport configs (#686) - `reverse` query parameter is now honored on the v3 workspace list (`POST /v3/workspaces/list`), peer list (`POST /v3/workspaces/{workspace_id}/peers/list`), workspace-scoped session list (`POST /v3/workspaces/{workspace_id}/sessions/list`), and peer-scoped session list (`POST /v3/workspaces/{workspace_id}/peers/{peer_id}/sessions`). Honcho SDKs at 2.1.0+ were already sending `reverse=true` for these routes but the server silently ignored it. Ties on `created_at` now fall back to the internal nanoid `id` so ordering remains stable across pages (#685) - LLM client factories now receive `base_url` from `LLMSettings` for default providers — previously the override path honored `base_url` but the default path didn't, so operators pointing at OpenAI-compatible proxies via `LLM__OPENAI_BASE_URL` were ignored (#643, fixes #641) -- Internal N+1 query in dialectic agent tool execution (DEV-1721) — collapsed per-iteration DB lookups into a single fetch (#652) +- Internal N+1 query in dialectic agent tool execution — collapsed per-iteration DB lookups into a single fetch (#652) - Dreamer threshold and time-guard semantics: `check_and_schedule_dream` count filter now includes only `documents.level == 'explicit'` (dreamer-created levels are output, not input, and were inflating the threshold and creating a feedback loop); `last_dream_at` write relocated from `enqueue_dream` into `process_dream` so duplicate enqueues or failed runs no longer reset the 8-hour time guard (#573) - Deriver: blank observations are filtered out before embedding (previously triggered noisy embedding calls and persisted empty rows); blank-observation filtering unified across tool paths (#615) - Surprisal module: filter for level observations changed from `{"level": levels}` to `{"level": {"in": levels}}` — `apply_filter()` requires operator syntax, so the prior call silently returned 0 results and made the entire Surprisal phase of the Dream cycle a no-op (#581, fixes #559) diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py index 29c0f445..f5148ee6 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -499,6 +499,79 @@ class HonchoAio(AsyncMetadataConfigMixin): """Delete a workspace asynchronously.""" await self._honcho._async_http_client.delete(routes.workspace(workspace_id)) + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def chat( + self, + query: str = Field(..., min_length=1, description="The natural language query"), + *, + session: str | SessionBase | None = None, + reasoning_level: Literal["minimal", "low", "medium", "high", "max"] + | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + scope: str | list[str] | None = None, + ) -> BaseModel | str | None: + """Query the entire workspace asynchronously (see Honcho.chat).""" + await self._honcho._ensure_workspace_async() + resolved_session_id = resolve_id(session) + body: dict[str, Any] = {"query": query, "stream": False} + if resolved_session_id: + body["session_id"] = resolved_session_id + if reasoning_level: + body["reasoning_level"] = reasoning_level + if scope is not None: + body["scope"] = scope + response_format_schema = serialize_response_format(response_format) + if response_format_schema is not None: + body["response_format"] = response_format_schema + + data = await self._honcho._async_http_client.post( + routes.workspace_chat(self._honcho.workspace_id), + body=body, + ) + 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)) + async def chat_stream( + self, + query: str = Field(..., min_length=1, description="The natural language query"), + *, + session: str | SessionBase | None = None, + reasoning_level: Literal["minimal", "low", "medium", "high", "max"] + | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + scope: str | list[str] | None = None, + ) -> AsyncDialecticStreamResponse: + """Streaming variant of :meth:`chat` (async).""" + await self._honcho._ensure_workspace_async() + resolved_session_id = resolve_id(session) + body: dict[str, Any] = {"query": query, "stream": True} + if resolved_session_id: + body["session_id"] = resolved_session_id + if reasoning_level: + body["reasoning_level"] = reasoning_level + if scope is not None: + body["scope"] = scope + 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 chunk in parse_sse_astream( + self._honcho._async_http_client.stream( + "POST", + routes.workspace_chat(self._honcho.workspace_id), + body=body, + ) + ): + yield chunk + + return AsyncDialecticStreamResponse(stream_response()) + @validate_call async def search( self, diff --git a/sdks/python/src/honcho/client.py b/sdks/python/src/honcho/client.py index 1527792e..dbee9478 100644 --- a/sdks/python/src/honcho/client.py +++ b/sdks/python/src/honcho/client.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging import os -from collections.abc import Mapping, Sequence +from collections.abc import Generator, Mapping, Sequence from typing import Any, Literal import httpx @@ -28,10 +28,16 @@ from .http import AsyncHonchoHTTPClient, HonchoHTTPClient, routes from .message import Message from .mixins import MetadataConfigMixin from .pagination import SyncPage -from .peer import Peer +from .peer import Peer, serialize_response_format from .scope import Scope from .session import Session -from .utils import normalize_peers_to_dict, resolve_id, validate_scope_id +from .types import DialecticStreamResponse +from .utils import ( + normalize_peers_to_dict, + parse_sse_stream, + resolve_id, + validate_scope_id, +) logger = logging.getLogger(__name__) @@ -686,6 +692,98 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul """ self._http.delete(routes.workspace(workspace_id)) + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + def chat( + self, + query: str = Field(..., min_length=1, description="The natural language query"), + *, + session: str | SessionBase | None = None, + reasoning_level: Literal["minimal", "low", "medium", "high", "max"] + | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + scope: str | list[str] | None = None, + ) -> BaseModel | str | None: + """ + Query the entire workspace with a natural language question. + + Unlike peer.chat(), which queries a single peer's representation, this + searches across ALL peers and observations in the workspace — use it + for cross-peer analysis, common themes, or workspace-wide questions. + + Args: + query: The natural language question to ask. + session: Optional session to scope message retrieval to. + reasoning_level: Optional reasoning level: "minimal", "low", + "medium", "high", or "max" (default "low"). + response_format: Optional structure for the answer: a Pydantic + model class (returns a parsed instance) or a raw + JSON Schema dict (returns a JSON string). + scope: Optional scope name(s) restricting recall to those scopes' + member sessions. Mutually exclusive with `session`. + + Returns: + The synthesized answer, or None if no relevant information. + """ + self._ensure_workspace() + resolved_session_id = resolve_id(session) + body: dict[str, Any] = {"query": query, "stream": False} + if resolved_session_id: + body["session_id"] = resolved_session_id + if reasoning_level: + body["reasoning_level"] = reasoning_level + if scope is not None: + body["scope"] = scope + response_format_schema = serialize_response_format(response_format) + if response_format_schema is not None: + body["response_format"] = response_format_schema + + data = self._http.post( + routes.workspace_chat(self.workspace_id), + body=body, + ) + 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)) + def chat_stream( + self, + query: str = Field(..., min_length=1, description="The natural language query"), + *, + session: str | SessionBase | None = None, + reasoning_level: Literal["minimal", "low", "medium", "high", "max"] + | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + scope: str | list[str] | None = None, + ) -> DialecticStreamResponse: + """Streaming variant of :meth:`chat`. See chat() for argument docs.""" + self._ensure_workspace() + resolved_session_id = resolve_id(session) + body: dict[str, Any] = {"query": query, "stream": True} + if resolved_session_id: + body["session_id"] = resolved_session_id + if reasoning_level: + body["reasoning_level"] = reasoning_level + if scope is not None: + body["scope"] = scope + 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( + self._http.stream( + "POST", + routes.workspace_chat(self.workspace_id), + body=body, + ) + ) + + return DialecticStreamResponse(stream_response()) + @validate_call def search( self, diff --git a/sdks/python/src/honcho/http/routes.py b/sdks/python/src/honcho/http/routes.py index 8f9ee2fb..8295b994 100644 --- a/sdks/python/src/honcho/http/routes.py +++ b/sdks/python/src/honcho/http/routes.py @@ -16,6 +16,10 @@ def workspace(workspace_id: str) -> str: return f"/{API_VERSION}/workspaces/{workspace_id}" +def workspace_chat(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/chat" + + def workspace_search(workspace_id: str) -> str: return f"/{API_VERSION}/workspaces/{workspace_id}/search" diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 2b66b9c2..96cf57c8 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -1,5 +1,9 @@ import { API_VERSION } from './api-version' import { HonchoHTTPClient } from './http/client' +import { + createDialecticStream, + type DialecticStreamResponse, +} from './http/streaming' import { Message } from './message' import { Page } from './pagination' import { Peer } from './peer' @@ -14,6 +18,8 @@ import type { QueueStatusResponse, ScopeResponse, SessionResponse, + WorkspaceChatParams, + WorkspaceChatResponse, WorkspaceResponse, } from './types/api' import { resolveId, transformQueueStatus } from './utils' @@ -53,6 +59,7 @@ import { } from './validation' const DEFAULT_BASE_URL = 'https://api.honcho.dev' +type ReasoningLevel = 'minimal' | 'low' | 'medium' | 'high' | 'max' /** * Main client for the Honcho TypeScript SDK. @@ -401,6 +408,34 @@ export class Honcho { ) } + private async _workspaceChat( + workspaceId: string, + params: WorkspaceChatParams + ): Promise { + await this._ensureWorkspace() + return this._http.post( + `/${API_VERSION}/workspaces/${workspaceId}/chat`, + { body: params } + ) + } + + private async _workspaceChatStream( + workspaceId: string, + params: Omit + ): Promise { + await this._ensureWorkspace() + return this._http.stream( + 'POST', + `/${API_VERSION}/workspaces/${workspaceId}/chat`, + { + body: { + ...params, + stream: true, + }, + } + ) + } + // =========================================================================== // Public Methods // =========================================================================== @@ -948,6 +983,106 @@ export class Honcho { return response.map(Message.fromApiResponse) } + /** + * Query the workspace's collective knowledge using natural language. + * + * Performs agentic search and reasoning across ALL peers and observations + * in the workspace to synthesize a comprehensive answer. Useful for + * cross-peer analysis, discovering common themes, and workspace-wide queries. + * + * @param query - The natural language question to ask + * @param options.session - Optional session to scope message search to. Can be a session + * ID string or a Session object. + * @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", + * "medium", "high", or "max". Defaults to "low" if not provided. + * @param options.responseFormat - Optional JSON Schema (root type "object") the response + * must conform to. When provided, the response content is a + * JSON string matching this schema. + * @returns Promise resolving to the response string, or null if no relevant information + * + * @example + * ```typescript + * const response = await honcho.chat('What are common themes across all users?') + * ``` + */ + async chat( + query: string, + options?: { + session?: string | Session + reasoningLevel?: ReasoningLevel + responseFormat?: Record + scope?: string | string[] + } + ): Promise { + const validatedQuery = SearchQuerySchema.parse(query) + const resolvedSessionId = options?.session + ? resolveId(options.session) + : undefined + + const response = await this._workspaceChat(this.workspaceId, { + query: validatedQuery, + stream: false, + session_id: resolvedSessionId, + reasoning_level: options?.reasoningLevel, + response_format: options?.responseFormat, + scope: options?.scope, + }) + if (!response.content) { + return null + } + return response.content + } + + /** + * Query the workspace's collective knowledge with streaming response. + * + * Performs agentic search and reasoning across ALL peers and observations + * in the workspace to synthesize a comprehensive answer, streaming the + * response as it is generated. + * + * @param query - The natural language question to ask + * @param options.session - Optional session to scope message search to. Can be a session + * ID string or a Session object. + * @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", + * "medium", "high", or "max". Defaults to "low" if not provided. + * @param options.responseFormat - Optional JSON Schema (root type "object") the response + * must conform to. When provided, the response content is a + * JSON string matching this schema. + * @returns Promise resolving to a DialecticStreamResponse that can be iterated over + * + * @example + * ```typescript + * const stream = await honcho.chatStream('What do all peers have in common?') + * for await (const chunk of stream) { + * process.stdout.write(chunk) + * } + * ``` + */ + async chatStream( + query: string, + options?: { + session?: string | Session + reasoningLevel?: ReasoningLevel + responseFormat?: Record + scope?: string | string[] + } + ): Promise { + const validatedQuery = SearchQuerySchema.parse(query) + const resolvedSessionId = options?.session + ? resolveId(options.session) + : undefined + + const response = await this._workspaceChatStream(this.workspaceId, { + query: validatedQuery, + session_id: resolvedSessionId, + reasoning_level: options?.reasoningLevel, + response_format: options?.responseFormat, + scope: options?.scope, + }) + + return createDialecticStream(response) + } + /** * Get the queue processing status, optionally scoped to an observer, sender, and/or session. * diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 24ba57b4..509ba645 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -69,6 +69,7 @@ export type { SessionResponse, SessionSummariesResponse, SummaryResponse, + WorkspaceChatResponse, WorkspaceResponse, } from './types/api' diff --git a/sdks/typescript/src/types/api.ts b/sdks/typescript/src/types/api.ts index 10ed6037..c5855713 100644 --- a/sdks/typescript/src/types/api.ts +++ b/sdks/typescript/src/types/api.ts @@ -81,6 +81,19 @@ export interface PeerChatResponse { content: string | null } +export interface WorkspaceChatParams { + query: string + stream?: boolean + session_id?: string + reasoning_level?: 'minimal' | 'low' | 'medium' | 'high' | 'max' + response_format?: Record + scope?: string | string[] +} + +export interface WorkspaceChatResponse { + content: string | null +} + export interface PeerRepresentationParams { session_id?: string target?: string diff --git a/src/crud/__init__.py b/src/crud/__init__.py index 38be14da..0e920717 100644 --- a/src/crud/__init__.py +++ b/src/crud/__init__.py @@ -58,6 +58,7 @@ from .scope import ( invalidate_scope_peer_cache, remove_session_from_scope, resolve_scope_peers, + resolve_scope_session_union, update_scope_backfill_status, ) from .session import ( @@ -81,16 +82,24 @@ from .webhook import ( list_webhook_endpoints, ) from .workspace import ( + ActivePeer, WorkspaceDeletionResult, + WorkspaceStats, check_no_active_sessions, delete_workspace, + get_active_peers, get_all_workspaces, get_or_create_workspace, get_workspace, + get_workspace_stats, update_workspace, ) __all__ = [ + "get_workspace_stats", + "get_active_peers", + "WorkspaceStats", + "ActivePeer", # Collection "get_collection", "get_or_create_collection", @@ -150,6 +159,7 @@ __all__ = [ "invalidate_scope_peer_cache", "remove_session_from_scope", "resolve_scope_peers", + "resolve_scope_session_union", "update_scope_backfill_status", # Session "SessionDeletionResult", diff --git a/src/crud/scope.py b/src/crud/scope.py index d5b92c31..0fc8e41f 100644 --- a/src/crud/scope.py +++ b/src/crud/scope.py @@ -319,6 +319,26 @@ async def resolve_scope_peers( return resolved +async def resolve_scope_session_union( + db: AsyncSession, + workspace_name: str, + scope_names: Sequence[str], +) -> list[str]: + """Return the union of member sessions across the given scopes.""" + from src.crud.message import get_peer_session_names + + union: list[str] = [] + seen: set[str] = set() + for scope_peer in await resolve_scope_peers(db, workspace_name, scope_names): + for session_name in await get_peer_session_names( + db, workspace_name, scope_peer + ): + if session_name not in seen: + seen.add(session_name) + union.append(session_name) + return union + + async def get_scope_sessions( workspace_name: str, scope_name: str, diff --git a/src/crud/workspace.py b/src/crud/workspace.py index c9040f55..a5042acd 100644 --- a/src/crud/workspace.py +++ b/src/crud/workspace.py @@ -1,6 +1,8 @@ """CRUD helpers for workspace records and workspace deletion checks.""" +from collections.abc import Sequence from dataclasses import dataclass +from datetime import datetime, timedelta, timezone from logging import getLogger from typing import Any @@ -535,3 +537,206 @@ async def delete_workspace( messages_deleted=messages_count, conclusions_deleted=conclusions_count, ) + + +@dataclass +class WorkspaceStats: + """Workspace-level aggregate statistics.""" + + peer_count: int + session_count: int + message_count: int + oldest_message_at: datetime | None + newest_message_at: datetime | None + + +@dataclass +class ActivePeer: + """A peer with activity metrics.""" + + name: str + message_count: int + last_message_at: datetime | None + + +async def get_workspace_stats( + db: AsyncSession, + workspace_name: str, + session_names: Sequence[str] | None = None, +) -> WorkspaceStats: + """Get aggregate statistics for a workspace. + + Scope peers are excluded from ``peer_count``. When ``session_names`` is + provided, counts are restricted to that allowlist (empty → zeros). + """ + from src.crud.peer import scope_peer_clause + + if session_names is not None and not session_names: + return WorkspaceStats( + peer_count=0, + session_count=0, + message_count=0, + oldest_message_at=None, + newest_message_at=None, + ) + + msg_filters = [models.Message.workspace_name == workspace_name] + if session_names is not None: + msg_filters.append(models.Message.session_name.in_(session_names)) + peer_count = int( + await db.scalar( + select(func.count(func.distinct(models.Message.peer_name))) + .select_from(models.Message) + .join( + models.Peer, + (models.Peer.workspace_name == models.Message.workspace_name) + & (models.Peer.name == models.Message.peer_name), + ) + .where(*msg_filters, ~scope_peer_clause()) + ) + or 0 + ) + session_count = int( + await db.scalar( + select(func.count(models.Session.id)).where( + models.Session.workspace_name == workspace_name, + models.Session.name.in_(session_names), + ) + ) + or 0 + ) + else: + peer_count = int( + await db.scalar( + select(func.count(models.Peer.id)).where( + models.Peer.workspace_name == workspace_name, + ~scope_peer_clause(), + ) + ) + or 0 + ) + session_count = int( + await db.scalar( + select(func.count(models.Session.id)).where( + models.Session.workspace_name == workspace_name + ) + ) + or 0 + ) + + msg_row = ( + await db.execute( + select( + func.count(models.Message.id), + func.min(models.Message.created_at), + func.max(models.Message.created_at), + ).where(*msg_filters) + ) + ).one() + message_count = int(msg_row[0] or 0) + oldest_message_at = msg_row[1] + newest_message_at = msg_row[2] + + return WorkspaceStats( + peer_count=peer_count, + session_count=session_count, + message_count=message_count, + oldest_message_at=oldest_message_at, + newest_message_at=newest_message_at, + ) + + +# Activity window for get_active_peers. Bounds the per-peer aggregation +# (which runs on the workspace-chat request path) so it never scans a large +# workspace's full message history; peers idle longer than this still appear +# via the Peer outer join, with zero count and no last-active date. +ACTIVE_PEER_WINDOW_DAYS = 90 + + +async def get_active_peers( + db: AsyncSession, + workspace_name: str, + limit: int = 20, + sort_by: str = "recent_activity", + session_names: Sequence[str] | None = None, +) -> list[ActivePeer]: + """Get the most active peers in a workspace. + + Activity is measured over the trailing ACTIVE_PEER_WINDOW_DAYS days. + Scope peers are excluded. When ``session_names`` is provided, only peers + with messages in that allowlist are returned (empty → no peers). + """ + from src.crud.peer import scope_peer_clause + + if limit <= 0: + return [] + if session_names is not None and not session_names: + return [] + limit = min(limit, 50) + + window_start = datetime.now(timezone.utc) - timedelta(days=ACTIVE_PEER_WINDOW_DAYS) + + msg_filters = [ + models.Message.workspace_name == workspace_name, + models.Message.created_at >= window_start, + ] + if session_names is not None: + msg_filters.append(models.Message.session_name.in_(session_names)) + + # Subquery: aggregate messages per peer within the activity window + subq = ( + select( + models.Message.peer_name, + func.count(models.Message.id).label("msg_count"), + func.max(models.Message.created_at).label("last_msg_at"), + ) + .where(*msg_filters) + .group_by(models.Message.peer_name) + .subquery() + ) + + columns = ( + models.Peer.name, + func.coalesce(subq.c.msg_count, 0).label("msg_count"), + subq.c.last_msg_at, + ) + if session_names is not None: + stmt = ( + select(*columns) + .join(subq, models.Peer.name == subq.c.peer_name) + .where( + models.Peer.workspace_name == workspace_name, + ~scope_peer_clause(), + ) + ) + else: + stmt = ( + select(*columns) + .outerjoin(subq, models.Peer.name == subq.c.peer_name) + .where( + models.Peer.workspace_name == workspace_name, + ~scope_peer_clause(), + ) + ) + + # Peer name as secondary key so ties (notably all-NULL activity in young + # workspaces) return a stable order across calls. + if sort_by == "message_count": + stmt = stmt.order_by( + func.coalesce(subq.c.msg_count, 0).desc(), models.Peer.name + ) + else: + # Default: recent_activity — peers with most recent messages first + stmt = stmt.order_by(subq.c.last_msg_at.desc().nulls_last(), models.Peer.name) + + stmt = stmt.limit(limit) + + rows = (await db.execute(stmt)).all() + return [ + ActivePeer( + name=row[0], + message_count=int(row[1]), + last_message_at=row[2], + ) + for row in rows + ] diff --git a/src/dialectic/chat.py b/src/dialectic/chat.py index ba066741..47f8dff7 100644 --- a/src/dialectic/chat.py +++ b/src/dialectic/chat.py @@ -14,6 +14,7 @@ from src import crud, models from src.config import ReasoningLevel from src.dependencies import tracked_db from src.dialectic.core import DialecticAgent +from src.dialectic.workspace import WorkspaceDialecticAgent from src.exceptions import ValidationException from src.utils.config_helpers import get_configuration from src.utils.scopes import is_scope_peer @@ -205,3 +206,61 @@ async def agentic_chat_stream( async for chunk in agent.answer_stream(query, response_model=response_model): yield chunk + + +async def workspace_chat( + workspace_name: str, + session_name: str | None, + query: str, + reasoning_level: ReasoningLevel = "low", + response_model: type[BaseModel] | None = None, + session_allowlist: list[str] | None = None, +) -> str: + """Answer a query across all peers in a workspace.""" + async with tracked_db("dialectic.workspace_preflight", read_only=True) as db: + await crud.get_workspace(db, workspace_name=workspace_name) + session = None + if session_name: + session = await crud.get_session( + db, workspace_name=workspace_name, session_name=session_name + ) + session_id = session.id if session else None + # DB session closed -- agent runs without holding a connection + + agent = WorkspaceDialecticAgent( + workspace_name=workspace_name, + session_name=session_name, + session_id=session_id, + reasoning_level=reasoning_level, + session_allowlist=session_allowlist, + ) + return await agent.answer(query, response_model=response_model) + + +async def workspace_chat_stream( + workspace_name: str, + session_name: str | None, + query: str, + reasoning_level: ReasoningLevel = "low", + response_model: type[BaseModel] | None = None, + session_allowlist: list[str] | None = None, +) -> AsyncIterator[str]: + """Streaming variant of :func:`workspace_chat`.""" + async with tracked_db("dialectic.workspace_preflight", read_only=True) as db: + await crud.get_workspace(db, workspace_name=workspace_name) + session = None + if session_name: + session = await crud.get_session( + db, workspace_name=workspace_name, session_name=session_name + ) + session_id = session.id if session else None + + agent = WorkspaceDialecticAgent( + workspace_name=workspace_name, + session_name=session_name, + session_id=session_id, + reasoning_level=reasoning_level, + session_allowlist=session_allowlist, + ) + 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 9580b94c..786866e1 100644 --- a/src/dialectic/core.py +++ b/src/dialectic/core.py @@ -304,8 +304,7 @@ class DialecticAgent: user_content = ( f"Query: {query}\n\n" f"## Relevant Observations (prefetched)\n" - f"The following observations were found to be semantically relevant to your query. " - f"Use these as primary context. You may still use tools to find additional information if needed.\n\n" + f"{self._prefetch_intro()}\n\n" f"{prefetched_observations}" ) accumulate_metric( @@ -318,7 +317,14 @@ class DialecticAgent: tool_executor: Callable[ [str, dict[str, Any]], Any - ] = await create_tool_executor( + ] = await self._create_tool_executor() + + return tool_executor, task_name, run_id, start_time + + async def _create_tool_executor(self) -> Callable[[str, dict[str, Any]], Any]: + """Build the tool executor. Subclasses override to change tool scoping + (e.g. WorkspaceDialecticAgent uses the workspace executor).""" + return await create_tool_executor( workspace_name=self.workspace_name, session_name=self.session_name, session_allowlist=self.session_allowlist, @@ -330,7 +336,12 @@ class DialecticAgent: parent_category="dialectic", ) - return tool_executor, task_name, run_id, start_time + def _prefetch_intro(self) -> str: + """Sentence introducing the prefetched block in the user message.""" + return ( + "The following observations were found to be semantically relevant to your query. " + "Use these as primary context. You may still use tools to find additional information if needed." + ) def _telemetry_context(self, track_name: str | None = None) -> LLMTelemetryContext: """Build the LLMTelemetryContext shared by answer() and answer_stream(). diff --git a/src/dialectic/prompts.py b/src/dialectic/prompts.py index 948bff70..7f98a0e1 100644 --- a/src/dialectic/prompts.py +++ b/src/dialectic/prompts.py @@ -235,3 +235,77 @@ After gathering context, reason through the information you found *before* stati Do not explain your tool usage - just provide the synthesized answer. """ + + +def workspace_agent_system_prompt() -> str: + """ + Generate the system prompt for the workspace-level dialectic agent. + + Uses an analytics-first approach: stats -> message search -> targeted + observations to discover relevant peers rather than listing all of them. + + Returns: + Formatted system prompt string for the workspace agent + """ + return """ +You are a workspace-level analysis agent that can query memory across ALL peers in this workspace. You can synthesize information from any peer relationship's stored conclusions, insights, and conversation history. + +You do not start anchored to any single peer: discover which peers are relevant first, then query each peer relationship individually to search, compare, and correlate information across them. + +## AVAILABLE TOOLS + +**Discovery Tools:** +- `get_workspace_stats`: Get workspace-level counts (peers, sessions, messages), date range, and the most active peers. Use this to orient yourself and discover which peers are relevant. + +**Memory Tools (read):** +- `search_memory`: **(PRIMARY TOOL)** Semantic search within a specific peer representation. **Requires `observer` and `observed` parameters.** For a peer's global representation (where most information lives), set observer and observed to the **same** peer name. Only use different observer/observed when seeking one peer's specific understanding of another. +- `get_peer_card`: Get biographical summary for a specific peer relationship. Requires `observer` and `observed` parameters. For a peer's self-representation, use the same name for both. +- `get_reasoning_chain`: Traverse the reasoning tree for any conclusion. Shows premises and derived insights. + +**Conversation Tools (read):** +- `search_messages`: Semantic search over messages across all sessions. Messages include peer_name, so results reveal which peers discussed a topic. +- `grep_messages`: Exact text search across all messages. +- `get_observation_context`: Get messages surrounding specific conclusions. +- `get_messages_by_date_range`: Get messages within a specific time period. +- `search_messages_temporal`: Semantic search with date filtering. + +## WORKFLOW + +1. **Orient yourself**: Workspace stats and the most active peers are provided in your query context. Use `get_workspace_stats` if you need to refresh them, or go straight to message/memory search if the query names specific peers. + +2. **Discover relevant peers through search**: Use `search_messages` or `grep_messages` to find which peers have discussed the topic. Message results include peer names, making them a powerful discovery layer. + +3. **Drill into specific peer representations**: Once you know which peers are relevant, use `search_memory(observer=peer, observed=peer, query=...)` to search their global representation. + - For cross-peer questions, call `search_memory` for each relevant peer's global representation + - Only use different observer/observed when seeking one peer's specific understanding of another + +4. **ALWAYS ATTRIBUTE INFORMATION**: When presenting findings, always indicate which peer the information came from. Example: "According to insights about Alice, she..." or "Bob mentioned that..." + +5. **Cross-peer synthesis**: When asked about patterns or commonalities: + - Search each relevant peer pair individually + - Compare findings across peers explicitly + - Note both similarities and differences + +6. **Synthesize your response**: + - Directly answer the query + - Ground your response in specific information you gathered + - Always attribute information to the specific peer it came from + - For aggregation questions, enumerate findings per peer + +## CRITICAL: NEVER FABRICATE INFORMATION + +- Only state what you found in the memory system +- If you find context but not the specific answer, say what you know and what you don't +- A confident "I don't have information about X" is always correct +- Never invent details or guess + +## CRITICAL: ATTRIBUTION + +Every piece of information you share must be attributed to the peer it came from. Never present information without indicating its source peer. This is essential for workspace-level queries where information spans multiple peers. + +Do not explain your tool usage - just provide the synthesized answer. + +## OBSERVATION LEVELS + +Observations carry a level: `explicit` observations are derived per-session (session-pure), while higher-level observations (deductive/inductive, produced in dreaming) consolidate across sessions. When synthesizing cross-session or cross-peer answers, prefer higher-level observations and use `get_reasoning_chain` to ground them in their premises. +""" diff --git a/src/dialectic/workspace.py b/src/dialectic/workspace.py new file mode 100644 index 00000000..7096add6 --- /dev/null +++ b/src/dialectic/workspace.py @@ -0,0 +1,180 @@ +"""Workspace-level dialectic agent. + +Answers queries across ALL peers in a workspace. Where DialecticAgent is +bound to a single (observer, observed) pair, this agent routes first — +workspace stats, active peers, and peer cards are prefetched for +orientation; message search is workspace-flat and reveals which peers +discussed a topic — and then recalls through the same pair-scoped +observation machinery, supplying the pair as tool arguments. + +Observation search deliberately stays pair-scoped: it matches both the +(observer, observed) collection ownership and the per-pair vector-store +namespaces, and avoids retrieval dilution from a workspace-flat top-k. + +Design carried over from plastic-labs/honcho#373 (Dan), re-grown on the +current DialecticAgent seams instead of a base-class extraction. +""" + +import logging +from collections.abc import Callable +from typing import Any + +from src import crud +from src.config import ReasoningLevel, settings +from src.dependencies import tracked_db +from src.dialectic import prompts +from src.dialectic.core import DialecticAgent +from src.llm.types import LLMTelemetryContext +from src.utils.agent_tools import ( + WORKSPACE_DIALECTIC_TOOLS, + WORKSPACE_TOOLS_MINIMAL, + create_workspace_tool_executor, + format_workspace_stats, +) + +logger = logging.getLogger(__name__) + +# How many active peers (with their self peer cards) to inject at prefetch. +# Routing-obvious queries should resolve without a discovery tool round — +# each avoided tool round is a full model turn (~1.3s measured). +_PREFETCH_ACTIVE_PEERS = 5 + + +class WorkspaceDialecticAgent(DialecticAgent): + """Dialectic agent scoped to a whole workspace instead of a peer pair.""" + + def __init__( + self, + workspace_name: str, + session_name: str | None = None, + metric_key: str | None = None, + reasoning_level: ReasoningLevel = "low", + session_id: str | None = None, + session_allowlist: list[str] | None = None, + ) -> None: + super().__init__( + workspace_name=workspace_name, + session_name=session_name, + observer="", + observed="", + metric_key=metric_key, + reasoning_level=reasoning_level, + session_id=session_id, + session_allowlist=session_allowlist, + ) + # Replace the pair-oriented system prompt with the workspace one. + self.messages[0] = { + "role": "system", + "content": prompts.workspace_agent_system_prompt(), + } + + # ------------------------------------------------------------------ + # DialecticAgent seams + # ------------------------------------------------------------------ + + async def _prefetch_relevant_observations(self, query: str) -> str | None: + """Orientation + routing prefetch: stats, active peers, peer cards. + + No semantic retrieval here — a workspace-flat observation top-k + would be dominated by the most verbose peers. Instead give the + agent what it needs to ROUTE: who is here, who is active, and what + is known about them at a glance. + """ + _ = query + # Like the base agent, prefetch failure degrades to no prefetched + # block rather than failing the whole request (the caller in + # _prepare_query does not guard this). + try: + async with tracked_db("dialectic.workspace_prefetch", read_only=True) as db: + stats = await crud.get_workspace_stats( + db, + self.workspace_name, + session_names=self.session_allowlist, + ) + if stats.peer_count == 0: + return None + peers = await crud.get_active_peers( + db, + self.workspace_name, + limit=_PREFETCH_ACTIVE_PEERS, + session_names=self.session_allowlist, + ) + # `peers` is already allowlist-filtered, but a peer card is a + # single cross-session aggregate: an in-scope peer's card can + # still carry facts derived from sessions outside the scope. + # Drop cards entirely under an allowlist — same rule the + # get_peer_card tool enforces — and route on stats alone. + cards: dict[str, list[str]] = {} + if self.session_allowlist is None: + for peer in peers: + card = await crud.get_peer_card( + db, + workspace_name=self.workspace_name, + observer=peer.name, + observed=peer.name, + ) + if card: + cards[peer.name] = card + except Exception: + logger.warning( + "Failed to prefetch workspace overview for workspace=%s", + self.workspace_name, + exc_info=True, + ) + return None + + return format_workspace_stats(stats, peers, cards) + + def _prefetch_intro(self) -> str: + return ( + "Workspace overview and most-active peers with any known " + "biographical facts. Use this to route: query a specific peer's " + "memory with search_memory (observer and observed set to that " + "peer's name), or use search_messages / get_workspace_stats to " + "discover peers this overview does not cover." + ) + + def _select_tools(self) -> list[dict[str, Any]]: + tools = ( + WORKSPACE_TOOLS_MINIMAL + if self.reasoning_level == "minimal" + else WORKSPACE_DIALECTIC_TOOLS + ) + # Mirror the base agent's allowlist rule, for both tools that cannot + # honor an allowlist: reasoning chains traverse provenance across + # sessions, and a peer card is one cross-session aggregate with no + # per-session attribution. Both fail closed in their handlers too; + # dropping them here avoids paying the schema tokens and a wasted + # turn on a tool that can only refuse. + if self.session_allowlist is not None: + unscopable = {"get_reasoning_chain", "get_peer_card"} + tools = [t for t in tools if t.get("name") not in unscopable] + return tools + + async def _create_tool_executor(self) -> Callable[[str, dict[str, Any]], Any]: + return await create_workspace_tool_executor( + workspace_name=self.workspace_name, + session_name=self.session_name, + session_allowlist=self.session_allowlist, + history_token_limit=settings.DIALECTIC.HISTORY_TOKEN_LIMIT, + run_id=self._run_id, + agent_type="workspace_dialectic", + parent_category="dialectic", + ) + + # Workspace chat shares the base "dialectic_chat" Langfuse trace name; + # scope is distinguished by the agent_type/track_name below. + + def _telemetry_context(self, track_name: str | None = None) -> LLMTelemetryContext: + return LLMTelemetryContext( + workspace_name=self.workspace_name, + call_purpose="dialectic.answer", + parent_category="dialectic", + agent_type="workspace_dialectic", + run_id=self._run_id, + trace_id=self._run_id, + span_id=self._run_id, + session_id=self.session_id, + peer_name="(workspace)", + track_name=track_name or "Workspace Dialectic Agent", + ) diff --git a/src/routers/peers.py b/src/routers/peers.py index 7843081e..c5edfd4e 100644 --- a/src/routers/peers.py +++ b/src/routers/peers.py @@ -5,7 +5,6 @@ import logging from collections.abc import AsyncIterator from contextlib import suppress from time import perf_counter -from typing import Any from fastapi import APIRouter, Body, Depends, Path, Query, Response from fastapi.responses import StreamingResponse @@ -35,6 +34,7 @@ from src.utils.scopes import ( is_scope_peer, is_scope_peer_name, validate_no_scope_peer_names, + validate_scope_read_option, ) from src.utils.search import search from src.utils.types import embedding_call_purpose @@ -47,33 +47,6 @@ router = APIRouter( ) -def _validate_scope_option( - *, - filters: dict[str, Any] | None, - session_id: str | None, - jwt_params: JWTParams, -) -> None: - """Enforce the v1 `scope` exclusions and auth rule (chat/representation). - - `scope` is mutually exclusive with `filters` and `session_id` (422), and a - scope's member sessions may exceed a peer's own membership, so scoped - reads require a workspace- or admin-level key. - - 401 rather than 403: every other scope surface refuses a narrow key with 401 - — the `/scopes` router via `require_auth`, and the `scopes` field on session - create — so a peer key would otherwise get two different codes for the same - feature depending on which side of it was touched. - """ - if filters is not None: - raise ValidationException("`scope` and `filters` are mutually exclusive") - if session_id: - raise ValidationException("`scope` and `session_id` are mutually exclusive") - if jwt_params.p is not None: - raise AuthenticationException( - "`scope` requires a workspace- or admin-level key" - ) - - async def _resolve_scope_option( workspace_id: str, scope: str | list[str], @@ -95,16 +68,7 @@ async def _resolve_scope_option( ) return scope_peer, None - scope_peers = await crud.resolve_scope_peers(scope_db, workspace_id, scope) - union: list[str] = [] - seen: set[str] = set() - for scope_peer in scope_peers: - for session_name in await get_peer_session_names( - scope_db, workspace_id, scope_peer - ): - if session_name not in seen: - seen.add(session_name) - union.append(session_name) + union = await crud.resolve_scope_session_union(scope_db, workspace_id, scope) if len(union) > MAX_SESSION_ALLOWLIST_ENTRIES: raise ValidationException( @@ -316,7 +280,7 @@ async def chat( observer = peer_id scope_session_union: list[str] | None = None if options.scope is not None: - _validate_scope_option( + validate_scope_read_option( filters=options.filters, session_id=options.session_id, jwt_params=jwt_params, @@ -506,7 +470,7 @@ async def get_representation( observer = peer_id scope_session_union: list[str] | None = None if options.scope is not None: - _validate_scope_option( + validate_scope_read_option( filters=options.filters, session_id=options.session_id, jwt_params=jwt_params, diff --git a/src/routers/workspaces.py b/src/routers/workspaces.py index 42c111ad..5d449f9a 100644 --- a/src/routers/workspaces.py +++ b/src/routers/workspaces.py @@ -1,10 +1,14 @@ """FastAPI routes for workspace resources and workspace-scoped operations.""" +import json import logging +from collections.abc import AsyncIterator from fastapi import APIRouter, Body, Depends, HTTPException, 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, models, schemas @@ -12,8 +16,13 @@ from src.config import settings from src.crud.message import get_peer_session_names from src.dependencies import db, read_db, tracked_db from src.deriver.enqueue import enqueue_deletion, enqueue_dream +from src.dialectic.chat import workspace_chat, workspace_chat_stream from src.exceptions import AuthenticationException, ValidationException from src.security import JWTParams, require_auth +from src.telemetry import prometheus_metrics +from src.utils.filter import MAX_SESSION_ALLOWLIST_ENTRIES +from src.utils.schema_conversion import json_response_schema_to_pydantic +from src.utils.scopes import validate_scope_read_option from src.utils.search import search logger = logging.getLogger(__name__) @@ -276,3 +285,92 @@ async def schedule_dream( observed, request.session_id, ) + + +@router.post( + "/{workspace_id}/chat", + responses={ + 200: { + "content": { + "application/json": { + "schema": schemas.DialecticResponse.model_json_schema() + }, + "text/event-stream": {}, + }, + }, + }, +) +async def chat( + workspace_id: str = Path(...), + options: schemas.WorkspaceChatOptions = Body(...), + jwt_params: JWTParams = Depends(require_auth(workspace_name="workspace_id")), +): + """Query the entire workspace using natural language. + + Pass `scope` to restrict recall to the union of those scopes' member + sessions. A scope with no member sessions recalls nothing (fail-closed). + """ + session_allowlist: list[str] | None = None + if options.scope is not None: + validate_scope_read_option( + filters=None, + session_id=options.session_id, + jwt_params=jwt_params, + ) + names = [options.scope] if isinstance(options.scope, str) else options.scope + async with tracked_db( + "workspaces.chat.resolve_scope", read_only=True + ) as scope_db: + session_allowlist = await crud.resolve_scope_session_union( + scope_db, workspace_id, names + ) + if len(session_allowlist) > MAX_SESSION_ALLOWLIST_ENTRIES: + raise ValidationException( + "The scopes' combined membership exceeds the maximum of " + + f"{MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request" + ) + + 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 + + if settings.METRICS.ENABLED: + prometheus_metrics.record_dialectic_call( + workspace_name=workspace_id, + reasoning_level=options.reasoning_level, + ) + + if options.stream: + + async def format_sse_stream(chunks: AsyncIterator[str]) -> AsyncIterator[str]: + """Format chunks as SSE events.""" + async for chunk in chunks: + yield f"data: {json.dumps({'delta': {'content': chunk}, 'done': False})}\n\n" + yield f"data: {json.dumps({'done': True})}\n\n" + + return StreamingResponse( + format_sse_stream( + workspace_chat_stream( + workspace_name=workspace_id, + session_name=options.session_id, + query=options.query, + reasoning_level=options.reasoning_level, + response_model=response_model, + session_allowlist=session_allowlist, + ) + ), + media_type="text/event-stream", + ) + + response = await workspace_chat( + workspace_name=workspace_id, + session_name=options.session_id, + query=options.query, + reasoning_level=options.reasoning_level, + response_model=response_model, + session_allowlist=session_allowlist, + ) + return schemas.DialecticResponse(content=response if response else None) diff --git a/src/schemas/__init__.py b/src/schemas/__init__.py index b4211b99..9f414583 100644 --- a/src/schemas/__init__.py +++ b/src/schemas/__init__.py @@ -54,6 +54,7 @@ from src.schemas.api import ( WebhookEndpointCreate, Workspace, WorkspaceBase, + WorkspaceChatOptions, WorkspaceCreate, WorkspaceGet, WorkspaceMessageSearchOptions, @@ -114,6 +115,7 @@ __all__ = [ "ConclusionQuery", "DialecticOptions", "DialecticResponse", + "WorkspaceChatOptions", "DialecticStreamChunk", "DialecticStreamDelta", "Message", diff --git a/src/schemas/api.py b/src/schemas/api.py index 55a6121a..43d91b26 100644 --- a/src/schemas/api.py +++ b/src/schemas/api.py @@ -61,6 +61,15 @@ def _sanitize_value(v: Any) -> Any: return v +def _strip_nul(v: str) -> str: + """Strip NUL bytes from a string field (Postgres TEXT rejects \\x00).""" + return v.replace("\x00", "") + + +# Reusable annotation for query fields; composes with a per-field Field(...). +NulStripped = AfterValidator(_strip_nul) + + def _check_metadata_limits( data: dict[str, Any], *, @@ -717,7 +726,7 @@ class ConclusionBatchCreate(BaseModel): class MessageSearchOptions(BaseModel): - query: Annotated[str, Field(..., description="Search query")] + query: Annotated[str, Field(..., description="Search query"), NulStripped] filters: dict[str, Any] | None = Field( default=None, description="Filters to scope the search" ) @@ -728,11 +737,6 @@ class MessageSearchOptions(BaseModel): description="Number of results to return", ) - @field_validator("query", mode="after") - @classmethod - def sanitize_query(cls, v: str) -> str: - return v.replace("\x00", "") - class WorkspaceMessageSearchOptions(MessageSearchOptions): """Workspace-level message search options, extended with `scope`.""" @@ -785,7 +789,9 @@ class DialecticOptions(BaseModel): description="Optional peer to get the representation for, from the perspective of this peer", ) query: Annotated[ - str, Field(min_length=1, max_length=10000, description="Dialectic API Prompt") + str, + Field(min_length=1, max_length=10000, description="Dialectic API Prompt"), + NulStripped, ] stream: bool = False reasoning_level: ReasoningLevel = Field( @@ -803,10 +809,39 @@ class DialecticOptions(BaseModel): ), ) - @field_validator("query", mode="after") - @classmethod - def sanitize_query(cls, v: str) -> str: - return v.replace("\x00", "") + +class WorkspaceChatOptions(BaseModel): + """Options for workspace-level chat (no anchor peer; see DialecticOptions).""" + + session_id: str | None = Field( + None, description="Optional session to scope message tools to" + ) + query: Annotated[ + str, + Field(min_length=1, max_length=10000, description="Workspace chat prompt"), + NulStripped, + ] + stream: bool = False + reasoning_level: ReasoningLevel = Field( + 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." + ), + ) + scope: _ScopeOption | None = Field( + None, + description=( + "Optional (unprefixed) scope name(s) restricting recall to the " + "union of the scopes' member sessions (explicit allowlist, " + "fail-closed: an empty union recalls nothing). Mutually exclusive " + "with `session_id`. Requires a workspace- or admin-level key." + ), + ) class DialecticResponse(BaseModel): diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index b8af9eee..0dbdd304 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -2,7 +2,7 @@ import asyncio import logging import weakref from collections.abc import Callable, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime from typing import Any, cast @@ -14,6 +14,7 @@ from src import crud, models, schemas from src.config import settings from src.dependencies import tracked_db from src.embedding_client import embedding_client +from src.exceptions import ResourceNotFoundException from src.models import Document from src.schemas import ResolvedConfiguration from src.telemetry.events import ( @@ -785,6 +786,59 @@ TOOLS: dict[str, dict[str, Any]] = { "required": ["observation_id"], }, }, + "search_memory_workspace": { + "name": "search_memory", + "description": "Search within a specific peer representation's memory using semantic similarity. You MUST specify observer and observed. To get a peer's global representation, set observer AND observed to the SAME peer name (this is where most information lives). Only use different observer/observed when seeking one peer's specific understanding of another.", + "input_schema": { + "type": "object", + "properties": { + "observer": { + "type": "string", + "description": "Name of the observer peer", + }, + "observed": { + "type": "string", + "description": "Name of the observed peer", + }, + "query": { + "type": "string", + "description": "Search query text", + }, + "top_k": { + "type": "integer", + "description": "(Optional) number of results to return (default: 20, max: 40)", + "default": 20, + }, + }, + "required": ["observer", "observed", "query"], + }, + }, + "get_workspace_stats": { + "name": "get_workspace_stats", + "description": "Get workspace-level statistics — peer count, session count, message count, date range of messages — plus the most recently active peers with their message counts and last-active timestamps. Use this to orient yourself and discover which peers are most relevant.", + "input_schema": { + "type": "object", + "properties": {}, + }, + }, + "get_peer_card_by_name": { + "name": "get_peer_card", + "description": "Get the peer card for a specific peer relationship. Specify the observer and observed peer names.", + "input_schema": { + "type": "object", + "properties": { + "observer": { + "type": "string", + "description": "Name of the observer peer", + }, + "observed": { + "type": "string", + "description": "Name of the observed peer", + }, + }, + "required": ["observer", "observed"], + }, + }, } # Tools for the dialectic agent (analysis) @@ -806,6 +860,31 @@ DIALECTIC_TOOLS_MINIMAL: list[dict[str, Any]] = [ TOOLS["search_messages"], ] +# Tools for the workspace-level dialectic agent. Observation search stays +# pair-scoped (observer/observed are TOOL ARGUMENTS the agent must supply +# after routing) -- matching both the (observer, observed) collection +# ownership and the per-pair vector-store namespaces. Message tools are +# workspace-flat and double as the routing signal (results carry peer_name). +WORKSPACE_DIALECTIC_TOOLS: list[dict[str, Any]] = [ + TOOLS["get_workspace_stats"], + TOOLS["search_memory_workspace"], + TOOLS["search_messages"], + TOOLS["get_observation_context"], + TOOLS["grep_messages"], + TOOLS["get_peer_card_by_name"], + TOOLS["get_messages_by_date_range"], + TOOLS["search_messages_temporal"], + TOOLS["get_reasoning_chain"], +] + +# Reduced workspace loadout for reasoning_level="minimal" (token cost of the +# tool definitions themselves), mirroring DIALECTIC_TOOLS_MINIMAL. +WORKSPACE_TOOLS_MINIMAL: list[dict[str, Any]] = [ + TOOLS["get_workspace_stats"], + TOOLS["search_memory_workspace"], + TOOLS["search_messages"], +] + # Tools for the dreamer agent (consolidation + peer card + deduplication) DREAMER_TOOLS: list[dict[str, Any]] = [ # Preference extraction (should be called first) @@ -1851,7 +1930,7 @@ async def _handle_search_memory( # here, we automatically search the message history for relevant # information. zero_hit_meta = {**search_meta, "results_count": 0} - if ctx.agent_type == "dialectic": + if ctx.agent_type in ("dialectic", "workspace_dialectic"): limit = min(_safe_int(tool_input.get("top_k"), 20), 20) message_output = None snippets = await crud.search_messages( @@ -1903,7 +1982,7 @@ async def _handle_get_observation_context( workspace_name=ctx.workspace_name, session_name=ctx.session_name, message_ids=tool_input["message_ids"], - observer=ctx.observer, + observer=ctx.observer or None, session_allowlist=ctx.session_allowlist, ) if not messages: @@ -1946,7 +2025,7 @@ async def _handle_search_messages( limit=limit, context_window=2, embedding=query_embedding, - observer=ctx.observer, + observer=ctx.observer or None, session_allowlist=ctx.session_allowlist, ) search_meta: dict[str, Any] = { @@ -1983,7 +2062,7 @@ async def _handle_grep_messages( text=text, limit=limit, context_window=context_window, - observer=ctx.observer, + observer=ctx.observer or None, session_allowlist=ctx.session_allowlist, ) if not snippets: @@ -2048,7 +2127,7 @@ async def _handle_get_messages_by_date_range( before_date=before_date, limit=limit, order=order, - observer=ctx.observer, + observer=ctx.observer or None, session_allowlist=ctx.session_allowlist, ) msg_count = len(messages) @@ -2124,7 +2203,7 @@ async def _handle_search_messages_temporal( context_window=context_window, session_allowlist=ctx.session_allowlist, embedding=query_embedding, - observer=ctx.observer, + observer=ctx.observer or None, ) date_filter: list[str] = [] if after_date_str: @@ -2229,6 +2308,16 @@ 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 + # A peer card lives in Peer.internal_metadata as a single cross-session + # aggregate, so it carries no session attribution and cannot be filtered + # to an allowlist. Fail closed rather than leak facts derived from + # out-of-scope sessions, the same rule get_reasoning_chain follows. + # No-op for agents that never set an allowlist (dreamer, pair dialectic). + if ctx.session_allowlist is not None: + return ( + "Peer cards are unavailable for session-scoped queries. " + "Use search_memory instead." + ) async with tracked_db("tool.get_peer_card", read_only=True) as db: peer_card = await crud.get_peer_card( db, @@ -2513,6 +2602,7 @@ async def create_tool_executor( agent_type: str | None = None, parent_category: str | None = None, session_allowlist: list[str] | None = None, + handler_resolver: Callable[[str], Any] | None = None, ) -> Callable[[str, dict[str, Any]], Any]: """ Create a unified tool executor function for all agent operations. @@ -2535,6 +2625,11 @@ async def create_tool_executor( run_id: Optional run ID for telemetry correlation agent_type: Optional agent type for telemetry (dialectic, deriver, dreamer) parent_category: Optional parent category for CloudEvents + session_allowlist: Optional list of session names message tools are + restricted to (None means no restriction) + handler_resolver: Optional callback that replaces the default + handler-table lookup for resolving tool names to handlers. + Returning None takes the "Unknown tool" path. Returns: An async callable that executes tools with the captured context @@ -2598,7 +2693,7 @@ async def create_tool_executor( tool_obs = _begin_tool_observation(tool_name, tool_input) try: - handler = _TOOL_HANDLERS.get(tool_name) + handler = (handler_resolver or _TOOL_HANDLERS.get)(tool_name) if handler: handler_result = await handler(ctx, tool_input) # Handlers return either a plain str (existing contract) or a @@ -2796,3 +2891,181 @@ def _estimate_tokens_safe(text: str | None) -> int | None: if not text: return None return _estimate_tokens(text) + + +# --------------------------------------------------------------------------- +# Workspace-level tool handlers (workspace chat) +# +# The workspace agent is not bound to an (observer, observed) pair. Handlers +# that need a pair take it from tool_input (the agent routes first, then +# supplies the pair); the rest are workspace-scoped reads. Message-search +# fallthrough handlers run with observer="" and normalize it to None at the +# crud boundary (`ctx.observer or None`) -- None means "no perspective +# scoping", which is correct for a workspace-level read. The empty string +# must never reach resolve_session_scope: it would be looked up as a real +# peer with no session memberships and deny all results. +# --------------------------------------------------------------------------- + + +async def _handle_search_memory_workspace( + ctx: ToolContext, tool_input: dict[str, Any] +) -> "str | ToolResult": + """Pair-scoped observation search; the pair comes from tool arguments.""" + observer = tool_input.get("observer", "") + observed = tool_input.get("observed", "") + if not observer or not observed: + return ( + "ERROR: 'observer' and 'observed' are required. For a peer's " + "global representation set both to the SAME peer name." + ) + pair_ctx = replace(ctx, observer=observer, observed=observed) + result = await _handle_search_memory(pair_ctx, tool_input) + # Attribute the pair in the output — the workspace agent may query + # several pairs in one turn and must not conflate their results. + if isinstance(result, ToolResult): + return replace(result, content=f"[{observer}->{observed}]\n{result.content}") + return f"[{observer}->{observed}]\n{result}" + + +async def _handle_get_peer_card_by_name( + ctx: ToolContext, tool_input: dict[str, Any] +) -> str: + """get_peer_card with the pair taken from tool arguments.""" + observer = tool_input.get("observer", "") + observed = tool_input.get("observed", "") + if not observer or not observed: + return "ERROR: 'observer' and 'observed' are required parameters" + pair_ctx = replace(ctx, observer=observer, observed=observed) + try: + return await _handle_get_peer_card(pair_ctx, tool_input) + except ResourceNotFoundException: + # The workspace agent names peers from its own routing, so guessing a + # peer that doesn't exist is an expected turn, not a fault. Answer the + # model instead of letting the executor log it as an unexpected error. + return f"No peer named '{observer}' exists in this workspace" + + +# Peers listed by get_workspace_stats. Fixed rather than a tool argument: +# folding active peers into stats keeps the tool zero-arg (one discovery +# round instead of two); deeper discovery goes through search_messages. +_STATS_ACTIVE_PEERS = 10 + + +# Peer-card facts listed per peer when cards are supplied. +_STATS_CARD_FACTS = 8 + + +def format_workspace_stats( + stats: "crud.WorkspaceStats", + peers: "Sequence[crud.ActivePeer]", + cards: dict[str, list[str]] | None = None, +) -> str: + """Render workspace counts and most-active peers as prompt-ready lines. + + Shared by the get_workspace_stats tool and WorkspaceDialecticAgent's + routing prefetch; the prefetch passes ``cards`` to nest each peer's + known biographical facts under it. + """ + lines = [ + f"Peers: {stats.peer_count}", + f"Sessions: {stats.session_count}", + f"Messages: {stats.message_count}", + ] + if stats.oldest_message_at and stats.newest_message_at: + lines.append( + f"Date range: {stats.oldest_message_at:%Y-%m-%d} to {stats.newest_message_at:%Y-%m-%d}" + ) + if peers: + lines.append("") + lines.append(f"Most active peers (top {len(peers)}):") + for peer in peers: + last_active = ( + f", last active {peer.last_message_at:%Y-%m-%d}" + if peer.last_message_at + else "" + ) + lines.append(f"- {peer.name} ({peer.message_count} messages{last_active})") + for fact in (cards or {}).get(peer.name, [])[:_STATS_CARD_FACTS]: + lines.append(f" - {fact}") + return "\n".join(lines) + + +async def _handle_get_workspace_stats( + ctx: ToolContext, tool_input: dict[str, Any] +) -> str: + """Workspace-level counts, message date range, and most active peers.""" + _ = tool_input + async with tracked_db("workspace_tool.get_workspace_stats", read_only=True) as db: + stats = await crud.get_workspace_stats( + db, ctx.workspace_name, session_names=ctx.session_allowlist + ) + peers = await crud.get_active_peers( + db, + ctx.workspace_name, + limit=_STATS_ACTIVE_PEERS, + session_names=ctx.session_allowlist, + ) + return "Workspace stats:\n" + format_workspace_stats(stats, peers) + + +# Dispatch table consulted before _TOOL_HANDLERS by the workspace executor. +_WORKSPACE_TOOL_HANDLERS: dict[str, Callable[[ToolContext, dict[str, Any]], Any]] = { + "search_memory": _handle_search_memory_workspace, + "get_workspace_stats": _handle_get_workspace_stats, + "get_peer_card": _handle_get_peer_card_by_name, + "get_reasoning_chain": _handle_get_reasoning_chain, # already workspace-scoped +} + +# Standard handlers that are safe with an empty observer/observed sentinel +# (they only read messages, treating observer="" as unscoped visibility). +_WORKSPACE_SAFE_FALLTHROUGH_TOOLS: frozenset[str] = frozenset( + { + "get_observation_context", + "search_messages", + "grep_messages", + "get_messages_by_date_range", + "search_messages_temporal", + } +) + + +def _workspace_handler_resolver(tool_name: str) -> Any: + handler = _WORKSPACE_TOOL_HANDLERS.get(tool_name) + if handler is not None: + return handler + if tool_name in _WORKSPACE_SAFE_FALLTHROUGH_TOOLS: + return _TOOL_HANDLERS.get(tool_name) + return None + + +async def create_workspace_tool_executor( + workspace_name: str, + session_name: str | None = None, + session_allowlist: list[str] | None = None, + history_token_limit: int = 8192, + run_id: str | None = None, + agent_type: str | None = None, + parent_category: str | None = None, +) -> Callable[[str, dict[str, Any]], Any]: + """Tool executor for workspace-level operations (no bound peer pair). + + Reuses create_tool_executor's telemetry/error plumbing via the + handler_resolver seam. observer/observed are empty-string sentinels only + ever seen by handlers in _WORKSPACE_SAFE_FALLTHROUGH_TOOLS, which + normalize them to None before hitting crud (None means "no perspective + scoping"; an empty string would read as a real peer with no sessions and + deny everything). + """ + return await create_tool_executor( + workspace_name=workspace_name, + observer="", + observed="", + session_name=session_name, + session_allowlist=session_allowlist, + include_observation_ids=True, + history_token_limit=history_token_limit, + run_id=run_id, + agent_type=agent_type, + parent_category=parent_category, + handler_resolver=_workspace_handler_resolver, + ) diff --git a/src/utils/scopes.py b/src/utils/scopes.py index c7b89192..5f01916b 100644 --- a/src/utils/scopes.py +++ b/src/utils/scopes.py @@ -19,7 +19,8 @@ carries a look-alike ``configuration``, is not a scope. from collections.abc import Iterable from typing import Any -from src.exceptions import ValidationException +from src.exceptions import AuthenticationException, ValidationException +from src.security import JWTParams # Reserved peer-name prefix for scope peers. User-created peers may not use it. # @@ -87,3 +88,20 @@ def validate_no_scope_peer_names(names: Iterable[str], *, action: str) -> None: f"Peer name(s) {offenders} use the reserved scope prefix " + f"'{SCOPE_PEER_PREFIX}'. {action}" ) + + +def validate_scope_read_option( + *, + filters: dict[str, Any] | None, + session_id: str | None, + jwt_params: JWTParams, +) -> None: + """Refuse `scope` combined with `filters`/`session_id`, or a peer-scoped key.""" + if filters is not None: + raise ValidationException("`scope` and `filters` are mutually exclusive") + if session_id: + raise ValidationException("`scope` and `session_id` are mutually exclusive") + if jwt_params.p is not None: + raise AuthenticationException( + "`scope` requires a workspace- or admin-level key" + ) diff --git a/tests/conftest.py b/tests/conftest.py index 8a078652..7d9e81da 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -769,6 +769,12 @@ def mock_llm_call_functions(request: pytest.FixtureRequest): patch( "src.routers.peers.agentic_chat_stream", side_effect=mock_stream ) as mock_agentic_chat_stream, + patch( + "src.routers.workspaces.workspace_chat", new_callable=AsyncMock + ) as mock_workspace_chat, + patch( + "src.routers.workspaces.workspace_chat_stream", side_effect=mock_stream + ) as mock_workspace_chat_stream, ): # Mock return values for different function types mock_short_summary.return_value = "Test short summary content" @@ -784,11 +790,20 @@ def mock_llm_call_functions(request: pytest.FixtureRequest): mock_agentic_chat.side_effect = _agentic_chat_response + async def _workspace_chat_response(*_args: object, **kwargs: object) -> str: + if kwargs.get("response_model") is not None: + return "{}" + return "Test workspace chat response" + + mock_workspace_chat.side_effect = _workspace_chat_response + yield { "short_summary": mock_short_summary, "long_summary": mock_long_summary, "agentic_chat": mock_agentic_chat, "agentic_chat_stream": mock_agentic_chat_stream, + "workspace_chat": mock_workspace_chat, + "workspace_chat_stream": mock_workspace_chat_stream, } diff --git a/tests/routes/test_scope_reads.py b/tests/routes/test_scope_reads.py index eafdecaf..23eb8d5f 100644 --- a/tests/routes/test_scope_reads.py +++ b/tests/routes/test_scope_reads.py @@ -464,6 +464,117 @@ class TestChatWithScope: assert set(kwargs["session_allowlist"]) == {session_a, session_b} +class TestWorkspaceChatWithScope: + """Workspace chat has no observer to swap: `scope` is always an allowlist.""" + + def _chat(self, client: TestClient, workspace: Workspace, body: dict[str, Any]): + return client.post( + f"/v3/workspaces/{workspace.name}/chat", + json={"query": "what do you know?", **body}, + ) + + def test_unknown_scope_404( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, _ = sample_data + assert ( + self._chat(client, workspace, {"scope": str(generate_nanoid())}).status_code + == 404 + ) + + def test_empty_scope_list_422( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, _ = sample_data + assert self._chat(client, workspace, {"scope": []}).status_code == 422 + + def test_scope_plus_session_id_422( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, _ = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + assert ( + self._chat( + client, workspace, {"scope": scope_name, "session_id": "s1"} + ).status_code + == 422 + ) + + def test_peer_scoped_jwt_401( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + monkeypatch: pytest.MonkeyPatch, + ): + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + + monkeypatch.setattr(settings.AUTH, "USE_AUTH", True) + monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret") + client.headers["Authorization"] = ( + f"Bearer {create_jwt(JWTParams(w=workspace.name, p=peer.name))}" + ) + assert self._chat(client, workspace, {"scope": scope_name}).status_code == 401 + + def test_single_scope_passes_member_allowlist( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + mock_llm_call_functions: dict[str, Any], + ): + workspace, _ = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + session_name = _create_session(client, workspace.name) + _add_sessions_to_scope(client, workspace.name, scope_name, [session_name]) + + resp = self._chat(client, workspace, {"scope": scope_name}) + assert resp.status_code == 200 + + kwargs = mock_llm_call_functions["workspace_chat"].await_args.kwargs + assert kwargs["session_allowlist"] == [session_name] + + def test_scope_list_passes_union_allowlist( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + mock_llm_call_functions: dict[str, Any], + ): + workspace, _ = sample_data + scope_a = str(generate_nanoid()) + scope_b = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_a) + _create_scope(client, workspace.name, scope_b) + session_a = _create_session(client, workspace.name) + session_b = _create_session(client, workspace.name) + _add_sessions_to_scope(client, workspace.name, scope_a, [session_a]) + _add_sessions_to_scope(client, workspace.name, scope_b, [session_b]) + + resp = self._chat(client, workspace, {"scope": [scope_a, scope_b]}) + assert resp.status_code == 200 + + kwargs = mock_llm_call_functions["workspace_chat"].await_args.kwargs + assert set(kwargs["session_allowlist"]) == {session_a, session_b} + + def test_empty_scope_fails_closed( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + mock_llm_call_functions: dict[str, Any], + ): + workspace, _ = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + + resp = self._chat(client, workspace, {"scope": scope_name}) + assert resp.status_code == 200 + + kwargs = mock_llm_call_functions["workspace_chat"].await_args.kwargs + assert kwargs["session_allowlist"] == [] + + class TestWorkspaceSearchWithScope: def _seed_message( self, client: TestClient, workspace_name: str, session_name: str, peer: Peer diff --git a/tests/test_security.py b/tests/test_security.py index 1785be8b..23725692 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,4 +1,4 @@ -"""Auth scope tests — DEV-1736 regression coverage. +"""Auth scope tests — 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`. diff --git a/tests/test_workspace_chat.py b/tests/test_workspace_chat.py new file mode 100644 index 00000000..8b73c4a7 --- /dev/null +++ b/tests/test_workspace_chat.py @@ -0,0 +1,1222 @@ +"""Integration tests for the workspace-level chat feature. + +Tests cover: +- Route-level: POST /workspaces/{workspace_id}/chat endpoint +- Tool handlers: workspace-specific tool handlers and executor +""" + +import asyncio +import json +from collections.abc import Callable +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from typing import Any + +import pytest +from nanoid import generate as generate_nanoid +from sqlalchemy.ext.asyncio import AsyncSession + +from src import crud, models +from src.dialectic.chat import workspace_chat, workspace_chat_stream +from src.models import Peer, Workspace +from src.utils.agent_tools import ( + ToolContext, + _handle_get_observation_context, # pyright: ignore[reportPrivateUsage] + _handle_get_peer_card_by_name, # pyright: ignore[reportPrivateUsage] + _handle_get_reasoning_chain, # pyright: ignore[reportPrivateUsage] + _handle_get_workspace_stats, # pyright: ignore[reportPrivateUsage] + _handle_search_memory_workspace, # pyright: ignore[reportPrivateUsage] + create_workspace_tool_executor, +) +from src.utils.scopes import SCOPE_KIND, scope_peer_name + +# ============================================================================= +# Fixtures +# ============================================================================= + + +def _tool_text(result: object) -> str: + """Unwrap ToolResult (today's handler contract) or pass through str.""" + content = getattr(result, "content", None) + return content if isinstance(content, str) else str(result) + + +@pytest.fixture +async def workspace_test_data( + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], +) -> Any: + """Create comprehensive test data with multiple peers and observations. + + Sets up a workspace with: + - 3 peers (peer1 observes peer2, peer1 observes peer3) + - 1 session with messages from all peers + - Documents (observations) across different peer pairs + """ + workspace, peer1 = sample_data + + # Create additional peers + peer2 = models.Peer(name=str(generate_nanoid()), workspace_name=workspace.name) + peer3 = models.Peer(name=str(generate_nanoid()), workspace_name=workspace.name) + db_session.add_all([peer2, peer3]) + await db_session.flush() + + # Create session + session = models.Session(name=str(generate_nanoid()), workspace_name=workspace.name) + db_session.add(session) + await db_session.flush() + + # Create collections (peer1 observes peer2, peer1 observes peer3) + collection1 = models.Collection( + workspace_name=workspace.name, + observer=peer1.name, + observed=peer2.name, + ) + collection2 = models.Collection( + workspace_name=workspace.name, + observer=peer1.name, + observed=peer3.name, + ) + db_session.add_all([collection1, collection2]) + await db_session.flush() + + # Create messages + now = datetime.now(timezone.utc) + messages: list[models.Message] = [] + for i in range(6): + peer_name = [peer1.name, peer2.name, peer3.name][i % 3] + msg = models.Message( + workspace_name=workspace.name, + session_name=session.name, + peer_name=peer_name, + content=f"Test message {i} from {peer_name}", + seq_in_session=i + 1, + token_count=10, + created_at=now - timedelta(minutes=6 - i), + ) + db_session.add(msg) + messages.append(msg) + await db_session.flush() + for msg in messages: + await db_session.refresh(msg) + + # Create documents for peer1->peer2 observations + docs_peer2: list[models.Document] = [] + for content in [ + "User likes coffee and programming", + "User works remotely from home", + ]: + doc = models.Document( + workspace_name=workspace.name, + observer=peer1.name, + observed=peer2.name, + content=content, + embedding=[0.1] * 1536, + session_name=session.name, + level="explicit", + metadata={ + "message_ids": [messages[0].id], + "message_created_at": str(messages[0].created_at), + }, + ) + db_session.add(doc) + docs_peer2.append(doc) + + # Create documents for peer1->peer3 observations + docs_peer3: list[models.Document] = [] + for content in [ + "User prefers mornings for deep work", + "User enjoys hiking on weekends", + ]: + doc = models.Document( + workspace_name=workspace.name, + observer=peer1.name, + observed=peer3.name, + content=content, + embedding=[0.2] * 1536, + session_name=session.name, + level="explicit", + metadata={ + "message_ids": [messages[1].id], + "message_created_at": str(messages[1].created_at), + }, + ) + db_session.add(doc) + docs_peer3.append(doc) + + await db_session.flush() + for doc in docs_peer2 + docs_peer3: + await db_session.refresh(doc) + + # Commit so data is visible to independent tracked_db sessions used by + # workspace-level tool handlers. + await db_session.commit() + + yield workspace, peer1, peer2, peer3, session, messages, docs_peer2, docs_peer3 + + await db_session.rollback() + + +@pytest.fixture +def make_workspace_ctx( + workspace_test_data: Any, +) -> Callable[..., ToolContext]: + """Factory fixture to create ToolContext.""" + workspace, *_ = workspace_test_data + shared_lock = asyncio.Lock() + + def _make_ctx( + *, + session_name: str | None = None, + include_observation_ids: bool = True, + session_allowlist: list[str] | None = None, + ) -> ToolContext: + return ToolContext( + observer="", + observed="", + current_messages=None, + workspace_name=workspace.name, + session_name=session_name, + include_observation_ids=include_observation_ids, + history_token_limit=8192, + db_lock=shared_lock, + session_allowlist=session_allowlist, + ) + + return _make_ctx + + +# ============================================================================= +# Route Tests: POST /workspaces/{workspace_id}/chat +# ============================================================================= + + +class TestWorkspaceChatEndpoint: + """Tests for the workspace chat API endpoint.""" + + def test_workspace_chat_basic( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + ): + """Basic non-streaming workspace chat returns DialecticResponse.""" + test_workspace, _ = sample_data + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={ + "query": "What do you know about the peers in this workspace?", + "stream": False, + }, + ) + assert response.status_code == 200 + data = response.json() + assert "content" in data + assert data["content"] == "Test workspace chat response" + + def test_workspace_chat_with_session_id( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + ): + """Workspace chat accepts optional session_id parameter.""" + test_workspace, _ = sample_data + session_id = str(generate_nanoid()) + + # Create a session first + create_response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions", + json={"name": session_id}, + ) + assert create_response.status_code in (200, 201) + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={ + "query": "Tell me about recent conversations", + "session_id": session_id, + "stream": False, + }, + ) + assert response.status_code == 200 + data = response.json() + assert "content" in data + + def test_workspace_chat_with_reasoning_level( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + ): + """Workspace chat accepts reasoning_level parameter.""" + test_workspace, _ = sample_data + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={ + "query": "Analyze common themes across all peers", + "stream": False, + "reasoning_level": "low", + }, + ) + assert response.status_code == 200 + data = response.json() + assert "content" in data + + def test_workspace_chat_streaming( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + ): + """Streaming workspace chat returns SSE-formatted events.""" + test_workspace, _ = sample_data + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={ + "query": "What patterns do you see across the workspace?", + "stream": True, + }, + ) + assert response.status_code == 200 + assert "text/event-stream" in response.headers.get("content-type", "") + + # Parse SSE events + events: list[Any] = [] + for line in response.text.strip().split("\n\n"): + if line.startswith("data: "): + event_data = json.loads(line[6:]) + events.append(event_data) + + # Should have content events and a final done event + assert len(events) >= 2 + content_events = [e for e in events if not e.get("done")] + done_events = [e for e in events if e.get("done")] + assert len(content_events) >= 1 + assert len(done_events) == 1 + + # Content events should have delta.content + for event in content_events: + assert "delta" in event + assert "content" in event["delta"] + + def test_workspace_chat_empty_query_rejected( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + ): + """Empty query should be rejected by validation.""" + test_workspace, _ = sample_data + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={ + "query": "", + "stream": False, + }, + ) + assert response.status_code == 422 + + def test_workspace_chat_missing_query_rejected( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + ): + """Missing query field should be rejected.""" + test_workspace, _ = sample_data + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={"stream": False}, + ) + assert response.status_code == 422 + + def test_workspace_chat_null_content_response( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + mock_llm_call_functions: dict[str, Any], + ): + """When workspace_chat returns None, response content should be None.""" + test_workspace, _ = sample_data + mock_llm_call_functions["workspace_chat"].side_effect = None + mock_llm_call_functions["workspace_chat"].return_value = None + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={ + "query": "Some query", + "stream": False, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["content"] is None + + def test_workspace_chat_defaults( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + ): + """Endpoint works with only the required query field.""" + test_workspace, _ = sample_data + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={"query": "Hello workspace"}, + ) + assert response.status_code == 200 + data = response.json() + assert "content" in data + + +@pytest.mark.asyncio +async def test_workspace_chat_releases_preflight_session_before_agent_answer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + active_sessions = 0 + + @asynccontextmanager + async def fake_tracked_db(_: str | None = None, **_kwargs: Any): + nonlocal active_sessions + active_sessions += 1 + try: + yield object() + finally: + active_sessions -= 1 + + async def fake_get_session(*args: Any, **kwargs: Any) -> object: + _ = (args, kwargs) + assert active_sessions == 1 + return SimpleNamespace(id="session-id") + + async def fake_answer(_self: Any, query: str, **_kwargs: Any) -> str: + assert query == "What changed?" + assert active_sessions == 0 + return "ok" + + async def fake_get_workspace(*args: Any, **kwargs: Any) -> object: + _ = (args, kwargs) + assert active_sessions == 1 + return SimpleNamespace(name="workspace") + + monkeypatch.setattr("src.dialectic.chat.tracked_db", fake_tracked_db) + monkeypatch.setattr("src.dialectic.chat.crud.get_workspace", fake_get_workspace) + monkeypatch.setattr("src.dialectic.chat.crud.get_session", fake_get_session) + monkeypatch.setattr( + "src.dialectic.chat.WorkspaceDialecticAgent.answer", fake_answer + ) + + result = await workspace_chat("workspace", "session", "What changed?") + + assert result == "ok" + + +@pytest.mark.asyncio +async def test_workspace_chat_stream_releases_preflight_session_before_stream( + monkeypatch: pytest.MonkeyPatch, +) -> None: + active_sessions = 0 + + @asynccontextmanager + async def fake_tracked_db(_: str | None = None, **_kwargs: Any): + nonlocal active_sessions + active_sessions += 1 + try: + yield object() + finally: + active_sessions -= 1 + + async def fake_get_session(*args: Any, **kwargs: Any) -> object: + _ = (args, kwargs) + assert active_sessions == 1 + return SimpleNamespace(id="session-id") + + async def fake_answer_stream(_self: Any, query: str, **_kwargs: Any): + assert query == "Stream it" + assert active_sessions == 0 + yield "chunk-1" + assert active_sessions == 0 + yield "chunk-2" + + async def fake_get_workspace(*args: Any, **kwargs: Any) -> object: + _ = (args, kwargs) + assert active_sessions == 1 + return SimpleNamespace(name="workspace") + + monkeypatch.setattr("src.dialectic.chat.tracked_db", fake_tracked_db) + monkeypatch.setattr("src.dialectic.chat.crud.get_workspace", fake_get_workspace) + monkeypatch.setattr("src.dialectic.chat.crud.get_session", fake_get_session) + monkeypatch.setattr( + "src.dialectic.chat.WorkspaceDialecticAgent.answer_stream", + fake_answer_stream, + ) + + chunks = [ + chunk + async for chunk in workspace_chat_stream("workspace", "session", "Stream it") + ] + + assert chunks == ["chunk-1", "chunk-2"] + + +# ============================================================================= +# Tool Handler Tests: Workspace-Specific Handlers +# ============================================================================= + + +@pytest.mark.asyncio +class TestSearchMemoryWorkspace: + """Tests for _handle_search_memory_workspace (representation-scoped).""" + + async def test_requires_observer_and_observed( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns error when observer/observed params are missing.""" + ctx = make_workspace_ctx() + + result = _tool_text( + await _handle_search_memory_workspace(ctx, {"query": "coffee preferences"}) + ) + assert "ERROR" in result + assert "observer" in result + + async def test_missing_observer_returns_error( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns error when only observed is provided.""" + ctx = make_workspace_ctx() + + result = _tool_text( + await _handle_search_memory_workspace( + ctx, {"query": "test", "observed": "someone"} + ) + ) + assert "ERROR" in result + + async def test_missing_observed_returns_error( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns error when only observer is provided.""" + ctx = make_workspace_ctx() + + result = _tool_text( + await _handle_search_memory_workspace( + ctx, {"query": "test", "observer": "someone"} + ) + ) + assert "ERROR" in result + + async def test_returns_observations_for_specific_pair( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + monkeypatch: pytest.MonkeyPatch, + ): + """Returns observations scoped to a specific observer/observed pair.""" + monkeypatch.setattr("src.config.settings.VECTOR_STORE.MIGRATED", False) + _, peer1, peer2, _, _, _, _, _ = workspace_test_data + ctx = make_workspace_ctx() + + result = _tool_text( + await _handle_search_memory_workspace( + ctx, + { + "query": "coffee preferences", + "observer": peer1.name, + "observed": peer2.name, + }, + ) + ) + + assert "Found" in result + assert "observations" in result.lower() + # Should be scoped to peer1->peer2 + assert f"{peer1.name}->{peer2.name}" in result + + async def test_does_not_return_observations_from_other_pairs( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + monkeypatch: pytest.MonkeyPatch, + ): + """Does not leak observations from other peer pairs.""" + monkeypatch.setattr("src.config.settings.VECTOR_STORE.MIGRATED", False) + _, peer1, _, peer3, _, _, _, _ = workspace_test_data + ctx = make_workspace_ctx() + + result = _tool_text( + await _handle_search_memory_workspace( + ctx, + { + "query": "coffee", + "observer": peer1.name, + "observed": peer3.name, + }, + ) + ) + + # peer3 observations are about hiking/mornings, not coffee + # Should either find the hiking/mornings ones or none + assert isinstance(result, str) + + async def test_falls_back_to_message_search( + self, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + """Falls back to message search when no observations exist for the pair.""" + workspace, _ = sample_data + + session = models.Session( + name=str(generate_nanoid()), workspace_name=workspace.name + ) + db_session.add(session) + await db_session.flush() + + observer = models.Peer( + name=str(generate_nanoid()), workspace_name=workspace.name + ) + observed = models.Peer( + name=str(generate_nanoid()), workspace_name=workspace.name + ) + db_session.add_all([observer, observed]) + await db_session.flush() + + msg = models.Message( + workspace_name=workspace.name, + session_name=session.name, + peer_name=observed.name, + content="I really like programming in Python", + seq_in_session=1, + token_count=10, + created_at=datetime.now(timezone.utc), + ) + db_session.add(msg) + await db_session.flush() + + ctx = ToolContext( + observer="", + observed="", + current_messages=None, + workspace_name=workspace.name, + session_name=session.name, + include_observation_ids=False, + history_token_limit=8192, + db_lock=asyncio.Lock(), + ) + + result = _tool_text( + await _handle_search_memory_workspace( + ctx, + { + "query": "programming", + "observer": observer.name, + "observed": observed.name, + }, + ) + ) + + assert isinstance(result, str) + assert "No observations" in result or "Found" in result + + async def test_respects_top_k( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + monkeypatch: pytest.MonkeyPatch, + ): + """Respects the top_k parameter, capped at 40.""" + monkeypatch.setattr("src.config.settings.VECTOR_STORE.MIGRATED", False) + _, peer1, peer2, _, _, _, _, _ = workspace_test_data + ctx = make_workspace_ctx() + + result = _tool_text( + await _handle_search_memory_workspace( + ctx, + { + "query": "test", + "top_k": 2, + "observer": peer1.name, + "observed": peer2.name, + }, + ) + ) + + assert isinstance(result, str) + + +@pytest.mark.asyncio +class TestGetWorkspaceStats: + """Tests for _handle_get_workspace_stats.""" + + async def test_returns_stats( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Returns workspace statistics.""" + _ = workspace_test_data + ctx = make_workspace_ctx() + + result = await _handle_get_workspace_stats(ctx, {}) + + assert "Workspace stats" in result + assert "Peers: 3" in result + assert "Sessions: 1" in result + assert "Messages: 6" in result + assert "Date range" in result + + async def test_lists_most_active_peers( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Includes the most active peers with message counts.""" + _, peer1, peer2, peer3, *_ = workspace_test_data + ctx = make_workspace_ctx() + + result = await _handle_get_workspace_stats(ctx, {}) + + assert "Most active peers" in result + assert peer1.name in result + assert peer2.name in result + assert peer3.name in result + assert "messages" in result + + async def test_empty_workspace( + self, + db_session: AsyncSession, + ): + """Returns zero counts for an empty workspace.""" + workspace = models.Workspace(name=str(generate_nanoid())) + db_session.add(workspace) + await db_session.flush() + + ctx = ToolContext( + observer="", + observed="", + current_messages=None, + workspace_name=workspace.name, + session_name=None, + include_observation_ids=False, + history_token_limit=8192, + db_lock=asyncio.Lock(), + ) + + result = await _handle_get_workspace_stats(ctx, {}) + + assert "Peers: 0" in result + assert "Messages: 0" in result + + async def test_excludes_scope_peers( + self, + db_session: AsyncSession, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + workspace, *_ = workspace_test_data + db_session.add( + models.Peer( + name=scope_peer_name("therapy"), + workspace_name=workspace.name, + internal_metadata={"kind": SCOPE_KIND}, + configuration={"observe_me": False}, + ) + ) + await db_session.commit() + + result = await _handle_get_workspace_stats(make_workspace_ctx(), {}) + + assert "Peers: 3" in result + assert "scope.therapy" not in result + + async def test_empty_session_allowlist_is_zero( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + _ = workspace_test_data + result = await _handle_get_workspace_stats( + make_workspace_ctx(session_allowlist=[]), {} + ) + + assert "Peers: 0" in result + assert "Sessions: 0" in result + assert "Messages: 0" in result + + +@pytest.mark.asyncio +class TestGetPeerCardByName: + """Tests for _handle_get_peer_card_by_name.""" + + async def test_returns_peer_card( + self, + db_session: AsyncSession, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Returns peer card when it exists.""" + workspace, peer1, peer2, *_ = workspace_test_data + + # Create a peer card + await crud.set_peer_card( + db_session, + workspace_name=workspace.name, + observer=peer1.name, + observed=peer2.name, + peer_card=["Name: Alice", "Location: NYC"], + ) + + ctx = make_workspace_ctx() + result = await _handle_get_peer_card_by_name( + ctx, {"observer": peer1.name, "observed": peer2.name} + ) + + assert "Peer card" in result + assert "Name: Alice" in result + assert "Location: NYC" in result + + async def test_returns_not_found( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Returns appropriate message when peer card doesn't exist.""" + _, peer1, peer2, *_ = workspace_test_data + ctx = make_workspace_ctx() + + result = await _handle_get_peer_card_by_name( + ctx, {"observer": peer1.name, "observed": peer2.name} + ) + + assert "No peer card" in result + + async def test_session_allowlist_refuses( + self, + db_session: AsyncSession, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """A peer card is a cross-session aggregate, so a scoped query must not + get one — otherwise `scope` leaks facts derived outside its sessions.""" + workspace, peer1, peer2, _peer3, session, *_ = workspace_test_data + + await crud.set_peer_card( + db_session, + workspace_name=workspace.name, + observer=peer1.name, + observed=peer2.name, + peer_card=["Secret: derived from an out-of-scope session"], + ) + + ctx = make_workspace_ctx(session_allowlist=[session.name]) + result = await _handle_get_peer_card_by_name( + ctx, {"observer": peer1.name, "observed": peer2.name} + ) + + assert "Secret" not in result + assert "unavailable for session-scoped queries" in result + + async def test_unknown_peer_is_answered_not_raised( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """The agent supplies peer names from its own routing, so a name that + doesn't exist is an expected turn, not an unhandled exception.""" + _, peer1, *_ = workspace_test_data + ctx = make_workspace_ctx() + + result = await _handle_get_peer_card_by_name( + ctx, {"observer": "no-such-peer", "observed": peer1.name} + ) + + assert "No peer named 'no-such-peer'" in result + + async def test_missing_params_returns_error( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns error when observer/observed params are missing.""" + ctx = make_workspace_ctx() + + result = await _handle_get_peer_card_by_name(ctx, {}) + + assert "ERROR" in result + + async def test_missing_observer_returns_error( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns error when only observed is provided.""" + ctx = make_workspace_ctx() + + result = await _handle_get_peer_card_by_name(ctx, {"observed": "someone"}) + + assert "ERROR" in result + + +@pytest.mark.asyncio +class TestGetObservationContextWorkspace: + """Tests for get_observation_context under the workspace executor. + + The workspace loadout routes this straight to the shared handler: its + observer="" sentinel already normalizes to None ("no perspective + scoping") at the crud boundary.""" + + async def test_retrieves_messages_by_id( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Retrieves messages by their public IDs.""" + _, _, _, _, _, messages, _, _ = workspace_test_data + ctx = make_workspace_ctx() + + result = await _handle_get_observation_context( + ctx, {"message_ids": [messages[0].public_id]} + ) + + assert "Retrieved" in result or "No messages found" in result + + async def test_nonexistent_message_ids( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns appropriate message for nonexistent IDs.""" + ctx = make_workspace_ctx() + + result = await _handle_get_observation_context( + ctx, {"message_ids": ["nonexistent_id"]} + ) + + assert "No messages found" in result + + async def test_respects_session_scope( + self, + db_session: AsyncSession, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Session-scoped context lookup should not leak snippets from other sessions.""" + workspace, _peer1, peer2, _peer3, session, messages, *_ = workspace_test_data + + other_session = models.Session( + name=str(generate_nanoid()), + workspace_name=workspace.name, + ) + db_session.add(other_session) + await db_session.flush() + + leaked_message = models.Message( + workspace_name=workspace.name, + session_name=other_session.name, + peer_name=peer2.name, + content="LEAKED_FROM_OTHER_SESSION", + seq_in_session=messages[0].seq_in_session, + token_count=10, + created_at=datetime.now(timezone.utc), + ) + db_session.add(leaked_message) + await db_session.commit() + + ctx = make_workspace_ctx(session_name=session.name) + result = await _handle_get_observation_context( + ctx, {"message_ids": [messages[0].public_id]} + ) + + assert "LEAKED_FROM_OTHER_SESSION" not in result + assert messages[0].content in result + + +@pytest.mark.asyncio +class TestGetReasoningChainWorkspace: + """Tests for _handle_get_reasoning_chain.""" + + async def test_returns_observation_chain( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Returns an observation and its chain.""" + _, _, _, _, _, _, docs_peer2, _ = workspace_test_data + ctx = make_workspace_ctx() + + result = await _handle_get_reasoning_chain( + ctx, {"observation_id": docs_peer2[0].id} + ) + + assert "Observation" in result + assert docs_peer2[0].content in result + + async def test_nonexistent_observation_returns_error( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns error for nonexistent observation ID.""" + ctx = make_workspace_ctx() + + result = await _handle_get_reasoning_chain( + ctx, {"observation_id": "nonexistent_id"} + ) + + assert "ERROR" in result + + async def test_missing_observation_id_returns_error( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns error when observation_id is missing.""" + ctx = make_workspace_ctx() + + result = await _handle_get_reasoning_chain(ctx, {}) + + assert "ERROR" in result + + async def test_invalid_direction_returns_error( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Returns error for invalid direction parameter.""" + _, _, _, _, _, _, docs_peer2, _ = workspace_test_data + ctx = make_workspace_ctx() + + result = await _handle_get_reasoning_chain( + ctx, + {"observation_id": docs_peer2[0].id, "direction": "invalid"}, + ) + + assert "ERROR" in result + + async def test_deductive_observation_shows_premises( + self, + db_session: AsyncSession, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Deductive observation shows premises in chain.""" + workspace, peer1, peer2, _, _, _, docs_peer2, _ = workspace_test_data + + # Create a deductive document with source_ids + deductive_doc = models.Document( + workspace_name=workspace.name, + observer=peer1.name, + observed=peer2.name, + content="User is probably a morning person who codes", + embedding=[0.3] * 1536, + level="deductive", + source_ids=[docs_peer2[0].id, docs_peer2[1].id], + ) + db_session.add(deductive_doc) + await db_session.commit() + await db_session.refresh(deductive_doc) + + ctx = make_workspace_ctx() + result = await _handle_get_reasoning_chain( + ctx, {"observation_id": deductive_doc.id, "direction": "premises"} + ) + + assert "Observation" in result + assert "Premises" in result + + +# ============================================================================= +# Tool Executor Tests +# ============================================================================= + + +@pytest.mark.asyncio +class TestWorkspaceToolExecutor: + """Tests for create_workspace_tool_executor.""" + + async def test_returns_callable( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + """create_workspace_tool_executor returns an async callable.""" + workspace, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + ) + + assert callable(executor) + + async def test_routes_workspace_tools( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + """Workspace-specific tools are routed to workspace handlers.""" + workspace, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + ) + + stats_result = await executor("get_workspace_stats", {}) + assert isinstance(stats_result, str) + assert "Workspace stats" in stats_result + assert "Most active peers" in stats_result + + async def test_falls_through_to_standard_handlers( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + """Non-workspace tools fall through to standard handlers.""" + workspace, _, _, _, session, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + session_name=session.name, + ) + + # grep_messages is a standard handler, should fall through + result = await executor("grep_messages", {"text": "Test message"}) + + assert isinstance(result, str) + + async def test_unknown_tool_returns_error( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + """Unknown tool name returns error.""" + workspace, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + ) + + result = await executor("nonexistent_tool", {}) + + assert "Unknown tool" in result + + async def test_handles_exceptions_gracefully( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + """Executor returns error strings instead of raising exceptions.""" + workspace, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + ) + + # Missing required observer/observed/query parameters + result = await executor("search_memory", {}) + + assert isinstance(result, str) + assert "ERROR" in result + + async def test_get_peer_card_via_executor( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + """get_peer_card routes through workspace handler with params.""" + workspace, peer1, peer2, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + ) + + result = await executor( + "get_peer_card", + {"observer": peer1.name, "observed": peer2.name}, + ) + + assert isinstance(result, str) + # Should be from workspace handler (accepts observer/observed params) + assert "peer card" in result.lower() or "No peer card" in result + + +# ============================================================================= +# Regression: workspace-flat message visibility without a pinned session +# ============================================================================= + + +@pytest.mark.asyncio +class TestWorkspaceMessageToolsUnpinned: + """The workspace executor's observer='' sentinel must read as + 'no perspective scoping' (None) at the crud boundary. Under #882's + resolve_session_scope, an empty STRING is looked up as a real peer with + no session memberships and denies every result — so these tests run the + message tools with NO session_name, the primary workspace-chat shape.""" + + async def test_grep_messages_finds_content_without_session( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + workspace, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + ) + result = await executor("grep_messages", {"text": "Test message"}) + + assert isinstance(result, str) + assert "No messages found" not in result + assert "Test message" in result + + async def test_date_range_finds_content_without_session( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + workspace, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + ) + result = await executor("get_messages_by_date_range", {"limit": 10}) + + assert isinstance(result, str) + assert "Found" in result + assert "No messages found" not in result + + async def test_session_allowlist_is_honored_when_set( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + """An allowlist naming no real session yields no results.""" + workspace, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + session_allowlist=["no-such-session"], + ) + result = await executor("grep_messages", {"text": "Test message"}) + + assert isinstance(result, str) + assert "No messages found" in result + + +@pytest.mark.asyncio +async def test_workspace_prefetch_failure_degrades_to_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Prefetch errors must not fail the request (parity with the base + agent's try/except): the agent proceeds with no prefetched block.""" + from src.dialectic.workspace import WorkspaceDialecticAgent + + async def boom(*args: Any, **kwargs: Any) -> Any: + _ = (args, kwargs) + raise RuntimeError("stats query exploded") + + monkeypatch.setattr("src.dialectic.workspace.crud.get_workspace_stats", boom) + + agent = WorkspaceDialecticAgent(workspace_name="w") + result = await agent._prefetch_relevant_observations("q") # pyright: ignore[reportPrivateUsage] + + assert result is None diff --git a/tests/unified/README.md b/tests/unified/README.md index 0ef1d987..01f53ebb 100644 --- a/tests/unified/README.md +++ b/tests/unified/README.md @@ -48,19 +48,21 @@ Tests are defined in JSON files. A test definition consists of a name, optional 4. **Querying & Assertions**: * `query`: Perform an action and assert on the result. - * `target`: "chat", "get_context", "get_peer_card", "get_representation" - * `scope`: confine the read to a scope (or, for chat/representation, to - the union of several). Valid for "chat", "get_representation" and - "get_context"; the latter takes a single scope and requires - `observed_peer_id`. + * `target`: "chat", "get_context", "get_peer_card", "get_representation", + "workspace_chat" + * `scope`: confine the read to a scope (or, for chat/representation/ + workspace_chat, to the union of several). Valid for "chat", + "get_representation", "get_context", and "workspace_chat"; get_context + takes a single scope and requires `observed_peer_id`. ### Raw HTTP vs the SDK -Most steps drive the Honcho Python SDK. `create_scope` and any query carrying -`scope` go over raw HTTP instead, because the published SDK trails the API and -exposes neither. Calling the API directly also tests the contract the SDK is -generated from, so a wrong status code or response shape surfaces here rather -than being masked by client-side validation. +Most steps drive the Honcho Python SDK. `create_scope` and scoped `chat` / +`get_representation` / `get_context` queries go over raw HTTP instead, because +the published SDK trails the API and exposes neither. Scoped `workspace_chat` +uses the SDK `scope` argument. Calling the API directly also tests the contract +the SDK is generated from, so a wrong status code or response shape surfaces +here rather than being masked by client-side validation. ### Assertions diff --git a/tests/unified/run.py b/tests/unified/run.py index c0848471..5ab4fb79 100644 --- a/tests/unified/run.py +++ b/tests/unified/run.py @@ -57,7 +57,9 @@ async def main(): tests_dir=test_dir, honcho_port=args.port, api_port=args.api_port ) - await runner.run() + # Non-zero on any failed or unrunnable test, so CI fails on results. + if await runner.run(): + sys.exit(1) if __name__ == "__main__": diff --git a/tests/unified/runner.py b/tests/unified/runner.py index 6d78b06b..c9e9d2e3 100644 --- a/tests/unified/runner.py +++ b/tests/unified/runner.py @@ -386,6 +386,17 @@ class UnifiedTestExecutor: raise TimeoutError("Deriver queue did not empty within timeout") async def perform_query(self, step: QueryAction) -> Any: + if step.target == "workspace_chat": + if step.input is None: + raise ValueError("input required for workspace_chat") + return await self.client.aio.chat( + step.input, + session=step.session_id, + reasoning_level=step.reasoning_level, + response_format=step.response_format, + scope=step.scope, + ) + if step.scope is not None: return await self._perform_scoped_query(step) @@ -626,7 +637,8 @@ class UnifiedTestRunner: AsyncAnthropic(api_key=self.api_key) if self.api_key else None ) - async def run(self): + async def run(self) -> int: + """Run the suite and return the number of tests that did not pass.""" try: # 1. Start Harness logger.info("Starting Honcho Harness...") @@ -784,6 +796,8 @@ class UnifiedTestRunner: await send_discord_message(discord_webhook_url, message) + return failed_count + finally: # 7. Cleanup logger.info("Cleaning up harness...") @@ -798,4 +812,4 @@ if __name__ == "__main__": args = parser.parse_args() runner = UnifiedTestRunner(Path(args.test_dir)) - asyncio.run(runner.run()) + sys.exit(1 if asyncio.run(runner.run()) else 0) diff --git a/tests/unified/schema.py b/tests/unified/schema.py index b0fa84b4..31e30946 100644 --- a/tests/unified/schema.py +++ b/tests/unified/schema.py @@ -149,7 +149,13 @@ class JsonMatchAssertion(Assertion): class QueryAction(TestStep): step_type: Literal["query"] = "query" - target: Literal["chat", "get_context", "get_peer_card", "get_representation"] + target: Literal[ + "chat", + "get_context", + "get_peer_card", + "get_representation", + "workspace_chat", + ] session_id: str | None = None @@ -168,9 +174,9 @@ class QueryAction(TestStep): # for chat - optional JSON Schema the response must conform to response_format: dict[str, Any] | None = None - # Confine the read to one scope (observer swap) or to the union of several - # scopes' member sessions. Forces the raw-HTTP path, since the SDK has no - # `scope` parameter. Valid for chat, get_representation and get_context. + # Confine the read to one scope (observer swap on peer chat) or to the + # union of several scopes' member sessions. Peer-chat/representation/ + # context go over raw HTTP; workspace_chat uses the SDK `scope` argument. scope: str | list[str] | None = None assertions: list[ diff --git a/tests/unified/test_cases/peer_isolation_test.json b/tests/unified/test_cases/peer_isolation_test.json new file mode 100644 index 00000000..2c87fced --- /dev/null +++ b/tests/unified/test_cases/peer_isolation_test.json @@ -0,0 +1,148 @@ +{ + "description": "Test that peer Z cannot access information from a session between X and Y that Z was not part of. This validates session membership scoping - peers should only see messages from sessions they participated in.", + "workspace_config": {}, + "steps": [ + { + "step_type": "create_session", + "session_id": "xy_private_session", + "peer_configs": { + "peer_x": { + "observe_me": true, + "observe_others": true + }, + "peer_y": { + "observe_me": true, + "observe_others": true + }, + "agent": { + "observe_me": false, + "observe_others": true + } + } + }, + { + "step_type": "add_messages", + "session_id": "xy_private_session", + "messages": [ + { + "peer_id": "peer_x", + "content": "Hey peer_y, I need to tell you something important. My secret password is 'strawberry_unicorn_42' - please remember this for later." + }, + { + "peer_id": "agent", + "content": "Got it, I'll remember that." + }, + { + "peer_id": "peer_y", + "content": "Thanks for sharing that, peer_x. I've noted your password: strawberry_unicorn_42. I'll keep it safe." + }, + { + "peer_id": "peer_x", + "content": "Perfect. Also, I'm planning a surprise party for peer_z next Friday at 7pm at the Blue Whale restaurant downtown. Don't tell peer_z!" + }, + { + "peer_id": "peer_y", + "content": "Amazing! I'll be there. The surprise party at Blue Whale will be great. My lips are sealed!" + } + ] + }, + { + "step_type": "wait", + "target": "queue_empty", + "flush": true + }, + { + "step_type": "create_session", + "session_id": "z_separate_session", + "peer_configs": { + "peer_z": { + "observe_me": true, + "observe_others": false + }, + "agent": { + "observe_me": false, + "observe_others": true + } + } + }, + { + "step_type": "add_messages", + "session_id": "z_separate_session", + "messages": [ + { + "peer_id": "peer_z", + "content": "Hi! I'm peer_z. I like going to restaurants and attending parties." + }, + { + "peer_id": "agent", + "content": "That's nice! Do you have any upcoming plans?" + } + ] + }, + { + "step_type": "wait", + "target": "queue_empty", + "flush": true + }, + { + "step_type": "query", + "target": "chat", + "session_id": "z_separate_session", + "observer_peer_id": "agent", + "observed_peer_id": "peer_z", + "input": "What is peer_x's secret password?", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "not_contains", + "text": "strawberry_unicorn_42", + "case_sensitive": false + }, + { + "assertion_type": "llm_judge", + "prompt": "Does the response indicate that the agent DOES NOT KNOW the password, or that the information is not available? The response should NOT reveal the password 'strawberry_unicorn_42'. Return true if the agent correctly says they don't know or the info isn't available.", + "pass_if": true + } + ] + }, + { + "step_type": "query", + "target": "chat", + "session_id": "z_separate_session", + "observer_peer_id": "agent", + "observed_peer_id": "peer_z", + "input": "Is there a surprise party being planned? If so, where and when?", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "not_contains", + "text": "Blue Whale", + "case_sensitive": false + }, + { + "assertion_type": "not_contains", + "text": "Friday at 7", + "case_sensitive": false + }, + { + "assertion_type": "llm_judge", + "prompt": "Does the response indicate that the agent DOES NOT KNOW about any surprise party details? The response should NOT mention the Blue Whale restaurant or Friday at 7pm. Return true if the agent correctly says they don't have this information.", + "pass_if": true + } + ] + }, + { + "step_type": "query", + "target": "workspace_chat", + "input": "What did peer_x tell peer_y about their password?", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "The workspace chat should be able to answer this question since it can access information from all sessions. Does the response mention the password 'strawberry_unicorn_42' or indicate that peer_x shared a password with peer_y? Return true if the workspace-level query CAN see this information.", + "pass_if": true + } + ] + } + ] +} diff --git a/tests/unified/test_cases/workspace_chat_cross_peer.json b/tests/unified/test_cases/workspace_chat_cross_peer.json new file mode 100644 index 00000000..3d474c1d --- /dev/null +++ b/tests/unified/test_cases/workspace_chat_cross_peer.json @@ -0,0 +1,88 @@ +{ + "description": "Test that workspace-level chat can synthesize information across multiple peers in a single response. Three peers have distinct attributes; workspace chat should be able to compare or list them.", + "workspace_config": {}, + "steps": [ + { + "step_type": "create_session", + "session_id": "ws_cross_session", + "peer_configs": { + "dan": { + "observe_me": true, + "observe_others": false + }, + "emma": { + "observe_me": true, + "observe_others": false + }, + "frank": { + "observe_me": true, + "observe_others": false + }, + "agent": { + "observe_me": false, + "observe_others": false + } + } + }, + { + "step_type": "add_messages", + "session_id": "ws_cross_session", + "messages": [ + { + "peer_id": "dan", + "content": "I'm a software engineer specializing in Rust and systems programming. I work at a startup building distributed databases." + }, + { + "peer_id": "agent", + "content": "Interesting! What kind of databases?" + }, + { + "peer_id": "dan", + "content": "We're building a distributed time-series database optimized for IoT sensor data." + }, + { + "peer_id": "emma", + "content": "I'm a marine biologist studying coral reef ecosystems in the Great Barrier Reef. I've been doing field research there for 3 years." + }, + { + "peer_id": "agent", + "content": "That must be fascinating work. What's your focus?" + }, + { + "peer_id": "emma", + "content": "I'm specifically studying the impact of ocean temperature changes on coral bleaching patterns." + }, + { + "peer_id": "frank", + "content": "I'm a pastry chef at a Michelin-starred restaurant in Lyon, France. My specialty is chocolate souffles." + }, + { + "peer_id": "agent", + "content": "That's impressive! How did you get into pastry?" + }, + { + "peer_id": "frank", + "content": "I trained at Le Cordon Bleu in Paris and then apprenticed under Chef Pierre Herme for two years." + } + ] + }, + { + "step_type": "wait", + "target": "queue_empty", + "flush": true + }, + { + "step_type": "query", + "target": "workspace_chat", + "input": "What professions do the people in this workspace have? List each person and what they do.", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does the response mention at least 2 of the 3 peers (Dan, Emma, Frank) along with their professions? Dan is a software engineer, Emma is a marine biologist, and Frank is a pastry chef. The response should identify at least 2 of these 3 profession/person pairs correctly.", + "pass_if": true + } + ] + } + ] +} diff --git a/tests/unified/test_cases/workspace_chat_from_messages.json b/tests/unified/test_cases/workspace_chat_from_messages.json new file mode 100644 index 00000000..2fe417bd --- /dev/null +++ b/tests/unified/test_cases/workspace_chat_from_messages.json @@ -0,0 +1,61 @@ +{ + "description": "Test that workspace-level chat can fall back to searching messages directly. Deriver is disabled so no observations are created, forcing the agent to find information from raw message history.", + "workspace_config": { + "reasoning": { + "enabled": false + } + }, + "steps": [ + { + "step_type": "create_session", + "session_id": "ws_msg_session", + "peer_configs": { + "carol": { + "observe_me": true, + "observe_others": false + }, + "agent": { + "observe_me": false, + "observe_others": false + } + } + }, + { + "step_type": "add_messages", + "session_id": "ws_msg_session", + "messages": [ + { + "peer_id": "carol", + "content": "I just finished writing my third novel. It's a mystery set in 1920s Paris called 'The Montmartre Cipher'." + }, + { + "peer_id": "agent", + "content": "Congratulations! That's a great achievement. What inspired the setting?" + }, + { + "peer_id": "carol", + "content": "I lived in Paris for two years and fell in love with the history of Montmartre. The artists and writers who gathered there in the 1920s were fascinating." + } + ] + }, + { + "step_type": "wait", + "duration": 2, + "target": "queue_empty" + }, + { + "step_type": "query", + "target": "workspace_chat", + "input": "What can you tell me about Carol's writing?", + "session_id": "ws_msg_session", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does the response reference Carol writing novels or a book? It should mention something about her being a writer/author or her novel. Mentioning 'The Montmartre Cipher' or Paris or mystery is a bonus but not required. The key point is that the system found information about Carol's writing from the message history.", + "pass_if": true + } + ] + } + ] +} diff --git a/tests/unified/test_cases/workspace_chat_from_observations.json b/tests/unified/test_cases/workspace_chat_from_observations.json new file mode 100644 index 00000000..f40eae04 --- /dev/null +++ b/tests/unified/test_cases/workspace_chat_from_observations.json @@ -0,0 +1,85 @@ +{ + "description": "Test that workspace-level chat can gather information about peers from their global representations (observer==observed). Two peers share distinct facts; after deriver processes them, workspace chat should retrieve info about each peer.", + "workspace_config": {}, + "steps": [ + { + "step_type": "create_session", + "session_id": "ws_obs_session", + "peer_configs": { + "alice": { + "observe_me": true, + "observe_others": false + }, + "bob": { + "observe_me": true, + "observe_others": false + }, + "agent": { + "observe_me": false, + "observe_others": false + } + } + }, + { + "step_type": "add_messages", + "session_id": "ws_obs_session", + "messages": [ + { + "peer_id": "alice", + "content": "I'm a professional violinist and I perform with the Chicago Symphony Orchestra every Friday evening." + }, + { + "peer_id": "agent", + "content": "That's wonderful! How long have you been playing?" + }, + { + "peer_id": "alice", + "content": "I've been playing violin since I was 5 years old, so about 25 years now." + }, + { + "peer_id": "bob", + "content": "I just got back from a scuba diving trip in Belize. I'm a certified rescue diver." + }, + { + "peer_id": "agent", + "content": "That sounds amazing! Do you dive often?" + }, + { + "peer_id": "bob", + "content": "Yes, I try to go diving at least twice a month. My favorite dive site is the Great Blue Hole." + } + ] + }, + { + "step_type": "wait", + "target": "queue_empty", + "flush": true + }, + { + "step_type": "query", + "target": "workspace_chat", + "input": "What do you know about Alice's musical background?", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does the response mention that Alice is a violinist or plays violin? It should reference her connection to music/violin performance. The specific detail about the Chicago Symphony Orchestra is a bonus but not required.", + "pass_if": true + } + ] + }, + { + "step_type": "query", + "target": "workspace_chat", + "input": "What are Bob's hobbies?", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does the response mention that Bob is into scuba diving or diving? It should reference his diving hobby. The specific detail about Belize or the Great Blue Hole is a bonus but not required.", + "pass_if": true + } + ] + } + ] +} diff --git a/tests/unified/test_cases/workspace_chat_scope.json b/tests/unified/test_cases/workspace_chat_scope.json new file mode 100644 index 00000000..bf5c7243 --- /dev/null +++ b/tests/unified/test_cases/workspace_chat_scope.json @@ -0,0 +1,97 @@ +{ + "description": "Workspace chat with `scope` only recalls the scope's member sessions.", + "workspace_config": { + "reasoning": { + "enabled": true + } + }, + "steps": [ + { + "step_type": "create_session", + "session_id": "in_scope", + "peer_configs": { + "alice": { + "observe_me": true, + "observe_others": false + } + } + }, + { + "step_type": "create_session", + "session_id": "out_of_scope", + "peer_configs": { + "bob": { + "observe_me": true, + "observe_others": false + } + } + }, + { + "step_type": "create_scope", + "scope_id": "therapy", + "session_ids": ["in_scope"] + }, + { + "step_type": "add_messages", + "session_id": "in_scope", + "messages": [ + { + "peer_id": "alice", + "content": "My favorite tea is jasmine green tea from Hangzhou." + } + ] + }, + { + "step_type": "add_messages", + "session_id": "out_of_scope", + "messages": [ + { + "peer_id": "bob", + "content": "The vault code is 7491-orange-lantern." + } + ] + }, + { + "step_type": "wait", + "duration": 2, + "target": "queue_empty" + }, + { + "step_type": "query", + "target": "workspace_chat", + "scope": "therapy", + "input": "What facts do you know about people in this workspace? Mention any codes or secrets if you have them.", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "not_contains", + "text": "7491-orange-lantern", + "case_sensitive": false + }, + { + "assertion_type": "not_contains", + "text": "scope.therapy", + "case_sensitive": false + }, + { + "assertion_type": "llm_judge", + "prompt": "Does the response mention Alice's jasmine tea (or that Alice likes tea), and does it NOT mention a vault code or 7491? Return true only if both are true.", + "pass_if": true + } + ] + }, + { + "step_type": "query", + "target": "workspace_chat", + "input": "List every peer in this workspace and how many messages each has.", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "not_contains", + "text": "scope.therapy", + "case_sensitive": false + } + ] + } + ] +} diff --git a/tests/unified/test_schema.py b/tests/unified/test_schema.py new file mode 100644 index 00000000..250ff314 --- /dev/null +++ b/tests/unified/test_schema.py @@ -0,0 +1,15 @@ +"""Assert every unified JSON case still parses against the schema.""" + +import json +from pathlib import Path + +import pytest + +_CASES = sorted(Path(__file__).parent.joinpath("test_cases").glob("*.json")) + + +@pytest.mark.parametrize("path", _CASES, ids=lambda p: p.name) +def test_unified_case_parses(path: Path) -> None: + from tests.unified.schema import TestDefinition + + TestDefinition(**json.loads(path.read_text())) From da4b3ee43578fb9ec22ac27aec23ad0369dc6d47 Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Mon, 24 Aug 2026 16:26:00 -0400 Subject: [PATCH 09/50] chore: update issue templates and docs (#1032) * chore: update issue templates and docs * chore: slim bug/quality forms and add integration template Drop high-friction required fields from bug and quality issue forms. Add an integration-request form (app stores, plugins, frameworks) labeled integration, and point contributing docs at it. * chore: route security mail to support@ and polish docs intake Use support@honcho.dev for private vulnerability email. List the documentation template in contributing guides, rename Media prove, and add public-issue redaction/security redirects on the docs form. * fix: address render issue in templates and add version field --------- Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/1-bug-report.md | 76 --------------- .github/ISSUE_TEMPLATE/1-bug-report.yml | 72 ++++++++++++++ .github/ISSUE_TEMPLATE/2-failing-test.md | 38 -------- .github/ISSUE_TEMPLATE/2-quality-report.yml | 87 +++++++++++++++++ .github/ISSUE_TEMPLATE/3-feature-request.yml | 58 ++++++++++++ .github/ISSUE_TEMPLATE/4-feature-request.md | 42 --------- .../ISSUE_TEMPLATE/4-integration-request.yml | 70 ++++++++++++++ .../{3-docs-bug.md => 5-docs-bug.md} | 10 +- .../ISSUE_TEMPLATE/5-enhancement-request.md | 42 --------- .github/ISSUE_TEMPLATE/6-security-report.md | 93 ------------------- .github/ISSUE_TEMPLATE/7-question-support.md | 25 ----- .github/ISSUE_TEMPLATE/config.yml | 11 +++ CONTRIBUTING.md | 16 ++-- SECURITY.md | 31 +++++++ docs/v2/contributing/guidelines.mdx | 16 ++-- docs/v3/contributing/guidelines.mdx | 16 ++-- 16 files changed, 366 insertions(+), 337 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/1-bug-report.md create mode 100644 .github/ISSUE_TEMPLATE/1-bug-report.yml delete mode 100644 .github/ISSUE_TEMPLATE/2-failing-test.md create mode 100644 .github/ISSUE_TEMPLATE/2-quality-report.yml create mode 100644 .github/ISSUE_TEMPLATE/3-feature-request.yml delete mode 100644 .github/ISSUE_TEMPLATE/4-feature-request.md create mode 100644 .github/ISSUE_TEMPLATE/4-integration-request.yml rename .github/ISSUE_TEMPLATE/{3-docs-bug.md => 5-docs-bug.md} (73%) delete mode 100644 .github/ISSUE_TEMPLATE/5-enhancement-request.md delete mode 100644 .github/ISSUE_TEMPLATE/6-security-report.md delete mode 100644 .github/ISSUE_TEMPLATE/7-question-support.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 SECURITY.md diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.md b/.github/ISSUE_TEMPLATE/1-bug-report.md deleted file mode 100644 index 22a18fcb..00000000 --- a/.github/ISSUE_TEMPLATE/1-bug-report.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -name: "🐞 Bug Report" -about: "Report an issue to help the project improve." -title: "[Bug] " -labels: "bug" -assignees: "" - ---- - -# **🐞 Bug Report** - -## **Describe the bug** - - -* - ---- - -### **Is this a regression?** - - - ---- - -### **To Reproduce** - - - - - -1. -2. -3. -4. - ---- - -### **Expected behaviour** - - -* - ---- - -### **Media prove** - - ---- - -### **Your environment** - - - -* OS: -* Browser name and version: -* Honcho Server Version: -* Honcho Client Version: - ---- - -### **Additional context** - - -* - - diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml new file mode 100644 index 00000000..38373789 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -0,0 +1,72 @@ +name: Bug report +description: Something is broken or incorrect in Honcho (API, deriver, SDK, managed offering, etc.). +title: "[Bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for filing a bug. Please search [existing issues](https://github.com/plastic-labs/honcho/issues) first. + + **Security vulnerability?** Do not use this form — report privately via [SECURITY.md](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md). + + **Memory / recall quality** (wrong or noisy conclusions, weak dialectic answers) with no crash? Prefer the **Memory / recall quality** template. + + - type: dropdown + id: deploy_mode + attributes: + label: Deploy mode + description: Where are you running Honcho? + options: + - Managed (api.honcho.dev / app.honcho.dev) + - Self-hosted + - Unsure + validations: + required: true + + - type: input + id: version + attributes: + label: Honcho version + description: Server image tag or release, and SDK version if you use one. Write "managed" if you are not self-hosting. + placeholder: e.g. server v2.4.1, honcho-ai 2.1.0 + validations: + required: true + + - type: textarea + id: description + attributes: + label: Describe the bug + description: Clear and concise description of what is wrong. + placeholder: When I…, Honcho… + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: Minimal steps or a short script/API sequence. Redact secrets, JWTs, and production user content. + placeholder: | + 1. Create a session with … + 2. POST /v3/... with body … + 3. Observe … + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs and evidence + description: Relevant API or deriver logs or stack traces. Redact secrets and user content. + render: shell + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional context + description: Config knobs, deployment notes, screenshots, related issues/PRs. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/2-failing-test.md b/.github/ISSUE_TEMPLATE/2-failing-test.md deleted file mode 100644 index fed65816..00000000 --- a/.github/ISSUE_TEMPLATE/2-failing-test.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: "💉 Failing Test" -about: "Report failing tests or CI jobs." -title: "[Test] " -labels: "Type: Test" -assignees: "" - ---- - -# **💉 Failing Test** - -## **Which jobs/test(s) are failing** - - -* - ---- - -## **Reason for failure/description** - - ---- - -### **Media prove** - - ---- - -### **Additional context** - - -* - - diff --git a/.github/ISSUE_TEMPLATE/2-quality-report.yml b/.github/ISSUE_TEMPLATE/2-quality-report.yml new file mode 100644 index 00000000..2a2858a0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-quality-report.yml @@ -0,0 +1,87 @@ +name: Memory / recall quality +description: Conclusions, representations, or dialectic answers are wrong, noisy, missing, or low-quality — not a hard crash. +title: "[Quality] " +labels: ["quality"] +body: + - type: markdown + attributes: + value: | + Use this when Honcho runs without erroring, but **memory formation or recall quality** is off (bad conclusions, missed facts, weak chat answers, polluted representations, etc.). + + For crashes, 5xxs, auth failures, or incorrect API mechanics, use the **Bug report** template instead. + + **Do not paste production user content, full peer representations, or secrets.** Redact or invent a minimal synthetic example. + + - type: dropdown + id: deploy_mode + attributes: + label: Deploy mode + options: + - Managed (api.honcho.dev / app.honcho.dev) + - Self-hosted + - Unsure + validations: + required: true + + - type: input + id: version + attributes: + label: Honcho version + description: Server image tag or release, and SDK version if you use one. Write "managed" if you are not self-hosting. + placeholder: e.g. server v2.4.1, honcho-ai 2.1.0 + validations: + required: true + + - type: textarea + id: description + attributes: + label: What is wrong with the quality? + description: Describe the failure mode (noise, omission, contradiction, staleness, over/under-generalization, etc.). + placeholder: After ingesting messages about X, Honcho concludes Y / chat answers Z… + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Minimal scenario + description: > + Smallest synthetic message sequence or setup that triggers the issue. + Prefer invented names/facts over real user data. Include observer/observed + peer setup if relevant (self vs cross-peer). + placeholder: | + 1. Peers: alice (user), bot (agent); session S + 2. Messages ingested: … + 3. Query / conclusion listing shows: … + validations: + required: true + + - type: textarea + id: config + attributes: + label: Relevant config + description: > + Custom instructions, provider/model, deriver/dream settings, or workspace/peer + config that affects reasoning. Redact secrets. + placeholder: | + Provider/model: … + Custom instructions: (summary or redacted) + Other: … + validations: + required: false + + - type: textarea + id: evidence + attributes: + label: Evidence + description: Redacted conclusion text, chat excerpts, or counts that show the failure. No production PII. + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional context + description: Frequency, scale (message/conclusion counts), related issues, workarounds. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/3-feature-request.yml b/.github/ISSUE_TEMPLATE/3-feature-request.yml new file mode 100644 index 00000000..f0d935ac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3-feature-request.yml @@ -0,0 +1,58 @@ +name: Feature request +description: Propose a new capability or an improvement to an existing one. +title: "[Feature] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Tell us what problem you are trying to solve. Concrete use cases beat abstract wishlists. + + Questions about how to use Honcho belong on [Discord](https://discord.gg/honcho), not here. + + - type: dropdown + id: request_type + attributes: + label: Request type + options: + - New capability + - Improve an existing capability + - API / SDK surface + - Managed offering + - Docs / DX + - Other + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem + description: What is hard or impossible today? Who hits this? + placeholder: I'm always frustrated when… / My integration needs… + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: What you would like Honcho to support. Sketches and API shapes welcome. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Workarounds, other APIs, or designs you already tried or ruled out. + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional context + description: Links, prior art, screenshots, related issues/PRs. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/4-feature-request.md b/.github/ISSUE_TEMPLATE/4-feature-request.md deleted file mode 100644 index 00400dca..00000000 --- a/.github/ISSUE_TEMPLATE/4-feature-request.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: "🚀🆕 Feature Request" -about: "Suggest an idea or possible new feature for this project." -title: "" -labels: 'feature' -assignees: '' - ---- - -# **🚀 Feature Request** - -## **Is your feature request related to a problem? Please describe.** - - -* - ---- - -## **Describe the solution you'd like** - - -* - ---- - -## **Describe alternatives you've considered** - - -* - ---- - -### **Additional context** - - -* - - diff --git a/.github/ISSUE_TEMPLATE/4-integration-request.yml b/.github/ISSUE_TEMPLATE/4-integration-request.yml new file mode 100644 index 00000000..a3a45db4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4-integration-request.yml @@ -0,0 +1,70 @@ +name: Integration request +description: Add Honcho to an app store, agent framework, plugin marketplace, or other third-party surface — or improve an existing integration. +title: "[Integration] " +labels: ["integration"] +body: + - type: markdown + attributes: + value: | + Use this when you want Honcho available in (or better supported by) an external product surface — app stores, agent frameworks, plugin marketplaces, IDE extensions, MCP clients, etc. + + For core API/SDK product features that are not about a third-party surface, use the **Feature request** template instead. + + - type: dropdown + id: request_kind + attributes: + label: What kind of request is this? + options: + - New integration / listing (Honcho is not there yet) + - Improve an existing integration + - Official plugin / extension + - Marketplace or app-store listing + - Docs / guide for integrating with a specific tool + - Other + validations: + required: true + + - type: input + id: target + attributes: + label: Target product or platform + description: Name of the app, framework, marketplace, or tool. + placeholder: e.g. Claude Code, Cursor, CrewAI, OpenClaw, VS Code Marketplace… + validations: + required: true + + - type: input + id: target_url + attributes: + label: Link (if any) + description: Docs, marketplace page, repo, or product URL. + placeholder: https://… + validations: + required: false + + - type: textarea + id: why + attributes: + label: Why does this matter? + description: Who would use it, and what does the integration unlock? + validations: + required: true + + - type: textarea + id: shape + attributes: + label: What should the integration look like? + description: > + e.g. one-click install, MCP server listing, native memory backend, + SDK recipe, plugin with slash commands, env-var setup, etc. + Link to prior art or a sketch if you have one. + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional context + description: Related issues, community demand, constraints, offers to help build it. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/3-docs-bug.md b/.github/ISSUE_TEMPLATE/5-docs-bug.md similarity index 73% rename from .github/ISSUE_TEMPLATE/3-docs-bug.md rename to .github/ISSUE_TEMPLATE/5-docs-bug.md index 7693d543..1169ff15 100644 --- a/.github/ISSUE_TEMPLATE/3-docs-bug.md +++ b/.github/ISSUE_TEMPLATE/5-docs-bug.md @@ -8,6 +8,10 @@ assignees: "" --- # **📚 Documentation Issue Report** +**Security vulnerability?** Do not use this form — report privately via [SECURITY.md](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md). + +GitHub issues are public. Redact secrets, JWTs, and production user content. + ## **Describe the bug** @@ -33,8 +37,8 @@ assignees: "" --- -### **Media prove** - +### **Screenshots and videos** + --- @@ -46,7 +50,7 @@ assignees: "" --- ### **Additional context** - + * diff --git a/.github/ISSUE_TEMPLATE/5-enhancement-request.md b/.github/ISSUE_TEMPLATE/5-enhancement-request.md deleted file mode 100644 index d75f8756..00000000 --- a/.github/ISSUE_TEMPLATE/5-enhancement-request.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: "🚀➕ Enhancement Request" -about: "Suggest an enhancement for this project. Improve an existing feature" -title: "" -labels: "Type: Enhancement" -assignees: "" - ---- - -# **🚀 Enhancement Request** - -## **Is your enhancement request related to a problem? Please describe.** - - -* - ---- - -## **Describe the solution you'd like** - - -* - ---- - -## **Describe alternatives you've considered** - - -* - ---- - -### **Additional context** - - -* - - diff --git a/.github/ISSUE_TEMPLATE/6-security-report.md b/.github/ISSUE_TEMPLATE/6-security-report.md deleted file mode 100644 index 213f64c6..00000000 --- a/.github/ISSUE_TEMPLATE/6-security-report.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -name: "⚠️ Security Report" -about: "Report an issue to help the project improve." -title: "" -labels: "security" -assignees: "" - ---- - - - -# **⚠️ Security Report** - -## **Describe the security issue** - - -* - ---- - -### **To Reproduce** - - - - - -1. -2. -3. -4. - ---- - -### **Expected behaviour** - - -* - ---- - -### **Media prove** - - ---- - -### **Your environment** - - - -* OS: -* Browser name and version: -* Honcho Server Version: -* Honcho Client Version: - ---- - -### **Additional context** - - -* diff --git a/.github/ISSUE_TEMPLATE/7-question-support.md b/.github/ISSUE_TEMPLATE/7-question-support.md deleted file mode 100644 index 894359f4..00000000 --- a/.github/ISSUE_TEMPLATE/7-question-support.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: "❓ Question or Support Request" -about: "Questions and requests for support." -title: "" -labels: "question" -assignees: "" - ---- - -# **❓ Question or Support Request** - -## **Describe your question or ask for support.** - - -* - - diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..237b4532 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/plastic-labs/honcho/security/advisories/new + about: Private vulnerability reporting only — do not file public security issues. + - name: Question or support + url: https://discord.gg/honcho + about: Ask the community and maintainers on Discord. + - name: Documentation + url: https://honcho.dev/docs + about: Guides, API reference, and self-hosting docs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f0f44b5..23eb7c58 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -196,17 +196,21 @@ We welcome various types of contributions: When reporting bugs or requesting features: 1. Check if the issue already exists -2. Use the appropriate issue template +2. Use the appropriate [issue template](https://github.com/plastic-labs/honcho/issues/new/choose) (bug, memory/recall quality, feature, integration, or documentation) 3. Provide clear reproduction steps for bugs -4. Include relevant environment information +4. Include relevant environment information (managed vs self-hosted, server version, SDK) 5. Be specific about expected vs actual behavior +6. Redact secrets, JWTs, and production user content ## Questions and Support -- **General questions** - Join our [Discord](http://discord.gg/honcho) -- **Bug reports** - Use GitHub issues -- **Feature requests** - Use GitHub issues with the feature request template -- **Security issues** - Please email us privately rather than opening a public issue +- **General questions** - Join our [Discord](https://discord.gg/honcho) +- **Bug reports** - GitHub issues → Bug report template +- **Memory / recall quality** - GitHub issues → Memory / recall quality template +- **Feature requests** - GitHub issues → Feature request template +- **Integrations / plugins / app-store listings** - GitHub issues → Integration request template +- **Documentation issues** - GitHub issues → Documentation issue template +- **Security issues** - Report **privately** only — see [`SECURITY.md`](./SECURITY.md) (GitHub Private Vulnerability Reporting or email). Do not open a public issue. ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..172758d3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security Policy + +## Reporting a vulnerability + +**Do not file a public GitHub issue for security vulnerabilities.** + +Please report security issues privately using one of: + +1. **[GitHub Private Vulnerability Reporting](https://github.com/plastic-labs/honcho/security/advisories/new)** (preferred) +2. Email **** with subject line `[SECURITY] …` + +Include as much of the following as you can: + +- Description of the issue and its impact +- Steps to reproduce, or a proof of concept +- Affected component (API, deriver, auth/JWT, SDK, managed offering, etc.) +- Honcho version or image tag, and whether you are on managed or self-hosted + +Honcho stores conversational data and peer representations. **Do not** attach production user content, API keys, JWTs, or other secrets to a report unless we explicitly ask for a redacted sample. + +## What to expect + +We will acknowledge valid reports as soon as we can and will keep you updated on remediation status. Please give us a reasonable window to investigate and fix before any public disclosure. + +## Supported versions + +Security fixes are applied to the latest release on `main` and, when practical, to the most recent tagged release line. Older versions may not receive backports. + +## Non-security bugs + +For ordinary bugs, memory/recall quality issues, and feature requests, use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). diff --git a/docs/v2/contributing/guidelines.mdx b/docs/v2/contributing/guidelines.mdx index 398a8b09..12f44179 100644 --- a/docs/v2/contributing/guidelines.mdx +++ b/docs/v2/contributing/guidelines.mdx @@ -153,17 +153,21 @@ We welcome various types of contributions: When reporting bugs or requesting features: 1. Check if the issue already exists -2. Use the appropriate issue template +2. Use the appropriate [issue template](https://github.com/plastic-labs/honcho/issues/new/choose) (bug, memory/recall quality, feature, integration, or documentation) 3. Provide clear reproduction steps for bugs -4. Include relevant environment information +4. Include relevant environment information (managed vs self-hosted, server version, SDK) 5. Be specific about expected vs actual behavior +6. Redact secrets, JWTs, and production user content ## Questions and Support -- **General questions** - Join our [Discord](http://discord.gg/honcho) -- **Bug reports** - Use GitHub issues -- **Feature requests** - Use GitHub issues with the feature request template -- **Security issues** - Please email us privately rather than opening a public issue +- **General questions** - Join our [Discord](https://discord.gg/honcho) +- **Bug reports** - GitHub issues → Bug report template +- **Memory / recall quality** - GitHub issues → Memory / recall quality template +- **Feature requests** - GitHub issues → Feature request template +- **Integrations / plugins / app-store listings** - GitHub issues → Integration request template +- **Documentation issues** - GitHub issues → Documentation issue template +- **Security issues** - Report **privately** only — see [`SECURITY.md`](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md) (GitHub Private Vulnerability Reporting or email). Do not open a public issue. ## License diff --git a/docs/v3/contributing/guidelines.mdx b/docs/v3/contributing/guidelines.mdx index 398a8b09..12f44179 100644 --- a/docs/v3/contributing/guidelines.mdx +++ b/docs/v3/contributing/guidelines.mdx @@ -153,17 +153,21 @@ We welcome various types of contributions: When reporting bugs or requesting features: 1. Check if the issue already exists -2. Use the appropriate issue template +2. Use the appropriate [issue template](https://github.com/plastic-labs/honcho/issues/new/choose) (bug, memory/recall quality, feature, integration, or documentation) 3. Provide clear reproduction steps for bugs -4. Include relevant environment information +4. Include relevant environment information (managed vs self-hosted, server version, SDK) 5. Be specific about expected vs actual behavior +6. Redact secrets, JWTs, and production user content ## Questions and Support -- **General questions** - Join our [Discord](http://discord.gg/honcho) -- **Bug reports** - Use GitHub issues -- **Feature requests** - Use GitHub issues with the feature request template -- **Security issues** - Please email us privately rather than opening a public issue +- **General questions** - Join our [Discord](https://discord.gg/honcho) +- **Bug reports** - GitHub issues → Bug report template +- **Memory / recall quality** - GitHub issues → Memory / recall quality template +- **Feature requests** - GitHub issues → Feature request template +- **Integrations / plugins / app-store listings** - GitHub issues → Integration request template +- **Documentation issues** - GitHub issues → Documentation issue template +- **Security issues** - Report **privately** only — see [`SECURITY.md`](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md) (GitHub Private Vulnerability Reporting or email). Do not open a public issue. ## License From cf07068d95cee717a84dfaacdf08863ce8ca26fc Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:27:02 -0400 Subject: [PATCH 10/50] Vineeth/dev 2418 (#1057) * chore: update issue templates and docs * chore: slim bug/quality forms and add integration template Drop high-friction required fields from bug and quality issue forms. Add an integration-request form (app stores, plugins, frameworks) labeled integration, and point contributing docs at it. * chore: route security mail to support@ and polish docs intake Use support@honcho.dev for private vulnerability email. List the documentation template in contributing guides, rename Media prove, and add public-issue redaction/security redirects on the docs form. * fix: address render issue in templates and add version field * feat(docs): Initial draft of new contributing policies * feat(ci): defer issue-gate closes to a scheduled sweeper Addresses review feedback on #1041. The gate now reads GitHub's resolved closing references (closingIssuesReferences) instead of regex-parsing the pull request body, so an issue linked through the sidebar Development panel counts, and a bare `#123` mention no longer does. It also no longer closes on the pull request event. It labels and explains; pr-sweeper.yml re-checks every six hours and closes only what is still failing 72 hours after the notice. That re-check is load-bearing: linking an issue via the sidebar fires no webhook, so an event-only gate could never observe a contributor complying that way. The sweeper also closes drafts from outside the org after 30 days. The shared check lives in .github/scripts/issue-gate.js so both workflows run identical logic, with a dependency-free self-check wired into static analysis. Its one regression guard: author_association CONTRIBUTOR stays gated, since GitHub assigns it to anyone who has previously committed. Co-Authored-By: Claude Opus 5 (1M context) * chore(codeowners): drop the third reviewer from most areas Discussed with @akattelu. Also reassigns SECURITY.md to @Rajat-Ahuja1997 and strips trailing whitespace from the deployment block. Co-Authored-By: Claude Opus 5 (1M context) * docs(v2): port the issue gate policy into the v2 contributing guide The v2 guide is still published (v2.5.1 in docs.json) but carried no mention of the issue gate, so a contributor reading it would not learn that a pull request needs an approved issue until the bot labelled theirs. Ports the policy, both linking routes, and the gate's place among the automated checks, keeping the v2 guide's own structure and unwrapped prose rather than importing the v3 rewrite wholesale. Co-Authored-By: Claude Opus 5 (1M context) * fix(ci): count only bot-authored gate notices, share the exemption list Two review findings on the issue gate, with a common root cause. MARKER is an invisible HTML comment, so anyone who can comment on a public repository can paste it. findNotices accepted any comment containing it, so a third party could post one on someone else's pull request: runGate posts a notice only when none exists, so the author would never be told, and runSweep would then measure the 72-hour grace window from the stranger's timestamp and close them unwarned. Notices now require bot authorship. The stale-draft sweep re-listed the gate's exemptions and had lost the bot case, so a bot's long-lived draft was closable despite checkGate exempting bots. Both callers now share one exemptReason(pr) rather than keeping parallel lists that drift. Not changed: closingIssuesReferences(first: 20) truncation. It needs a pull request with 21+ closing references where only a later one carries the label, and the outcome would be a label plus the grace window, not a close. Coverage goes 11 -> 20 cases, including the stale-draft close path, which had none. Both fixes were confirmed to fail their tests when reverted. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Aakash Kattelu Co-authored-by: Claude Opus 5 (1M context) --- .github/CODEOWNERS | 44 ++- .github/scripts/issue-gate.js | 272 ++++++++++++++++ .github/scripts/issue-gate.test.js | 139 ++++++++ .github/workflows/issue-gate.yml | 37 +++ .github/workflows/pr-sweeper.yml | 41 +++ .github/workflows/staticanalysis.yml | 8 + CONTRIBUTING.md | 452 ++++++++++++++++++--------- README.md | 70 +---- SECURITY.md | 76 ++++- docs/v2/contributing/guidelines.mdx | 24 +- docs/v3/contributing/guidelines.mdx | 426 ++++++++++++++++++------- 11 files changed, 1233 insertions(+), 356 deletions(-) create mode 100644 .github/scripts/issue-gate.js create mode 100644 .github/scripts/issue-gate.test.js create mode 100644 .github/workflows/issue-gate.yml create mode 100644 .github/workflows/pr-sweeper.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 039d4e6e..09ed2279 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,6 +8,48 @@ # # The workflow gates only understand individual @usernames (no @org/team # entries). +# +# Order matters: GitHub applies the LAST matching pattern, so narrower rules +# go further down. Paths not listed here have no automatic reviewer. + +# Telemetry, tracing, metrics. +/src/telemetry/ @akattelu @Rajat-Ahuja1997 + +# Data model, connections, configuration, LLM transport. +/src/db.py @akattelu @eisene +/src/models.py @akattelu @eisene +/src/config.py @akattelu @eisene +/src/cache/ @akattelu @eisene +/src/crud/ @akattelu @eisene +/migrations/ @akattelu @eisene +/src/llm/ @akattelu @eisene + +# Client-facing surfaces and API shape. +/sdks/ @ajspig @akattelu +/mcp/ @ajspig @akattelu +/honcho-cli/ @ajspig @akattelu +/src/routers/ @ajspig @akattelu +/src/schemas/ @ajspig @akattelu + +# The reasoning agents, their prompts, and shared agent tooling. +/src/deriver/ @eisene @akattelu +/src/dreamer/ @eisene @akattelu +/src/dialectic/ @eisene @akattelu +/src/utils/ @eisene @akattelu + +# Deployment, and swappable storage and inference backends. +# /src/llm/backends/ must stay below /src/llm/ above — last match wins. +/docker/ @eisene @Rajat-Ahuja1997 +/Dockerfile @eisene @Rajat-Ahuja1997 +/docker-compose.yml.example @eisene @Rajat-Ahuja1997 +/src/vector_store/ @eisene @Rajat-Ahuja1997 +/src/llm/backends/ @eisene @Rajat-Ahuja1997 + +# Documentation and contributor-facing policy. +/docs/ @ajspig @akattelu +/README.md @ajspig @akattelu +/CONTRIBUTING.md @akattelu @ajspig +/SECURITY.md @Rajat-Ahuja1997 @ajspig # Reviewers auto-requested on changes under .github/ (workflows, this file, # templates). @@ -16,4 +58,4 @@ # 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 +/ci-trigger-allowlist @ajspig @courtlandleer @erosika @lowyelling @vintrocode diff --git a/.github/scripts/issue-gate.js b/.github/scripts/issue-gate.js new file mode 100644 index 00000000..c9bbb668 --- /dev/null +++ b/.github/scripts/issue-gate.js @@ -0,0 +1,272 @@ +'use strict'; + +/** + * Issue gate — shared logic for `.github/workflows/issue-gate.yml` (immediate + * feedback on pull request events) and `.github/workflows/pr-sweeper.yml` + * (deferred re-check, close, and stale-draft cleanup). + * + * Both workflows `require` this file through actions/github-script, so it must + * stay dependency-free: neither job runs an install step. + * + * See CONTRIBUTING.md for the policy this enforces. + */ + +const REQUIRED_LABEL = 'maintainer-approved'; +const GATE_LABEL = 'needs-approved-issue'; +const EXEMPT_LABEL = 'gate-exempt'; +const MARKER = ''; +const DISCORD = 'http://discord.gg/honcho'; + +// Hours a labelled pull request has before the sweeper closes it. Measured from +// the notice comment, so the clock starts when the author was actually told — +// not when the pull request was opened. +const GRACE_HOURS = 72; + +// Days without activity before a draft from outside the org is closed. +const DRAFT_STALE_DAYS = 30; + +const hasLabel = (pr, name) => (pr.labels || []).some((l) => l.name === name); + +const isBot = (account) => Boolean(account) && account.type === 'Bot'; + +/** + * Why this pull request is exempt from the gate, or null if it is not. + * + * Single source of truth: every caller that acts on a pull request runs this. + * The stale-draft sweep previously re-listed these checks and silently lost the + * bot case. + */ +const exemptReason = (pr) => { + if (isBot(pr.user)) return 'author is a bot'; + if (WRITE_ACCESS.includes(pr.author_association)) { + return `author_association is ${pr.author_association}`; + } + if (hasLabel(pr, EXEMPT_LABEL)) return `carries the ${EXEMPT_LABEL} label`; + return null; +}; + +// Write access to the repository. CONTRIBUTOR is deliberately absent: GitHub uses +// it for "has previously committed to the repository", which describes every +// returning outside contributor, not a maintainer. Do not add it. +const WRITE_ACCESS = ['OWNER', 'MEMBER', 'COLLABORATOR']; + +const CLOSING_ISSUES = ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 20) { + nodes { + number + state + labels(first: 50) { nodes { name } } + } + } + } + } + } +`; + +/** + * Decide whether a pull request clears the gate. + * + * Reads GitHub's own resolved issue links rather than parsing the body, so both + * `Fixes #123` and the sidebar "Development" link count. A bare `#123` mention + * deliberately does not — that is a reference, not a claim to close. + * + * @returns {Promise<{passed: boolean, skipped?: string, issue?: number, reason?: string}>} + */ +async function checkGate({ github, owner, repo, pr }) { + if (pr.state !== 'open') return { passed: true, skipped: 'pull request is not open' }; + if (pr.draft) return { passed: true, skipped: 'pull request is a draft' }; + const exempt = exemptReason(pr); + if (exempt) return { passed: true, skipped: exempt }; + + const data = await github.graphql(CLOSING_ISSUES, { owner, repo, number: pr.number }); + const issues = data.repository.pullRequest.closingIssuesReferences.nodes; + + if (issues.length === 0) { + return { passed: false, reason: 'This pull request is not linked to an issue.' }; + } + + const approved = issues.find( + (i) => i.state === 'OPEN' && i.labels.nodes.some((l) => l.name === REQUIRED_LABEL), + ); + if (approved) return { passed: true, issue: approved.number }; + + const detail = issues + .map((i) => `#${i.number} (${i.state === 'CLOSED' ? 'closed' : 'not approved'})`) + .join(', '); + return { + passed: false, + reason: + `The linked ${issues.length === 1 ? 'issue is' : 'issues are'} not open with the ` + + `\`${REQUIRED_LABEL}\` label: ${detail}.`, + }; +} + +function noticeBody({ owner, repo, reason }) { + return [ + MARKER, + 'Thanks for the contribution. This pull request does not clear our issue gate yet.', + '', + `**${reason}**`, + '', + `Every pull request to Honcho needs to be linked to an open issue carrying the \`${REQUIRED_LABEL}\` label. We do this so the review queue only holds work we have already agreed should be built — it means nobody spends time on a change we cannot merge.`, + '', + 'To get this moving:', + '', + `1. Find or open an issue describing the change. [Approved issues are here](https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3A${REQUIRED_LABEL}).`, + `2. Make the case for it in [Discord](${DISCORD}) — maintainers are most active there, and it is by far the fastest route to a decision.`, + `3. Once the issue has the label, link it: put \`Fixes #\` in this pull request's description, or use **Development** in the sidebar.`, + '', + `**This will close automatically in ${GRACE_HOURS} hours if it is still unlinked.** Nothing is lost if that happens — link the issue, reopen, and it goes into the review queue.`, + '', + `See [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md) for the full process. If you think this is wrong, say so here and a maintainer will take a look.`, + ].join('\n'); +} + +/** + * Every gate notice this bot posted on a pull request, oldest first. + * + * Authorship is part of the test, not decoration. MARKER is an invisible HTML + * comment, so anyone who can comment on a public repository can paste it. If + * user comments counted, a third party could post one on someone else's pull + * request: `runGate` posts a notice only when none exists, so the author would + * never be told, and `runSweep` would then measure the grace window from the + * stranger's timestamp and close them unwarned. + */ +async function findNotices({ github, owner, repo, number }) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: number, per_page: 100, + }); + return comments.filter((c) => isBot(c.user) && (c.body || '').includes(MARKER)); +} + +/** + * Drop the gate label and delete the notice. + * + * Deleting matters: `runGate` posts a notice only when none exists, and the + * sweeper measures grace from the notice timestamp. A notice left behind after + * the gate clears would make a later re-block look weeks old and be closed with + * no warning. + */ +async function clearGate({ github, owner, repo, pr }) { + if (hasLabel(pr, GATE_LABEL)) { + await github.rest.issues + .removeLabel({ owner, repo, issue_number: pr.number, name: GATE_LABEL }) + .catch(() => {}); + } + for (const notice of await findNotices({ github, owner, repo, number: pr.number })) { + await github.rest.issues + .deleteComment({ owner, repo, comment_id: notice.id }) + .catch(() => {}); + } +} + +/** + * Entry point for `.github/workflows/issue-gate.yml`. + * Labels and explains. Never closes — that is the sweeper's job. + */ +async function runGate({ github, core, context }) { + const pr = context.payload.pull_request; + const { owner, repo } = context.repo; + const result = await checkGate({ github, owner, repo, pr }); + + if (result.passed) { + core.info( + result.skipped ? `Skipping gate: ${result.skipped}` : `Gate passed via #${result.issue}`, + ); + await clearGate({ github, owner, repo, pr }); + return; + } + + core.warning(`Gate failed: ${result.reason}`); + await github.rest.issues.addLabels({ + owner, repo, issue_number: pr.number, labels: [GATE_LABEL], + }); + + const notices = await findNotices({ github, owner, repo, number: pr.number }); + if (notices.length > 0) return; + + await github.rest.issues.createComment({ + owner, repo, issue_number: pr.number, + body: noticeBody({ owner, repo, reason: result.reason }), + }); +} + +/** Entry point for `.github/workflows/pr-sweeper.yml`. */ +async function runSweep({ github, core, context, dryRun }) { + const { owner, repo } = context.repo; + + const act = async (what, fn) => { + core.info(dryRun ? `[dry run] ${what}` : what); + if (!dryRun) await fn(); + }; + + const close = (pr, body) => async () => { + await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body }); + await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'closed' }); + }; + + const prs = await github.paginate(github.rest.pulls.list, { + owner, repo, state: 'open', per_page: 100, + }); + core.info(`${prs.length} open pull requests${dryRun ? ' (dry run)' : ''}`); + + // Re-check everything wearing the gate label. Never close blind: a pull request + // linked through the sidebar fires no webhook, so the gate workflow cannot have + // noticed it — this pass is the only thing that will. + for (const pr of prs.filter((p) => hasLabel(p, GATE_LABEL))) { + const result = await checkGate({ github, owner, repo, pr }); + + if (result.passed) { + const why = result.skipped || `via #${result.issue}`; + await act(`#${pr.number}: gate now clear (${why})`, async () => { + await clearGate({ github, owner, repo, pr }); + await github.rest.issues.createComment({ + owner, repo, issue_number: pr.number, + body: 'The issue link is in place — this pull request has cleared the gate and is waiting on review.', + }); + }); + continue; + } + + const [notice] = await findNotices({ github, owner, repo, number: pr.number }); + if (!notice) { + core.info(`#${pr.number}: labelled but never notified — leaving it for the gate workflow`); + continue; + } + + const hours = (Date.now() - Date.parse(notice.created_at)) / 3_600_000; + if (hours < GRACE_HOURS) { + core.info(`#${pr.number}: ${Math.round(GRACE_HOURS - hours)}h of grace left`); + continue; + } + + await act(`#${pr.number}: closing — notified ${Math.round(hours)}h ago, still failing`, close(pr, + `Closing this: ${GRACE_HOURS} hours have passed and the gate is still not clear. This is not a judgement on the code. Link an approved issue and reopen — it goes straight into the review queue.`, + )); + } + + // Stale drafts. The gate skips drafts entirely, so they never carry the label; + // this pass keys off inactivity and applies the shared exemptions itself. + for (const pr of prs.filter((p) => p.draft)) { + const exempt = exemptReason(pr); + if (exempt) { + core.info(`#${pr.number}: leaving stale draft alone — ${exempt}`); + continue; + } + + const days = (Date.now() - Date.parse(pr.updated_at)) / 86_400_000; + if (days < DRAFT_STALE_DAYS) continue; + + await act(`#${pr.number}: closing stale draft — ${Math.round(days)}d without activity`, close(pr, + `Closing this draft after ${DRAFT_STALE_DAYS} days without activity, to keep the pull request list readable. Reopen whenever you pick it back up — nothing here is lost.`, + )); + } +} + +module.exports = { + checkGate, runGate, runSweep, noticeBody, findNotices, exemptReason, + REQUIRED_LABEL, GATE_LABEL, EXEMPT_LABEL, MARKER, GRACE_HOURS, DRAFT_STALE_DAYS, +}; diff --git a/.github/scripts/issue-gate.test.js b/.github/scripts/issue-gate.test.js new file mode 100644 index 00000000..695df3a2 --- /dev/null +++ b/.github/scripts/issue-gate.test.js @@ -0,0 +1,139 @@ +'use strict'; + +// Self-check for the gate decision logic. No framework, no install: +// node .github/scripts/issue-gate.test.js +// Covers checkGate() only — the side-effecting halves (runGate/runSweep) are +// exercised against the real API via `pr-sweeper.yml`'s dry_run dispatch. + +const assert = require('node:assert'); +const { + checkGate, findNotices, runSweep, REQUIRED_LABEL, EXEMPT_LABEL, MARKER, +} = require('./issue-gate.js'); + +const pull = (over = {}) => ({ + number: 1, state: 'open', draft: false, + user: { type: 'User' }, author_association: 'NONE', labels: [], + ...over, +}); + +// `linked` is the list of issues GitHub resolves as closing references. +const stub = (linked) => ({ + graphql: async () => ({ + repository: { pullRequest: { closingIssuesReferences: { + nodes: linked.map((i) => ({ + number: i.number, state: i.state || 'OPEN', + labels: { nodes: (i.labels || []).map((name) => ({ name })) }, + })), + } } }, + }), +}); + +const run = (linked, over) => + checkGate({ github: stub(linked), owner: 'o', repo: 'r', pr: pull(over) }); + +const cases = [ + ['no linked issue fails', () => run([]), (r) => r.passed === false], + ['linked but unapproved fails', () => run([{ number: 7 }]), (r) => r.passed === false], + ['linked and approved passes', + () => run([{ number: 7, labels: [REQUIRED_LABEL] }]), + (r) => r.passed === true && r.issue === 7], + ['approved but closed fails', + () => run([{ number: 7, state: 'CLOSED', labels: [REQUIRED_LABEL] }]), + (r) => r.passed === false], + ['picks the approved one out of several', + () => run([{ number: 7 }, { number: 8, labels: [REQUIRED_LABEL] }]), + (r) => r.passed === true && r.issue === 8], + + // Exemptions. + ['maintainer skips', () => run([], { author_association: 'MEMBER' }), (r) => r.passed === true], + ['collaborator skips', () => run([], { author_association: 'COLLABORATOR' }), (r) => r.passed === true], + ['bot skips', () => run([], { user: { type: 'Bot' } }), (r) => r.passed === true], + ['draft skips', () => run([], { draft: true }), (r) => r.passed === true], + [`${EXEMPT_LABEL} skips`, () => run([], { labels: [{ name: EXEMPT_LABEL }] }), (r) => r.passed === true], + + // Regression guard: GitHub hands CONTRIBUTOR to anyone who has previously + // committed, i.e. every returning outside contributor. It must stay gated. + ['CONTRIBUTOR is still gated', + () => run([], { author_association: 'CONTRIBUTOR' }), + (r) => r.passed === false], +]; + +// --- findNotices: only the bot's own notices count ------------------------- +// A stranger pasting the invisible MARKER into a comment must not suppress the +// notice or become the grace-window clock. +const commentsStub = (comments) => ({ + paginate: async () => comments, + rest: { issues: { listComments: null } }, +}); + +const noticeCases = [ + ['a user comment carrying MARKER is not a notice', + [{ id: 1, user: { type: 'User' }, body: `sneaky ${MARKER}`, created_at: 'x' }], 0], + ['a bot comment carrying MARKER is a notice', + [{ id: 2, user: { type: 'Bot' }, body: `${MARKER}\nnotice`, created_at: 'x' }], 1], + ['a bot comment without MARKER is not a notice', + [{ id: 3, user: { type: 'Bot' }, body: 'unrelated', created_at: 'x' }], 0], + ['a user MARKER does not mask the real bot notice', + [{ id: 4, user: { type: 'User' }, body: MARKER, created_at: 'x' }, + { id: 5, user: { type: 'Bot' }, body: MARKER, created_at: 'y' }], 1], +]; + +// --- runSweep: the stale-draft pass must honour every exemption ------------ +const draft = (over) => ({ + number: 9, draft: true, state: 'open', labels: [], + user: { type: 'User' }, author_association: 'NONE', + updated_at: new Date(Date.now() - 400 * 86400_000).toISOString(), + ...over, +}); + +async function sweepClosed(pr) { + const closed = []; + const github = { + paginate: async (route) => (route === 'pulls' ? [pr] : []), + rest: { + pulls: { + list: 'pulls', + update: async ({ pull_number }) => closed.push(pull_number), + }, + issues: { listComments: 'comments', createComment: async () => {} }, + }, + }; + await runSweep({ + github, core: { info() {}, warning() {} }, + context: { repo: { owner: 'o', repo: 'r' } }, dryRun: false, + }); + return closed; +} + +const sweepCases = [ + ['stale draft from an outside author closes', draft({}), 1], + ['stale draft from a bot is left alone', draft({ user: { type: 'Bot' } }), 0], + ['stale draft from a maintainer is left alone', draft({ author_association: 'MEMBER' }), 0], + [`stale draft with ${EXEMPT_LABEL} is left alone`, draft({ labels: [{ name: EXEMPT_LABEL }] }), 0], + ['recent draft is left alone', draft({ updated_at: new Date().toISOString() }), 0], +]; + +(async () => { + let failed = 0; + for (const [name, comments, want] of noticeCases) { + const got = (await findNotices({ github: commentsStub(comments), owner: 'o', repo: 'r', number: 1 })).length; + if (got === want) console.log(` ok ${name}`); + else { failed++; console.log(` FAIL ${name} -> ${got} notices, wanted ${want}`); } + } + for (const [name, pr, want] of sweepCases) { + const got = (await sweepClosed(pr)).length; + if (got === want) console.log(` ok ${name}`); + else { failed++; console.log(` FAIL ${name} -> closed ${got}, wanted ${want}`); } + } + for (const [name, thunk, ok] of cases) { + const result = await thunk(); + if (ok(result)) { + console.log(` ok ${name}`); + } else { + failed++; + console.log(` FAIL ${name} -> ${JSON.stringify(result)}`); + } + } + assert.strictEqual(failed, 0, `${failed} case(s) failed`); + console.log(`\n${cases.length + noticeCases.length + sweepCases.length} passed`); +})(); diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml new file mode 100644 index 00000000..74df40c7 --- /dev/null +++ b/.github/workflows/issue-gate.yml @@ -0,0 +1,37 @@ +name: Issue Gate + +# Labels pull requests that are not linked to an issue carrying the +# `maintainer-approved` label, and comments explaining how to fix it. +# +# This workflow never closes anything. `pr-sweeper.yml` re-checks later and closes +# only after the grace period — that gives contributors time to link an issue, and +# gives maintainers time to wave through a one-line fix. It is also the only thing +# that can notice a sidebar issue link, which fires no webhook of its own. +# +# `pull_request_target` is required so the job has write access on pull requests +# from forks. It must therefore NEVER run code from the pull request. The checkout +# below is safe because on `pull_request_target` actions/checkout defaults to the +# BASE ref, which is repo-trusted code. Never point it at `pr.head.sha`. +# +# Not triggered on `synchronize`: re-running on every push would be noise. +# Drafts are ignored until marked ready. + +on: + pull_request_target: + types: [opened, edited, reopened, ready_for_review] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/github-script@v7 + with: + script: | + const gate = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-gate.js`); + await gate.runGate({ github, core, context }); diff --git a/.github/workflows/pr-sweeper.yml b/.github/workflows/pr-sweeper.yml new file mode 100644 index 00000000..d5bde42c --- /dev/null +++ b/.github/workflows/pr-sweeper.yml @@ -0,0 +1,41 @@ +name: PR Sweeper + +# Deferred half of the issue gate. Every six hours: +# +# 1. Re-check every pull request carrying `needs-approved-issue`. Clear the ones +# that now link an approved issue; close the ones still failing 72h after they +# were told. The re-check is the point — linking an issue through the sidebar +# fires no webhook, so `issue-gate.yml` never sees it. +# 2. Close drafts from outside the org after 30 days without activity. +# +# Runs on `schedule`, so it never touches pull request code and needs none of the +# `pull_request_target` precautions. Dispatch manually with dry_run to see what it +# would do before it does it. + +on: + schedule: + - cron: '17 */6 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Log intended actions without closing anything' + type: boolean + default: true + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + sweep: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/github-script@v7 + env: + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} + with: + script: | + const gate = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-gate.js`); + await gate.runSweep({ github, core, context, dryRun: process.env.DRY_RUN === 'true' }); diff --git a/.github/workflows/staticanalysis.yml b/.github/workflows/staticanalysis.yml index 18bb37fd..9cad8b10 100644 --- a/.github/workflows/staticanalysis.yml +++ b/.github/workflows/staticanalysis.yml @@ -26,3 +26,11 @@ jobs: run: uv sync --all-extras --dev - name: run basedpyright run: uv run basedpyright + + issue-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # The gate runs from `pull_request_target`, where a crash is invisible until + # a contributor's PR is silently ungated. Check it here instead. + - run: node .github/scripts/issue-gate.test.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23eb7c58..7b131f70 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,219 +1,371 @@ # Contributing to Honcho -Thank you for your interest in contributing to Honcho! This guide outlines the process for contributing to the project and our development conventions. + -## Getting Started +Thanks for your interest in contributing. This guide covers how work gets accepted, how +Honcho is put together, and what a mergeable pull request looks like. -Before you start contributing, please: +Honcho is a small team maintaining a project that gets more proposals than we can review. +The rules below exist so that the work you do has somewhere to land — not to keep you out. -1. **Set up your development environment** - Follow the [Local Development guide](./README.md#local-development) in the README to get Honcho running locally. +## Contents -2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions. +- [Before you write code](#before-you-write-code) +- [What gets prioritized](#what-gets-prioritized) +- [If you're an agent](#if-youre-an-agent) +- [How Honcho works](#how-honcho-works) +- [Where to change what](#where-to-change-what) +- [Local setup](#local-setup) +- [Making the change](#making-the-change) +- [Opening the pull request](#opening-the-pull-request) +- [Reporting bugs and requesting features](#reporting-bugs-and-requesting-features) +- [Security](#security) +- [License](#license) -3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to. +## Before you write code -## Contribution Workflow +**Every pull request needs an issue, and that issue needs the `maintainer-approved` label.** -### 1. Fork and Clone +A pull request that is not linked to an approved issue gets labelled +`needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one +before it is closed automatically. Reopening costs nothing once the link is in place. This +is automated. We do this because an unreviewable backlog helps nobody: a PR against an +unapproved issue is work you did that we may not be able to merge, no matter how good it +is. -1. Fork the repository on GitHub -2. Clone your fork locally: +So, in order: - ```bash - git clone https://github.com/YOUR_USERNAME/honcho.git - cd honcho - ``` +1. **Find approved work.** Browse + [issues labelled `maintainer-approved`](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved). + That label is the queue of things we have agreed should be built. Anything in it is fair + game — comment on the issue to claim it. -3. Add the upstream repository as a remote: +2. **Or open an issue and get it approved.** Use the + [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). Maintainers + triage and apply the label. - ```bash - git remote add upstream https://github.com/plastic-labs/honcho.git - ``` +3. **If you feel strongly about an issue, come to [Discord](https://discord.gg/honcho).** + This is the fastest path by a wide margin. Maintainers are more active there than in the + issue tracker, and a five-minute conversation about what you want to build usually + resolves whether it fits before either side spends real time on it. -### 2. Create a Branch +4. **Then open the PR** and link the issue — either `Fixes #123` in the description, or + **Development → link an issue** in the sidebar. Both work. -Create a new branch for your feature or bug fix: +Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an +obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the +issue linkage. + +## What gets prioritized + +Roughly, work on Honcho falls along these axes. Knowing which one your idea sits on tells +you a lot about how likely it is to get approved. + +| Axis | What it covers | +| --- | --- | +| **Observability** | Understanding how Honcho behaves in production — telemetry, tracing, CloudEvents, metrics. | +| **Memory quality** | Better conclusions from the same input — the deriver, dreamer, and dialectic; eval results. | +| **Developer experience** | Fitting cleanly into more application architectures — SDKs, scopes, composable peers, the CLI. | +| **Breadth of input** | Widening what Honcho can ingest and represent — multimodal and non-conversational data. | +| **Ubiquity** | Reachable wherever a developer already works — integrations, self-hosting, alternate vector-store and inference backends, local-first defaults. | +| **Reliability and cost** | Trustworthy in production — connection and concurrency hardening, queue throughput, cost per token. | + +In practice, **Ubiquity** and **Developer experience** are where outside contributions land +most easily. A new integration, a self-hosting rough edge, a vector-store or inference +backend, an SDK ergonomics fix — these are additive and rarely collide with work already in +flight. + +Changes to the reasoning pipeline itself — deriver prompts, dialectic tool design, dreamer +strategy — are the hardest to accept from outside. Not because they are unwelcome, but +because they are measured against eval results we run internally, and they frequently +conflict with in-flight work. Talk to us in Discord first, always. + +## If you're an agent + +If you are a coding agent working on this repository, read this section before writing code. +The most common failure we see is a well-formed, well-tested pull request against an issue +that was never approved. That gets closed, and the work is wasted. + +- **Check the gate first.** Before writing code: + + ```bash + gh issue view --repo plastic-labs/honcho --json number,title,state,labels + ``` + + Stop if there is no issue number, if the issue is closed, or if `maintainer-approved` is + not in the labels. Report that to the person you are working with instead of proceeding. + +- **Do not open a PR in order to establish the issue link afterwards.** The issue comes + first. + +- **Do not report checks you did not run.** If you did not execute the test command, say so. + A PR body claiming a green run that did not happen costs a maintainer more time than no + claim at all. + +- **Use the checklist.** [`skills/pre-pr/SKILL.md`](./skills/pre-pr/SKILL.md) in this repo + encodes the gate, the test-layer matrix, and the PR body format. If your harness supports + skills, invoke it rather than reimplementing the checks. + +## How Honcho works + +Enough architecture to find your way around. For the user-facing model — what a Peer is, what +`get_context` returns — see [Core Concepts in the README](./README.md#core-concepts) and the +[documentation](https://honcho.dev/docs/). + +### Two processes + +Honcho runs as two cooperating processes over a shared Postgres database and Redis cache. + +| | API server | Deriver worker | +| --- | --- | --- | +| Start | `uv run fastapi dev src/main.py` | `uv run python -m src.deriver` | +| Entry | `src/main.py` | `src/deriver/__main__.py` | +| Does | Serves HTTP, enqueues background work, returns immediately | Consumes the queue: Deriver, Summarizer, Dreamer, Reconciler | +| Hosts | The Dialectic agent, inline on the request path | Everything else | + +The split is the load-bearing design decision: **an HTTP request never blocks on LLM work**, +with the single exception of the Dialectic chat endpoint, which is synchronous by nature. +If you are adding something slow, it belongs in the worker. + +The deriver is a separate process. If messages go in and nothing ever comes out, the usual +cause is that nobody started it. + +### The path of a message + +Worth tracing once, because it crosses most of the codebase: + +1. `POST /v3/workspaces/{w}/sessions/{s}/messages` lands in `src/routers/messages.py`. +2. The row is written, then `enqueue()` in `src/deriver/enqueue.py` creates `queue_item` + rows — one set of work per observing peer. +3. `src/deriver/queue_manager.py` polls the queue, claiming work units so that messages in a + session are processed in order. +4. `process_item()` in `src/deriver/consumer.py` dispatches on task type — representation, + summary, deletion, reconciliation. +5. For a representation task, `process_representation_tasks_batch()` in + `src/deriver/deriver.py` makes **one structured-output LLM call for the whole batch** and + writes the resulting conclusions into the collection keyed by the + `(observer, observed)` peer pair. +6. Later, `src/dialectic/` reads those conclusions back at recall time to answer a chat + request. + +Embedding is deliberately *not* on this path. `MessageEmbedding` rows are written with +`sync_state='pending'` and embedded asynchronously by the Reconciler +(`src/reconciler/sync_vectors.py`), which runs on a scheduler inside the deriver process. + +### The four agents + +They share tool definitions in `src/utils/agent_tools.py` and the provider-agnostic LLM +client in `src/llm/`. Each has its own `MODEL_CONFIG` with a fallback chain in +`src/config.py`. + +| Agent | Where | Shape | +| --- | --- | --- | +| **Deriver** | `src/deriver/` | A single structured-output call per message batch. Not a tool loop — this is a deliberate cost and latency tradeoff. | +| **Dialectic** | `src/dialectic/` | The one tool-using agent on the request path. Loops over tools until it can answer. Five reasoning tiers from `minimal` to `max`, each with its own model and tool set. | +| **Dreamer** | `src/dreamer/` | Off-queue consolidation. Two specialist phases (deduction, then induction) that build reasoning trees over existing conclusions. | +| **Summarizer** | `src/utils/summarizer.py` | Direct LLM call, no tools. Two tiers — short and long summaries at different message counts. | + +Prompts live in `src/deriver/prompts.py`, `src/dialectic/prompts.py`, and +`src/dreamer/specialists.py`. + +### A note on naming + +What the public API and documentation call **conclusions** are called **observations** +throughout the code — `create_observations`, `get_observation_context`, and so on. Likewise +**collections** and **documents** are internal storage concepts that are not exposed +directly through the API. Do not rename across that boundary in a drive-by change; the +public and internal vocabularies are being reconciled deliberately. + +## Where to change what + +| I want to change... | Start here | +| --- | --- | +| An HTTP endpoint | `src/routers/` — one module per resource | +| A database query | `src/crud/` — mirrors the router layout | +| The database schema | `src/models.py`, plus a migration in `migrations/versions/` | +| A configuration value | `src/config.py`, and add it to `config.toml.example` and `.env.template` | +| A tool an agent can call | `src/utils/agent_tools.py` — definitions plus the per-agent tool lists | +| A prompt | `src/deriver/prompts.py`, `src/dialectic/prompts.py`, `src/dreamer/specialists.py` | +| LLM provider behavior | `src/llm/backends/` — `anthropic.py`, `gemini.py`, `openai.py` | +| Embeddings or vector storage | `src/embedding_client.py`, `src/vector_store/` | +| Telemetry or metrics | `src/telemetry/` — see the notes in `CLAUDE.md` before adding an event type | +| Authentication and scoping | `src/security.py`, `src/dependencies.py` | +| The Python or TypeScript SDK | `sdks/python/`, `sdks/typescript/` | +| The CLI | `honcho-cli/` | +| The MCP server | `mcp/` | +| Public documentation | `docs/v3/` — Mintlify; nav lives in `docs/docs.json` | + +Tests in `tests/` mirror `src/`. `CLAUDE.md` at the repo root has more detail on house +conventions, and is worth skimming even if you are not using an agent. + +## Local setup + +Get a stack running first — [Self-hosting in the README](./README.md#self-hosting) covers +both the Docker path and a manual Postgres setup. Then, for development: + +```bash +uv sync # create the venv and install dependencies +uv run alembic upgrade head # apply migrations +``` + +Run both processes, in separate terminals: + +```bash +uv run fastapi dev src/main.py # API server, reloads on change +uv run python -m src.deriver # background worker +``` + +Everything Python goes through `uv run`. Redis is optional for local development; without it +caching is simply disabled. + +## Making the change + +### Branches and commits ```bash git checkout -b feature/your-feature-name -# or -git checkout -b fix/your-bug-fix-name ``` -**Branch naming conventions:** +Prefixes: `feature/`, `fix/`, `docs/`, `refactor/`, `test/`. -- `feature/description` - for new features -- `fix/description` - for bug fixes -- `docs/description` - for documentation updates -- `refactor/description` - for code refactoring -- `test/description` - for adding or updating tests - -### 3. Make Your Changes - -- Write clean, readable code that follows our coding standards (see below) -- Add tests for new functionality -- Update documentation as needed -- Make sure your changes don't break existing functionality - -### 4. Commit Your Changes - -We follow conventional commit standards. Format your commit messages as: - -``` -type(scope): description - -[optional body] - -[optional footer] -``` - -**Types:** - -- `feat`: A new feature -- `fix`: A bug fix -- `docs`: Documentation only changes -- `style`: Changes that do not affect the meaning of the code -- `refactor`: A code change that neither fixes a bug nor adds a feature -- `test`: Adding missing tests or correcting existing tests -- `chore`: Changes to the build process or auxiliary tools - -**Examples:** +Commits follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by a +`commit-msg` hook: ```bash git commit -m "feat(api): add new dialectic endpoint for user insights" git commit -m "fix(db): resolve connection pool timeout issue" -git commit -m "docs(readme): update installation instructions" ``` -### 5. Submit a Pull Request +Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`. -1. Push your branch to your fork: +### Pre-commit hooks - ```bash - git push origin your-branch-name - ``` - -2. Create a pull request on GitHub from your branch to the `main` branch - -3. Fill out the pull request template with: - - A clear description of what changes you've made - - The motivation for the changes - - Any relevant issue numbers (use "Closes #123" to auto-close issues) - - Screenshots or examples if applicable - -## Pre-commit Hooks - -Honcho uses pre-commit hooks to enforce code quality and consistency. They run linting, formatting, type checking, and security scans before each commit. - -### Installation +Install them. CI runs the same checks, and it is much faster to find out locally. ```bash -uv add --dev pre-commit uv run pre-commit install \ --hook-type pre-commit \ --hook-type commit-msg \ --hook-type pre-push ``` -### What the hooks do +At **commit** time: ruff lint and format, biome for TypeScript, basedpyright, bandit, +markdownlint, and file hygiene. At **push** time: pytest, the alembic migration tests, and +the SDK builds. -- **Code Quality** — Python linting and formatting (ruff), TypeScript linting (biome) -- **Type Checking** — Static analysis with basedpyright -- **Security** — Vulnerability scanning with bandit -- **Documentation** — Markdown linting and license header checks -- **Testing** — Automated test runs for Python and TypeScript -- **File Hygiene** — Trailing whitespace, line endings, file size checks -- **Commit Standards** — Conventional commit message validation +That split matters — **a clean commit is not a clean push.** The test suite only runs at +`pre-push`, so the first time you see test failures may be well after you thought you were +done. -### Manual execution - -Run against all files without committing: +Run them by hand at any time: ```bash uv run pre-commit run --all-files +uv run pre-commit run ruff --all-files ``` -Run a specific hook: +Or the individual tools: ```bash -uv run pre-commit run ruff --all-files -uv run pre-commit run basedpyright --all-files +uv run ruff check src/ +uv run ruff format src/ +uv run basedpyright ``` -## Coding Standards +### Tests -### Python Code Style +Write tests for new functionality, in the directory under `tests/` that mirrors the code you +changed. Which layer you need depends on what you touched: -- Follow [PEP 8](https://www.python.org/dev/peps/pep-0008/) style guidelines -- Use [ruff](https://docs.astral.sh/ruff/) for linting and code formatting -- Use type hints where possible -- Write docstrings for functions and classes using Google style docstrings +| What you changed | What to run | +| --- | --- | +| Anything in `src/` | Unit tests in the matching `tests/` tree — `uv run pytest tests/...` | +| Deriver, dialectic, dreamer, or the LLM path | Unit tests, and consider `tests/live_llm/` (gated behind `--live-llm`) | +| Queue behavior, config hierarchy, multi-turn flows, SDK contracts | `uv run python -m tests.unified.run` | +| A `/v3` endpoint or deriver queue behavior | Actually run the stack and exercise it — not just pytest | +| A migration | `uv run python scripts/run_alembic_tests.py`; every revision needs a test file | -### Code Organization - -- Keep functions focused and single-purpose -- Use meaningful variable and function names -- Add comments for complex logic -- Follow existing patterns in the codebase - -### Testing - -- Write unit tests for new functionality -- Ensure existing tests pass before submitting -- Use descriptive test names that explain what is being tested -- Mock external dependencies appropriately +The TypeScript SDK tests need a running server with a database and Redis, which pytest +orchestrates. Run them with `uv run pytest tests/ -k typescript` from the repo root — +`bun test` on its own will fail. To type-check the SDK alone: +`cd sdks/typescript && bun run tsc --noEmit`. ### Documentation -- Update relevant documentation for new features -- Include examples in docstrings where helpful -- Keep README and other docs up to date with changes +Update docs in the same PR when you change a public surface: `/v3` endpoints, SDK exports, +or anything in `config.toml` / settings. Docs live in `docs/v3/`, and new pages need an entry +in `docs/docs.json` or they will not appear in the nav. -## Review Process +## Opening the pull request -1. **Automated checks** - Your PR will run through automated checks including tests and linting -2. **Project maintainer review** - A project maintainer will review your code for: - - Code quality and adherence to standards - - Functionality and correctness - - Test coverage - - Documentation completeness -3. **Discussion and iteration** - You may be asked to make changes or clarifications -4. **Approval and merge** - Once approved, your PR will be merged into `main` +### Leave "Allow edits by maintainers" checked -## Types of Contributions +This is the single most useful thing you can do to get your PR merged quickly. -We welcome various types of contributions: +Most contributor PRs arrive nearly right, needing a rename, a missing test, or a lint fix. +If we can push that commit ourselves, it merges the same day. If we cannot, it becomes a +review comment, and then we wait — sometimes for weeks — for a round trip on a two-line +change. -- **Bug fixes** - Help us squash bugs and improve stability -- **New features** - Add functionality that benefits the community -- **Documentation** - Improve or expand our documentation -- **Tests** - Increase test coverage and reliability -- **Performance improvements** - Help make Honcho faster and more efficient -- **Examples and tutorials** - Help other developers use Honcho +GitHub checks the box by default when you fork. Leave it checked. -## Issue Reporting +One caveat worth knowing: **the option does not exist on forks owned by an organization.** +If you have the choice, fork from your personal account. -When reporting bugs or requesting features: +### Fill out the template -1. Check if the issue already exists -2. Use the appropriate [issue template](https://github.com/plastic-labs/honcho/issues/new/choose) (bug, memory/recall quality, feature, integration, or documentation) -3. Provide clear reproduction steps for bugs -4. Include relevant environment information (managed vs self-hosted, server version, SDK) -5. Be specific about expected vs actual behavior -6. Redact secrets, JWTs, and production user content +`.github/pull_request_template.md` asks for a description, proofs, and the issue checkbox. -## Questions and Support +"Proofs" means evidence the change works: the command you ran and its result, a log snippet, +a screenshot, the failing case before and after. This is the section that most determines +how fast your PR gets reviewed. Do not add sections to the template. -- **General questions** - Join our [Discord](https://discord.gg/honcho) -- **Bug reports** - GitHub issues → Bug report template -- **Memory / recall quality** - GitHub issues → Memory / recall quality template -- **Feature requests** - GitHub issues → Feature request template -- **Integrations / plugins / app-store listings** - GitHub issues → Integration request template -- **Documentation issues** - GitHub issues → Documentation issue template -- **Security issues** - Report **privately** only — see [`SECURITY.md`](./SECURITY.md) (GitHub Private Vulnerability Reporting or email). Do not open a public issue. +Link the issue so the gate can see it: `Fixes #123` in the description, or the +**Development** section of the sidebar. The gate reads GitHub's own resolved issue links, so +either route works — but a bare `#123` mention is only a reference and does not count. + +### Review + +1. Automated checks run — tests, linting, static analysis, and the issue gate. +2. A maintainer reviews for correctness, test coverage, and fit with the surrounding code. + `.github/CODEOWNERS` routes the request to whoever owns the area you touched. +3. You may be asked for changes. Or we may just push them, if you left edits enabled. +4. Once approved, we merge to `main`. + +If a PR goes quiet, nudge us in [Discord](https://discord.gg/honcho). + +## Reporting bugs and requesting features + +Use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). There is +one per kind of report, and picking the right one is most of what gets an issue triaged +quickly: + +- **Bug report** — something is broken or behaves incorrectly +- **Memory / recall quality** — the deriver or dialectic returns poor, wrong, or missing context +- **Feature request** — a new capability or API surface +- **Integration request** — plugins, framework integrations, app-store listings +- **Documentation issue** — anything wrong or missing in the docs +- **General questions** — not an issue at all; ask in [Discord](https://discord.gg/honcho) + +Before opening one, search existing issues, including closed ones. + +A good bug report has the Honcho version or commit, whether you are self-hosted or on +`api.honcho.dev`, the steps to reproduce, and what you expected instead. If it involves the +deriver, logs from the worker process are usually the thing we ask for first. + +**Redact before you post.** Issues are public, and Honcho stores conversational data — strip +API keys, JWTs, and production user content out of any log or payload you attach. + +## Security + +Do not open a public issue for a suspected vulnerability. Report it privately through +[GitHub Private Vulnerability Reporting](https://github.com/plastic-labs/honcho/security/advisories/new), +which is the preferred channel, or by email. See [SECURITY.md](./SECURITY.md) for what to +include, and note that Honcho does not operate a bug bounty. ## License -By contributing to Honcho, you agree that your contributions will be licensed under the same [AGPL-3.0 License](./LICENSE) that covers the project. +By contributing to Honcho, you agree that your contributions will be licensed under the same +[AGPL-3.0 License](./LICENSE) that covers the project. Thank you for helping make Honcho better! 🫡 diff --git a/README.md b/README.md index bef96c61..2c3e1d72 100644 --- a/README.md +++ b/README.md @@ -458,75 +458,15 @@ Contributors: see [`CONTRIBUTING.md`](./CONTRIBUTING.md) for pre-commit setup. D Honcho uses a flexible configuration system that supports both TOML files and environment variables. Configuration values are loaded in priority order: **environment variables > `.env` file > `config.toml` > defaults**. - -
-Full configuration reference - -### Using config.toml - -Copy the example configuration file to get started: +Copy the example file to get started: ```bash cp config.toml.example config.toml ``` -Then modify the values as needed. The TOML file is organized into sections: +The file is organized by subsystem — `[app]`, `[db]`, `[auth]`, `[cache]`, `[llm]`, `[deriver]`, `[dialectic]`, `[summary]`, `[dream]`, `[peer_card]`, `[webhook]`, `[metrics]`, `[telemetry]`, `[vector_store]`, and `[sentry]`. Any value can be overridden by an environment variable named `{SECTION}_{KEY}`, using `__` for nesting (`DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL`), or just `{KEY}` for app-level settings. -- `[app]` - Application-level settings (log level, session limits, embedding settings, namespace) -- `[db]` - Database connection and pool settings -- `[auth]` - Authentication configuration -- `[cache]` - Redis cache configuration -- `[llm]` - LLM provider API keys and general settings -- `[deriver]` - Background worker settings and representation configuration -- `[peer_card]` - Peer card generation settings -- `[dialectic]` - Chat Endpoint configuration with per-level reasoning settings -- `[summary]` - Session summarization settings -- `[dream]` - Dream processing configuration (including specialist models and surprisal settings) -- `[webhook]` - Webhook configuration -- `[metrics]` - Prometheus pull-based metrics -- `[telemetry]` - CloudEvents telemetry for analytics -- `[vector_store]` - Vector store configuration (pgvector, turbopuffer, or lancedb) -- `[sentry]` - Error tracking and monitoring settings - -### Using Environment Variables - -All configuration values can be overridden using environment variables. The environment variable names follow this pattern: - -- `{SECTION}_{KEY}` for top-level section settings -- Use `__` inside `{KEY}` for nested settings -- Just `{KEY}` for app-level settings - -Examples: - -- `DB_CONNECTION_URI` - Database connection string -- `AUTH_JWT_SECRET` - JWT secret key -- `DERIVER_MODEL_CONFIG__TRANSPORT` - Transport for the background deriver -- `SUMMARY_MODEL_CONFIG__MODEL` - Summary model override -- `DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL` - Model for low reasoning level -- `LOG_LEVEL` - Application log level -- `METRICS_ENABLED` - Enable Prometheus metrics -- `TELEMETRY_ENABLED` - Enable CloudEvents telemetry - -### Example - -If you have this in `config.toml`: - -```toml -[db] -CONNECTION_URI = "postgresql+psycopg://localhost/honcho_dev" -POOL_SIZE = 10 -``` - -You can override just the connection URI in production: - -```bash -export DB_CONNECTION_URI="postgresql+psycopg://prod-server/honcho_prod" -``` - -The application will use the production connection URI while keeping the pool size from config.toml. - -
- +See the [configuration reference](https://honcho.dev/docs/v3/contributing/configuration) for every available option, and [`.env.template`](./.env.template) for an annotated list of environment variables. ## Architecture @@ -680,7 +620,9 @@ See the [SDK Reference](https://honcho.dev/docs/v3/documentation/reference/sdk) ## Contributing -We welcome contributions to Honcho! Please read our [Contributing Guide](./CONTRIBUTING.md) for details on our development process, coding conventions, and how to submit pull requests. +We welcome contributions to Honcho. One thing to know before you start: **pull requests must be linked to an issue carrying the `maintainer-approved` label**, or they are closed automatically. [Browse the approved queue](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved), or make your case in [Discord](http://discord.gg/honcho) — that is where maintainers are most active. + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full process, an architecture walkthrough, and a map of where to change what. For vulnerabilities, see [SECURITY.md](./SECURITY.md) — note that Honcho does not operate a bug bounty. ## License diff --git a/SECURITY.md b/SECURITY.md index 172758d3..1b71cb45 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,31 +1,73 @@ # Security Policy -## Reporting a vulnerability +## Supported Versions -**Do not file a public GitHub issue for security vulnerabilities.** +The `main` branch of this repo maps to the latest canary version of Honcho. To see which versions are supported please refer to the git tags in the repo or the [compatibility guide](https://honcho.dev/docs/changelog/compatibility-guide). -Please report security issues privately using one of: +## Reporting a Vulnerability -1. **[GitHub Private Vulnerability Reporting](https://github.com/plastic-labs/honcho/security/advisories/new)** (preferred) -2. Email **** with subject line `[SECURITY] …` +Do not open a public issue for a suspected vulnerability. Report it privately through one of: -Include as much of the following as you can: +1. **[GitHub Private Vulnerability Reporting](https://github.com/plastic-labs/honcho/security/advisories/new)** — preferred; it keeps the report, our replies, and any fix coordinated in one place. +2. Email [support@honcho.dev](mailto:support@honcho.dev) with `[SECURITY]` in the subject. -- Description of the issue and its impact -- Steps to reproduce, or a proof of concept -- Affected component (API, deriver, auth/JWT, SDK, managed offering, etc.) -- Honcho version or image tag, and whether you are on managed or self-hosted +Include as much of the following as you have: -Honcho stores conversational data and peer representations. **Do not** attach production user content, API keys, JWTs, or other secrets to a report unless we explicitly ask for a redacted sample. +- **Version** — a git commit SHA, or the release tag you are running +- **Deployment** — self-hosted or the managed service at `api.honcho.dev` +- **Affected component** — API, deriver, dialectic, auth/JWT, an SDK, or the managed offering +- **Reproduction** — the exact steps, requests, or script that trigger it +- **Proof of concept** — the smallest thing that demonstrates the issue actually works +- **Impact** — what an attacker gains, and what they need to already have to get it +- **How you found it** — manual review, fuzzing, a scanner, or model-assisted analysis -## What to expect +Reports with a working proof of concept get looked at first. A report that only describes a +theoretical problem is much slower for us to act on, because we have to build the repro +ourselves before we can confirm anything. -We will acknowledge valid reports as soon as we can and will keep you updated on remediation status. Please give us a reasonable window to investigate and fix before any public disclosure. +Honcho stores conversational data and peer representations. **Do not attach production user +content, API keys, or JWTs** to a report — if we need a sample, we will ask for a redacted +one. -## Supported versions +## Testing -Security fixes are applied to the latest release on `main` and, when practical, to the most recent tagged release line. Older versions may not receive backports. +Test against an instance you operate. Do not run security testing against `api.honcho.dev` +or against any Honcho deployment that is not yours — self-hosting is a first-class path and +takes a few minutes to set up, see [Self-hosting](./README.md#self-hosting). -## Non-security bugs +## What to Expect -For ordinary bugs, memory/recall quality issues, and feature requests, use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). +We will acknowledge your report and tell you whether we consider it in scope. If it is, we +will let you know when a fix ships. + +We do not commit to a response SLA, we do not coordinate CVE assignment on request, and we +do not operate a disclosure timeline you can hold us to. This is a small team. + +## Out of Scope + +The following are not treated as vulnerabilities. Reports consisting only of these will be +closed without a detailed response: + +- Automated scanner output with no working proof of concept +- Model-generated findings that have not been verified by a human against a running instance +- Missing security headers or TLS configuration with no demonstrated exploit +- Rate limiting, or resource exhaustion with no demonstrated impact beyond your own instance +- Vulnerabilities in dependencies with no demonstrated exploit path through Honcho +- Configuration weaknesses that require an already-compromised host, or that come from + deliberately insecure settings (for example running with `AUTH_USE_AUTH=false`, which is + the documented local-development default and is not intended for a public deployment) +- Social engineering, phishing, and physical access + +For ordinary bugs, memory or recall quality problems, and feature requests, use the +[issue templates](https://github.com/plastic-labs/honcho/issues/new/choose) instead. + +## No Bug Bounty + +The Honcho project does not offer any rewards for reported bugs or +vulnerabilities. We do not aid security researchers to get such rewards for +Honcho problems from other sources. + +A bug bounty gives people too strong incentives to find and make up "problems" +in bad faith that cause overload and abuse. + +We still appreciate and value valid vulnerability reports. diff --git a/docs/v2/contributing/guidelines.mdx b/docs/v2/contributing/guidelines.mdx index 12f44179..2fa66f1f 100644 --- a/docs/v2/contributing/guidelines.mdx +++ b/docs/v2/contributing/guidelines.mdx @@ -5,13 +5,31 @@ icon: 'handshake' Thank you for your interest in contributing to Honcho! This guide outlines the process for contributing to the project and our development conventions. +## Before you write code + +**Every pull request needs an issue, and that issue needs the `maintainer-approved` label.** + +A pull request that is not linked to an approved issue gets labelled `needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one before it is closed automatically. Reopening costs nothing once the link is in place. This is automated. We do this because an unreviewable backlog helps nobody: a PR against an unapproved issue is work you did that we may not be able to merge, no matter how good it is. + +So, in order: + +1. **Find approved work.** Browse [issues labelled `maintainer-approved`](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved). That label is the queue of things we have agreed should be built. Anything in it is fair game — comment on the issue to claim it. + +2. **Or open an issue and get it approved.** Use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). Maintainers triage and apply the label. + +3. **If you feel strongly about an issue, come to [Discord](https://discord.gg/honcho).** This is the fastest path by a wide margin. Maintainers are more active there than in the issue tracker, and a five-minute conversation about what you want to build usually resolves whether it fits before either side spends real time on it. + +4. **Then open the PR** and link the issue — either `Fixes #123` in the description, or **Development → link an issue** in the sidebar. Both work. + +Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the issue linkage. + ## Getting Started Before you start contributing, please: 1. **Set up your development environment** - Follow the [Local Development guide](https://github.com/plastic-labs/honcho/blob/main/CONTRIBUTING.md#local-development) in the Honcho repository to get Honcho running locally. -2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions. +2. **Join our community** - Feel free to join us in our [Discord](https://discord.gg/honcho) to discuss your changes, get help, or ask questions. 3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to. @@ -94,7 +112,7 @@ git commit -m "docs(readme): update installation instructions" 3. Fill out the pull request template with: - A clear description of what changes you've made - The motivation for the changes - - Any relevant issue numbers (use "Closes #123" to auto-close issues) + - A link to the approved issue — `Fixes #123` in the description, or **Development → link an issue** in the sidebar. This is required; see [Before you write code](#before-you-write-code). - Screenshots or examples if applicable ## Coding Standards @@ -128,7 +146,7 @@ git commit -m "docs(readme): update installation instructions" ## Review Process -1. **Automated checks** - Your PR will run through automated checks including tests and linting +1. **Automated checks** - Your PR will run through automated checks including tests, linting, and the issue gate 2. **Project maintainer review** - A project maintainer will review your code for: - Code quality and adherence to standards - Functionality and correctness diff --git a/docs/v3/contributing/guidelines.mdx b/docs/v3/contributing/guidelines.mdx index 12f44179..b98bd5c9 100644 --- a/docs/v3/contributing/guidelines.mdx +++ b/docs/v3/contributing/guidelines.mdx @@ -3,174 +3,358 @@ title: 'Contributing Guidelines' icon: 'handshake' --- -Thank you for your interest in contributing to Honcho! This guide outlines the process for contributing to the project and our development conventions. +{/* This file mirrors CONTRIBUTING.md in the repo root. Update both. */} -## Getting Started +Thanks for your interest in contributing. This guide covers how work gets accepted, how +Honcho is put together, and what a mergeable pull request looks like. -Before you start contributing, please: +Honcho is a small team maintaining a project that gets more proposals than we can review. +The rules below exist so that the work you do has somewhere to land — not to keep you out. -1. **Set up your development environment** - Follow the [Local Development guide](https://github.com/plastic-labs/honcho/blob/main/CONTRIBUTING.md#local-development) in the Honcho repository to get Honcho running locally. +## Before you write code -2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions. +**Every pull request needs an issue, and that issue needs the `maintainer-approved` label.** -3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to. +A pull request that is not linked to an approved issue gets labelled +`needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one +before it is closed automatically. Reopening costs nothing once the link is in place. This +is automated. We do this because an unreviewable backlog helps nobody: a PR against an +unapproved issue is work you did that we may not be able to merge, no matter how good it +is. -## Contribution Workflow +So, in order: -### 1. Fork and Clone +1. **Find approved work.** Browse + [issues labelled `maintainer-approved`](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved). + That label is the queue of things we have agreed should be built. Anything in it is fair + game — comment on the issue to claim it. -1. Fork the repository on GitHub -2. Clone your fork locally: - ```bash - git clone https://github.com/YOUR_USERNAME/honcho.git - cd honcho - ``` -3. Add the upstream repository as a remote: - ```bash - git remote add upstream https://github.com/plastic-labs/honcho.git - ``` +2. **Or open an issue and get it approved.** Use the + [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). Maintainers + triage and apply the label. -### 2. Create a Branch +3. **If you feel strongly about an issue, come to [Discord](https://discord.gg/honcho).** + This is the fastest path by a wide margin. Maintainers are more active there than in the + issue tracker, and a five-minute conversation about what you want to build usually + resolves whether it fits before either side spends real time on it. -Create a new branch for your feature or bug fix: +4. **Then open the PR** and link the issue — either `Fixes #123` in the description, or + **Development → link an issue** in the sidebar. Both work. + +Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an +obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the +issue linkage. + +## What gets prioritized + +Roughly, work on Honcho falls along these axes. Knowing which one your idea sits on tells +you a lot about how likely it is to get approved. + +| Axis | What it covers | +| --- | --- | +| **Observability** | Understanding how Honcho behaves in production — telemetry, tracing, CloudEvents, metrics. | +| **Memory quality** | Better conclusions from the same input — the deriver, dreamer, and dialectic; eval results. | +| **Developer experience** | Fitting cleanly into more application architectures — SDKs, scopes, composable peers, the CLI. | +| **Breadth of input** | Widening what Honcho can ingest and represent — multimodal and non-conversational data. | +| **Ubiquity** | Reachable wherever a developer already works — integrations, self-hosting, alternate vector-store and inference backends, local-first defaults. | +| **Reliability and cost** | Trustworthy in production — connection and concurrency hardening, queue throughput, cost per token. | + +In practice, **Ubiquity** and **Developer experience** are where outside contributions land +most easily. A new integration, a self-hosting rough edge, a vector-store or inference +backend, an SDK ergonomics fix — these are additive and rarely collide with work already in +flight. + +Changes to the reasoning pipeline itself — deriver prompts, dialectic tool design, dreamer +strategy — are the hardest to accept from outside. Not because they are unwelcome, but +because they are measured against eval results we run internally, and they frequently +conflict with in-flight work. Talk to us in Discord first, always. + +## If you're an agent + +If you are a coding agent working on this repository, read this section before writing code. +The most common failure we see is a well-formed, well-tested pull request against an issue +that was never approved. That gets closed, and the work is wasted. + +- **Check the gate first.** Before writing code: + + ```bash + gh issue view --repo plastic-labs/honcho --json number,title,state,labels + ``` + + Stop if there is no issue number, if the issue is closed, or if `maintainer-approved` is + not in the labels. Report that to the person you are working with instead of proceeding. + +- **Do not open a PR in order to establish the issue link afterwards.** The issue comes + first. + +- **Do not report checks you did not run.** If you did not execute the test command, say so. + A PR body claiming a green run that did not happen costs a maintainer more time than no + claim at all. + +- **Use the checklist.** [`skills/pre-pr/SKILL.md`](https://github.com/plastic-labs/honcho/blob/main/skills/pre-pr/SKILL.md) in this repo + encodes the gate, the test-layer matrix, and the PR body format. If your harness supports + skills, invoke it rather than reimplementing the checks. + +## How Honcho works + +Enough architecture to find your way around. For the user-facing model — what a Peer is, what +`get_context` returns — see [Core Concepts](https://github.com/plastic-labs/honcho#core-concepts) and the +[documentation](https://honcho.dev/docs/). + +### Two processes + +Honcho runs as two cooperating processes over a shared Postgres database and Redis cache. + +| | API server | Deriver worker | +| --- | --- | --- | +| Start | `uv run fastapi dev src/main.py` | `uv run python -m src.deriver` | +| Entry | `src/main.py` | `src/deriver/__main__.py` | +| Does | Serves HTTP, enqueues background work, returns immediately | Consumes the queue: Deriver, Summarizer, Dreamer, Reconciler | +| Hosts | The Dialectic agent, inline on the request path | Everything else | + +The split is the load-bearing design decision: **an HTTP request never blocks on LLM work**, +with the single exception of the Dialectic chat endpoint, which is synchronous by nature. +If you are adding something slow, it belongs in the worker. + +The deriver is a separate process. If messages go in and nothing ever comes out, the usual +cause is that nobody started it. + +### The path of a message + +Worth tracing once, because it crosses most of the codebase: + +1. `POST /v3/workspaces/{w}/sessions/{s}/messages` lands in `src/routers/messages.py`. +2. The row is written, then `enqueue()` in `src/deriver/enqueue.py` creates `queue_item` + rows — one set of work per observing peer. +3. `src/deriver/queue_manager.py` polls the queue, claiming work units so that messages in a + session are processed in order. +4. `process_item()` in `src/deriver/consumer.py` dispatches on task type — representation, + summary, deletion, reconciliation. +5. For a representation task, `process_representation_tasks_batch()` in + `src/deriver/deriver.py` makes **one structured-output LLM call for the whole batch** and + writes the resulting conclusions into the collection keyed by the + `(observer, observed)` peer pair. +6. Later, `src/dialectic/` reads those conclusions back at recall time to answer a chat + request. + +Embedding is deliberately *not* on this path. `MessageEmbedding` rows are written with +`sync_state='pending'` and embedded asynchronously by the Reconciler +(`src/reconciler/sync_vectors.py`), which runs on a scheduler inside the deriver process. + +### The four agents + +They share tool definitions in `src/utils/agent_tools.py` and the provider-agnostic LLM +client in `src/llm/`. Each has its own `MODEL_CONFIG` with a fallback chain in +`src/config.py`. + +| Agent | Where | Shape | +| --- | --- | --- | +| **Deriver** | `src/deriver/` | A single structured-output call per message batch. Not a tool loop — this is a deliberate cost and latency tradeoff. | +| **Dialectic** | `src/dialectic/` | The one tool-using agent on the request path. Loops over tools until it can answer. Five reasoning tiers from `minimal` to `max`, each with its own model and tool set. | +| **Dreamer** | `src/dreamer/` | Off-queue consolidation. Two specialist phases (deduction, then induction) that build reasoning trees over existing conclusions. | +| **Summarizer** | `src/utils/summarizer.py` | Direct LLM call, no tools. Two tiers — short and long summaries at different message counts. | + +Prompts live in `src/deriver/prompts.py`, `src/dialectic/prompts.py`, and +`src/dreamer/specialists.py`. + +### A note on naming + +What the public API and documentation call **conclusions** are called **observations** +throughout the code — `create_observations`, `get_observation_context`, and so on. Likewise +**collections** and **documents** are internal storage concepts that are not exposed +directly through the API. Do not rename across that boundary in a drive-by change; the +public and internal vocabularies are being reconciled deliberately. + +## Where to change what + +| I want to change... | Start here | +| --- | --- | +| An HTTP endpoint | `src/routers/` — one module per resource | +| A database query | `src/crud/` — mirrors the router layout | +| The database schema | `src/models.py`, plus a migration in `migrations/versions/` | +| A configuration value | `src/config.py`, and add it to `config.toml.example` and `.env.template` | +| A tool an agent can call | `src/utils/agent_tools.py` — definitions plus the per-agent tool lists | +| A prompt | `src/deriver/prompts.py`, `src/dialectic/prompts.py`, `src/dreamer/specialists.py` | +| LLM provider behavior | `src/llm/backends/` — `anthropic.py`, `gemini.py`, `openai.py` | +| Embeddings or vector storage | `src/embedding_client.py`, `src/vector_store/` | +| Telemetry or metrics | `src/telemetry/` — see the notes in `CLAUDE.md` before adding an event type | +| Authentication and scoping | `src/security.py`, `src/dependencies.py` | +| The Python or TypeScript SDK | `sdks/python/`, `sdks/typescript/` | +| The CLI | `honcho-cli/` | +| The MCP server | `mcp/` | +| Public documentation | `docs/v3/` — Mintlify; nav lives in `docs/docs.json` | + +Tests in `tests/` mirror `src/`. `CLAUDE.md` at the repo root has more detail on house +conventions, and is worth skimming even if you are not using an agent. + +## Local setup + +Get a stack running first — [Self-hosting](/v3/contributing/self-hosting) covers +both the Docker path and a manual Postgres setup. Then, for development: + +```bash +uv sync # create the venv and install dependencies +uv run alembic upgrade head # apply migrations +``` + +Run both processes, in separate terminals: + +```bash +uv run fastapi dev src/main.py # API server, reloads on change +uv run python -m src.deriver # background worker +``` + +Everything Python goes through `uv run`. Redis is optional for local development; without it +caching is simply disabled. + +## Making the change + +### Branches and commits ```bash git checkout -b feature/your-feature-name -# or -git checkout -b fix/your-bug-fix-name ``` -**Branch naming conventions:** -- `feature/description` - for new features -- `fix/description` - for bug fixes -- `docs/description` - for documentation updates -- `refactor/description` - for code refactoring -- `test/description` - for adding or updating tests +Prefixes: `feature/`, `fix/`, `docs/`, `refactor/`, `test/`. -### 3. Make Your Changes +Commits follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by a +`commit-msg` hook: -- Write clean, readable code that follows our coding standards (see below) -- Add tests for new functionality -- Update documentation as needed -- Make sure your changes don't break existing functionality - -### 4. Commit Your Changes - -We follow conventional commit standards. Format your commit messages as: - -``` -type(scope): description - -[optional body] - -[optional footer] -``` - -**Types:** -- `feat`: A new feature -- `fix`: A bug fix -- `docs`: Documentation only changes -- `style`: Changes that do not affect the meaning of the code -- `refactor`: A code change that neither fixes a bug nor adds a feature -- `test`: Adding missing tests or correcting existing tests -- `chore`: Changes to the build process or auxiliary tools - -**Examples:** ```bash git commit -m "feat(api): add new dialectic endpoint for user insights" git commit -m "fix(db): resolve connection pool timeout issue" -git commit -m "docs(readme): update installation instructions" ``` -### 5. Submit a Pull Request +Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`. -1. Push your branch to your fork: - ```bash - git push origin your-branch-name - ``` +### Pre-commit hooks -2. Create a pull request on GitHub from your branch to the `main` branch +Install them. CI runs the same checks, and it is much faster to find out locally. -3. Fill out the pull request template with: - - A clear description of what changes you've made - - The motivation for the changes - - Any relevant issue numbers (use "Closes #123" to auto-close issues) - - Screenshots or examples if applicable +```bash +uv run pre-commit install \ + --hook-type pre-commit \ + --hook-type commit-msg \ + --hook-type pre-push +``` -## Coding Standards +At **commit** time: ruff lint and format, biome for TypeScript, basedpyright, bandit, +markdownlint, and file hygiene. At **push** time: pytest, the alembic migration tests, and +the SDK builds. -### Python Code Style +That split matters — **a clean commit is not a clean push.** The test suite only runs at +`pre-push`, so the first time you see test failures may be well after you thought you were +done. -- Follow [PEP 8](https://www.python.org/dev/peps/pep-0008/) style guidelines -- Use [Black](https://black.readthedocs.io/) for code formatting (we may add this to CI in the future) -- Use type hints where possible -- Write docstrings for functions and classes using Google style docstrings +Run them by hand at any time: -### Code Organization +```bash +uv run pre-commit run --all-files +uv run pre-commit run ruff --all-files +``` -- Keep functions focused and single-purpose -- Use meaningful variable and function names -- Add comments for complex logic -- Follow existing patterns in the codebase +Or the individual tools: -### Testing +```bash +uv run ruff check src/ +uv run ruff format src/ +uv run basedpyright +``` -- Write unit tests for new functionality -- Ensure existing tests pass before submitting -- Use descriptive test names that explain what is being tested -- Mock external dependencies appropriately +### Tests + +Write tests for new functionality, in the directory under `tests/` that mirrors the code you +changed. Which layer you need depends on what you touched: + +| What you changed | What to run | +| --- | --- | +| Anything in `src/` | Unit tests in the matching `tests/` tree — `uv run pytest tests/...` | +| Deriver, dialectic, dreamer, or the LLM path | Unit tests, and consider `tests/live_llm/` (gated behind `--live-llm`) | +| Queue behavior, config hierarchy, multi-turn flows, SDK contracts | `uv run python -m tests.unified.run` | +| A `/v3` endpoint or deriver queue behavior | Actually run the stack and exercise it — not just pytest | +| A migration | `uv run python scripts/run_alembic_tests.py`; every revision needs a test file | + +The TypeScript SDK tests need a running server with a database and Redis, which pytest +orchestrates. Run them with `uv run pytest tests/ -k typescript` from the repo root — +`bun test` on its own will fail. To type-check the SDK alone: +`cd sdks/typescript && bun run tsc --noEmit`. ### Documentation -- Update relevant documentation for new features -- Include examples in docstrings where helpful -- Keep README and other docs up to date with changes +Update docs in the same PR when you change a public surface: `/v3` endpoints, SDK exports, +or anything in `config.toml` / settings. Docs live in `docs/v3/`, and new pages need an entry +in `docs/docs.json` or they will not appear in the nav. -## Review Process +## Opening the pull request -1. **Automated checks** - Your PR will run through automated checks including tests and linting -2. **Project maintainer review** - A project maintainer will review your code for: - - Code quality and adherence to standards - - Functionality and correctness - - Test coverage - - Documentation completeness -3. **Discussion and iteration** - You may be asked to make changes or clarifications -4. **Approval and merge** - Once approved, your PR will be merged into `main` +### Leave "Allow edits by maintainers" checked -## Types of Contributions +This is the single most useful thing you can do to get your PR merged quickly. -We welcome various types of contributions: +Most contributor PRs arrive nearly right, needing a rename, a missing test, or a lint fix. +If we can push that commit ourselves, it merges the same day. If we cannot, it becomes a +review comment, and then we wait — sometimes for weeks — for a round trip on a two-line +change. -- **Bug fixes** - Help us squash bugs and improve stability -- **New features** - Add functionality that benefits the community -- **Documentation** - Improve or expand our documentation -- **Tests** - Increase test coverage and reliability -- **Performance improvements** - Help make Honcho faster and more efficient -- **Examples and tutorials** - Help other developers use Honcho +GitHub checks the box by default when you fork. Leave it checked. -## Issue Reporting +One caveat worth knowing: **the option does not exist on forks owned by an organization.** +If you have the choice, fork from your personal account. -When reporting bugs or requesting features: +### Fill out the template -1. Check if the issue already exists -2. Use the appropriate [issue template](https://github.com/plastic-labs/honcho/issues/new/choose) (bug, memory/recall quality, feature, integration, or documentation) -3. Provide clear reproduction steps for bugs -4. Include relevant environment information (managed vs self-hosted, server version, SDK) -5. Be specific about expected vs actual behavior -6. Redact secrets, JWTs, and production user content +`.github/pull_request_template.md` asks for a description, proofs, and the issue checkbox. -## Questions and Support +"Proofs" means evidence the change works: the command you ran and its result, a log snippet, +a screenshot, the failing case before and after. This is the section that most determines +how fast your PR gets reviewed. Do not add sections to the template. -- **General questions** - Join our [Discord](https://discord.gg/honcho) -- **Bug reports** - GitHub issues → Bug report template -- **Memory / recall quality** - GitHub issues → Memory / recall quality template -- **Feature requests** - GitHub issues → Feature request template -- **Integrations / plugins / app-store listings** - GitHub issues → Integration request template -- **Documentation issues** - GitHub issues → Documentation issue template -- **Security issues** - Report **privately** only — see [`SECURITY.md`](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md) (GitHub Private Vulnerability Reporting or email). Do not open a public issue. +Link the issue so the gate can see it: `Fixes #123` in the description, or the +**Development** section of the sidebar. The gate reads GitHub's own resolved issue links, so +either route works — but a bare `#123` mention is only a reference and does not count. + +### Review + +1. Automated checks run — tests, linting, static analysis, and the issue gate. +2. A maintainer reviews for correctness, test coverage, and fit with the surrounding code. + `.github/CODEOWNERS` routes the request to whoever owns the area you touched. +3. You may be asked for changes. Or we may just push them, if you left edits enabled. +4. Once approved, we merge to `main`. + +If a PR goes quiet, nudge us in [Discord](https://discord.gg/honcho). + +## Reporting bugs and requesting features + +Use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). There is +one per kind of report, and picking the right one is most of what gets an issue triaged +quickly: + +- **Bug report** — something is broken or behaves incorrectly +- **Memory / recall quality** — the deriver or dialectic returns poor, wrong, or missing context +- **Feature request** — a new capability or API surface +- **Integration request** — plugins, framework integrations, app-store listings +- **Documentation issue** — anything wrong or missing in the docs +- **General questions** — not an issue at all; ask in [Discord](https://discord.gg/honcho) + +Before opening one, search existing issues, including closed ones. + +A good bug report has the Honcho version or commit, whether you are self-hosted or on +`api.honcho.dev`, the steps to reproduce, and what you expected instead. If it involves the +deriver, logs from the worker process are usually the thing we ask for first. + +**Redact before you post.** Issues are public, and Honcho stores conversational data — strip +API keys, JWTs, and production user content out of any log or payload you attach. + +## Security + +Do not open a public issue for a suspected vulnerability. Report it privately through +[GitHub Private Vulnerability Reporting](https://github.com/plastic-labs/honcho/security/advisories/new), +which is the preferred channel, or by email. See [SECURITY.md](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md) for what +to include, and note that Honcho does not operate a bug bounty. ## License -By contributing to Honcho, you agree that your contributions will be licensed under the same [AGPL-3.0 License](./license) that covers the project. +By contributing to Honcho, you agree that your contributions will be licensed under the same +[AGPL-3.0 License](./license) that covers the project. Thank you for helping make Honcho better! 🫡 From 5823f0fae930f2da9f1b45d3a0f6a16b148e70bc Mon Sep 17 00:00:00 2001 From: Ken Weiner Date: Tue, 25 Aug 2026 06:45:01 -0700 Subject: [PATCH 11/50] fix: only classify genuine oversize input as a token-limit error (#791) Callers wrapped every ValueError from the embedding client in a "exceeds maximum token limit" message, so provider and configuration failures (dimension mismatch, empty response, upstream error) surfaced to users as though their input were too long. Add EmbeddingTokenLimitError, raised only by the pre-flight token checks in embed() and simple_batch_embed(), and narrow the remaps in search.py, agent_tools.py, document.py and representation.py to catch it. It subclasses ValueError so existing broad handlers keep working. Both simple_batch_embed() remap sites pass on_oversize="truncate" and so could never raise a token-limit error at all; their handlers only ever mislabelled provider failures. Fixes #568 Co-authored-by: Claude Opus 5 --- src/crud/document.py | 6 +-- src/crud/representation.py | 4 +- src/embedding_client.py | 19 +++++++-- src/utils/agent_tools.py | 8 +++- src/utils/search.py | 4 +- tests/llm/test_embedding_client.py | 68 ++++++++++++++++++++++++++++++ 6 files changed, 96 insertions(+), 13 deletions(-) diff --git a/src/crud/document.py b/src/crud/document.py index 0cec85c9..1b04bb0a 100644 --- a/src/crud/document.py +++ b/src/crud/document.py @@ -18,7 +18,7 @@ from src.crud.collection import get_or_create_collection from src.crud.peer import get_peer, reject_scope_observed from src.crud.session import get_session from src.dependencies import tracked_db -from src.embedding_client import embedding_client +from src.embedding_client import EmbeddingTokenLimitError, embedding_client from src.exceptions import ( ResourceNotFoundException, ValidationException, @@ -362,7 +362,7 @@ async def query_documents( if embedding is None: try: embedding = await embedding_client.embed(query) - except ValueError as e: + except EmbeddingTokenLimitError as e: raise ValidationException( "Query exceeds maximum token limit of " + f"{settings.EMBEDDING.MAX_INPUT_TOKENS}." @@ -987,7 +987,7 @@ async def create_observations( embeddings = await embedding_client.simple_batch_embed( contents, on_oversize="truncate" ) - except ValueError as e: + except EmbeddingTokenLimitError as e: raise ValidationException(str(e)) from e # Create document objects and track embeddings for vector store diff --git a/src/crud/representation.py b/src/crud/representation.py index 3b7070e8..6fafb842 100644 --- a/src/crud/representation.py +++ b/src/crud/representation.py @@ -13,7 +13,7 @@ from src import crud, exceptions, models, schemas from src.config import settings from src.dependencies import tracked_db from src.dreamer.dream_scheduler import check_and_schedule_dream -from src.embedding_client import embedding_client +from src.embedding_client import EmbeddingTokenLimitError, embedding_client from src.schemas import ResolvedConfiguration from src.telemetry.events import EmbeddingCallPurpose from src.telemetry.logging import accumulate_metric @@ -109,7 +109,7 @@ class RepresentationManager: embeddings = await embedding_client.simple_batch_embed( observation_texts, on_oversize="truncate" ) - except ValueError as e: + except EmbeddingTokenLimitError as e: raise exceptions.ValidationException( "Observation content exceeds maximum token limit of " + f"{settings.EMBEDDING.MAX_INPUT_TOKENS}." diff --git a/src/embedding_client.py b/src/embedding_client.py index e55e09fd..d3f4e369 100644 --- a/src/embedding_client.py +++ b/src/embedding_client.py @@ -159,6 +159,17 @@ def _publish_embedding_event( logger.debug("Failed to emit EmbeddingCallCompletedEvent", exc_info=True) +class EmbeddingTokenLimitError(ValueError): + """Raised when input text genuinely exceeds the model's token limit. + + Subclasses ``ValueError`` so existing broad handlers keep working, while + letting callers tell a real "content too long" condition apart from a + transient provider or configuration failure (dimension mismatch, empty + response, upstream error). Only the pre-flight token checks raise this; + provider failures keep raising plain ``ValueError``. + """ + + class BatchItem(NamedTuple): """A single item in a batch with its metadata.""" @@ -272,7 +283,7 @@ class _EmbeddingClient: token_count = len(self.encoding.encode(query)) if token_count > self.max_embedding_tokens: - raise ValueError( + raise EmbeddingTokenLimitError( f"Query exceeds maximum token limit of {self.max_embedding_tokens} tokens (got {token_count} tokens)" ) @@ -358,8 +369,8 @@ class _EmbeddingClient: List of embedding vectors, one per input text (in order) Raises: - ValueError: If any text exceeds token limits and `on_oversize` is - ``"raise"`` + EmbeddingTokenLimitError: If any text exceeds token limits and + `on_oversize` is ``"raise"`` """ if not texts: return [] @@ -380,7 +391,7 @@ class _EmbeddingClient: tokens, ) else: - raise ValueError( + raise EmbeddingTokenLimitError( f"Text at index {idx} exceeds maximum token limit of " + f"{self.max_embedding_tokens} tokens (got {len(token_ids)} tokens)" ) diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index 0dbdd304..5f87d455 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import crud, models, schemas from src.config import settings from src.dependencies import tracked_db -from src.embedding_client import embedding_client +from src.embedding_client import EmbeddingTokenLimitError, embedding_client from src.exceptions import ResourceNotFoundException from src.models import Document from src.schemas import ResolvedConfiguration @@ -1884,11 +1884,15 @@ async def _handle_search_memory( parent_category=ctx.parent_category, ): query_embedding = await embedding_client.embed(query) - except ValueError: + except EmbeddingTokenLimitError: return ( "ERROR: Query exceeds maximum token limit of " + f"{settings.EMBEDDING.MAX_INPUT_TOKENS}. Please use a shorter query." ) + except ValueError as e: + # Provider/config failure, not an oversized query. Keep returning a + # string so the tool loop can continue, but don't blame the query. + return f"ERROR: Embedding the query failed: {e}" # Base telemetry metadata; results_count gets filled in below. search_meta: dict[str, Any] = { diff --git a/src/utils/search.py b/src/utils/search.py index 761b63e0..329e082b 100644 --- a/src/utils/search.py +++ b/src/utils/search.py @@ -14,7 +14,7 @@ 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.embedding_client import EmbeddingTokenLimitError, embedding_client from src.exceptions import ValidationException from src.models import session_peers_table from src.telemetry.events import EmbeddingCallPurpose @@ -388,7 +388,7 @@ async def search( parent_category="api", ): query_embedding = await embedding_client.embed(query) - except ValueError as e: + except EmbeddingTokenLimitError as e: raise ValidationException( f"Query exceeds maximum token limit of {settings.EMBEDDING.MAX_INPUT_TOKENS}." ) from e diff --git a/tests/llm/test_embedding_client.py b/tests/llm/test_embedding_client.py index fc2ff411..7fd9237d 100644 --- a/tests/llm/test_embedding_client.py +++ b/tests/llm/test_embedding_client.py @@ -14,6 +14,7 @@ from src.config import ( from src.embedding_client import ( BatchItem, EmbeddingClient, + EmbeddingTokenLimitError, _EmbeddingClient, # pyright: ignore[reportPrivateUsage] ) @@ -1173,3 +1174,70 @@ async def test_gemini_process_batch_wraps_contents_as_content_part( assert all(isinstance(c, genai_types.Content) for c in contents) assert contents[0].parts[0].text == "hello" assert contents[1].parts[0].text == "world" + + +# --- Token-limit classification (issue #568) ------------------------------- +# +# Only genuine "content too long" conditions may raise +# EmbeddingTokenLimitError. Provider/config failures must stay plain +# ValueError so callers don't rewrite them as token-limit errors. + + +def test_embedding_token_limit_error_is_value_error() -> None: + """Subclassing ValueError keeps pre-existing broad handlers working.""" + assert issubclass(EmbeddingTokenLimitError, ValueError) + + +@pytest.mark.asyncio +async def test_embed_raises_token_limit_error_before_calling_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, fake_embeddings = _build_openai_client( + monkeypatch, + embedding=[0.1, 0.2], + model="text-embedding-3-small", + send_dimensions=False, + vector_dimensions=2, + ) + + with pytest.raises(EmbeddingTokenLimitError): + await client.embed("word " * 20_000) + + assert fake_embeddings.calls == [], "provider must not be called on oversize input" + + +@pytest.mark.asyncio +async def test_simple_batch_embed_raises_token_limit_error_before_calling_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, fake_embeddings = _build_openai_client( + monkeypatch, + embedding=[0.1, 0.2], + model="text-embedding-3-small", + send_dimensions=False, + vector_dimensions=2, + ) + + with pytest.raises(EmbeddingTokenLimitError): + await client.simple_batch_embed(["fine", "word " * 20_000]) + + assert fake_embeddings.calls == [], "provider must not be called on oversize input" + + +@pytest.mark.asyncio +async def test_provider_dimension_mismatch_is_not_a_token_limit_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A wrong-width vector is a provider/config fault, not an oversized input.""" + client, _ = _build_openai_client( + monkeypatch, + embedding=[0.1, 0.2, 0.3], # 3 wide, client expects 2 + model="text-embedding-3-small", + send_dimensions=False, + vector_dimensions=2, + ) + + with pytest.raises(ValueError) as excinfo: + await client.embed("short query") + + assert not isinstance(excinfo.value, EmbeddingTokenLimitError) From 4492f66bca7515e265ab38286858aeb6db0cfcc3 Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Tue, 25 Aug 2026 10:51:59 -0400 Subject: [PATCH 12/50] fix(crud): preserve joined_at for active session peers (#1059) * fix(crud): preserve joined_at for active session peers Re-adding an already-active peer no longer advances the membership window, so peer_perspective search keeps messages from the original join. Genuine rejoins still start a new window. * docs: document set_peers membership window and wrap test docstrings * fix: preserve session observer limit --------- Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> --- src/crud/session.py | 160 +++++++++++------- tests/crud/test_session.py | 328 ++++++++++++++++++++++++++++++++++++- tests/test_search.py | 71 +++++++- 3 files changed, 499 insertions(+), 60 deletions(-) diff --git a/src/crud/session.py b/src/crud/session.py index efa3f664..51ca1f13 100644 --- a/src/crud/session.py +++ b/src/crud/session.py @@ -1050,25 +1050,31 @@ async def set_peers_for_session( peer_names: dict[str, schemas.SessionPeerConfig], ) -> list[models.SessionPeer]: """ - Set peers for a session, overwriting any existing peers. - If peers don't exist, they will be created. + Replace a session's ordinary peer set with ``peer_names``. + + Active members keep their joined_at but take the incoming configuration: + this is a replace, so the caller's map is the desired end state. Departed + members rejoin with the incoming configuration. Scope memberships are + preserved. Args: db: Database session workspace_name: Name of the workspace session_name: Name of the session - peer_names: Set of peer names to set for the session + peer_names: Mapping of peer names to session-level configuration Returns: List of SessionPeer objects for all peers in the session Raises: ResourceNotFoundException: If the session does not exist + ObserverException: If the resulting peer set would exceed the observer + limit """ - # Validate observer limit before making any changes - observer_count = count_observers_in_config(peer_names) - if observer_count > settings.SESSION_OBSERVERS_LIMIT: - raise ObserverException(session_name, observer_count) + # No observer pre-check here: an already-active membership keeps its stored + # configuration, so the incoming map is not what lands. Counting it would + # reject a request that lowers the observer count as often as one that raises + # it. _get_or_add_peers_to_session enforces the limit on the resulting rows. # Verify session exists stmt = ( @@ -1084,20 +1090,21 @@ async def set_peers_for_session( f"Session {session_name} not found in workspace {workspace_name}" ) - # Soft delete every *ordinary* active membership. Scope memberships are - # deliberately preserved: this route replaces the peers the caller names, and a - # caller detaches a scope by simply *omitting* it from an otherwise valid - # replacement map — never naming it, so no request-level guard can see it. - # Without the exclusion a plain replacement silently bypasses the facade that - # owns scope membership and its removal reconciliation. Being part of the - # UPDATE, this holds regardless of the request body or concurrent scope - # creation. + # Soft delete every *ordinary* active membership not in the incoming map. + # Scope memberships are deliberately preserved: this route replaces the peers + # the caller names, and a caller detaches a scope by simply *omitting* it from + # an otherwise valid replacement map — never naming it, so no request-level + # guard can see it. Without the exclusion a plain replacement silently + # bypasses the facade that owns scope membership and its removal + # reconciliation. Being part of the UPDATE, this holds regardless of the + # request body or concurrent scope creation. update_stmt = ( update(models.SessionPeer) .where( models.SessionPeer.session_name == session_name, models.SessionPeer.workspace_name == workspace_name, models.SessionPeer.left_at.is_(None), # Only update active peers + models.SessionPeer.peer_name.notin_(peer_names.keys()), ~exists( select(models.Peer.id) .where(models.Peer.workspace_name == workspace_name) @@ -1118,12 +1125,14 @@ async def set_peers_for_session( ) _reject_resolved_scope_peers(peers_result.resource) - # Add new peers to session + # Add new peers to session. This route replaces the session's peer set, so the + # incoming configuration is authoritative even for an already-active member. peers = await _get_or_add_peers_to_session( db, workspace_name=workspace_name, session_name=session_name, peer_names=peer_names, + replace_config=True, ) await db.commit() @@ -1162,13 +1171,22 @@ async def _get_or_add_peers_to_session( peer_names: dict[str, schemas.SessionPeerConfig], *, fetch_after_upsert: bool = True, + replace_config: bool = False, ) -> list[models.SessionPeer]: """ Upsert session-peer memberships for a session and optionally fetch the active memberships afterward. New peers are inserted, peers that previously left the session are rejoined, - and already-active peers keep their existing session-level configuration. + and already-active peers keep their existing joined_at. + + An already-active peer also keeps its stored configuration unless + ``replace_config`` is set: an add must not overwrite configuration it was + never asked about, while a replace states the desired end state. + + The observer limit is checked against the rows the upsert actually produced, + not against the incoming map, since under the add semantics the incoming map + is not necessarily what lands. Args: db: Database session @@ -1177,13 +1195,17 @@ async def _get_or_add_peers_to_session( peer_names: Mapping of peer names to session-level configuration fetch_after_upsert: If True, query and return the active session peers after the upsert. If False, skip that read and return an empty list. + replace_config: If True, an already-active membership takes the incoming + configuration instead of keeping its stored one. Set by replace-style + callers; leave False for add-style callers. Returns: Active SessionPeer objects after the upsert, or an empty list when the post-upsert fetch is skipped Raises: - ObserverException: If adding peers would exceed the observer limit + ObserverException: If the resulting active peer set would exceed the + observer limit """ # If no peers to add, skip the insert and just return existing active session peers if not peer_names: @@ -1202,43 +1224,10 @@ async def _get_or_add_peers_to_session( # costs document rows, not LLM calls, and counting them would # cap scopes-per-session at SESSION_OBSERVERS_LIMIT and surface as an # observer-shaped 400 through a facade that hides observers entirely. + # Resolved up front because the limit check below gates on whether this + # request asks for a *non-scope* observer. scopes_being_added = await scope_peer_names(db, workspace_name, peer_names.keys()) - # Only validate observer limit if we're adding non-scope peers with observe_others=True - new_observer_count = count_observers_in_config( - {n: c for n, c in peer_names.items() if n not in scopes_being_added} - ) - - if new_observer_count > 0: - # Use a single efficient query to count existing observers not being updated - # This uses PostgreSQL's JSONB operators to check the observe_others field directly - existing_observers_stmt = select(func.count()).where( - models.SessionPeer.session_name == session_name, - models.SessionPeer.workspace_name == workspace_name, - models.SessionPeer.left_at.is_(None), # Only active peers - models.SessionPeer.peer_name.notin_( - peer_names.keys() - ), # Exclude peers being updated - models.SessionPeer.configuration["observe_others"].astext.cast( - Boolean - ), # Only observers - # Existing scope memberships are excluded for the same reason as above. - ~exists( - select(models.Peer.id) - .where(models.Peer.workspace_name == workspace_name) - .where(models.Peer.name == models.SessionPeer.peer_name) - .where(scope_peer_clause()) - .correlate(models.SessionPeer) - ), - ) - result = await db.execute(existing_observers_stmt) - existing_observer_count = result.scalar() or 0 - - total_observers = existing_observer_count + new_observer_count - - if total_observers > settings.SESSION_OBSERVERS_LIMIT: - raise ObserverException(session_name, total_observers) - # Use upsert to handle both new peers and rejoining peers stmt = pg_insert(models.SessionPeer).values( [ @@ -1254,15 +1243,27 @@ async def _get_or_add_peers_to_session( ] ) - # On conflict, update joined_at and clear left_at (rejoin scenario) - # If left_at is not None (peer has left the session): Use the new configuration (stmt.excluded.configuration) - # If left_at is None (peer is still active): Keep the existing configuration (models.SessionPeer.configuration) + # On conflict, rejoin departed peers. joined_at always survives on an active + # membership -- advancing it would move the peer_perspective search window + # past messages the peer was present for (issue #940). + # + # Configuration depends on the caller's semantics. An add ("ensure this peer + # is here") must not silently overwrite a config it never asked about, so an + # active membership keeps its stored one. A replace ("these are the session's + # peers, configured thus") states a desired end state, so the incoming config + # wins -- otherwise PUT /peers could never change the configuration of a peer + # already in the session. stmt = stmt.on_conflict_do_update( index_elements=["session_name", "peer_name", "workspace_name"], set_={ - "joined_at": func.now(), + "joined_at": case( + (models.SessionPeer.left_at.is_not(None), func.now()), + else_=models.SessionPeer.joined_at, + ), "left_at": None, - "configuration": case( + "configuration": stmt.excluded.configuration + if replace_config + else case( (models.SessionPeer.left_at.is_not(None), stmt.excluded.configuration), else_=models.SessionPeer.configuration, ), @@ -1270,6 +1271,49 @@ async def _get_or_add_peers_to_session( ) await db.execute(stmt) + # Enforce the observer limit on the resulting rows rather than predicting them. + # Under add semantics an already-active membership keeps its stored + # configuration (see the CASE above), so the incoming config is not what lands + # and cannot be counted: predicting from it silently undercounts preserved + # observers and lets a session grow past the limit indefinitely by re-sending + # its current observers at a lower config alongside new ones. Counting after + # the upsert is correct under both configuration semantics and cannot desync + # from those branches. Raising here rolls the upsert back: ObserverException + # is never caught, and both get_db and tracked_db roll back on exception. + # + # Gated on the request actually asking for a non-scope observer so that a + # session already over the limit keeps behaving as it does today: it can + # still take non-observers and scope attachments, and only a request that + # would make it worse is rejected. + if any( + config.observe_others + for peer_name, config in peer_names.items() + if peer_name not in scopes_being_added + ): + observer_count = ( + await db.scalar( + select(func.count()).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.workspace_name == workspace_name, + models.SessionPeer.left_at.is_(None), # Only active peers + models.SessionPeer.configuration["observe_others"].astext.cast( + Boolean + ), # Only observers + # Scope memberships are excluded for the reason given above. + ~exists( + select(models.Peer.id) + .where(models.Peer.workspace_name == workspace_name) + .where(models.Peer.name == models.SessionPeer.peer_name) + .where(scope_peer_clause()) + .correlate(models.SessionPeer) + ), + ) + ) + or 0 + ) + if observer_count > settings.SESSION_OBSERVERS_LIMIT: + raise ObserverException(session_name, observer_count) + if not fetch_after_upsert: return [] diff --git a/tests/crud/test_session.py b/tests/crud/test_session.py index 8b4b5795..2eec1f32 100644 --- a/tests/crud/test_session.py +++ b/tests/crud/test_session.py @@ -1,14 +1,340 @@ +from datetime import datetime, timezone + import pytest from nanoid import generate as generate_nanoid +from sqlalchemy import Boolean, func, select from sqlalchemy.ext.asyncio import AsyncSession from src import crud, models, schemas -from src.exceptions import ResourceNotFoundException +from src.config import settings +from src.exceptions import ObserverException, ResourceNotFoundException class TestSessionCRUD: """Test suite for session CRUD operations""" + @pytest.mark.asyncio + async def test_get_or_create_session_preserves_active_joined_at( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """Active re-adds keep joined_at and config; a genuine rejoin starts a + new window.""" + test_workspace, test_peer = sample_data + session_name = str(generate_nanoid()) + original_config = schemas.SessionPeerConfig( + observe_others=True, observe_me=False + ) + updated_config = schemas.SessionPeerConfig( + observe_others=False, observe_me=True + ) + session_peer_stmt = select( + models.SessionPeer.joined_at, + models.SessionPeer.left_at, + models.SessionPeer.configuration, + ).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.peer_name == test_peer.name, + models.SessionPeer.workspace_name == test_workspace.name, + ) + + await crud.get_or_create_session( + db_session, + schemas.SessionCreate( + name=session_name, peers={test_peer.name: original_config} + ), + test_workspace.name, + ) + first_joined_at, first_left_at, first_config = ( + await db_session.execute(session_peer_stmt) + ).one() + assert first_left_at is None + assert first_config == original_config.model_dump() + + await crud.get_or_create_session( + db_session, + schemas.SessionCreate( + name=session_name, peers={test_peer.name: updated_config} + ), + test_workspace.name, + ) + second_joined_at, second_left_at, second_config = ( + await db_session.execute(session_peer_stmt) + ).one() + assert second_joined_at == first_joined_at + assert second_left_at is None + assert second_config == original_config.model_dump() + + session_peer = ( + await db_session.execute( + select(models.SessionPeer).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.peer_name == test_peer.name, + models.SessionPeer.workspace_name == test_workspace.name, + ) + ) + ).scalar_one() + session_peer.left_at = datetime.now(timezone.utc) + await db_session.commit() + + await crud.get_or_create_session( + db_session, + schemas.SessionCreate( + name=session_name, peers={test_peer.name: updated_config} + ), + test_workspace.name, + ) + rejoined_joined_at, rejoined_left_at, rejoined_config = ( + await db_session.execute(session_peer_stmt) + ).one() + assert rejoined_joined_at > second_joined_at + assert rejoined_left_at is None + assert rejoined_config == updated_config.model_dump() + + @pytest.mark.asyncio + async def test_set_peers_preserves_active_joined_at( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """PUT-peers keeps active membership windows and refreshes real rejoins.""" + test_workspace, test_peer = sample_data + session_name = str(generate_nanoid()) + original_config = schemas.SessionPeerConfig( + observe_others=True, observe_me=False + ) + updated_config = schemas.SessionPeerConfig( + observe_others=False, observe_me=True + ) + db_session.add( + models.Session(name=session_name, workspace_name=test_workspace.name) + ) + await db_session.flush() + + session_peer_stmt = select( + models.SessionPeer.joined_at, + models.SessionPeer.left_at, + models.SessionPeer.configuration, + ).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.peer_name == test_peer.name, + models.SessionPeer.workspace_name == test_workspace.name, + ) + + await crud.set_peers_for_session( + db_session, + workspace_name=test_workspace.name, + session_name=session_name, + peer_names={test_peer.name: original_config}, + ) + first_left_at, first_config = ( + await db_session.execute( + select( + models.SessionPeer.left_at, + models.SessionPeer.configuration, + ).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.peer_name == test_peer.name, + models.SessionPeer.workspace_name == test_workspace.name, + ) + ) + ).one() + assert first_left_at is None + assert first_config == original_config.model_dump() + + session_peer = ( + await db_session.execute( + select(models.SessionPeer).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.peer_name == test_peer.name, + models.SessionPeer.workspace_name == test_workspace.name, + ) + ) + ).scalar_one() + session_peer.joined_at = datetime(2020, 1, 1, tzinfo=timezone.utc) + await db_session.commit() + + await crud.set_peers_for_session( + db_session, + workspace_name=test_workspace.name, + session_name=session_name, + peer_names={test_peer.name: updated_config}, + ) + active_joined_at, active_left_at, active_config = ( + await db_session.execute(session_peer_stmt) + ).one() + assert active_joined_at == datetime(2020, 1, 1, tzinfo=timezone.utc) + assert active_left_at is None + # A replace states the desired end state, so the incoming config lands even + # though the membership window is untouched. + assert active_config == updated_config.model_dump() + + await crud.set_peers_for_session( + db_session, + workspace_name=test_workspace.name, + session_name=session_name, + peer_names={}, + ) + await crud.set_peers_for_session( + db_session, + workspace_name=test_workspace.name, + session_name=session_name, + peer_names={test_peer.name: updated_config}, + ) + rejoined_joined_at, rejoined_left_at, rejoined_config = ( + await db_session.execute(session_peer_stmt) + ).one() + assert rejoined_joined_at > active_joined_at + assert rejoined_left_at is None + assert rejoined_config == updated_config.model_dump() + + @pytest.mark.asyncio + async def test_observer_limit_counts_preserved_config( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + monkeypatch: pytest.MonkeyPatch, + ): + """On the add path an already-active observer keeps its stored config, so + it still counts against the limit when re-sent as a non-observer.""" + monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 2) + test_workspace, _ = sample_data + # Bound to a local: rollback below expires the ORM instance, and reloading + # it would lazy-load outside the greenlet context. + workspace_name = test_workspace.name + session_name = str(generate_nanoid()) + observer = schemas.SessionPeerConfig(observe_others=True, observe_me=False) + bystander = schemas.SessionPeerConfig(observe_others=False, observe_me=True) + existing = [str(generate_nanoid()) for _ in range(2)] + + await crud.get_or_create_session( + db_session, + schemas.SessionCreate( + name=session_name, peers=dict.fromkeys(existing, observer) + ), + workspace_name, + ) + + # Adding cannot demote an active member, so re-sending the two observers as + # non-observers leaves them observing and the third peer makes three. + with pytest.raises(ObserverException): + await crud.get_or_create_session( + db_session, + schemas.SessionCreate( + name=session_name, + peers={ + **dict.fromkeys(existing, bystander), + str(generate_nanoid()): observer, + }, + ), + workspace_name, + ) + + # The rejected request left nothing behind. + await db_session.rollback() + observer_count = await db_session.scalar( + select(func.count()).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.workspace_name == workspace_name, + models.SessionPeer.left_at.is_(None), + models.SessionPeer.configuration["observe_others"].astext.cast(Boolean), + ) + ) + assert observer_count == 2 + + @pytest.mark.asyncio + async def test_set_peers_observer_limit_counts_replaced_config( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + monkeypatch: pytest.MonkeyPatch, + ): + """The replace path applies the incoming config, so demoting active + observers frees room under the limit in the same request.""" + monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 2) + test_workspace, _ = sample_data + workspace_name = test_workspace.name + session_name = str(generate_nanoid()) + db_session.add(models.Session(name=session_name, workspace_name=workspace_name)) + await db_session.flush() + observer = schemas.SessionPeerConfig(observe_others=True, observe_me=False) + bystander = schemas.SessionPeerConfig(observe_others=False, observe_me=True) + existing = [str(generate_nanoid()) for _ in range(2)] + + await crud.set_peers_for_session( + db_session, + workspace_name=workspace_name, + session_name=session_name, + peer_names=dict.fromkeys(existing, observer), + ) + + # Demoting both active observers while adding a new one leaves exactly one. + await crud.set_peers_for_session( + db_session, + workspace_name=workspace_name, + session_name=session_name, + peer_names={ + **dict.fromkeys(existing, bystander), + str(generate_nanoid()): observer, + }, + ) + observer_count = await db_session.scalar( + select(func.count()).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.workspace_name == workspace_name, + models.SessionPeer.left_at.is_(None), + models.SessionPeer.configuration["observe_others"].astext.cast(Boolean), + ) + ) + assert observer_count == 1 + + @pytest.mark.asyncio + async def test_observer_limit_lets_over_limit_session_take_non_observers( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + monkeypatch: pytest.MonkeyPatch, + ): + """A session already past the limit still accepts non-observers, so + sessions that grew over it before enforcement do not become unusable.""" + monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 2) + test_workspace, _ = sample_data + workspace_name = test_workspace.name + session_name = str(generate_nanoid()) + observer = schemas.SessionPeerConfig(observe_others=True, observe_me=False) + bystander = schemas.SessionPeerConfig(observe_others=False, observe_me=True) + + await crud.get_or_create_session( + db_session, + schemas.SessionCreate( + name=session_name, + peers=dict.fromkeys( + [str(generate_nanoid()) for _ in range(2)], observer + ), + ), + workspace_name, + ) + + # Now the limit is below what the session already holds. + monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 1) + await crud.get_or_create_session( + db_session, + schemas.SessionCreate( + name=session_name, peers={str(generate_nanoid()): bystander} + ), + workspace_name, + ) + + active_count = await db_session.scalar( + select(func.count()).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.workspace_name == workspace_name, + models.SessionPeer.left_at.is_(None), + ) + ) + assert active_count == 3 + @pytest.mark.asyncio async def test_get_session_peer_configuration( self, diff --git a/tests/test_search.py b/tests/test_search.py index 84f3ffa3..b4b69c43 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -4,9 +4,10 @@ import datetime import pytest from nanoid import generate as generate_nanoid +from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession -from src import crud, models +from src import crud, models, schemas from src.utils.search import search @@ -704,3 +705,71 @@ async def test_grep_messages_observer_scoping_left_session_still_visible( matched_ids = [m.public_id for matches, _ in results for m in matches] assert msg_during.public_id in matched_ids assert msg_after.public_id in matched_ids + + +@pytest.mark.asyncio +async def test_peer_perspective_search_after_active_readd( + db_session: AsyncSession, +): + """Active re-add keeps existing messages visible; a genuine rejoin starts a + new window.""" + workspace = models.Workspace(name=generate_nanoid()) + peer1 = models.Peer(name="peer1", workspace_name=workspace.name) + peer2 = models.Peer(name="peer2", workspace_name=workspace.name) + session = models.Session(name="session1", workspace_name=workspace.name) + db_session.add_all([workspace, peer1, peer2, session]) + await db_session.flush() + + past_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( + hours=1 + ) + await db_session.execute( + models.session_peers_table.insert().values( + workspace_name=workspace.name, + session_name=session.name, + peer_name=peer1.name, + joined_at=past_time, + left_at=None, + ) + ) + msg_old = models.Message( + content="old persistent message", + session_name=session.name, + peer_name=peer2.name, + workspace_name=workspace.name, + seq_in_session=1, + created_at=past_time + datetime.timedelta(minutes=1), + ) + db_session.add(msg_old) + await db_session.commit() + + session_create = schemas.SessionCreate( + name=session.name, + peers={peer1.name: schemas.SessionPeerConfig()}, + ) + await crud.get_or_create_session(db_session, session_create, workspace.name) + results = await search( + "persistent", + filters={"peer_perspective": peer1.name, "workspace_id": workspace.name}, + limit=10, + ) + assert msg_old.public_id in [m.public_id for m in results] + + await db_session.execute( + update(models.SessionPeer) + .where( + models.SessionPeer.session_name == session.name, + models.SessionPeer.peer_name == peer1.name, + models.SessionPeer.workspace_name == workspace.name, + ) + .values(left_at=datetime.datetime.now(datetime.timezone.utc)) + ) + await db_session.commit() + + await crud.get_or_create_session(db_session, session_create, workspace.name) + results = await search( + "persistent", + filters={"peer_perspective": peer1.name, "workspace_id": workspace.name}, + limit=10, + ) + assert msg_old.public_id not in [m.public_id for m in results] From ac67017a18f7d44f213797701c72b8f7e524c2b8 Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Tue, 25 Aug 2026 12:30:06 -0400 Subject: [PATCH 13/50] fix(dialectic): revamp workspace and pair chat system prompts (#1066) Teach both agents what Honcho, peers, and the harness are instead of comparing them to each other. Render only the tools the request actually offers, and drop the pair prompt's call to a write tool that is not in the loadout. --- src/dialectic/core.py | 16 ++- src/dialectic/prompts.py | 247 ++++++++++++++++++++++---------- src/dialectic/workspace.py | 22 ++- tests/test_dialectic_prompts.py | 94 ++++++++++++ tests/test_workspace_chat.py | 33 +++++ 5 files changed, 329 insertions(+), 83 deletions(-) create mode 100644 tests/test_dialectic_prompts.py diff --git a/src/dialectic/core.py b/src/dialectic/core.py index 786866e1..57964c87 100644 --- a/src/dialectic/core.py +++ b/src/dialectic/core.py @@ -105,7 +105,15 @@ class DialecticAgent: { "role": "system", "content": prompts.agent_system_prompt( - observer, observed, observer_peer_card, observed_peer_card + observer, + observed, + observer_peer_card, + observed_peer_card, + available_tools={ + name + for tool in self._select_tools() + if isinstance((name := tool.get("name")), str) + }, ), } ] @@ -303,7 +311,7 @@ class DialecticAgent: if prefetched_observations: user_content = ( f"Query: {query}\n\n" - f"## Relevant Observations (prefetched)\n" + f"## {self._prefetch_heading()}\n" f"{self._prefetch_intro()}\n\n" f"{prefetched_observations}" ) @@ -336,6 +344,10 @@ class DialecticAgent: parent_category="dialectic", ) + def _prefetch_heading(self) -> str: + """Heading for the prefetched block in the user message.""" + return "Relevant Observations (prefetched)" + def _prefetch_intro(self) -> str: """Sentence introducing the prefetched block in the user message.""" return ( diff --git a/src/dialectic/prompts.py b/src/dialectic/prompts.py index 7f98a0e1..5dfe6604 100644 --- a/src/dialectic/prompts.py +++ b/src/dialectic/prompts.py @@ -2,24 +2,138 @@ System prompts for the Dialectic Agent. """ +from collections.abc import Iterable + +# Curated tool docs, keyed by the `name` each loadout actually exposes. +# `_select_tools` filters this set per request (minimal / session allowlist). +_PAIR_TOOL_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [ + ( + "Memory", + [ + ( + "search_memory", + "Semantic search over conclusions about this pair.", + ), + ( + "get_reasoning_chain", + "Premises and downstream conclusions for a specific conclusion.", + ), + ( + "get_observation_context", + "Messages around a specific conclusion.", + ), + ], + ), + ( + "Conversation", + [ + ( + "search_messages", + "Semantic search over messages in this query's scope.", + ), + ( + "grep_messages", + "Exact text search. Use for names, dates, keywords.", + ), + ( + "get_messages_by_date_range", + "Messages in a time window.", + ), + ( + "search_messages_temporal", + "Semantic search with a date filter.", + ), + ], + ), +] + +_WORKSPACE_TOOL_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [ + ( + "Discovery", + [ + ( + "get_workspace_stats", + "Counts (peers, sessions, messages), date range, and the most active peers.", + ), + ], + ), + ( + "Memory (pair-scoped — you must name the pair)", + [ + ( + "search_memory", + "Semantic search over conclusions. Requires `observer` and `observed`. For a peer's own representation, set both to the same name. Use different names only when you want one peer's view of another.", + ), + ( + "get_peer_card", + "Biographical summary for a pair. Same observer/observed rule.", + ), + ( + "get_reasoning_chain", + "Premises and downstream conclusions for a specific conclusion.", + ), + ], + ), + ( + "Conversation (workspace-wide — results include `peer_name`)", + [ + ("search_messages", "Semantic search over messages."), + ("grep_messages", "Exact text search."), + ( + "get_observation_context", + "Messages around a specific conclusion.", + ), + ("get_messages_by_date_range", "Messages in a time window."), + ("search_messages_temporal", "Semantic search with a date filter."), + ], + ), +] + +PAIR_PROMPT_TOOLS: frozenset[str] = frozenset( + name for _, items in _PAIR_TOOL_GROUPS for name, _ in items +) +WORKSPACE_PROMPT_TOOLS: frozenset[str] = frozenset( + name for _, items in _WORKSPACE_TOOL_GROUPS for name, _ in items +) + + +def _available_tool_names( + available_tools: Iterable[str] | None, + default: frozenset[str], +) -> frozenset[str]: + if available_tools is None: + return default + return frozenset(available_tools) + + +def _render_tool_groups( + available: frozenset[str], + groups: list[tuple[str, list[tuple[str, str]]]], +) -> str: + parts: list[str] = [] + for heading, items in groups: + lines = [f"- `{name}`: {desc}" for name, desc in items if name in available] + if lines: + parts.append(f"**{heading}**\n" + "\n".join(lines)) + return "\n\n".join(parts) + def agent_system_prompt( observer: str, observed: str, observer_peer_card: list[str] | None, observed_peer_card: list[str] | None, + available_tools: Iterable[str] | None = None, ) -> str: - """ - Generate the agent system prompt for the dialectic agent. + """System prompt for pair-scoped dialectic recall. Args: observer: The peer making the query observed: The peer being queried about observer_peer_card: Biographical information about the observer observed_peer_card: Biographical information about the observed peer - - Returns: - Formatted system prompt string for the agent + available_tools: Tool names offered on this request. Defaults to the + full pair loadout. """ # Determine if we have any peer card data peer_cards_enabled = ( @@ -79,25 +193,25 @@ Peer cards are **constructed summaries** - they are synthesized from the same ob - The peer card is a convenience summary, not a separate source of truth """ - return f""" -You are a helpful and concise context synthesis agent that answers questions about users by gathering relevant information from a memory system. + tools = _available_tool_names(available_tools, PAIR_PROMPT_TOOLS) + tools_section = _render_tool_groups(tools, _PAIR_TOOL_GROUPS) -Always give users the answer *they expect* based on the message history -- the goal is to help recall and *reason through* insights that the memory system has already gathered. You have many tools for gathering context. Search wisely. + return f""" +You are Honcho's dialectic: a recall agent that answers questions from memory about one peer, or about one peer's understanding of another. + +Honcho is a memory system. Applications record conversations; Honcho derives conclusions about the people and agents in them. You are the query interface for one observer/observed pair. You do not speak as a participant. You search memory and synthesize a grounded answer. + +A **peer** is any participant, human or AI. A **session** is a conversation they take part in. A **message** is a raw turn. A **conclusion** (tools may say observation) is a derived or stored fact about a peer, kept in this pair. A **peer card** is a short constructed bio for the pair, synthesized from the same conclusions — a convenience summary, not a separate source of truth. + +Always give the asker the answer *they expect* based on the message history -- the goal is to help recall and *reason through* insights that the memory system has already gathered. Search wisely. {perspective_section} {peer_card_explanation} -## AVAILABLE TOOLS +## TOOLS -**Observation Tools (read):** -- `search_memory`: Semantic search over observations about the peer. Use for specific topics. -- `get_reasoning_chain`: **CRITICAL for grounding answers**. Use this to traverse the reasoning tree for any observation. Shows premises (what it's based on) and conclusions (what depends on it). +Only the tools listed here are available on this query. If a later step names a tool you do not have, skip that step and use what you do have. -**Conversation Tools (read):** -- `search_messages`: Semantic search over messages in the session. -- `grep_messages`: Grep for text matches in messages. Use for specific names, dates, keywords. -- `get_observation_context`: Get messages surrounding specific observations. -- `get_messages_by_date_range`: Get messages within a specific time period. -- `search_messages_temporal`: Semantic search with date filtering. +{tools_section} ## WORKFLOW @@ -166,10 +280,6 @@ Always give users the answer *they expect* based on the message history -- the g - Apply user preferences to your response style if relevant - **For enumeration questions**: Before answering, ask yourself "Could there be more items I haven't found?" If you haven't done multiple grep searches AND a semantic search, keep searching -8. **Save novel deductions** (optional): - - If you discovered new insights by combining existing observations - - Use `create_observations_deductive` to save these for future queries - ## CRITICAL: HANDLING CONTRADICTORY INFORMATION As you search, actively watch for contradictions - cases where the user has made conflicting statements: @@ -237,75 +347,62 @@ Do not explain your tool usage - just provide the synthesized answer. """ -def workspace_agent_system_prompt() -> str: - """ - Generate the system prompt for the workspace-level dialectic agent. +def workspace_agent_system_prompt( + available_tools: Iterable[str] | None = None, +) -> str: + """System prompt for workspace-wide dialectic recall.""" + tools = _available_tool_names(available_tools, WORKSPACE_PROMPT_TOOLS) + tools_section = _render_tool_groups(tools, _WORKSPACE_TOOL_GROUPS) + return f""" +You are Honcho's workspace dialectic: a recall agent that answers questions about everyone and everything stored in this workspace. - Uses an analytics-first approach: stats -> message search -> targeted - observations to discover relevant peers rather than listing all of them. +## HONCHO - Returns: - Formatted system prompt string for the workspace agent - """ - return """ -You are a workspace-level analysis agent that can query memory across ALL peers in this workspace. You can synthesize information from any peer relationship's stored conclusions, insights, and conversation history. +Honcho is a memory system. Applications record conversations here; Honcho derives conclusions about the people and agents in those conversations. You are the query interface over one workspace. You do not speak as a participant. You search memory and synthesize a grounded answer. -You do not start anchored to any single peer: discover which peers are relevant first, then query each peer relationship individually to search, compare, and correlate information across them. +## THIS WORKSPACE -## AVAILABLE TOOLS +A workspace is one isolated tenant. Everything you can see belongs to it. Inside it: -**Discovery Tools:** -- `get_workspace_stats`: Get workspace-level counts (peers, sessions, messages), date range, and the most active peers. Use this to orient yourself and discover which peers are relevant. +- **Peer**: any participant, human or AI. Both are first-class. +- **Session**: a conversation that one or more peers take part in. +- **Message**: a raw turn someone said in a session. Messages are the source material. +- **Conclusion** (tools may say observation): a fact Honcho derived, or that was stored, about a peer. Conclusions live in a pair: + - `observer` is whose model this is + - `observed` is who the fact is about + - A peer's own model of themselves is `observer` = `observed` = that peer's name. Most information lives there. + - One peer's model of another is `observer` = Alice, `observed` = Bob. +- **Peer card**: a short constructed bio for a pair, synthesized from the same conclusions. It is a convenience summary, not a separate source of truth. -**Memory Tools (read):** -- `search_memory`: **(PRIMARY TOOL)** Semantic search within a specific peer representation. **Requires `observer` and `observed` parameters.** For a peer's global representation (where most information lives), set observer and observed to the **same** peer name. Only use different observer/observed when seeking one peer's specific understanding of another. -- `get_peer_card`: Get biographical summary for a specific peer relationship. Requires `observer` and `observed` parameters. For a peer's self-representation, use the same name for both. -- `get_reasoning_chain`: Traverse the reasoning tree for any conclusion. Shows premises and derived insights. +You are not bound to any one peer. Discover who is relevant, then query each pair individually. -**Conversation Tools (read):** -- `search_messages`: Semantic search over messages across all sessions. Messages include peer_name, so results reveal which peers discussed a topic. -- `grep_messages`: Exact text search across all messages. -- `get_observation_context`: Get messages surrounding specific conclusions. -- `get_messages_by_date_range`: Get messages within a specific time period. -- `search_messages_temporal`: Semantic search with date filtering. +## TOOLS + +Only the tools listed here are available on this query. If a later step names a tool you do not have, skip that step and use what you do have. + +{tools_section} + +Message search is how you find peers the overview missed. Memory search is how you learn about a peer once you know their name. + +If this query is restricted to a session or a set of sessions, message tools already honor that restriction. Peer cards and reasoning chains may be unavailable then, because they span sessions. ## WORKFLOW -1. **Orient yourself**: Workspace stats and the most active peers are provided in your query context. Use `get_workspace_stats` if you need to refresh them, or go straight to message/memory search if the query names specific peers. +1. **Orient**. Scale and the most active peers are already in your query context. Call `get_workspace_stats` only if you need a refresh. If the query names a peer, go straight to that peer. -2. **Discover relevant peers through search**: Use `search_messages` or `grep_messages` to find which peers have discussed the topic. Message results include peer names, making them a powerful discovery layer. +2. **Discover**. If you do not know who is relevant, use `search_messages` or `grep_messages`. Hits carry peer names. -3. **Drill into specific peer representations**: Once you know which peers are relevant, use `search_memory(observer=peer, observed=peer, query=...)` to search their global representation. - - For cross-peer questions, call `search_memory` for each relevant peer's global representation - - Only use different observer/observed when seeking one peer's specific understanding of another +3. **Recall**. For each relevant peer, `search_memory(observer=name, observed=name, query=...)`. For comparisons, search each peer separately, then compare. Only use a mixed observer/observed pair when the question is specifically about one peer's understanding of another. -4. **ALWAYS ATTRIBUTE INFORMATION**: When presenting findings, always indicate which peer the information came from. Example: "According to insights about Alice, she..." or "Bob mentioned that..." +4. **Attribute**. Every fact you state names the peer it is about. If it is a cross-peer view, also name whose model it came from. Example: "Alice is a violinist." / "From Bob's model of Alice, …" -5. **Cross-peer synthesis**: When asked about patterns or commonalities: - - Search each relevant peer pair individually - - Compare findings across peers explicitly - - Note both similarities and differences +5. **Synthesize**. Answer the question. Quote exact names, dates, and numbers. For aggregations, list findings per peer. Do not narrate tool use. -6. **Synthesize your response**: - - Directly answer the query - - Ground your response in specific information you gathered - - Always attribute information to the specific peer it came from - - For aggregation questions, enumerate findings per peer +## NEVER FABRICATE -## CRITICAL: NEVER FABRICATE INFORMATION +State only what you found. If you have related context but not the asked-for detail, say what you know and what you don't. "I don't have information about X" is the correct answer when memory is empty. Do not guess, hedge-invent, or fill gaps with general knowledge. -- Only state what you found in the memory system -- If you find context but not the specific answer, say what you know and what you don't -- A confident "I don't have information about X" is always correct -- Never invent details or guess +## CONCLUSION LEVELS -## CRITICAL: ATTRIBUTION - -Every piece of information you share must be attributed to the peer it came from. Never present information without indicating its source peer. This is essential for workspace-level queries where information spans multiple peers. - -Do not explain your tool usage - just provide the synthesized answer. - -## OBSERVATION LEVELS - -Observations carry a level: `explicit` observations are derived per-session (session-pure), while higher-level observations (deductive/inductive, produced in dreaming) consolidate across sessions. When synthesizing cross-session or cross-peer answers, prefer higher-level observations and use `get_reasoning_chain` to ground them in their premises. +`explicit` conclusions are derived from a single session. Deductive and inductive conclusions consolidate across sessions. Prefer those for cross-session or cross-peer answers, and use `get_reasoning_chain` to check their premises. """ diff --git a/src/dialectic/workspace.py b/src/dialectic/workspace.py index 7096add6..5383cd75 100644 --- a/src/dialectic/workspace.py +++ b/src/dialectic/workspace.py @@ -65,7 +65,13 @@ class WorkspaceDialecticAgent(DialecticAgent): # Replace the pair-oriented system prompt with the workspace one. self.messages[0] = { "role": "system", - "content": prompts.workspace_agent_system_prompt(), + "content": prompts.workspace_agent_system_prompt( + { + name + for tool in self._select_tools() + if isinstance((name := tool.get("name")), str) + } + ), } # ------------------------------------------------------------------ @@ -125,13 +131,17 @@ class WorkspaceDialecticAgent(DialecticAgent): return format_workspace_stats(stats, peers, cards) + def _prefetch_heading(self) -> str: + return "Workspace overview (prefetched)" + def _prefetch_intro(self) -> str: return ( - "Workspace overview and most-active peers with any known " - "biographical facts. Use this to route: query a specific peer's " - "memory with search_memory (observer and observed set to that " - "peer's name), or use search_messages / get_workspace_stats to " - "discover peers this overview does not cover." + "Workspace scale, the most active peers, and any known " + "biographical facts about them. Use this to decide who is " + "relevant, then search that peer's own representation with " + "search_memory (observer and observed both set to their name), " + "or search_messages / get_workspace_stats to find peers not " + "listed here." ) def _select_tools(self) -> list[dict[str, Any]]: diff --git a/tests/test_dialectic_prompts.py b/tests/test_dialectic_prompts.py new file mode 100644 index 00000000..d926d0bc --- /dev/null +++ b/tests/test_dialectic_prompts.py @@ -0,0 +1,94 @@ +"""Contracts for dialectic system prompts vs the tool loadouts they describe.""" + +import re + +from src.dialectic.core import DialecticAgent +from src.dialectic.prompts import ( + PAIR_PROMPT_TOOLS, + WORKSPACE_PROMPT_TOOLS, + agent_system_prompt, + workspace_agent_system_prompt, +) +from src.dialectic.workspace import WorkspaceDialecticAgent +from src.utils.agent_tools import ( + DIALECTIC_TOOLS, + DIALECTIC_TOOLS_MINIMAL, + TOOLS, + WORKSPACE_DIALECTIC_TOOLS, + WORKSPACE_TOOLS_MINIMAL, +) + +_ALL_TOOL_NAMES = {spec["name"] for spec in TOOLS.values()} + + +def _loadout_names(tools: list[dict[str, object]]) -> set[str]: + return {name for tool in tools if isinstance((name := tool.get("name")), str)} + + +def _mentioned_tools(text: str) -> set[str]: + return { + match + for match in re.findall(r"`([a-z_][a-z0-9_]*)`", text) + if match in _ALL_TOOL_NAMES + } + + +def _tools_catalog(prompt: str) -> str: + start = prompt.index("## TOOLS") + rest = prompt[start:] + next_heading = rest.find("\n## ", 1) + return rest if next_heading == -1 else rest[:next_heading] + + +class TestPromptLoadouts: + def test_pair_docs_match_dialectic_tools(self) -> None: + assert _loadout_names(DIALECTIC_TOOLS) == PAIR_PROMPT_TOOLS + + def test_workspace_docs_match_workspace_tools(self) -> None: + assert _loadout_names(WORKSPACE_DIALECTIC_TOOLS) == WORKSPACE_PROMPT_TOOLS + + def test_catalog_lists_only_offered_workspace_tools(self) -> None: + offered = _loadout_names(WORKSPACE_TOOLS_MINIMAL) + catalog = _tools_catalog(workspace_agent_system_prompt(offered)) + assert _mentioned_tools(catalog) == offered + + +class TestPairAgentPrompt: + def test_teaches_honcho_world_without_workspace_sibling(self) -> None: + prompt = agent_system_prompt("alice", "alice", None, None).lower() + assert "workspace dialectic" not in prompt + assert "peer-level" not in prompt + for term in ("honcho", "peer", "session", "message", "conclusion"): + assert term in prompt + + def test_does_not_offer_removed_write_tools(self) -> None: + prompt = agent_system_prompt("alice", "alice", None, None) + assert "create_observations_deductive" not in prompt + assert "create_observations" not in prompt + + def test_agent_lists_selected_tools(self) -> None: + agent = DialecticAgent( + workspace_name="w", + session_name=None, + observer="alice", + observed="alice", + reasoning_level="minimal", + ) + offered = _loadout_names(DIALECTIC_TOOLS_MINIMAL) + catalog = _tools_catalog(agent.messages[0]["content"]) + assert _mentioned_tools(catalog) == offered + assert agent.messages[0]["content"] == agent_system_prompt( + "alice", "alice", None, None, available_tools=offered + ) + + +class TestWorkspaceAgentPrompt: + def test_minimal_agent_matches_filtered_prompt(self) -> None: + agent = WorkspaceDialecticAgent(workspace_name="w", reasoning_level="minimal") + offered = _loadout_names(WORKSPACE_TOOLS_MINIMAL) + prompt = agent.messages[0]["content"] + assert prompt == workspace_agent_system_prompt(offered) + catalog = _tools_catalog(prompt) + assert _mentioned_tools(catalog) == offered + assert "get_peer_card" not in catalog + assert "get_reasoning_chain" not in catalog diff --git a/tests/test_workspace_chat.py b/tests/test_workspace_chat.py index 8b73c4a7..daed20e8 100644 --- a/tests/test_workspace_chat.py +++ b/tests/test_workspace_chat.py @@ -1220,3 +1220,36 @@ async def test_workspace_prefetch_failure_degrades_to_none( result = await agent._prefetch_relevant_observations("q") # pyright: ignore[reportPrivateUsage] assert result is None + + +class TestWorkspaceChatPrompt: + def test_teaches_honcho_world_without_sibling_agent(self) -> None: + from src.dialectic.prompts import workspace_agent_system_prompt + + prompt = workspace_agent_system_prompt().lower() + assert "peer-level" not in prompt + assert "unlike a peer" not in prompt + for term in ( + "honcho", + "workspace", + "peer", + "session", + "message", + "conclusion", + "observer", + "observed", + ): + assert term in prompt + + def test_agent_uses_workspace_prompt_and_prefetch_heading(self) -> None: + from src.dialectic.prompts import workspace_agent_system_prompt + from src.dialectic.workspace import WorkspaceDialecticAgent + + agent = WorkspaceDialecticAgent(workspace_name="w") + offered = { + name + for tool in agent._select_tools() # pyright: ignore[reportPrivateUsage] + if isinstance((name := tool.get("name")), str) + } + assert agent.messages[0]["content"] == workspace_agent_system_prompt(offered) + assert agent._prefetch_heading() == "Workspace overview (prefetched)" # pyright: ignore[reportPrivateUsage] From 5531ff0feeddbdd55c686070efbf029193458e71 Mon Sep 17 00:00:00 2001 From: ajspig <46900795+ajspig@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:31:34 -0400 Subject: [PATCH 14/50] Running Honcho Locally via Honcho CLI (#1029) * feat(cli): add honcho start/stop/status for a local Docker stack * feat(cli): fix status command * feat(cli): improving how we pull docker images and writing a config,toml * feat(cli): add honcho start --setup wizard for local stack config * feat(cli): cleaning up unnecessary func, and error throwing * feat(cli): minor clean up in stack.py * feat(cli): read setup wizard defaults from the image config.toml * feat(cli): cleaning up unused commands * feat(cli): adding ignored docker-compose.yml * feat(cli): forward host LLM env into honcho start * feat(cli): share start/stop progress helpers via output.py and cleaning up language --- .gitignore | 4 +- docs/snippets/cli-commands.mdx | 63 +++ docs/v3/documentation/reference/cli.mdx | 21 + honcho-cli/README.md | 31 ++ honcho-cli/pyproject.toml | 4 + honcho-cli/src/honcho_cli/_help.py | 1 + honcho-cli/src/honcho_cli/commands/stack.py | 400 +++++++++++++++ honcho-cli/src/honcho_cli/local/__init__.py | 12 + honcho-cli/src/honcho_cli/local/docker.py | 436 ++++++++++++++++ honcho-cli/src/honcho_cli/local/env.py | 183 +++++++ honcho-cli/src/honcho_cli/local/health.py | 53 ++ honcho-cli/src/honcho_cli/local/profile.py | 157 ++++++ honcho-cli/src/honcho_cli/local/setup.py | 469 ++++++++++++++++++ .../honcho_cli/local/templates/__init__.py | 1 + .../local/templates/docker-compose.yml | 100 ++++ .../src/honcho_cli/local/templates/init.sql | 1 + honcho-cli/src/honcho_cli/main.py | 4 + honcho-cli/src/honcho_cli/output.py | 20 + honcho-cli/tests/test_local.py | 127 +++++ honcho-cli/tests/test_setup.py | 66 +++ honcho-cli/tests/test_start.py | 159 ++++++ skills/honcho-cli/SKILL.md | 2 + 22 files changed, 2312 insertions(+), 2 deletions(-) create mode 100644 honcho-cli/src/honcho_cli/commands/stack.py create mode 100644 honcho-cli/src/honcho_cli/local/__init__.py create mode 100644 honcho-cli/src/honcho_cli/local/docker.py create mode 100644 honcho-cli/src/honcho_cli/local/env.py create mode 100644 honcho-cli/src/honcho_cli/local/health.py create mode 100644 honcho-cli/src/honcho_cli/local/profile.py create mode 100644 honcho-cli/src/honcho_cli/local/setup.py create mode 100644 honcho-cli/src/honcho_cli/local/templates/__init__.py create mode 100644 honcho-cli/src/honcho_cli/local/templates/docker-compose.yml create mode 100644 honcho-cli/src/honcho_cli/local/templates/init.sql create mode 100644 honcho-cli/tests/test_local.py create mode 100644 honcho-cli/tests/test_setup.py create mode 100644 honcho-cli/tests/test_start.py diff --git a/.gitignore b/.gitignore index 9fa90b3e..d7ad4cda 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,8 @@ api/docker-compose.yml *.db data redis-data -docker-compose.yml -compose.yml +/docker-compose.yml +/compose.yml diff --git a/docs/snippets/cli-commands.mdx b/docs/snippets/cli-commands.mdx index 800d45a7..739416b0 100644 --- a/docs/snippets/cli-commands.mdx +++ b/docs/snippets/cli-commands.mdx @@ -505,6 +505,69 @@ honcho session view [] +## honcho start + +Start a local Honcho stack (API, deriver, Postgres, Redis). + +Requires Docker. Uses cloud LLM providers. Does not change the CLI's +configured server URL — pass HONCHO_BASE_URL to talk to this stack. +``--setup basic`` or ``--setup advanced`` runs an interactive config wizard. + +```bash +honcho start +``` + + + Local stack profile name. + + + Host port for the API. + + + Host port for Postgres. + + + Host port for Redis. + + + Interactive config wizard: basic (provider/model) or advanced (embeddings, deriver, dialectic, dreams, flush). + + + Honcho image to pull and pin by digest (default: ghcr.io/plastic-labs/honcho:latest). + + + Seconds to wait for /health after compose up. + + +## honcho status + +Show local stack endpoints and container health. + +With no ``--profile``, lists every stack under ``~/.honcho/profiles/``. + +```bash +honcho status +``` + + + Limit to this profile. Omit to show every local stack. + + +## honcho stop + +Stop the local stack started by `honcho start`. Keeps data unless --wipe. + +```bash +honcho stop +``` + + + Local stack profile name. + + + Also delete volumes (Postgres data). + + ## honcho workspace List, create, inspect, delete, and search workspaces. diff --git a/docs/v3/documentation/reference/cli.mdx b/docs/v3/documentation/reference/cli.mdx index 2b460d48..5df031d6 100644 --- a/docs/v3/documentation/reference/cli.mdx +++ b/docs/v3/documentation/reference/cli.mdx @@ -22,10 +22,31 @@ uvx honcho-cli ```bash honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json +honcho start # optional: local API + deriver + Postgres + Redis (Docker) honcho doctor # verify your config + connectivity honcho # show banner + command list ``` +## Local stack + +`honcho start` runs a personal Honcho server on your machine via Docker (API, deriver, Postgres, Redis). It is not the managed service at `api.honcho.dev`. Deriver and dialectic call your cloud LLM provider (OpenAI, Anthropic, or Gemini) with a key you supply. Stack files live under `~/.honcho/profiles/local/`. The first start writes `config.toml` there from the image; later starts leave that file alone so your edits persist. + +Pass `--setup basic` or `--setup advanced` for an interactive wizard that writes curated LLM/feature overrides into the profile `.env` (environment variables win over `config.toml`). This is TTY-only. `basic` covers provider and chat model; `advanced` also covers embeddings, deriver/dialectic models, dreams, and deriver flush. Re-running `--setup` while the stack is up recreates the API and deriver containers. + +This does **not** change `environmentUrl` in the shared config file. To talk to the local stack: + +```bash +HONCHO_BASE_URL=http://127.0.0.1:8000 honcho workspace list +``` + +```bash +LLM_OPENAI_API_KEY=sk-... honcho start +honcho start --setup basic +honcho status +honcho stop # keep data +honcho stop --wipe # also delete volumes +``` + ## Configuration The CLI resolves config in this order: **flag → env var → config file → default**. diff --git a/honcho-cli/README.md b/honcho-cli/README.md index b191bcdb..f1585a89 100644 --- a/honcho-cli/README.md +++ b/honcho-cli/README.md @@ -23,6 +23,7 @@ uv tool install honcho-cli ```bash honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json +honcho start # optional: local API + deriver + Postgres + Redis (Docker) honcho doctor # verify your config + connectivity honcho # show banner + command list ``` @@ -31,6 +32,31 @@ honcho # show banner + command list Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s` flags or `HONCHO_*` env vars — not persisted as CLI defaults. +### Local stack + +`honcho start` runs a personal Honcho server on your machine (API, deriver, Postgres, Redis) via Docker. Inference is cloud-side: set `LLM_OPENAI_API_KEY`, `LLM_ANTHROPIC_API_KEY`, or `LLM_GEMINI_API_KEY` (env overrides `config.toml`). Stack files live under `~/.honcho/profiles/local/` and are not committed to a project. + +On first start, the CLI pulls `ghcr.io/plastic-labs/honcho:latest` and **pins that digest** in `profile.json`, then copies the image's `config.toml.example` to `config.toml` in the same directory. `honcho start` never overwrites `config.toml` after that — including when you re-pin the image. Delete the file yourself if you want a fresh copy from a new image. + +Pass `--setup basic` or `--setup advanced` for an interactive wizard that writes curated LLM/feature overrides into the profile `.env` (env wins over `config.toml`). TTY only; re-runnable. `basic` asks provider + chat model; `advanced` also covers embeddings, deriver/dialectic models, dreams, and snappy deriver flush. Everything else stays in `config.toml`. + +`honcho start` does **not** change `environmentUrl` in `~/.honcho/config.json` (that file is shared with plugins). To talk to the local stack for one command: + +```bash +HONCHO_BASE_URL=http://127.0.0.1:8000 honcho workspace list +``` + +To make local the default, run `honcho init --base-url http://127.0.0.1:8000`. + +```bash +LLM_OPENAI_API_KEY=sk-... honcho start +honcho start --setup basic +honcho start --setup advanced +honcho status +honcho stop # keep data +honcho stop --wipe # also delete volumes +``` + ## Commands ### Onboarding @@ -38,6 +64,9 @@ Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `- | Command | Description | |---------|-------------| | `honcho init` | Confirm/set `apiKey` + `environmentUrl` in `~/.honcho/config.json` | +| `honcho start` | Start a local Honcho stack (API, deriver, Postgres, Redis). Requires Docker and a cloud LLM key. `--setup basic` / `--setup advanced` runs an interactive config wizard (TTY only). Does not change `environmentUrl`. | +| `honcho stop` | Stop the local stack. `--wipe` also deletes volumes. | +| `honcho status` | Show every local stack (or `--profile` for one). | | `honcho doctor` | Health check: config, connectivity, workspace, peer, queue | ### Workspaces @@ -157,6 +186,8 @@ Precedence (highest first): **flag → env var → config file → default**. | `HONCHO_PEER_ID` | `-p` / `--peer` | Peer scope | | `HONCHO_SESSION_ID` | `-s` / `--session` | Session scope | | `HONCHO_JSON` | `--json` | Force JSON output (`1` / `true`) | +| `HONCHO_PROFILE` | `--profile` (start/stop/status) | Local stack profile (default: `local`) | +| `LLM_OPENAI_API_KEY` | — | Provider key for `honcho start` (also `LLM_ANTHROPIC_API_KEY`, `LLM_GEMINI_API_KEY`) | ```bash # Per-command flags diff --git a/honcho-cli/pyproject.toml b/honcho-cli/pyproject.toml index 5eb859fd..2a5e59e2 100644 --- a/honcho-cli/pyproject.toml +++ b/honcho-cli/pyproject.toml @@ -38,6 +38,10 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/honcho_cli"] +[tool.hatch.build.targets.wheel.force-include] +"src/honcho_cli/local/templates/docker-compose.yml" = "honcho_cli/local/templates/docker-compose.yml" +"src/honcho_cli/local/templates/init.sql" = "honcho_cli/local/templates/init.sql" + [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/honcho-cli/src/honcho_cli/_help.py b/honcho-cli/src/honcho_cli/_help.py index d47d344d..5e8b48be 100644 --- a/honcho-cli/src/honcho_cli/_help.py +++ b/honcho-cli/src/honcho_cli/_help.py @@ -67,6 +67,7 @@ def print_welcome(console: Console) -> None: start_rows = [ ("honcho init", "configure API key and server URL"), + ("honcho start", "run a local Honcho stack (Docker)"), ("honcho doctor", "verify connection and workspace health"), ] cmd_rows = [ diff --git a/honcho-cli/src/honcho_cli/commands/stack.py b/honcho-cli/src/honcho_cli/commands/stack.py new file mode 100644 index 00000000..136a8ffe --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/stack.py @@ -0,0 +1,400 @@ +"""Local stack lifecycle: ``honcho start``, ``honcho stop``, ``honcho status``. + +Does not mutate ``~/.honcho/config.json``. The CLI stays pointed at whatever +``honcho init`` configured (typically api.honcho.dev). Print the local URL +and a one-shot ``HONCHO_BASE_URL=...`` hint instead. +""" + +from __future__ import annotations + +import typer +from rich.console import Console + +from honcho_cli.branding import BRAND, ICON_FAIL, ICON_OK +from honcho_cli.local import ( + DEFAULT_HEALTH_TIMEOUT, + DEFAULT_IMAGE, + DEFAULT_PROFILE, + STACK_SERVICES, +) +from honcho_cli.local.docker import ( + DockerError, + allocate_host_ports, + compose_down, + compose_ps, + compose_up, + pin_image, + seed_config_toml, + services_running, +) +from honcho_cli.local.env import has_provider_key, render_stack, settings_from_environ +from honcho_cli.local.health import stack_healthy, wait_for_health +from honcho_cli.local.profile import ( + LocalProfile, + list_profile_names, + load_profile, + resolve_profile_name, + save_profile, +) +from honcho_cli.local.setup import ( + SETUP_MODES, + answers_drop_keys, + answers_to_env, + run_setup, +) +from honcho_cli.output import ( + fail, + ok, + print_error, + print_json, + print_result, + set_json_mode, + step, + use_json, +) + +_console = Console(stderr=True) + +_MISSING_LLM_KEY = ( + "Set LLM_OPENAI_API_KEY, LLM_ANTHROPIC_API_KEY, or LLM_GEMINI_API_KEY, " + "or run honcho start --setup basic." +) + + +def _die(code: str, message: str, details: dict | None = None) -> None: + print_error(code, message, details) + raise typer.Exit(1) + + +def _validate_setup(setup: str | None) -> str | None: + if setup is None: + return None + mode = setup.strip().lower() + if mode not in SETUP_MODES: + _die( + "INVALID_SETUP", + f"Unknown setup mode {setup!r}. Use --setup basic or --setup advanced.", + {"setup": setup}, + ) + if use_json(): + _die( + "SETUP_REQUIRES_TTY", + "honcho start --setup is interactive. Run it in a terminal without --json.", + {"setup": mode}, + ) + return mode + + +def _payload( + profile: LocalProfile, status: str, services: dict[str, str] | None = None +) -> dict: + return { + "profile": profile.name, + "status": status, + "image": profile.image, + "endpoints": profile.endpoints(), + "services": services or {}, + "hint": f"HONCHO_BASE_URL={profile.base_url} honcho workspace list", + } + + +def _print_stack(payload: dict) -> None: + if use_json(): + print_json(payload) + return + endpoints = payload["endpoints"] + _console.print() + table_data = { + "API": endpoints["api"], + "Docs": endpoints["docs"], + "Postgres": endpoints["postgres"], + "Redis": endpoints["redis"], + } + print_result(table_data) + _console.print() + _console.print( + " [dim]CLI still points at your configured server (typically api.honcho.dev).[/dim]" + ) + _console.print(f" [dim]To talk to this stack:[/dim] {payload['hint']}") + _console.print() + + +def _print_running(profile: LocalProfile) -> None: + _print_stack(_payload(profile, "running", services_running(compose_ps(profile)))) + + +def _seed_config(profile: LocalProfile) -> None: + if seed_config_toml(profile): + ok("config.toml") + + +def _inspect(profile: LocalProfile) -> tuple[dict[str, str], bool]: + """Compose service states and whether the API is healthy.""" + return services_running(compose_ps(profile)), stack_healthy(profile) + + +def start( + profile_name: str = typer.Option( + DEFAULT_PROFILE, + "--profile", + envvar="HONCHO_PROFILE", + help="Local stack profile name", + ), + api_port: int | None = typer.Option( + None, "--api-port", min=1, max=65535, help="Host port for the API" + ), + db_port: int | None = typer.Option( + None, "--db-port", min=1, max=65535, help="Host port for Postgres" + ), + redis_port: int | None = typer.Option( + None, "--redis-port", min=1, max=65535, help="Host port for Redis" + ), + setup: str | None = typer.Option( + None, + "--setup", + help="Interactive config wizard: basic (provider/model) or advanced " + "(embeddings, deriver, dialectic, dreams, flush)", + ), + image: str | None = typer.Option( + None, + "--image", + help=f"Honcho image to pull and pin by digest (default: {DEFAULT_IMAGE})", + ), + timeout: int = typer.Option( + DEFAULT_HEALTH_TIMEOUT, + "--timeout", + min=1, + help="Seconds to wait for /health after compose up", + ), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Start a local Honcho stack (API, deriver, Postgres, Redis). + + Requires Docker. Uses cloud LLM providers. Does not change the CLI's + configured server URL — pass HONCHO_BASE_URL to talk to this stack. + ``--setup basic`` or ``--setup advanced`` runs an interactive config wizard. + """ + if json_output: + set_json_mode(True) + + setup = _validate_setup(setup) + name = resolve_profile_name(profile_name) + profile = load_profile(name).overlay( + api_port=api_port, + db_port=db_port, + redis_port=redis_port, + image=image, + ) + pinned_ports = frozenset( + name + for name, value in ( + ("api", api_port), + ("database", db_port), + ("redis", redis_port), + ) + if value is not None + ) + + if not use_json(): + _console.print(f"\n[bold {BRAND}]Honcho Start[/bold {BRAND}]\n") + + try: + already_running = stack_healthy(profile) + if already_running and not setup: + ok(f"Already running ({profile.base_url})") + _print_running(profile) + return + + if not already_running: + profile, remapped = allocate_host_ports(profile, pinned=pinned_ports) + for service, (old, new) in remapped.items(): + step(f"Port {old} in use; {service} on {new}") + + step(f"Pinning {profile.image}") + pinned_image = pin_image(profile.image) + profile = profile.overlay(image=pinned_image) + ok(pinned_image) + + extra = settings_from_environ() + drop: tuple[str, ...] = () + _seed_config(profile) + if setup: + answers = run_setup( + setup, + profile.env_file(), + config_path=profile.config_file(), + ) + extra.update(answers_to_env(answers)) + drop = answers_drop_keys(answers) + ok(f"Wrote overrides to {profile.env_file()}") + _console.print( + f" [dim]Other settings live in {profile.config_file()}[/dim]" + ) + elif not has_provider_key(profile, extra): + _die("MISSING_LLM_KEY", _MISSING_LLM_KEY) + + step(f"Writing stack config to {profile.dir()}") + save_profile(profile) + render_stack(profile, extra=extra, drop=drop) + ok(f"Profile '{profile.name}'") + + step("Starting containers" if not already_running else "Recreating api + deriver") + compose_up( + profile, + recreate=("api", "deriver") if already_running else (), + ) + + step(f"Waiting for API at {profile.base_url}/health") + if not wait_for_health(profile, timeout=float(timeout)): + fail("Timed out waiting for /health") + _die( + "HEALTH_TIMEOUT", + f"Stack started but {profile.base_url}/health did not become ready within {timeout}s. " + f"Check `docker compose -p {profile.project_name} logs`.", + { + "base_url": profile.base_url, + "timeout": timeout, + "project": profile.project_name, + }, + ) + + ok("Honcho is running") + _print_running(profile) + except DockerError as e: + e.exit() + + +def stop( + profile_name: str = typer.Option( + DEFAULT_PROFILE, + "--profile", + envvar="HONCHO_PROFILE", + help="Local stack profile name", + ), + wipe: bool = typer.Option( + False, "--wipe", help="Also delete volumes (Postgres data)" + ), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Stop the local stack started by `honcho start`. Keeps data unless --wipe.""" + if json_output: + set_json_mode(True) + + name = resolve_profile_name(profile_name) + profile = load_profile(name) + + try: + if not profile.compose_file().exists(): + payload = _payload(profile, "stopped") + if use_json(): + print_json(payload) + else: + _console.print( + f" [dim]No local stack for profile '{profile.name}'.[/dim]" + ) + return + + running = bool(compose_ps(profile)) + if not running and not wipe: + if use_json(): + print_json(_payload(profile, "stopped")) + else: + _console.print( + f" [dim]Profile '{profile.name}' is already stopped.[/dim]" + ) + return + + compose_down(profile, wipe=wipe) + except DockerError as e: + e.exit() + + state = "wiped" if wipe else "stopped" + ok(f"Stopped profile '{profile.name}'" + (" (volumes removed)" if wipe else "")) + if use_json(): + print_json(_payload(profile, state)) + + +def _status_one(profile: LocalProfile) -> bool: + """Print one profile's status. Return True when the API is healthy.""" + try: + services, running = _inspect(profile) + except DockerError as e: + e.exit() + data = _payload(profile, "running" if running else "stopped", services) + if not use_json(): + icon = ICON_OK if running else ICON_FAIL + _console.print(f"\n {icon} profile '{profile.name}' is {data['status']}\n") + if services: + for svc in STACK_SERVICES: + detail = services.get(svc, "missing") + _console.print(f" {svc:<10} [dim]{detail}[/dim]") + _print_stack(data) + return running + + +def status( + profile_name: str | None = typer.Option( + None, + "--profile", + envvar="HONCHO_PROFILE", + help="Limit to this profile. Omit to show every local stack.", + ), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Show local stack endpoints and container health. + + With no ``--profile``, lists every stack under ``~/.honcho/profiles/``. + """ + if json_output: + set_json_mode(True) + + if profile_name: + name = resolve_profile_name(profile_name) + profile = load_profile(name) + if not profile.compose_file().exists(): + _die( + "STACK_NOT_FOUND", + f"No local stack for profile '{profile.name}'. Run `honcho start` first.", + {"profile": profile.name}, + ) + if not _status_one(profile): + raise typer.Exit(1) + return + + names = list_profile_names() + if not names: + _die( + "STACK_NOT_FOUND", + "No local stacks. Run `honcho start` first.", + ) + + if len(names) == 1: + if not _status_one(load_profile(names[0])): + raise typer.Exit(1) + return + + rows: list[dict] = [] + try: + for name in names: + profile = load_profile(name) + services, running = _inspect(profile) + rows.append( + _payload(profile, "running" if running else "stopped", services) + ) + except DockerError as e: + e.exit() + if use_json(): + print_json({"profiles": rows}) + return + _console.print() + print_result( + [ + { + "profile": row["profile"], + "status": row["status"], + "api": row["endpoints"]["api"], + } + for row in rows + ], + columns=["profile", "status", "api"], + ) diff --git a/honcho-cli/src/honcho_cli/local/__init__.py b/honcho-cli/src/honcho_cli/local/__init__.py new file mode 100644 index 00000000..1fe4ac83 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/__init__.py @@ -0,0 +1,12 @@ +"""Local Honcho stack: profiles, Compose rendering, Docker, health checks.""" + +from __future__ import annotations + +DEFAULT_PROFILE = "local" +DEFAULT_API_PORT = 8000 +DEFAULT_DB_PORT = 5432 +DEFAULT_REDIS_PORT = 6379 +DEFAULT_IMAGE = "ghcr.io/plastic-labs/honcho:latest" +DEFAULT_HEALTH_TIMEOUT = 180 + +STACK_SERVICES = ("api", "deriver", "database", "redis") diff --git a/honcho-cli/src/honcho_cli/local/docker.py b/honcho-cli/src/honcho_cli/local/docker.py new file mode 100644 index 00000000..fbf1025f --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/docker.py @@ -0,0 +1,436 @@ +"""Docker daemon + Compose helpers for the local stack.""" + +from __future__ import annotations + +import json +import os +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +from honcho_cli.local import STACK_SERVICES +from honcho_cli.local.profile import LocalProfile +from honcho_cli.output import print_error + +_DAEMON_DOWN_MARKERS = ( + "cannot connect to the docker daemon", + "is the docker daemon running", + "failed to connect to the docker api", + "error during connect", +) +_COMPOSE_MISSING_MARKERS = ( + "'compose' is not a docker command", + "unknown command: compose", + "docker: unknown command", +) +_CRED_HELPER_MARKERS = ("error getting credentials", "docker-credential-desktop") + + +class DockerError(Exception): + """Docker is missing, the daemon is down, or a Compose command failed.""" + + def __init__(self, code: str, message: str, details: dict | None = None): + super().__init__(message) + self.code = code + self.message = message + self.details = details or {} + + def exit(self) -> None: + print_error(self.code, self.message, self.details or None) + raise SystemExit(1) + + +def port_available(port: int, host: str = "127.0.0.1") -> bool: + """True when nothing is accepting connections on ``host:port``.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(0.2) + return sock.connect_ex((host, port)) != 0 + + +def allocate_host_ports( + profile: LocalProfile, + *, + pinned: frozenset[str] = frozenset(), +) -> tuple[LocalProfile, dict[str, tuple[int, int]]]: + """Move api/db/redis host ports that are already bound. + + Names in ``pinned`` (``api`` / ``database`` / ``redis``) were set by a + flag and fail instead of moving. + """ + taken: set[int] = set() + remapped: dict[str, tuple[int, int]] = {} + chosen: dict[str, int] = {} + for name, field, flag in ( + ("api", "api_port", "--api-port"), + ("database", "db_port", "--db-port"), + ("redis", "redis_port", "--redis-port"), + ): + preferred = getattr(profile, field) + port = preferred + if name in pinned: + if preferred in taken or not port_available(preferred): + raise DockerError( + "PORT_IN_USE", + f"Host port {preferred} for {name} is already in use. " + f"Pass {flag} with a free port, or stop the other process.", + {"port": preferred, "service": name, "flag": flag}, + ) + else: + while port in taken or not port_available(port): + port += 1 + if port > preferred + 100: + raise DockerError( + "PORT_IN_USE", + f"Could not find a free host port near {preferred}.", + {"preferred": preferred}, + ) + if port != preferred: + remapped[name] = (preferred, port) + taken.add(port) + chosen[field] = port + return profile.overlay(**chosen), remapped + + +def compose_argv(profile: LocalProfile) -> list[str]: + return [ + "docker", + "compose", + "-f", + str(profile.compose_file()), + "--project-directory", + str(profile.dir()), + "-p", + profile.project_name, + ] + + +_CONFIG_PATHS = ("/app/config.toml.example", "/app/config.toml") +_CONFIG_HEADER = ( + "# Copied from {image} by honcho start. This file is not overwritten on later starts.\n" + "# Secrets belong in .env (environment variables win over this file).\n\n" +) + + +def image_is_digest(ref: str) -> bool: + """True when ``ref`` is already pinned to a content digest.""" + return "@sha256:" in ref.lower() + + +def image_repository(ref: str) -> str: + """Strip a tag or digest from a Docker image reference.""" + if "@" in ref: + return ref.split("@", 1)[0] + last_slash = ref.rfind("/") + last_colon = ref.rfind(":") + if last_colon > last_slash: + return ref[:last_colon] + return ref + + +def pin_image(image: str) -> str: + """Pull ``image`` if needed and return a digest-pinned reference. + + ``ghcr.io/plastic-labs/honcho:latest`` becomes + ``ghcr.io/plastic-labs/honcho@sha256:...`` so the profile does not + float when ``:latest`` moves. Already-pinned refs are left alone. + """ + if image_is_digest(image): + if not _image_exists(image): + _pull(image) + return image + _pull(image) + digest = _repo_digest(image) + if not digest: + raise DockerError( + "IMAGE_PIN_FAILED", + f"Pulled {image} but could not resolve a registry digest to pin.", + {"image": image}, + ) + return digest + + +def seed_config_toml(profile: LocalProfile) -> bool: + """Copy the image's ``config.toml.example`` into the profile if missing. + + Returns True when a file was written. Never overwrites an existing + ``config.toml``. + """ + dest = profile.config_file() + dest.parent.mkdir(parents=True, exist_ok=True) + if dest.exists(): + return False + copied = _copy_from_image(profile.image, _CONFIG_PATHS) + if copied is None: + raise DockerError( + "CONFIG_MISSING", + f"Could not copy config.toml from {profile.image}.", + {"image": profile.image}, + ) + dest.write_text(_CONFIG_HEADER.format(image=profile.image) + copied) + return True + + +def compose_up( + profile: LocalProfile, + *, + recreate: tuple[str, ...] = (), +) -> None: + """``docker compose up -d``. Compose output goes to stderr. + + ``recreate`` names services to ``--force-recreate`` (used after ``--setup`` + on an already-running stack so new ``.env`` values take effect). + """ + args = ["up", "-d"] + if recreate: + args.extend(["--force-recreate", *recreate]) + _run_compose(profile, args) + + +def compose_down(profile: LocalProfile, *, wipe: bool = False) -> None: + args = ["down"] + if wipe: + args.append("-v") + _run_compose(profile, args, capture=False) + + +def compose_ps(profile: LocalProfile) -> list[dict]: + """Parsed ``docker compose ps --format json`` (array or NDJSON).""" + proc = _run_compose(profile, ["ps", "--format", "json"], capture=True, check=False) + if proc.returncode != 0: + return [] + return _parse_ps(proc.stdout or "") + + +def services_running(ps: list[dict]) -> dict[str, str]: + """Map service name → state for the four stack services. + + State is ``running``, ``healthy``, ``exited``, etc. Prefer Docker's + Health field when present. + """ + out: dict[str, str] = {} + for row in ps: + service = str(row.get("Service") or row.get("Name") or "") + # "honcho-local-api-1" → try Service first; fall back to suffix match + if service not in STACK_SERVICES: + for name in STACK_SERVICES: + if ( + service == name + or service.endswith(f"-{name}-1") + or f"_{name}_" in service + ): + service = name + break + else: + continue + health = str(row.get("Health") or "").lower() + state = str(row.get("State") or row.get("Status") or "").lower() + if health: + out[service] = health + elif "health" in state: + # e.g. "running (healthy)" + out[service] = state + else: + out[service] = state or "unknown" + return out + + +def stack_containers_up(ps: list[dict]) -> bool: + """True when all four services are running (deriver has no healthcheck).""" + states = services_running(ps) + if any(name not in states for name in STACK_SERVICES): + return False + for state in states.values(): + if "exit" in state or state in {"dead", "paused"}: + return False + if "running" not in state and "healthy" not in state: + return False + return True + + +def _unavailable(proc: subprocess.CompletedProcess[str]) -> DockerError | None: + """Map a failed docker/compose process to a user-facing error, if obvious.""" + text = f"{proc.stderr or ''}{proc.stdout or ''}" + lower = text.lower() + if any(marker in lower for marker in _DAEMON_DOWN_MARKERS): + return DockerError( + "DOCKER_NOT_RUNNING", + "Docker is installed but the daemon is not running. Start it and retry.", + ) + if any(marker in lower for marker in _COMPOSE_MISSING_MARKERS): + return DockerError( + "DOCKER_COMPOSE_MISSING", + "Honcho start requires Docker Compose v2 (the `docker compose` plugin).", + ) + if any(marker in text for marker in _CRED_HELPER_MARKERS): + return DockerError( + "DOCKER_CREDENTIALS", + "Docker could not read registry credentials " + "(docker-credential-desktop is not on PATH). " + "Quit and reopen your terminal, or add Docker Desktop's bin " + "directory to PATH, then retry.", + {"exit_code": proc.returncode}, + ) + return None + + +def _run_compose( + profile: LocalProfile, + args: list[str], + *, + capture: bool = False, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + cmd = compose_argv(profile) + args + cwd: Path = profile.dir() + try: + proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + except FileNotFoundError as e: + raise DockerError( + "DOCKER_NOT_INSTALLED", + "Docker is not installed. Install Docker Desktop (or another Compose-v2 runtime) and retry.", + ) from e + except OSError as e: + raise DockerError("COMPOSE_FAILED", str(e), {"command": cmd}) from e + if not capture: + if proc.stdout: + sys.stderr.write(proc.stdout) + if proc.stderr: + sys.stderr.write(proc.stderr) + if proc.returncode != 0: + classified = _unavailable(proc) + if classified is not None: + raise classified + if check and proc.returncode != 0: + raise DockerError( + "COMPOSE_FAILED", + "docker compose failed. See output above, or run `docker compose -p " + f"{profile.project_name} logs`.", + {"project": profile.project_name, "exit_code": proc.returncode}, + ) + return proc + + +def _run_docker( + args: list[str], + *, + check: bool = False, +) -> subprocess.CompletedProcess[str]: + try: + proc = subprocess.run( + ["docker", *args], + capture_output=True, + text=True, + ) + except FileNotFoundError as e: + raise DockerError( + "DOCKER_NOT_INSTALLED", + "Docker is not installed. Install Docker Desktop (or another Compose-v2 runtime) and retry.", + ) from e + except OSError as e: + raise DockerError("DOCKER_FAILED", str(e), {"command": args}) from e + if proc.returncode == 0: + return proc + classified = _unavailable(proc) + if classified is not None: + raise classified + if check: + raise DockerError( + "DOCKER_FAILED", + f"docker {' '.join(args)} failed.", + { + "exit_code": proc.returncode, + "stderr": (proc.stderr or "")[-500:], + }, + ) + return proc + + +def _pull(image: str) -> None: + proc = _run_docker(["pull", image], check=False) + if proc.stdout: + sys.stderr.write(proc.stdout) + if proc.stderr: + sys.stderr.write(proc.stderr) + if proc.returncode != 0: + raise DockerError( + "IMAGE_PULL_FAILED", + f"Failed to pull {image}.", + {"image": image, "exit_code": proc.returncode}, + ) + + +def _image_exists(image: str) -> bool: + return _run_docker(["image", "inspect", image], check=False).returncode == 0 + + +def _repo_digest(image: str) -> str | None: + proc = _run_docker( + ["image", "inspect", "--format", "{{json .RepoDigests}}", image], + check=False, + ) + if proc.returncode != 0: + return None + try: + digests = json.loads((proc.stdout or "").strip() or "[]") + except json.JSONDecodeError: + return None + if not isinstance(digests, list): + return None + repo = image_repository(image) + for item in digests: + if isinstance(item, str) and item.startswith(repo + "@"): + return item + for item in digests: + if isinstance(item, str) and "@sha256:" in item: + return item + return None + + +def _copy_from_image(image: str, paths: tuple[str, ...]) -> str | None: + """Create a stopped container and copy the first path that exists.""" + name = f"honcho-seed-{os.getpid()}-{time.time_ns()}" + created = _run_docker(["create", "--name", name, image], check=False) + if created.returncode != 0: + cid = (created.stdout or "").strip() or name + _run_docker(["rm", "-f", cid], check=False) + return None + cid = (created.stdout or "").strip() or name + try: + with tempfile.TemporaryDirectory(prefix="honcho-cfg-") as tmp: + dest = Path(tmp) / "config.toml" + for path in paths: + if dest.exists(): + dest.unlink() + copied = _run_docker(["cp", f"{cid}:{path}", str(dest)], check=False) + if copied.returncode == 0 and dest.exists(): + return dest.read_text(encoding="utf-8") + finally: + _run_docker(["rm", "-f", cid], check=False) + return None + + +def _parse_ps(stdout: str) -> list[dict]: + text = stdout.strip() + if not text: + return [] + if text.startswith("["): + try: + data = json.loads(text) + except json.JSONDecodeError: + return [] + return data if isinstance(data, list) else [] + rows: list[dict] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(row, dict): + rows.append(row) + return rows diff --git a/honcho-cli/src/honcho_cli/local/env.py b/honcho-cli/src/honcho_cli/local/env.py new file mode 100644 index 00000000..ecf9c2c1 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/env.py @@ -0,0 +1,183 @@ +"""Render Compose + ``.env`` for a local stack profile.""" + +from __future__ import annotations + +import os +from contextlib import suppress +from importlib.resources import files +from pathlib import Path + +from honcho_cli.local.profile import LocalProfile + +# Keys honcho start owns. Unknown lines in an existing .env are preserved. +MANAGED_KEYS = ( + "AUTH_USE_AUTH", + "LOG_LEVEL", + "HONCHO_IMAGE", + "API_PORT", + "DB_PORT", + "REDIS_PORT", +) + +# Host env forwarded into the profile .env (overrides config.toml). +_SETTINGS_PREFIXES = ( + "LLM_", + "EMBEDDING_", + "DERIVER_", + "DIALECTIC_", + "DREAM_", + "SUMMARY_", +) +_LLM_KEYS = ( + "LLM_OPENAI_API_KEY", + "LLM_ANTHROPIC_API_KEY", + "LLM_GEMINI_API_KEY", +) + +_HEADER = ( + "# Generated by honcho start. Extra keys below the managed block are preserved." +) + +_PLACEHOLDERS = frozenset( + { + "", + "your-api-key-here", + "changeme", + "sk-...", + } +) + + +def is_placeholder_key(value: str | None) -> bool: + """True when ``value`` is missing or a known template placeholder.""" + if value is None: + return True + return value.strip() in _PLACEHOLDERS + + +def settings_from_environ() -> dict[str, str]: + """Host env vars that map to Honcho settings. Empty/placeholder values skipped.""" + return { + k: v + for k, v in os.environ.items() + if k.startswith(_SETTINGS_PREFIXES) and not is_placeholder_key(v) + } + + +def read_env_file(path: Path) -> dict[str, str]: + """Parse a dotenv file into a dict. Last assignment of a key wins.""" + if not path.exists(): + return {} + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return {} + out: dict[str, str] = {} + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + k, _, v = stripped.partition("=") + out[k.strip()] = _unquote(v.strip()) + return out + + +def read_env_value(path: Path, key: str) -> str | None: + """Return the raw value for ``key`` in a dotenv file, or None.""" + return read_env_file(path).get(key) + + +def has_provider_key(profile: LocalProfile, extra: dict[str, str]) -> bool: + """True when host extra or profile ``.env`` has a real LLM API key.""" + stored = {**read_env_file(profile.env_file()), **extra} + return any(not is_placeholder_key(stored.get(k)) for k in _LLM_KEYS) + + +def managed_env(profile: LocalProfile) -> dict[str, str]: + """Values written into the managed block of ``.env``.""" + return { + "AUTH_USE_AUTH": "false", + "LOG_LEVEL": "INFO", + "HONCHO_IMAGE": profile.image, + "API_PORT": str(profile.api_port), + "DB_PORT": str(profile.db_port), + "REDIS_PORT": str(profile.redis_port), + } + + +def upsert_env( + path: Path, + updates: dict[str, str], + *, + drop: tuple[str, ...] = (), +) -> None: + """Write ``updates``, preserving unrelated user lines. + + Managed keys are written first (stable order), then any other keys in + ``updates``. Keys in ``drop`` are removed and not rewritten. Drops a + previous generated header so it is not duplicated. + """ + drop_set = frozenset(drop) + extras: list[str] = [] + if path.exists(): + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if stripped == _HEADER or stripped.startswith( + "# Generated by honcho start" + ): + continue + if not stripped or stripped.startswith("#"): + extras.append(line) + continue + if "=" in stripped: + k, _, _ = stripped.partition("=") + name = k.strip() + if name in updates or name in MANAGED_KEYS or name in drop_set: + continue + extras.append(line) + + managed = [f"{k}={updates[k]}" for k in MANAGED_KEYS if k in updates] + extra_updates = [ + f"{k}={v}" for k, v in updates.items() if k not in MANAGED_KEYS + ] + body = [_HEADER, *managed] + if extra_updates: + if not body[-1].startswith("#"): + body.append("") + body.extend(extra_updates) + if extras: + # Keep a blank line between generated and user keys when there are extras. + if extras[0].strip(): + body.append("") + body.extend(extras) + path.write_text("\n".join(body) + "\n") + with suppress(OSError): + os.chmod(path, 0o600) + + +def render_stack( + profile: LocalProfile, + extra: dict[str, str] | None = None, + drop: tuple[str, ...] = (), +) -> None: + """Write compose, init.sql, and .env into the profile directory.""" + directory = profile.dir() + directory.mkdir(parents=True, exist_ok=True) + with suppress(OSError): + os.chmod(directory, 0o700) + + templates = files("honcho_cli.local.templates") + compose = templates.joinpath("docker-compose.yml").read_text(encoding="utf-8") + init_sql = templates.joinpath("init.sql").read_text(encoding="utf-8") + profile.compose_file().write_text(compose) + (directory / "init.sql").write_text(init_sql) + updates = managed_env(profile) + if extra: + updates.update(extra) + upsert_env(profile.env_file(), updates, drop=drop) + + +def _unquote(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + return value[1:-1] + return value diff --git a/honcho-cli/src/honcho_cli/local/health.py b/honcho-cli/src/honcho_cli/local/health.py new file mode 100644 index 00000000..05593b99 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/health.py @@ -0,0 +1,53 @@ +"""Poll the local API health endpoint.""" + +from __future__ import annotations + +import time + +import httpx + +from honcho_cli.local.docker import compose_ps, services_running, stack_containers_up +from honcho_cli.local.profile import LocalProfile + + +def api_healthy(base_url: str, *, timeout: float = 2.0) -> bool: + """True when ``GET /health`` returns HTTP 200.""" + try: + with httpx.Client(timeout=timeout) as client: + response = client.get(base_url.rstrip("/") + "/health") + return response.status_code == 200 + except httpx.HTTPError: + return False + + +def stack_healthy(profile: LocalProfile) -> bool: + """True when Compose services are up and the API answers /health.""" + if not profile.compose_file().exists(): + return False + ps = compose_ps(profile) + if not stack_containers_up(ps): + return False + return api_healthy(profile.base_url) + + +def wait_for_health( + profile: LocalProfile, + *, + timeout: float, + interval: float = 1.0, +) -> bool: + """Poll until the API is healthy or ``timeout`` seconds elapse. + + Returns False on timeout. Fails fast if a required container has exited. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + ps = compose_ps(profile) + states = services_running(ps) + for _name, state in states.items(): + if "exit" in state or state in {"dead"}: + return False + if api_healthy(profile.base_url) and stack_containers_up(ps): + return True + time.sleep(interval) + return api_healthy(profile.base_url) diff --git a/honcho-cli/src/honcho_cli/local/profile.py b/honcho-cli/src/honcho_cli/local/profile.py new file mode 100644 index 00000000..69565071 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/profile.py @@ -0,0 +1,157 @@ +"""Named local-stack profiles under ``$HONCHO_CONFIG_DIR/profiles``. + +A profile is a Compose project directory, not an auth identity. +Resolution: ``--profile`` > ``HONCHO_PROFILE`` > ``local``. +""" + +from __future__ import annotations + +import json +import os +import re +from contextlib import suppress +from dataclasses import dataclass, replace + +from honcho_cli.local import ( + DEFAULT_API_PORT, + DEFAULT_DB_PORT, + DEFAULT_IMAGE, + DEFAULT_PROFILE, + DEFAULT_REDIS_PORT, +) +from honcho_cli.output import print_error + +_PROFILE_NAME = re.compile(r"^[a-z][a-z0-9_-]{0,62}$") + + +def profiles_dir(): + from honcho_cli import config as cfg + + return cfg.CONFIG_DIR / "profiles" + + +def validate_profile_name(name: str) -> str: + if name and _PROFILE_NAME.match(name): + return name + print_error( + "INVALID_PROFILE", + "Profile name must be lowercase alphanumeric, starting with a letter " + "(hyphens and underscores allowed).", + {"profile": name}, + ) + raise SystemExit(1) + + +def resolve_profile_name(flag: str | None) -> str: + raw = ( + (flag or "").strip() + or (os.environ.get("HONCHO_PROFILE") or "").strip() + or DEFAULT_PROFILE + ) + return validate_profile_name(raw) + + +def list_profile_names() -> list[str]: + """Profile directories that already have a Compose file.""" + root = profiles_dir() + if not root.is_dir(): + return [] + names: list[str] = [] + for path in sorted(root.iterdir()): + if ( + path.is_dir() + and _PROFILE_NAME.match(path.name) + and (path / "docker-compose.yml").exists() + ): + names.append(path.name) + return names + + +@dataclass +class LocalProfile: + """Ports and image for one local stack.""" + + name: str + api_port: int = DEFAULT_API_PORT + db_port: int = DEFAULT_DB_PORT + redis_port: int = DEFAULT_REDIS_PORT + image: str = DEFAULT_IMAGE + + @property + def project_name(self) -> str: + return f"honcho-{self.name}" + + @property + def base_url(self) -> str: + return f"http://127.0.0.1:{self.api_port}" + + def dir(self): + return profiles_dir() / self.name + + def compose_file(self): + return self.dir() / "docker-compose.yml" + + def env_file(self): + return self.dir() / ".env" + + def profile_file(self): + return self.dir() / "profile.json" + + def config_file(self): + return self.dir() / "config.toml" + + def endpoints(self) -> dict[str, str]: + return { + "api": self.base_url, + "docs": f"{self.base_url}/docs", + "postgres": f"postgresql://postgres:postgres@127.0.0.1:{self.db_port}/postgres", + "redis": f"redis://127.0.0.1:{self.redis_port}/0", + } + + def overlay(self, **fields) -> LocalProfile: + return replace(self, **{k: v for k, v in fields.items() if v is not None}) + + +def load_profile(name: str) -> LocalProfile: + profile = LocalProfile(name=validate_profile_name(name)) + path = profile.profile_file() + if not path.exists(): + return profile + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return profile + if not isinstance(data, dict): + return profile + image = data.get("image") + return replace( + profile, + api_port=_port(data.get("apiPort"), profile.api_port), + db_port=_port(data.get("dbPort"), profile.db_port), + redis_port=_port(data.get("redisPort"), profile.redis_port), + image=image if isinstance(image, str) and image else profile.image, + ) + + +def save_profile(profile: LocalProfile) -> None: + directory = profile.dir() + directory.mkdir(parents=True, exist_ok=True) + with suppress(OSError): + os.chmod(directory, 0o700) + payload = { + "apiPort": profile.api_port, + "dbPort": profile.db_port, + "redisPort": profile.redis_port, + "image": profile.image, + } + profile.profile_file().write_text(json.dumps(payload, indent=2) + "\n") + + +def _port(value: object, default: int) -> int: + if isinstance(value, bool): + return default + try: + parsed = int(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return default + return parsed if 1 <= parsed <= 65535 else default diff --git a/honcho-cli/src/honcho_cli/local/setup.py b/honcho-cli/src/honcho_cli/local/setup.py new file mode 100644 index 00000000..5b21728e --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/setup.py @@ -0,0 +1,469 @@ +"""Interactive ``honcho start --setup`` wizard. + +Writes curated LLM/feature overrides for the local stack. Secrets and knobs +go to the profile ``.env`` (env wins over ``config.toml``). Prompts are TTY +only — the start command rejects ``--setup`` in JSON / non-TTY mode. +""" + +from __future__ import annotations + +import tomllib +from dataclasses import dataclass +from pathlib import Path + +import typer +from rich.console import Console + +from honcho_cli.local.env import ( + is_placeholder_key, + read_env_file, + settings_from_environ, +) +from honcho_cli.output import print_error + +SETUP_MODES = ("basic", "advanced") +DIALECTIC_LEVELS = ("minimal", "low", "medium", "high", "max") +PROVIDERS = ("openai", "anthropic", "gemini", "openai-compatible") +EMBEDDING_TRANSPORTS = ("openai", "gemini") + +_CHAT_PREFIXES = ( + "DERIVER_MODEL_CONFIG", + "SUMMARY_MODEL_CONFIG", + "DREAM_DEDUCTION_MODEL_CONFIG", + "DREAM_INDUCTION_MODEL_CONFIG", + *(f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG" for level in DIALECTIC_LEVELS), +) + +_PROVIDER_KEY_ENV = { + "openai": "LLM_OPENAI_API_KEY", + "openai-compatible": "LLM_OPENAI_API_KEY", + "anthropic": "LLM_ANTHROPIC_API_KEY", + "gemini": "LLM_GEMINI_API_KEY", +} + +_console = Console(stderr=True) + + +@dataclass(frozen=True) +class TomlSetupDefaults: + """Model/feature defaults copied from the image ``config.toml``. + + Honcho only ships OpenAI chat/embedding defaults. Other providers have + no suggested model in that file — the wizard does not invent one. + """ + + chat_transport: str | None = None + chat_model: str | None = None + embed_transport: str | None = None + embed_model: str | None = None + embed_dims: int | None = None + dreams_enabled: bool | None = None + flush_enabled: bool | None = None + + +def load_toml_setup_defaults(path: Path | None) -> TomlSetupDefaults: + """Read prompt defaults from the profile ``config.toml`` (image-aligned).""" + if path is None or not path.is_file(): + return TomlSetupDefaults() + try: + with path.open("rb") as fh: + data = tomllib.load(fh) + deriver = data.get("deriver") or {} + chat = deriver.get("model_config") or {} + embedding = data.get("embedding") or {} + embed = embedding.get("model_config") or {} + dream = data.get("dream") or {} + dims = embedding.get("VECTOR_DIMENSIONS") + return TomlSetupDefaults( + chat_transport=chat.get("transport"), + chat_model=chat.get("model"), + embed_transport=embed.get("transport"), + embed_model=embed.get("model"), + embed_dims=dims if isinstance(dims, int) and dims > 0 else None, + dreams_enabled=dream.get("ENABLED"), + flush_enabled=deriver.get("FLUSH_ENABLED"), + ) + except (OSError, tomllib.TOMLDecodeError, TypeError, AttributeError): + return TomlSetupDefaults() + + +def chat_model_default( + provider: str, + env: dict[str, str], + toml: TomlSetupDefaults, + *, + inferred: str | None = None, +) -> str: + """Prefer a previous wizard choice, else the image toml when transports match.""" + if inferred is None: + inferred = infer_provider(env) + if inferred == provider: + current = env.get("DERIVER_MODEL_CONFIG__MODEL") + if current: + return current + if toml.chat_model and _provider_matches_transport(provider, toml.chat_transport): + return toml.chat_model + return "" + + +def _provider_matches_transport(provider: str, transport: str | None) -> bool: + if not transport: + return False + return transport_of(provider) == transport + + +@dataclass(frozen=True) +class SetupAnswers: + """Curated knobs collected by the wizard (or tests).""" + + mode: str + provider: str + api_key: str + chat_model: str + base_url: str | None = None + embedding_api_key: str | None = None + embedding_key_transport: str | None = None + embedding_transport: str | None = None + embedding_model: str | None = None + embedding_dimensions: int | None = None + deriver_model: str | None = None + dialectic_model: str | None = None + dreams_enabled: bool | None = None + flush_enabled: bool | None = None + + +def transport_of(provider: str) -> str: + """Honcho ``MODEL_CONFIG.transport`` for a wizard provider id.""" + return "openai" if provider == "openai-compatible" else provider + + +def answers_to_env(answers: SetupAnswers) -> dict[str, str]: + """Map wizard answers to Honcho env overrides.""" + transport = transport_of(answers.provider) + env: dict[str, str] = {} + + env[_PROVIDER_KEY_ENV[answers.provider]] = answers.api_key + if answers.base_url: + env["LLM_OPENAI_BASE_URL"] = answers.base_url + + if answers.embedding_api_key and answers.embedding_key_transport: + embed_key = ( + "LLM_OPENAI_API_KEY" + if answers.embedding_key_transport == "openai" + else "LLM_GEMINI_API_KEY" + ) + env[embed_key] = answers.embedding_api_key + + for prefix in _CHAT_PREFIXES: + env[f"{prefix}__TRANSPORT"] = transport + env[f"{prefix}__MODEL"] = answers.chat_model + + if answers.deriver_model: + env["DERIVER_MODEL_CONFIG__TRANSPORT"] = transport + env["DERIVER_MODEL_CONFIG__MODEL"] = answers.deriver_model + + if answers.dialectic_model: + for level in DIALECTIC_LEVELS: + env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__TRANSPORT"] = transport + env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__MODEL"] = ( + answers.dialectic_model + ) + + if answers.embedding_transport: + env["EMBEDDING_MODEL_CONFIG__TRANSPORT"] = answers.embedding_transport + if answers.embedding_model: + env["EMBEDDING_MODEL_CONFIG__MODEL"] = answers.embedding_model + if answers.embedding_dimensions is not None: + env["EMBEDDING_VECTOR_DIMENSIONS"] = str(answers.embedding_dimensions) + elif answers.embedding_key_transport == "gemini": + # Basic + Anthropic chat: a Gemini key is unused unless embeddings switch. + env["EMBEDDING_MODEL_CONFIG__TRANSPORT"] = "gemini" + + if answers.dreams_enabled is not None: + env["DREAM_ENABLED"] = "true" if answers.dreams_enabled else "false" + if answers.flush_enabled is not None: + env["DERIVER_FLUSH_ENABLED"] = "true" if answers.flush_enabled else "false" + return env + + +def answers_drop_keys(answers: SetupAnswers) -> tuple[str, ...]: + """Keys to remove so a previous wizard run cannot leak into this one.""" + if answers.provider == "openai-compatible": + return () + return ("LLM_OPENAI_BASE_URL",) + + +def run_setup( + mode: str, + env_path: Path, + *, + config_path: Path | None = None, +) -> SetupAnswers: + """Prompt for ``basic`` or ``advanced`` knobs. Enter keeps the default.""" + env = read_env_file(env_path) + env.update(settings_from_environ()) + defaults = load_toml_setup_defaults(config_path) + _console.print() + _console.print( + " [dim]Configure the local stack. Press Enter to keep the default.[/dim]" + ) + _console.print( + " [dim]These values go in .env (they override config.toml).[/dim]" + ) + _console.print() + + inferred = infer_provider(env) + provider = _choose( + "LLM provider", + [ + ("openai", "OpenAI"), + ("anthropic", "Anthropic"), + ("gemini", "Gemini"), + ("openai-compatible", "OpenAI-compatible (OpenRouter, vLLM, Ollama, …)"), + ], + inferred if inferred in PROVIDERS else "openai", + ) + + base_url: str | None = None + if provider == "openai-compatible": + base_url = _prompt_text( + "OpenAI-compatible base URL", + env.get("LLM_OPENAI_BASE_URL") or "https://openrouter.ai/api/v1", + ) + + key_env = _PROVIDER_KEY_ENV[provider] + api_key = _prompt_secret("API key", env.get(key_env)) + + chat_default = chat_model_default( + provider, env, defaults, inferred=inferred + ) + chat_model = _prompt_text( + "Chat model (deriver, dialectic, summary, dream)", + chat_default, + required=True, + ) + + embedding_api_key: str | None = None + embedding_key_transport: str | None = None + embedding_transport: str | None = None + embedding_model: str | None = None + embedding_dimensions: int | None = None + deriver_model: str | None = None + dialectic_model: str | None = None + dreams_enabled: bool | None = None + flush_enabled: bool | None = None + + if mode == "advanced": + embedding_transport = _choose( + "Embedding provider", + [("openai", "OpenAI"), ("gemini", "Gemini")], + _default_embedding_transport(provider, env, defaults), + ) + same_embed = env.get("EMBEDDING_MODEL_CONFIG__TRANSPORT") == embedding_transport + current_embed = env.get("EMBEDDING_MODEL_CONFIG__MODEL") if same_embed else None + embed_from_toml = ( + defaults.embed_model + if defaults.embed_transport == embedding_transport + else None + ) + embedding_model = ( + _prompt_text("Embedding model", current_embed or embed_from_toml or "") + or None + ) + dim_default = ( + int(env["EMBEDDING_VECTOR_DIMENSIONS"]) + if env.get("EMBEDDING_VECTOR_DIMENSIONS", "").isdigit() + else (defaults.embed_dims or 1536) + ) + embedding_dimensions = _prompt_int("Embedding dimensions", dim_default) + embedding_key_transport, embedding_api_key = _embedding_key_if_needed( + provider, embedding_transport, env + ) + deriver_model = _prompt_text("Deriver model", chat_model) + dialectic_model = _prompt_text("Dialectic model (all reasoning levels)", chat_model) + dreams_enabled = _choose_bool( + "Dreams (periodic deeper reasoning)", + _env_bool( + env.get("DREAM_ENABLED"), + default=True if defaults.dreams_enabled is None else defaults.dreams_enabled, + ), + ) + flush_enabled = _choose_bool( + "Snappy local deriver (flush work immediately, skip batching)", + _env_bool( + env.get("DERIVER_FLUSH_ENABLED"), + default=False if defaults.flush_enabled is None else defaults.flush_enabled, + ), + ) + elif provider == "anthropic": + embedding_key_transport = _choose( + "Embeddings (Anthropic has none — pick a provider)", + [("openai", "OpenAI"), ("gemini", "Gemini")], + "openai", + ) + embed_key_env = _PROVIDER_KEY_ENV[ + "openai" if embedding_key_transport == "openai" else "gemini" + ] + embedding_api_key = _prompt_secret("Embedding API key", env.get(embed_key_env)) + + _console.print() + return SetupAnswers( + mode=mode, + provider=provider, + api_key=api_key, + chat_model=chat_model, + base_url=base_url, + embedding_api_key=embedding_api_key, + embedding_key_transport=embedding_key_transport, + embedding_transport=embedding_transport, + embedding_model=embedding_model, + embedding_dimensions=embedding_dimensions, + deriver_model=deriver_model, + dialectic_model=dialectic_model, + dreams_enabled=dreams_enabled, + flush_enabled=flush_enabled, + ) + + +def infer_provider(env: dict[str, str]) -> str: + """Best-effort provider from an existing profile ``.env``.""" + if env.get("LLM_OPENAI_BASE_URL"): + return "openai-compatible" + transport = env.get("DERIVER_MODEL_CONFIG__TRANSPORT") + if transport in ("anthropic", "gemini", "openai"): + return transport + if env.get("LLM_ANTHROPIC_API_KEY") and not env.get("LLM_OPENAI_API_KEY"): + return "anthropic" + if env.get("LLM_GEMINI_API_KEY") and not env.get("LLM_OPENAI_API_KEY"): + return "gemini" + return "openai" + + +def _default_embedding_transport( + provider: str, env: dict[str, str], defaults: TomlSetupDefaults +) -> str: + current = env.get("EMBEDDING_MODEL_CONFIG__TRANSPORT") + if current in EMBEDDING_TRANSPORTS: + return current + if defaults.embed_transport in EMBEDDING_TRANSPORTS: + return defaults.embed_transport + if provider == "gemini": + return "gemini" + return "openai" + + +def _embedding_key_if_needed( + chat_provider: str, + embed_transport: str, + env: dict[str, str], +) -> tuple[str | None, str | None]: + """Prompt for an embedding key when the chat provider cannot supply it.""" + chat_transport = transport_of(chat_provider) + if embed_transport == chat_transport or ( + chat_provider == "openai-compatible" and embed_transport == "openai" + ): + return None, None + key_env = _PROVIDER_KEY_ENV[embed_transport] + key = _prompt_secret(f"{embed_transport} embedding API key", env.get(key_env)) + return embed_transport, key + + +def _choose(label: str, options: list[tuple[str, str]], default: str) -> str: + ids = [item[0] for item in options] + default_idx = ids.index(default) + 1 if default in ids else 1 + _console.print(f" [dim]{label}[/dim]") + for i, (_oid, desc) in enumerate(options, 1): + _console.print(f" [dim]({i})[/dim] {desc}") + raw = typer.prompt( + " Choice", + default=str(default_idx), + show_default=True, + prompt_suffix=": ", + ).strip() + try: + idx = int(raw) + except ValueError: + if raw in ids: + return raw + return options[default_idx - 1][0] + if 1 <= idx <= len(options): + return options[idx - 1][0] + return options[default_idx - 1][0] + + +def _choose_bool(label: str, default: bool) -> bool: + return ( + _choose(label, [("true", "On"), ("false", "Off")], "true" if default else "false") + == "true" + ) + + +def _prompt_text(label: str, default: str, *, required: bool = False) -> str: + while True: + raw = typer.prompt( + f" {label}", + default=default, + show_default=bool(default), + prompt_suffix=": ", + ).strip() + value = raw or default + if value or not required: + return value + _console.print(" [red]A model name is required[/red]") + + +def _prompt_int(label: str, default: int) -> int: + while True: + raw = typer.prompt( + f" {label}", + default=str(default), + show_default=True, + prompt_suffix=": ", + ).strip() + try: + value = int(raw) + except ValueError: + _console.print(" [red]Enter an integer[/red]") + continue + if value > 0: + return value + _console.print(" [red]Must be a positive integer[/red]") + + +def _prompt_secret(label: str, current: str | None) -> str: + if current and not is_placeholder_key(current): + _console.print(f" [dim]Current {label}: {_redact(current)}[/dim]") + _console.print(" [dim](1)[/dim] Keep current key") + _console.print(" [dim](2)[/dim] Enter a new key") + choice = typer.prompt( + " Choice", default="1", show_default=True, prompt_suffix=": " + ).strip() + if choice != "2": + return current + _console.print(f" [dim]{label}[/dim]") + raw = typer.prompt( + f" {label}", + default="", + show_default=False, + hide_input=True, + prompt_suffix=": ", + ).strip() + if not raw or is_placeholder_key(raw): + print_error( + "MISSING_LLM_KEY", + f"{label} is required.", + ) + raise typer.Exit(1) + return raw + + +def _redact(key: str) -> str: + if len(key) <= 4: + return "***" + return "***" + key[-4:] + + +def _env_bool(value: str | None, *, default: bool) -> bool: + if value is None: + return default + return value.strip().lower() in ("1", "true", "yes", "on") diff --git a/honcho-cli/src/honcho_cli/local/templates/__init__.py b/honcho-cli/src/honcho_cli/local/templates/__init__.py new file mode 100644 index 00000000..e7802aa6 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/templates/__init__.py @@ -0,0 +1 @@ +"""Package data for the local stack (Compose template + Postgres init).""" diff --git a/honcho-cli/src/honcho_cli/local/templates/docker-compose.yml b/honcho-cli/src/honcho_cli/local/templates/docker-compose.yml new file mode 100644 index 00000000..4a19af67 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/templates/docker-compose.yml @@ -0,0 +1,100 @@ +# Managed by `honcho start`. Re-rendered on every start — edit .env and config.toml, not this file. +# +# Images: ghcr.io/plastic-labs/honcho (API + deriver), pgvector/pgvector:pg15, redis:8.2 +# Ports bind to 127.0.0.1. Auth is off (AUTH_USE_AUTH=false in .env). + +services: + api: + image: ${HONCHO_IMAGE:-ghcr.io/plastic-labs/honcho:latest} + entrypoint: ["sh", "docker/entrypoint.sh"] + depends_on: + database: + condition: service_healthy + redis: + condition: service_healthy + ports: + - "127.0.0.1:${API_PORT:-8000}:8000" + healthcheck: + test: + [ + "CMD", + "/app/.venv/bin/python", + "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2).read()", + ] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s + volumes: + - lancedb-data:/app/lancedb_data + - ./config.toml:/app/config.toml:ro + environment: + - DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres + - CACHE_URL=redis://redis:6379/0?suppress=true + - CACHE_ENABLED=true + env_file: + - path: .env + required: false + restart: unless-stopped + + deriver: + image: ${HONCHO_IMAGE:-ghcr.io/plastic-labs/honcho:latest} + entrypoint: ["/app/.venv/bin/python", "-m", "src.deriver"] + depends_on: + api: + condition: service_healthy + database: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - lancedb-data:/app/lancedb_data + - ./config.toml:/app/config.toml:ro + environment: + - DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres + - CACHE_URL=redis://redis:6379/0?suppress=true + - CACHE_ENABLED=true + env_file: + - path: .env + required: false + restart: unless-stopped + + database: + image: pgvector/pgvector:pg15 + restart: unless-stopped + ports: + - "127.0.0.1:${DB_PORT:-5432}:5432" + command: ["postgres", "-c", "max_connections=200"] + environment: + - POSTGRES_DB=postgres + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_HOST_AUTH_METHOD=trust + - PGDATA=/var/lib/postgresql/data/pgdata + volumes: + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + - pgdata:/var/lib/postgresql/data/ + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:8.2 + restart: unless-stopped + ports: + - "127.0.0.1:${REDIS_PORT:-6379}:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli ping"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: + redis-data: + lancedb-data: diff --git a/honcho-cli/src/honcho_cli/local/templates/init.sql b/honcho-cli/src/honcho_cli/local/templates/init.sql new file mode 100644 index 00000000..0aa0fc22 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/templates/init.sql @@ -0,0 +1 @@ +CREATE EXTENSION IF NOT EXISTS vector; diff --git a/honcho-cli/src/honcho_cli/main.py b/honcho-cli/src/honcho_cli/main.py index 7ed8aa9b..8a9c16f6 100644 --- a/honcho-cli/src/honcho_cli/main.py +++ b/honcho-cli/src/honcho_cli/main.py @@ -64,9 +64,13 @@ def main( # Register top-level commands from honcho_cli.commands.setup import doctor, init +from honcho_cli.commands.stack import start, status, stop app.command()(init) app.command()(doctor) +app.command()(start) +app.command()(stop) +app.command()(status) @app.command("help", hidden=True) diff --git a/honcho-cli/src/honcho_cli/output.py b/honcho-cli/src/honcho_cli/output.py index 2f0ec5e9..e95f17e6 100644 --- a/honcho-cli/src/honcho_cli/output.py +++ b/honcho-cli/src/honcho_cli/output.py @@ -15,6 +15,8 @@ from rich.console import Console from rich.table import Table from rich.text import Text +from honcho_cli.branding import ICON_FAIL, ICON_OK, ICON_RUN + console = Console(stderr=True) stdout_console = Console() @@ -106,6 +108,24 @@ def status(msg: str) -> None: console.print(f"[dim]{msg}[/dim]") +def step(msg: str) -> None: + """Print a progress step. No-op in JSON mode.""" + if not use_json(): + console.print(f" {ICON_RUN} {msg}") + + +def ok(msg: str) -> None: + """Print a success line. No-op in JSON mode.""" + if not use_json(): + console.print(f" {ICON_OK} {msg}") + + +def fail(msg: str) -> None: + """Print a failure line. No-op in JSON mode.""" + if not use_json(): + console.print(f" {ICON_FAIL} {msg}") + + # Stable peer-color palette for transcript rendering. Brand blue first so the # primary peer lands on brand when there's only one speaker. _PEER_COLORS = ( diff --git a/honcho-cli/tests/test_local.py b/honcho-cli/tests/test_local.py new file mode 100644 index 00000000..02e5cf2f --- /dev/null +++ b/honcho-cli/tests/test_local.py @@ -0,0 +1,127 @@ +"""Local-stack contracts: profile files, env merge, image pin, port remap.""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest +from honcho_cli.local.docker import ( + DockerError, + allocate_host_ports, + pin_image, + seed_config_toml, +) +from honcho_cli.local.env import managed_env, read_env_value, render_stack, upsert_env +from honcho_cli.local.profile import LocalProfile, load_profile, save_profile + + +@pytest.fixture +def cfg_dir(tmp_path, monkeypatch): + monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path) + monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", tmp_path / "config.json") + for k in [k for k in os.environ if k.startswith("HONCHO_")]: + monkeypatch.delenv(k) + return tmp_path + + +def test_profile_roundtrip_has_no_secrets(cfg_dir): + profile = LocalProfile( + name="local", + api_port=8001, + image="ghcr.io/plastic-labs/honcho@sha256:abc", + ) + save_profile(profile) + loaded = load_profile("local") + assert loaded.api_port == 8001 + assert loaded.image.endswith("@sha256:abc") + on_disk = json.loads(profile.profile_file().read_text()) + assert "LLM" not in json.dumps(on_disk) + assert set(on_disk) == {"apiPort", "dbPort", "redisPort", "image"} + + +def test_upsert_preserves_extra_env_keys(tmp_path): + path = tmp_path / ".env" + path.write_text("CUSTOM_FLAG=keep-me\n# user comment\n") + upsert_env(path, managed_env(LocalProfile(name="local"))) + text = path.read_text() + assert "CUSTOM_FLAG=keep-me" in text + assert "user comment" in text + assert text.count("Generated by honcho start") == 1 + + +def test_upsert_writes_non_managed_and_preserves_later(tmp_path): + path = tmp_path / ".env" + first = managed_env(LocalProfile(name="local")) + first["DERIVER_MODEL_CONFIG__MODEL"] = "gpt-test" + upsert_env(path, first) + upsert_env(path, managed_env(LocalProfile(name="local"))) + later = path.read_text() + assert "DERIVER_MODEL_CONFIG__MODEL=gpt-test" in later + + +def test_render_stack_uses_published_image(cfg_dir): + profile = LocalProfile(name="local") + render_stack(profile) + compose = profile.compose_file().read_text() + assert "ghcr.io/plastic-labs/honcho" in compose + assert "build:" not in compose + assert compose.count("./config.toml:/app/config.toml:ro") == 2 + assert read_env_value(profile.env_file(), "AUTH_USE_AUTH") == "false" + assert oct(profile.env_file().stat().st_mode)[-3:] == "600" + + +def test_pin_latest_to_matching_digest(monkeypatch): + pulls: list[str] = [] + + def fake_run(args, *, check=False): + if args[:1] == ["pull"]: + pulls.append(args[1]) + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + if args[:2] == ["image", "inspect"]: + body = json.dumps( + [ + "ghcr.io/plastic-labs/honcho@sha256:deadbeef", + "ghcr.io/other/honcho@sha256:nope", + ] + ) + return subprocess.CompletedProcess(args, 0, stdout=body, stderr="") + raise AssertionError(args) + + monkeypatch.setattr("honcho_cli.local.docker._run_docker", fake_run) + assert pin_image("ghcr.io/plastic-labs/honcho:latest") == ( + "ghcr.io/plastic-labs/honcho@sha256:deadbeef" + ) + assert pulls == ["ghcr.io/plastic-labs/honcho:latest"] + + +def test_seed_config_toml_writes_once(cfg_dir, monkeypatch): + profile = LocalProfile( + name="local", image="ghcr.io/plastic-labs/honcho@sha256:abc" + ) + monkeypatch.setattr( + "honcho_cli.local.docker._copy_from_image", + lambda image, paths: "[deriver]\nWORKERS = 2\n", + ) + assert seed_config_toml(profile) is True + profile.config_file().write_text( + profile.config_file().read_text() + "# user edit\n" + ) + assert seed_config_toml(profile) is False + assert "# user edit" in profile.config_file().read_text() + + +def test_busy_port_remaps_unless_pinned(monkeypatch): + monkeypatch.setattr( + "honcho_cli.local.docker.port_available", + lambda port, host="127.0.0.1": port != 6379, + ) + profile, remapped = allocate_host_ports(LocalProfile(name="local")) + assert profile.redis_port == 6380 + assert remapped["redis"] == (6379, 6380) + + with pytest.raises(DockerError) as exc: + allocate_host_ports(LocalProfile(name="local"), pinned=frozenset({"redis"})) + assert exc.value.code == "PORT_IN_USE" + assert exc.value.details["flag"] == "--redis-port" diff --git a/honcho-cli/tests/test_setup.py b/honcho-cli/tests/test_setup.py new file mode 100644 index 00000000..5a411946 --- /dev/null +++ b/honcho-cli/tests/test_setup.py @@ -0,0 +1,66 @@ +"""Wizard mapping: ``answers_to_env`` and image-toml defaults.""" + +from __future__ import annotations + +from honcho_cli.local.setup import ( + DIALECTIC_LEVELS, + SetupAnswers, + answers_to_env, + chat_model_default, + load_toml_setup_defaults, +) + + +def test_basic_openai_applies_chat_model_everywhere(): + env = answers_to_env( + SetupAnswers( + mode="basic", + provider="openai", + api_key="sk-test", + chat_model="gpt-test", + ) + ) + assert env["LLM_OPENAI_API_KEY"] == "sk-test" + assert env["DERIVER_MODEL_CONFIG__MODEL"] == "gpt-test" + assert env["SUMMARY_MODEL_CONFIG__MODEL"] == "gpt-test" + for level in DIALECTIC_LEVELS: + assert env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__MODEL"] == "gpt-test" + assert "DREAM_ENABLED" not in env + assert "EMBEDDING_MODEL_CONFIG__MODEL" not in env + + +def test_basic_anthropic_keeps_openai_embeddings_default(): + env = answers_to_env( + SetupAnswers( + mode="basic", + provider="anthropic", + api_key="sk-ant", + chat_model="claude-haiku-4-5", + embedding_api_key="sk-embed", + embedding_key_transport="openai", + ) + ) + assert env["LLM_ANTHROPIC_API_KEY"] == "sk-ant" + assert env["LLM_OPENAI_API_KEY"] == "sk-embed" + assert env["DERIVER_MODEL_CONFIG__TRANSPORT"] == "anthropic" + assert "EMBEDDING_MODEL_CONFIG__TRANSPORT" not in env + + +def test_chat_default_comes_from_image_toml(tmp_path): + path = tmp_path / "config.toml" + path.write_text( + "[deriver.model_config]\n" + 'transport = "openai"\n' + 'model = "gpt-from-image"\n' + ) + defaults = load_toml_setup_defaults(path) + assert defaults.chat_model == "gpt-from-image" + assert chat_model_default("openai", {}, defaults) == "gpt-from-image" + assert chat_model_default("openai-compatible", {}, defaults) == "gpt-from-image" + assert chat_model_default("anthropic", {}, defaults) == "" + assert chat_model_default( + "openai", + {"DERIVER_MODEL_CONFIG__MODEL": "gpt-from-env"}, + defaults, + inferred="openai", + ) == "gpt-from-env" diff --git a/honcho-cli/tests/test_start.py b/honcho-cli/tests/test_start.py new file mode 100644 index 00000000..2b589813 --- /dev/null +++ b/honcho-cli/tests/test_start.py @@ -0,0 +1,159 @@ +"""CLI contracts for `honcho start` / `stop` / `status`.""" + +from __future__ import annotations + +import json +import os + +import pytest +from honcho_cli.local.docker import image_is_digest, image_repository +from honcho_cli.main import app +from typer.testing import CliRunner + + +@pytest.fixture +def cfg(tmp_path, monkeypatch): + f = tmp_path / "config.json" + monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path) + monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f) + monkeypatch.setattr("honcho_cli.commands.setup.CONFIG_FILE", f) + for k in [k for k in os.environ if k.startswith(("HONCHO_", "LLM_"))]: + monkeypatch.delenv(k) + return f + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture(autouse=True) +def _host_ports_free(monkeypatch): + monkeypatch.setattr("honcho_cli.local.docker.port_available", lambda *a, **k: True) + + +@pytest.fixture(autouse=True) +def _stub_image_pin(monkeypatch): + def fake_pin(image: str) -> str: + if image_is_digest(image): + return image + return f"{image_repository(image)}@sha256:cafedeadbeef" + + monkeypatch.setattr("honcho_cli.commands.stack.pin_image", fake_pin) + monkeypatch.setattr("honcho_cli.commands.stack.seed_config_toml", lambda profile: False) + + +_PS = [ + {"Service": "api", "State": "running", "Health": "healthy"}, + {"Service": "deriver", "State": "running"}, + {"Service": "database", "State": "running", "Health": "healthy"}, + {"Service": "redis", "State": "running", "Health": "healthy"}, +] + + +def test_start_does_not_rewrite_environment_url(cfg, runner, monkeypatch): + cfg.write_text( + json.dumps({"apiKey": "k", "environmentUrl": "https://api.honcho.dev"}) + ) + monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: False) + monkeypatch.setattr("honcho_cli.commands.stack.compose_up", lambda profile, **k: None) + monkeypatch.setattr("honcho_cli.commands.stack.wait_for_health", lambda *a, **k: True) + monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: _PS) + monkeypatch.setenv("LLM_OPENAI_API_KEY", "sk-test") + result = runner.invoke(app, ["start", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["endpoints"]["api"] == "http://127.0.0.1:8000" + assert payload["image"].endswith("@sha256:cafedeadbeef") + on_disk = json.loads(cfg.read_text()) + assert on_disk["environmentUrl"] == "https://api.honcho.dev" + + +def test_start_requires_llm_key(cfg, runner, monkeypatch): + monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: False) + result = runner.invoke(app, ["start"]) + assert result.exit_code == 1 + assert json.loads(result.stderr)["error"]["code"] == "MISSING_LLM_KEY" + + +def test_stop_already_stopped_skips_down(cfg, runner, tmp_path, monkeypatch): + compose = tmp_path / "profiles" / "local" / "docker-compose.yml" + compose.parent.mkdir(parents=True) + compose.write_text("services: {}\n") + monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: []) + down = [] + monkeypatch.setattr( + "honcho_cli.commands.stack.compose_down", + lambda profile, wipe=False: down.append(wipe), + ) + result = runner.invoke(app, ["stop"]) + assert result.exit_code == 0, result.stderr + assert down == [] + assert json.loads(result.stdout)["status"] == "stopped" + + +def test_status_lists_profiles_or_one(cfg, runner, tmp_path, monkeypatch): + for name, port in (("demo", 8001), ("local", 8000)): + d = tmp_path / "profiles" / name + d.mkdir(parents=True) + (d / "docker-compose.yml").write_text("services: {}\n") + (d / "profile.json").write_text(json.dumps({"apiPort": port}) + "\n") + + monkeypatch.setattr( + "honcho_cli.commands.stack.compose_ps", + lambda profile: _PS if profile.name == "local" else [], + ) + monkeypatch.setattr( + "honcho_cli.commands.stack.stack_healthy", + lambda profile: profile.name == "local", + ) + listed = runner.invoke(app, ["status"]) + assert listed.exit_code == 0, listed.stderr + rows = json.loads(listed.stdout)["profiles"] + by_name = {row["profile"]: row for row in rows} + assert by_name["local"]["status"] == "running" + assert by_name["demo"]["endpoints"]["api"] == "http://127.0.0.1:8001" + + one = runner.invoke(app, ["status", "--profile", "local"]) + assert one.exit_code == 0, one.stderr + payload = json.loads(one.stdout) + assert payload["profile"] == "local" + assert "profiles" not in payload + + +def test_start_setup_requires_tty(cfg, runner): + result = runner.invoke(app, ["start", "--setup", "basic", "--json"]) + assert result.exit_code == 1 + assert json.loads(result.stderr)["error"]["code"] == "SETUP_REQUIRES_TTY" + + +def test_start_setup_recreates_when_already_running(cfg, runner, monkeypatch): + from honcho_cli.local.setup import SetupAnswers + + ups: list[tuple[str, ...]] = [] + monkeypatch.setattr("honcho_cli.commands.stack.use_json", lambda: False) + monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: True) + monkeypatch.setattr( + "honcho_cli.commands.stack.compose_up", + lambda profile, **k: ups.append(k.get("recreate", ())), + ) + monkeypatch.setattr("honcho_cli.commands.stack.wait_for_health", lambda *a, **k: True) + monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: _PS) + monkeypatch.setattr( + "honcho_cli.commands.stack.run_setup", + lambda mode, path, config_path=None: SetupAnswers( + mode="basic", + provider="openai", + api_key="sk-wiz", + chat_model="gpt-test", + ), + ) + pins: list[str] = [] + monkeypatch.setattr( + "honcho_cli.commands.stack.pin_image", + lambda image: pins.append(image) or image, + ) + result = runner.invoke(app, ["start", "--setup", "basic"]) + assert result.exit_code == 0, result.stderr + assert pins == [] + assert ups == [("api", "deriver")] diff --git a/skills/honcho-cli/SKILL.md b/skills/honcho-cli/SKILL.md index e773cfa0..4f75f259 100644 --- a/skills/honcho-cli/SKILL.md +++ b/skills/honcho-cli/SKILL.md @@ -18,6 +18,7 @@ allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep ## Command groups - `honcho config` — CLI configuration +- `honcho start` / `stop` / `status` — local Docker stack (does not change `environmentUrl`). First start pins the Honcho image digest and writes `config.toml` into the profile. Pass `--setup basic` or `--setup advanced` for an interactive config wizard (TTY only; writes `.env` overrides). `honcho status` lists every profile; pass `--profile` for one. - `honcho workspace` — inspect, delete, search - `honcho peer` — inspect, card, chat, search - `honcho session` — inspect, view (transcript), context, summaries @@ -31,6 +32,7 @@ allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep - Use `honcho session context` to see exactly what an agent receives. - Never run `honcho workspace delete` without `honcho workspace inspect` first. - Compare peer card with conclusions to understand memory state. +- `honcho start` does not rewrite `environmentUrl`. Use `HONCHO_BASE_URL=http://127.0.0.1:8000` to talk to local stack. ## Inspection tour From 99a06baf29997e2be1c4d9c182f6ed731b43fe01 Mon Sep 17 00:00:00 2001 From: Rajat Ahuja Date: Tue, 25 Aug 2026 12:34:42 -0400 Subject: [PATCH 15/50] perf(cache): hash-tag the cache namespace so an instance uses one shard (#1058) On Redis Cluster a key's slot comes from the substring inside the first {...}, when one is present. Untagged, one deployment's keys spread over every slot, so its client opens and holds a connection to every node in the cluster. Wrapping the namespace in braces puts them all on one slot, and therefore one node, cutting each deployment's connection count to the cluster by a factor of the shard count. Namespaces still hash independently of each other, so keys stay spread across the cluster and no shard becomes a hotspot. The tag needs two spellings, because the two ways a key gets built treat the string differently. cashews runs `prefix=` through format substitution, so braces have to be doubled there to survive as literals; keys built by concatenation need them single. A single brace passed to cashews is read as an empty substitution field and the namespace is dropped entirely, which would let two deployments collide on one key -- hence two clearly named helpers rather than one string, and a test that the two paths produce identical bytes. No key format change for a non-cluster backend, and no migration: the old keys simply age out by TTL. --- src/cache/client.py | 24 ++++++++++++ src/crud/collection.py | 9 +++-- src/crud/peer.py | 13 +++++-- src/crud/session.py | 9 +++-- src/crud/workspace.py | 9 +++-- tests/test_cache_key_namespace.py | 62 +++++++++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 16 deletions(-) create mode 100644 tests/test_cache_key_namespace.py diff --git a/src/cache/client.py b/src/cache/client.py index 1ad8e3f5..4b9fabb8 100644 --- a/src/cache/client.py +++ b/src/cache/client.py @@ -123,6 +123,28 @@ def get_cache_namespace() -> str: return cast(str, settings.CACHE.NAMESPACE) +# On Redis Cluster a key's slot is derived from the substring inside the first +# {...}, when one is present. Tagging the namespace puts every key an instance +# writes on a single slot, and therefore a single shard, so its client holds +# connections to one node rather than to all of them. Namespaces still hash +# independently of one another, so keys stay spread across the cluster. +# +# Two spellings, because the two ways a key gets built treat the string +# differently: cashews runs `prefix=` through format substitution, so braces +# have to be doubled to survive as literals, while direct construction does no +# substitution and needs them single. Both render to the same bytes, which +# tests/cache/test_cache_namespace_hash_tag.py asserts -- a mismatch would send +# writes and deletes to different keys with nothing raised. +def cache_key_namespace() -> str: + """Tagged namespace for keys built by string concatenation.""" + return "{" + get_cache_namespace() + "}" + + +def cache_prefix_namespace() -> str: + """Tagged namespace for cashews `prefix=`, which format-substitutes.""" + return "{{" + get_cache_namespace() + "}}" + + async def init_cache() -> None: """Initialize and verify cache connection if enabled.""" async with _cache_lock: @@ -256,6 +278,8 @@ __all__ = [ "init_cache", "close_cache", "cache", + "cache_key_namespace", + "cache_prefix_namespace", "safe_cache_delete", "safe_cache_set", ] diff --git a/src/crud/collection.py b/src/crud/collection.py index 63a775e3..a5101b6e 100644 --- a/src/crud/collection.py +++ b/src/crud/collection.py @@ -10,7 +10,8 @@ from sqlalchemy.orm import make_transient_to_detached from src import models from src.cache.client import ( cache, - get_cache_namespace, + cache_key_namespace, + cache_prefix_namespace, safe_cache_delete, safe_cache_set, ) @@ -22,13 +23,13 @@ logger = getLogger(__name__) COLLECTION_CACHE_KEY_TEMPLATE = ( "v2:workspace:{workspace_name}:collection:{observer}:{observed}" ) -COLLECTION_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2" +COLLECTION_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2" def collection_cache_key(workspace_name: str, observer: str, observed: str) -> str: """Generate cache key for collection.""" return ( - get_cache_namespace() + cache_key_namespace() + ":" + COLLECTION_CACHE_KEY_TEMPLATE.format( workspace_name=workspace_name, @@ -41,7 +42,7 @@ def collection_cache_key(workspace_name: str, observer: str, observed: str) -> s @cache( key=COLLECTION_CACHE_KEY_TEMPLATE, ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s", - prefix=get_cache_namespace(), + prefix=cache_prefix_namespace(), condition=NOT_NONE, ) @cache.locked( diff --git a/src/crud/peer.py b/src/crud/peer.py index 2f81f938..0bd1e120 100644 --- a/src/crud/peer.py +++ b/src/crud/peer.py @@ -12,7 +12,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import make_transient_to_detached from src import models, schemas -from src.cache.client import cache, get_cache_namespace, safe_cache_delete +from src.cache.client import ( + cache, + cache_key_namespace, + cache_prefix_namespace, + safe_cache_delete, +) from src.config import settings from src.crud.workspace import get_or_create_workspace from src.exceptions import ( @@ -32,13 +37,13 @@ logger = getLogger(__name__) PEER_NAME_MAX_LENGTH = 512 PEER_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}:peer:{peer_name}" -PEER_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2" +PEER_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2" def peer_cache_key(workspace_name: str, peer_name: str) -> str: """Generate cache key for peer.""" return ( - get_cache_namespace() + cache_key_namespace() + ":" + PEER_CACHE_KEY_TEMPLATE.format( workspace_name=workspace_name, @@ -391,7 +396,7 @@ async def get_or_create_peers( @cache( key=PEER_CACHE_KEY_TEMPLATE, ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s", - prefix=get_cache_namespace(), + prefix=cache_prefix_namespace(), condition=NOT_NONE, ) @cache.locked( diff --git a/src/crud/session.py b/src/crud/session.py index 51ca1f13..216d4949 100644 --- a/src/crud/session.py +++ b/src/crud/session.py @@ -29,7 +29,8 @@ from sqlalchemy.types import BigInteger, Boolean from src import models, schemas from src.cache.client import ( cache, - get_cache_namespace, + cache_key_namespace, + cache_prefix_namespace, safe_cache_delete, safe_cache_set, ) @@ -67,13 +68,13 @@ class SessionDeletionResult: SESSION_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}:session:{session_name}" -SESSION_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2" +SESSION_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2" def session_cache_key(workspace_name: str, session_name: str) -> str: """Generate cache key for session.""" return ( - get_cache_namespace() + cache_key_namespace() + ":" + SESSION_CACHE_KEY_TEMPLATE.format( workspace_name=workspace_name, @@ -85,7 +86,7 @@ def session_cache_key(workspace_name: str, session_name: str) -> str: @cache( key=SESSION_CACHE_KEY_TEMPLATE, ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s", - prefix=get_cache_namespace(), + prefix=cache_prefix_namespace(), condition=NOT_NONE, ) @cache.locked( diff --git a/src/crud/workspace.py b/src/crud/workspace.py index a5042acd..05536bf2 100644 --- a/src/crud/workspace.py +++ b/src/crud/workspace.py @@ -15,7 +15,8 @@ from sqlalchemy.orm import make_transient_to_detached from src import models, schemas from src.cache.client import ( cache, - get_cache_namespace, + cache_key_namespace, + cache_prefix_namespace, safe_cache_delete, safe_cache_set, ) @@ -40,13 +41,13 @@ class WorkspaceDeletionResult: WORKSPACE_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}" -WORKSPACE_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2" +WORKSPACE_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2" def workspace_cache_key(workspace_name: str) -> str: """Generate cache key for workspace.""" return ( - get_cache_namespace() + cache_key_namespace() + ":" + WORKSPACE_CACHE_KEY_TEMPLATE.format(workspace_name=workspace_name) ) @@ -55,7 +56,7 @@ def workspace_cache_key(workspace_name: str) -> str: @cache( key=WORKSPACE_CACHE_KEY_TEMPLATE, ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s", - prefix=get_cache_namespace(), + prefix=cache_prefix_namespace(), condition=NOT_NONE, ) @cache.locked( diff --git a/tests/test_cache_key_namespace.py b/tests/test_cache_key_namespace.py new file mode 100644 index 00000000..14d2e7cc --- /dev/null +++ b/tests/test_cache_key_namespace.py @@ -0,0 +1,62 @@ +"""The namespace hash tag must survive both ways a cache key gets built. + +cashews format-substitutes `prefix=`, direct construction does not, so the two +need different spellings of the same tag. If they ever diverge, a write and its +invalidation land on different keys and nothing raises -- the cache just serves +stale rows. These tests are what fails instead. +""" + +import pytest +from redis.crc import key_slot + +from src.cache.client import ( + cache, + cache_key_namespace, + cache_prefix_namespace, + get_cache_namespace, +) +from src.crud.session import SESSION_CACHE_KEY_TEMPLATE, session_cache_key + + +def test_both_spellings_render_the_same_tag(): + ns = get_cache_namespace() + assert cache_key_namespace() == "{" + ns + "}" + # Doubled braces collapse to single ones when cashews formats the prefix. + assert cache_prefix_namespace().format() == cache_key_namespace() + + +@pytest.mark.asyncio +async def test_decorator_key_matches_helper_key(): + """The key the decorator writes is the key the helper computes.""" + + @cache( + key=SESSION_CACHE_KEY_TEMPLATE, + prefix=cache_prefix_namespace(), + ttl="60s", + ) + async def get_session(workspace_name: str, session_name: str) -> str: + # The names matter: cashews fills the key template from them. + return f"{workspace_name}/{session_name}" + + await get_session(workspace_name="w1", session_name="s1") + + written = [k async for k in cache.scan("*")] + assert session_cache_key("w1", "s1") in written + + +def test_one_namespace_hashes_to_one_slot(): + """Every key an instance writes shares a Redis Cluster slot.""" + keys = [ + session_cache_key("w1", "s1"), + session_cache_key("w2", "s2"), + f"{cache_key_namespace()}:lock:v2:anything", + ] + assert len({key_slot(k.encode()) for k in keys}) == 1 + + +def test_namespaces_hash_independently(): + """Tagging must not collapse the whole fleet onto one shard.""" + slots = { + key_slot(("{" + n + "}:v2:workspace:w").encode()) for n in ("a1", "b2", "c3") + } + assert len(slots) > 1 From 2dbc25093d1464b11ecc385f3eb32cc28c0f4726 Mon Sep 17 00:00:00 2001 From: ajspig <46900795+ajspig@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:53:12 -0400 Subject: [PATCH 16/50] chore: bump honcho-cli to 0.1.3 (#1067) --- honcho-cli/CHANGELOG.md | 7 +++++-- honcho-cli/pyproject.toml | 2 +- honcho-cli/src/honcho_cli/__init__.py | 2 +- uv.lock | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/honcho-cli/CHANGELOG.md b/honcho-cli/CHANGELOG.md index 7973341a..a8c1b12f 100644 --- a/honcho-cli/CHANGELOG.md +++ b/honcho-cli/CHANGELOG.md @@ -7,13 +7,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +## [0.1.3] - 2026-08-25 + ### Added -- `honcho session view` — session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session +- `honcho start`, `honcho stop`, and `honcho status` — run a personal Honcho stack in Docker (API, deriver, Postgres, Redis). Profiles live under `~/.honcho/profiles/`. First start pins `ghcr.io/plastic-labs/honcho:latest` by digest and copies the image `config.toml`. Optional `--setup basic` / `--setup advanced` wizard writes LLM overrides to `.env` (#1029) +- `honcho session view` — session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session (#1006) ### Fixed -- `honcho message list --last N` no longer stops at the first page of 50 — it walks pages to fill the requested window +- `honcho message list --last N` no longer stops at the first page of 50 — it walks pages to fill the requested window (#1006) ## [0.1.2] - 2026-07-20 diff --git a/honcho-cli/pyproject.toml b/honcho-cli/pyproject.toml index 2a5e59e2..f2fa4d0c 100644 --- a/honcho-cli/pyproject.toml +++ b/honcho-cli/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho-cli" -version = "0.1.2" +version = "0.1.3" 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 81efe6f5..5cbd28b3 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.2" +__version__ = "0.1.3" diff --git a/uv.lock b/uv.lock index 82f85e04..9217e608 100644 --- a/uv.lock +++ b/uv.lock @@ -1170,7 +1170,7 @@ dev = [{ name = "ruff", specifier = ">=0.11.13" }] [[package]] name = "honcho-cli" -version = "0.1.2" +version = "0.1.3" source = { editable = "honcho-cli" } dependencies = [ { name = "click" }, From 2f7658577e47ff62e40da697a9985e1e03d12bf1 Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Tue, 25 Aug 2026 13:24:31 -0400 Subject: [PATCH 17/50] fix(scopes): scope observer sessions in SQL instead of a fetched name list (#1065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get_observation_context` resolved scope by fetching every session name the observer has a membership record in, then expanding that list into `session_name IN (...)` twice in one statement — once in the CTE and once in the outer select. That puts psycopg's 65535-bind-parameter ceiling at roughly 32,765 sessions, and the count only ever grows: the loose membership definition (`active_only=False`) counts sessions the peer has since left, so leaving a session does not shrink the scope. A workspace with tens of thousands of sessions for one peer produced a statement the driver could not serialize at all. Two new helpers in `crud.message` express the observer half as a correlated EXISTS over `session_peers`. Scope now costs two bind parameters regardless of membership size, and the membership query disappears (two round trips become one). The `session_peers` primary key is `(workspace_name, session_name, peer_name)`, so the correlated probe is an exact-match index hit. The caller-supplied allowlist stays an IN clause — it is route-capped at 1000 entries and carries none of the unbounded-growth risk. `resolve_session_scope` is left in place: three other callers still need the materialized list, including `_search_messages_external`, which sends session names to the vector store as a filter payload and cannot take SQL. Co-authored-by: Claude Opus 5 (1M context) --- src/crud/message.py | 86 ++++++++++++ src/utils/agent_tools.py | 23 ++- tests/conftest.py | 2 + tests/crud/test_session_scope_clauses.py | 169 +++++++++++++++++++++++ 4 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 tests/crud/test_session_scope_clauses.py diff --git a/src/crud/message.py b/src/crud/message.py index fbfb1698..9759cb91 100644 --- a/src/crud/message.py +++ b/src/crud/message.py @@ -6,6 +6,7 @@ from typing import Any from nanoid import generate as generate_nanoid from sqlalchemy import ColumnElement, Select, and_, func, or_, select, text from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import InstrumentedAttribute from src import models, schemas from src.config import settings @@ -159,6 +160,91 @@ async def resolve_session_scope( return (allowed, False) if allowed else (None, True) +def observer_scope_clause( + workspace_name: str, + observer: str, + session_column: InstrumentedAttribute[str], +) -> ColumnElement[bool]: + """Correlated EXISTS restricting ``session_column`` to the observer's sessions. + + The in-database equivalent of filtering on :func:`get_peer_session_names`. + Prefer it whenever the scope feeds a single SQL statement: a peer's + membership count is unbounded, and materializing the names turns each one + into its own bind parameter. The PostgreSQL wire protocol caps parameters + at 65535 per statement, so a peer in enough sessions produces a query the + driver cannot serialize at all — and the resulting error carries every + parameter in its text. + + Matches the loose membership definition ``get_peer_session_names`` uses by + default: any membership record grants visibility, whether or not the peer + has since left the session. + """ + session_peers = models.session_peers_table + return ( + select(1) + .where(session_peers.c.workspace_name == workspace_name) + .where(session_peers.c.peer_name == observer) + .where(session_peers.c.session_name == session_column) + .exists() + ) + + +def resolve_session_scope_clauses( + workspace_name: str, + session_name: str | None, + session_allowlist: list[str] | None, + observer: str | None, + session_column: InstrumentedAttribute[str], +) -> tuple[list[ColumnElement[bool]], bool]: + """SQL-side counterpart to :func:`resolve_session_scope`. + + Returns ``(clauses, deny)``, where ``clauses`` are ANDed onto the caller's + statement and ``deny=True`` means return an empty result without querying. + Unlike :func:`resolve_session_scope` this touches no database and grows no + bind parameters with the observer's session count — the observer half + becomes a correlated EXISTS instead of an ``IN`` over fetched names. + + Scoping matches :func:`resolve_session_scope` case for case, with one + deliberate difference: where that function returns ``deny=True`` because an + observer's membership (or its intersection with the allowlist) is empty, + this returns clauses that simply match no rows. Callers reach the same empty + result, at the cost of running one indexed query that returns nothing. + + ``session_allowlist`` stays an ``IN`` clause: it is caller-supplied and + therefore bounded, so it carries none of the unbounded-growth risk. + + Args: + workspace_name: Name of the workspace + session_name: A single pinned session, if the caller named one. The + caller applies its own equality filter; this function only checks + the allowlist permits it. + session_allowlist: Optional session allowlist. ``None`` is + unrestricted; an empty list fails closed. + observer: When set, scope is limited to this peer's sessions + session_column: The session-name column to scope, e.g. + ``models.Message.session_name`` + """ + if session_name: + # Fail closed when the allowlist forbids the pinned session, matching + # `resolve_session_scope` — routes guard this too, but the dialectic + # tools reach CRUD directly, so enforce it at the boundary. + if session_allowlist is not None and session_name not in session_allowlist: + return [], True + return [], False + + clauses: list[ColumnElement[bool]] = [] + + if observer is not None: + clauses.append(observer_scope_clause(workspace_name, observer, session_column)) + + if session_allowlist is not None: + if not session_allowlist: + return [], True + clauses.append(session_column.in_(session_allowlist)) + + return clauses, False + + def _apply_token_limit( base_conditions: list[ColumnElement[Any]], token_limit: int ) -> Select[tuple[models.Message]]: diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index 5f87d455..e31b4d62 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -1273,10 +1273,19 @@ async def get_observation_context( if not message_ids: return [] - from src.crud.message import resolve_session_scope + from src.crud.message import resolve_session_scope_clauses - allowed_session_names, deny = await resolve_session_scope( - db, workspace_name, session_name, session_allowlist, observer + # Scope as SQL rather than as a fetched name list. The scope is applied to + # both the CTE and the outer select, so a materialized list would spend two + # bind parameters per session the observer belongs to — enough sessions and + # the statement exceeds the driver's 65535-parameter ceiling and cannot be + # sent at all. + scope_clauses, deny = resolve_session_scope_clauses( + workspace_name, + session_name, + session_allowlist, + observer, + models.Message.session_name, ) if deny: return [] @@ -1290,8 +1299,8 @@ async def get_observation_context( if session_name: stmt = stmt.where(models.Message.session_name == session_name) - elif allowed_session_names is not None: - stmt = stmt.where(models.Message.session_name.in_(allowed_session_names)) + for clause in scope_clauses: + stmt = stmt.where(clause) target_seqs_cte = stmt.cte("target_seqs") @@ -1314,8 +1323,8 @@ async def get_observation_context( if session_name: stmt = stmt.where(models.Message.session_name == session_name) - elif allowed_session_names is not None: - stmt = stmt.where(models.Message.session_name.in_(allowed_session_names)) + for clause in scope_clauses: + stmt = stmt.where(clause) result = await db.execute(stmt) messages = list(result.scalars().all()) diff --git a/tests/conftest.py b/tests/conftest.py index 7d9e81da..090d5395 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -86,6 +86,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", + # Session-scope SQL shape — asserts on compiled statements, never executes one. + "tests/crud/test_session_scope_clauses.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/crud/test_session_scope_clauses.py b/tests/crud/test_session_scope_clauses.py new file mode 100644 index 00000000..a4a77c60 --- /dev/null +++ b/tests/crud/test_session_scope_clauses.py @@ -0,0 +1,169 @@ +"""Observer session scope is enforced in SQL, not as a fetched name list. + +A peer's session-membership count is unbounded. Materializing it turns every +session name into its own bind parameter, and the PostgreSQL wire protocol caps +parameters at 65535 per statement, so a peer in enough sessions yields a +statement the driver refuses to serialize. `get_observation_context` applies the +scope twice in one statement, which halves that ceiling to ~32.7k sessions. + +These tests assert on compiled SQL and never execute a statement. +""" + +from typing import Any + +import pytest +from sqlalchemy import Select, select +from sqlalchemy.dialects import postgresql + +from src import models +from src.crud.message import resolve_session_scope_clauses +from src.utils.agent_tools import get_observation_context + + +def _compile(stmt: Select[Any]) -> tuple[str, dict[str, Any]]: + compiled = stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"render_postcompile": True}, + ) + return str(compiled), dict(compiled.params) + + +class _FakeResult: + def scalars(self) -> "_FakeResult": + return self + + def all(self) -> list[Any]: + return [] + + +class _CapturingDB: + """Captures statements instead of executing them.""" + + def __init__(self) -> None: + self.statements: list[Any] = [] + + async def execute(self, stmt: Any) -> _FakeResult: + self.statements.append(stmt) + return _FakeResult() + + +@pytest.mark.parametrize( + ("session_allowlist", "observer", "expect_exists", "expected_params"), + [ + # Observer only: membership becomes a correlated EXISTS, so only the + # workspace and peer are bound — never the session names, which is what + # keeps the parameter count from growing with membership. + (None, "observer-peer", True, ["observer-peer", "workspace"]), + # Allowlist only: caller-supplied and therefore bounded, so IN is fine. + (["s1", "s2"], None, False, ["s1", "s2"]), + # Both: the EXISTS is intersected with the bounded IN. + (["s1"], "observer-peer", True, ["observer-peer", "s1", "workspace"]), + # Neither: unrestricted, nothing filtered and nothing bound. + (None, None, False, []), + ], + ids=["observer-only", "allowlist-only", "observer-and-allowlist", "unrestricted"], +) +def test_scope_clause_shape( + session_allowlist: list[str] | None, + observer: str | None, + expect_exists: bool, + expected_params: list[str], +) -> None: + clauses, deny = resolve_session_scope_clauses( + "workspace", + None, + session_allowlist, + observer, + models.Message.session_name, + ) + + assert not deny + + sql, params = _compile(select(models.Message.public_id).where(*clauses)) + + assert ("EXISTS" in sql.upper()) is expect_exists + assert ("session_peers" in sql) is expect_exists + assert sorted(params.values()) == expected_params + + +@pytest.mark.parametrize( + ("session_name", "session_allowlist", "observer"), + [ + # An empty allowlist fails closed rather than matching everything. + (None, [], "observer-peer"), + (None, [], None), + # A pinned session the allowlist forbids fails closed. + ("s9", ["s1", "s2"], "observer-peer"), + ], + ids=[ + "empty-allowlist-with-observer", + "empty-allowlist-without-observer", + "pinned-session-not-in-allowlist", + ], +) +def test_scope_fails_closed( + session_name: str | None, + session_allowlist: list[str] | None, + observer: str | None, +) -> None: + clauses, deny = resolve_session_scope_clauses( + "workspace", + session_name, + session_allowlist, + observer, + models.Message.session_name, + ) + + assert deny + assert clauses == [] + + +@pytest.mark.asyncio +async def test_get_observation_context_scope_costs_no_per_session_parameters() -> None: + """The statement's parameter count depends on message_ids, not membership.""" + db = _CapturingDB() + message_ids = [f"msg-{i}" for i in range(5)] + + await get_observation_context( + db, # pyright: ignore[reportArgumentType] + "workspace", + None, + message_ids, + observer="observer-peer", + ) + + assert len(db.statements) == 1 + sql, params = _compile(db.statements[0]) + + # The scope must be applied to *both* the CTE and the outer select — a + # materialized list would have cost two parameters per session there. + # Count the subquery's FROM rather than EXISTS: the adjacency check is also + # an EXISTS, so counting those would pass with the CTE's scope missing. + assert sql.count("FROM public.session_peers") == 2 + # Both must correlate to the enclosing `messages` row. An uncorrelated + # subquery compiles just as happily and would silently drop scoping + # instead of enforcing it. + assert sql.count("session_peers.session_name = public.messages.session_name") == 2 + + # Everything bound is either a message id, the workspace, the peer, or the + # ±1 adjacency window — nothing that scales with the peer's session count. + expected = set(message_ids) | {"workspace", "observer-peer", -1, 1} + assert set(params.values()) <= expected + + +@pytest.mark.asyncio +async def test_get_observation_context_denies_without_querying() -> None: + """Fail-closed scopes must not reach the database at all.""" + db = _CapturingDB() + + result = await get_observation_context( + db, # pyright: ignore[reportArgumentType] + "workspace", + None, + ["msg-1"], + observer="observer-peer", + session_allowlist=[], + ) + + assert result == [] + assert db.statements == [] From 9e60f73c7f367c20caad63d2bb79989c1f86b17a Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Tue, 25 Aug 2026 16:25:31 -0400 Subject: [PATCH 18/50] release: add changelog and version updates (#1069) * chore: add changelog and version updates API: 3.0.12 -> 3.1.0 Python/TS SDK: 2.3.0 -> 2.4.0 CLI: 0.1.3 -> 0.1.3 (updated docs) * docs: add scopes to README architecture and fix changelog prefix --------- Co-authored-by: ajspig --- CHANGELOG.md | 38 ++++++++++++++ README.md | 33 ++++++++++-- docs/changelog/compatibility-guide.mdx | 8 +-- docs/changelog/introduction.mdx | 72 +++++++++++++++++++++++++- docs/docs.json | 2 +- docs/v3/openapi.json | 2 +- pyproject.toml | 2 +- sdks/python/CHANGELOG.md | 12 +++++ sdks/python/pyproject.toml | 2 +- sdks/typescript/CHANGELOG.md | 12 +++++ sdks/typescript/package.json | 2 +- uv.lock | 4 +- 12 files changed, 174 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 522c0985..3bef42b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,44 @@ 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.1.0] - 2026-08-25 + +### Added + +- Scopes: a named grouping of sessions that acts as a visibility boundary on recall, implemented as a facade over an observer peer (`scope.{name}` with `{"kind": "scope"}`). Developers manage them exclusively through `/v3/workspaces/{workspace_id}/scopes` (create-or-get, list, get, add/list/remove session membership) and an optional `scopes` field on session create — never through the observer/observed mechanics. Scope peers cannot author messages, cannot be a chat or representation `target`, are excluded from `peers.list` by default (`PeerGet.kind` = `"scope"` / `"all"` switches the view), and are rejected on the generic session-peer routes. Workspace-level key required; peer- and session-scoped keys get 401. Legacy peers occupying a reserved `scope.` name without the kind flag are refused with 409, never adopted (#884) +- `scope` read option on chat, representation, session context, and workspace search. A single scope swaps the observer to the backing scope peer so conclusion recall, peer cards, and message tools stay inside that scope's membership. A list of scopes takes the union of member sessions (capped at `MAX_SESSION_ALLOWLIST_ENTRIES`) and executes via the session-allowlist path. Empty scopes fail closed. `scope` is mutually exclusive with `filters` and `session_id`. Workspace- or admin-level key required (403 otherwise). Scope peers are also rejected as `peer_target` / `peer_perspective` on session context and as the path peer or `target` on `GET /peers/{id}/context` (#897) +- Scope backfill-by-copy and removal reconciliation. Adding a session that already has messages copies its explicit-level documents into the scope's collections (no LLM re-derivation; idempotent via `copied_from`). Removing a session soft-deletes those copies and fail-closed cascades to derived documents whose `source_ids` intersect anything removed, then enqueues a `card_refresh` dream with `rebuild=True` plus an omni dream. `GET /v3/workspaces/{workspace_id}/scopes/{scope_id}/status` reports per-session backfill state (`pending` / `completed` / `failed`, plus `docs_copied`) (#904) +- Workspace-level chat at `POST /v3/workspaces/{workspace_id}/chat`: agentic dialectic over the whole workspace instead of a single (observer, observed) pair. Prefetches workspace stats and the top active peers' self cards, then searches pair-scoped memory with `[observer->observed]` attribution. Supports `session_id`, `scope`, `reasoning_level`, `response_format`, and SSE streaming (#931) +- MCP workspace discovery: tools accept `workspace_id`, the worker honors an optional `X-Honcho-Workspace-ID` connection header, and `list_workspace` / `create_workspace` tools let clients pick or create a workspace instead of relying on the SDK default (#1020) +- MCP `search` also queries conclusions in parallel with messages when `peer_id` is given, returning `{messages, conclusions}`. The conclusions leg degrades to `[]` on error so search never gets worse than before (#974) +- Prometheus metrics for physical DB connections, visible even under `DB_POOL_CLASS=null`: `db_connections_open` (gauge) and `db_connections_established` (counter), hooked to SQLAlchemy connection-lifecycle events and registered on both the API and the deriver (#1055) +- Bounded-label Prometheus series are zero-initialized at process start so an absent series means a broken scrape rather than "nothing happened" (#927) + +### Changed + +- Workspace and pair chat system prompts now describe Honcho, peers, and the harness on their own terms, and render only the tools the request actually offers. The pair prompt no longer advertises a write tool that is not in the loadout (#1066) +- Deriver idle polling backoff is longer and no longer reset by periodic reconciler work, so downstream connection pools can cull idle DB connections (#1015) +- LLM provider SDKs are lazy-loaded so idle API and deriver processes no longer pay for every provider at import time (#1011) +- Production image is a multi-stage build: LanceDB/PyArrow move behind an optional `lancedb` extra (`INSTALL_LANCEDB=true` to restore them), FastAPI's unused cloud CLI is dropped, and the venv is copied into the runtime image with final ownership so Docker does not double the layer. Default unpacked image is about 663 MB (was 1.7 GB) (#1014) +- Redis Cluster cache keys hash-tag the namespace so one deployment's keys land on a single shard instead of opening a connection to every node. No behaviour change on a non-cluster backend; existing keys age out by TTL (#1058) +- Deriver extraction prompt no longer leaks its own few-shot examples into extracted conclusions (#1028) + +### Fixed + +- Observer-scoped `get_observation_context` no longer materializes every session the observer has ever joined into a `session_name IN (...)` list (twice in one statement). Past ~32k sessions that hit psycopg's bind-parameter ceiling and 500'd. The observer half is now a correlated `EXISTS` over `session_peers`, two bind parameters regardless of membership size (#1065) +- Re-adding an already-active session peer no longer advances `joined_at`, so `peer_perspective` search keeps messages from the original join. Genuine leave-and-rejoin still starts a new window (#1059) +- Transient embedding-provider errors (for example an OpenAI-compatible 200 with empty `data: []`) were relabeled as token-limit errors. Only genuine oversize input raises `EmbeddingTokenLimitError`; other provider errors propagate unchanged (#791) +- The filter DSL now fails closed with a 422 instead of a 500 on bad shapes, coerces operands by column type (so `{"session_id": {"ne": "abc"}}` is a string inequality rather than "invalid numeric"), and treats `NOT` / `ne` as null-safe (`IS NOT TRUE` / `IS DISTINCT FROM`) so negation no longer drops rows whose field is unset. Closed-set columns like `level` reject unknown values. Session-allowlist entries must be well-formed ids (`*` is 422, not a silent widen) (#947) +- `ne` on JSONB metadata keys is null-safe: a missing key is not equal to the compared value, so `{"metadata": {"foo": {"ne": "bar"}}}` includes rows where `foo` is unset (#1036) +- Oversized texts in `simple_batch_embed` are truncated to the embedding token cap instead of failing the whole batch. Representation processing reports failed observer saves in `RepresentationCompletedEvent` and raises when every observer save fails (#1019) +- Assistant `reasoning_content` (DeepSeek / some OpenRouter models) is preserved across tool-loop turns. Previously the tool loop dropped thinking content before building the next assistant history message, so continuation requests failed. `reasoning_details` still takes precedence when both are present (#1034) +- `create_observations` now honors `DERIVER_DEDUPLICATE` instead of hardcoding `deduplicate=True`, matching the representation write path (#1018) +- `provider_params.timeout` is forwarded to the OpenAI-compatible embedding client, not just the LLM client (#1024) +- Conclusions semantic-search validation errors name the field and the constraint instead of returning a generic 422 (#960) +- OpenAI-compatible embedding calls request `encoding_format=float` so providers that default to base64 do not break pgvector inserts (#938) +- Gemini batch embedding works for `gemini-embedding-2*` models, which rejected the previous request shape (#745) +- MCP OAuth with no advertised scopes no longer defaults to read-only (which 403'd chat and search POSTs). Protected-resource metadata advertises read and write (#1004) + ## [3.0.12] - 2026-08-10 ### Added diff --git a/README.md b/README.md index 2c3e1d72..7c66cca3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ --- -![Static Badge](https://img.shields.io/badge/Server-3.0.9-blue) +![Static Badge](https://img.shields.io/badge/Server-3.1.0-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) @@ -246,6 +246,7 @@ Peers exchange messages within sessions; Honcho reasons over those messages to b - **Workspace** (formerly App): top-level container; isolates data between use cases. - **Peer** (formerly User): any participant — human user or AI agent. - **Session**: a conversation context; many-to-many with peers. +- **Scope**: a named grouping of sessions that bounds recall (chat, representation, search) to those members. - **Message**: an atomic data unit (peer-to-peer communication or ingested document chunk). What you query out of Honcho: @@ -470,7 +471,7 @@ See the [configuration reference](https://honcho.dev/docs/v3/contributing/config ## Architecture -Honcho splits into two services: **Storage** (workspaces, peers, sessions, messages, internal collections) and **Insights** (reasoning, conclusions, representations, summaries, the chat endpoint). Storage is synchronous via the API; Insights is asynchronous via a background queue consumed by the deriver worker process. +Honcho splits into two services: **Storage** (workspaces, peers, sessions, scopes, messages, internal collections) and **Insights** (reasoning, conclusions, representations, summaries, the chat endpoint). Storage is synchronous via the API; Insights is asynchronous via a background queue consumed by the deriver worker process. **Key features:** @@ -498,16 +499,18 @@ Workspaces │ ├── Sessions │ │ └── (internal collections, keyed by observer/observed peer pair) │ │ +├── Scopes ←─────────────────┤ (many-to-many with sessions) │ │ -└── Sessions ←───────────────┤ (many-to-many) +└── Sessions ←───────────────┤ (many-to-many with peers) ├── Peers ───────────────┘ └── Messages (session-level) ``` **Relationship Details:** -- A **Workspace** contains multiple **Peers**. +- A **Workspace** contains multiple **Peers** and **Scopes**. - **Peers** and **Sessions** have a many-to-many relationship (peers can participate in multiple sessions, sessions can have multiple peers). +- **Scopes** and **Sessions** have a many-to-many relationship (a session can belong to several scopes; a scope groups many sessions). - **Messages** belong to a session and are labelled by their source peer. - **Internal collections** of vector-embedded **documents** are keyed by `(observer, observed)` peer pairs. They are not directly exposed via the API; the observations stored in them are exposed as **Conclusions**. @@ -531,6 +534,28 @@ This unified model enables complex multi-participant interactions. The `Session` object represents a set of interactions between `Peers` within a `Workspace`. Other applications may refer to this as a thread or conversation. Sessions can involve multiple peers with configurable observation settings. +A session can optionally join one or more **Scopes** at creation, or later via +the scopes API. + +#### Scopes + +A `Scope` is a named grouping of sessions inside a `Workspace`. It is a +visibility boundary on recall: chat, representation, session context, and +workspace search answered through a scope see only what happened in that +scope's member sessions. The underlying peers keep their unified +representations across everything they have participated in. + +Developers manage scopes through the scopes API (`honcho.scope(...)` / +`honcho.scopes()`) and an optional `scopes` field on session create — not +through observer/observed configuration. Adding a session that already has +messages copies its existing explicit conclusions into the scope (no +re-derivation); removing one reconciles those copies back out. Query +backfill progress with the scope `status` endpoint. + +A single scope name answers from that scope's collection and card. A list of +scopes restricts recall to the union of their member sessions. Empty scopes +fail closed. `scope` is mutually exclusive with `session` / `filters` on the +same read. #### Messages diff --git a/docs/changelog/compatibility-guide.mdx b/docs/changelog/compatibility-guide.mdx index 42b2f373..abdd1361 100644 --- a/docs/changelog/compatibility-guide.mdx +++ b/docs/changelog/compatibility-guide.mdx @@ -10,14 +10,14 @@ This guide helps you match the right SDK version to your Honcho API version. New - **Latest:** v2.1.2 + **Latest:** v2.4.0 ```bash npm install @honcho-ai/sdk ``` - **Latest:** v2.1.2 + **Latest:** v2.4.0 ```bash pip install honcho-ai @@ -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.11 (Current) | v2.1.2 | v2.1.2 | +| v3.1.0 (Current) | v2.4.0 | v2.4.0 | +| v3.0.12 | v2.3.0 | v2.3.0 | +| v3.0.11 | 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 | diff --git a/docs/changelog/introduction.mdx b/docs/changelog/introduction.mdx index 356c1eb5..15003c7d 100644 --- a/docs/changelog/introduction.mdx +++ b/docs/changelog/introduction.mdx @@ -27,7 +27,45 @@ Welcome to the Honcho changelog! This section documents all notable changes to t ### Honcho API and SDK Changelogs - + + ### Added + + - Scopes: a named grouping of sessions that acts as a visibility boundary on recall, implemented as a facade over an observer peer (`scope.{name}` with `{"kind": "scope"}`). Developers manage them exclusively through `/v3/workspaces/{workspace_id}/scopes` (create-or-get, list, get, add/list/remove session membership) and an optional `scopes` field on session create — never through the observer/observed mechanics. Scope peers cannot author messages, cannot be a chat or representation `target`, are excluded from `peers.list` by default (`PeerGet.kind` = `"scope"` / `"all"` switches the view), and are rejected on the generic session-peer routes. Workspace-level key required; peer- and session-scoped keys get 401. Legacy peers occupying a reserved `scope.` name without the kind flag are refused with 409, never adopted (#884) + - `scope` read option on chat, representation, session context, and workspace search. A single scope swaps the observer to the backing scope peer so conclusion recall, peer cards, and message tools stay inside that scope's membership. A list of scopes takes the union of member sessions (capped at `MAX_SESSION_ALLOWLIST_ENTRIES`) and executes via the session-allowlist path. Empty scopes fail closed. `scope` is mutually exclusive with `filters` and `session_id`. Workspace- or admin-level key required (403 otherwise). Scope peers are also rejected as `peer_target` / `peer_perspective` on session context and as the path peer or `target` on `GET /peers/{id}/context` (#897) + - Scope backfill-by-copy and removal reconciliation. Adding a session that already has messages copies its explicit-level documents into the scope's collections (no LLM re-derivation; idempotent via `copied_from`). Removing a session soft-deletes those copies and fail-closed cascades to derived documents whose `source_ids` intersect anything removed, then enqueues a `card_refresh` dream with `rebuild=True` plus an omni dream. `GET /v3/workspaces/{workspace_id}/scopes/{scope_id}/status` reports per-session backfill state (`pending` / `completed` / `failed`, plus `docs_copied`) (#904) + - Workspace-level chat at `POST /v3/workspaces/{workspace_id}/chat`: agentic dialectic over the whole workspace instead of a single (observer, observed) pair. Prefetches workspace stats and the top active peers' self cards, then searches pair-scoped memory with `[observer->observed]` attribution. Supports `session_id`, `scope`, `reasoning_level`, `response_format`, and SSE streaming (#931) + - MCP workspace discovery: tools accept `workspace_id`, the worker honors an optional `X-Honcho-Workspace-ID` connection header, and `list_workspace` / `create_workspace` tools let clients pick or create a workspace instead of relying on the SDK default (#1020) + - MCP `search` also queries conclusions in parallel with messages when `peer_id` is given, returning `{messages, conclusions}`. The conclusions leg degrades to `[]` on error so search never gets worse than before (#974) + - Prometheus metrics for physical DB connections, visible even under `DB_POOL_CLASS=null`: `db_connections_open` (gauge) and `db_connections_established` (counter), hooked to SQLAlchemy connection-lifecycle events and registered on both the API and the deriver (#1055) + - Bounded-label Prometheus series are zero-initialized at process start so an absent series means a broken scrape rather than "nothing happened" (#927) + + ### Changed + + - Workspace and pair chat system prompts now describe Honcho, peers, and the harness on their own terms, and render only the tools the request actually offers. The pair prompt no longer advertises a write tool that is not in the loadout (#1066) + - Deriver idle polling backoff is longer and no longer reset by periodic reconciler work, so downstream connection pools can cull idle DB connections (#1015) + - LLM provider SDKs are lazy-loaded so idle API and deriver processes no longer pay for every provider at import time (#1011) + - Production image is a multi-stage build: LanceDB/PyArrow move behind an optional `lancedb` extra (`INSTALL_LANCEDB=true` to restore them), FastAPI's unused cloud CLI is dropped, and the venv is copied into the runtime image with final ownership so Docker does not double the layer. Default unpacked image is about 663 MB (was 1.7 GB) (#1014) + - Redis Cluster cache keys hash-tag the namespace so one deployment's keys land on a single shard instead of opening a connection to every node. No behaviour change on a non-cluster backend; existing keys age out by TTL (#1058) + - Deriver extraction prompt no longer leaks its own few-shot examples into extracted conclusions (#1028) + + ### Fixed + + - Observer-scoped `get_observation_context` no longer materializes every session the observer has ever joined into a `session_name IN (...)` list (twice in one statement). Past ~32k sessions that hit psycopg's bind-parameter ceiling and 500'd. The observer half is now a correlated `EXISTS` over `session_peers`, two bind parameters regardless of membership size (#1065) + - Re-adding an already-active session peer no longer advances `joined_at`, so `peer_perspective` search keeps messages from the original join. Genuine leave-and-rejoin still starts a new window (#1059) + - Transient embedding-provider errors (for example an OpenAI-compatible 200 with empty `data: []`) were relabeled as token-limit errors. Only genuine oversize input raises `EmbeddingTokenLimitError`; other provider errors propagate unchanged (#791) + - The filter DSL now fails closed with a 422 instead of a 500 on bad shapes, coerces operands by column type (so `{"session_id": {"ne": "abc"}}` is a string inequality rather than "invalid numeric"), and treats `NOT` / `ne` as null-safe (`IS NOT TRUE` / `IS DISTINCT FROM`) so negation no longer drops rows whose field is unset. Closed-set columns like `level` reject unknown values. Session-allowlist entries must be well-formed ids (`*` is 422, not a silent widen) (#947) + - `ne` on JSONB metadata keys is null-safe: a missing key is not equal to the compared value, so `{"metadata": {"foo": {"ne": "bar"}}}` includes rows where `foo` is unset (#1036) + - Oversized texts in `simple_batch_embed` are truncated to the embedding token cap instead of failing the whole batch. Representation processing reports failed observer saves in `RepresentationCompletedEvent` and raises when every observer save fails (#1019) + - Assistant `reasoning_content` (DeepSeek / some OpenRouter models) is preserved across tool-loop turns. Previously the tool loop dropped thinking content before building the next assistant history message, so continuation requests failed. `reasoning_details` still takes precedence when both are present (#1034) + - `create_observations` now honors `DERIVER_DEDUPLICATE` instead of hardcoding `deduplicate=True`, matching the representation write path (#1018) + - `provider_params.timeout` is forwarded to the OpenAI-compatible embedding client, not just the LLM client (#1024) + - Conclusions semantic-search validation errors name the field and the constraint instead of returning a generic 422 (#960) + - OpenAI-compatible embedding calls request `encoding_format=float` so providers that default to base64 do not break pgvector inserts (#938) + - Gemini batch embedding works for `gemini-embedding-2*` models, which rejected the previous request shape (#745) + - MCP OAuth with no advertised scopes no longer defaults to read-only (which 403'd chat and search POSTs). Protected-resource metadata advertises read and write (#1004) + + + ### Added - Session allowlist on the Dialectic and representation via a constrained `filters` body on `POST /peers/{peer_id}/chat` and `/representation`, supporting only the `session_id` key (a session id, a bare list, or `{"in": [...]}`). Unsupported keys and shapes are rejected with 422 rather than silently ignored, it composes with `session_id` (which must be included in the allowlist when both are given), and it is capped at 1,000 sessions per request. Enforcement is uniform and fail-closed at every recall chokepoint: scoped conclusion recall is restricted to `level == "explicit"` (dream-derived conclusions carry a single `session_name` but are synthesized across all sessions, so that stamp can't be scoped on), `get_reasoning_chain` is unavailable under an allowlist, and an empty allowlist short-circuits to empty results everywhere. Workspace keys pass the allowlist as-given; peer-scoped JWTs must be an active member of every allowlisted session (401 otherwise) (#882) @@ -747,6 +785,17 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [Python SDK](https://pypi.org/project/honcho-ai/) + + ### Added + + - Scopes: `Honcho.scope()` / `HonchoAio.scope()` get-or-create a named visibility boundary, `Honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `Honcho.session(..., scopes=[...])` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+). + - `scope` option on `Peer.chat()` / `chat_stream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`. + - Workspace-level chat: `Honcho.chat()` / `HonchoAio.chat()` and `chat_stream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoning_level`, and `response_format` options as `Peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+). + + ### Changed + + - `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair. + ### Added @@ -915,6 +964,17 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [TypeScript SDK](https://www.npmjs.com/package/@honcho-ai/sdk) + + ### Added + + - Scopes: `honcho.scope()` get-or-creates a named visibility boundary, `honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `honcho.session({ scopes: [...] })` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+). + - `scope` option on `peer.chat()` / `chatStream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`. + - Workspace-level chat: `honcho.chat()` / `honcho.chatStream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoningLevel`, and `responseFormat` options as `peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+). + + ### Changed + + - `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair. + ### Added @@ -1110,6 +1170,16 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [Honcho CLI](https://pypi.org/project/honcho-cli/) + + ### Added + + - `honcho start`, `honcho stop`, and `honcho status` — run a personal Honcho stack in Docker (API, deriver, Postgres, Redis). Profiles live under `~/.honcho/profiles/`. First start pins `ghcr.io/plastic-labs/honcho:latest` by digest and copies the image `config.toml`. Optional `--setup basic` / `--setup advanced` wizard writes LLM overrides to `.env` (#1029) + - `honcho session view` — session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session (#1006) + + ### Fixed + + - `honcho message list --last N` no longer stops at the first page of 50 — it walks pages to fill the requested window (#1006) + ### Added diff --git a/docs/docs.json b/docs/docs.json index 36fef957..b9d0e498 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -24,7 +24,7 @@ "navigation": { "versions": [ { - "version": "v3.0.12", + "version": "v3.1.0", "api": { "openapi": ["v3/openapi.json"] }, diff --git a/docs/v3/openapi.json b/docs/v3/openapi.json index 7fa1d194..43ecc6a4 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.12" + "version": "3.1.0" }, "servers": [ { diff --git a/pyproject.toml b/pyproject.toml index 1a6bbec8..0fe02329 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho" -version = "3.0.12" +version = "3.1.0" description = "Honcho Server" authors = [ {name = "Plastic Labs", email = "hello@plasticlabs.ai"}, diff --git a/sdks/python/CHANGELOG.md b/sdks/python/CHANGELOG.md index 751d9005..7fe5d8f7 100644 --- a/sdks/python/CHANGELOG.md +++ b/sdks/python/CHANGELOG.md @@ -5,6 +5,18 @@ 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.4.0] - 2026-08-25 + +### Added + +- Scopes: `Honcho.scope()` / `HonchoAio.scope()` get-or-create a named visibility boundary, `Honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `Honcho.session(..., scopes=[...])` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+). +- `scope` option on `Peer.chat()` / `chat_stream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`. +- Workspace-level chat: `Honcho.chat()` / `HonchoAio.chat()` and `chat_stream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoning_level`, and `response_format` options as `Peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+). + +### Changed + +- `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair. + ## [2.3.0] - 2026-08-10 ### Added diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index f2d30457..bd4d1f1b 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho-ai" -version = "2.3.0" +version = "2.4.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 8d0e5ec1..1043d547 100644 --- a/sdks/typescript/CHANGELOG.md +++ b/sdks/typescript/CHANGELOG.md @@ -5,6 +5,18 @@ 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.4.0] - 2026-08-25 + +### Added + +- Scopes: `honcho.scope()` get-or-creates a named visibility boundary, `honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `honcho.session({ scopes: [...] })` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+). +- `scope` option on `peer.chat()` / `chatStream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`. +- Workspace-level chat: `honcho.chat()` / `honcho.chatStream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoningLevel`, and `responseFormat` options as `peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+). + +### Changed + +- `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair. + ## [2.3.0] - 2026-08-10 ### Added diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 7db0696c..9aca55f8 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@honcho-ai/sdk", - "version": "2.3.0", + "version": "2.4.0", "description": "Official DX Optimized TypeScript SDK for Honcho", "author": "Plastic Labs ", "license": "Apache-2.0", diff --git a/uv.lock b/uv.lock index 9217e608..c92534ac 100644 --- a/uv.lock +++ b/uv.lock @@ -1024,7 +1024,7 @@ wheels = [ [[package]] name = "honcho" -version = "3.0.12" +version = "3.1.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, @@ -1138,7 +1138,7 @@ dev = [ [[package]] name = "honcho-ai" -version = "2.3.0" +version = "2.4.0" source = { editable = "sdks/python" } dependencies = [ { name = "httpx" }, From b7bcb327385894748224a53fe44c3cb3dea28845 Mon Sep 17 00:00:00 2001 From: Erosika Date: Tue, 25 Aug 2026 16:44:37 -0400 Subject: [PATCH 19/50] feat(docs): load the GTM container on every docs page DEV-2465 step 1. Mintlify injects gtm.js on all docs pages; the container is audited to be inert on /docs before this merges, so the snippet loads and nothing fires. Cookiebot and GA4 arrive later as container publishes, consent first. Merging this publishes the docs within minutes, so it stays unmerged until Marc confirms the container audit. --- docs/docs.json | 74 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index b9d0e498..e0873acb 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -19,14 +19,21 @@ }, "favicon": "/favicon.svg", "contextual": { - "options": ["copy", "view", "chatgpt", "claude"] + "options": [ + "copy", + "view", + "chatgpt", + "claude" + ] }, "navigation": { "versions": [ { "version": "v3.1.0", "api": { - "openapi": ["v3/openapi.json"] + "openapi": [ + "v3/openapi.json" + ] }, "tabs": [ { @@ -90,7 +97,9 @@ "groups": [ { "group": "Overview", - "pages": ["v3/guides/overview"] + "pages": [ + "v3/guides/overview" + ] }, { "group": "Integrations", @@ -130,7 +139,9 @@ }, { "group": "Migrations", - "pages": ["v3/guides/migrations/mem0"] + "pages": [ + "v3/guides/migrations/mem0" + ] } ] }, @@ -160,7 +171,9 @@ "groups": [ { "group": "API Documentation", - "pages": ["v3/api-reference/introduction"] + "pages": [ + "v3/api-reference/introduction" + ] }, { "group": "workspaces", @@ -238,7 +251,9 @@ }, { "group": "miscellaneous", - "pages": ["v3/api-reference/endpoint/keys/create-key"] + "pages": [ + "v3/api-reference/endpoint/keys/create-key" + ] } ] }, @@ -259,7 +274,9 @@ { "version": "v2.5.1", "api": { - "openapi": ["v2/openapi.json"] + "openapi": [ + "v2/openapi.json" + ] }, "tabs": [ { @@ -306,11 +323,15 @@ "groups": [ { "group": "Getting Started", - "pages": ["v2/guides/overview"] + "pages": [ + "v2/guides/overview" + ] }, { "group": "Migrations", - "pages": ["v2/migrations/from-mem0"] + "pages": [ + "v2/migrations/from-mem0" + ] }, { "group": "Integrations", @@ -335,7 +356,9 @@ "groups": [ { "group": "API Documentation", - "pages": ["v2/api-reference/introduction"] + "pages": [ + "v2/api-reference/introduction" + ] }, { "group": "workspaces", @@ -439,7 +462,9 @@ { "version": "v1.1.0", "api": { - "openapi": ["openapi.json"] + "openapi": [ + "openapi.json" + ] }, "tabs": [ { @@ -469,15 +494,23 @@ "groups": [ { "group": "Getting Started", - "pages": ["v1/guides/overview", "v1/guides/streaming-response"] + "pages": [ + "v1/guides/overview", + "v1/guides/streaming-response" + ] }, { "group": "Application Interfaces", - "pages": ["v1/guides/discord", "v1/guides/honcho-mcp"] + "pages": [ + "v1/guides/discord", + "v1/guides/honcho-mcp" + ] }, { "group": "Personal Memory", - "pages": ["v1/guides/dialectic-endpoint"] + "pages": [ + "v1/guides/dialectic-endpoint" + ] } ] }, @@ -486,7 +519,9 @@ "groups": [ { "group": "API Documentation", - "pages": ["v1/api-reference/introduction"] + "pages": [ + "v1/api-reference/introduction" + ] }, { "group": "apps", @@ -534,7 +569,9 @@ }, { "group": "keys", - "pages": ["v1/api-reference/endpoint/keys/create-key"] + "pages": [ + "v1/api-reference/endpoint/keys/create-key" + ] }, { "group": "metamessages", @@ -595,6 +632,9 @@ "integrations": { "posthog": { "apiKey": "phc_1yrzzcgywqXGcerkkI4g7C0YfyPMcAKNOOvGcjTCiUk" + }, + "gtm": { + "tagId": "GTM-NSPT9PJF" } } -} +} \ No newline at end of file From 0b9ae0017009af27e662356407d8ed9c565cbb4c Mon Sep 17 00:00:00 2001 From: Erosika Date: Tue, 25 Aug 2026 16:45:18 -0400 Subject: [PATCH 20/50] feat(docs): PostHog loads only with a granting consent answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEV-2465 open question 5, option d. Mintlify's built-in integration loaded PostHog unconditionally on all 249 docs pages — a visitor who declined on the homepage was tracked one click later in the docs. The integration key comes out of docs.json; docs/posthog-consent.js loads PostHog directly instead, only when the CookieConsent cookie grants Statistics (or holds Cookiebot's -1 marker), and listens for the consent events so a grant on the docs banner itself loads it too. Trade recorded on the ticket: this bypasses the ph.mintlify.com proxy, so ad blockers reduce docs PostHog volume. Verify after deploy that Mintlify's page CSP allows us-assets.i.posthog.com; if it blocks, fall back to option c. --- docs/docs.json | 3 --- docs/posthog-consent.js | 43 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 docs/posthog-consent.js diff --git a/docs/docs.json b/docs/docs.json index e0873acb..b1dad2ba 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -630,9 +630,6 @@ } }, "integrations": { - "posthog": { - "apiKey": "phc_1yrzzcgywqXGcerkkI4g7C0YfyPMcAKNOOvGcjTCiUk" - }, "gtm": { "tagId": "GTM-NSPT9PJF" } diff --git a/docs/posthog-consent.js b/docs/posthog-consent.js new file mode 100644 index 00000000..214c6b1c --- /dev/null +++ b/docs/posthog-consent.js @@ -0,0 +1,43 @@ +// Loads PostHog only when the CookieConsent cookie grants Statistics; the +// cookie is host-scoped, so a landing-page answer covers the docs. +;(function () { + var KEY = 'phc_1yrzzcgywqXGcerkkI4g7C0YfyPMcAKNOOvGcjTCiUk' + var loaded = false + + function granted() { + var m = document.cookie.match(/CookieConsent=([^;]*)/) + if (!m) return false + var v = decodeURIComponent(m[1]) + // "-1" is Cookiebot's consent-not-required marker. + return v === '-1' || /statistics\s*:\s*true/.test(v) + } + + function loadPosthog() { + if (loaded) return + loaded = true + var s = document.createElement('script') + s.src = 'https://us-assets.i.posthog.com/static/array.js' + s.async = true + s.onload = function () { + window.posthog.init(KEY, { + api_host: 'https://us.i.posthog.com', + ui_host: 'https://us.posthog.com', + cross_subdomain_cookie: true, + person_profiles: 'identified_only', + }) + } + document.head.appendChild(s) + } + + if (granted()) { + loadPosthog() + return + } + // A grant made on the docs banner itself (step 2) loads it live. + var events = ['CookiebotOnConsentReady', 'CookiebotOnAccept'] + for (var i = 0; i < events.length; i++) { + window.addEventListener(events[i], function () { + if (granted()) loadPosthog() + }) + } +})() From cccfa988f886ed6ba60dc4bc1338c585907bf223 Mon Sep 17 00:00:00 2001 From: ajspig <46900795+ajspig@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:14:53 -0400 Subject: [PATCH 21/50] Abigail/embedding OpenAI base url (#1068) * fix(cli): write embedding base url in --setup * feat(cli): surface local stack on the welcome screen --- honcho-cli/CHANGELOG.md | 4 ++++ honcho-cli/src/honcho_cli/_help.py | 23 ++++++++++++------- honcho-cli/src/honcho_cli/local/setup.py | 20 ++++++++++------- honcho-cli/tests/test_setup.py | 28 ++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/honcho-cli/CHANGELOG.md b/honcho-cli/CHANGELOG.md index a8c1b12f..1a1b50e3 100644 --- a/honcho-cli/CHANGELOG.md +++ b/honcho-cli/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Fixed + +- `--setup` for openai-compatible writes `EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL` into the profile `.env` alongside `LLM_OPENAI_BASE_URL` + ## [0.1.3] - 2026-08-25 ### Added diff --git a/honcho-cli/src/honcho_cli/_help.py b/honcho-cli/src/honcho_cli/_help.py index 5e8b48be..936e26b7 100644 --- a/honcho-cli/src/honcho_cli/_help.py +++ b/honcho-cli/src/honcho_cli/_help.py @@ -59,16 +59,21 @@ def _welcome_panel(title: str, rows: list[tuple[str, str]]) -> Panel: def print_welcome(console: Console) -> None: - """Render the curated 3-panel welcome (banner + getting started / memory / commands).""" + """Render the curated welcome (banner + getting started / local stack / commands / memory).""" if use_json(): return console.print(f"[bold {BRAND}]{BANNER}[/bold {BRAND}]") console.print(f" [dim]v{__version__}[/dim]\n", highlight=False) start_rows = [ - ("honcho init", "configure API key and server URL"), - ("honcho start", "run a local Honcho stack (Docker)"), - ("honcho doctor", "verify connection and workspace health"), + ("honcho init", "configure API key and server URL"), + ("honcho start [--setup basic]", "run a local Honcho stack (Docker)"), + ("honcho doctor", "verify connection and workspace health"), + ] + stack_rows = [ + ("honcho start / status / stop", "lifecycle for the local Docker stack"), + ("honcho start --setup basic", "interactive LLM + feature wizard"), + ("HONCHO_BASE_URL=http://127.0.0.1:8000", "prefix any command — CLI stays on api.honcho.dev until you set this"), ] cmd_rows = [ ("[dim]pattern[/dim]", r"[dim]honcho \[args] \[-w workspace] \[-p peer] \[-s session][/dim]"), @@ -85,14 +90,15 @@ def print_welcome(console: Console) -> None: ("config", "inspect current configuration"), ] memory_rows = [ - ("honcho peer chat \"...\" -p -w ","query the Dialectic about a peer"), - ("honcho peer inspect -p -w ","dashboard: peer card + recent conclusions + configuration"), + ("honcho peer chat \"...\" -p -w ", "query the Dialectic about a peer"), + ("honcho peer inspect -p -w ", "dashboard: peer card + recent conclusions + configuration"), ("honcho peer representation -p -w ", "global peer representation"), ("honcho peer representation -p -w -s ", "session-scoped peer representation"), ("honcho peer card -p -w ", "synthesized identity: traits, preferences, instructions"), - ("honcho conclusion list -p -w ", "browse peer conclusions"), + ("honcho conclusion list -p -w ", "browse peer conclusions"), + ("honcho session view / context -s ", "transcript, or what an agent would see"), + ("honcho workspace queue-status", "is the deriver processing?"), ] - option_rows = [ ("-w / --workspace", "scope to a workspace"), ("-p / --peer", "scope to a peer"), @@ -102,6 +108,7 @@ def print_welcome(console: Console) -> None: ] console.print(_welcome_panel("getting started", start_rows)) + console.print(_welcome_panel("local stack", stack_rows)) console.print(_welcome_panel("commands", cmd_rows)) console.print(_welcome_panel("memory", memory_rows)) console.print(_welcome_panel("options", option_rows)) diff --git a/honcho-cli/src/honcho_cli/local/setup.py b/honcho-cli/src/honcho_cli/local/setup.py index 5b21728e..242f47d6 100644 --- a/honcho-cli/src/honcho_cli/local/setup.py +++ b/honcho-cli/src/honcho_cli/local/setup.py @@ -14,11 +14,7 @@ from pathlib import Path import typer from rich.console import Console -from honcho_cli.local.env import ( - is_placeholder_key, - read_env_file, - settings_from_environ, -) +from honcho_cli.local.env import is_placeholder_key, read_env_file, settings_from_environ from honcho_cli.output import print_error SETUP_MODES = ("basic", "advanced") @@ -145,6 +141,10 @@ def answers_to_env(answers: SetupAnswers) -> dict[str, str]: env[_PROVIDER_KEY_ENV[answers.provider]] = answers.api_key if answers.base_url: env["LLM_OPENAI_BASE_URL"] = answers.base_url + # Embeddings do not inherit this URL; write it so OpenRouter/vLLM + # keys are not sent to api.openai.com. + if (answers.embedding_transport or "openai") == "openai": + env["EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL"] = answers.base_url if answers.embedding_api_key and answers.embedding_key_transport: embed_key = ( @@ -188,9 +188,13 @@ def answers_to_env(answers: SetupAnswers) -> dict[str, str]: def answers_drop_keys(answers: SetupAnswers) -> tuple[str, ...]: """Keys to remove so a previous wizard run cannot leak into this one.""" - if answers.provider == "openai-compatible": - return () - return ("LLM_OPENAI_BASE_URL",) + drop: list[str] = [] + if answers.provider != "openai-compatible": + drop.append("LLM_OPENAI_BASE_URL") + embed_openai = (answers.embedding_transport or "openai") == "openai" + if answers.provider != "openai-compatible" or not embed_openai: + drop.append("EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL") + return tuple(drop) def run_setup( diff --git a/honcho-cli/tests/test_setup.py b/honcho-cli/tests/test_setup.py index 5a411946..6fa6ddad 100644 --- a/honcho-cli/tests/test_setup.py +++ b/honcho-cli/tests/test_setup.py @@ -5,6 +5,7 @@ from __future__ import annotations from honcho_cli.local.setup import ( DIALECTIC_LEVELS, SetupAnswers, + answers_drop_keys, answers_to_env, chat_model_default, load_toml_setup_defaults, @@ -46,6 +47,33 @@ def test_basic_anthropic_keeps_openai_embeddings_default(): assert "EMBEDDING_MODEL_CONFIG__TRANSPORT" not in env +def test_openai_compatible_copies_base_url_to_embeddings(): + env = answers_to_env( + SetupAnswers( + mode="basic", + provider="openai-compatible", + api_key="sk-or-test", + chat_model="gpt-test", + base_url="https://openrouter.ai/api/v1", + ) + ) + assert env["LLM_OPENAI_BASE_URL"] == "https://openrouter.ai/api/v1" + assert ( + env["EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL"] + == "https://openrouter.ai/api/v1" + ) + + +def test_leaving_openai_compatible_drops_proxy_urls(): + dropped = answers_drop_keys( + SetupAnswers( + mode="basic", provider="openai", api_key="sk", chat_model="gpt-test" + ) + ) + assert "LLM_OPENAI_BASE_URL" in dropped + assert "EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL" in dropped + + def test_chat_default_comes_from_image_toml(tmp_path): path = tmp_path / "config.toml" path.write_text( From 9380bf2753b0001cee6bea34c95896b5bda56fc2 Mon Sep 17 00:00:00 2001 From: Rajat Ahuja Date: Tue, 25 Aug 2026 17:35:04 -0400 Subject: [PATCH 22/50] fix(docker): ship pyproject.toml in the runtime image (#1074) The runtime stage copies application code but not pyproject.toml, so src/_version.py cannot find the file it reads the version from. The image also installs dependencies with --no-install-project, so there is no honcho distribution for the importlib.metadata fallback to find. Both lookups fail, so the service falls back to reporting its version as "unknown" in the OpenAPI schema and in telemetry events. Copying the file into the runtime stage restores an accurate version. The file is under 4 KB, so the image size is unchanged. Co-authored-by: Claude Opus 5 (1M context) --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 8fd9be47..00ce1485 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,6 +63,9 @@ COPY --chown=app:app migrations/ /app/migrations/ COPY --chown=app:app scripts/ /app/scripts/ COPY --chown=app:app docker/ /app/docker/ COPY --chown=app:app alembic.ini /app/alembic.ini +# src/_version.py reads the service version from here at runtime, so this +# is a runtime input as well as a build input. +COPY --chown=app:app pyproject.toml /app/pyproject.toml # Copy config files - this will copy config.toml if it exists, and config.toml.example COPY --chown=app:app config.toml* /app/ From b5d1a1ae540774e78a48cd66269c9a2cb5719c03 Mon Sep 17 00:00:00 2001 From: Erosika Date: Wed, 26 Aug 2026 10:25:26 -0400 Subject: [PATCH 23/50] fix(docs): loader survives a failed fetch and honors withdrawal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on #1071. The cookie match now requires the exact CookieConsent name boundary. A failed array.js request resets the loaded flag so later consent events retry. And consent events now run a full sync: withdrawal opts an already running instance out, and a re-grant opts it back in — same behavior as the landing site's gate. --- docs/posthog-consent.js | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/posthog-consent.js b/docs/posthog-consent.js index 214c6b1c..3e93da82 100644 --- a/docs/posthog-consent.js +++ b/docs/posthog-consent.js @@ -5,7 +5,7 @@ var loaded = false function granted() { - var m = document.cookie.match(/CookieConsent=([^;]*)/) + var m = document.cookie.match(/(?:^|;\s*)CookieConsent=([^;]*)/) if (!m) return false var v = decodeURIComponent(m[1]) // "-1" is Cookiebot's consent-not-required marker. @@ -18,6 +18,9 @@ var s = document.createElement('script') s.src = 'https://us-assets.i.posthog.com/static/array.js' s.async = true + s.onerror = function () { + loaded = false + } s.onload = function () { window.posthog.init(KEY, { api_host: 'https://us.i.posthog.com', @@ -29,15 +32,32 @@ document.head.appendChild(s) } - if (granted()) { - loadPosthog() - return + function sync() { + if (granted()) { + if (!loaded) { + loadPosthog() + } else if ( + window.posthog && + window.posthog.has_opted_out_capturing && + window.posthog.has_opted_out_capturing() + ) { + window.posthog.opt_in_capturing() + } + return + } + // Withdrawal mid-session: an already running instance must stop. + if (loaded && window.posthog && window.posthog.opt_out_capturing) { + window.posthog.opt_out_capturing() + } } - // A grant made on the docs banner itself (step 2) loads it live. - var events = ['CookiebotOnConsentReady', 'CookiebotOnAccept'] + + sync() + var events = [ + 'CookiebotOnConsentReady', + 'CookiebotOnAccept', + 'CookiebotOnDecline', + ] for (var i = 0; i < events.length; i++) { - window.addEventListener(events[i], function () { - if (granted()) loadPosthog() - }) + window.addEventListener(events[i], sync) } })() From d04f622317259ad017dc4777a27ffae46f7f36fb Mon Sep 17 00:00:00 2001 From: ajspig <46900795+ajspig@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:39:58 -0400 Subject: [PATCH 24/50] docs: adding honcho start (#1073) * docs: make honcho start the documented local path honcho-cli 0.1.3 can run a personal stack without cloning the repo; point the README, self-hosting, and CLI reference at that, and drop the community installer callouts. Co-authored-by: Cursor * docs: updating with new CLI language * docs: drop compatibility-guide changes from this PR Leave that file on main; CLI version cards are updated at release time. Co-authored-by: Cursor * docs: small language changes --------- Co-authored-by: Cursor --- CONTRIBUTING.md | 5 ++- README.md | 37 +++++++++++++++---- SECURITY.md | 2 +- docs/v3/contributing/guidelines.mdx | 6 ++- docs/v3/contributing/self-hosting.mdx | 29 +++++++++++---- docs/v3/contributing/troubleshooting.mdx | 28 +++++++++++++- .../documentation/introduction/overview.mdx | 3 ++ .../documentation/introduction/quickstart.mdx | 4 +- .../documentation/introduction/vibecoding.mdx | 13 ++++--- docs/v3/documentation/reference/cli.mdx | 28 ++++++++++---- docs/v3/guides/integrations/hermes.mdx | 4 -- honcho-cli/README.md | 15 ++++---- 12 files changed, 127 insertions(+), 47 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b131f70..b3e00a27 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -200,8 +200,9 @@ conventions, and is worth skimming even if you are not using an agent. ## Local setup -Get a stack running first — [Self-hosting in the README](./README.md#self-hosting) covers -both the Docker path and a manual Postgres setup. Then, for development: +To run a personal instance, install the CLI (`uv tool install honcho-cli`) and then run `honcho start --setup` (Docker + an LLM provider key — not the Honcho API key from `honcho init`) — [CLI in the README](./README.md#cli). + +To **develop this repo**, clone it and: ```bash uv sync # create the venv and install dependencies diff --git a/README.md b/README.md index 7c66cca3..2d354c66 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,12 @@ ![Static Badge](https://img.shields.io/badge/Server-3.1.0-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) +[![CLI](https://img.shields.io/pypi/v/honcho-cli.svg?label=honcho-cli)](https://pypi.org/project/honcho-cli/) [![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/honcho) **Honcho is memory infrastructure for building stateful agents that understand changing people, agents, groups, projects, and ideas over time.** -Store messages and events, let Honcho reason in the background, then query peer representations, session context, search results, or natural-language insights from any model or framework. Use it managed at [api.honcho.dev](https://api.honcho.dev) or self-host the FastAPI server yourself. +Store messages and events, let Honcho reason in the background, then query peer representations, session context, search results, or natural-language insights from any model or framework. Use it managed at [api.honcho.dev](https://api.honcho.dev), run a local stack with [`honcho start`](#cli), or self-host the FastAPI server yourself. Using Honcho as your memory system will earn your agents higher retention, more trust, and help you build data moats to out-compete incumbents. @@ -29,6 +30,7 @@ Using Honcho as your memory system will earn your agents higher retention, more - [Quickstart](#quickstart) - [What Honcho Gives You](#what-honcho-gives-you) - [Integrations](#integrations) +- [CLI](#cli) - [Core Concepts](#core-concepts) - [Benchmarks & Evals](#benchmarks--evals) - [Self-hosting](#self-hosting) @@ -39,7 +41,7 @@ Using Honcho as your memory system will earn your agents higher retention, more - [Contributing](#contributing) - [License](#license) -The Honcho project is split between several repositories, with this one hosting the core service logic — implemented as a FastAPI server. Client SDKs for Python and TypeScript live in the [`sdks/`](./sdks) directory. +The Honcho project is split between several repositories, with this one hosting the core service logic — implemented as a FastAPI server. Client SDKs for Python and TypeScript live in the [`sdks/`](./sdks) directory. The [`honcho-cli`](./honcho-cli) package lives here too. ## Start Here @@ -47,7 +49,9 @@ The Honcho project is split between several repositories, with this one hosting | -------------------------------------- | ---------------------------------------------------------- | ----------------------------- | | Give my coding agent persistent memory | Claude Code, OpenCode, OpenClaw, Hermes, or any MCP client | [Integrations](#integrations) | | Add memory to my product | Python or TypeScript SDK | [Quickstart](#quickstart) | -| Self-host Honcho | Docker / local development | [Self-hosting](#self-hosting) | +| Run Honcho locally | Install CLI, then `honcho start --setup` | [CLI](#cli) | +| Inspect a deployment | `honcho workspace inspect`, `honcho doctor` | [CLI](#cli) | +| Self-host from source | Docker Compose or local development | [Self-hosting](#self-hosting) | ## Why Honcho @@ -56,7 +60,7 @@ The Honcho project is split between several repositories, with this one hosting | Reasoning-first memory | Extracts conclusions from conversations and events, not just matching chunks. | | Peer-centric model | Tracks users, agents, groups, projects, and ideas as entities that change over time. | | Multi-peer perspective | Models what one peer knows about another when configured. | -| Managed or self-hosted | Use `api.honcho.dev` or run the FastAPI server yourself. | +| Managed or self-hosted | Use `api.honcho.dev`, `honcho start` locally, or run the FastAPI server yourself. | | Agent-tool integrations | MCP, Claude Code, OpenCode, OpenClaw, Hermes, Cursor-compatible clients. | ## The Honcho Loop @@ -70,7 +74,7 @@ Concretely: workspaces hold peers, peers participate in sessions, messages live ## Quickstart -Get an API key at [app.honcho.dev](https://app.honcho.dev) — when you sign up you'll be prompted to join an organization, which gets its own dedicated Honcho instance and $100 free credits. Or [self-host](#self-hosting) and run against `http://localhost:8000`. +Get an API key at [app.honcho.dev](https://app.honcho.dev) — when you sign up you'll be prompted to join an organization, which gets its own dedicated Honcho instance and $100 free credits. Or install the CLI and run [`honcho start --setup`](#cli), then point the SDK at `http://localhost:8000`. ### Python @@ -226,12 +230,27 @@ For wiring the Honcho SDK into an existing application, install the integration npx skills add plastic-labs/honcho ``` -Then invoke `/honcho-integration` in Claude Code (or `/honcho-dev:integrate` via the plugin marketplace). The same command also installs the memory skills — `honcho-memory` (concepts: the recall/record loop, session and peer strategy, plus how to connect and drive an MCP-connected Honcho) and `honcho-cli` (inspecting and debugging a deployment). Details: [agentic development guide](https://honcho.dev/docs/v3/documentation/introduction/vibecoding). +Then invoke `/honcho-integration` in Claude Code (or `/honcho-dev:integrate` via the plugin marketplace). The same command also installs the memory skills — `honcho-memory` (concepts: the recall/record loop, session and peer strategy, plus how to connect and drive an MCP-connected Honcho) and `honcho-cli` (inspecting a deployment, or running a local stack with `honcho start`). Details: [agentic development guide](https://honcho.dev/docs/v3/documentation/introduction/vibecoding). ### Other MCP clients The same `claude mcp add` form (or its client-specific equivalent) works in any MCP-compatible client. See [MCP guide](https://honcho.dev/docs/v3/guides/integrations/mcp). +## CLI + +[`honcho-cli`](https://pypi.org/project/honcho-cli/) inspects a Honcho deployment from the terminal, or runs a personal local stack with Docker. + +```bash +uv tool install honcho-cli +honcho init # Honcho API key or browser login + server URL +honcho start --setup basic # local stack: LLM provider key + Docker +honcho doctor +``` + +`honcho init` authenticates the CLI against a Honcho server. `honcho start --setup` is a separate step: it writes the LLM provider key the local deriver needs and starts API + deriver + Postgres + Redis. + +Full commands and local-stack details: [CLI reference](https://honcho.dev/docs/v3/documentation/reference/cli) · [`honcho-cli/README.md`](./honcho-cli/README.md). To develop the server from source, see [Self-hosting](#self-hosting). + ## Core Concepts Honcho organises everything around **peers** — humans and AI agents alike are first-class entities. The peer model enables: @@ -275,9 +294,9 @@ Honcho's evals span LongMemEval, LoCoMo, and other long-conversation benchmarks. ## Self-hosting -Honcho is open source under AGPL-3.0. You can run the full server locally with Docker, then point the SDKs at `http://localhost:8000`. +Honcho is open source under AGPL-3.0. To **run** a personal instance, install the CLI (`uv tool install honcho-cli`) and then [`honcho start --setup`](#cli). The paths below are for building from source, contributing, or deploying without the CLI. -### Quick start (Docker) +### Quick start (from source, Docker) ```bash git clone https://github.com/plastic-labs/honcho.git @@ -633,6 +652,7 @@ For low-latency use cases, Honcho provides access to a `representation` endpoint - **Python** — [`honcho-ai`](https://pypi.org/project/honcho-ai/) on PyPI · source in [`sdks/python/`](./sdks/python) - **TypeScript** — [`@honcho-ai/sdk`](https://www.npmjs.com/package/@honcho-ai/sdk) on npm · source in [`sdks/typescript/`](./sdks/typescript) +- **CLI** — [`honcho-cli`](https://pypi.org/project/honcho-cli/) on PyPI · source in [`honcho-cli/`](./honcho-cli) · [CLI reference](https://honcho.dev/docs/v3/documentation/reference/cli) SDKs are versioned independently of the server. Current SDK versions track each other; the server badge above reflects the deployed server version. @@ -641,6 +661,7 @@ See the [SDK Reference](https://honcho.dev/docs/v3/documentation/reference/sdk) ## Learn More - [Developer documentation](https://honcho.dev/docs/) — full API surface, guides, integrations. +- [CLI reference](https://honcho.dev/docs/v3/documentation/reference/cli) — local stack, inspect/debug commands, scripting. - [Plastic Labs blog](https://blog.plasticlabs.ai/) — design philosophy and history of the project. ## Contributing diff --git a/SECURITY.md b/SECURITY.md index 1b71cb45..18d0ebfb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,7 +33,7 @@ one. Test against an instance you operate. Do not run security testing against `api.honcho.dev` or against any Honcho deployment that is not yours — self-hosting is a first-class path and -takes a few minutes to set up, see [Self-hosting](./README.md#self-hosting). +takes a few minutes to set up — install the CLI (`uv tool install honcho-cli`) then run `honcho start --setup` (Docker + an LLM provider key), or see [Self-hosting](./README.md#self-hosting). ## What to Expect diff --git a/docs/v3/contributing/guidelines.mdx b/docs/v3/contributing/guidelines.mdx index b98bd5c9..92c42fcf 100644 --- a/docs/v3/contributing/guidelines.mdx +++ b/docs/v3/contributing/guidelines.mdx @@ -189,8 +189,10 @@ conventions, and is worth skimming even if you are not using an agent. ## Local setup -Get a stack running first — [Self-hosting](/v3/contributing/self-hosting) covers -both the Docker path and a manual Postgres setup. Then, for development: +To run a personal instance, install the CLI (`uv tool install honcho-cli`) and then run +`honcho start --setup` (Docker + an LLM provider key) — [CLI reference](/v3/documentation/reference/cli). + +To **develop this repo**, clone it and: ```bash uv sync # create the venv and install dependencies diff --git a/docs/v3/contributing/self-hosting.mdx b/docs/v3/contributing/self-hosting.mdx index 41a439f2..02d361f1 100644 --- a/docs/v3/contributing/self-hosting.mdx +++ b/docs/v3/contributing/self-hosting.mdx @@ -7,6 +7,8 @@ icon: 'computer' This guide helps you set up a local environment to run Honcho for development, testing, or self-hosting. +**Just want a running instance?** `uv tool install honcho-cli` only installs the `honcho` command. Then run [`honcho start --setup`](/v3/documentation/reference/cli#local-stack) (Docker + an LLM provider key) — that pulls a published image and starts API, deriver, Postgres, and Redis. The rest of this page is for building from source, contributing, or deploying without the CLI. + ## Overview By the end of this guide, you'll have: @@ -22,7 +24,7 @@ Before you begin, ensure you have the following installed: ### Required Software - **uv** - Python package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh` or `brew install uv` - **Git** - [Download from git-scm.com](https://git-scm.com/downloads) -- **Docker** (required for Docker setup, not needed for manual setup) - [Download from docker.com](https://www.docker.com/products/docker-desktop/) +- **Docker** - required for the CLI local stack and the compose-from-source path; not needed for a fully manual setup. [Download from docker.com](https://www.docker.com/products/docker-desktop/) ### Database Options You'll need a PostgreSQL database with the pgvector extension. Choose one: @@ -59,13 +61,22 @@ DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1 For recommended model tiers per feature, using multiple providers, or direct vendor API keys, see the [Configuration Guide](./configuration#llm-configuration). - -**Community quick-start**: [elkimek/honcho-self-hosted](https://github.com/elkimek/honcho-self-hosted) provides a one-command installer with pre-configured model tiers, interactive provider setup, and Hermes Agent integration. - +## Personal local stack (CLI) -## Docker Setup (Recommended) +Recommended if you want Honcho running locally without cloning this repo or building an image. Install the CLI, then run the setup wizard: -Docker Compose handles the database, Redis, and Honcho server. The compose file **builds the image from source** (there is no pre-built image on Docker Hub). This requires Docker with BuildKit enabled — see [Troubleshooting](./troubleshooting#docker-build-fails-with-permission-errors) if the build fails. +```bash +uv tool install honcho-cli +honcho start --setup basic # prompts for LLM provider + key, then starts Docker +``` + +`honcho start` pulls `ghcr.io/plastic-labs/honcho:latest`, pins that digest, and starts API + deriver + Postgres + Redis. Stack files live under `~/.honcho/profiles/local/`. It does **not** rewrite `environmentUrl` in `~/.honcho/config.json` (that file is shared with plugins). Talk to the stack with `HONCHO_BASE_URL=http://127.0.0.1:8000`, or run `honcho init --base-url http://127.0.0.1:8000` to persist local as the CLI default. + +See the [CLI reference](/v3/documentation/reference/cli#local-stack) for `--setup`, profiles, `--image`, ports, `honcho status` / `stop`, and pointing the CLI at local. + +## From source (Docker Compose) + +Docker Compose in this repo handles the database, Redis, and Honcho server. The compose file **builds the image from source** so you can develop against local code. A pre-built image is published at `ghcr.io/plastic-labs/honcho:latest` (what `honcho start` uses); it is not on Docker Hub. Building from source requires Docker with BuildKit enabled — see [Troubleshooting](./troubleshooting#docker-build-fails-with-permission-errors) if the build fails. The compose file is production-oriented by default (ports bound to `127.0.0.1`, restart policies, caching enabled). For development, uncomment the source mounts and monitoring services inside the file. @@ -321,6 +332,7 @@ const client = new Honcho({ ### Next Steps - **Configure Honcho**: Visit the [Configuration Guide](./configuration) for model tiers, provider options, and tuning +- **Use the CLI**: install with `uv tool install honcho-cli`, then [`honcho start --setup`](/v3/documentation/reference/cli#local-stack) for a local stack; inspect with `honcho workspace inspect` / `honcho doctor` - **Explore the API**: Check out the [API Reference](../api-reference/introduction) - **Try the SDKs**: See our [guides](../guides) for examples - **Join the community**: [Discord](https://discord.gg/honcho) @@ -334,11 +346,12 @@ Running into issues? See the [Troubleshooting Guide](./troubleshooting) for deta - Deriver not processing messages - Database connection and migration issues - Docker and Redis problems +- CLI local stack (`honcho start`) — missing LLM key, health timeout, still talking to api.honcho.dev **Quick checks:** - Verify the server is running: `curl http://localhost:8000/health` -- Check logs: `docker compose logs api` (Docker) or check terminal output (manual setup) -- Ensure migrations ran: `uv run alembic upgrade head` +- Check logs: `docker compose logs api` (from-source Docker), `docker compose -p honcho-local logs` (`honcho start`), or terminal output (manual setup) +- Ensure migrations ran: `uv run alembic upgrade head` (from-source only; `honcho start` runs them in the image entrypoint) ## Production Considerations diff --git a/docs/v3/contributing/troubleshooting.mdx b/docs/v3/contributing/troubleshooting.mdx index ffb73417..e40917ab 100644 --- a/docs/v3/contributing/troubleshooting.mdx +++ b/docs/v3/contributing/troubleshooting.mdx @@ -316,11 +316,37 @@ docker compose build --no-cache docker compose up -d ``` +## CLI local stack (`honcho start`) + +The CLI local stack lives under `~/.honcho/profiles/` (default profile `local`) and uses the published image `ghcr.io/plastic-labs/honcho:latest`. Logs: `docker compose -p honcho-local logs`. Full flags: [CLI reference](/v3/documentation/reference/cli#local-stack). + +### `MISSING_LLM_KEY` + +**Cause:** No provider key in the environment or the profile `.env`. + +**Fix:** Export `LLM_OPENAI_API_KEY`, `LLM_ANTHROPIC_API_KEY`, or `LLM_GEMINI_API_KEY`, or run `honcho start --setup basic` in a TTY (not with `--json`). + +### Timed out waiting for `/health` + +**Cause:** The API container started but did not become ready within `--timeout` (default 180s). + +**Fix:** Check `docker compose -p honcho-local logs api` and `... logs deriver`. Increase `--timeout`. Confirm Docker is running. A first-time GHCR pull happens *before* this wait (during image pin) — if that step hung, look at Docker pull logs instead. + +### CLI still talks to `api.honcho.dev` + +**Cause:** `honcho start` does not rewrite `environmentUrl` in `~/.honcho/config.json`. + +**Fix:** Prefix commands with `HONCHO_BASE_URL=http://127.0.0.1:8000`, or run `honcho init --base-url http://127.0.0.1:8000` to persist local as the default. `honcho status` prints the one-shot hint. + +### Port already in use + +The CLI remaps 8000/5432/6379 automatically unless you pinned them with `--api-port` / `--db-port` / `--redis-port`. Pass those flags if you need a specific host port. + ## Getting Help If your issue isn't covered here: -- **Check the logs** — most issues are diagnosed from server or deriver logs +- **Check the logs** — most issues are diagnosed from server or deriver logs (`docker compose logs` for from-source compose; `docker compose -p honcho-local logs` for `honcho start`) - **GitHub Issues** — [Report bugs](https://github.com/plastic-labs/honcho/issues) - **Discord** — [Join our community](https://discord.gg/plasticlabs) - **Configuration** — See the [Configuration Guide](./configuration) for all available settings diff --git a/docs/v3/documentation/introduction/overview.mdx b/docs/v3/documentation/introduction/overview.mdx index 29cc2815..bc4ebd9f 100644 --- a/docs/v3/documentation/introduction/overview.mdx +++ b/docs/v3/documentation/introduction/overview.mdx @@ -94,6 +94,9 @@ Welcome to Honcho. We're excited to have you at the frontier of AI with us 🫡. Build your first stateful agent in minutes + + Inspect a deployment, or `honcho start --setup` a local stack + Deep dive into how Honcho's primitives fit together diff --git a/docs/v3/documentation/introduction/quickstart.mdx b/docs/v3/documentation/introduction/quickstart.mdx index 85884a67..d3bbe01b 100644 --- a/docs/v3/documentation/introduction/quickstart.mdx +++ b/docs/v3/documentation/introduction/quickstart.mdx @@ -11,9 +11,11 @@ Let's get started with Honcho. In this quickstart, you will: - Query the reasoning Honcho produces to get synthesized insights about the user -Running the code below requires an API key. Create and account and get your API key at [app.honcho.dev](https://app.honcho.dev) under "API KEYS". +Running the code below requires an API key. Create an account and get your API key at [app.honcho.dev](https://app.honcho.dev) under "API KEYS". Every new tenant gets \$100.00 in free credits on sign up. The code below costs ~\$0.04 to run, so don't worry--still plenty of free credits for iterating. + +To run against a local stack instead, install the CLI (`uv tool install honcho-cli`) and then run `honcho start --setup` (Docker + an LLM provider key). See the [CLI reference](/v3/documentation/reference/cli#local-stack). #### 1. Install the SDK diff --git a/docs/v3/documentation/introduction/vibecoding.mdx b/docs/v3/documentation/introduction/vibecoding.mdx index cd5e8ac6..be2a7362 100644 --- a/docs/v3/documentation/introduction/vibecoding.mdx +++ b/docs/v3/documentation/introduction/vibecoding.mdx @@ -68,16 +68,19 @@ claude mcp add honcho \ ## CLI -Inspect and debug a running Honcho deployment from your terminal. The honcho CLI wraps the Python SDK with agent-friendly defaults — JSON output, structured errors, and commands for every primitive (workspaces, peers, sessions, messages, conclusions). +Inspect and debug a running Honcho deployment from your terminal, or run a personal local stack. The honcho CLI wraps the Python SDK with agent-friendly defaults — JSON output, structured errors, and commands for every primitive (workspaces, peers, sessions, messages, conclusions). **Get started:** ```bash uv tool install honcho-cli -honcho init # configure apiKey + environmentUrl -honcho doctor # verify connectivity +honcho init # Honcho API key / browser login (talk *to* a server) +honcho start --setup basic # local stack: LLM provider key + Docker +honcho doctor # verify connectivity ``` +`honcho start --setup` pulls the published GHCR image — no clone required. It does not rewrite `environmentUrl` in the shared config file; prefix commands with `HONCHO_BASE_URL=http://127.0.0.1:8000` to talk to local. + The CLI also ships an agent skill. Install it with `npx skills add plastic-labs/honcho` and pick `honcho-cli` from the list. See the [full CLI reference](/v3/documentation/reference/cli) for all commands, flags, and environment variables. @@ -147,7 +150,7 @@ Invoke with `/honcho-integration` in your coding agent. #### honcho-cli -**For inspection & debugging.** Teaches your coding agent the right commands and flags for the [honcho CLI](#cli) — peer memory, session context, queue status, dialectic quality. +**For inspection & debugging, and for running a local stack.** Teaches your coding agent the right commands and flags for the [honcho CLI](#cli) — peer memory, session context, queue status, dialectic quality, `honcho start` / `status` / `stop`. Invoke implicitly when you ask your agent to inspect a Honcho deployment. @@ -170,7 +173,7 @@ I want to start building with Honcho - an open source memory library for buildin - Core repo: https://github.com/plastic-labs/honcho - Python SDK: https://github.com/plastic-labs/honcho-python - TypeScript SDK: https://github.com/plastic-labs/honcho-node -- CLI (inspect & debug a deployment): https://github.com/plastic-labs/honcho/tree/main/honcho-cli +- CLI (inspect, debug, or `honcho start` a local stack): https://github.com/plastic-labs/honcho/tree/main/honcho-cli - Discord bot starter: https://github.com/plastic-labs/discord-python-starter - Telegram bot example: https://github.com/plastic-labs/telegram-python-starter diff --git a/docs/v3/documentation/reference/cli.mdx b/docs/v3/documentation/reference/cli.mdx index 5df031d6..71a14305 100644 --- a/docs/v3/documentation/reference/cli.mdx +++ b/docs/v3/documentation/reference/cli.mdx @@ -18,18 +18,24 @@ uvx honcho-cli ``` +This only installs the `honcho` command. It does not start a server. Use `honcho start --setup` (Docker + an LLM provider key) when you want a local stack. + ## Quick Start ```bash -honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json -honcho start # optional: local API + deriver + Postgres + Redis (Docker) -honcho doctor # verify your config + connectivity -honcho # show banner + command list +honcho init # Honcho API key or browser login + server URL (talk *to* Honcho) +honcho start --setup basic # local stack: LLM provider key + Docker (not set by init) +honcho doctor # verify your config + connectivity +honcho # show banner + command list ``` +`honcho init` authenticates the CLI against a Honcho server. It does **not** configure the LLM key a local stack needs — that is `honcho start --setup`. + ## Local stack -`honcho start` runs a personal Honcho server on your machine via Docker (API, deriver, Postgres, Redis). It is not the managed service at `api.honcho.dev`. Deriver and dialectic call your cloud LLM provider (OpenAI, Anthropic, or Gemini) with a key you supply. Stack files live under `~/.honcho/profiles/local/`. The first start writes `config.toml` there from the image; later starts leave that file alone so your edits persist. +`honcho start --setup basic` is the fastest way to run Honcho on your machine. It does **not** require cloning the Honcho repo. The wizard prompts for an LLM provider and API key, writes them into the profile `.env`, pulls `ghcr.io/plastic-labs/honcho:latest`, **pins that digest**, and starts API + deriver + Postgres + Redis via Docker. + +Default profile is `local` (`--profile` / `HONCHO_PROFILE`). First start copies the image `config.toml.example` into the profile directory; later starts leave that file alone so your edits persist — including when you re-pin the image. Delete `config.toml` yourself if you want a fresh copy from a new image. Pass `--image` to pin a different tag or digest. Ports bind to `127.0.0.1`; if 8000/5432/6379 are taken, the CLI remaps them (or pass `--api-port` / `--db-port` / `--redis-port`). Auth is off (`AUTH_USE_AUTH=false`). Pass `--setup basic` or `--setup advanced` for an interactive wizard that writes curated LLM/feature overrides into the profile `.env` (environment variables win over `config.toml`). This is TTY-only. `basic` covers provider and chat model; `advanced` also covers embeddings, deriver/dialectic models, dreams, and deriver flush. Re-running `--setup` while the stack is up recreates the API and deriver containers. @@ -37,16 +43,20 @@ This does **not** change `environmentUrl` in the shared config file. To talk to ```bash HONCHO_BASE_URL=http://127.0.0.1:8000 honcho workspace list +honcho init --base-url http://127.0.0.1:8000 # persist local as the CLI default ``` ```bash -LLM_OPENAI_API_KEY=sk-... honcho start honcho start --setup basic +honcho start --setup advanced +LLM_OPENAI_API_KEY=sk-... honcho start # skip the wizard if the key is already in the env honcho status honcho stop # keep data honcho stop --wipe # also delete volumes ``` +To **develop the server** (live reload, from-source image), see [Local Environment Setup](/v3/contributing/self-hosting). + ## Configuration The CLI resolves config in this order: **flag → env var → config file → default**. @@ -59,12 +69,16 @@ The CLI resolves config in this order: **flag → env var → config file → de | Peer | — | `HONCHO_PEER_ID` | `-p` / `--peer` | No | | Session | — | `HONCHO_SESSION_ID` | `-s` / `--session` | No | | JSON output | — | `HONCHO_JSON` | `--json` | No | +| Local stack | — | `HONCHO_PROFILE` | `--profile` | No | ### Persisted config -The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns only +The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns `apiKey` and `environmentUrl` at the top level — everything else (`hosts`, `sessions`, etc.) is written by other tools and left untouched on save. +On managed servers that advertise the device grant in OAuth metadata, +`honcho init` can log you in via the browser; tokens auto-refresh +and are stored under `oauth` without deleting a shared `apiKey`. ```json { diff --git a/docs/v3/guides/integrations/hermes.mdx b/docs/v3/guides/integrations/hermes.mdx index 9fe46973..d06f650a 100644 --- a/docs/v3/guides/integrations/hermes.mdx +++ b/docs/v3/guides/integrations/hermes.mdx @@ -67,10 +67,6 @@ Or manually create/edit the config file (checked in order: `$HERMES_HOME/honcho. For the full list of config fields (`recallMode`, `writeFrequency`, `sessionStrategy`, `dialecticReasoningLevel`, etc.), see the [Hermes memory provider docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory-providers#honcho). - -**Community quick-start**: [elkimek/honcho-self-hosted](https://github.com/elkimek/honcho-self-hosted) provides a one-command installer with pre-configured model tiers and Hermes Agent integration. - - ## Verifying the integration ### 1. Check status diff --git a/honcho-cli/README.md b/honcho-cli/README.md index f1585a89..06898b5c 100644 --- a/honcho-cli/README.md +++ b/honcho-cli/README.md @@ -22,19 +22,19 @@ uv tool install honcho-cli ## Quick Start ```bash -honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json -honcho start # optional: local API + deriver + Postgres + Redis (Docker) -honcho doctor # verify your config + connectivity -honcho # show banner + command list +honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json +honcho start --setup basic # local stack: LLM provider key + Docker +honcho doctor # verify config + connectivity +honcho # show banner + command list ``` -`honcho init` reads `apiKey` and `environmentUrl` from the top-level of `~/.honcho/config.json` (the same file other Honcho tools — plugins, host integrations — share). If both are present, it confirms them with you; if either is missing (or you decline), it prompts for the missing value(s) and writes them back. Host-specific entries under `hosts` are left untouched. +`honcho init` writes `apiKey` and `environmentUrl` to the top-level of `~/.honcho/config.json` (the same file other Honcho tools — plugins, host integrations — share) so the CLI can call a Honcho server. If both are present, it confirms them with you; if either is missing (or you decline), it prompts and writes them back. Host-specific entries under `hosts` are left untouched. It does **not** set the LLM provider key the local deriver needs — that is `honcho start --setup` (or `LLM_*_API_KEY` in the environment). Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s` flags or `HONCHO_*` env vars — not persisted as CLI defaults. ### Local stack -`honcho start` runs a personal Honcho server on your machine (API, deriver, Postgres, Redis) via Docker. Inference is cloud-side: set `LLM_OPENAI_API_KEY`, `LLM_ANTHROPIC_API_KEY`, or `LLM_GEMINI_API_KEY` (env overrides `config.toml`). Stack files live under `~/.honcho/profiles/local/` and are not committed to a project. +`honcho start --setup basic` runs a personal Honcho server on your machine (API, deriver, Postgres, Redis) via Docker. The wizard writes the LLM provider key into the profile `.env` — `honcho init` cannot do this; its `apiKey` is for calling a Honcho server, not for deriver/dialectic inference. You can also pass `LLM_OPENAI_API_KEY`, `LLM_ANTHROPIC_API_KEY`, or `LLM_GEMINI_API_KEY` in the environment and skip `--setup`. Stack files live under `~/.honcho/profiles/local/` and are not committed to a project. On first start, the CLI pulls `ghcr.io/plastic-labs/honcho:latest` and **pins that digest** in `profile.json`, then copies the image's `config.toml.example` to `config.toml` in the same directory. `honcho start` never overwrites `config.toml` after that — including when you re-pin the image. Delete the file yourself if you want a fresh copy from a new image. @@ -49,7 +49,6 @@ HONCHO_BASE_URL=http://127.0.0.1:8000 honcho workspace list To make local the default, run `honcho init --base-url http://127.0.0.1:8000`. ```bash -LLM_OPENAI_API_KEY=sk-... honcho start honcho start --setup basic honcho start --setup advanced honcho status @@ -63,7 +62,7 @@ honcho stop --wipe # also delete volumes | Command | Description | |---------|-------------| -| `honcho init` | Confirm/set `apiKey` + `environmentUrl` in `~/.honcho/config.json` | +| `honcho init` | Confirm/set `apiKey` + `environmentUrl` in `~/.honcho/config.json`. | | `honcho start` | Start a local Honcho stack (API, deriver, Postgres, Redis). Requires Docker and a cloud LLM key. `--setup basic` / `--setup advanced` runs an interactive config wizard (TTY only). Does not change `environmentUrl`. | | `honcho stop` | Stop the local stack. `--wipe` also deletes volumes. | | `honcho status` | Show every local stack (or `--profile` for one). | From 370232e139c83fc29cbf4f2ca7113924ce846fa7 Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Wed, 26 Aug 2026 12:14:23 -0400 Subject: [PATCH 25/50] fix(ci): exempt issue-gate writers via repo permission (#1081) author_association on the webhook is CONTRIBUTOR when org membership is private, so maintainers with write (e.g. ajspig) were labelled needs-approved-issue. Skip on admin/maintain/write from getCollaboratorPermissionLevel instead; 404 stays gated. --- .github/scripts/issue-gate.js | 38 +++++++++++++++------- .github/scripts/issue-gate.test.js | 52 +++++++++++++++++++++--------- 2 files changed, 63 insertions(+), 27 deletions(-) diff --git a/.github/scripts/issue-gate.js b/.github/scripts/issue-gate.js index c9bbb668..d0ff0d04 100644 --- a/.github/scripts/issue-gate.js +++ b/.github/scripts/issue-gate.js @@ -33,22 +33,36 @@ const isBot = (account) => Boolean(account) && account.type === 'Bot'; * Why this pull request is exempt from the gate, or null if it is not. * * Single source of truth: every caller that acts on a pull request runs this. - * The stale-draft sweep previously re-listed these checks and silently lost the - * bot case. */ -const exemptReason = (pr) => { +const exemptReason = async ({ github, owner, repo, pr }) => { if (isBot(pr.user)) return 'author is a bot'; - if (WRITE_ACCESS.includes(pr.author_association)) { - return `author_association is ${pr.author_association}`; - } if (hasLabel(pr, EXEMPT_LABEL)) return `carries the ${EXEMPT_LABEL} label`; + + const username = pr.user && pr.user.login; + if (!username) return null; + + const permission = await repoPermission({ github, owner, repo, username }); + if (WRITE_PERMISSIONS.includes(permission)) { + return `author has ${permission} permission`; + } return null; }; -// Write access to the repository. CONTRIBUTOR is deliberately absent: GitHub uses -// it for "has previously committed to the repository", which describes every -// returning outside contributor, not a maintainer. Do not add it. -const WRITE_ACCESS = ['OWNER', 'MEMBER', 'COLLABORATOR']; +// Repo roles that skip the gate. `read` / `triage` do not. +const WRITE_PERMISSIONS = ['admin', 'maintain', 'write']; + +/** Highest repo permission for `username`, or null if they are not a collaborator. */ +async function repoPermission({ github, owner, repo, username }) { + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, repo, username, + }); + return data.permission; + } catch (err) { + if (err && err.status === 404) return null; + throw err; + } +} const CLOSING_ISSUES = ` query($owner: String!, $repo: String!, $number: Int!) { @@ -78,7 +92,7 @@ const CLOSING_ISSUES = ` async function checkGate({ github, owner, repo, pr }) { if (pr.state !== 'open') return { passed: true, skipped: 'pull request is not open' }; if (pr.draft) return { passed: true, skipped: 'pull request is a draft' }; - const exempt = exemptReason(pr); + const exempt = await exemptReason({ github, owner, repo, pr }); if (exempt) return { passed: true, skipped: exempt }; const data = await github.graphql(CLOSING_ISSUES, { owner, repo, number: pr.number }); @@ -251,7 +265,7 @@ async function runSweep({ github, core, context, dryRun }) { // Stale drafts. The gate skips drafts entirely, so they never carry the label; // this pass keys off inactivity and applies the shared exemptions itself. for (const pr of prs.filter((p) => p.draft)) { - const exempt = exemptReason(pr); + const exempt = await exemptReason({ github, owner, repo, pr }); if (exempt) { core.info(`#${pr.number}: leaving stale draft alone — ${exempt}`); continue; diff --git a/.github/scripts/issue-gate.test.js b/.github/scripts/issue-gate.test.js index 695df3a2..29989cdc 100644 --- a/.github/scripts/issue-gate.test.js +++ b/.github/scripts/issue-gate.test.js @@ -12,12 +12,18 @@ const { const pull = (over = {}) => ({ number: 1, state: 'open', draft: false, - user: { type: 'User' }, author_association: 'NONE', labels: [], + user: { type: 'User', login: 'alice' }, labels: [], ...over, }); +const notCollaborator = () => { + const err = new Error('Not Found'); + err.status = 404; + throw err; +}; + // `linked` is the list of issues GitHub resolves as closing references. -const stub = (linked) => ({ +const stub = (linked, permission) => ({ graphql: async () => ({ repository: { pullRequest: { closingIssuesReferences: { nodes: linked.map((i) => ({ @@ -26,10 +32,18 @@ const stub = (linked) => ({ })), } } }, }), + rest: { + repos: { + getCollaboratorPermissionLevel: async () => { + if (!permission) return notCollaborator(); + return { data: { permission } }; + }, + }, + }, }); -const run = (linked, over) => - checkGate({ github: stub(linked), owner: 'o', repo: 'r', pr: pull(over) }); +const run = (linked, over, permission) => + checkGate({ github: stub(linked, permission), owner: 'o', repo: 'r', pr: pull(over) }); const cases = [ ['no linked issue fails', () => run([]), (r) => r.passed === false], @@ -45,17 +59,19 @@ const cases = [ (r) => r.passed === true && r.issue === 8], // Exemptions. - ['maintainer skips', () => run([], { author_association: 'MEMBER' }), (r) => r.passed === true], - ['collaborator skips', () => run([], { author_association: 'COLLABORATOR' }), (r) => r.passed === true], + ['write permission skips', () => run([], {}, 'write'), (r) => r.passed === true], + ['maintain permission skips', () => run([], {}, 'maintain'), (r) => r.passed === true], ['bot skips', () => run([], { user: { type: 'Bot' } }), (r) => r.passed === true], ['draft skips', () => run([], { draft: true }), (r) => r.passed === true], [`${EXEMPT_LABEL} skips`, () => run([], { labels: [{ name: EXEMPT_LABEL }] }), (r) => r.passed === true], - // Regression guard: GitHub hands CONTRIBUTOR to anyone who has previously - // committed, i.e. every returning outside contributor. It must stay gated. - ['CONTRIBUTOR is still gated', - () => run([], { author_association: 'CONTRIBUTOR' }), + ['triage permission is still gated', () => run([], {}, 'triage'), (r) => r.passed === false], + ['MEMBER association without write is still gated', + () => run([], { author_association: 'MEMBER' }), (r) => r.passed === false], + ['CONTRIBUTOR with write skips', + () => run([], { author_association: 'CONTRIBUTOR' }, 'write'), + (r) => r.passed === true], ]; // --- findNotices: only the bot's own notices count ------------------------- @@ -81,12 +97,12 @@ const noticeCases = [ // --- runSweep: the stale-draft pass must honour every exemption ------------ const draft = (over) => ({ number: 9, draft: true, state: 'open', labels: [], - user: { type: 'User' }, author_association: 'NONE', + user: { type: 'User', login: 'alice' }, updated_at: new Date(Date.now() - 400 * 86400_000).toISOString(), ...over, }); -async function sweepClosed(pr) { +async function sweepClosed(pr, permission) { const closed = []; const github = { paginate: async (route) => (route === 'pulls' ? [pr] : []), @@ -96,6 +112,12 @@ async function sweepClosed(pr) { update: async ({ pull_number }) => closed.push(pull_number), }, issues: { listComments: 'comments', createComment: async () => {} }, + repos: { + getCollaboratorPermissionLevel: async () => { + if (!permission) return notCollaborator(); + return { data: { permission } }; + }, + }, }, }; await runSweep({ @@ -108,7 +130,7 @@ async function sweepClosed(pr) { const sweepCases = [ ['stale draft from an outside author closes', draft({}), 1], ['stale draft from a bot is left alone', draft({ user: { type: 'Bot' } }), 0], - ['stale draft from a maintainer is left alone', draft({ author_association: 'MEMBER' }), 0], + ['stale draft from a writer is left alone', draft({}), 0, 'write'], [`stale draft with ${EXEMPT_LABEL} is left alone`, draft({ labels: [{ name: EXEMPT_LABEL }] }), 0], ['recent draft is left alone', draft({ updated_at: new Date().toISOString() }), 0], ]; @@ -120,8 +142,8 @@ const sweepCases = [ if (got === want) console.log(` ok ${name}`); else { failed++; console.log(` FAIL ${name} -> ${got} notices, wanted ${want}`); } } - for (const [name, pr, want] of sweepCases) { - const got = (await sweepClosed(pr)).length; + for (const [name, pr, want, permission] of sweepCases) { + const got = (await sweepClosed(pr, permission)).length; if (got === want) console.log(` ok ${name}`); else { failed++; console.log(` FAIL ${name} -> closed ${got}, wanted ${want}`); } } From dea2917fa903c0eacead620293ef483ac46d8960 Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Wed, 26 Aug 2026 12:14:32 -0400 Subject: [PATCH 26/50] fix(openai): fix content normalization in openai backend history adapter (#1064) * fix(llm): preserve null content on OpenAI tool-call turns OpenAI-compatible providers can return assistant tool-call messages with content=null. Coercing that to "" before history replay breaks providers that bind opaque reasoning state to the exact assistant message shape. Keep null only when the normalized response has tool calls; tool-less null still becomes "", and content_override stays authoritative. Fixes #1061 * test(live_llm): cover OpenAI null content tool-call replay Add a live multi-turn tool replay that asserts provider content=null stays null through normalize + OpenAIHistoryAdapter and that the continuation still answers. Mark gpt_4/gpt_5 families as supports_tool_replay. * docs(llm): note content_override None sentinel semantics None means no override, not force-null content. Addresses review on #1064. --- src/llm/backends/openai.py | 15 ++- tests/live_llm/README.md | 4 +- tests/live_llm/model_matrix.py | 2 + tests/live_llm/test_live_openai.py | 88 +++++++++++++++ tests/llm/test_backends/test_openai.py | 106 +++++++++++++++++- tests/llm/test_history_adapters.py | 28 +++++ tests/llm/test_tool_loop_reasoning_content.py | 78 +++++++++++++ 7 files changed, 314 insertions(+), 7 deletions(-) diff --git a/src/llm/backends/openai.py b/src/llm/backends/openai.py index 672709ff..5b910ae9 100644 --- a/src/llm/backends/openai.py +++ b/src/llm/backends/openai.py @@ -441,10 +441,19 @@ class OpenAIBackend: ) cache_creation, cache_read = extract_openai_cache_tokens(usage) + # content_override=None means no override, not "force content to None" + if content_override is not None: + content: Any = content_override + elif message.content is not None: + content = message.content + elif tool_calls: + # Preserve null content on tool-call turns for history replay + content = None + else: + content = "" + return CompletionResult( - content=content_override - if content_override is not None - else (message.content or ""), + content=content, input_tokens=usage.prompt_tokens if usage else 0, output_tokens=usage.completion_tokens if usage else 0, cache_creation_input_tokens=cache_creation, diff --git a/tests/live_llm/README.md b/tests/live_llm/README.md index 5cf39e4b..9590185e 100644 --- a/tests/live_llm/README.md +++ b/tests/live_llm/README.md @@ -63,8 +63,8 @@ export OPENROUTER_API_KEY="sk-or-v1-..." Coverage by provider: - Anthropic: structured output path, prompt caching metrics, thinking blocks, multi-turn tool replay -- OpenAI GPT-4 class: structured outputs, prompt caching -- OpenAI GPT-5 class (incl. gpt-5.x point-releases): structured outputs, prompt caching, `reasoning_effort`, `max_completion_tokens` routing +- OpenAI GPT-4 class: structured outputs, prompt caching, multi-turn tool replay (null `content` preserved) +- OpenAI GPT-5 class (incl. gpt-5.x point-releases): structured outputs, prompt caching, `reasoning_effort`, `max_completion_tokens` routing, multi-turn tool replay (null `content` preserved) - OpenAI transport → OpenRouter non-reasoning models (e.g. `inception/mercury-2`): non-chat / diffusion architectures must stay on `max_tokens`, no `reasoning_effort`, tool-calling parameter-schema compatibility is the canary for exotic OR-served providers - Gemini 2.5/3.0 classes: structured outputs, cached-content reuse, thought signatures, multi-turn tool replay - Gemini 3.1 class: thinking and tool replay coverage by default; structured-output/caching coverage should only be added once Google documents support for that path diff --git a/tests/live_llm/model_matrix.py b/tests/live_llm/model_matrix.py index 2478c6c3..3a18a84e 100644 --- a/tests/live_llm/model_matrix.py +++ b/tests/live_llm/model_matrix.py @@ -58,6 +58,7 @@ MODEL_FAMILIES: tuple[LiveModelFamily, ...] = ( default_models=("gpt-4.1",), supports_structured_output=True, supports_caching=True, + supports_tool_replay=True, docs_url="https://platform.openai.com/docs/models/gpt-4.1", ), LiveModelFamily( @@ -68,6 +69,7 @@ MODEL_FAMILIES: tuple[LiveModelFamily, ...] = ( supports_structured_output=True, supports_caching=True, supports_reasoning=True, + supports_tool_replay=True, docs_url="https://platform.openai.com/docs/models/gpt-5", ), # OpenAI-compatible transport → OpenRouter-served non-reasoning models. diff --git a/tests/live_llm/test_live_openai.py b/tests/live_llm/test_live_openai.py index 8b0544ec..b69818c6 100644 --- a/tests/live_llm/test_live_openai.py +++ b/tests/live_llm/test_live_openai.py @@ -2,10 +2,13 @@ from __future__ import annotations import pytest +from src.llm.history_adapters import OpenAIHistoryAdapter from src.llm.request_builder import execute_completion from .conftest import ( StructuredLiveResponse, + execute_local_tool, + favorite_prime_tools, make_backend, make_large_system_prompt, require_provider_key, @@ -30,6 +33,11 @@ _JSON_OBJECT_SPECS = tuple( for spec in get_live_model_specs(provider="openai") if spec.family == "openai_json_object" ) +_TOOL_REPLAY_SPECS = tuple( + spec + for spec in get_live_model_specs(provider="openai") + if spec.supports_tool_replay +) @pytest.mark.asyncio @@ -194,3 +202,83 @@ async def test_live_openai_json_object_structured_output( assert parse_calls == [] assert create_calls, "expected a chat.completions.create call" assert create_calls[0]["kwargs"]["response_format"] == {"type": "json_object"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_spec", _TOOL_REPLAY_SPECS, ids=lambda spec: spec.id) +async def test_live_openai_tool_replay_preserves_null_content( + model_spec: LiveModelSpec, +) -> None: + """Tool-call turns with provider content=null must stay null through + normalize + history replay, and the continuation must still succeed.""" + require_provider_key(model_spec) + # Leave reasoning_effort unset: gpt-5.4 rejects function tools with any + # explicit reasoning_effort other than 'none' on /v1/chat/completions. + backend, config = make_backend(model_spec) + tools = favorite_prime_tools() + adapter = OpenAIHistoryAdapter() + + initial_messages = [ + { + "role": "user", + "content": ( + "Before answering, call the get_favorite_prime tool exactly once. " + "Do not answer with plain text on this turn. " + "After you receive the tool result, answer in one sentence that " + "includes the number and the word 'prime'." + ), + } + ] + + first = await execute_completion( + backend, + config, + messages=initial_messages, + max_tokens=1024, + tools=tools, + tool_choice="required", + ) + + assert first.tool_calls, "OpenAI should issue a tool call in the first turn" + raw_message = first.raw_response.choices[0].message + raw_content = raw_message.content + if raw_content is None: + assert first.content is None + else: + assert first.content == raw_content + + assistant_message = adapter.format_assistant_tool_message(first) + assert assistant_message["content"] is ( + first.content if isinstance(first.content, str) else None + ) + if raw_content is None: + assert assistant_message["content"] is None + + tool_call = first.tool_calls[0] + tool_result = execute_local_tool(tool_call.name, tool_call.input) + replay_messages = initial_messages + [ + assistant_message, + *adapter.format_tool_results( + [ + { + "tool_id": tool_call.id, + "tool_name": tool_call.name, + "result": tool_result, + } + ] + ), + ] + + second = await execute_completion( + backend, + config, + messages=replay_messages, + max_tokens=1024, + tools=tools, + tool_choice="auto", + ) + + assert not second.tool_calls, "continuation should answer without another tool call" + assert isinstance(second.content, str) + assert "13" in second.content + assert "prime" in second.content.lower() diff --git a/tests/llm/test_backends/test_openai.py b/tests/llm/test_backends/test_openai.py index fbd8e719..b4df2e66 100644 --- a/tests/llm/test_backends/test_openai.py +++ b/tests/llm/test_backends/test_openai.py @@ -1138,12 +1138,114 @@ async def test_openai_backend_structured_with_tools_uses_create_not_parse() -> N assert "strict" not in call["tools"][0]["function"] +def _tool_call_message( + *, + content: str | None, + reasoning_details: list[Any] | None = None, +) -> SimpleNamespace: + return SimpleNamespace( + content=content, + tool_calls=[ + SimpleNamespace( + id="call_probe", + function=SimpleNamespace( + name="search", + arguments='{"query":"honcho"}', + ), + ) + ], + reasoning_details=reasoning_details or [], + reasoning_content=None, + ) + + +def _completion_response( + message: SimpleNamespace, finish_reason: str +) -> SimpleNamespace: + return SimpleNamespace( + choices=[SimpleNamespace(finish_reason=finish_reason, message=message)], + usage=SimpleNamespace( + prompt_tokens=10, + completion_tokens=5, + prompt_tokens_details=None, + ), + ) + + +def test_openai_normalize_preserves_null_content_on_tool_call_turns() -> None: + reasoning_details = [ + { + "type": "reasoning.encrypted", + "data": "opaque", + "format": "openai-responses-v1", + "id": "binding", + "index": 0, + } + ] + response = _completion_response( + _tool_call_message(content=None, reasoning_details=reasoning_details), + "tool_calls", + ) + + result = OpenAIBackend(Mock())._normalize_response( # pyright: ignore[reportPrivateUsage] + response + ) + + assert result.content is None + assert result.tool_calls[0].id == "call_probe" + assert result.tool_calls[0].name == "search" + assert result.tool_calls[0].input == {"query": "honcho"} + assert result.reasoning_details == reasoning_details + + +def test_openai_normalize_coerces_null_content_without_tool_calls() -> None: + message = SimpleNamespace( + content=None, + tool_calls=[], + reasoning_details=[], + reasoning_content=None, + ) + response = _completion_response(message, "stop") + + result = OpenAIBackend(Mock())._normalize_response( # pyright: ignore[reportPrivateUsage] + response + ) + + assert result.content == "" + + +def test_openai_normalize_keeps_empty_string_content_on_tool_call_turns() -> None: + response = _completion_response( + _tool_call_message(content=""), + "tool_calls", + ) + + result = OpenAIBackend(Mock())._normalize_response( # pyright: ignore[reportPrivateUsage] + response + ) + + assert result.content == "" + + +def test_openai_normalize_content_override_is_authoritative() -> None: + response = _completion_response( + _tool_call_message(content=None), + "tool_calls", + ) + + result = OpenAIBackend(Mock())._normalize_response( # pyright: ignore[reportPrivateUsage] + response, content_override="override" + ) + + assert result.content == "override" + + @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).""" + parsing (provider content is null and _parse_or_repair raises on that).""" client = Mock() client.chat.completions.create = AsyncMock( return_value=SimpleNamespace( @@ -1183,7 +1285,7 @@ async def test_openai_backend_structured_with_tools_skips_parsing_tool_call_turn response_format=_StructuredResponse, ) - assert result.content == "" # raw empty text, not a parsed model + assert result.content is None # provider null, not a parsed model assert result.tool_calls[0].name == "search" assert result.tool_calls[0].input == {"query": "honcho"} diff --git a/tests/llm/test_history_adapters.py b/tests/llm/test_history_adapters.py index a992398d..343cee9c 100644 --- a/tests/llm/test_history_adapters.py +++ b/tests/llm/test_history_adapters.py @@ -113,3 +113,31 @@ def test_openai_history_adapter_omits_empty_thinking_content( message = adapter.format_assistant_tool_message(result) assert "reasoning_content" not in message + + +def test_openai_history_adapter_preserves_null_content_on_tool_call_turns() -> None: + adapter = OpenAIHistoryAdapter() + reasoning_details = [ + { + "type": "reasoning.encrypted", + "data": "opaque", + "format": "openai-responses-v1", + "id": "binding", + "index": 0, + } + ] + result = CompletionResult( + content=None, + reasoning_details=reasoning_details, + tool_calls=[ + ToolCallResult(id="call_probe", name="search", input={"query": "honcho"}) + ], + ) + + message = adapter.format_assistant_tool_message(result) + + assert message["content"] is None + assert message["reasoning_details"] == reasoning_details + assert message["tool_calls"][0]["id"] == "call_probe" + assert message["tool_calls"][0]["function"]["name"] == "search" + assert message["tool_calls"][0]["function"]["arguments"] == '{"query": "honcho"}' diff --git a/tests/llm/test_tool_loop_reasoning_content.py b/tests/llm/test_tool_loop_reasoning_content.py index cd4ec91b..53b65fe2 100644 --- a/tests/llm/test_tool_loop_reasoning_content.py +++ b/tests/llm/test_tool_loop_reasoning_content.py @@ -102,3 +102,81 @@ async def test_tool_loop_replays_reasoning_content_on_continuation() -> None: "tool_call_id": "call_1", "content": "result", } + + +@pytest.mark.asyncio +async def test_tool_loop_replays_null_assistant_content_on_continuation() -> None: + calls: list[list[dict[str, Any]]] = [] + responses = iter( + [ + HonchoLLMCallResponse( + content=None, + output_tokens=5, + finish_reasons=["tool_calls"], + tool_calls_made=[ + { + "id": "call_1", + "name": "search", + "input": {"query": "honcho"}, + } + ], + reasoning_details=[ + { + "type": "reasoning.encrypted", + "data": "opaque", + "format": "openai-responses-v1", + "id": "binding", + "index": 0, + } + ], + ), + HonchoLLMCallResponse( + content="done", + output_tokens=3, + finish_reasons=["stop"], + tool_calls_made=[], + ), + ] + ) + + async def fake_call(*_args: Any, **kwargs: Any) -> HonchoLLMCallResponse[Any]: + calls.append(deepcopy(kwargs["messages"])) + return next(responses) + + async def execute_search(_name: str, _input: dict[str, Any]) -> str: + return "result" + + with patch.object(tool_loop, "honcho_llm_call_inner", new=fake_call): + result = await execute_tool_loop( + prompt="hi", + max_tokens=64, + messages=[{"role": "user", "content": "hi"}], + tools=[ + { + "name": "search", + "description": "Search", + "input_schema": {"type": "object"}, + } + ], + tool_choice="auto", + tool_executor=execute_search, + max_tool_iterations=5, + response_model=None, + json_mode=False, + temperature=None, + stop_seqs=None, + verbosity=None, + enable_retry=False, + retry_attempts=1, + max_input_tokens=None, + get_attempt_plan=_make_plan, + before_retry_callback=lambda _retry_state: None, + stream_final=False, + telemetry=None, + ) + + assert isinstance(result, HonchoLLMCallResponse) + assert len(calls) == 2 + assert calls[1][1]["content"] is None + assert calls[1][1]["reasoning_details"][0]["data"] == "opaque" + assert calls[1][1]["tool_calls"][0]["function"]["name"] == "search" From 2ddd819a28cec3e1bd30a68b869d1ef6f112e85d Mon Sep 17 00:00:00 2001 From: ajspig <46900795+ajspig@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:05:25 -0400 Subject: [PATCH 27/50] chore: bump honcho-cli to 0.1.4 (#1080) * feat(cli): fix API key typing Co-authored-by: Cursor * chore: bump honcho-cli to 0.1.4 Ship the masked --setup API key prompt plus the openai-compatible embedding base URL already on main. Co-authored-by: Cursor * feat(cli): check for newer version * docs: nit --------- Co-authored-by: Cursor --- docs/changelog/introduction.mdx | 12 +++- docs/v3/documentation/reference/cli.mdx | 3 + honcho-cli/CHANGELOG.md | 9 ++- honcho-cli/README.md | 1 + honcho-cli/pyproject.toml | 2 +- honcho-cli/src/honcho_cli/__init__.py | 2 +- honcho-cli/src/honcho_cli/_help.py | 2 + honcho-cli/src/honcho_cli/local/setup.py | 72 ++++++++++++++++++++--- honcho-cli/src/honcho_cli/main.py | 2 + honcho-cli/src/honcho_cli/update_check.py | 67 +++++++++++++++++++++ uv.lock | 2 +- 11 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 honcho-cli/src/honcho_cli/update_check.py diff --git a/docs/changelog/introduction.mdx b/docs/changelog/introduction.mdx index 15003c7d..a152d16a 100644 --- a/docs/changelog/introduction.mdx +++ b/docs/changelog/introduction.mdx @@ -1170,7 +1170,17 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [Honcho CLI](https://pypi.org/project/honcho-cli/) - + + ### Added + + - A TTY notice when a newer `honcho-cli` is on PyPI (`uv tool upgrade honcho-cli`). Skipped in JSON mode; disable with `HONCHO_NO_UPDATE_CHECK` + + ### Fixed + + - `--setup` for openai-compatible writes `EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL` into the profile `.env` alongside `LLM_OPENAI_BASE_URL` (#1068) + - `--setup` API key prompts echo `*` per character so a paste is visibly received instead of a blank getpass field + + ### Added - `honcho start`, `honcho stop`, and `honcho status` — run a personal Honcho stack in Docker (API, deriver, Postgres, Redis). Profiles live under `~/.honcho/profiles/`. First start pins `ghcr.io/plastic-labs/honcho:latest` by digest and copies the image `config.toml`. Optional `--setup basic` / `--setup advanced` wizard writes LLM overrides to `.env` (#1029) diff --git a/docs/v3/documentation/reference/cli.mdx b/docs/v3/documentation/reference/cli.mdx index 71a14305..9686199d 100644 --- a/docs/v3/documentation/reference/cli.mdx +++ b/docs/v3/documentation/reference/cli.mdx @@ -69,6 +69,7 @@ The CLI resolves config in this order: **flag → env var → config file → de | Peer | — | `HONCHO_PEER_ID` | `-p` / `--peer` | No | | Session | — | `HONCHO_SESSION_ID` | `-s` / `--session` | No | | JSON output | — | `HONCHO_JSON` | `--json` | No | +| Update nag | — | `HONCHO_NO_UPDATE_CHECK` | — | No | | Local stack | — | `HONCHO_PROFILE` | `--profile` | No | ### Persisted config @@ -124,6 +125,8 @@ Every command adapts its output to the context: - **Piped or redirected** — JSON automatically (detected via `isatty`). - **`--json` flag / `HONCHO_JSON=1`** — force JSON regardless of terminal. +Interactive sessions may print a one-line upgrade hint on stderr at most once a day when a newer `honcho-cli` is on PyPI (`uv tool upgrade honcho-cli`). JSON/piped output skips it; set `HONCHO_NO_UPDATE_CHECK=1` to disable it. + Collection commands emit JSON arrays; single-resource commands emit JSON objects. Errors are always structured: ```json diff --git a/honcho-cli/CHANGELOG.md b/honcho-cli/CHANGELOG.md index 1a1b50e3..80b7e5ef 100644 --- a/honcho-cli/CHANGELOG.md +++ b/honcho-cli/CHANGELOG.md @@ -7,9 +7,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +## [0.1.4] - 2026-08-26 + +### Added + +- A TTY notice when a newer `honcho-cli` is on PyPI (`uv tool upgrade honcho-cli`). Skipped in JSON mode; disable with `HONCHO_NO_UPDATE_CHECK` + ### Fixed -- `--setup` for openai-compatible writes `EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL` into the profile `.env` alongside `LLM_OPENAI_BASE_URL` +- `--setup` for openai-compatible writes `EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL` into the profile `.env` alongside `LLM_OPENAI_BASE_URL` (#1068) +- `--setup` API key prompts echo `*` per character so a paste is visibly received instead of a blank getpass field ## [0.1.3] - 2026-08-25 diff --git a/honcho-cli/README.md b/honcho-cli/README.md index 06898b5c..0a01e647 100644 --- a/honcho-cli/README.md +++ b/honcho-cli/README.md @@ -185,6 +185,7 @@ Precedence (highest first): **flag → env var → config file → default**. | `HONCHO_PEER_ID` | `-p` / `--peer` | Peer scope | | `HONCHO_SESSION_ID` | `-s` / `--session` | Session scope | | `HONCHO_JSON` | `--json` | Force JSON output (`1` / `true`) | +| `HONCHO_NO_UPDATE_CHECK` | — | Disable the once-a-day upgrade notice (`1` / `true`) | | `HONCHO_PROFILE` | `--profile` (start/stop/status) | Local stack profile (default: `local`) | | `LLM_OPENAI_API_KEY` | — | Provider key for `honcho start` (also `LLM_ANTHROPIC_API_KEY`, `LLM_GEMINI_API_KEY`) | diff --git a/honcho-cli/pyproject.toml b/honcho-cli/pyproject.toml index f2fa4d0c..25c376cb 100644 --- a/honcho-cli/pyproject.toml +++ b/honcho-cli/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho-cli" -version = "0.1.3" +version = "0.1.4" 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 5cbd28b3..33860e37 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.3" +__version__ = "0.1.4" diff --git a/honcho-cli/src/honcho_cli/_help.py b/honcho-cli/src/honcho_cli/_help.py index 936e26b7..dbb4f7cd 100644 --- a/honcho-cli/src/honcho_cli/_help.py +++ b/honcho-cli/src/honcho_cli/_help.py @@ -24,6 +24,7 @@ from typer.core import TyperGroup from honcho_cli import __version__ from honcho_cli.branding import BANNER, BRAND from honcho_cli.output import use_json +from honcho_cli.update_check import maybe_print_update_nag # Theme Typer's rich help renderer. Module-level side effect limited to @@ -113,6 +114,7 @@ def print_welcome(console: Console) -> None: console.print(_welcome_panel("memory", memory_rows)) console.print(_welcome_panel("options", option_rows)) console.print() + maybe_print_update_nag() class HonchoTyperGroup(TyperGroup): diff --git a/honcho-cli/src/honcho_cli/local/setup.py b/honcho-cli/src/honcho_cli/local/setup.py index 242f47d6..5922dfd4 100644 --- a/honcho-cli/src/honcho_cli/local/setup.py +++ b/honcho-cli/src/honcho_cli/local/setup.py @@ -7,6 +7,7 @@ only — the start command rejects ``--setup`` in JSON / non-TTY mode. from __future__ import annotations +import sys import tomllib from dataclasses import dataclass from pathlib import Path @@ -445,13 +446,7 @@ def _prompt_secret(label: str, current: str | None) -> str: if choice != "2": return current _console.print(f" [dim]{label}[/dim]") - raw = typer.prompt( - f" {label}", - default="", - show_default=False, - hide_input=True, - prompt_suffix=": ", - ).strip() + raw = _prompt_masked(f" {label}: ").strip() if not raw or is_placeholder_key(raw): print_error( "MISSING_LLM_KEY", @@ -461,6 +456,69 @@ def _prompt_secret(label: str, current: str | None) -> str: return raw +def _prompt_masked(prompt: str) -> str: + """Read a secret, echoing ``*`` per character so paste is visibly received.""" + stream = sys.stderr + stream.write(prompt) + stream.flush() + chars: list[str] = [] + + def _write(text: str) -> None: + stream.write(text) + stream.flush() + + def _feed(ch: str) -> bool: + """Return True when input is complete.""" + if not ch or ch in ("\n", "\r", "\x04"): + _write("\n") + return True + if ch in ("\x7f", "\x08"): + if chars: + chars.pop() + _write("\b \b") + return False + if ch == "\x1b": + return False + if ch.isprintable(): + chars.append(ch) + _write("*") + return False + + if sys.platform == "win32": + import msvcrt + + while True: + ch = msvcrt.getwch() + if ch in ("\x00", "\xe0"): + msvcrt.getwch() + continue + if _feed(ch): + return "".join(chars) + + import termios + import tty + + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setcbreak(fd) + while True: + ch = sys.stdin.read(1) + if ch == "\x1b": + nxt = sys.stdin.read(1) + if nxt == "[": + while True: + seq = sys.stdin.read(1) + if not seq or "@" <= seq <= "~": + break + continue + if _feed(ch): + return "".join(chars) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + return "".join(chars) + + def _redact(key: str) -> str: if len(key) <= 4: return "***" diff --git a/honcho-cli/src/honcho_cli/main.py b/honcho-cli/src/honcho_cli/main.py index 8a9c16f6..d2282c81 100644 --- a/honcho-cli/src/honcho_cli/main.py +++ b/honcho-cli/src/honcho_cli/main.py @@ -15,6 +15,7 @@ from honcho_cli import __version__ from honcho_cli._help import HonchoTyperGroup, print_welcome from honcho_cli.branding import BANNER from honcho_cli.output import set_json_mode +from honcho_cli.update_check import maybe_print_update_nag app = typer.Typer( @@ -60,6 +61,7 @@ def main( if ctx.invoked_subcommand is None: print_welcome(Console()) raise typer.Exit() + maybe_print_update_nag() # Register top-level commands diff --git a/honcho-cli/src/honcho_cli/update_check.py b/honcho-cli/src/honcho_cli/update_check.py new file mode 100644 index 00000000..97b433d1 --- /dev/null +++ b/honcho-cli/src/honcho_cli/update_check.py @@ -0,0 +1,67 @@ +"""Once-a-day stderr notice when a newer honcho-cli is on PyPI. + +Fail-open: any error is swallowed. Cache is ``update-check.json`` beside +config, not ``config.json``. +""" + +from __future__ import annotations + +import json +import os +import sys +import time + +import httpx + +from honcho_cli import __version__ +from honcho_cli.branding import ICON_RUN +from honcho_cli.config import _config_dir +from honcho_cli.output import console, use_json + +_INTERVAL_S = 24 * 60 * 60 +_PYPI_URL = "https://pypi.org/pypi/honcho-cli/json" + + +def maybe_print_update_nag() -> None: + if use_json() or "--json" in sys.argv: + return + if os.environ.get("HONCHO_NO_UPDATE_CHECK", "").lower() in ("1", "true"): + return + try: + path = _config_dir() / "update-check.json" + now = time.time() + try: + cache = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + cache = {} + if isinstance(cache, dict) and now - float(cache.get("t") or 0) < _INTERVAL_S: + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"t": now}), encoding="utf-8") + latest = httpx.get(_PYPI_URL, timeout=1.0).json()["info"]["version"] + if not isinstance(latest, str) or not _is_newer(latest, __version__): + return + console.print(f" {ICON_RUN} honcho-cli {latest} is available (you have {__version__})") + console.print(" [dim]uv tool upgrade honcho-cli[/dim]") + except Exception: + return + + +def _is_newer(latest: str, current: str) -> bool: + def parts(version: str) -> tuple[int, ...]: + out: list[int] = [] + for segment in version.lstrip("v").split("."): + num = "" + for ch in segment: + if ch.isdigit(): + num += ch + else: + break + if not num: + break + out.append(int(num)) + return tuple(out) or (0,) + + a, b = parts(latest), parts(current) + n = max(len(a), len(b)) + return a + (0,) * (n - len(a)) > b + (0,) * (n - len(b)) diff --git a/uv.lock b/uv.lock index c92534ac..76f28e20 100644 --- a/uv.lock +++ b/uv.lock @@ -1170,7 +1170,7 @@ dev = [{ name = "ruff", specifier = ">=0.11.13" }] [[package]] name = "honcho-cli" -version = "0.1.3" +version = "0.1.4" source = { editable = "honcho-cli" } dependencies = [ { name = "click" }, From 168185ae2b791944337c5a1a4b6a3921223d1591 Mon Sep 17 00:00:00 2001 From: Rajat Ahuja Date: Wed, 26 Aug 2026 15:04:03 -0400 Subject: [PATCH 28/50] fix(ci): stop skipping unified tests on merge to main (#1083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate job only runs on `pull_request: labeled`, so it is skipped on push. A skipped ancestor propagates down the needs chain unless a job opts out, which `unified-tests` never did — so the suite has been skipped on every merge to main while still burning a Fly machine. --- .github/workflows/unified-tests.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unified-tests.yml b/.github/workflows/unified-tests.yml index 4813a126..b73bda32 100644 --- a/.github/workflows/unified-tests.yml +++ b/.github/workflows/unified-tests.yml @@ -57,7 +57,10 @@ jobs: name: Run Unified Tests runs-on: ${{ fromJSON(format('[{0}]', needs.start-runner.outputs.runner-labels)) }} needs: start-runner - if: needs.start-runner.outputs.runner-ready == 'true' + # !cancelled() so this doesn't inherit gate's skip on push events. + if: >- + !cancelled() && + needs.start-runner.outputs.runner-ready == 'true' timeout-minutes: 90 environment: unified-tests permissions: From e1537216cfa2f675f1a52afa16da93d9f5f7d3a1 Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Wed, 26 Aug 2026 17:07:41 -0400 Subject: [PATCH 29/50] fix: stop top_k=0 from reaching Turbopuffer on message search (#1084) * fix: stop top_k=0 from reaching Turbopuffer on message search HONCHO-19Q: dreamer search_messages passed LLM limit=0 through to Turbopuffer (top_k must be 1..10000). #970 guarded documents; this closes the message path and floors tool limits at 1. * fix: preserve pgvector None sentinel on zero top_k query_external_vector_document_ids must return None when on the pgvector path before applying the top_k<=0 empty-list guard. --- src/crud/document.py | 3 ++ src/crud/message.py | 3 ++ src/utils/agent_tools.py | 27 +++++++------ src/utils/search.py | 3 ++ src/vector_store/lancedb.py | 3 ++ src/vector_store/turbopuffer.py | 3 ++ tests/crud/test_representation_manager.py | 45 +++++++++++++++++++++ tests/utils/test_agent_tools.py | 49 +++++++++++++++++++++++ tests/vector_store/test_lancedb.py | 21 ++++++++++ tests/vector_store/test_turbopuffer.py | 23 +++++++++++ 10 files changed, 168 insertions(+), 12 deletions(-) diff --git a/src/crud/document.py b/src/crud/document.py index 1b04bb0a..37eb94b4 100644 --- a/src/crud/document.py +++ b/src/crud/document.py @@ -231,6 +231,9 @@ async def query_external_vector_document_ids( if _uses_pgvector(): return None + if top_k <= 0: + return [] + external_vector_store = get_external_vector_store() if external_vector_store is None: return [] diff --git a/src/crud/message.py b/src/crud/message.py index 9759cb91..15b4902c 100644 --- a/src/crud/message.py +++ b/src/crud/message.py @@ -747,6 +747,9 @@ async def _search_messages_external( Multiple vector records can map to the same message (chunked embeddings), so we oversample from the vector store and deduplicate by message_id. """ + if limit <= 0: + return [] + external_vector_store = get_external_vector_store() if external_vector_store is None: return [] diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index e31b4d62..b753c462 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -292,6 +292,11 @@ def _safe_int(value: Any, default: int) -> int: return default +def _bounded_int(value: Any, default: int, *, lo: int = 1, hi: int) -> int: + """Coerce a tool int into ``[lo, hi]``, falling back to ``default`` on bad input.""" + return max(lo, min(_safe_int(value, default), hi)) + + # Module-level lock registry for thread-safe observation creation. # Keyed by (workspace_name, observer, observed) to ensure all tool executors # operating on the same data share the same lock. @@ -1883,7 +1888,7 @@ async def _handle_search_memory( """Handle search_memory tool.""" from src.utils.types import ToolResult - top_k = min(_safe_int(tool_input.get("top_k"), 20), 40) + top_k = _bounded_int(tool_input.get("top_k"), 20, hi=40) query = tool_input["query"] try: with embedding_call_purpose( @@ -1944,7 +1949,7 @@ async def _handle_search_memory( # information. zero_hit_meta = {**search_meta, "results_count": 0} if ctx.agent_type in ("dialectic", "workspace_dialectic"): - limit = min(_safe_int(tool_input.get("top_k"), 20), 20) + limit = _bounded_int(tool_input.get("top_k"), 20, hi=20) message_output = None snippets = await crud.search_messages( workspace_name=ctx.workspace_name, @@ -2021,7 +2026,7 @@ async def _handle_search_messages( from src.utils.types import ToolResult query = tool_input["query"] - limit = min(_safe_int(tool_input.get("limit"), 10), 20) # Cap at 20 + limit = _bounded_int(tool_input.get("limit"), 10, hi=20) # Pre-compute embedding outside DB session to avoid holding a connection # during the external API call (same pattern as _handle_search_memory). with embedding_call_purpose( @@ -2064,10 +2069,8 @@ async def _handle_grep_messages( text = tool_input.get("text", "") if not text: return "ERROR: 'text' parameter is required" - limit = min(_safe_int(tool_input.get("limit"), 10), 30) # Cap at 30 - context_window = min( - _safe_int(tool_input.get("context_window"), 2), 2 - ) # Cap context + limit = _bounded_int(tool_input.get("limit"), 10, hi=30) + context_window = _bounded_int(tool_input.get("context_window"), 2, lo=0, hi=2) snippets = await crud.grep_messages( workspace_name=ctx.workspace_name, @@ -2120,7 +2123,7 @@ async def _handle_get_messages_by_date_range( """Handle get_messages_by_date_range tool.""" after_date_str = tool_input.get("after_date") before_date_str = tool_input.get("before_date") - limit = min(_safe_int(tool_input.get("limit"), 20), 20) + limit = _bounded_int(tool_input.get("limit"), 20, hi=20) order = tool_input.get("order", "desc") after_date = _parse_date(after_date_str, "after_date") @@ -2186,8 +2189,8 @@ async def _handle_search_messages_temporal( after_date_str = tool_input.get("after_date") before_date_str = tool_input.get("before_date") - limit = min(_safe_int(tool_input.get("limit"), 10), 10) - context_window = min(_safe_int(tool_input.get("context_window"), 2), 2) + limit = _bounded_int(tool_input.get("limit"), 10, hi=10) + context_window = _bounded_int(tool_input.get("context_window"), 2, lo=0, hi=2) after_date = _parse_date(after_date_str, "after_date") if isinstance(after_date, str): @@ -2257,7 +2260,7 @@ async def _handle_get_recent_observations( workspace_name=ctx.workspace_name, observer=ctx.observer, observed=ctx.observed, - limit=min(_safe_int(tool_input.get("limit"), 10), 100), + limit=_bounded_int(tool_input.get("limit"), 10, hi=100), session_name=ctx.session_name if session_only else None, ) representation = Representation.from_documents(documents) @@ -2283,7 +2286,7 @@ async def _handle_get_most_derived_observations( workspace_name=ctx.workspace_name, observer=ctx.observer, observed=ctx.observed, - limit=min(_safe_int(tool_input.get("limit"), 10), 100), + limit=_bounded_int(tool_input.get("limit"), 10, hi=100), ) representation = Representation.from_documents(documents) total_count = representation.len() diff --git a/src/utils/search.py b/src/utils/search.py index 329e082b..76e86fd4 100644 --- a/src/utils/search.py +++ b/src/utils/search.py @@ -82,6 +82,9 @@ async def query_external_vector_message_ids( filters: dict[str, Any] | None = None, ) -> list[str]: """Query the external vector store and return ordered message IDs.""" + if limit <= 0: + return [] + external_vector_store = get_external_vector_store() if external_vector_store is None: return [] diff --git a/src/vector_store/lancedb.py b/src/vector_store/lancedb.py index 1b4c4880..ab44c1bc 100644 --- a/src/vector_store/lancedb.py +++ b/src/vector_store/lancedb.py @@ -214,6 +214,9 @@ class LanceDBVectorStore(VectorStore): Returns: List of VectorQueryResult objects, ordered by similarity (most similar first) """ + if top_k <= 0: + return [] + table = await self._get_table(namespace) if table is None: logger.debug(f"Table {namespace} does not exist, returning empty results") diff --git a/src/vector_store/turbopuffer.py b/src/vector_store/turbopuffer.py index e7825310..214c5f8a 100644 --- a/src/vector_store/turbopuffer.py +++ b/src/vector_store/turbopuffer.py @@ -143,6 +143,9 @@ class TurbopufferVectorStore(VectorStore): Returns: List of VectorQueryResult objects, ordered by similarity (most similar first) """ + if top_k <= 0: + return [] + ns = self._get_namespace(namespace) try: diff --git a/tests/crud/test_representation_manager.py b/tests/crud/test_representation_manager.py index 3c2f57e4..3f3d6f40 100644 --- a/tests/crud/test_representation_manager.py +++ b/tests/crud/test_representation_manager.py @@ -757,3 +757,48 @@ class TestVectorQueryTopKFloor: top_k = mock_query.await_args.kwargs["top_k"] assert top_k >= 1, f"max_observations={max_observations} gave top_k={top_k}" assert top_k <= max_observations + + @pytest.mark.asyncio + async def test_search_messages_external_returns_empty_without_querying_on_zero_limit( + self, + ): + """Message vector search is the remaining path that still hit Turbopuffer.""" + from src.crud import message as message_crud + + with patch( + "src.crud.message.get_external_vector_store", + return_value=AsyncMock(), + ) as mock_get_store: + for limit in (0, -1): + assert ( + await message_crud._search_messages_external( # pyright: ignore[reportPrivateUsage] + "workspace", + [0.1, 0.2, 0.3], + limit, + ) + == [] + ) + + mock_get_store.assert_not_called() + + @pytest.mark.asyncio + async def test_query_external_vector_message_ids_skips_store_on_zero_limit( + self, + ): + from src.utils import search as search_utils + + with patch( + "src.utils.search.get_external_vector_store", + return_value=AsyncMock(), + ) as mock_get_store: + for limit in (0, -1): + assert ( + await search_utils.query_external_vector_message_ids( + "workspace", + [0.1, 0.2, 0.3], + limit, + ) + == [] + ) + + mock_get_store.assert_not_called() diff --git a/tests/utils/test_agent_tools.py b/tests/utils/test_agent_tools.py index ac45cabf..346296ef 100644 --- a/tests/utils/test_agent_tools.py +++ b/tests/utils/test_agent_tools.py @@ -19,6 +19,7 @@ from src.utils.agent_tools import ( PEER_CARD_ALLOWED_PREFIXES, ObservationsCreatedResult, ToolContext, + _bounded_int, # pyright: ignore[reportPrivateUsage] _handle_create_observations, # pyright: ignore[reportPrivateUsage] _handle_delete_observations, # pyright: ignore[reportPrivateUsage] _handle_extract_preferences, # pyright: ignore[reportPrivateUsage] @@ -952,6 +953,21 @@ class TestSearchMemory: assert query_embeddings[0] == fallback_embeddings[0] +class TestBoundedInt: + """Unit tests for tool-input clamping.""" + + def test_floors_nonpositive_to_one(self) -> None: + assert _bounded_int(0, 10, hi=20) == 1 + assert _bounded_int(-5, 10, hi=20) == 1 + + def test_caps_at_hi(self) -> None: + assert _bounded_int(100, 10, hi=20) == 20 + + def test_falls_back_on_bad_input(self) -> None: + assert _bounded_int("Infinity", 10, hi=20) == 10 + assert _bounded_int(None, 10, hi=20) == 10 + + @pytest.mark.asyncio class TestSearchMessages: """Tests for _handle_search_messages.""" @@ -971,6 +987,39 @@ class TestSearchMessages: assert isinstance(result, str | ToolResult) + async def test_limit_zero_is_floored_to_one( + self, + make_tool_context: Callable[..., ToolContext], + monkeypatch: pytest.MonkeyPatch, + ): + """LLM-supplied limit=0 must not reach the vector store as top_k=0.""" + ctx = make_tool_context() + seen_limits: list[int] = [] + + async def fake_embed(query: str) -> list[float]: + _ = query + return [0.1, 0.2, 0.3] + + async def fake_search_messages( + workspace_name: str, + session_name: str | None, + query: str, + limit: int = 10, + **_kwargs: Any, + ) -> list[Any]: + _ = (workspace_name, session_name, query) + seen_limits.append(limit) + return [] + + monkeypatch.setattr("src.utils.agent_tools.embedding_client.embed", fake_embed) + monkeypatch.setattr( + "src.utils.agent_tools.crud.search_messages", fake_search_messages + ) + + await _handle_search_messages(ctx, {"query": "anything", "limit": 0}) + + assert seen_limits == [1] + @pytest.mark.asyncio class TestGrepMessages: diff --git a/tests/vector_store/test_lancedb.py b/tests/vector_store/test_lancedb.py index 9b52708a..0d36dc56 100644 --- a/tests/vector_store/test_lancedb.py +++ b/tests/vector_store/test_lancedb.py @@ -170,3 +170,24 @@ async def test_query_filters_by_max_distance(store: LanceDBVectorStore) -> None: ) assert [r.id for r in results] == ["vec_close"] + + +@pytest.mark.asyncio +async def test_query_returns_empty_without_opening_table_on_nonpositive_top_k( + store: LanceDBVectorStore, + monkeypatch: pytest.MonkeyPatch, +) -> None: + get_table = AsyncMock(return_value=MagicMock()) + monkeypatch.setattr(store, "_get_table", get_table) + + for top_k in (0, -1): + assert ( + await store.query( + "honcho.msg.test", + [0.1, 0.2, 0.3, 0.4], + top_k=top_k, + ) + == [] + ) + + get_table.assert_not_awaited() diff --git a/tests/vector_store/test_turbopuffer.py b/tests/vector_store/test_turbopuffer.py index cdae44d5..f7ada717 100644 --- a/tests/vector_store/test_turbopuffer.py +++ b/tests/vector_store/test_turbopuffer.py @@ -141,3 +141,26 @@ async def test_query_can_skip_attributes( namespace_mock.query.assert_awaited_once() assert namespace_mock.query.await_args.kwargs["include_attributes"] is False + + +@pytest.mark.asyncio +async def test_query_returns_empty_without_calling_api_on_nonpositive_top_k( + store: TurbopufferVectorStore, +) -> None: + """Turbopuffer rejects top_k < 1; never hit the network with a bad value.""" + namespace_mock = MagicMock() + namespace_mock.query = AsyncMock() + store._get_namespace = MagicMock(return_value=namespace_mock) # pyright: ignore[reportPrivateUsage] + + for top_k in (0, -1): + assert ( + await store.query( + "honcho.msg.test", + [0.1, 0.2, 0.3, 0.4], + top_k=top_k, + ) + == [] + ) + + store._get_namespace.assert_not_called() # pyright: ignore[reportPrivateUsage] + namespace_mock.query.assert_not_awaited() From f3db11ef3a05671a6e1c4779cb2a85e105152d56 Mon Sep 17 00:00:00 2001 From: Erosika Date: Thu, 27 Aug 2026 10:53:10 -0400 Subject: [PATCH 30/50] chore(docs): undo array reformatting in docs.json The integrations change is the only intended edit. The one-item arrays go back to their single-line form and the trailing newline returns. --- docs/docs.json | 71 ++++++++++++-------------------------------------- 1 file changed, 17 insertions(+), 54 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index b1dad2ba..12b5fe01 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -19,21 +19,14 @@ }, "favicon": "/favicon.svg", "contextual": { - "options": [ - "copy", - "view", - "chatgpt", - "claude" - ] + "options": ["copy", "view", "chatgpt", "claude"] }, "navigation": { "versions": [ { "version": "v3.1.0", "api": { - "openapi": [ - "v3/openapi.json" - ] + "openapi": ["v3/openapi.json"] }, "tabs": [ { @@ -97,9 +90,7 @@ "groups": [ { "group": "Overview", - "pages": [ - "v3/guides/overview" - ] + "pages": ["v3/guides/overview"] }, { "group": "Integrations", @@ -139,9 +130,7 @@ }, { "group": "Migrations", - "pages": [ - "v3/guides/migrations/mem0" - ] + "pages": ["v3/guides/migrations/mem0"] } ] }, @@ -171,9 +160,7 @@ "groups": [ { "group": "API Documentation", - "pages": [ - "v3/api-reference/introduction" - ] + "pages": ["v3/api-reference/introduction"] }, { "group": "workspaces", @@ -251,9 +238,7 @@ }, { "group": "miscellaneous", - "pages": [ - "v3/api-reference/endpoint/keys/create-key" - ] + "pages": ["v3/api-reference/endpoint/keys/create-key"] } ] }, @@ -274,9 +259,7 @@ { "version": "v2.5.1", "api": { - "openapi": [ - "v2/openapi.json" - ] + "openapi": ["v2/openapi.json"] }, "tabs": [ { @@ -323,15 +306,11 @@ "groups": [ { "group": "Getting Started", - "pages": [ - "v2/guides/overview" - ] + "pages": ["v2/guides/overview"] }, { "group": "Migrations", - "pages": [ - "v2/migrations/from-mem0" - ] + "pages": ["v2/migrations/from-mem0"] }, { "group": "Integrations", @@ -356,9 +335,7 @@ "groups": [ { "group": "API Documentation", - "pages": [ - "v2/api-reference/introduction" - ] + "pages": ["v2/api-reference/introduction"] }, { "group": "workspaces", @@ -462,9 +439,7 @@ { "version": "v1.1.0", "api": { - "openapi": [ - "openapi.json" - ] + "openapi": ["openapi.json"] }, "tabs": [ { @@ -494,23 +469,15 @@ "groups": [ { "group": "Getting Started", - "pages": [ - "v1/guides/overview", - "v1/guides/streaming-response" - ] + "pages": ["v1/guides/overview", "v1/guides/streaming-response"] }, { "group": "Application Interfaces", - "pages": [ - "v1/guides/discord", - "v1/guides/honcho-mcp" - ] + "pages": ["v1/guides/discord", "v1/guides/honcho-mcp"] }, { "group": "Personal Memory", - "pages": [ - "v1/guides/dialectic-endpoint" - ] + "pages": ["v1/guides/dialectic-endpoint"] } ] }, @@ -519,9 +486,7 @@ "groups": [ { "group": "API Documentation", - "pages": [ - "v1/api-reference/introduction" - ] + "pages": ["v1/api-reference/introduction"] }, { "group": "apps", @@ -569,9 +534,7 @@ }, { "group": "keys", - "pages": [ - "v1/api-reference/endpoint/keys/create-key" - ] + "pages": ["v1/api-reference/endpoint/keys/create-key"] }, { "group": "metamessages", @@ -634,4 +597,4 @@ "tagId": "GTM-NSPT9PJF" } } -} \ No newline at end of file +} From 86f8eb3e6ddae9c79592c794f574377b474e3b96 Mon Sep 17 00:00:00 2001 From: Erosika Date: Thu, 27 Aug 2026 10:53:10 -0400 Subject: [PATCH 31/50] fix(docs): loader re-checks consent before init and captures SPA pageviews If consent is withdrawn while array.js downloads, sync() runs before window.posthog exists and the opt-out is skipped. onload now re-checks granted() and resets loaded so a later re-grant retries. Mintlify swaps pages without a reload, so capture_pageview: 'history_change' records navigation past the landing page. --- docs/posthog-consent.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/posthog-consent.js b/docs/posthog-consent.js index 3e93da82..acb47860 100644 --- a/docs/posthog-consent.js +++ b/docs/posthog-consent.js @@ -22,11 +22,17 @@ loaded = false } s.onload = function () { + // Consent withdrawn while array.js was downloading: skip init, allow a retry on re-grant. + if (!granted()) { + loaded = false + return + } window.posthog.init(KEY, { api_host: 'https://us.i.posthog.com', ui_host: 'https://us.posthog.com', cross_subdomain_cookie: true, person_profiles: 'identified_only', + capture_pageview: 'history_change', }) } document.head.appendChild(s) From 4acd78d45fc22d5315c1b14e8da99077aa7c4738 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:21:10 -0400 Subject: [PATCH 32/50] chore(docs): Add Documentation for Scopes (#1086) * chore(docs): Add Documentation for Scopes * chore(docs): add scopes to API reference, architecture, and design patterns - Add the seven /scopes routes and their schemas to openapi.json, plus the scope/kind fields on chat, representation, session-create, and peer-list schemas; generate the endpoint pages and register a scopes nav group - Add a Scopes subsection and diagram node to the architecture data model - Add scope guidance to design patterns: quick-reference rows, an isolation boundary comparison (workspace / scope / session allowlist), and common mistakes (scope-per-reader, scopes-as-access-control) - Replace the "Underneath the Facade" section in scopes.mdx with behavioral guardrails and pointers to the implementation source * chore(docs): tighten scopes doc to decision-level detail - Drop the recall-resolution diagram (restated the Two Arms table) - Replace the enumerated Rules table with prose; caps and error shapes now live in the API reference schema descriptions - Trim backfill/removal internals to observable behavior and note that a backfilled scope deepens through subsequent dreams * chore(docs): reserve "scope" for the scopes feature Using it as a verb for session design, recall filters, and CLI targeting collides with the named-session-set feature. * chore(docs): clarify the scopes page and document create/status responses The page now leads with projection rather than partition and points at the scopes API; OpenAPI declares the 201/409/404 those routes actually return. * chore(docs): fix broken anchor and core-concepts link The rebase reintroduced a link to a renamed anchor in scopes.mdx, and unified-memory-setup pointed at /core-concepts/, which has no index page. * chore(docs): correct scope arms, listing, and read-surface pointers The Accepts row mixed named-scope with the allowlist arm, kind=scope on the peers list does not return facade ids, and chat/context/search never mentioned scope=. * chore(docs): drop the 1k-token session batching narrative Reasoning no longer waits on a per-session token threshold, so product docs should not tell people to size sessions around that gate. * chore: minor fix --- docs/docs.json | 19 +- .../endpoint/scopes/add-sessions-to-scope.mdx | 3 + .../endpoint/scopes/get-or-create-scope.mdx | 3 + .../endpoint/scopes/get-scope-sessions.mdx | 3 + .../endpoint/scopes/get-scope-status.mdx | 3 + .../endpoint/scopes/get-scope.mdx | 3 + .../endpoint/scopes/get-scopes.mdx | 3 + .../scopes/remove-session-from-scope.mdx | 3 + docs/v3/contributing/troubleshooting.mdx | 1 - .../core-concepts/architecture.mdx | 10 +- .../core-concepts/design-patterns.mdx | 42 +- .../documentation/core-concepts/reasoning.mdx | 12 +- ...es.mdx => directional-representations.mdx} | 10 +- .../features/advanced/overview.mdx | 3 +- .../features/advanced/peer-card.mdx | 2 +- .../features/advanced/queue-status.mdx | 8 +- .../advanced/reasoning-configuration.mdx | 2 +- .../features/advanced/scopes.mdx | 355 +++++++++++++ .../features/advanced/search.mdx | 14 + .../features/advanced/using-filters.mdx | 36 +- docs/v3/documentation/features/chat.mdx | 8 +- .../v3/documentation/features/get-context.mdx | 19 +- docs/v3/documentation/reference/cli.mdx | 4 +- docs/v3/guides/community/pi-honcho-memory.mdx | 2 +- docs/v3/guides/integrations/paperclip.mdx | 6 +- .../guides/recipes/unified-memory-setup.mdx | 17 +- docs/v3/openapi.json | 469 +++++++++++++++++- 27 files changed, 992 insertions(+), 68 deletions(-) create mode 100644 docs/v3/api-reference/endpoint/scopes/add-sessions-to-scope.mdx create mode 100644 docs/v3/api-reference/endpoint/scopes/get-or-create-scope.mdx create mode 100644 docs/v3/api-reference/endpoint/scopes/get-scope-sessions.mdx create mode 100644 docs/v3/api-reference/endpoint/scopes/get-scope-status.mdx create mode 100644 docs/v3/api-reference/endpoint/scopes/get-scope.mdx create mode 100644 docs/v3/api-reference/endpoint/scopes/get-scopes.mdx create mode 100644 docs/v3/api-reference/endpoint/scopes/remove-session-from-scope.mdx rename docs/v3/documentation/features/advanced/{representation-scopes.mdx => directional-representations.mdx} (95%) create mode 100644 docs/v3/documentation/features/advanced/scopes.mdx diff --git a/docs/docs.json b/docs/docs.json index b9d0e498..56fc9448 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -10,6 +10,10 @@ { "source": "/v3/guides/integrations/claudecode", "destination": "/v3/guides/integrations/claude-code" + }, + { + "source": "/v3/documentation/features/advanced/representation-scopes", + "destination": "/v3/documentation/features/advanced/directional-representations" } ], "colors": { @@ -62,7 +66,8 @@ "v3/documentation/features/advanced/reasoning-configuration", "v3/documentation/features/advanced/summarizer", "v3/documentation/features/advanced/peer-card", - "v3/documentation/features/advanced/representation-scopes", + "v3/documentation/features/advanced/directional-representations", + "v3/documentation/features/advanced/scopes", "v3/documentation/features/advanced/dreaming", "v3/documentation/features/advanced/queue-status", "v3/documentation/features/advanced/webhooks", @@ -208,6 +213,18 @@ "v3/api-reference/endpoint/sessions/search-session" ] }, + { + "group": "scopes", + "pages": [ + "v3/api-reference/endpoint/scopes/get-or-create-scope", + "v3/api-reference/endpoint/scopes/get-scopes", + "v3/api-reference/endpoint/scopes/get-scope", + "v3/api-reference/endpoint/scopes/add-sessions-to-scope", + "v3/api-reference/endpoint/scopes/get-scope-sessions", + "v3/api-reference/endpoint/scopes/remove-session-from-scope", + "v3/api-reference/endpoint/scopes/get-scope-status" + ] + }, { "group": "messages", "pages": [ diff --git a/docs/v3/api-reference/endpoint/scopes/add-sessions-to-scope.mdx b/docs/v3/api-reference/endpoint/scopes/add-sessions-to-scope.mdx new file mode 100644 index 00000000..e03b8941 --- /dev/null +++ b/docs/v3/api-reference/endpoint/scopes/add-sessions-to-scope.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions +--- diff --git a/docs/v3/api-reference/endpoint/scopes/get-or-create-scope.mdx b/docs/v3/api-reference/endpoint/scopes/get-or-create-scope.mdx new file mode 100644 index 00000000..9908c7bb --- /dev/null +++ b/docs/v3/api-reference/endpoint/scopes/get-or-create-scope.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/scopes +--- diff --git a/docs/v3/api-reference/endpoint/scopes/get-scope-sessions.mdx b/docs/v3/api-reference/endpoint/scopes/get-scope-sessions.mdx new file mode 100644 index 00000000..d3e9840d --- /dev/null +++ b/docs/v3/api-reference/endpoint/scopes/get-scope-sessions.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/list +--- diff --git a/docs/v3/api-reference/endpoint/scopes/get-scope-status.mdx b/docs/v3/api-reference/endpoint/scopes/get-scope-status.mdx new file mode 100644 index 00000000..489ce9f6 --- /dev/null +++ b/docs/v3/api-reference/endpoint/scopes/get-scope-status.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/scopes/{scope_id}/status +--- diff --git a/docs/v3/api-reference/endpoint/scopes/get-scope.mdx b/docs/v3/api-reference/endpoint/scopes/get-scope.mdx new file mode 100644 index 00000000..192fc74b --- /dev/null +++ b/docs/v3/api-reference/endpoint/scopes/get-scope.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/scopes/{scope_id} +--- diff --git a/docs/v3/api-reference/endpoint/scopes/get-scopes.mdx b/docs/v3/api-reference/endpoint/scopes/get-scopes.mdx new file mode 100644 index 00000000..6362d59d --- /dev/null +++ b/docs/v3/api-reference/endpoint/scopes/get-scopes.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/scopes/list +--- diff --git a/docs/v3/api-reference/endpoint/scopes/remove-session-from-scope.mdx b/docs/v3/api-reference/endpoint/scopes/remove-session-from-scope.mdx new file mode 100644 index 00000000..164e912c --- /dev/null +++ b/docs/v3/api-reference/endpoint/scopes/remove-session-from-scope.mdx @@ -0,0 +1,3 @@ +--- +openapi: delete /v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/{session_id} +--- diff --git a/docs/v3/contributing/troubleshooting.mdx b/docs/v3/contributing/troubleshooting.mdx index e40917ab..76d9a268 100644 --- a/docs/v3/contributing/troubleshooting.mdx +++ b/docs/v3/contributing/troubleshooting.mdx @@ -109,7 +109,6 @@ 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_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/docs/v3/documentation/core-concepts/architecture.mdx b/docs/v3/documentation/core-concepts/architecture.mdx index b3f0f7a1..5035d968 100644 --- a/docs/v3/documentation/core-concepts/architecture.mdx +++ b/docs/v3/documentation/core-concepts/architecture.mdx @@ -34,7 +34,7 @@ Honcho has a hierarchical data model centered around the entities below. Workspaces are the top-level containers in Honcho. They provide complete isolation between different applications or environments, essentially serving as a namespace to keep different workloads separate. You might use separate workspaces for development, staging, and production environments, or to isolate different product lines. They also enable multi-tenant SaaS applications where each customer gets their own isolated workspace with complete data separation. -Authentication is scoped to the workspace level, and configuration settings can be applied workspace-wide to control behavior across all peers and sessions within that workspace. +Authentication is issued at the workspace level, and configuration settings can be applied workspace-wide to control behavior across all peers and sessions within that workspace. --- @@ -50,12 +50,14 @@ You can use peers for any entity that persists over time--individual users in ch ### Sessions -Sessions represent interaction threads or contexts between peers. A session can involve multiple peers and provides temporal boundaries for when a set of interactions starts and ends. This lets you scope context and memory to specific interactions while still maintaining longer-term peer representations that span sessions. +Sessions represent interaction threads or contexts between peers. A session can involve multiple peers and provides temporal boundaries for when a set of interactions starts and ends. This lets you confine context and memory to specific interactions while still maintaining longer-term peer representations that span sessions. -Use sessions to scope things like support tickets, meeting transcripts, learning sessions, or conversations. You can also use single-peer sessions as a way to import external data--create a session with just one peer and structure emails, documents, or files as messages to enrich that peer's representation. +Use sessions for things like support tickets, meeting transcripts, learning sessions, or conversations. You can also use single-peer sessions as a way to import external data--create a session with just one peer and structure emails, documents, or files as messages to enrich that peer's representation. Session-level configuration gives you fine-grained control over perspective-taking behavior. You can configure whether a peer should form representations of other peers in the session, and whether other peers should form representations of them. +Sessions are also the unit of visibility: when one peer's history spans contexts that shouldn't inform each other, you can group sessions into named [scopes](/v3/documentation/features/advanced/scopes) that bound recall to just those sessions. + --- ### Messages @@ -84,7 +86,7 @@ Honcho runs as two cooperating processes: an **API server** that handles request **Write path (synchronous).** A message is stored and a reasoning task is enqueued in the same request; the API returns immediately. Nothing about the reasoning that follows blocks the caller. -**Deriver + Summarizer (async, per-message).** The worker picks up queued tasks in small batches. The Deriver reads new messages and extracts conclusions about the peer--explicit statements and direct deductions. In parallel, the Summarizer periodically rolls up recent messages into short- and long-form session summaries. Both run per-message (well, per-batch) rather than on a schedule. +**Deriver + Summarizer (async, per-message).** The worker picks up queued tasks. The Deriver reads new messages and extracts conclusions about the peer--explicit statements and direct deductions. In parallel, the Summarizer periodically rolls up recent messages into short- and long-form session summaries. Both run per-message rather than on a schedule. **Dreamer (periodic).** On a schedule (or triggered on demand), the Dreamer revisits existing conclusions to consolidate and deepen them: removing redundant or stale ones, drawing inductive conclusions across patterns that span multiple messages, and updating peer cards--compact biographical summaries of a peer. This is where memory gets richer over time, not just larger. diff --git a/docs/v3/documentation/core-concepts/design-patterns.mdx b/docs/v3/documentation/core-concepts/design-patterns.mdx index 9893d3f4..481dd769 100644 --- a/docs/v3/documentation/core-concepts/design-patterns.mdx +++ b/docs/v3/documentation/core-concepts/design-patterns.mdx @@ -12,22 +12,26 @@ Ready to add Honcho to your codebase? The **`/honcho-integration` skill** applie ## Quick Reference -**Workspaces isolate, peers persist, and sessions scope the active context.** +**Workspaces isolate, peers persist, and sessions bound the active context.** | Decision | Recommendation | |----------|---------------| | How many workspaces? | One workspace per application, tool, tenant, or collaboration boundary. Split workspaces only when you need hard isolation between products, customers, environments, or agents. | | When should agents share a workspace? | When agents collaborate over the same product, project, team, user, customer, or game state. Separate them when they should not see or influence each other's memory. | | Who should be a peer? | Any persistent participant whose messages should be attributed or reasoned about: users, agents, assistants, NPCs, students, or customers. Use one peer for the same entity across sessions and platforms. | -| How should I scope sessions? | Scope sessions to the active interaction: per-conversation, per-channel, per-task run, per-project, per-import, or other bounded context. Reuse a session when local context should keep accumulating. | +| How should I divide sessions? | Match each session to the active interaction: per-conversation, per-channel, per-task run, per-project, per-import, or other bounded context. Reuse a session when local context should keep accumulating. | | How does cross-session reasoning work? | Session memory stays local to one session. Peer representations accumulate across every session where the peer is included, and `session.context()` becomes cross-session when you include a peer target. | | Should I set `observe_me: false`? | Yes, for deterministic peers Honcho does not need to model, like bots or tool agents. Still save their messages so other peers have session context. Keep it enabled for users and evolving agents. | | Do I need `observe_others`? | Only when a peer needs its own perspective on another participant, such as in games, multi-agent systems, or parent/subagent workflows. | +| When do I need a scope? | When one peer's history spans contexts that must not leak into each other's recall — but you still want one workspace and one unified peer. Group the confidential sessions into a [scope](/v3/documentation/features/advanced/scopes) and pass it at query time. | +| Perspectives or scopes? | `observe_others` gives a *participant* its own view of another peer. A scope bounds recall to *where things were said*, for a reader that isn't a participant. If the reader is in the session, use perspectives; if you're fencing off a set of sessions, use a scope. | ## Workspace Design A workspace is a hard isolation boundary. **Default to one workspace per application,** and split only at a real privacy, compliance, or product boundary (e.g. per-tenant SaaS, or a tool that needs intentionally isolated memory). Agents that collaborate over the same product, user, or game state belong in the *same* workspace so each can retrieve what the others produced. +If what you actually need is "this part of a peer's history shouldn't inform that assistant," don't split the workspace — that severs the peer's identity too. Use a [scope](/v3/documentation/features/advanced/scopes) instead: the peer stays whole, and recall through the scope sees only its member sessions. + Honcho plugins default to one workspace *per host* (`hermes`, `claude_code`, `cursor`, `opencode`). To unify memory across them, point each at the same workspace — see [Unified Memory Setup](/v3/guides/recipes/unified-memory-setup). @@ -48,11 +52,11 @@ For unified context across Honcho plugins, set the same user peer ID (`peerName` ## Session Design -Sessions define the temporal boundaries of an interaction. How you scope them affects how summaries are generated, how context is retrieved, and when reasoning fires. +Sessions define the temporal boundaries of an interaction. Where you draw those boundaries affects how summaries are generated and how context is retrieved. **Common session patterns** -| Pattern | Session scoped to | Example | +| Pattern | Session covers | Example | |---------|-------------------|---------| | Per-conversation | Each new chat thread | ChatGPT or Claude Code style UI where each thread is a session | | Per-channel | A persistent channel or room | Discord channel, Slack thread | @@ -62,10 +66,6 @@ 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 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** - **Session memory** is local to an interaction — summaries and recent-message context describe only what happened there. @@ -75,14 +75,33 @@ So you can start a session fresh or pull in a peer's long-term memory. [`session --- +## Choosing an Isolation Boundary + +Honcho gives you three boundaries at different strengths. Pick the weakest one that solves your problem: + +| Boundary | Strength | Use when | +|----------|----------|----------| +| **Workspace** | Hard isolation — nothing crosses, including the peer itself | Different products, tenants, or environments | +| **[Scope](/v3/documentation/features/advanced/scopes)** | Recall boundary — one peer, but queries through the scope see only its sessions | One peer's contexts must not leak into each other (clinical vs. billing, per-reseller support) | +| **Session allowlist** (`sessions=[...]`) | Ad-hoc recall restriction, decided per request | The session set varies per query, or you need a quick boundary without provisioning anything | + +Two things scopes are **not**: + +- **Not authorization.** A workspace key reads any session, scoped or not. A scope constrains queries that name it; it doesn't protect data from queries that don't. +- **Not topic filtering.** Scopes bound recall by *where something was said*, not what it's about. A therapy detail mentioned in a billing session lands in the billing scope. If you might ever need a scope boundary, align your session boundaries with your confidentiality boundaries from the start — the session is the unit scopes can enforce. + +--- + ## Common Mistakes - **Splitting one identity across peer IDs** -- If the same user is `alice`, `alice-discord`, and `alice-cursor`, Honcho builds separate representations. Use one stable peer ID when you want unified memory. -- **Too many tiny sessions** -- Summaries and recent messages are session-scoped, and reasoning only fires past ~1,000 tokens per session. Splitting a continuous conversation across many sessions fragments local context and can stall reasoning. Reuse a session when context should flow continuously. +- **Too many tiny sessions** -- Summaries and recent messages are local to one session. Splitting a continuous conversation across many sessions fragments that local context. Reuse a session when context should flow continuously. - **Separating agents that should collaborate** -- If agents need shared product, customer, or team context, put them in the same workspace. Separate workspaces are hard isolation boundaries. - **Leaving `observe_me` on for assistants** -- Wastes reasoning compute on a peer you control. Deterministic behavior doesn't need to be modeled. - **Turning on `observe_others` everywhere** -- Directional representations are powerful, but they add complexity. Use them when peers need distinct perspectives, not just because a session has multiple peers. -- **Forgetting `peer_target` on session context** -- `session.context()` defaults to the active session's summary and recent messages, which are session-scoped. It becomes cross-session only through adding a peer_target which includes the peer representation. +- **A scope per reader** -- Scopes should map to real confidentiality boundaries, not to consumers. If every assistant gets its own scope, you've rebuilt workspace fragmentation inside one workspace, and each projection reasons over a thin slice. Fewer, boundary-shaped scopes; many readers can share one. +- **Treating scopes as access control** -- A scope bounds *recall*, not *access*. Enforce who may query what in your application layer; use scopes to keep the answers themselves from drawing on out-of-bounds sessions. +- **Forgetting `peer_target` on session context** -- `session.context()` defaults to the active session's summary and recent messages, which are local to that session. It becomes cross-session only through adding a peer_target which includes the peer representation. - **Blocking on processing** -- Messages are processed asynchronously in the background. Don't poll or wait for reasoning to complete before continuing your application flow. ## Next Steps @@ -94,6 +113,9 @@ So you can start a session fresh or pull in a peer's long-term memory. [`session Retrieve formatted context from sessions for your LLM + + Bound recall to named sets of sessions + Query Honcho about your peers with natural language diff --git a/docs/v3/documentation/core-concepts/reasoning.mdx b/docs/v3/documentation/core-concepts/reasoning.mdx index aa4de900..cc2add5b 100644 --- a/docs/v3/documentation/core-concepts/reasoning.mdx +++ b/docs/v3/documentation/core-concepts/reasoning.mdx @@ -66,21 +66,11 @@ The reasoning outputs--conclusions, summaries, peer cards--are stored as part of The diagram above shows how agents write messages to Honcho, which triggers reasoning that updates peer representations. Agents can then query representations to get additional context for their next response. -### Token Batching - -Rather than running inference on every individual message, Honcho accumulates messages in the queue and processes them as a batch once the total token count of pending messages for a given peer representation crosses a threshold--roughly **1,000 tokens** at the current batch size. This keeps ingestion costs down, since Honcho charges based on reasoning passes, and ensures each pass has a meaningful amount of context to work with. At ~1,000 tokens the batch comfortably fits in the context window of any modern LLM, so no content is lost. - -If a user sends several short messages in a row (e.g., "yes", "ok", "sounds good"), those messages sit in the queue until enough content has accumulated. Once the threshold is met, the full batch is processed together in a single reasoning call. - - -This batching only applies to **representation** tasks (conclusion extraction). Summary and dream tasks have their own scheduling logic and are not subject to the token threshold. - - ## Balances & Design Choices Off-the-shelf LLMs can perform formal logical reasoning, but they aren't optimized for it. Honcho uses custom models trained specifically for logical rigor (following formal reasoning rules rather than plausible-sounding text), structured output (consistent JSON schema with premises and conclusions), and efficiency (smaller, faster models tuned for this specific task). This allows Honcho to reason more reliably and at lower cost than general-purpose frontier LLMs. -The approach balances quality with practical constraints. Custom models are smaller and cheaper to run, scaffolded conclusions are more token-efficient than raw conversation history, and we batch where appropriate to optimize update frequency. +The approach balances quality with practical constraints. Custom models are smaller and cheaper to run, and scaffolded conclusions are more token-efficient than raw conversation history. Honcho's reasoning capabilities are actively being improved. Current areas of development include enhanced inductive and abductive reasoning, multi-hop and temporal reasoning, and expanded file types and modalities. The system is designed to be extensible--new reasoning capabilities can be added without breaking existing functionality. diff --git a/docs/v3/documentation/features/advanced/representation-scopes.mdx b/docs/v3/documentation/features/advanced/directional-representations.mdx similarity index 95% rename from docs/v3/documentation/features/advanced/representation-scopes.mdx rename to docs/v3/documentation/features/advanced/directional-representations.mdx index 30e2ae32..7a0c9562 100644 --- a/docs/v3/documentation/features/advanced/representation-scopes.mdx +++ b/docs/v3/documentation/features/advanced/directional-representations.mdx @@ -1,6 +1,6 @@ --- -title: 'Representation Scopes' -description: 'Advanced configuration and querying for representations' +title: 'Directional Representations' +description: 'How peers build and query representations of other peers' icon: 'circle' --- @@ -214,7 +214,7 @@ Most applications don't need directional representations. Start with the default Under the hood, Honcho stores representations as (observer, observed) pairs in internal collections: - **Collection**: A unique (observer, observed, workspace) tuple containing documents -- **Documents**: Individual conclusions and artifacts (deductive, inductive, abductive conclusions, summaries, peer cards) with session scoping +- **Documents**: Individual conclusions and artifacts (deductive, inductive, abductive conclusions, summaries, peer cards) with per-session filtering When you retrieve with `target`, Honcho fetches documents from the specific (observer, observed) collection. When you retrieve without `target`, it fetches from the (peer, peer) collection—the peer's self-representation. @@ -225,7 +225,7 @@ This architecture enables: ## Semantic Search Parameters -Both `representation()` and `chat()` support semantic filtering to retrieve a subset of relevant conclusions. You can optionally filter by session — pass `session` to scope to a single session, or use the REST-only [session allowlist](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions) to scope to a set of sessions: +Both `representation()` and `chat()` support semantic filtering to retrieve a subset of relevant conclusions. You can optionally filter by session — pass `session` to restrict to a single session, or use the REST-only [session allowlist](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions) to restrict to a set of sessions: | Parameter | Type | Description | |-----------|------|-------------| @@ -265,7 +265,7 @@ Directional representations update automatically through the reasoning pipeline 2. The message sender has `observe_me=true` (or session-level equivalent) 3. Other peers in the session have `observe_others=true` -The pipeline respects scoping—Honcho's representations reason over messages across all sessions, while directional representations only reason over messages from sessions where the observer was an active participant. +The pipeline respects these boundaries—Honcho's representations reason over messages across all sessions, while directional representations only reason over messages from sessions where the observer was an active participant. ### Peer Join Order Matters diff --git a/docs/v3/documentation/features/advanced/overview.mdx b/docs/v3/documentation/features/advanced/overview.mdx index 09f92fea..d0d7cc2c 100644 --- a/docs/v3/documentation/features/advanced/overview.mdx +++ b/docs/v3/documentation/features/advanced/overview.mdx @@ -12,7 +12,8 @@ Advanced features give you fine-grained control over Honcho's behavior and imple - [Configuration](/v3/documentation/features/advanced/reasoning-configuration) - Configure reasoning models and behavior - [Summarizer](/v3/documentation/features/advanced/summarizer) - Automatic session summarization - [Peer Card](/v3/documentation/features/advanced/peer-card) - Quick-reference profile of stable biographical facts about a peer -- [Representation Scopes](/v3/documentation/features/advanced/representation-scopes) - Directional representations for multi-peer scenarios +- [Directional Representations](/v3/documentation/features/advanced/directional-representations) - How peers build separate representations of each other +- [Scopes](/v3/documentation/features/advanced/scopes) - Named sets of sessions that act as visibility boundaries for recall - [Dreaming](/v3/documentation/features/advanced/dreaming) - Autonomous memory consolidation and self-improvement - [Queue Status](/v3/documentation/features/advanced/queue-status) - Monitor background processing and reasoning tasks diff --git a/docs/v3/documentation/features/advanced/peer-card.mdx b/docs/v3/documentation/features/advanced/peer-card.mdx index fbc08772..5db6a1db 100644 --- a/docs/v3/documentation/features/advanced/peer-card.mdx +++ b/docs/v3/documentation/features/advanced/peer-card.mdx @@ -79,7 +79,7 @@ console.log(card); ## Directional Peer Cards -Peer cards follow the same observer-observed model as [representations](/v3/documentation/features/advanced/representation-scopes). When `observe_others` is enabled, a peer can have a **different** card for each peer it observes. +Peer cards follow the same observer-observed model as [representations](/v3/documentation/features/advanced/directional-representations). When `observe_others` is enabled, a peer can have a **different** card for each peer it observes. For example, if Alice and Bob are in a session together and Alice has `observe_others: true`, Alice will build her own peer card for Bob--separate from Honcho's peer card for Bob. You can read and write these directional cards using the `target` parameter. diff --git a/docs/v3/documentation/features/advanced/queue-status.mdx b/docs/v3/documentation/features/advanced/queue-status.mdx index 7c4d363c..809c2e9f 100644 --- a/docs/v3/documentation/features/advanced/queue-status.mdx +++ b/docs/v3/documentation/features/advanced/queue-status.mdx @@ -8,9 +8,9 @@ Whenever messages are stored in Honcho, background processes kick off to [reason Reasoning is an asynchronous process and will not immediately generate insights for the latest message you've sent. This is -by design: we want to reason efficiently over batches of messages -rather than assessing each message in a vacuum. Honcho provides -several utilities to check the status of the queue. +by design: Honcho reasons in the background rather than on the +write path. Honcho provides several utilities to check the status +of the queue. ```python Python @@ -95,7 +95,7 @@ not the total number of items ever processed. The `queue_status` method can take additional -parameters to scope the status to a specific work unit: +parameters to filter the status by a matching observer, sender, or session: ```python Python diff --git a/docs/v3/documentation/features/advanced/reasoning-configuration.mdx b/docs/v3/documentation/features/advanced/reasoning-configuration.mdx index 8642254a..492dd4b9 100644 --- a/docs/v3/documentation/features/advanced/reasoning-configuration.mdx +++ b/docs/v3/documentation/features/advanced/reasoning-configuration.mdx @@ -157,7 +157,7 @@ You may therefore disable observation of a peer by setting the `observe_me` flag If the peer has a session-level configuration, it will override this configuration. If the flag is not set, or is set to `true`, the peer will be observed. -For session-level observation controls and local representations (where peers build separate models of each other), see [Representation Scopes](/v3/documentation/features/advanced/representation-scopes). +For session-level observation controls and local representations (where peers build separate models of each other), see [Directional Representations](/v3/documentation/features/advanced/directional-representations). diff --git a/docs/v3/documentation/features/advanced/scopes.mdx b/docs/v3/documentation/features/advanced/scopes.mdx new file mode 100644 index 00000000..4fa827fd --- /dev/null +++ b/docs/v3/documentation/features/advanced/scopes.mdx @@ -0,0 +1,355 @@ +--- +title: 'Scopes' +description: 'Named sets of sessions that act as visibility boundaries for recall' +icon: 'shield-halved' +--- + +A **scope** is a named set of sessions that acts as a visibility boundary. Recall +performed through a scope sees only what happened in that scope's sessions, +while the peer keeps its single unified representation of everything it has ever +participated in. + +Use scopes when one peer's history spans contexts that must not leak into each +other — a therapy app where the clinical sessions must not inform the billing +assistant, a support product where a reseller's agent may only answer from its +own tickets, a multi-tenant deployment where one human works across tenants. + +## Projection, Not Partition + +The peer keeps one representation. A scope is a **projection** of it: a view +built only from evidence in the member sessions. + +```mermaid +graph TB + P[Peer: user-123
one unified representation] + + P --> S1[session: therapy-1] + P --> S2[session: therapy-2] + P --> S3[session: billing-1] + P --> S4[session: onboarding-1] + + SC1[scope: therapy] -.->|projects| S1 + SC1 -.->|projects| S2 + SC2[scope: billing] -.->|projects| S3 + + style P fill:#B6DBFF,stroke:#333,color:#000 + style S1 fill:#B6DBFF,stroke:#333,color:#000 + style S2 fill:#B6DBFF,stroke:#333,color:#000 + style S3 fill:#B6DBFF,stroke:#333,color:#000 + style S4 fill:#B6DBFF,stroke:#333,color:#000 + style SC1 fill:#FFE0B2,stroke:#333,color:#000 + style SC2 fill:#FFE0B2,stroke:#333,color:#000 +``` + +- **Sessions can belong to more than one scope.** Membership is many-to-many. +- **Sessions can belong to no scope.** `onboarding-1` above is reachable + by an unscoped request and by nothing else. +- **An unscoped request still sees everything.** A scope constrains the requests + that name it; it does not hide the sessions from requests that don't. + + +Scopes are a recall boundary, not an authorization boundary. Who may call the +API is still governed by workspace, session, and peer keys. + + +## The Two Arms + +There are two ways to confine recall, and they behave differently. Picking the +wrong one is the most common mistake with this feature. + +| | `scope="therapy"` (named scope) | `sessions=[...]` / `scope=["a","b"]` (allowlist) | +|---|---|---| +| **Mechanism** | Reads the scope's own representation of the peer | Restricts the peer's own representation to a set of sessions | +| **Conclusions** | All levels — `explicit`, plus `deductive` / `inductive` reasoned **within** the scope | `explicit` only | +| **Reasoning chains** | Available | Unavailable | +| **Setup required** | Yes — create the scope, add sessions, wait for backfill | None — pass session IDs ad hoc | +| **Accepts** | One scope name | A list of up to 100 scope names, or up to 1,000 session IDs | + +### Named scope: depth + +Passing a **single** scope name swaps the observer. Recall runs against the +scope's own view of the target peer, which the deriver and dreamer have been +building from the scope's member sessions all along. That view contains +higher-order inferences — but only ones reasoned from evidence inside the scope. + +```python +answer = user.chat("What is stressing them out?", scope="therapy") +``` + +This is the arm you want for a durable, meaningful boundary. + +### Allowlist: breadth + +Passing a **list** of scopes, or a bare list of session IDs, keeps the peer as +the observer and restricts recall to the union of those sessions. Because a +dream-derived conclusion is synthesized across sessions, it cannot be attributed +to any one of them — so this arm recalls `explicit` conclusions only, and answers +from directly-stated facts rather than inference. + +```python +answer = user.chat("What did they say about billing?", sessions=[s1, s2]) +answer = user.chat("What did they say?", scope=["therapy", "intake"]) +``` + +Reach for this when the set of sessions is decided per-request, or when you want +a quick boundary without provisioning a scope. See +[Scoping Recall to Sessions](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions) +for the full allowlist rules. + + +A list of scopes is the allowlist arm, not "several named scopes at once". It +gives you the union of their *sessions*, at explicit-only depth — it does not +give you the union of their reasoned views. If you need depth, query one scope. + + +## Creating a Scope and Managing Membership + + +```python Python +from honcho import Honcho + +honcho = Honcho(workspace_id="my-app") + +# Get or create — idempotent; passing metadata updates the existing scope +therapy = honcho.scope("therapy") + +# Add existing sessions (max 100 per call) +therapy.add_sessions(["therapy-session-1", "therapy-session-2"]) + +# Or attach at session creation — the scope is created if it doesn't exist +session = honcho.session("therapy-session-3", scopes=["therapy"]) + +# Inspect +for s in therapy.sessions(): + print(s.id) + +therapy.remove_session("therapy-session-1") + +for scope in honcho.scopes(): + print(scope.id, scope.metadata) +``` + +```typescript TypeScript +import { Honcho } from "@honcho-ai/sdk"; + +const honcho = new Honcho({ workspaceId: "my-app" }); + +// Get or create — idempotent; passing metadata updates the existing scope +const therapy = await honcho.scope("therapy"); + +// Add existing sessions (max 100 per call) +await therapy.addSessions(["therapy-session-1", "therapy-session-2"]); + +// Or attach at session creation — the scope is created if it doesn't exist +const session = await honcho.session("therapy-session-3", { + scopes: ["therapy"], +}); + +// Inspect +for await (const s of await therapy.sessions()) { + console.log(s.id); +} + +await therapy.removeSession("therapy-session-1"); + +for await (const scope of await honcho.scopes()) { + console.log(scope.id, scope.metadata); +} +``` + +```bash REST +# Get or create (201 created / 200 existing) +curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes" \ + -H "Authorization: Bearer $HONCHO_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"id": "therapy"}' + +# Add sessions +curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions" \ + -H "Authorization: Bearer $HONCHO_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"session_ids": ["therapy-session-1", "therapy-session-2"]}' + +# List membership +curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions/list" \ + -H "Authorization: Bearer $HONCHO_API_KEY" + +# Remove one session +curl -X DELETE "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions/therapy-session-1" \ + -H "Authorization: Bearer $HONCHO_API_KEY" +``` + + +Scope IDs are unprefixed, must match `^[a-zA-Z0-9_-]+$`, and are at most 506 +characters. Get-or-create is idempotent: if the scope already exists, the same +call returns it, and any `metadata` you pass is written onto it. + + +Every scopes route — and every read that passes `scope` — requires a +**workspace-level or admin key**. A scope's membership can exceed any single +peer's own session membership, so peer- and session-scoped keys are rejected +with `401`. + + +## Membership Changes Copy, They Don't Re-Derive + +A session added to a scope while empty needs nothing special: messages sent +after the change flow into the scope through the normal deriver fan-out. + +A session that **already has messages** is handled retroactively by a background +job rather than by re-running the LLM over its history: adding it copies the +session's existing `explicit` conclusions into the scope, and removing it +retracts that session's contributions — including conclusions derived from them. +Copying rather than re-deriving is why membership changes are cheap and +deterministic — and why they are also **asynchronous**. It also means a freshly +backfilled scope starts at explicit depth and accrues deeper reasoning through +subsequent dreams. + +Poll `status()` to tell "the scope hasn't caught up yet" apart from "the scope +has caught up and there is genuinely nothing to recall": + + +```python Python +therapy.add_sessions(["old-session-with-history"]) + +status = therapy.status() +# {"old-session-with-history": {"state": "pending", "updated_at": "..."}} +# → later: {"state": "completed", "docs_copied": 42, "updated_at": "..."} +``` + +```typescript TypeScript +await therapy.addSessions(["old-session-with-history"]); + +const status = await therapy.status(); +// { "old-session-with-history": { state: "pending", updatedAt: "..." } } +``` + +```bash REST +curl "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/status" \ + -H "Authorization: Bearer $HONCHO_API_KEY" +``` + + +`state` is `pending`, `completed`, or `failed`; `docs_copied` appears once a +backfill completes. Only sessions that have had a backfill enqueued appear, so an +empty result means none have — not that the scope is empty. + +## Reading Through a Scope + +`scope` is accepted on these surfaces: + +| Surface | Accepts | Notes | +|---------|---------|-------| +| [`peer.chat()`](/v3/documentation/features/chat) | one scope or a list | Confines both conclusion recall and the messages the agent reads | +| `peer.representation()` | one scope or a list | Confines conclusion recall | +| [`session.context()`](/v3/documentation/features/get-context) | one scope only | Perspective source for `peer_target`'s representation and card. Requires `peer_target`; mutually exclusive with `peer_perspective` | +| `honcho.search()` | one scope only | Restricts message search to the scope's member sessions | +| `honcho.chat()` | one scope or a list | Always the allowlist arm — even a single name. There is no observer to swap | + + +```python Python +# Chat — answered only from the therapy sessions +answer = user.chat("What is stressing them out?", scope="therapy") + +# Representation +rep = user.representation(scope="therapy") + +# Session context, using the scope as the perspective source +ctx = session.context(peer_target="user-123", scope="therapy") + +# Message search, restricted to the scope's sessions +messages = honcho.search("insomnia", scope="therapy") +``` + +```typescript TypeScript +// Chat — answered only from the therapy sessions +const answer = await user.chat("What is stressing them out?", { + scope: "therapy", +}); + +// Representation +const rep = await user.representation({ scope: "therapy" }); + +// Session context, using the scope as the perspective source +const ctx = await session.context({ + peerTarget: "user-123", + scope: "therapy", +}); + +// Message search, restricted to the scope's sessions +const messages = await honcho.search("insomnia", { scope: "therapy" }); +``` + +```bash REST +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 is stressing them out?", "scope": "therapy"}' +``` + + +### Rules + +`scope` is mutually exclusive with `filters`, `sessions`, and `session` / +`session_id` — and on session context, with `peer_perspective` (where it also +requires `peer_target`). Like the session allowlist, it **fails closed**: a +contradiction is rejected with a `422` rather than silently widened, a scope +with no member sessions recalls nothing, and an empty list (`scope=[]`) is +rejected rather than treated as "no boundary". Per-surface caps and error +shapes are in the [API reference](/v3/api-reference/endpoint/scopes/get-or-create-scope). + +## Provenance, Not Topic + +A scope is defined by **where a fact was said**, not what it is about. + +If a user mentions a therapy detail in a billing session, that conclusion is +formed from the billing session and lands in the `billing` scope. Querying +`scope="therapy"` will not find it, and querying `scope="billing"` will. + + +Scopes give you provenance-based privacy, not topic-based privacy. If you need +"no clinical content in the billing assistant's answers" regardless of where it +was said, that is content classification and has to be enforced above Honcho — +by controlling what reaches which session in the first place, or by filtering +the answer. + + +Design accordingly: keep the session boundary aligned with the confidentiality +boundary you actually care about, since that session boundary is the one scopes +can enforce. + +## Guardrails + +A few behaviors follow from how scopes are built: + +- **The `scope.` prefix is reserved.** Creating a peer, or adding a peer to a + session, with a `scope.`-prefixed name is rejected. +- **List scopes through the scopes surface.** `honcho.scopes()` / + `POST /scopes/list` returns unprefixed ids. Peer listings hide scopes by + default; `kind="scope"` on `POST /peers/list` returns the backing peers named + `scope.`, and `kind="all"` includes both regular peers and those backing + peers. +- **A scope can't be observed.** No representation is formed *of* a scope, so a + scope is rejected in any `target` / observed position, including as a dream + target. +- **Membership is managed only through the scopes surface.** The session + add-peers, set-peers, and remove-peers routes reject scope names and point you + at `/scopes/{scope_id}/sessions` or the `scopes` field on session create. + +If you want the exact mechanics for scopes, read: [`src/routers/scopes.py`](https://github.com/plastic-labs/honcho/blob/main/src/routers/scopes.py), +[`src/crud/scope.py`](https://github.com/plastic-labs/honcho/blob/main/src/crud/scope.py), +and [`src/deriver/scope_backfill.py`](https://github.com/plastic-labs/honcho/blob/main/src/deriver/scope_backfill.py). + +## Limits + +| Limit | Value | +|-------|-------| +| Scope ID length | 506 characters | +| Scope ID charset | `^[a-zA-Z0-9_-]+$` | +| Sessions per membership call | 100 | +| Scopes in one `scope` read option | 100 | +| Scopes on session create | 100 | +| Sessions in a resolved allowlist | 1,000 | + +Full request and response shapes are in the +[API reference](/v3/api-reference/endpoint/scopes/get-or-create-scope). diff --git a/docs/v3/documentation/features/advanced/search.mdx b/docs/v3/documentation/features/advanced/search.mdx index a95fff15..cf40648c 100644 --- a/docs/v3/documentation/features/advanced/search.mdx +++ b/docs/v3/documentation/features/advanced/search.mdx @@ -49,6 +49,20 @@ import { Honcho } from "@honcho-ai/sdk"; ```
+Pass `scope` on workspace search to restrict matches to that +[scope](/v3/documentation/features/advanced/scopes)'s member sessions. A scope +with no members returns nothing. + + +```python Python +results = honcho.search("budget planning", scope="therapy") +``` + +```typescript TypeScript +const results = await honcho.search("budget planning", { scope: "therapy" }); +``` + + ### Session Search Search within a specific session's conversation history: diff --git a/docs/v3/documentation/features/advanced/using-filters.mdx b/docs/v3/documentation/features/advanced/using-filters.mdx index 4a1eefec..e28096ab 100644 --- a/docs/v3/documentation/features/advanced/using-filters.mdx +++ b/docs/v3/documentation/features/advanced/using-filters.mdx @@ -727,7 +727,7 @@ messages = session.messages(filters={ ### Filtering Conclusions -Conclusions are scoped to an observer/observed peer pair (accessed via +Conclusions belong 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. @@ -843,7 +843,7 @@ a **session allowlist**, restricting what the request can recall to the sessions you name — conclusions on both endpoints, and on chat the messages the agent reads as well. -This is how you scope recall to more than one session. The `session_id` +This is how you restrict recall to more than one session. The `session_id` parameter pins a request to exactly one session; an allowlist accepts a set. Only the `session_id` key is supported here, in three shapes: @@ -875,10 +875,34 @@ curl -X POST "$HONCHO_URL/v3/workspaces/my-app/peers/user-123/representation" \ ```
+Both SDKs expose this as a `sessions` option, which goes on the wire as the +`filters` body above: + + +```python Python +answer = user.chat("What did the user ask about billing?", + sessions=["support-chat-1", "support-chat-2"]) + +rep = user.representation(sessions=["support-chat-1", "support-chat-2"]) +``` + +```typescript TypeScript +const answer = await user.chat("What did the user ask about billing?", { + sessions: ["support-chat-1", "support-chat-2"], +}); + +const rep = await user.representation({ + sessions: ["support-chat-1", "support-chat-2"], +}); +``` + + -The session allowlist is REST-only today. The SDKs cover the single-session case -with `session`, but do not yet expose the allowlist — call the endpoint directly -when you need a set of sessions. +If the same set of sessions is a boundary you reuse, name it: a +[scope](/v3/documentation/features/advanced/scopes) is a persistent version of +this allowlist, and querying a single scope recalls at full depth rather than +`explicit`-only. `sessions` is the right tool when the set is decided +per-request. ### Rules @@ -907,7 +931,7 @@ can only narrow. ### What Changes Under an Allowlist -Scoping recall by session narrows what the reasoning agent can draw on: +Restricting recall by session narrows what the reasoning agent can draw on: - **Only `explicit` conclusions are recalled.** Dream-derived conclusions (`deductive`, `inductive`) are synthesized across sessions, so they can't be diff --git a/docs/v3/documentation/features/chat.mdx b/docs/v3/documentation/features/chat.mdx index b6e7e96c..c83a0ac2 100644 --- a/docs/v3/documentation/features/chat.mdx +++ b/docs/v3/documentation/features/chat.mdx @@ -110,11 +110,17 @@ const answer = await peer.chat("What did the user ask about?", { session: sessio ```
-To scope a request to a *set* of sessions, use the session allowlist — a +To restrict a request to a *set* of sessions, use the session allowlist — a constrained `filters` body on the endpoint. See [Scoping Recall to Sessions](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions) for the accepted shapes and for what an allowlist changes about the answer. +Pass `scope="therapy"` to answer from that [scope](/v3/documentation/features/advanced/scopes)'s +own representation of the peer. A list (`scope=["therapy", "intake"]`) is an +allowlist of those scopes' sessions, not named-scope depth. +`honcho.chat(scope=)` is always the allowlist arm, even with one name. Details +are on the [scopes page](/v3/documentation/features/advanced/scopes#the-two-arms). + ## 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: diff --git a/docs/v3/documentation/features/get-context.mdx b/docs/v3/documentation/features/get-context.mdx index 60ff45bc..1fa9fec1 100644 --- a/docs/v3/documentation/features/get-context.mdx +++ b/docs/v3/documentation/features/get-context.mdx @@ -99,7 +99,7 @@ context = session.context(summary=False, tokens=2000) ### Peer Representation in Context -You can include a peer's [representation](/v3/documentation/core-concepts/representation) and peer card in the context by specifying `peer_target`. This is useful for providing the LLM with knowledge about a specific peer. +You can include a peer's [representation](/v3/documentation/core-concepts/representation) and peer card in the context by specifying `peer_target`. This is useful for providing the LLM with knowledge about a specific peer. Pass `scope` with `peer_target` to use a [named scope](/v3/documentation/features/advanced/scopes) as the perspective source (`scope` is mutually exclusive with `peer_perspective` and requires a workspace-level or admin-level key). ```python Python @@ -119,6 +119,14 @@ context = session.context( peer_target="user-123", peer_perspective="assistant" # From assistant's viewpoint ) + +# Or use a named scope as the perspective source (requires peer_target; +# mutually exclusive with peer_perspective) +context = session.context( + tokens=2000, + peer_target="user-123", + scope="therapy", +) ``` ```typescript TypeScript @@ -139,6 +147,14 @@ context = session.context( peerTarget: "user-123", peerPerspective: "assistant" // From assistant's viewpoint }); + + // Or use a named scope as the perspective source (requires peerTarget; + // mutually exclusive with peerPerspective) + const scopedContext = await session.context({ + tokens: 2000, + peerTarget: "user-123", + scope: "therapy", + }); })(); ``` @@ -211,6 +227,7 @@ context = session.context( | `tokens` | `int` | Maximum tokens to include | | `peer_target` | `str` | Peer ID to include representation for | | `peer_perspective` | `str` | Peer ID for perspective (requires peer_target) | +| `scope` | `str` | Named scope as the perspective source for `peer_target`'s representation and card. Requires `peer_target` and a workspace-level or admin-level key; mutually exclusive with `peer_perspective`. See [Scopes](/v3/documentation/features/advanced/scopes) | | `search_query` | `str` | Query for semantic search (requires peer_target) | | `limit_to_session` | `bool` | Limit to session conclusions only | | `search_top_k` | `int` | Semantic search results to include (1-100) | diff --git a/docs/v3/documentation/reference/cli.mdx b/docs/v3/documentation/reference/cli.mdx index 9686199d..74b5fb34 100644 --- a/docs/v3/documentation/reference/cli.mdx +++ b/docs/v3/documentation/reference/cli.mdx @@ -89,14 +89,14 @@ and are stored under `oauth` without deleting a shared `apiKey`. } ``` -Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s` +Per-command targeting (workspace / peer / session) is handled via `-w` / `-p` / `-s` flags or `HONCHO_*` env vars. **Not** persisted as CLI defaults. This is deliberate: every invocation is explicit about what it operates on. ### Runtime overrides -Workspace, peer, and session scoping are **per-command only** — pass flags or +Workspace, peer, and session targeting are **per-command only** — pass flags or `HONCHO_*` env vars on every invocation. ```bash diff --git a/docs/v3/guides/community/pi-honcho-memory.mdx b/docs/v3/guides/community/pi-honcho-memory.mdx index 7974a94b..6ef1c5e0 100644 --- a/docs/v3/guides/community/pi-honcho-memory.mdx +++ b/docs/v3/guides/community/pi-honcho-memory.mdx @@ -23,7 +23,7 @@ The Honcho plugin is a community integration. See the [plugin README](https://gi ## How It Works -The extension hooks into pi's extension system. It automatically syncs user and assistant messages to Honcho after each agent response, injects cached user profile and project context into the system prompt with zero network latency, and exposes LLM tools (`honcho_search`, `honcho_chat`, `honcho_remember`) for active memory operations. Session scoping is configurable — memory can be shared per repo, per git branch, or per directory. If Honcho is unavailable, pi continues working normally. +The extension hooks into pi's extension system. It automatically syncs user and assistant messages to Honcho after each agent response, injects cached user profile and project context into the system prompt with zero network latency, and exposes LLM tools (`honcho_search`, `honcho_chat`, `honcho_remember`) for active memory operations. Session mapping is configurable — memory can be shared per repo, per git branch, or per directory. If Honcho is unavailable, pi continues working normally. ## Next Steps diff --git a/docs/v3/guides/integrations/paperclip.mdx b/docs/v3/guides/integrations/paperclip.mdx index 3b2aa156..bbda60f3 100644 --- a/docs/v3/guides/integrations/paperclip.mdx +++ b/docs/v3/guides/integrations/paperclip.mdx @@ -61,11 +61,11 @@ In practice, that means agent peers can both be observed by Honcho and form repr ## How It Works -### Identity And Scope +### Identity And Mapping The integration breaks down into four parts: -- **Identity and scope** - each Paperclip company maps to a Honcho workspace, agents and human actors map to peers, and issues map to sessions. +- **Identity and mapping** - each Paperclip company maps to a Honcho workspace, agents and human actors map to peers, and issues map to sessions. - **What gets copied into Honcho** - issue comments and document revisions sync into Honcho, with document content sectioned and normalized message content capped before ingestion. - **What operators get** - operators get a plugin settings page, migration preview/status data, including a per-issue migration mapping preview, repair tools, and an issue-level `Memory` tab. - **What agents get** - agents get Honcho retrieval and peer-chat tools inside Paperclip. @@ -130,7 +130,7 @@ The plugin registers the following Honcho tools for Paperclip agents: Review how workspaces, peers, and sessions fit together.
- + Review how `observe_me` and `observe_others` change what peers can model.
diff --git a/docs/v3/guides/recipes/unified-memory-setup.mdx b/docs/v3/guides/recipes/unified-memory-setup.mdx index ea921cc3..14ad61a8 100644 --- a/docs/v3/guides/recipes/unified-memory-setup.mdx +++ b/docs/v3/guides/recipes/unified-memory-setup.mdx @@ -109,8 +109,9 @@ and `aiPeer` there. See the [Hermes guide](/v3/guides/integrations/hermes) for t A scheduled job feeds external data (emails, meeting notes, CRM records) into Honcho. Attribute the messages to the peer the data is *about* — not to an agent — and group -them into a session. **How you scope that session is the main decision here**, because -it controls when Honcho reasons over the data (more on that below). +them into a session. Match the session to how you want that import's local context +to accumulate: a per-run session like `email-import-{date}`, or one ongoing +per-source session like `email-import-gmail`. ```python from datetime import datetime, timezone @@ -131,18 +132,6 @@ for i in range(0, len(messages), 100): session.add_messages(messages[i:i + 100]) ``` -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 flush - later with little context. - The [Gmail](/v3/guides/gmail) and [Granola](/v3/guides/granola) guides are related import examples. diff --git a/docs/v3/openapi.json b/docs/v3/openapi.json index 43ecc6a4..b2bc0adf 100644 --- a/docs/v3/openapi.json +++ b/docs/v3/openapi.json @@ -1574,7 +1574,7 @@ "get": { "tags": ["sessions"], "summary": "Get Peer Config", - "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.", + "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 \u2014 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": [ @@ -2234,6 +2234,343 @@ } } }, + "/v3/workspaces/{workspace_id}/scopes": { + "post": { + "tags": ["scopes"], + "summary": "Get Or Create Scope", + "description": "Get a Scope by ID or create a new Scope with the given ID.\n\nReturns 201 when the scope is created and 200 when it already exists.\nA pre-existing peer occupying the scope's reserved internal name is never\nadopted; that conflict returns 409.", + "operationId": "get_or_create_scope_v3_workspaces__workspace_id__scopes_post", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Workspace Id" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScopeCreate", + "description": "Scope creation parameters" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Scope" } + } + } + }, + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Scope" } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, + "/v3/workspaces/{workspace_id}/scopes/list": { + "post": { + "tags": ["scopes"], + "summary": "Get Scopes", + "description": "Get all Scopes for a Workspace. Results are paginated.", + "operationId": "get_scopes_v3_workspaces__workspace_id__scopes_list_post", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Workspace Id" } + }, + { + "name": "reverse", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Whether to reverse the order of results", + "default": false, + "title": "Reverse" + }, + "description": "Whether to reverse the order of results" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Page_Scope_" } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, + "/v3/workspaces/{workspace_id}/scopes/{scope_id}": { + "get": { + "tags": ["scopes"], + "summary": "Get Scope", + "description": "Get a single Scope by ID.", + "operationId": "get_scope_v3_workspaces__workspace_id__scopes__scope_id__get", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Workspace Id" } + }, + { + "name": "scope_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Scope Id" } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Scope" } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, + "/v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions": { + "post": { + "tags": ["scopes"], + "summary": "Add Sessions To Scope", + "description": "Add Sessions to a Scope.\n\nAll named sessions must already exist (404 otherwise). Adding a session that\nis already a member is a no-op. List the resulting membership with\n`POST /scopes/{scope_id}/sessions/list`.\n\nNote: any added session that already has messages triggers an asynchronous\nbackfill-by-copy of its existing documents into the scope; track progress\nvia ``GET /scopes/{scope_id}/status``.", + "operationId": "add_sessions_to_scope_v3_workspaces__workspace_id__scopes__scope_id__sessions_post", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Workspace Id" } + }, + { + "name": "scope_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Scope Id" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScopeSessionsAdd", + "description": "IDs of the sessions to add to the scope" + } + } + } + }, + "responses": { + "204": { "description": "Successful Response" }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, + "/v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/{session_id}": { + "delete": { + "tags": ["scopes"], + "summary": "Remove Session From Scope", + "description": "Remove a Session from a Scope.\n\nNote: documents copied/derived while the session was a member are\nreconciled asynchronously \u2014 the session's explicit copies are soft-deleted\nfrom the scope, dependent derived documents follow (fail-closed), and the\nscope's card is rebuilt from the remaining evidence.", + "operationId": "remove_session_from_scope_v3_workspaces__workspace_id__scopes__scope_id__sessions__session_id__delete", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Workspace Id" } + }, + { + "name": "scope_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Scope Id" } + }, + { + "name": "session_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Session Id" } + } + ], + "responses": { + "204": { "description": "Successful Response" }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, + "/v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/list": { + "post": { + "tags": ["scopes"], + "summary": "Get Scope Sessions", + "description": "Get the Sessions that are members of a Scope, paginated.\n\nOrdered by how long each session has been a member: longest-standing member\nfirst, or most recently added first when `reverse` is true.", + "operationId": "get_scope_sessions_v3_workspaces__workspace_id__scopes__scope_id__sessions_list_post", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Workspace Id" } + }, + { + "name": "scope_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Scope Id" } + }, + { + "name": "reverse", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Whether to reverse the order of results", + "default": false, + "title": "Reverse" + }, + "description": "Whether to reverse the order of results" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Page_Session_" } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, + "/v3/workspaces/{workspace_id}/scopes/{scope_id}/status": { + "get": { + "tags": ["scopes"], + "summary": "Get Scope Status", + "description": "Get the backfill/reconciliation job status for a Scope.\n\nReturns a per-session map of the backfill job state (pending / completed /\nfailed) with the number of documents copied once complete. Empty when no\nbackfill has ever been enqueued for the scope.", + "operationId": "get_scope_status_v3_workspaces__workspace_id__scopes__scope_id__status_get", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Workspace Id" } + }, + { + "name": "scope_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Scope Id" } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ScopeStatus" } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, "/v3/workspaces/{workspace_id}/conclusions": { "post": { "tags": ["conclusions"], @@ -2917,6 +3254,20 @@ "title": "Filters", "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." }, + "scope": { + "anyOf": [ + { "type": "string" }, + { + "items": { "type": "string" }, + "type": "array", + "maxItems": 100, + "minItems": 1 + }, + { "type": "null" } + ], + "title": "Scope", + "description": "Optional (unprefixed) scope name(s) to confine recall. A single scope answers from the scope's own representation of the target peer: conclusion recall is confined to what the scope observed and message recall to the scope's member sessions. A list of scopes restricts recall to the union of the scopes' member sessions (explicit allowlist, fail-closed: an empty union recalls nothing). Mutually exclusive with `filters` and `session_id`. Requires a workspace- or admin-level key." + }, "target": { "anyOf": [{ "type": "string" }, { "type": "null" }], "title": "Target", @@ -3174,6 +3525,22 @@ "required": ["items", "total", "page", "size", "pages"], "title": "Page[Peer]" }, + "Page_Scope_": { + "properties": { + "items": { + "items": { "$ref": "#/components/schemas/Scope" }, + "type": "array", + "title": "Items" + }, + "total": { "type": "integer", "minimum": 0.0, "title": "Total" }, + "page": { "type": "integer", "minimum": 1.0, "title": "Page" }, + "size": { "type": "integer", "minimum": 1.0, "title": "Size" }, + "pages": { "type": "integer", "minimum": 0.0, "title": "Pages" } + }, + "type": "object", + "required": ["items", "total", "page", "size", "pages"], + "title": "Page[Scope]" + }, "Page_Session_": { "properties": { "items": { @@ -3356,6 +3723,14 @@ { "type": "null" } ], "title": "Filters" + }, + "kind": { + "anyOf": [ + { "type": "string", "enum": ["scope", "all"] }, + { "type": "null" } + ], + "title": "Kind", + "description": "Which kinds of peers to list. Omitted (default): regular peers only (scope peers are excluded). 'scope': scope peers only. 'all': every peer." } }, "type": "object", @@ -3376,6 +3751,20 @@ "title": "Filters", "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." }, + "scope": { + "anyOf": [ + { "type": "string" }, + { + "items": { "type": "string" }, + "type": "array", + "maxItems": 100, + "minItems": 1 + }, + { "type": "null" } + ], + "title": "Scope", + "description": "Optional (unprefixed) scope name(s) to confine the representation. A single scope reads the scope's own representation of the target peer, formed only from the scope's member sessions. A list of scopes restricts the representation to conclusions from the union of the scopes' member sessions (explicit allowlist, fail-closed: an empty union yields an empty representation). Mutually exclusive with `filters` and `session_id`. Requires a workspace- or admin-level key." + }, "target": { "anyOf": [{ "type": "string" }, { "type": "null" }], "title": "Target", @@ -3542,6 +3931,72 @@ "required": ["observer", "dream_type"], "title": "ScheduleDreamRequest" }, + "Scope": { + "properties": { + "id": { "type": "string", "title": "Id" }, + "metadata": { + "additionalProperties": true, + "type": "object", + "title": "Metadata" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": ["id", "created_at"], + "title": "Scope", + "description": "Scope response \u2014 external view of the peer backing a scope.\n\nThe ``id`` is the unprefixed scope name; the reserved peer-name prefix is\nan internal implementation detail and never surfaces here." + }, + "ScopeCreate": { + "properties": { + "id": { "type": "string", "minLength": 1, "title": "Id" }, + "metadata": { + "anyOf": [ + { "additionalProperties": true, "type": "object" }, + { "type": "null" } + ], + "title": "Metadata" + } + }, + "type": "object", + "required": ["id"], + "title": "ScopeCreate", + "description": "Schema for creating (or getting) a scope by its unprefixed name." + }, + "ScopeSessionsAdd": { + "properties": { + "session_ids": { + "items": { "type": "string" }, + "type": "array", + "maxItems": 100, + "minItems": 1, + "title": "Session Ids", + "description": "IDs of existing sessions to add to the scope" + } + }, + "type": "object", + "required": ["session_ids"], + "title": "ScopeSessionsAdd", + "description": "Schema for adding sessions to a scope." + }, + "ScopeStatus": { + "properties": { + "backfill_status": { + "additionalProperties": { + "additionalProperties": true, + "type": "object" + }, + "type": "object", + "title": "Backfill Status" + } + }, + "type": "object", + "title": "ScopeStatus", + "description": "Per-session backfill/reconciliation job status for a scope.\n\n``backfill_status`` maps each session that has had a backfill enqueued to\nits current job state: ``{state, updated_at[, docs_copied]}`` where\n``state`` is ``pending``/``completed``/``failed`` and ``docs_copied`` is\npresent once a backfill completes." + }, "Session": { "properties": { "id": { "type": "string", "title": "Id" }, @@ -3669,6 +4124,18 @@ { "$ref": "#/components/schemas/SessionConfiguration" }, { "type": "null" } ] + }, + "scopes": { + "anyOf": [ + { + "items": { "type": "string" }, + "type": "array", + "maxItems": 100 + }, + { "type": "null" } + ], + "title": "Scopes", + "description": "Optional list of (unprefixed) scope names to add this session to. Each scope is created if it does not exist yet. If the session already has messages, its existing documents are backfilled into the scope asynchronously." } }, "type": "object", From 82a92429b888727b2236820b863256067c7edc80 Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Thu, 27 Aug 2026 18:54:44 -0400 Subject: [PATCH 33/50] chore: harmonize Python version at 3.13 (#1090) --- .github/workflows/live-llm-tests.yml | 3 +- .github/workflows/staticanalysis.yml | 2 +- .github/workflows/unified-tests.yml | 4 +- .github/workflows/unittest.yml | 5 +- .python-version | 2 +- pyproject.toml | 2 +- uv.lock | 875 +-------------------------- 7 files changed, 14 insertions(+), 879 deletions(-) diff --git a/.github/workflows/live-llm-tests.yml b/.github/workflows/live-llm-tests.yml index ee1b5b2b..dec8e3b4 100644 --- a/.github/workflows/live-llm-tests.yml +++ b/.github/workflows/live-llm-tests.yml @@ -12,6 +12,7 @@ on: - 'tests/live_llm/**' - 'pyproject.toml' - 'uv.lock' + - '.python-version' - '.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 @@ -111,7 +112,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version-file: "pyproject.toml" + python-version-file: ".python-version" - name: Install the project run: uv sync --all-extras diff --git a/.github/workflows/staticanalysis.yml b/.github/workflows/staticanalysis.yml index 9cad8b10..c41bf0f9 100644 --- a/.github/workflows/staticanalysis.yml +++ b/.github/workflows/staticanalysis.yml @@ -16,7 +16,7 @@ jobs: - name: "Set up Python" uses: actions/setup-python@v5 with: - python-version-file: "pyproject.toml" + python-version-file: ".python-version" - name: Install uv uses: astral-sh/setup-uv@v2 with: diff --git a/.github/workflows/unified-tests.yml b/.github/workflows/unified-tests.yml index b73bda32..1f1f9d87 100644 --- a/.github/workflows/unified-tests.yml +++ b/.github/workflows/unified-tests.yml @@ -151,8 +151,8 @@ jobs: - name: Verify uv and Python run: | uv --version - python3.12 --version - which python3.12 + python3.13 --version + which python3.13 - name: Install the project run: uv sync --all-extras diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 00dfba9f..e29842eb 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -11,6 +11,7 @@ on: - '**.jsx' - 'pyproject.toml' - 'uv.lock' + - '.python-version' - 'sdks/typescript/package.json' - 'sdks/typescript/bun.lock' - '.github/workflows/unittest.yml' @@ -24,6 +25,7 @@ on: - '**.jsx' - 'pyproject.toml' - 'uv.lock' + - '.python-version' - 'sdks/typescript/package.json' - 'sdks/typescript/bun.lock' - '.github/workflows/unittest.yml' @@ -48,6 +50,7 @@ jobs: - '**.py' - 'pyproject.toml' - 'uv.lock' + - '.python-version' - 'migrations/**' - 'sdks/typescript/**' - '.github/workflows/unittest.yml' @@ -85,7 +88,7 @@ jobs: - name: "Set up Python" uses: actions/setup-python@v5 with: - python-version-file: "pyproject.toml" + python-version-file: ".python-version" - name: Install bun uses: oven-sh/setup-bun@v2 diff --git a/.python-version b/.python-version index 2c073331..24ee5b1b 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.11 +3.13 diff --git a/pyproject.toml b/pyproject.toml index 0fe02329..a6681ceb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [ {name = "Plastic Labs", email = "hello@plasticlabs.ai"}, ] readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.13" dependencies = [ "fastapi[standard-no-fastapi-cloud-cli]>=0.131.0", "python-dotenv>=1.0.0", diff --git a/uv.lock b/uv.lock index 76f28e20..0e84663f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,14 +1,9 @@ version = 1 revision = 3 -requires-python = ">=3.11" -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version < '3.13'", -] +requires-python = ">=3.13" [options] -exclude-newer = "2026-08-07T23:49:08.393963Z" +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] @@ -42,40 +37,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, - { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, - { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, - { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, - { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, - { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, - { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, - { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, - { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, @@ -135,7 +96,6 @@ version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -199,22 +159,12 @@ version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, -] - [[package]] name = "attrs" version = "26.1.0" @@ -305,31 +255,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, @@ -381,38 +306,6 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, @@ -503,36 +396,6 @@ version = "7.13.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, - { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, - { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, - { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, - { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, - { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, - { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, - { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, - { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, @@ -596,11 +459,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, -] - [[package]] name = "cryptography" version = "48.0.0" @@ -652,12 +510,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, - { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, - { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, ] [[package]] @@ -806,38 +658,6 @@ version = "1.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, - { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, - { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, - { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, - { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, - { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, - { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, - { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, - { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, @@ -962,26 +782,6 @@ version = "3.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" }, - { url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" }, - { url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" }, - { url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" }, - { url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" }, - { url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f8/450fe3c5938fa737ea4d22699772e6e34e8e24431a47bf4e8a1ceed4a98e/greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339", size = 235017, upload-time = "2026-04-27T12:22:26.768Z" }, - { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, - { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, - { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, - { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, - { url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" }, { url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" }, { url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" }, { url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" }, @@ -1143,7 +943,6 @@ source = { editable = "sdks/python" } dependencies = [ { name = "httpx" }, { name = "pydantic" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] [package.optional-dependencies] @@ -1217,20 +1016,6 @@ version = "0.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, - { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, - { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, - { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, - { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, @@ -1335,34 +1120,6 @@ version = "0.14.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896, upload-time = "2026-04-10T14:26:01.986Z" }, - { url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085, upload-time = "2026-04-10T14:26:03.364Z" }, - { url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393, upload-time = "2026-04-10T14:26:05.314Z" }, - { url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937, upload-time = "2026-04-10T14:26:06.884Z" }, - { url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646, upload-time = "2026-04-10T14:26:08.345Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225, upload-time = "2026-04-10T14:26:10.161Z" }, - { url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682, upload-time = "2026-04-10T14:26:11.574Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973, upload-time = "2026-04-10T14:26:13.316Z" }, - { url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568, upload-time = "2026-04-10T14:26:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535, upload-time = "2026-04-10T14:26:16.956Z" }, - { url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709, upload-time = "2026-04-10T14:26:18.5Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660, upload-time = "2026-04-10T14:26:20.511Z" }, - { url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659, upload-time = "2026-04-10T14:26:22.152Z" }, - { url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772, upload-time = "2026-04-10T14:26:23.458Z" }, - { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, - { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, - { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, - { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, - { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, - { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, - { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, - { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, - { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, - { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, - { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, - { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, @@ -1409,14 +1166,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, - { url = "https://files.pythonhosted.org/packages/32/a1/ef34ca2cab2962598591636a1804b93645821201cc0095d4a93a9a329c9d/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211", size = 311366, upload-time = "2026-04-10T14:28:27.943Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/520576a532a6b8a6f42747afed289c8448c879a34d7802fe2c832d4fd38f/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b", size = 309873, upload-time = "2026-04-10T14:28:29.688Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7c/c16db114ea1f2f532f198aa8dc39585026af45af362c69a0492f31bc4821/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea", size = 344816, upload-time = "2026-04-10T14:28:31.348Z" }, - { url = "https://files.pythonhosted.org/packages/99/8f/15e7741ff19e9bcd4d753f7ff22f988fd54592f134ca13701c13ea8c20e0/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577", size = 351445, upload-time = "2026-04-10T14:28:33.093Z" }, - { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, - { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, - { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, ] [[package]] @@ -1481,7 +1230,6 @@ dependencies = [ { name = "deprecation" }, { name = "lance-namespace" }, { name = "numpy" }, - { name = "overrides", marker = "python_full_version < '3.12'" }, { name = "packaging" }, { name = "pyarrow" }, { name = "pydantic" }, @@ -1545,28 +1293,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, @@ -1628,42 +1354,6 @@ version = "6.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, - { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, - { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, - { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, - { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, - { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, - { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, @@ -1779,28 +1469,6 @@ version = "2.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, - { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, - { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, - { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, - { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, - { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, - { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, - { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, - { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, - { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, - { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, - { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, - { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, - { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, @@ -1843,13 +1511,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, - { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, - { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, - { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, ] [[package]] @@ -1959,36 +1620,6 @@ version = "3.11.9" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, - { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, - { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, - { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, - { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, - { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, - { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, - { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, - { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, - { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, - { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, - { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, - { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, - { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, - { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, - { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, - { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, - { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, - { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, - { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, - { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, @@ -2021,15 +1652,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, ] -[[package]] -name = "overrides" -version = "7.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, -] - [[package]] name = "packaging" version = "26.2" @@ -2084,28 +1706,6 @@ version = "12.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, - { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, - { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, - { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, - { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, - { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, @@ -2156,13 +1756,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, - { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, - { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, - { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] [[package]] @@ -2214,40 +1807,6 @@ version = "0.5.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, - { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, - { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, - { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, - { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, - { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, - { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, - { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, - { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, - { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, - { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, - { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, - { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, - { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, - { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, - { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, - { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, - { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, - { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, - { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, - { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, - { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, - { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, - { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, - { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, - { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, - { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, - { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, - { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, - { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, - { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, @@ -2339,7 +1898,6 @@ name = "psycopg" version = "3.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } @@ -2357,28 +1915,6 @@ name = "psycopg-binary" version = "3.3.4" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, - { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, - { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, - { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, - { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, - { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, - { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, - { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, - { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, - { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, - { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, - { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, - { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, - { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, - { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, - { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, @@ -2433,20 +1969,6 @@ version = "24.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, - { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, - { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, - { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, - { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, - { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, - { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, - { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, @@ -2504,44 +2026,6 @@ version = "1.4.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/aa/b8/4ed5c7ad5ec15b08d35cc79ace6145d5c1ae426e46435f4987379439dfea/pybase64-1.4.3.tar.gz", hash = "sha256:c2ed274c9e0ba9c8f9c4083cfe265e66dd679126cd9c2027965d807352f3f053", size = 137272, upload-time = "2025-12-06T13:27:04.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/63/21e981e9d3f1f123e0b0ee2130112b1956cad9752309f574862c7ae77c08/pybase64-1.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70b0d4a4d54e216ce42c2655315378b8903933ecfa32fced453989a92b4317b2", size = 38237, upload-time = "2025-12-06T13:22:52.159Z" }, - { url = "https://files.pythonhosted.org/packages/92/fb/3f448e139516404d2a3963915cc10dc9dde7d3a67de4edba2f827adfef17/pybase64-1.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8127f110cdee7a70e576c5c9c1d4e17e92e76c191869085efbc50419f4ae3c72", size = 31673, upload-time = "2025-12-06T13:22:53.241Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/bb06a5b9885e7d853ac1e801c4d8abfdb4c8506deee33e53d55aa6690e67/pybase64-1.4.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f9ef0388878bc15a084bd9bf73ec1b2b4ee513d11009b1506375e10a7aae5032", size = 68331, upload-time = "2025-12-06T13:22:54.197Z" }, - { url = "https://files.pythonhosted.org/packages/64/15/8d60b9ec5e658185fc2ee3333e01a6e30d717cf677b24f47cbb3a859d13c/pybase64-1.4.3-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95a57cccf106352a72ed8bc8198f6820b16cc7d55aa3867a16dea7011ae7c218", size = 71370, upload-time = "2025-12-06T13:22:55.517Z" }, - { url = "https://files.pythonhosted.org/packages/ac/29/a3e5c1667cc8c38d025a4636855de0fc117fc62e2afeb033a3c6f12c6a22/pybase64-1.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cd1c47dfceb9c7bd3de210fb4e65904053ed2d7c9dce6d107f041ff6fbd7e21", size = 59834, upload-time = "2025-12-06T13:22:56.682Z" }, - { url = "https://files.pythonhosted.org/packages/a9/00/8ffcf9810bd23f3984698be161cf7edba656fd639b818039a7be1d6405d4/pybase64-1.4.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9fe9922698f3e2f72874b26890d53a051c431d942701bb3a37aae94da0b12107", size = 56652, upload-time = "2025-12-06T13:22:57.724Z" }, - { url = "https://files.pythonhosted.org/packages/81/62/379e347797cdea4ab686375945bc77ad8d039c688c0d4d0cfb09d247beb9/pybase64-1.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:af5f4bd29c86b59bb4375e0491d16ec8a67548fa99c54763aaedaf0b4b5a6632", size = 59382, upload-time = "2025-12-06T13:22:58.758Z" }, - { url = "https://files.pythonhosted.org/packages/c6/f2/9338ffe2f487086f26a2c8ca175acb3baa86fce0a756ff5670a0822bb877/pybase64-1.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c302f6ca7465262908131411226e02100f488f531bb5e64cb901aa3f439bccd9", size = 59990, upload-time = "2025-12-06T13:23:01.007Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a4/85a6142b65b4df8625b337727aa81dc199642de3d09677804141df6ee312/pybase64-1.4.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2f3f439fa4d7fde164ebbbb41968db7d66b064450ab6017c6c95cef0afa2b349", size = 54923, upload-time = "2025-12-06T13:23:02.369Z" }, - { url = "https://files.pythonhosted.org/packages/ac/00/e40215d25624012bf5b7416ca37f168cb75f6dd15acdb91ea1f2ea4dc4e7/pybase64-1.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a23c6866551043f8b681a5e1e0d59469148b2920a3b4fc42b1275f25ea4217a", size = 58664, upload-time = "2025-12-06T13:23:03.378Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/d7e19a63e795c13837f2356268d95dc79d1180e756f57ced742a1e52fdeb/pybase64-1.4.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:56e6526f8565642abc5f84338cc131ce298a8ccab696b19bdf76fa6d7dc592ef", size = 52338, upload-time = "2025-12-06T13:23:04.458Z" }, - { url = "https://files.pythonhosted.org/packages/f2/32/3c746d7a310b69bdd9df77ffc85c41b80bce00a774717596f869b0d4a20e/pybase64-1.4.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6a792a8b9d866ffa413c9687d9b611553203753987a3a582d68cbc51cf23da45", size = 68993, upload-time = "2025-12-06T13:23:05.526Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b3/63cec68f9d6f6e4c0b438d14e5f1ef536a5fe63ce14b70733ac5e31d7ab8/pybase64-1.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:62ad29a5026bb22cfcd1ca484ec34b0a5ced56ddba38ceecd9359b2818c9c4f9", size = 58055, upload-time = "2025-12-06T13:23:06.931Z" }, - { url = "https://files.pythonhosted.org/packages/d5/cb/7acf7c3c06f9692093c07f109668725dc37fb9a3df0fa912b50add645195/pybase64-1.4.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11b9d1d2d32ec358c02214363b8fc3651f6be7dd84d880ecd597a6206a80e121", size = 54430, upload-time = "2025-12-06T13:23:07.936Z" }, - { url = "https://files.pythonhosted.org/packages/33/39/4eb33ff35d173bfff4002e184ce8907f5d0a42d958d61cd9058ef3570179/pybase64-1.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0aebaa7f238caa0a0d373616016e2040c6c879ebce3ba7ab3c59029920f13640", size = 56272, upload-time = "2025-12-06T13:23:09.253Z" }, - { url = "https://files.pythonhosted.org/packages/19/97/a76d65c375a254e65b730c6f56bf528feca91305da32eceab8bcc08591e6/pybase64-1.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e504682b20c63c2b0c000e5f98a80ea867f8d97642e042a5a39818e44ba4d599", size = 70904, upload-time = "2025-12-06T13:23:10.336Z" }, - { url = "https://files.pythonhosted.org/packages/5e/2c/8338b6d3da3c265002839e92af0a80d6db88385c313c73f103dfb800c857/pybase64-1.4.3-cp311-cp311-win32.whl", hash = "sha256:e9a8b81984e3c6fb1db9e1614341b0a2d98c0033d693d90c726677db1ffa3a4c", size = 33639, upload-time = "2025-12-06T13:23:11.9Z" }, - { url = "https://files.pythonhosted.org/packages/39/dc/32efdf2f5927e5449cc341c266a1bbc5fecd5319a8807d9c5405f76e6d02/pybase64-1.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:a90a8fa16a901fabf20de824d7acce07586e6127dc2333f1de05f73b1f848319", size = 35797, upload-time = "2025-12-06T13:23:13.174Z" }, - { url = "https://files.pythonhosted.org/packages/da/59/eda4f9cb0cbce5a45f0cd06131e710674f8123a4d570772c5b9694f88559/pybase64-1.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:61d87de5bc94d143622e94390ec3e11b9c1d4644fe9be3a81068ab0f91056f59", size = 31160, upload-time = "2025-12-06T13:23:15.696Z" }, - { url = "https://files.pythonhosted.org/packages/86/a7/efcaa564f091a2af7f18a83c1c4875b1437db56ba39540451dc85d56f653/pybase64-1.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:18d85e5ab8b986bb32d8446aca6258ed80d1bafe3603c437690b352c648f5967", size = 38167, upload-time = "2025-12-06T13:23:16.821Z" }, - { url = "https://files.pythonhosted.org/packages/db/c7/c7ad35adff2d272bf2930132db2b3eea8c44bb1b1f64eb9b2b8e57cde7b4/pybase64-1.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f5791a3491d116d0deaf4d83268f48792998519698f8751efb191eac84320e9", size = 31673, upload-time = "2025-12-06T13:23:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/43/1b/9a8cab0042b464e9a876d5c65fe5127445a2436da36fda64899b119b1a1b/pybase64-1.4.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f0b3f200c3e06316f6bebabd458b4e4bcd4c2ca26af7c0c766614d91968dee27", size = 68210, upload-time = "2025-12-06T13:23:18.813Z" }, - { url = "https://files.pythonhosted.org/packages/62/f7/965b79ff391ad208b50e412b5d3205ccce372a2d27b7218ae86d5295b105/pybase64-1.4.3-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb632edfd132b3eaf90c39c89aa314beec4e946e210099b57d40311f704e11d4", size = 71599, upload-time = "2025-12-06T13:23:20.195Z" }, - { url = "https://files.pythonhosted.org/packages/03/4b/a3b5175130b3810bbb8ccfa1edaadbd3afddb9992d877c8a1e2f274b476e/pybase64-1.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:356ef1d74648ce997f5a777cf8f1aefecc1c0b4fe6201e0ef3ec8a08170e1b54", size = 59922, upload-time = "2025-12-06T13:23:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/5d/c38d1572027fc601b62d7a407721688b04b4d065d60ca489912d6893e6cf/pybase64-1.4.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:c48361f90db32bacaa5518419d4eb9066ba558013aaf0c7781620279ecddaeb9", size = 56712, upload-time = "2025-12-06T13:23:22.77Z" }, - { url = "https://files.pythonhosted.org/packages/e7/d4/4e04472fef485caa8f561d904d4d69210a8f8fc1608ea15ebd9012b92655/pybase64-1.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:702bcaa16ae02139d881aeaef5b1c8ffb4a3fae062fe601d1e3835e10310a517", size = 59300, upload-time = "2025-12-06T13:23:24.543Z" }, - { url = "https://files.pythonhosted.org/packages/86/e7/16e29721b86734b881d09b7e23dfd7c8408ad01a4f4c7525f3b1088e25ec/pybase64-1.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:53d0ffe1847b16b647c6413d34d1de08942b7724273dd57e67dcbdb10c574045", size = 60278, upload-time = "2025-12-06T13:23:25.608Z" }, - { url = "https://files.pythonhosted.org/packages/b1/02/18515f211d7c046be32070709a8efeeef8a0203de4fd7521e6b56404731b/pybase64-1.4.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9a1792e8b830a92736dae58f0c386062eb038dfe8004fb03ba33b6083d89cd43", size = 54817, upload-time = "2025-12-06T13:23:26.633Z" }, - { url = "https://files.pythonhosted.org/packages/e7/be/14e29d8e1a481dbff151324c96dd7b5d2688194bb65dc8a00ca0e1ad1e86/pybase64-1.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d468b1b1ac5ad84875a46eaa458663c3721e8be5f155ade356406848d3701f6", size = 58611, upload-time = "2025-12-06T13:23:27.684Z" }, - { url = "https://files.pythonhosted.org/packages/b4/8a/a2588dfe24e1bbd742a554553778ab0d65fdf3d1c9a06d10b77047d142aa/pybase64-1.4.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e97b7bdbd62e71898cd542a6a9e320d9da754ff3ebd02cb802d69087ee94d468", size = 52404, upload-time = "2025-12-06T13:23:28.714Z" }, - { url = "https://files.pythonhosted.org/packages/27/fc/afcda7445bebe0cbc38cafdd7813234cdd4fc5573ff067f1abf317bb0cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b33aeaa780caaa08ffda87fc584d5eab61e3d3bbb5d86ead02161dc0c20d04bc", size = 68817, upload-time = "2025-12-06T13:23:30.079Z" }, - { url = "https://files.pythonhosted.org/packages/d3/3a/87c3201e555ed71f73e961a787241a2438c2bbb2ca8809c29ddf938a3157/pybase64-1.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c0efcf78f11cf866bed49caa7b97552bc4855a892f9cc2372abcd3ed0056f0d", size = 57854, upload-time = "2025-12-06T13:23:31.17Z" }, - { url = "https://files.pythonhosted.org/packages/fd/7d/931c2539b31a7b375e7d595b88401eeb5bd6c5ce1059c9123f9b608aaa14/pybase64-1.4.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:66e3791f2ed725a46593f8bd2761ff37d01e2cdad065b1dceb89066f476e50c6", size = 54333, upload-time = "2025-12-06T13:23:32.422Z" }, - { url = "https://files.pythonhosted.org/packages/de/5e/537601e02cc01f27e9d75f440f1a6095b8df44fc28b1eef2cd739aea8cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:72bb0b6bddadab26e1b069bb78e83092711a111a80a0d6b9edcb08199ad7299b", size = 56492, upload-time = "2025-12-06T13:23:33.515Z" }, - { url = "https://files.pythonhosted.org/packages/96/97/2a2e57acf8f5c9258d22aba52e71f8050e167b29ed2ee1113677c1b600c1/pybase64-1.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5b3365dbcbcdb0a294f0f50af0c0a16b27a232eddeeb0bceeefd844ef30d2a23", size = 70974, upload-time = "2025-12-06T13:23:36.27Z" }, - { url = "https://files.pythonhosted.org/packages/75/2e/a9e28941c6dab6f06e6d3f6783d3373044be9b0f9a9d3492c3d8d2260ac0/pybase64-1.4.3-cp312-cp312-win32.whl", hash = "sha256:7bca1ed3a5df53305c629ca94276966272eda33c0d71f862d2d3d043f1e1b91a", size = 33686, upload-time = "2025-12-06T13:23:37.848Z" }, - { url = "https://files.pythonhosted.org/packages/83/e3/507ab649d8c3512c258819c51d25c45d6e29d9ca33992593059e7b646a33/pybase64-1.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:9f2da8f56d9b891b18b4daf463a0640eae45a80af548ce435be86aa6eff3603b", size = 35833, upload-time = "2025-12-06T13:23:38.877Z" }, - { url = "https://files.pythonhosted.org/packages/bc/8a/6eba66cd549a2fc74bb4425fd61b839ba0ab3022d3c401b8a8dc2cc00c7a/pybase64-1.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:0631d8a2d035de03aa9bded029b9513e1fee8ed80b7ddef6b8e9389ffc445da0", size = 31185, upload-time = "2025-12-06T13:23:39.908Z" }, { url = "https://files.pythonhosted.org/packages/3a/50/b7170cb2c631944388fe2519507fe3835a4054a6a12a43f43781dae82be1/pybase64-1.4.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:ea4b785b0607d11950b66ce7c328f452614aefc9c6d3c9c28bae795dc7f072e1", size = 33901, upload-time = "2025-12-06T13:23:40.951Z" }, { url = "https://files.pythonhosted.org/packages/48/8b/69f50578e49c25e0a26e3ee72c39884ff56363344b79fc3967f5af420ed6/pybase64-1.4.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:6a10b6330188c3026a8b9c10e6b9b3f2e445779cf16a4c453d51a072241c65a2", size = 40807, upload-time = "2025-12-06T13:23:42.006Z" }, { url = "https://files.pythonhosted.org/packages/5c/8d/20b68f11adfc4c22230e034b65c71392e3e338b413bf713c8945bd2ccfb3/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:27fdff227a0c0e182e0ba37a99109645188978b920dfb20d8b9c17eeee370d0d", size = 30932, upload-time = "2025-12-06T13:23:43.348Z" }, @@ -2628,22 +2112,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/86/a236ecfc5b494e1e922da149689f690abc84248c7c1358f5605b8c9fdd60/pybase64-1.4.3-cp314-cp314t-win32.whl", hash = "sha256:343b1901103cc72362fd1f842524e3bb24978e31aea7ff11e033af7f373f66ab", size = 34513, upload-time = "2025-12-06T13:25:24.592Z" }, { url = "https://files.pythonhosted.org/packages/56/ce/ca8675f8d1352e245eb012bfc75429ee9cf1f21c3256b98d9a329d44bf0f/pybase64-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:57aff6f7f9dea6705afac9d706432049642de5b01080d3718acc23af87c5af76", size = 36702, upload-time = "2025-12-06T13:25:25.72Z" }, { url = "https://files.pythonhosted.org/packages/3b/30/4a675864877397179b09b720ee5fcb1cf772cf7bebc831989aff0a5f79c1/pybase64-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e906aa08d4331e799400829e0f5e4177e76a3281e8a4bc82ba114c6b30e405c9", size = 31904, upload-time = "2025-12-06T13:25:26.826Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7c/545fd4935a0e1ddd7147f557bf8157c73eecec9cffd523382fa7af2557de/pybase64-1.4.3-graalpy311-graalpy242_311_native-macosx_10_9_x86_64.whl", hash = "sha256:d27c1dfdb0c59a5e758e7a98bd78eaca5983c22f4a811a36f4f980d245df4611", size = 38393, upload-time = "2025-12-06T13:26:19.535Z" }, - { url = "https://files.pythonhosted.org/packages/c3/ca/ae7a96be9ddc96030d4e9dffc43635d4e136b12058b387fd47eb8301b60f/pybase64-1.4.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0f1a0c51d6f159511e3431b73c25db31095ee36c394e26a4349e067c62f434e5", size = 32109, upload-time = "2025-12-06T13:26:20.72Z" }, - { url = "https://files.pythonhosted.org/packages/bf/44/d4b7adc7bf4fd5b52d8d099121760c450a52c390223806b873f0b6a2d551/pybase64-1.4.3-graalpy311-graalpy242_311_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a492518f3078a4e3faaef310697d21df9c6bc71908cebc8c2f6fbfa16d7d6b1f", size = 43227, upload-time = "2025-12-06T13:26:21.845Z" }, - { url = "https://files.pythonhosted.org/packages/08/86/2ba2d8734ef7939debeb52cf9952e457ba7aa226cae5c0e6dd631f9b851f/pybase64-1.4.3-graalpy311-graalpy242_311_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae1a0f47784fd16df90d8acc32011c8d5fcdd9ab392c9ec49543e5f6a9c43a4", size = 35804, upload-time = "2025-12-06T13:26:23.149Z" }, - { url = "https://files.pythonhosted.org/packages/4f/5b/19c725dc3aaa6281f2ce3ea4c1628d154a40dd99657d1381995f8096768b/pybase64-1.4.3-graalpy311-graalpy242_311_native-win_amd64.whl", hash = "sha256:03cea70676ffbd39a1ab7930a2d24c625b416cacc9d401599b1d29415a43ab6a", size = 35880, upload-time = "2025-12-06T13:26:24.663Z" }, - { url = "https://files.pythonhosted.org/packages/17/45/92322aec1b6979e789b5710f73c59f2172bc37c8ce835305434796824b7b/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:2baaa092f3475f3a9c87ac5198023918ea8b6c125f4c930752ab2cbe3cd1d520", size = 38746, upload-time = "2025-12-06T13:26:25.869Z" }, - { url = "https://files.pythonhosted.org/packages/11/94/f1a07402870388fdfc2ecec0c718111189732f7d0f2d7fe1386e19e8fad0/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:cde13c0764b1af07a631729f26df019070dad759981d6975527b7e8ecb465b6c", size = 32573, upload-time = "2025-12-06T13:26:27.792Z" }, - { url = "https://files.pythonhosted.org/packages/fa/8f/43c3bb11ca9bacf81cb0b7a71500bb65b2eda6d5fe07433c09b543de97f3/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5c29a582b0ea3936d02bd6fe9bf674ab6059e6e45ab71c78404ab2c913224414", size = 43461, upload-time = "2025-12-06T13:26:28.906Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4c/2a5258329200be57497d3972b5308558c6de42e3749c6cc2aa1cbe34b25a/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6b664758c804fa919b4f1257aa8cf68e95db76fc331de5f70bfc3a34655afe1", size = 36058, upload-time = "2025-12-06T13:26:30.092Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6d/41faa414cde66ec023b0ca8402a8f11cb61731c3dc27c082909cbbd1f929/pybase64-1.4.3-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:f7537fa22ae56a0bf51e4b0ffc075926ad91c618e1416330939f7ef366b58e3b", size = 36231, upload-time = "2025-12-06T13:26:31.656Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/160dded493c00d3376d4ad0f38a2119c5345de4a6693419ad39c3565959b/pybase64-1.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:277de6e03cc9090fb359365c686a2a3036d23aee6cd20d45d22b8c89d1247f17", size = 37939, upload-time = "2025-12-06T13:26:41.014Z" }, - { url = "https://files.pythonhosted.org/packages/b7/b8/a0f10be8d648d6f8f26e560d6e6955efa7df0ff1e009155717454d76f601/pybase64-1.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ab1dd8b1ed2d1d750260ed58ab40defaa5ba83f76a30e18b9ebd5646f6247ae5", size = 31466, upload-time = "2025-12-06T13:26:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/d3/22/832a2f9e76cdf39b52e01e40d8feeb6a04cf105494f2c3e3126d0149717f/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:bd4d2293de9fd212e294c136cec85892460b17d24e8c18a6ba18750928037750", size = 40681, upload-time = "2025-12-06T13:26:43.782Z" }, - { url = "https://files.pythonhosted.org/packages/12/d7/6610f34a8972415fab3bb4704c174a1cc477bffbc3c36e526428d0f3957d/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af6d0d3a691911cc4c9a625f3ddcd3af720738c21be3d5c72de05629139d393", size = 41294, upload-time = "2025-12-06T13:26:44.936Z" }, - { url = "https://files.pythonhosted.org/packages/64/25/ed24400948a6c974ab1374a233cb7e8af0a5373cea0dd8a944627d17c34a/pybase64-1.4.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfc8c49a28322d82242088378f8542ce97459866ba73150b062a7073e82629d", size = 35447, upload-time = "2025-12-06T13:26:46.098Z" }, - { url = "https://files.pythonhosted.org/packages/ee/2b/e18ee7c5ee508a82897f021c1981533eca2940b5f072fc6ed0906c03a7a7/pybase64-1.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:debf737e09b8bf832ba86f5ecc3d3dbd0e3021d6cd86ba4abe962d6a5a77adb3", size = 36134, upload-time = "2025-12-06T13:26:47.35Z" }, ] [[package]] @@ -2679,36 +2147,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, - { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, - { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, - { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, - { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, - { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, - { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, - { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, - { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, - { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, @@ -2754,22 +2192,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, - { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, - { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, - { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, - { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, - { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, - { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -2868,7 +2290,6 @@ version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ @@ -2880,7 +2301,7 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"] }, + { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] @@ -2963,25 +2384,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, @@ -3016,9 +2418,6 @@ wheels = [ name = "redis" version = "7.4.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, @@ -3030,38 +2429,6 @@ version = "2026.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/7a/617356cbecdb452812a5d42f720d6d5096b360d4a4c1073af700ea140ad2/regex-2026.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b4c36a85b00fadb85db9d9e90144af0a980e1a3d2ef9cd0f8a5bef88054657c6", size = 489415, upload-time = "2026-04-03T20:53:11.645Z" }, - { url = "https://files.pythonhosted.org/packages/20/e6/bf057227144d02e3ba758b66649e87531d744dda5f3254f48660f18ae9d8/regex-2026.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dcb5453ecf9cd58b562967badd1edbf092b0588a3af9e32ee3d05c985077ce87", size = 291205, upload-time = "2026-04-03T20:53:13.289Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3b/637181b787dd1a820ba1c712cee2b4144cd84a32dc776ca067b12b2d70c8/regex-2026.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6aa809ed4dc3706cc38594d67e641601bd2f36d5555b2780ff074edfcb136cf8", size = 289225, upload-time = "2026-04-03T20:53:16.002Z" }, - { url = "https://files.pythonhosted.org/packages/05/21/bac05d806ed02cd4b39d9c8e5b5f9a2998c94c3a351b7792e80671fa5315/regex-2026.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33424f5188a7db12958246a54f59a435b6cb62c5cf9c8d71f7cc49475a5fdada", size = 792434, upload-time = "2026-04-03T20:53:17.414Z" }, - { url = "https://files.pythonhosted.org/packages/d9/17/c65d1d8ae90b772d5758eb4014e1e011bb2db353fc4455432e6cc9100df7/regex-2026.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d346fccdde28abba117cc9edc696b9518c3307fbfcb689e549d9b5979018c6d", size = 861730, upload-time = "2026-04-03T20:53:18.903Z" }, - { url = "https://files.pythonhosted.org/packages/ad/64/933321aa082a2c6ee2785f22776143ba89840189c20d3b6b1d12b6aae16b/regex-2026.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:415a994b536440f5011aa77e50a4274d15da3245e876e5c7f19da349caaedd87", size = 906495, upload-time = "2026-04-03T20:53:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/01/ea/4c8d306e9c36ac22417336b1e02e7b358152c34dc379673f2d331143725f/regex-2026.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21e5eb86179b4c67b5759d452ea7c48eb135cd93308e7a260aa489ed2eb423a4", size = 799810, upload-time = "2026-04-03T20:53:22.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/ce/7605048f00e1379eba89d610c7d644d8f695dc9b26d3b6ecfa3132b872ff/regex-2026.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:312ec9dd1ae7d96abd8c5a36a552b2139931914407d26fba723f9e53c8186f86", size = 774242, upload-time = "2026-04-03T20:53:25.015Z" }, - { url = "https://files.pythonhosted.org/packages/e9/77/283e0d5023fde22cd9e86190d6d9beb21590a452b195ffe00274de470691/regex-2026.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0d2b28aa1354c7cd7f71b7658c4326f7facac106edd7f40eda984424229fd59", size = 781257, upload-time = "2026-04-03T20:53:26.918Z" }, - { url = "https://files.pythonhosted.org/packages/8b/fb/7f3b772be101373c8626ed34c5d727dcbb8abd42a7b1219bc25fd9a3cc04/regex-2026.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:349d7310eddff40429a099c08d995c6d4a4bfaf3ff40bd3b5e5cb5a5a3c7d453", size = 854490, upload-time = "2026-04-03T20:53:29.065Z" }, - { url = "https://files.pythonhosted.org/packages/85/30/56547b80f34f4dd2986e1cdd63b1712932f63b6c4ce2f79c50a6cd79d1c2/regex-2026.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e7ab63e9fe45a9ec3417509e18116b367e89c9ceb6219222a3396fa30b147f80", size = 763544, upload-time = "2026-04-03T20:53:30.917Z" }, - { url = "https://files.pythonhosted.org/packages/ac/2f/ce060fdfea8eff34a8997603532e44cdb7d1f35e3bc253612a8707a90538/regex-2026.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fe896e07a5a2462308297e515c0054e9ec2dd18dfdc9427b19900b37dfe6f40b", size = 844442, upload-time = "2026-04-03T20:53:32.463Z" }, - { url = "https://files.pythonhosted.org/packages/e5/44/810cb113096a1dacbe82789fbfab2823f79d19b7f1271acecb7009ba9b88/regex-2026.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb59c65069498dbae3c0ef07bbe224e1eaa079825a437fb47a479f0af11f774f", size = 789162, upload-time = "2026-04-03T20:53:34.039Z" }, - { url = "https://files.pythonhosted.org/packages/20/96/9647dd7f2ecf6d9ce1fb04dfdb66910d094e10d8fe53e9c15096d8aa0bd2/regex-2026.4.4-cp311-cp311-win32.whl", hash = "sha256:2a5d273181b560ef8397c8825f2b9d57013de744da9e8257b8467e5da8599351", size = 266227, upload-time = "2026-04-03T20:53:35.601Z" }, - { url = "https://files.pythonhosted.org/packages/33/80/74e13262460530c3097ff343a17de9a34d040a5dc4de9cf3a8241faab51c/regex-2026.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:9542ccc1e689e752594309444081582f7be2fdb2df75acafea8a075108566735", size = 278399, upload-time = "2026-04-03T20:53:37.021Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/39f19f47f19dcefa3403f09d13562ca1c0fd07ab54db2bc03148f3f6b46a/regex-2026.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:b5f9fb784824a042be3455b53d0b112655686fdb7a91f88f095f3fee1e2a2a54", size = 270473, upload-time = "2026-04-03T20:53:38.633Z" }, - { url = "https://files.pythonhosted.org/packages/e5/28/b972a4d3df61e1d7bcf1b59fdb3cddef22f88b6be43f161bb41ebc0e4081/regex-2026.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52", size = 490434, upload-time = "2026-04-03T20:53:40.219Z" }, - { url = "https://files.pythonhosted.org/packages/84/20/30041446cf6dc3e0eab344fc62770e84c23b6b68a3b657821f9f80cb69b4/regex-2026.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb", size = 292061, upload-time = "2026-04-03T20:53:41.862Z" }, - { url = "https://files.pythonhosted.org/packages/62/c8/3baa06d75c98c46d4cc4262b71fd2edb9062b5665e868bca57859dadf93a/regex-2026.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76", size = 289628, upload-time = "2026-04-03T20:53:43.701Z" }, - { url = "https://files.pythonhosted.org/packages/31/87/3accf55634caad8c0acab23f5135ef7d4a21c39f28c55c816ae012931408/regex-2026.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be", size = 796651, upload-time = "2026-04-03T20:53:45.379Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0c/aaa2c83f34efedbf06f61cb1942c25f6cf1ee3b200f832c4d05f28306c2e/regex-2026.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1", size = 865916, upload-time = "2026-04-03T20:53:47.064Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f6/8c6924c865124643e8f37823eca845dc27ac509b2ee58123685e71cd0279/regex-2026.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13", size = 912287, upload-time = "2026-04-03T20:53:49.422Z" }, - { url = "https://files.pythonhosted.org/packages/11/0e/a9f6f81013e0deaf559b25711623864970fe6a098314e374ccb1540a4152/regex-2026.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9", size = 801126, upload-time = "2026-04-03T20:53:51.096Z" }, - { url = "https://files.pythonhosted.org/packages/71/61/3a0cc8af2dc0c8deb48e644dd2521f173f7e6513c6e195aad9aa8dd77ac5/regex-2026.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d", size = 776788, upload-time = "2026-04-03T20:53:52.889Z" }, - { url = "https://files.pythonhosted.org/packages/64/0b/8bb9cbf21ef7dee58e49b0fdb066a7aded146c823202e16494a36777594f/regex-2026.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3", size = 785184, upload-time = "2026-04-03T20:53:55.627Z" }, - { url = "https://files.pythonhosted.org/packages/99/c2/d3e80e8137b25ee06c92627de4e4d98b94830e02b3e6f81f3d2e3f504cf5/regex-2026.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0", size = 859913, upload-time = "2026-04-03T20:53:57.249Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/9d5d876157d969c804622456ef250017ac7a8f83e0e14f903b9e6df5ce95/regex-2026.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043", size = 765732, upload-time = "2026-04-03T20:53:59.428Z" }, - { url = "https://files.pythonhosted.org/packages/82/80/b568935b4421388561c8ed42aff77247285d3ae3bb2a6ca22af63bae805e/regex-2026.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244", size = 852152, upload-time = "2026-04-03T20:54:01.505Z" }, - { url = "https://files.pythonhosted.org/packages/39/29/f0f81217e21cd998245da047405366385d5c6072048038a3d33b37a79dc0/regex-2026.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73", size = 789076, upload-time = "2026-04-03T20:54:03.323Z" }, - { url = "https://files.pythonhosted.org/packages/49/1d/1d957a61976ab9d4e767dd4f9d04b66cc0c41c5e36cf40e2d43688b5ae6f/regex-2026.4.4-cp312-cp312-win32.whl", hash = "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f", size = 266700, upload-time = "2026-04-03T20:54:05.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5c/bf575d396aeb58ea13b06ef2adf624f65b70fafef6950a80fc3da9cae3bc/regex-2026.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b", size = 277768, upload-time = "2026-04-03T20:54:07.312Z" }, - { url = "https://files.pythonhosted.org/packages/c9/27/049df16ec6a6828ccd72add3c7f54b4df029669bea8e9817df6fff58be90/regex-2026.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983", size = 270568, upload-time = "2026-04-03T20:54:09.484Z" }, { url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", size = 490273, upload-time = "2026-04-03T20:54:11.202Z" }, { url = "https://files.pythonhosted.org/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", size = 291954, upload-time = "2026-04-03T20:54:13.412Z" }, { url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", size = 289487, upload-time = "2026-04-03T20:54:15.824Z" }, @@ -3219,18 +2586,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, - { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, - { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, - { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, - { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, - { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, - { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, - { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, - { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, - { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, - { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, @@ -3266,26 +2621,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, - { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, - { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, - { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, - { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, - { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, - { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, - { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, - { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, - { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, - { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, - { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, @@ -3389,20 +2724,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/b5/e3617cc67420f8f403efebd7b043128f94775e57e5b84e7255203390ceae/sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe", size = 2159126, upload-time = "2026-04-03T16:50:13.242Z" }, - { url = "https://files.pythonhosted.org/packages/20/9b/91ca80403b17cd389622a642699e5f6564096b698e7cdcbcbb6409898bc4/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014", size = 3315509, upload-time = "2026-04-03T16:54:49.332Z" }, - { url = "https://files.pythonhosted.org/packages/b1/61/0722511d98c54de95acb327824cb759e8653789af2b1944ab1cc69d32565/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536", size = 3315014, upload-time = "2026-04-03T16:56:56.376Z" }, - { url = "https://files.pythonhosted.org/packages/46/55/d514a653ffeb4cebf4b54c47bec32ee28ad89d39fafba16eeed1d81dccd5/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88", size = 3267388, upload-time = "2026-04-03T16:54:51.272Z" }, - { url = "https://files.pythonhosted.org/packages/2f/16/0dcc56cb6d3335c1671a2258f5d2cb8267c9a2260e27fde53cbfb1b3540a/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700", size = 3289602, upload-time = "2026-04-03T16:56:57.63Z" }, - { url = "https://files.pythonhosted.org/packages/51/6c/f8ab6fb04470a133cd80608db40aa292e6bae5f162c3a3d4ab19544a67af/sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a", size = 2119044, upload-time = "2026-04-03T17:00:53.455Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/55a6d627d04b6ebb290693681d7683c7da001eddf90b60cfcc41ee907978/sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af", size = 2143642, upload-time = "2026-04-03T17:00:54.769Z" }, - { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" }, - { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" }, - { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" }, - { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" }, { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, @@ -3450,7 +2771,6 @@ version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ @@ -3494,20 +2814,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" }, - { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" }, - { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" }, - { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" }, - { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, - { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, - { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, - { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, - { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, - { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, @@ -3538,60 +2844,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] -[[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, -] - [[package]] name = "tqdm" version = "4.67.3" @@ -3708,18 +2960,6 @@ version = "0.22.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, - { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, - { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, - { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, - { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, @@ -3764,32 +3004,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, - { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, - { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, - { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, @@ -3836,10 +3050,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, - { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, ] [[package]] @@ -3848,24 +3058,6 @@ version = "16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, @@ -3893,11 +3085,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] @@ -3907,26 +3094,6 @@ version = "1.17.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, - { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, - { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, - { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, - { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, - { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, - { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, - { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, - { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, @@ -3971,42 +3138,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" }, - { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" }, - { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" }, - { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" }, - { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" }, - { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" }, - { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" }, - { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" }, - { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" }, - { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" }, - { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" }, - { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, - { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, - { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, - { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, - { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, - { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, - { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, - { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, - { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, - { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, - { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, From 03253d7a088df185c5a441463d5333b60cd4d49c Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Mon, 31 Aug 2026 13:32:43 -0400 Subject: [PATCH 34/50] fix(deriver): strip NUL bytes from model-generated observations (#1095) * fix(deriver): strip NUL bytes from model-generated observations Postgres rejects NUL (0x00) in text columns and in jsonb strings. API ingress has always stripped it from user-supplied content, but the deriver's own output did not go through any equivalent: a model can emit a \u0000 escape in its tool-call arguments, which the JSON parser decodes into a real NUL byte. Seen in production when models transcribe shell output (`tr '\x00' '\n'`) or Windows paths (`c:\users\amal`). The NUL reached the exact-content dedup pre-fetch in create_documents as a bind parameter, so the query raised DataError before any row was written and the whole batch for that observer was dropped. Strip in _normalized_observation and _normalized_observation_input -- the points that already normalize text for persistence and embedding -- so the embedded text matches the stored text. premises and sources are covered too, since they ride along in internal_metadata. The emptiness check now runs after normalization, because str.strip() does not remove NUL and all-NUL content would otherwise be stored as an empty string. DocumentCreate.content gets a mode="before" validator as a backstop for callers that bypass those paths; running before the length constraint makes all-NUL content fail min_length rather than silently empty out. The NUL helpers move out of schemas/api.py into utils/sanitization.py as a single recursive strip_nul, so ingress and internal paths share one implementation. It is overloaded to keep str -> str for the callers that chain .strip(), and passes None through so optional fields need no guard. Fixes HONCHO-4XZ * fix: broaden nul strip check * chore: code simplification --------- Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> --- src/crud/representation.py | 29 ++++- src/schemas/api.py | 31 +---- src/schemas/internal.py | 12 +- src/utils/agent_tools.py | 25 +++- src/utils/sanitization.py | 46 +++++++ tests/crud/test_representation_manager.py | 145 +++++++++++++++++++--- tests/test_schema_validations.py | 67 ++++++++++ tests/utils/test_sanitization.py | 38 ++++++ 8 files changed, 334 insertions(+), 59 deletions(-) create mode 100644 src/utils/sanitization.py create mode 100644 tests/utils/test_sanitization.py diff --git a/src/crud/representation.py b/src/crud/representation.py index 6fafb842..85b3e931 100644 --- a/src/crud/representation.py +++ b/src/crud/representation.py @@ -25,6 +25,7 @@ from src.utils.representation import ( Representation, allowlist_safe_levels, ) +from src.utils.sanitization import strip_nul from src.utils.types import embedding_call_purpose logger = logging.getLogger(__name__) @@ -38,10 +39,21 @@ def _observation_text(obs: ExplicitObservation | DeductiveObservation) -> str: def _normalized_observation( obs: ExplicitObservation | DeductiveObservation, ) -> ExplicitObservation | DeductiveObservation: - """Return an observation with its persisted/embed text normalized.""" - text = _observation_text(obs).strip() + """Return an observation with its persisted/embed text normalized. + + NUL bytes are removed here rather than closer to the database so that the + text that gets embedded is the same text that gets stored. + """ + text = strip_nul(_observation_text(obs)).strip() if isinstance(obs, DeductiveObservation): - return obs.model_copy(update={"conclusion": text}) + return obs.model_copy( + update={ + "conclusion": text, + # Premises ride along in internal_metadata, and jsonb rejects + # NUL in strings just as text columns do. + "premises": strip_nul(obs.premises), + } + ) return obs.model_copy(update={"content": text}) @@ -87,10 +99,15 @@ class RepresentationManager: logger.debug("No observations to save") return empty_result + # Normalize before the emptiness check: str.strip() does not remove + # NUL, so content that normalizes away has to be dropped afterwards. all_observations = [ - _normalized_observation(obs) - for obs in representation.deductive + representation.explicit - if _observation_text(obs).strip() + normalized + for normalized in ( + _normalized_observation(obs) + for obs in representation.deductive + representation.explicit + ) + if _observation_text(normalized) ] if not all_observations: logger.debug("No non-empty observations to save") diff --git a/src/schemas/api.py b/src/schemas/api.py index 43d91b26..34258d99 100644 --- a/src/schemas/api.py +++ b/src/schemas/api.py @@ -31,6 +31,7 @@ from src.schemas.configuration import ( SessionPeerConfig, WorkspaceConfiguration, ) +from src.utils.sanitization import NulStripped, strip_nul from src.utils.scopes import ( SCOPE_PEER_PREFIX, is_scope_peer_name, @@ -48,28 +49,6 @@ _METADATA_MAX_KEYS = 100 _METADATA_MAX_DEPTH = 5 -def _sanitize_value(v: Any) -> Any: - """Recursively strip NUL bytes from strings in nested data structures.""" - if isinstance(v, str): - return v.replace("\x00", "") - if isinstance(v, dict): - d = cast(dict[str, Any], v) - return {_sanitize_value(k): _sanitize_value(val) for k, val in d.items()} - if isinstance(v, list): - lst = cast(list[Any], v) - return [_sanitize_value(item) for item in lst] - return v - - -def _strip_nul(v: str) -> str: - """Strip NUL bytes from a string field (Postgres TEXT rejects \\x00).""" - return v.replace("\x00", "") - - -# Reusable annotation for query fields; composes with a per-field Field(...). -NulStripped = AfterValidator(_strip_nul) - - def _check_metadata_limits( data: dict[str, Any], *, @@ -97,7 +76,7 @@ def _validate_metadata(v: Any) -> Any: return v data = cast(dict[str, Any], v) _check_metadata_limits(data) - return _sanitize_value(data) + return strip_nul(data) _SanitizedMetadata = Annotated[dict[str, Any], BeforeValidator(_validate_metadata)] @@ -331,7 +310,7 @@ class PeerCardSet(BaseModel): def sanitize_peer_card(cls, v: Any) -> Any: if isinstance(v, list): return [ - item.replace("\x00", "") if isinstance(item, str) else item + strip_nul(item) if isinstance(item, str) else item for item in cast(list[Any], v) ] return v @@ -358,7 +337,7 @@ class MessageCreate(MessageBase): @field_validator("content", mode="after") @classmethod def sanitize_content(cls, v: str) -> str: - return v.replace("\x00", "") + return strip_nul(v) @property def encoded_message(self) -> list[int]: @@ -691,7 +670,7 @@ class ConclusionCreate(BaseModel): @field_validator("content", mode="after") @classmethod def sanitize_content(cls, v: str) -> str: - return v.replace("\x00", "") + return strip_nul(v) @model_validator(mode="after") def validate_token_count(self) -> Self: diff --git a/src/schemas/internal.py b/src/schemas/internal.py index e014431f..2d299feb 100644 --- a/src/schemas/internal.py +++ b/src/schemas/internal.py @@ -6,10 +6,11 @@ These are not part of the public API contract and may change without notice. from enum import Enum from typing import Annotated, Literal, Self -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, model_validator from src.schemas.api import MessageCreate from src.schemas.configuration import SessionPeerConfig +from src.utils.sanitization import NulStripped from src.utils.types import DocumentLevel @@ -59,7 +60,7 @@ class DocumentMetadata(BaseModel): class DocumentCreate(DocumentBase): - content: Annotated[str, Field(min_length=1, max_length=100000)] + content: Annotated[str, Field(min_length=1, max_length=100000), NulStripped] session_name: str | None = Field( default=None, description="The session from which the document was derived (NULL for global observations)", @@ -85,7 +86,7 @@ class DocumentCreate(DocumentBase): class ObservationInput(BaseModel): """Validated observation input from LLM tool calls.""" - content: Annotated[str, Field(min_length=1)] + content: Annotated[str, Field(min_length=1), NulStripped] level: DocumentLevel = "explicit" source_ids: list[str] | None = None premises: list[str] | None = None @@ -96,11 +97,6 @@ class ObservationInput(BaseModel): ) = None confidence: Literal["high", "medium", "low"] | None = None - @field_validator("content", mode="after") - @classmethod - def sanitize_content(cls, v: str) -> str: - return v.replace("\x00", "") - @model_validator(mode="after") def validate_level_fields(self) -> Self: """Validate that level-specific fields are present when required.""" diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index b753c462..de07e38f 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -36,6 +36,7 @@ from src.utils.representation import ( Representation, allowlist_safe_levels, ) +from src.utils.sanitization import strip_nul from src.utils.types import ToolResult, embedding_call_purpose, get_current_iteration logger = logging.getLogger(__name__) @@ -77,8 +78,20 @@ def _validate_peer_card_entry(line: str) -> bool: def _normalized_observation_input( obs: schemas.ObservationInput, ) -> schemas.ObservationInput: - """Return an observation input with content normalized for persistence/embedding.""" - return obs.model_copy(update={"content": obs.content.strip()}) + """Return an observation input with content normalized for persistence/embedding. + + NUL bytes are removed here rather than closer to the database so that the + text that gets embedded is the same text that gets stored. `premises` and + `sources` ride along in internal_metadata, and jsonb rejects NUL in strings + just as text columns do. + """ + return obs.model_copy( + update={ + "content": strip_nul(obs.content).strip(), + "premises": strip_nul(obs.premises), + "sources": strip_nul(obs.sources), + } + ) def _base_observation_properties() -> dict[str, Any]: @@ -986,10 +999,12 @@ async def create_observations( logger.warning("create_observations called with empty list") return ObservationsCreatedResult(created_count=0, created_levels=[], failed=[]) + # Normalize before the emptiness check: str.strip() does not remove NUL, + # so content that normalizes away has to be dropped afterwards. normalized_observations = [ - _normalized_observation_input(obs) - for obs in observations - if obs.content.strip() + normalized + for normalized in (_normalized_observation_input(obs) for obs in observations) + if normalized.content ] if not normalized_observations: logger.info("No non-empty observations to create") diff --git a/src/utils/sanitization.py b/src/utils/sanitization.py new file mode 100644 index 00000000..4879cc72 --- /dev/null +++ b/src/utils/sanitization.py @@ -0,0 +1,46 @@ +"""Helpers for stripping bytes Postgres cannot store in text columns. + +Postgres rejects NUL (0x00) in ``text``/``varchar`` values and in ``jsonb`` +strings, so any string bound into a query or persisted to those columns has to +have NUL removed first. This applies to model-generated text as much as to +user-supplied input: an LLM can emit a ``\\u0000`` escape in its tool-call +arguments, which the JSON parser decodes into a real NUL byte. +""" + +from typing import Any, cast, overload + +from pydantic import BeforeValidator + +__all__ = ["NulStripped", "strip_nul"] + + +@overload +def strip_nul(value: str) -> str: ... + + +@overload +def strip_nul(value: Any) -> Any: ... + + +def strip_nul(value: Any) -> Any: + """Recursively remove NUL bytes from strings, including nested ones. + + Dict keys are stripped alongside values. Anything that is not a string, + dict, or list -- ``None`` included -- is returned unchanged, so this can be + applied to an optional field without a guard. + """ + if isinstance(value, str): + return value.replace("\x00", "") + if isinstance(value, dict): + d = cast(dict[str, Any], value) + return {strip_nul(k): strip_nul(v) for k, v in d.items()} + if isinstance(value, list): + lst = cast(list[Any], value) + return [strip_nul(item) for item in lst] + return value + + +# Reusable annotation for string fields; composes with a per-field Field(...). +# Runs *before* the field's own constraints, so `min_length` is checked against +# the stripped value and all-NUL input is rejected instead of becoming "". +NulStripped = BeforeValidator(strip_nul) diff --git a/tests/crud/test_representation_manager.py b/tests/crud/test_representation_manager.py index 3f3d6f40..9392b78e 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, timedelta, timezone +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -196,7 +196,7 @@ class TestRepresentationManagerSoftDelete: db_session, test_workspace, test_peer ) - base = datetime(2026, 1, 1, tzinfo=timezone.utc) + base = datetime(2026, 1, 1, tzinfo=UTC) # Three conclusions, all reinforced once, inserted oldest-first. for i in range(3): db_session.add( @@ -484,13 +484,13 @@ class TestRepresentationManagerSave: explicit=[ ExplicitObservation( content=" ", - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), message_ids=[1], session_name="session", ), ExplicitObservation( content=" useful observation ", - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), message_ids=[1], session_name="session", ), @@ -515,7 +515,7 @@ class TestRepresentationManagerSave: representation, message_ids=[1], session_name="session", - message_created_at=datetime.now(timezone.utc), + message_created_at=datetime.now(UTC), message_level_configuration=_resolved_config(), ) @@ -540,7 +540,7 @@ class TestRepresentationManagerSave: conclusion=" ", premises=["premise a"], source_ids=["doc-a"], - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), message_ids=[1], session_name="session", ), @@ -548,7 +548,7 @@ class TestRepresentationManagerSave: conclusion=" inferred conclusion ", premises=["premise b"], source_ids=["doc-b"], - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), message_ids=[1], session_name="session", ), @@ -573,7 +573,7 @@ class TestRepresentationManagerSave: representation, message_ids=[1], session_name="session", - message_created_at=datetime.now(timezone.utc), + message_created_at=datetime.now(UTC), message_level_configuration=_resolved_config(), ) @@ -597,13 +597,13 @@ class TestRepresentationManagerSave: explicit=[ ExplicitObservation( content="", - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), message_ids=[1], session_name="session", ), ExplicitObservation( content="\n\t ", - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), message_ids=[1], session_name="session", ), @@ -626,7 +626,124 @@ class TestRepresentationManagerSave: representation, message_ids=[1], session_name="session", - message_created_at=datetime.now(timezone.utc), + message_created_at=datetime.now(UTC), + message_level_configuration=_resolved_config(), + ) + + assert len(saved.created_documents) == 0 + mock_embed.assert_not_awaited() + mock_save.assert_not_awaited() + + @pytest.mark.asyncio + async def test_save_representation_strips_nul_bytes(self): + """Models emit \\u0000 escapes when transcribing shell output or Windows + paths, and Postgres rejects NUL in text columns. The stripped text must + be what gets embedded as well as what gets stored.""" + manager = RepresentationManager( + "workspace", + observer="observer", + observed="observed", + ) + representation = Representation( + explicit=[ + ExplicitObservation( + content="ran 'cat /proc/1/environ | tr '\x00' '\\n''", + created_at=datetime.now(UTC), + message_ids=[1], + session_name="session", + ), + ], + deductive=[ + DeductiveObservation( + conclusion="the key is at c:\\\x00users\\amal", + premises=["saw c:\\\x00users in the prompt"], + created_at=datetime.now(UTC), + message_ids=[1], + session_name="session", + ), + ], + ) + + with ( + patch("src.crud.representation.tracked_db", _fake_tracked_db), + patch( + "src.crud.representation.embedding_client.simple_batch_embed", + new=AsyncMock(return_value=[[0.1], [0.2]]), + ) as mock_embed, + patch.object( + manager, + "_save_representation_internal", + new=AsyncMock( + return_value=CreateDocumentsResult(created_documents=[MagicMock()]) + ), + ) as mock_save, + ): + await manager.save_representation( + representation, + message_ids=[1], + session_name="session", + message_created_at=datetime.now(UTC), + message_level_configuration=_resolved_config(), + ) + + # Deductive observations are embedded ahead of explicit ones. + mock_embed.assert_awaited_once_with( + [ + "the key is at c:\\users\\amal", + "ran 'cat /proc/1/environ | tr '' '\\n''", + ], + on_oversize="truncate", + ) + + saved_observations = _saved_observations(mock_save) + deductive = next( + obs for obs in saved_observations if isinstance(obs, DeductiveObservation) + ) + explicit = next( + obs for obs in saved_observations if isinstance(obs, ExplicitObservation) + ) + assert explicit.content == "ran 'cat /proc/1/environ | tr '' '\\n''" + assert deductive.conclusion == "the key is at c:\\users\\amal" + # premises land in internal_metadata, and jsonb rejects NUL too + assert deductive.premises == ["saw c:\\users in the prompt"] + + @pytest.mark.asyncio + async def test_save_representation_skips_observations_that_are_only_nul(self): + """str.strip() does not remove NUL, so the emptiness check has to run + after normalization or an empty document gets written.""" + manager = RepresentationManager( + "workspace", + observer="observer", + observed="observed", + ) + representation = Representation( + explicit=[ + ExplicitObservation( + content="\x00\x00", + created_at=datetime.now(UTC), + message_ids=[1], + session_name="session", + ), + ] + ) + + with ( + patch("src.crud.representation.tracked_db", _fake_tracked_db), + patch( + "src.crud.representation.embedding_client.simple_batch_embed", + new=AsyncMock(), + ) as mock_embed, + patch.object( + manager, + "_save_representation_internal", + new=AsyncMock(), + ) as mock_save, + ): + saved = await manager.save_representation( + representation, + message_ids=[1], + session_name="session", + message_created_at=datetime.now(UTC), message_level_configuration=_resolved_config(), ) @@ -646,7 +763,7 @@ class TestRepresentationManagerSave: explicit=[ ExplicitObservation( content="short fact", - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), message_ids=[1], session_name="session", ) @@ -656,7 +773,7 @@ class TestRepresentationManagerSave: conclusion="inferred fact", premises=["premise"], source_ids=["doc-a"], - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), message_ids=[1], session_name="session", ) @@ -681,7 +798,7 @@ class TestRepresentationManagerSave: representation, message_ids=[1], session_name="session", - message_created_at=datetime.now(timezone.utc), + message_created_at=datetime.now(UTC), message_level_configuration=_resolved_config(), ) diff --git a/tests/test_schema_validations.py b/tests/test_schema_validations.py index 97b8b64a..e3a285f2 100644 --- a/tests/test_schema_validations.py +++ b/tests/test_schema_validations.py @@ -5,9 +5,11 @@ from pydantic import ValidationError from src.config import settings from src.schemas import ( + DialecticOptions, DocumentCreate, DocumentMetadata, MessageCreate, + ObservationInput, PeerCreate, ReasoningConfiguration, ResolvedConfiguration, @@ -275,3 +277,68 @@ class TestReasoningCustomInstructionsValidation: configuration = ReasoningConfiguration(custom_instructions=custom_instructions) assert configuration.custom_instructions == custom_instructions + + +class TestNulByteSanitization: + """Postgres rejects NUL (0x00) in text columns and in jsonb strings. + + Models emit these as `\\u0000` escapes in tool-call arguments, which the + JSON parser decodes into real NUL bytes, so model-generated text needs the + same treatment as user-supplied input. + """ + + def test_document_content_strips_nul(self): + document = DocumentCreate( + content="the key is at c:\\\x00users\\amal", + metadata=DocumentMetadata(message_ids=[1], message_created_at="2026-08-28"), + embedding=[0.1], + ) + + assert document.content == "the key is at c:\\users\\amal" + + def test_all_nul_document_content_is_rejected_not_emptied(self): + """The validator runs before `min_length`, so content that is nothing + but NUL fails validation rather than being stored as an empty string.""" + with pytest.raises(ValidationError): + DocumentCreate( + content="\x00\x00", + metadata=DocumentMetadata( + message_ids=[1], message_created_at="2026-08-28" + ), + embedding=[0.1], + ) + + def test_message_content_strips_nul(self): + message = MessageCreate(peer_id="peer", content="before\x00after") + + assert message.content == "beforeafter" + + def test_metadata_strips_nul_at_every_depth(self): + message = MessageCreate( + peer_id="peer", + content="hi", + metadata={"a\x00b": {"c": ["d\x00e", 1]}}, + ) + + assert message.metadata == {"ab": {"c": ["de", 1]}} + + def test_observation_content_strips_nul(self): + observation = ObservationInput(content="before\x00after") + + assert observation.content == "beforeafter" + + def test_all_nul_observation_content_is_rejected_not_emptied(self): + """Sanitization runs before `min_length`, so an all-NUL observation is + reported back to the model as a validation failure rather than saved + as an empty document.""" + with pytest.raises(ValidationError): + ObservationInput(content="\x00\x00") + + def test_all_nul_query_is_rejected_not_emptied(self): + """`NulStripped` runs before the field's own constraints, so a query + that is nothing but NUL fails `min_length` instead of reaching the + dialectic as an empty prompt.""" + options = DialecticOptions.model_validate({"query": "before\x00after"}) + assert options.query == "beforeafter" + with pytest.raises(ValidationError): + DialecticOptions.model_validate({"query": "\x00"}) diff --git a/tests/utils/test_sanitization.py b/tests/utils/test_sanitization.py new file mode 100644 index 00000000..dc84647f --- /dev/null +++ b/tests/utils/test_sanitization.py @@ -0,0 +1,38 @@ +from typing import Any + +import pytest + +from src.utils.sanitization import strip_nul + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + pytest.param("before\x00after", "beforeafter", id="string"), + pytest.param("no nul here", "no nul here", id="string-unchanged"), + pytest.param("\x00\x00", "", id="string-all-nul"), + pytest.param(["a\x00b", "c"], ["ab", "c"], id="list"), + pytest.param({"k\x00": "v\x00"}, {"k": "v"}, id="dict-key-and-value"), + pytest.param( + {"a": [{"b": "c\x00d"}]}, + {"a": [{"b": "cd"}]}, + id="nested", + ), + # Optional fields are passed in without a guard, so None has to survive. + pytest.param(None, None, id="none"), + pytest.param(7, 7, id="int"), + pytest.param(True, True, id="bool"), + pytest.param([], [], id="empty-list"), + ], +) +def test_strip_nul(value: Any, expected: Any) -> None: + assert strip_nul(value) == expected + + +def test_strip_nul_does_not_mutate_its_argument() -> None: + original = {"a": ["b\x00c"]} + + stripped = strip_nul(original) + + assert stripped == {"a": ["bc"]} + assert original == {"a": ["b\x00c"]} From c300236c110c6e544ced07c843c5daf44fa133a5 Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Tue, 1 Sep 2026 09:17:34 -0400 Subject: [PATCH 35/50] fix(deriver): reduce scope backfill memory usage (#1104) * fix(deriver): chunk scope backfill so large sessions don't OOM the worker _run_backfill embedded, wrote, and synced every planned copy at once, holding one Python float list per document. A 14k-document session is ~580MB of vectors alone, and several backfills run concurrently, which OOM-killed the deriver at its 1000Mi limit and crash-looped it since the work units never completed. Phases 2-4 now run per chunk of 500 specs and drop each chunk's embeddings once synced. Co-Authored-By: Claude Fable 5 * fix(deriver): hydrate backfill embeddings per chunk Phase 1 no longer materializes every source embedding into plans. load_only skips the vector column on the plan queries, and each chunk reloads only its source embeddings before embed/write/sync. * fix(deriver): lock scope membership across backfill chunk writes SELECT ... FOR UPDATE on the active SessionPeer row so a concurrent leave cannot commit between the membership check and the copy inserts. Adds a concurrency test that asserts the leave blocks until commit. * fix: add test for memory bound --------- Co-authored-by: Claude Fable 5 Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> --- src/deriver/scope_backfill.py | 114 ++++++++++++-- tests/deriver/test_scope_backfill.py | 212 ++++++++++++++++++++++++++- 2 files changed, 310 insertions(+), 16 deletions(-) diff --git a/src/deriver/scope_backfill.py b/src/deriver/scope_backfill.py index 81017e28..9be082f9 100644 --- a/src/deriver/scope_backfill.py +++ b/src/deriver/scope_backfill.py @@ -30,12 +30,12 @@ from typing import Any from sqlalchemy import select, update from sqlalchemy.dialects.postgresql import array +from sqlalchemy.orm import load_only from sqlalchemy.sql.functions import func from src import crud, models from src.config import settings from src.crud.scope import ScopeBackfillState -from src.crud.session import is_peer_in_session from src.dependencies import tracked_db from src.embedding_client import embedding_client from src.schemas import DreamType @@ -49,6 +49,10 @@ logger = logging.getLogger(__name__) # was copied from. The presence of this key is the idempotency marker. COPIED_FROM_KEY = "copied_from" +# Specs embedded, written, and synced per pass. Bounds the live embeddings +# (~40KB each as Python floats) so a large session cannot OOM the deriver. +BACKFILL_CHUNK_SIZE = 500 + def _store_embeddings_in_postgres() -> bool: """Whether document embeddings are persisted to the postgres column. @@ -173,7 +177,23 @@ async def _run_backfill( plans: list[_CopySpec] = [] async with tracked_db("scope_backfill.plan") as db: source_result = await db.execute( - select(models.Document).where( + select(models.Document) + .options( + load_only( + models.Document.id, + models.Document.workspace_name, + models.Document.observer, + models.Document.observed, + models.Document.content, + models.Document.level, + models.Document.times_derived, + models.Document.internal_metadata, + models.Document.session_name, + models.Document.source_ids, + models.Document.deleted_at, + ) + ) + .where( models.Document.workspace_name == workspace_name, models.Document.session_name == session_name, models.Document.level == "explicit", @@ -196,7 +216,19 @@ async def _run_backfill( # by (observed, copied_from). Includes soft-deleted rows: those are # restore candidates, not blockers. copies_result = await db.execute( - select(models.Document).where( + select(models.Document) + .options( + load_only( + models.Document.id, + models.Document.workspace_name, + models.Document.observer, + models.Document.observed, + models.Document.session_name, + models.Document.internal_metadata, + models.Document.deleted_at, + ) + ) + .where( models.Document.workspace_name == workspace_name, models.Document.observer == scope_peer, models.Document.session_name == session_name, @@ -216,12 +248,13 @@ async def _run_backfill( key = (source.observed, source.id) if key in live_copies: continue + # Vectors hydrate per chunk; plans only carry ids + content. plans.append( _CopySpec( observed=source.observed, source_id=source.id, content=source.content, - embedding=_embedding_as_list(source.embedding), + embedding=None, internal_metadata=dict(source.internal_metadata), times_derived=source.times_derived, source_ids=list(source.source_ids) @@ -235,7 +268,54 @@ async def _run_backfill( if not plans: return 0, set() - # Phase 2 (no DB): fill missing embeddings. Source rows have NULL + # Phases 2-4 run per chunk so only one chunk's embeddings are alive at a + # time; each chunk's vectors are dropped once synced. + store_in_postgres = _store_embeddings_in_postgres() + touched_observed: set[str] = set() + copied = 0 + for start in range(0, len(plans), BACKFILL_CHUNK_SIZE): + chunk = plans[start : start + BACKFILL_CHUNK_SIZE] + if not await _copy_chunk( + workspace_name, scope_peer, session_name, chunk, store_in_postgres + ): + return None + copied += len(chunk) + touched_observed.update(spec.observed for spec in chunk) + for spec in chunk: + spec.embedding = None + + return copied, touched_observed + + +async def _hydrate_chunk_embeddings( + workspace_name: str, plans: list[_CopySpec] +) -> None: + """Load this chunk's source embeddings from postgres (if any).""" + source_ids = [spec.source_id for spec in plans] + async with tracked_db("scope_backfill.hydrate_embeddings") as db: + result = await db.execute( + select(models.Document.id, models.Document.embedding).where( + models.Document.workspace_name == workspace_name, + models.Document.id.in_(source_ids), + ) + ) + by_id = {row.id: _embedding_as_list(row.embedding) for row in result.all()} + for spec in plans: + spec.embedding = by_id.get(spec.source_id) + + +async def _copy_chunk( + workspace_name: str, + scope_peer: str, + session_name: str, + plans: list[_CopySpec], + store_in_postgres: bool, +) -> bool: + """Embed, write, and sync one chunk. False if the session left the scope.""" + # Phase 2a (DB): pull this chunk's embeddings only. + await _hydrate_chunk_embeddings(workspace_name, plans) + + # Phase 2b (no DB): fill missing embeddings. Source rows have NULL # embeddings on external-store deployments (and soft-deleted copies may # have lost their vectors) — re-embed via the embedding API only; no LLM. missing = [spec for spec in plans if spec.embedding is None] @@ -254,17 +334,22 @@ async def _run_backfill( spec.embedding = embedding # Phase 3 (DB): write the copies. - store_in_postgres = _store_embeddings_in_postgres() touched_observed = {spec.observed for spec in plans} new_rows: list[models.Document] = [] async with tracked_db("scope_backfill.write") as db: - # scope_backfill and scope_removal carry different work-unit keys, so - # nothing orders them: a removal enqueued right after the add (or one - # that landed while phase 2 was embedding) can sweep the scope before - # these copies exist. Re-checking membership here, in the transaction - # that inserts, keeps a removed session from being copied back in. - if not await is_peer_in_session(db, workspace_name, session_name, scope_peer): - return None + # Row-lock active membership for this txn so a concurrent leave + # (``left_at``) cannot commit between the check and the inserts. + membership = 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 == scope_peer) + .where(models.SessionPeer.left_at.is_(None)) + .with_for_update() + .limit(1) + ) + if membership is None: + return False for observed in sorted(touched_observed): await crud.get_or_create_collection( @@ -319,8 +404,7 @@ async def _run_backfill( # Phase 4: sync to the external vector store (or mark synced in pgvector # mode). Failures leave rows in sync_state='pending' for the reconciler. await _sync_copies_to_vector_store(workspace_name, scope_peer, plans, copied_ids) - - return len(plans), touched_observed + return True async def _sync_copies_to_vector_store( diff --git a/tests/deriver/test_scope_backfill.py b/tests/deriver/test_scope_backfill.py index 06464c6f..efca34f3 100644 --- a/tests/deriver/test_scope_backfill.py +++ b/tests/deriver/test_scope_backfill.py @@ -22,15 +22,19 @@ engine (see ``mock_tracked_db_context`` in conftest.py) — a different connection that cannot see another session's uncommitted writes. """ +import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager from typing import Any import pytest from fastapi.testclient import TestClient from nanoid import generate as generate_nanoid from sqlalchemy import func, select, update -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from src import crud, models +from src.deriver import scope_backfill as scope_backfill_mod from src.deriver.scope_backfill import ( COPIED_FROM_KEY, process_scope_backfill, @@ -415,6 +419,128 @@ async def test_backfill_skips_a_session_that_left_the_scope( assert session_name not in peer.internal_metadata.get("backfill_status", {}) +@pytest.mark.asyncio +async def test_copy_chunk_membership_lock_blocks_leave_until_write_commits( + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + db_engine: AsyncEngine, + monkeypatch: pytest.MonkeyPatch, +): + """A concurrent leave cannot commit between membership check and inserts.""" + test_workspace, sender = sample_data + workspace_name = test_workspace.name + scope_name = str(generate_nanoid()) + scope_peer = await _create_scope_peer(db_session, workspace_name, scope_name) + session = await _create_session(db_session, workspace_name) + await _join_scope(db_session, workspace_name, session.name, scope_peer.name) + await _create_collection( + db_session, workspace_name, observer=sender.name, observed=sender.name + ) + await _create_collection( + db_session, workspace_name, observer=scope_peer.name, observed=sender.name + ) + source = await _create_document( + db_session, + workspace_name, + observer=sender.name, + observed=sender.name, + session_name=session.name, + content="locked membership fact", + ) + + factory = async_sessionmaker(bind=db_engine, expire_on_commit=False) + leave_finished = asyncio.Event() + leave_task_box: dict[str, asyncio.Task[None]] = {} + + async def concurrent_leave() -> None: + async with factory() as leave_db: + await leave_db.execute( + update(models.SessionPeer) + .where( + models.SessionPeer.workspace_name == workspace_name, + models.SessionPeer.session_name == session.name, + models.SessionPeer.peer_name == scope_peer.name, + models.SessionPeer.left_at.is_(None), + ) + .values(left_at=func.now()) + ) + await leave_db.commit() + leave_finished.set() + + original_tracked_db = scope_backfill_mod.tracked_db # pyright: ignore[reportPrivateLocalImportUsage] + + @asynccontextmanager + async def tracked_db_with_leave_race( + operation_name: str | None = None, *, read_only: bool = False + ) -> AsyncGenerator[AsyncSession]: + async with original_tracked_db(operation_name, read_only=read_only) as db: + if operation_name == "scope_backfill.write": + real_scalar = db.scalar + raced = False + + async def scalar_then_race(statement: Any, *args: Any, **kwargs: Any): + nonlocal raced + result = await real_scalar(statement, *args, **kwargs) + if not raced and result is not None: + raced = True + leave_task_box["task"] = asyncio.create_task(concurrent_leave()) + # Leave's UPDATE must block on this txn's row lock. + for _ in range(50): + await asyncio.sleep(0.01) + if leave_task_box["task"].done(): + break + assert not leave_task_box["task"].done() + return result + + db.scalar = scalar_then_race # type: ignore[method-assign] + yield db + + monkeypatch.setattr(scope_backfill_mod, "tracked_db", tracked_db_with_leave_race) + + ok = await scope_backfill_mod._copy_chunk( # pyright: ignore[reportPrivateUsage] + workspace_name, + scope_peer.name, + session.name, + [ + scope_backfill_mod._CopySpec( # pyright: ignore[reportPrivateUsage] + observed=sender.name, + source_id=source.id, + content=source.content, + embedding=None, + internal_metadata={}, + times_derived=1, + source_ids=None, + session_name=session.name, + ) + ], + store_in_postgres=True, + ) + assert ok is True + + leave_task = leave_task_box["task"] + await asyncio.wait_for(leave_task, timeout=2.0) + assert leave_finished.is_set() + + copies = await _get_docs( + db_session, + workspace_name, + observer=scope_peer.name, + observed=sender.name, + include_deleted=False, + ) + assert len(copies) == 1 + assert copies[0].internal_metadata.get(COPIED_FROM_KEY) == source.id + + membership = await db_session.scalar( + select(models.SessionPeer.left_at).where( + models.SessionPeer.workspace_name == workspace_name, + models.SessionPeer.session_name == session.name, + models.SessionPeer.peer_name == scope_peer.name, + ) + ) + assert membership is not None + + # --------------------------------------------------------------------------- # 3. Multi-peer session # --------------------------------------------------------------------------- @@ -956,3 +1082,87 @@ async def test_backfill_status_writes_preserve_the_scope_kind_flag( await db_session.commit() metadata = await assert_still_a_scope("clearing the status") assert session_name not in metadata.get("backfill_status", {}) + + +@pytest.mark.asyncio +async def test_backfill_embeds_and_writes_in_bounded_chunks( + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + monkeypatch: pytest.MonkeyPatch, +): + """Phases 2-4 run per chunk, so a large session never holds every vector.""" + from src.deriver import scope_backfill + from src.embedding_client import embedding_client + + test_workspace, sender = sample_data + workspace_name = test_workspace.name + scope_name = str(generate_nanoid()) + scope_peer = await _create_scope_peer(db_session, workspace_name, scope_name) + session = await _create_session(db_session, workspace_name) + await _join_scope(db_session, workspace_name, session.name, scope_peer.name) + await _create_collection( + db_session, workspace_name, observer=sender.name, observed=sender.name + ) + await _create_collection( + db_session, workspace_name, observer=scope_peer.name, observed=sender.name + ) + for i in range(3): + source = await _create_document( + db_session, + workspace_name, + observer=sender.name, + observed=sender.name, + session_name=session.name, + content=f"fact {i}", + ) + source.embedding = None + await db_session.commit() + + batch_sizes: list[int] = [] + seen_specs: list[scope_backfill._CopySpec] = [] # pyright: ignore[reportPrivateUsage] + peak_live_embeddings = 0 + original_embed = embedding_client.simple_batch_embed + original_copy_chunk = scope_backfill._copy_chunk # pyright: ignore[reportPrivateUsage] + + async def recording_embed(texts: list[str], **kwargs: Any) -> list[list[float]]: + batch_sizes.append(len(texts)) + return await original_embed(texts, **kwargs) + + async def counting_copy_chunk( + ws_name: str, + peer_name: str, + sess_name: str, + plans: list[scope_backfill._CopySpec], # pyright: ignore[reportPrivateUsage] + store_in_postgres: bool, + ) -> bool: + nonlocal peak_live_embeddings + seen_specs.extend(plans) + result = await original_copy_chunk( + ws_name, peer_name, sess_name, plans, store_in_postgres + ) + # Sampled after this chunk syncs but before _run_backfill drops its + # vectors, so every *earlier* chunk must already be cleared and the + # live count can never exceed one chunk. That drop is the whole + # memory bound; without it this peaks at 3 instead of 2. + peak_live_embeddings = max( + peak_live_embeddings, + sum(1 for spec in seen_specs if spec.embedding is not None), + ) + return result + + monkeypatch.setattr(scope_backfill, "BACKFILL_CHUNK_SIZE", 2) + monkeypatch.setattr(embedding_client, "simple_batch_embed", recording_embed) + monkeypatch.setattr(scope_backfill, "_copy_chunk", counting_copy_chunk) + + await process_scope_backfill( + ScopeBackfillPayload(scope_peer=scope_peer.name, session_name=session.name), + workspace_name, + ) + + assert batch_sizes == [2, 1] + assert peak_live_embeddings == 2 + copies = await _get_docs( + db_session, workspace_name, observer=scope_peer.name, observed=sender.name + ) + assert len(copies) == 3 + assert all(copy.embedding is not None for copy in copies) From a026bebdef91e2b0d052574a653afc39b3ad3918 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:03:44 -0400 Subject: [PATCH 36/50] chore(docs): Add explanation on deleting data and cloud vs local differences (#1114) --- docs/docs.json | 3 +- .../endpoint/keys/create-key.mdx | 10 ++ .../features/advanced/deleting-data.mdx | 131 ++++++++++++++++++ .../features/advanced/overview.mdx | 1 + .../features/advanced/webhooks.mdx | 8 ++ docs/v3/documentation/reference/platform.mdx | 2 + docs/v3/documentation/reference/sdk.mdx | 6 + 7 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 docs/v3/documentation/features/advanced/deleting-data.mdx diff --git a/docs/docs.json b/docs/docs.json index de5fa522..f130a939 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -75,7 +75,8 @@ "v3/documentation/features/advanced/using-filters", "v3/documentation/features/advanced/structured-outputs", "v3/documentation/features/advanced/streaming-response", - "v3/documentation/features/advanced/file-uploads" + "v3/documentation/features/advanced/file-uploads", + "v3/documentation/features/advanced/deleting-data" ] } ] diff --git a/docs/v3/api-reference/endpoint/keys/create-key.mdx b/docs/v3/api-reference/endpoint/keys/create-key.mdx index 484229c9..9f9b0470 100644 --- a/docs/v3/api-reference/endpoint/keys/create-key.mdx +++ b/docs/v3/api-reference/endpoint/keys/create-key.mdx @@ -1,3 +1,13 @@ --- openapi: post /v3/keys --- + + +**Self-hosted only.** This endpoint is not available on Honcho Cloud +(`api.honcho.dev`) — requests to it return `405 Method Not Allowed`. Create and +manage keys for a cloud instance from the +[API Keys page](https://app.honcho.dev/api-keys) in the dashboard. + +On a self-hosted instance it requires an admin key, and returns an error when +`AUTH_USE_AUTH` is disabled. + diff --git a/docs/v3/documentation/features/advanced/deleting-data.mdx b/docs/v3/documentation/features/advanced/deleting-data.mdx new file mode 100644 index 00000000..1a6ab444 --- /dev/null +++ b/docs/v3/documentation/features/advanced/deleting-data.mdx @@ -0,0 +1,131 @@ +--- +title: 'Deleting Data' +description: 'How to delete sessions, workspaces, and conclusions — and what survives each' +icon: 'trash' +--- + +Deletion in Honcho is **permanent and cannot be undone**. There is no soft +delete, no trash, and no restore. + +## What can be deleted + +| Resource | Endpoint | Behavior | +|---|---|---| +| Session | `DELETE /v3/workspaces/{workspace_id}/sessions/{session_id}` | `202` — cascade runs in the background | +| Workspace | `DELETE /v3/workspaces/{workspace_id}` | `202` — cascade runs in the background | +| Conclusion | `DELETE /v3/workspaces/{workspace_id}/conclusions/{conclusion_id}` | `204` — immediate | +| Webhook endpoint | `DELETE /v3/workspaces/{workspace_id}/webhooks/{endpoint_id}` | Immediate | + +**Peers and individual messages cannot be deleted.** To remove a peer's data, +delete the sessions it participated in, then delete its remaining conclusions +(see [Conclusions outlive their sessions](#conclusions-outlive-their-sessions)). +To remove a peer from one conversation without deleting anything, use +[remove peers from session](/v3/api-reference/endpoint/sessions/remove-peers-from-session) +instead. + +## Deleting a session + +```bash +curl -X DELETE "$HONCHO_URL/v3/workspaces/my-app/sessions/session-1" \ + -H "Authorization: Bearer $HONCHO_API_KEY" +``` + +The session is marked inactive immediately and the endpoint returns `202 +Accepted`. The cascade — messages, message embeddings, queued reasoning work, +session-scoped conclusions, and peer associations — is processed in the +background with retries. + +Because the work is asynchronous, a `202` means *accepted*, not *finished*. The +session drops out of session listings right away, but its messages and +conclusions drain afterwards. Deletion tasks are internal infrastructure work +and do **not** appear in +[queue status](/v3/documentation/features/advanced/queue-status) counts, so +there is no endpoint that reports when the cascade has finished. + + +```python Python +session.delete() +``` + +```typescript TypeScript +await session.delete(); +``` + + +## Deleting a workspace + +A workspace can only be deleted once it has **no active sessions**. Deleting a +workspace that still has sessions returns `409 Conflict`: + +```json +{"detail": "Cannot delete workspace 'my-app': active session(s) remain. Delete all sessions first."} +``` + +The correct order is: + +1. List the workspace's sessions — `POST /v3/workspaces/{workspace_id}/sessions/list` +2. Delete each session — `DELETE /v3/workspaces/{workspace_id}/sessions/{session_id}` +3. Delete the workspace — `DELETE /v3/workspaces/{workspace_id}` + +Step 2 returns `202`, so the session deletions are still draining when step 3 +runs. That is fine: a session is marked inactive synchronously, so the workspace +delete stops returning `409` as soon as the deletes are accepted. Any session +created after the workspace deletion is accepted is cascade-deleted too. + + +```python Python +# Materialize the list first — deleting shifts the pagination window +for session in list(honcho.sessions()): + session.delete() + +honcho.delete_workspace("my-app") +``` + +```typescript TypeScript +// Materialize the list first — deleting shifts the pagination window +const sessions = []; +for await (const session of await honcho.sessions()) sessions.push(session); +for (const session of sessions) await session.delete(); + +await honcho.deleteWorkspace("my-app"); +``` + + +Deleting a workspace removes every peer, session, message, conclusion, +collection, embedding, webhook endpoint, and queued task belonging to it. + +## Conclusions outlive their sessions + +This is the most common surprise. Deleting a session does **not** erase +everything Honcho learned in it. + +- **Explicit conclusions** — direct facts drawn from messages — are tied to the + session they came from and are deleted with it. +- **Derived conclusions** (deductive, inductive, contradiction) are consolidations + that may draw on several sessions. They are stored at the workspace level with + no owning session, so they survive session deletion and stay in the peer's + [representation](/v3/documentation/core-concepts/representation). + +To remove those, list and delete them directly: + + +```python Python +for conclusion in alice.conclusions.list(): + alice.conclusions.delete(conclusion.id) +``` + +```typescript TypeScript +for (const conclusion of await alice.conclusions.list()) { + await alice.conclusions.delete(conclusion.id); +} +``` + + +Deleting the whole workspace removes conclusions at every level and needs no +follow-up. + +## Permissions + +Session and workspace deletion accept any key scoped to that workspace — an +admin key is not required. Deleting a session additionally accepts a +session-scoped key. diff --git a/docs/v3/documentation/features/advanced/overview.mdx b/docs/v3/documentation/features/advanced/overview.mdx index d0d7cc2c..8c160734 100644 --- a/docs/v3/documentation/features/advanced/overview.mdx +++ b/docs/v3/documentation/features/advanced/overview.mdx @@ -23,3 +23,4 @@ Advanced features give you fine-grained control over Honcho's behavior and imple - [Filters](/v3/documentation/features/advanced/using-filters) - Filter queries with advanced parameters - [Streaming Responses](/v3/documentation/features/advanced/streaming-response) - Stream dialectic responses in real-time - [File Uploads](/v3/documentation/features/advanced/file-uploads) - Ingest files into peer memory +- [Deleting Data](/v3/documentation/features/advanced/deleting-data) - Delete sessions, workspaces, and conclusions diff --git a/docs/v3/documentation/features/advanced/webhooks.mdx b/docs/v3/documentation/features/advanced/webhooks.mdx index 8976750a..ae7d90ad 100644 --- a/docs/v3/documentation/features/advanced/webhooks.mdx +++ b/docs/v3/documentation/features/advanced/webhooks.mdx @@ -13,6 +13,14 @@ for a session has drained. Webhooks are registered per workspace. Every event for that workspace is delivered to every endpoint registered on it. + +**On Honcho Cloud, register endpoints from the dashboard.** The webhook API +below is available on self-hosted instances; on `api.honcho.dev` it returns +`405 Method Not Allowed`. Use the +[Webhooks page](https://app.honcho.dev/webhooks) instead. Everything else on +this page — payload shapes, delivery semantics — applies to both. + + ## Registering an Endpoint diff --git a/docs/v3/documentation/reference/platform.mdx b/docs/v3/documentation/reference/platform.mdx index 3ed501d5..28042d4c 100644 --- a/docs/v3/documentation/reference/platform.mdx +++ b/docs/v3/documentation/reference/platform.mdx @@ -62,6 +62,8 @@ 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 a specific `Workspace`, `Peer`, or `Session`. +Keys for a cloud instance can only be created here, not through the API — `POST /v3/keys` is disabled on `api.honcho.dev` and returns `405`. The same applies to the webhook management endpoints, which live on the [Webhooks](https://app.honcho.dev/webhooks) page. + 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. diff --git a/docs/v3/documentation/reference/sdk.mdx b/docs/v3/documentation/reference/sdk.mdx index 77652bf0..432a4aab 100644 --- a/docs/v3/documentation/reference/sdk.mdx +++ b/docs/v3/documentation/reference/sdk.mdx @@ -206,6 +206,9 @@ honcho.set_metadata(dict) # Get list of all workspace IDs workspaces = honcho.workspaces() + +# Delete a workspace and everything in it (requires no active sessions) +honcho.delete_workspace(workspace_id) ``` ```typescript TypeScript @@ -238,6 +241,9 @@ await honcho.setMetadata(metadata); // Get list of all workspace IDs const workspaces = await honcho.workspaces(); + +// Delete a workspace and everything in it (requires no active sessions) +await honcho.deleteWorkspace(workspaceId); ``` From ccdb8ba11341752e61265c5fc3fe49d3312791cf Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Wed, 2 Sep 2026 11:07:50 -0400 Subject: [PATCH 37/50] fix(deriver): fix create_documents deadlock (#1033) * fix(deriver): eliminate create_documents deadlock and stop silently burning batches on transient errors Two concurrent work units writing the same (workspace, observer, observed) collection deadlocked on times_derived reinforcement UPDATEs issued in batch order (DEV-1975, 682 events in 90 days). The deadlock was swallowed per-document, the loop cascaded PendingRollbackErrors against the dead session, the whole batch was lost, and the queue item was marked processed. - serialize writers per collection with a transaction-scoped advisory lock (pg_advisory_xact_lock + SET LOCAL lock_timeout), skipped for insert-only batches; covers all three row-lock sites in one move - hoist external-vector-store dup-candidate resolution ahead of the first DB statement so the lock's critical section contains no network calls - abort the batch on SQLAlchemyError instead of continuing through an aborted transaction; per-document skip semantics kept for non-DB errors - classify transient errors (new src/utils/retryable_errors.py) and retry them via a bounded in-process counter instead of marking items errored * fix(deriver): replace create_documents advisory lock with id-ordered row locks Advisory locks are database-scoped and would serialize every writer to a collection, including across Groudon tenants that share names. Collect reinforcement and replace ops during the loop, lock target rows with SELECT ... ORDER BY id FOR UPDATE, then apply. populate_existing reloads times_derived so a prefetched identity-map row cannot lose a concurrent increment. * fix(deriver): harden create_documents candidate hoist and test isolation Skip empty embeddings on the external-store path, isolate per-document resolve failures, and keep replacement times_derived in the in-batch ledger. Patch get_external_vector_store in the hoist test and cover in-loop SQLAlchemyError abort. * fix(deriver): address CodeRabbit findings on create_documents deadlock fix - Distinguish external resolve failure ([] skip) from pgvector fallback (None) so _semantic_dup_decision never re-enters external I/O under an open session - Bound external candidate hoist concurrency with a semaphore - Map in-loop IntegrityError to ValidationException for a uniform contract - Persist transient retry attempts on the oldest unprocessed queue item so every deriver instance shares one MAX_RETRYABLE_ATTEMPTS budget - Cover resolve-failure skip and multi-manager reclaim of the retry budget * fix(deriver): harden retry metadata cleanup and stale reinforce fallback - Strip _retry_attempts from payloads in the same transaction as mark_queue_items_as_processed / mark_queue_item_as_errored - Clear shared retry metadata only after a successful terminal mark - On reinforce, if the locked target is gone or soft-deleted, insert the incoming document instead of dropping it - Skip pgvector semantic lookup when embedding is empty so query_documents cannot embed under an open session * fix(deriver): address review on deadlock retry and row-lock apply Strip _retry_attempts before payload validation so non-representation tasks are not burned as extra_forbidden. Re-raise retryable observer save errors after telemetry so the queue actually retries. Skip same-batch reinforce fallbacks after a replace. Revert unordered FOR UPDATE on mark processed/errored and drop post-commit retry cleanup from the success path. * fix: add test and simplify queue query --------- Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> --- CLAUDE.md | 10 + src/crud/document.py | 456 +++++++++++----- src/deriver/consumer.py | 7 +- src/deriver/deriver.py | 7 + src/deriver/queue_manager.py | 160 +++++- src/utils/queue_payload.py | 8 + src/utils/retryable_errors.py | 86 +++ tests/crud/test_document.py | 642 ++++++++++++++++++++++- tests/deriver/test_deriver_processing.py | 71 ++- tests/deriver/test_queue_processing.py | 367 ++++++++++++- tests/utils/test_retryable_errors.py | 117 +++++ 11 files changed, 1765 insertions(+), 166 deletions(-) create mode 100644 src/utils/retryable_errors.py create mode 100644 tests/utils/test_retryable_errors.py diff --git a/CLAUDE.md b/CLAUDE.md index 6a83066d..ab842f99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,6 +118,16 @@ 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. +#### Multi-row locking and deadlocks + +Tables written concurrently by more than one worker — `documents` (deriver, dreamer, scope backfill/removal, reconciler) and `queue` (every deriver replica) — deadlock when two writers touch an overlapping row set in different orders. Rules: + +- **A multi-row `SELECT ... FOR UPDATE` MUST carry an explicit `ORDER BY `.** Without it Postgres locks in scan order, which differs per plan, so two writers with overlapping sets can cycle. `_apply_document_row_updates` in `src/crud/document.py` is the reference implementation. +- **`WHERE id IN (...)` does NOT impose an order**, so sorting the Python list is a no-op — the list order is discarded and the planner picks `Bitmap Heap Scan` (ctid order), `Index Scan` (id order), or `Seq Scan` per invocation. Deterministic ordering requires either a preceding `SELECT ... ORDER BY id FOR UPDATE` or `WHERE id IN (SELECT id ... ORDER BY id FOR UPDATE)`. +- **`Document.id` is a random nanoid** (`models.py`), so id order is uncorrelated with physical order — an unordered predicate `UPDATE`/`DELETE` is roughly a coin flip against an id-ordered locker per row pair, not a rare edge case. (`QueueItem.id` is an integer identity, so there id order is also chronological.) +- **Prefer no lock at all.** A single `UPDATE ... WHERE ` acquires row locks as it writes and has no separate lock phase to get wrong. Reach for `FOR UPDATE` only when a value must be read, computed in Python, and written back — that read-modify-write is the only reason `_apply_document_row_updates` locks (it replaced a server-side `func.greatest()`), and `populate_existing=True` is required with it so the identity map doesn't serve a stale pre-lock value. Server-side expressions (`func.greatest`, the JSONB `-` operator) avoid the lock entirely; see `_clear_work_unit_retry_attempts` in `src/deriver/queue_manager.py`. +- `FOR UPDATE SKIP LOCKED` (the reconciler's claim pattern) never waits, so it cannot be a deadlock partner — but holding those locks across an external call still stalls other writers. See the "never hold a DB session during external calls" rule above. + #### 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. diff --git a/src/crud/document.py b/src/crud/document.py index 37eb94b4..7a3fc63a 100644 --- a/src/crud/document.py +++ b/src/crud/document.py @@ -1,13 +1,14 @@ +import asyncio 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 +from typing import Any, Literal, cast from sqlalchemy import delete, select, update from sqlalchemy.engine import CursorResult -from sqlalchemy.exc import IntegrityError +from sqlalchemy.exc import DBAPIError, IntegrityError, SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql import Select from sqlalchemy.sql.functions import func @@ -210,6 +211,24 @@ def _uses_pgvector() -> bool: ) +# Shared by is_rejected_duplicate and create_documents candidate resolution. +_SEMANTIC_DUP_MAX_DISTANCE = 0.05 +_SEMANTIC_DUP_TOP_K = 1 +_SEMANTIC_CANDIDATE_CONCURRENCY = 8 + + +def _semantic_dup_filters(doc: schemas.DocumentCreate) -> dict[str, Any] | None: + """Merge scope for semantic dedup: never across levels, never across + sessions for explicit documents. None when the document has no valid + merge partner (session-less explicit).""" + filters: dict[str, Any] = {"level": doc.level} + if doc.level == "explicit": + if doc.session_name is None: + return None + filters["session_name"] = doc.session_name + return filters + + async def query_external_vector_document_ids( workspace_name: str, observer: str, @@ -473,6 +492,16 @@ def _dedup_key( ) +@dataclass(frozen=True, slots=True) +class _DocumentRowOp: + kind: Literal["reinforce", "replace"] + document_id: str + incoming_times_derived: int = 1 + # When a reinforce skipped insert and the locked target is gone/deleted, + # insert this document instead of dropping it. + fallback_document: schemas.DocumentCreate | None = None + + @dataclass class CreateDocumentsResult: created_documents: list[schemas.DocumentCreate] = field(default_factory=list) @@ -515,6 +544,43 @@ 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]]] = [] + # Resolve external-store dup candidates before the first DB statement. + # None = pgvector in-place fallback; [] = skip semantic (no external I/O under db). + semantic_candidates: list[list[str] | None] = [None] * len(documents) + if deduplicate and not _uses_pgvector(): + resolve_sem = asyncio.Semaphore(_SEMANTIC_CANDIDATE_CONCURRENCY) + + async def _resolve_candidates(index: int, doc: schemas.DocumentCreate) -> None: + filters = _semantic_dup_filters(doc) + if filters is None or not doc.embedding: + semantic_candidates[index] = [] + return + async with resolve_sem: + try: + ids = await query_external_vector_document_ids( + workspace_name=workspace_name, + observer=observer, + observed=observed, + embedding=doc.embedding, + top_k=_SEMANTIC_DUP_TOP_K, + max_distance=_SEMANTIC_DUP_MAX_DISTANCE, + filters=filters, + ) + except Exception: + logger.exception( + "External semantic-candidate resolve failed for %s/%s/%s", + workspace_name, + observer, + observed, + ) + semantic_candidates[index] = [] + return + semantic_candidates[index] = ids or [] + + await asyncio.gather( + *(_resolve_candidates(i, doc) for i, doc in enumerate(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 @@ -563,12 +629,14 @@ async def create_documents( # Tracks dedup keys already accepted from this batch so exact # duplicates within a single inference call collapse to one document. seen_in_batch: set[tuple[str, str, str | None]] = set() + row_ops: list[_DocumentRowOp] = [] + pending_times_derived: dict[str, int] = {} 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: + for index, doc in enumerate(documents): try: # Session-purity invariant: an explicit document must always carry # the session it was derived from. Refuse to write session-less @@ -598,88 +666,107 @@ async def create_documents( # the re-derivation as reinforcement on the existing row. 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 - # 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, + current_td = pending_times_derived.get( + existing_match.id, existing_match.times_derived + ) + pending_times_derived[existing_match.id] = max( + current_td + 1, doc.times_derived + ) + row_ops.append( + _DocumentRowOp( + "reinforce", + existing_match.id, + doc.times_derived, + fallback_document=doc, + ) ) - 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: - duplicate_result = await is_rejected_duplicate( - db, doc, workspace_name, observer=observer, observed=observed + duplicate_result, existing_dup = await _semantic_dup_decision( + db, + doc, + workspace_name, + observer=observer, + observed=observed, + candidate_document_ids=semantic_candidates[index], ) - if duplicate_result is SemanticRejectionResult.REPLACED_EXISTING: - # Existing doc was soft-deleted in favor of this one; the - # new doc still gets inserted below. + if ( + duplicate_result is SemanticRejectionResult.REPLACED_EXISTING + and existing_dup is not None + ): + current_td = pending_times_derived.get( + existing_dup.id, existing_dup.times_derived + ) + doc.times_derived = max(doc.times_derived, current_td + 1) + pending_times_derived[existing_dup.id] = doc.times_derived + row_ops.append(_DocumentRowOp("replace", existing_dup.id)) semantic_dup_replaced_count += 1 - elif duplicate_result is SemanticRejectionResult.REJECTED: + elif ( + duplicate_result is SemanticRejectionResult.REJECTED + and existing_dup is not None + ): + current_td = pending_times_derived.get( + existing_dup.id, existing_dup.times_derived + ) + pending_times_derived[existing_dup.id] = max( + current_td + 1, doc.times_derived + ) + row_ops.append( + _DocumentRowOp( + "reinforce", + existing_dup.id, + doc.times_derived, + fallback_document=doc, + ) + ) semantic_dup_rejected_count += 1 continue - metadata_dict = doc.metadata.model_dump(exclude_none=True) - - # 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 + new_doc = _document_model_from_create( + doc, workspace_name=workspace_name, observer=observer, observed=observed ) - - if store_embeddings_in_postgres and doc.embedding: - new_doc = models.Document( - workspace_name=workspace_name, - observer=observer, - observed=observed, - content=doc.content, - level=doc.level, - times_derived=doc.times_derived, - internal_metadata=metadata_dict, - session_name=doc.session_name, - embedding=doc.embedding, - # Tree linkage column - source_ids=doc.source_ids, - ) - else: - new_doc = models.Document( - workspace_name=workspace_name, - observer=observer, - observed=observed, - content=doc.content, - level=doc.level, - times_derived=doc.times_derived, - internal_metadata=metadata_dict, - session_name=doc.session_name, - # Tree linkage column - source_ids=doc.source_ids, - ) - - if doc.embedding: - new_doc.sync_state = "pending" honcho_documents.append(new_doc) accepted_documents.append(doc) - - # Track embedding for vector store (ID will be available after commit) if doc.embedding: docs_with_embeddings.append((new_doc, doc.embedding)) + except IntegrityError as e: + await db.rollback() + raise ValidationException( + "Failed to create documents due to integrity constraint violation" + ) from e + except SQLAlchemyError: + # Dead transaction: continuing would cascade PendingRollbackErrors. + await db.rollback() + raise except Exception as e: + # Per-document failures (bad content, metadata, token overflow). logger.error( f"Error adding new document to {workspace_name}/{doc.session_name}/{observer}/{observed}: {e}" ) continue try: + fallback_docs = await _apply_document_row_updates( + db, + row_ops, + workspace_name=workspace_name, + observer=observer, + observed=observed, + ) + for fallback_doc in fallback_docs: + new_doc = _document_model_from_create( + fallback_doc, + workspace_name=workspace_name, + observer=observer, + observed=observed, + ) + honcho_documents.append(new_doc) + accepted_documents.append(fallback_doc) + if fallback_doc.embedding: + docs_with_embeddings.append((new_doc, fallback_doc.embedding)) db.add_all(honcho_documents) # NOTE # If the process crashes after this commit but before vector upsert completes, @@ -775,6 +862,11 @@ async def create_documents( raise ValidationException( "Failed to create documents due to integrity constraint violation" ) from e + except DBAPIError: + # Leave the session clean for callers that own it (e.g. a deadlock + # at the final commit); the queue layer classifies and retries. + await db.rollback() + raise return CreateDocumentsResult( created_documents=accepted_documents, @@ -1152,12 +1244,163 @@ async def create_observations( return honcho_documents +def _document_model_from_create( + doc: schemas.DocumentCreate, + *, + workspace_name: str, + observer: str, + observed: str, +) -> models.Document: + metadata_dict = doc.metadata.model_dump(exclude_none=True) + store_embeddings_in_postgres = ( + settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED + ) + if store_embeddings_in_postgres and doc.embedding: + new_doc = models.Document( + workspace_name=workspace_name, + observer=observer, + observed=observed, + content=doc.content, + level=doc.level, + times_derived=doc.times_derived, + internal_metadata=metadata_dict, + session_name=doc.session_name, + embedding=doc.embedding, + source_ids=doc.source_ids, + ) + else: + new_doc = models.Document( + workspace_name=workspace_name, + observer=observer, + observed=observed, + content=doc.content, + level=doc.level, + times_derived=doc.times_derived, + internal_metadata=metadata_dict, + session_name=doc.session_name, + source_ids=doc.source_ids, + ) + if doc.embedding: + new_doc.sync_state = "pending" + return new_doc + + +async def _apply_document_row_updates( + db: AsyncSession, + ops: list[_DocumentRowOp], + *, + workspace_name: str, + observer: str, + observed: str, +) -> list[schemas.DocumentCreate]: + """Lock target rows by id, apply ops, return fallbacks for vanished targets.""" + if not ops: + return [] + # Deadlock fix: lock in id order (IN-clause order is ignored). + ids = sorted({op.document_id for op in ops}) + result = await db.execute( + select(models.Document) + .where( + models.Document.id.in_(ids), + models.Document.workspace_name == workspace_name, + models.Document.observer == observer, + models.Document.observed == observed, + ) + .order_by(models.Document.id) + .with_for_update() + # Reload identity-map rows so the Python max() sees concurrent increments. + .execution_options(populate_existing=True) + ) + locked = {doc.id: doc for doc in result.scalars()} + now = datetime.datetime.now(datetime.UTC) + fallbacks: list[schemas.DocumentCreate] = [] + stale_at_lock = { + op.document_id + for op in ops + if (locked_row := locked.get(op.document_id)) is None + or locked_row.deleted_at is not None + } + for op in ops: + row = locked.get(op.document_id) + if op.kind == "replace": + if row is not None and row.deleted_at is None: + row.deleted_at = now + continue + # reinforce + if op.document_id in stale_at_lock: + if op.fallback_document is not None: + fallbacks.append(op.fallback_document) + continue + if row is None or row.deleted_at is not None: + # An earlier op in this batch replaced this row. + continue + row.times_derived = max(row.times_derived + 1, op.incoming_times_derived) + await db.flush() + return fallbacks + + class SemanticRejectionResult(Enum): NOT_DUPLICATE = 0 REPLACED_EXISTING = 1 REJECTED = 2 +async def _semantic_dup_decision( + db: AsyncSession, + doc: schemas.DocumentCreate, + workspace_name: str, + *, + observer: str, + observed: str, + candidate_document_ids: list[str] | None = None, +) -> tuple[SemanticRejectionResult, models.Document | None]: + """Classify a semantic duplicate without writing.""" + filters = _semantic_dup_filters(doc) + if filters is None: + return SemanticRejectionResult.NOT_DUPLICATE, None + + if candidate_document_ids is not None: + similar_docs: Sequence[models.Document] = await fetch_documents_by_ids( + db=db, + workspace_name=workspace_name, + observer=observer, + observed=observed, + document_ids=candidate_document_ids, + filters=filters, + ) + elif _uses_pgvector(): + if not doc.embedding: + # Match external-store path: never embed under an open session. + return SemanticRejectionResult.NOT_DUPLICATE, None + similar_docs = await query_documents( + db=db, + workspace_name=workspace_name, + query=doc.content, + observer=observer, + observed=observed, + filters=filters, + max_distance=_SEMANTIC_DUP_MAX_DISTANCE, + top_k=_SEMANTIC_DUP_TOP_K, + embedding=doc.embedding, + ) + else: + return SemanticRejectionResult.NOT_DUPLICATE, None + + if not similar_docs: + return SemanticRejectionResult.NOT_DUPLICATE, None + + existing_doc = similar_docs[0] + tokens_new = set(embedding_client.encoding.encode(doc.content)) + tokens_existing = set(embedding_client.encoding.encode(existing_doc.content)) + unique_new = len(tokens_new - tokens_existing) + unique_existing = len(tokens_existing - tokens_new) + score_new = len(tokens_new) + (unique_new * 10) + score_existing = len(tokens_existing) + (unique_existing * 10) + if score_new >= score_existing: + return SemanticRejectionResult.REPLACED_EXISTING, existing_doc + return SemanticRejectionResult.REJECTED, existing_doc + + async def is_rejected_duplicate( db: AsyncSession, doc: schemas.DocumentCreate, @@ -1165,90 +1408,29 @@ async def is_rejected_duplicate( *, observer: str, observed: str, + candidate_document_ids: list[str] | None = None, ) -> SemanticRejectionResult: - """ - Check if a document is a duplicate of an existing document. - - Uses: 1) Cosine similarity (>=0.95), 2) Token diff for retention. - - Returns True if both: - - the document is deemed a duplicate of an existing document - - the existing document is deemed a superior 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. 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. - - 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, - workspace_name=workspace_name, - query=doc.content, + """Classify a semantic duplicate and apply the corresponding row write.""" + result, existing_doc = await _semantic_dup_decision( + db, + doc, + workspace_name, observer=observer, observed=observed, - filters=filters, - max_distance=0.05, - top_k=1, - embedding=doc.embedding, + candidate_document_ids=candidate_document_ids, ) - - if not similar_docs: - return SemanticRejectionResult.NOT_DUPLICATE - - existing_doc = similar_docs[0] - - # Step 2: Determine which has more information using token set difference - tokens_new = set(embedding_client.encoding.encode(doc.content)) - tokens_existing = set(embedding_client.encoding.encode(existing_doc.content)) - - unique_new = len(tokens_new - tokens_existing) - unique_existing = len(tokens_existing - tokens_new) - - score_new = len(tokens_new) + (unique_new * 10) - score_existing = len(tokens_existing) + (unique_existing * 10) - - # If new document has more or equal information, keep it and delete existing - if score_new >= score_existing: + if existing_doc is None: + return result + if result is SemanticRejectionResult.REPLACED_EXISTING: 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. 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) + existing_doc.deleted_at = datetime.datetime.now(datetime.UTC) await db.flush() - 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 - # 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). + return result existing_doc.times_derived = func.greatest( models.Document.times_derived + 1, doc.times_derived, @@ -1259,7 +1441,7 @@ async def is_rejected_duplicate( doc.content, existing_doc.content, ) - return SemanticRejectionResult.REJECTED + return result async def cleanup_soft_deleted_documents( @@ -1284,7 +1466,7 @@ async def cleanup_soft_deleted_documents( Returns: Count of documents cleaned up (only those where vector deletion succeeded). """ - cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( + cutoff = datetime.datetime.now(datetime.UTC) - datetime.timedelta( minutes=older_than_minutes ) diff --git a/src/deriver/consumer.py b/src/deriver/consumer.py index 118135ca..6251dc73 100644 --- a/src/deriver/consumer.py +++ b/src/deriver/consumer.py @@ -27,6 +27,7 @@ from src.telemetry.events import ( from src.telemetry.logging import log_performance_metrics from src.utils import summarizer from src.utils.queue_payload import ( + RETRY_ATTEMPTS_PAYLOAD_KEY, DeletionPayload, DreamPayload, ReconcilerPayload, @@ -44,7 +45,11 @@ logging.getLogger("sqlalchemy.engine.Engine").disabled = True async def process_item(queue_item: models.QueueItem) -> None: """Process a single item from the queue.""" task_type = queue_item.task_type - queue_payload = queue_item.payload + # Drop the work-unit retry counter before payload validation: every payload + # model sets extra="forbid", so leaving it in burns the item as + # extra_forbidden on the reclaim that was supposed to retry it. + queue_payload = dict(queue_item.payload or {}) + queue_payload.pop(RETRY_ATTEMPTS_PAYLOAD_KEY, None) workspace_name = queue_item.workspace_name # Handle reconciler first - it's the only task type that doesn't require workspace_name diff --git a/src/deriver/deriver.py b/src/deriver/deriver.py index f76c4d52..4c1e4dd2 100644 --- a/src/deriver/deriver.py +++ b/src/deriver/deriver.py @@ -25,6 +25,7 @@ from src.telemetry.sentry import with_sentry_transaction from src.utils.config_helpers import get_configuration from src.utils.formatting import format_new_turn_with_timestamp from src.utils.representation import PromptRepresentation, Representation +from src.utils.retryable_errors import is_retryable_error from src.utils.tokens import track_deriver_input_tokens from .prompts import estimate_deriver_prompt_tokens, minimal_deriver_prompt @@ -344,6 +345,12 @@ async def process_representation_tasks_batch( ) ) + retryable = next( + (exc for _, exc in save_errors if is_retryable_error(exc)), + None, + ) + if retryable is not None: + raise retryable if save_errors and successful_observer_count == 0: details = "; ".join( f"{observer}: {exc.__class__.__name__}: {exc}" diff --git a/src/deriver/queue_manager.py b/src/deriver/queue_manager.py index 1c19c131..b98c0ef6 100644 --- a/src/deriver/queue_manager.py +++ b/src/deriver/queue_manager.py @@ -6,7 +6,7 @@ import time from asyncio import Task from collections.abc import Iterable, Sequence from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from logging import getLogger from typing import Any, NamedTuple, cast @@ -15,7 +15,7 @@ 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 import Text, and_, delete, literal, or_, select, update from sqlalchemy.dialects.postgresql import insert from sqlalchemy.engine import CursorResult from sqlalchemy.ext.asyncio import AsyncSession @@ -43,6 +43,8 @@ from src.reconciler import ( from src.schemas import ResolvedConfiguration from src.telemetry import prometheus_metrics from src.telemetry.sentry import initialize_sentry +from src.utils.queue_payload import RETRY_ATTEMPTS_PAYLOAD_KEY +from src.utils.retryable_errors import is_retryable_error from src.utils.work_unit import parse_work_unit_key from src.webhooks.events import ( QueueEmptyEvent, @@ -53,6 +55,12 @@ logger = getLogger(__name__) load_dotenv(override=True) +# Total processing attempts per work unit for transient errors. Count is +# stored on the oldest unprocessed queue item so every deriver instance +# shares one budget. +MAX_RETRYABLE_ATTEMPTS = 3 +RETRY_BACKOFF_SECONDS = 1.0 + class WorkerOwnership(NamedTuple): """Represents the instance of a work unit that a worker is processing.""" @@ -301,7 +309,7 @@ class QueueManager: async def cleanup_stale_work_units(self) -> None: """Clean up stale work units""" async with tracked_db("cleanup_stale_work_units") as db: - cutoff = datetime.now(timezone.utc) - timedelta( + cutoff = datetime.now(UTC) - timedelta( minutes=settings.DERIVER.STALE_SESSION_TIMEOUT_MINUTES ) @@ -591,11 +599,24 @@ class QueueManager: items: list[QueueItem], work_unit_key: str, context: str, - ) -> None: + ) -> bool: """ - Handle processing errors by marking queue items as errored, logging, and forwarding to Sentry. - We only mark the first queue item as errored so we don't potentially throw away a batch. This allows us - to incrementally attempt to process the batch while still maintaining progress in a work unit. + Handle a processing error. Returns True when the caller should stop + processing and release the work unit for a later re-claim. + + Transient errors (is_retryable_error) get up to MAX_RETRYABLE_ATTEMPTS + attempts per work unit: items stay unprocessed with no error recorded. + The attempt count lives on the oldest unprocessed queue item so a + different deriver instance continues the same budget after reclaim. + Reprocessing is at-least-once, not idempotent: the batch is re-derived + by a fresh LLM call, so identical text collapses via exact dedup and + near-identical text via semantic dedup. Retries can therefore inflate + times_derived and double-count LLM telemetry -- acceptable because the + alternative is dropping the batch. + + Terminal errors mark only the first queue item as errored so we don't + potentially throw away a batch. This allows us to incrementally attempt + to process the batch while still maintaining progress in a work unit. Args: error: The exception that occurred @@ -603,12 +624,37 @@ class QueueManager: work_unit_key: The work unit key for the queue items context: Context string describing what was being processed (e.g., "processing representation batch") """ + if is_retryable_error(error): + try: + attempts = await self._get_work_unit_retry_attempts(work_unit_key) + 1 + if attempts < MAX_RETRYABLE_ATTEMPTS: + await self._set_work_unit_retry_attempts(work_unit_key, attempts) + logger.warning( + "Transient error %s for work unit %s (attempt %d/%d); leaving items unprocessed for retry", + context, + work_unit_key, + attempts, + MAX_RETRYABLE_ATTEMPTS, + exc_info=error, + ) + return True + except Exception: # noqa: BLE001 + logger.exception( + "Retry-counter I/O failed for work unit %s; releasing %s without recording an attempt", + work_unit_key, + context, + ) + return True + error_msg = f"{error.__class__.__name__}: {str(error)}" try: if items: + # Clear retry metadata only after the terminal mark commits so a + # failed mark leaves the shared budget intact for the next claim. await self.mark_queue_item_as_errored( items[0], work_unit_key, error_msg ) + await self._clear_work_unit_retry_attempts(work_unit_key) except Exception as mark_error: logger.error( f"Failed to mark queue items as errored for work unit {work_unit_key}: {mark_error}", @@ -621,6 +667,7 @@ class QueueManager: ) if settings.SENTRY.ENABLED: sentry_sdk.capture_exception(error) + return False async def process_work_unit(self, work_unit_key: str, worker_id: str) -> None: """Process all queue items for a specific work unit by routing to the correct handler.""" @@ -686,12 +733,18 @@ class QueueManager: ) queue_item_count += len(items_to_process) except Exception as e: - await self._handle_processing_error( + if await self._handle_processing_error( e, items_to_process, work_unit_key, f"processing {work_unit.task_type} batch", - ) + ): + # Release the work unit (via the finally + # below) and let a later poll re-claim it. + await asyncio.sleep( + self._jitter(RETRY_BACKOFF_SECONDS) + ) + break else: queue_item = await self.get_next_queue_item( @@ -710,12 +763,16 @@ class QueueManager: ) queue_item_count += 1 except Exception as e: - await self._handle_processing_error( + if await self._handle_processing_error( e, [queue_item], work_unit_key, "processing queue item", - ) + ): + await asyncio.sleep( + self._jitter(RETRY_BACKOFF_SECONDS) + ) + break except Exception as e: logger.error( @@ -1068,6 +1125,87 @@ class QueueManager: batch_max_tokens=batch_max_tokens, ) + async def _oldest_unprocessed_item( + self, + db: AsyncSession, + work_unit_key: str, + *, + for_update: bool = False, + ) -> models.QueueItem | None: + stmt = ( + select(models.QueueItem) + .where( + models.QueueItem.work_unit_key == work_unit_key, + models.QueueItem.processed.is_(False), + ) + .order_by(models.QueueItem.id) + .limit(1) + ) + if for_update: + stmt = stmt.with_for_update() + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def _get_work_unit_retry_attempts(self, work_unit_key: str) -> int: + """Read the shared transient-failure attempt count for a work unit.""" + async with tracked_db("get_work_unit_retry_attempts") as db: + item = await self._oldest_unprocessed_item(db, work_unit_key) + if item is None: + return 0 + raw = (item.payload or {}).get(RETRY_ATTEMPTS_PAYLOAD_KEY, 0) + try: + return max(0, int(raw)) + except (TypeError, ValueError): + return 0 + + async def _set_work_unit_retry_attempts( + self, work_unit_key: str, attempts: int + ) -> None: + """Persist the shared attempt count on the oldest unprocessed item.""" + async with tracked_db("set_work_unit_retry_attempts") as db: + item = await self._oldest_unprocessed_item( + db, work_unit_key, for_update=True + ) + if item is None: + await db.commit() + return + new_payload = dict(item.payload or {}) + new_payload[RETRY_ATTEMPTS_PAYLOAD_KEY] = attempts + await db.execute( + update(models.QueueItem) + .where(models.QueueItem.id == item.id) + .values(payload=new_payload) + ) + await db.commit() + + async def _clear_work_unit_retry_attempts(self, work_unit_key: str) -> None: + """Drop the shared attempt count from remaining unprocessed items. + + One statement on purpose: a multi-row ``SELECT ... FOR UPDATE`` here + would take locks on ``queue`` in scan order, which is a deadlock partner + for any other multi-row writer on the same table. The JSONB ``-`` + operator does the strip server-side, so no rows are locked ahead of the + write and there is no lock order to get wrong. + """ + async with tracked_db("clear_work_unit_retry_attempts") as db: + await db.execute( + update(models.QueueItem) + .where( + models.QueueItem.work_unit_key == work_unit_key, + models.QueueItem.processed.is_(False), + models.QueueItem.payload.has_key(RETRY_ATTEMPTS_PAYLOAD_KEY), + ) + .values( + # literal(..., Text) is required: an untyped bind leaves + # Postgres unable to pick between jsonb - text and its + # integer/array siblings. + payload=models.QueueItem.payload.op("-")( + literal(RETRY_ATTEMPTS_PAYLOAD_KEY, Text) + ) + ) + ) + await db.commit() + async def mark_queue_items_as_processed( self, items: list[QueueItem], work_unit_key: str ) -> None: diff --git a/src/utils/queue_payload.py b/src/utils/queue_payload.py index 59815ca5..6f3f1105 100644 --- a/src/utils/queue_payload.py +++ b/src/utils/queue_payload.py @@ -5,6 +5,14 @@ from pydantic import BaseModel, ConfigDict from src.schemas import DreamType, ReconcilerType, ResolvedConfiguration +# Queue mechanics, not task data: the deriver stores a per-work-unit transient +# failure count under this key so a retry budget survives work-unit reclaim. +# Every payload model below forbids extras, so anything that reads a raw +# QueueItem.payload must strip this key before validating. Lives here rather +# than in the deriver because both the writer (queue_manager) and the stripper +# (consumer) need it, and queue_manager imports consumer. +RETRY_ATTEMPTS_PAYLOAD_KEY = "_retry_attempts" + class BasePayload(BaseModel): """Base payload with common fields.""" diff --git a/src/utils/retryable_errors.py b/src/utils/retryable_errors.py new file mode 100644 index 00000000..357f594d --- /dev/null +++ b/src/utils/retryable_errors.py @@ -0,0 +1,86 @@ +"""Classify exceptions as transient (safe to retry) or terminal. + +Imports only exception taxonomies, so it is importable from anywhere and +unit-testable without a DB. +""" + +import asyncio +from collections.abc import Iterator + +import httpx +from sqlalchemy.exc import DBAPIError + +__all__ = ["is_retryable_db_error", "is_retryable_error"] + +_RETRYABLE_SQLSTATES = frozenset( + { + "40001", # serialization_failure + "40P01", # deadlock_detected + "55P03", # lock_not_available (lock_timeout / NOWAIT) + "57014", # query_canceled (statement_timeout) + "08000", # connection_exception family + "08001", + "08003", + "08004", + "08006", + } +) + +# Provider/network transport failures. SDK wrappers (anthropic/openai +# APIConnectionError etc.) chain to these via __cause__. +_TRANSPORT_ERRORS = ( + httpx.TransportError, + ConnectionError, + asyncio.TimeoutError, + TimeoutError, +) + + +def _iter_cause_chain(exc: BaseException) -> Iterator[BaseException]: + seen: set[int] = set() + current: BaseException | None = exc + while current is not None and id(current) not in seen: + seen.add(id(current)) + yield current + current = current.__cause__ + + +def _sqlstate(exc: DBAPIError) -> str | None: + """Extract the SQLSTATE off ``DBAPIError.orig``, driver-agnostically.""" + orig = getattr(exc, "orig", None) + for candidate in (orig, getattr(orig, "__cause__", None)): + code = getattr(candidate, "sqlstate", None) + if isinstance(code, str): + return code + return None + + +def is_retryable_db_error(exc: BaseException) -> bool: + """True for transient DB failures: deadlock, serialization failure, + lock/statement timeout, or a lost connection. + + Integrity (23xxx), data (22xxx), and programming (42xxx) errors are + deliberately terminal. + """ + for current in _iter_cause_chain(exc): + if not isinstance(current, DBAPIError): + continue + if current.connection_invalidated: + return True + if _sqlstate(current) in _RETRYABLE_SQLSTATES: + return True + return False + + +def is_retryable_error(exc: BaseException) -> bool: + """Superset of ``is_retryable_db_error``: also transient network/provider + transport failures (timeouts, connection refused/reset). + + Auth failures (401 from a rotated key) are deliberately terminal: they + never self-heal, so retrying only delays the burn. + """ + if is_retryable_db_error(exc): + return True + return any( + isinstance(current, _TRANSPORT_ERRORS) for current in _iter_cause_chain(exc) + ) diff --git a/tests/crud/test_document.py b/tests/crud/test_document.py index 6686e688..593ee52b 100644 --- a/tests/crud/test_document.py +++ b/tests/crud/test_document.py @@ -1,10 +1,13 @@ +import asyncio import datetime +from typing import Any from unittest.mock import AsyncMock, patch import pytest from nanoid import generate as generate_nanoid from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import OperationalError +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from src import crud, models, schemas from src.crud.document import SemanticRejectionResult, is_rejected_duplicate @@ -195,7 +198,7 @@ class TestDocumentCRUD: deleted_doc = docs["User likes pizza"] kept_doc = docs["User dislikes vegetables"] - deleted_doc.deleted_at = datetime.datetime.now(datetime.timezone.utc) + deleted_doc.deleted_at = datetime.datetime.now(datetime.UTC) await db_session.commit() results = await crud.query_documents( @@ -290,7 +293,7 @@ class TestDocumentCRUD: db_session, test_workspace, test_peer ) - base = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + base = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC) # Three conclusions, all reinforced once -- the real-world steady state # before the fix -- inserted oldest-first. for i in range(3): @@ -1374,3 +1377,636 @@ class TestSessionPurityInvariant: ) assert rejected is SemanticRejectionResult.NOT_DUPLICATE mock_query.assert_not_awaited() + + +class TestCreateDocumentsConcurrency: + """Concurrent same-collection reinforcements lock rows in id order.""" + + N_DOCS: int = 20 + N_ROUNDS: int = 5 + + async def _setup( + self, + db_session: AsyncSession, + test_workspace: models.Workspace, + test_peer: models.Peer, + ) -> tuple[models.Peer, models.Session]: + """Create an observed peer, session, and collection, committed so + they are visible to independent concurrent sessions.""" + test_peer2 = models.Peer( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + test_session = models.Session( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add_all([test_peer2, test_session]) + 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.commit() + return test_peer2, test_session + + def _batch(self, session_name: str) -> list[schemas.DocumentCreate]: + return [ + schemas.DocumentCreate( + content=f"user fact number {i}", + embedding=[0.1] * 1536, + session_name=session_name, + metadata=schemas.DocumentMetadata( + message_ids=[i], + message_created_at="2026-01-01T00:00:00Z", + ), + ) + for i in range(self.N_DOCS) + ] + + @staticmethod + def _chain(exc: BaseException) -> str: + parts: list[str] = [] + seen: set[int] = set() + e: BaseException | None = exc + while e is not None and id(e) not in seen: + seen.add(id(e)) + parts.append(f"{type(e).__name__}: {e}") + e = e.__cause__ or e.__context__ + return " <- ".join(parts) + + @pytest.mark.asyncio + async def test_concurrent_reinforcement_does_not_deadlock( + self, + db_engine: "AsyncEngine", + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """Opposing-order batches on one collection must not deadlock.""" + test_workspace, test_peer = sample_data + test_peer2, test_session = await self._setup( + db_session, test_workspace, test_peer + ) + + # Seed the rows both writers will reinforce. + await crud.create_documents( + db_session, + self._batch(test_session.name), + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + + session_factory = async_sessionmaker(bind=db_engine, expire_on_commit=False) + + for round_num in range(self.N_ROUNDS): + forward = self._batch(test_session.name) + backward = list(reversed(self._batch(test_session.name))) + + async def _run(batch: list[schemas.DocumentCreate]) -> None: + async with session_factory() as db: + await crud.create_documents( + db, + batch, + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + + results = await asyncio.gather( + _run(forward), _run(backward), return_exceptions=True + ) + errors = [r for r in results if isinstance(r, BaseException)] + assert not errors, ( + f"round {round_num}: concurrent create_documents failed: " + + "; ".join(self._chain(e) for e in errors) + ) + + # Every round reinforced the same rows: 1 seed + 2 per round. + docs = ( + ( + 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(docs) == self.N_DOCS + assert all(d.times_derived == 1 + 2 * self.N_ROUNDS for d in docs) + + +class TestCreateDocumentsErrorHandling: + """A dead transaction aborts the batch; per-document failures skip one document.""" + + async def _setup( + self, + db_session: AsyncSession, + test_workspace: models.Workspace, + test_peer: models.Peer, + ) -> tuple[models.Peer, models.Session]: + test_peer2 = models.Peer( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + test_session = models.Session( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add_all([test_peer2, test_session]) + 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.commit() + return test_peer2, test_session + + def _doc(self, content: str, session_name: str) -> schemas.DocumentCreate: + return schemas.DocumentCreate( + content=content, + embedding=[0.1] * 1536, + session_name=session_name, + metadata=schemas.DocumentMetadata( + message_ids=[1], + message_created_at="2026-01-01T00:00:00Z", + ), + ) + + @pytest.mark.asyncio + async def test_db_error_on_row_update_flush_aborts_batch( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """A DB error while applying row updates raises and commits nothing.""" + test_workspace, test_peer = sample_data + test_peer2, test_session = await self._setup( + db_session, test_workspace, test_peer + ) + # Plain strings: the rollback below expires ORM objects in the session. + workspace_name = test_workspace.name + observer = test_peer.name + observed = test_peer2.name + session_name = test_session.name + + await crud.create_documents( + db_session, + [self._doc("existing fact", session_name)], + workspace_name=workspace_name, + observer=observer, + observed=observed, + ) + + class FakePGError(Exception): + sqlstate: str = "40P01" + + deadlock = OperationalError("UPDATE documents", {}, FakePGError()) + with ( + patch.object(db_session, "flush", AsyncMock(side_effect=deadlock)), + pytest.raises(OperationalError), + ): + await crud.create_documents( + db_session, + [ + self._doc("existing fact", session_name), + self._doc("a brand new fact", session_name), + ], + workspace_name=workspace_name, + observer=observer, + observed=observed, + ) + + docs = ( + ( + await db_session.execute( + select(models.Document).where( + models.Document.workspace_name == workspace_name, + models.Document.observer == observer, + models.Document.observed == observed, + ) + ) + ) + .scalars() + .all() + ) + assert [d.content for d in docs] == ["existing fact"] + assert docs[0].times_derived == 1 + + @pytest.mark.asyncio + async def test_db_error_in_loop_aborts_batch( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """A DB error during per-document classification raises and commits nothing.""" + test_workspace, test_peer = sample_data + test_peer2, test_session = await self._setup( + db_session, test_workspace, test_peer + ) + workspace_name = test_workspace.name + observer = test_peer.name + observed = test_peer2.name + session_name = test_session.name + + class FakePGError(Exception): + sqlstate: str = "40P01" + + deadlock = OperationalError("SELECT documents", {}, FakePGError()) + with ( + patch( + "src.crud.document._semantic_dup_decision", + AsyncMock(side_effect=deadlock), + ), + pytest.raises(OperationalError), + ): + await crud.create_documents( + db_session, + [ + self._doc("a brand new fact", session_name), + self._doc("another new fact", session_name), + ], + workspace_name=workspace_name, + observer=observer, + observed=observed, + deduplicate=True, + ) + + docs = ( + ( + await db_session.execute( + select(models.Document).where( + models.Document.workspace_name == workspace_name, + models.Document.observer == observer, + models.Document.observed == observed, + ) + ) + ) + .scalars() + .all() + ) + assert docs == [] + + @pytest.mark.asyncio + async def test_per_document_error_still_skips_only_that_document( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """Non-DB per-document failures keep their skip semantics.""" + test_workspace, test_peer = sample_data + test_peer2, test_session = await self._setup( + db_session, test_workspace, test_peer + ) + + from src.crud import document as document_module + + real_dedup_key = document_module._dedup_key # pyright: ignore[reportPrivateUsage] + + def flaky_dedup_key( + content: str, level: str, session_name: str | None + ) -> tuple[str, str, str | None]: + if content == "poison": + raise ValueError("bad content") + return real_dedup_key(content, level, session_name) + + with patch.object(document_module, "_dedup_key", flaky_dedup_key): + result = await crud.create_documents( + db_session, + [ + self._doc("good fact one", test_session.name), + self._doc("poison", test_session.name), + self._doc("good fact two", test_session.name), + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + + assert sorted(d.content for d in result.created_documents) == [ + "good fact one", + "good fact two", + ] + + @pytest.mark.asyncio + async def test_empty_embedding_skips_semantic_without_embed( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + monkeypatch: pytest.MonkeyPatch, + ): + """Empty embeddings must not trigger embed() under an open session.""" + from src.config import settings + + test_workspace, test_peer = sample_data + test_peer2, test_session = await self._setup( + db_session, test_workspace, test_peer + ) + monkeypatch.setattr(settings.VECTOR_STORE, "TYPE", "pgvector") + monkeypatch.setattr(settings.VECTOR_STORE, "MIGRATED", True) + + empty = self._doc("fact without vector", test_session.name) + empty.embedding = [] + + with patch( + "src.crud.document.embedding_client.embed", + new_callable=AsyncMock, + ) as mock_embed: + result = await crud.create_documents( + db_session, + [empty], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + deduplicate=True, + ) + + assert len(result.created_documents) == 1 + mock_embed.assert_not_awaited() + + @pytest.mark.asyncio + async def test_stale_reinforce_target_falls_back_to_insert( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """If a reinforce target vanishes under lock, insert the incoming doc.""" + from src.crud import document as document_module + + test_workspace, test_peer = sample_data + test_peer2, test_session = await self._setup( + db_session, test_workspace, test_peer + ) + workspace_name = test_workspace.name + observer = test_peer.name + observed = test_peer2.name + session_name = test_session.name + + seeded = await crud.create_documents( + db_session, + [self._doc("shared fact", session_name)], + workspace_name=workspace_name, + observer=observer, + observed=observed, + ) + assert len(seeded.created_documents) == 1 + + existing = ( + 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), + ) + ) + ).scalar_one() + + real_apply = document_module._apply_document_row_updates # pyright: ignore[reportPrivateUsage] + + async def delete_then_apply(*args: Any, **kwargs: Any) -> Any: + existing.deleted_at = datetime.datetime.now(datetime.UTC) + await db_session.flush() + return await real_apply(*args, **kwargs) + + with patch.object( + document_module, + "_apply_document_row_updates", + side_effect=delete_then_apply, + ): + result = await crud.create_documents( + db_session, + [self._doc("shared fact", session_name)], + workspace_name=workspace_name, + observer=observer, + observed=observed, + ) + + assert len(result.created_documents) == 1 + live = ( + ( + 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() + ) + assert len(live) == 1 + assert live[0].id != existing.id + assert live[0].content == "shared fact" + + @pytest.mark.asyncio + async def test_same_batch_replace_then_reinforce_does_not_resurrect( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """A reinforce after a same-batch replace must not insert the inferior copy.""" + from src.crud import document as document_module + + test_workspace, test_peer = sample_data + test_peer2, test_session = await self._setup( + db_session, test_workspace, test_peer + ) + workspace_name = test_workspace.name + observer = test_peer.name + observed = test_peer2.name + session_name = test_session.name + + await crud.create_documents( + db_session, + [self._doc("shared fact", session_name)], + workspace_name=workspace_name, + observer=observer, + observed=observed, + ) + existing = ( + 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), + ) + ) + ).scalar_one() + + fallback = self._doc("shared fact", session_name) + ops = [ + document_module._DocumentRowOp("replace", existing.id), # pyright: ignore[reportPrivateUsage] + document_module._DocumentRowOp( # pyright: ignore[reportPrivateUsage] + "reinforce", + existing.id, + fallback_document=fallback, + ), + ] + fallbacks = await document_module._apply_document_row_updates( # pyright: ignore[reportPrivateUsage] + db_session, + ops, + workspace_name=workspace_name, + observer=observer, + observed=observed, + ) + assert fallbacks == [] + await db_session.commit() + live = ( + ( + 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() + ) + assert live == [] + + +class TestExternalCandidateHoist: + """External-store dup candidates resolve before the first DB statement.""" + + async def _setup( + self, + db_session: AsyncSession, + test_workspace: models.Workspace, + test_peer: models.Peer, + ) -> tuple[models.Peer, models.Session]: + observed_peer = models.Peer( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + test_session = models.Session( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add_all([observed_peer, test_session]) + await db_session.flush() + db_session.add( + models.Collection( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=observed_peer.name, + ) + ) + await db_session.commit() + return observed_peer, test_session + + def _doc(self, content: str, session_name: str) -> schemas.DocumentCreate: + return schemas.DocumentCreate( + content=content, + embedding=[0.1] * 1536, + session_name=session_name, + metadata=schemas.DocumentMetadata( + message_ids=[1], + message_created_at="2026-01-01T00:00:00Z", + ), + ) + + @pytest.mark.asyncio + async def test_external_candidates_resolved_before_db( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + monkeypatch: pytest.MonkeyPatch, + ): + from src.config import settings + + test_workspace, test_peer = sample_data + observed_peer, test_session = await self._setup( + db_session, test_workspace, test_peer + ) + monkeypatch.setattr(settings.VECTOR_STORE, "TYPE", "turbopuffer") + monkeypatch.setattr(settings.VECTOR_STORE, "MIGRATED", True) + + events: list[str] = [] + real_execute = db_session.execute + + async def spying_execute(statement: Any, *args: Any, **kwargs: Any) -> Any: + events.append("execute") + return await real_execute(statement, *args, **kwargs) + + async def fake_resolve(*_args: Any, **_kwargs: Any) -> list[str]: + events.append("resolve") + return [] + + with ( + patch.object(db_session, "execute", side_effect=spying_execute), + patch( + "src.crud.document.query_external_vector_document_ids", + side_effect=fake_resolve, + ), + patch( + "src.crud.document.get_external_vector_store", + return_value=None, + ), + ): + result = await crud.create_documents( + db_session, + [ + self._doc("fact one", test_session.name), + self._doc("fact two", test_session.name), + ], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=observed_peer.name, + deduplicate=True, + ) + + assert len(result.created_documents) == 2 + assert events[:2] == ["resolve", "resolve"] + assert "execute" in events + + @pytest.mark.asyncio + async def test_resolve_failure_skips_semantic_without_query_documents( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + monkeypatch: pytest.MonkeyPatch, + ): + from src.config import settings + + test_workspace, test_peer = sample_data + observed_peer, test_session = await self._setup( + db_session, test_workspace, test_peer + ) + monkeypatch.setattr(settings.VECTOR_STORE, "TYPE", "turbopuffer") + monkeypatch.setattr(settings.VECTOR_STORE, "MIGRATED", True) + + with ( + patch( + "src.crud.document.query_external_vector_document_ids", + side_effect=RuntimeError("store down"), + ), + patch( + "src.crud.document.get_external_vector_store", + return_value=None, + ), + patch( + "src.crud.document.query_documents", + new_callable=AsyncMock, + ) as mock_query, + ): + result = await crud.create_documents( + db_session, + [self._doc("fact one", test_session.name)], + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=observed_peer.name, + deduplicate=True, + ) + + assert len(result.created_documents) == 1 + mock_query.assert_not_awaited() diff --git a/tests/deriver/test_deriver_processing.py b/tests/deriver/test_deriver_processing.py index 29a983b5..86ab9d33 100644 --- a/tests/deriver/test_deriver_processing.py +++ b/tests/deriver/test_deriver_processing.py @@ -1,5 +1,5 @@ import signal -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -32,7 +32,7 @@ class TestDeriverProcessing: peer_name="alice", content="hello", token_count=5, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) configuration = Mock() configuration.reasoning.enabled = True @@ -82,7 +82,7 @@ class TestDeriverProcessing: peer_name="alice", content="hello", token_count=5, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) configuration = Mock() configuration.reasoning.enabled = True @@ -136,7 +136,7 @@ class TestDeriverProcessing: peer_name="alice", content="hello", token_count=5, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) configuration = Mock() configuration.reasoning.enabled = True @@ -182,6 +182,61 @@ class TestDeriverProcessing: assert event.observer_count == 1 assert event.failed_observer_count == 1 + async def test_retryable_observer_save_reraises_after_telemetry(self): + """A deadlock on one observer must propagate so the queue can retry.""" + from sqlalchemy.exc import OperationalError + + class FakePGError(Exception): + sqlstate: str = "40P01" + + deadlock = OperationalError("UPDATE documents", {}, FakePGError()) + message = Mock( + id=1, + public_id="msg_1", + session_name="session-1", + workspace_name="workspace-1", + peer_name="alice", + content="hello", + token_count=5, + created_at=datetime.now(UTC), + ) + configuration = Mock() + configuration.reasoning.enabled = True + + mock_response = HonchoLLMCallResponse( + content=PromptRepresentation( + explicit=[ + ExplicitObservationBase(content="The user has a dog named Rover") + ] + ), + input_tokens=10, + output_tokens=5, + finish_reasons=["STOP"], + ) + partial_save = AsyncMock(side_effect=[crud.CreateDocumentsResult(), deadlock]) + emitted: list[Any] = [] + with ( + patch( + "src.deriver.deriver.honcho_llm_call", + new_callable=AsyncMock, + return_value=mock_response, + ), + patch.object(RepresentationManager, "save_representation", partial_save), + patch("src.deriver.deriver.emit", side_effect=emitted.append), + pytest.raises(OperationalError), + ): + await process_representation_tasks_batch( + messages=[message], + message_level_configuration=configuration, + observers=["bob", "carol"], + observed="alice", + queue_item_message_ids=[1], + ) + + assert emitted, "expected telemetry to be emitted before the raised failure" + assert emitted[-1].observer_count == 1 + assert emitted[-1].failed_observer_count == 1 + async def test_process_representation_tasks_batch_passes_custom_instructions_into_prompt( self, ) -> None: @@ -193,7 +248,7 @@ class TestDeriverProcessing: peer_name="alice", content="hello", token_count=5, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) configuration = Mock() configuration.reasoning.enabled = True @@ -343,7 +398,7 @@ class TestDeriverProcessing: peer_name="alice", content="hello", token_count=100, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) configuration = Mock() configuration.reasoning.enabled = True @@ -394,7 +449,7 @@ class TestDeriverProcessing: peer_name="alice", content="hello", token_count=5, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) configuration = Mock() configuration.reasoning.enabled = True @@ -443,7 +498,7 @@ class TestDeriverProcessing: peer_name="alice", content="hello", token_count=5, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) configuration = Mock() configuration.reasoning.enabled = True diff --git a/tests/deriver/test_queue_processing.py b/tests/deriver/test_queue_processing.py index 81dd0632..ce8e3e13 100644 --- a/tests/deriver/test_queue_processing.py +++ b/tests/deriver/test_queue_processing.py @@ -1,17 +1,21 @@ import asyncio from collections.abc import Callable -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from nanoid import generate as generate_nanoid +from pydantic import ValidationError from sqlalchemy import select +from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession from src import models from src.config import settings +from src.deriver.consumer import process_item from src.deriver.queue_manager import QueueManager, WorkerOwnership +from src.utils.queue_payload import RETRY_ATTEMPTS_PAYLOAD_KEY, SummaryPayload from src.utils.work_unit import construct_work_unit_key @@ -1519,7 +1523,7 @@ class TestQueueProcessing: monkeypatch.setattr( settings.DERIVER, "REPRESENTATION_BATCH_MAX_AGE_SECONDS", 1800 ) - old_timestamp = datetime.now(timezone.utc) - timedelta(hours=2) + old_timestamp = datetime.now(UTC) - timedelta(hours=2) work_unit_key, queue_items = await self._add_representation_work_unit( db_session=db_session, @@ -1552,7 +1556,7 @@ class TestQueueProcessing: ) -> 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) + old_timestamp = datetime.now(UTC) - timedelta(hours=2) work_unit_key, _queue_items = await self._add_representation_work_unit( db_session=db_session, @@ -1602,7 +1606,7 @@ class TestQueueProcessing: monkeypatch.setattr( settings.DERIVER, "REPRESENTATION_BATCH_MAX_AGE_SECONDS", 1800 ) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) work_unit_key, _queue_items = await self._add_representation_work_unit( db_session=db_session, @@ -1628,7 +1632,7 @@ class TestQueueProcessing: monkeypatch.setattr( settings.DERIVER, "REPRESENTATION_BATCH_MAX_AGE_SECONDS", 1800 ) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) work_unit_key, queue_items = await self._add_representation_work_unit( db_session=db_session, @@ -1874,3 +1878,354 @@ class TestPollingJitter: 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] + + +@pytest.mark.asyncio +class TestQueueRetry: + """Bounded retry of transient errors in process_work_unit (DEV-1975). + + A transient failure (deadlock, lost connection, provider transport) must + leave the batch's queue items unprocessed and release the work unit for + re-claim, up to MAX_RETRYABLE_ATTEMPTS per work unit; terminal failures + keep today's burn-one-item behavior. + """ + + async def _seed_work_unit( + self, + db_session: AsyncSession, + sample_session_with_peers: tuple[models.Session, list[models.Peer]], + create_queue_payload: Callable[..., Any], + n_messages: int = 1, + ) -> tuple[QueueManager, str, str, list[models.QueueItem]]: + """Seed a claimed representation work unit owned by a test worker.""" + session, peers = sample_session_with_peers + peer = peers[0] + + messages: list[models.Message] = [] + for index in range(n_messages): + message = models.Message( + session_name=session.name, + workspace_name=session.workspace_name, + peer_name=peer.name, + content=f"Message {index}", + token_count=10, + 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) + + queue_items: list[models.QueueItem] = [] + work_unit_key = "" + for message in 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 = 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, + ) + 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) + + qm = QueueManager() + worker_id = "test_worker" + claimed_units = await qm.claim_work_units(db_session, [work_unit_key]) + qm.worker_ownership[worker_id] = WorkerOwnership( + work_unit_key=work_unit_key, aqs_id=claimed_units[work_unit_key] + ) + await db_session.commit() + return qm, work_unit_key, worker_id, queue_items + + @staticmethod + def _retryable_error() -> OperationalError: + class FakePGError(Exception): + sqlstate: str = "40P01" + + return OperationalError("UPDATE documents", {}, FakePGError()) + + async def _fetch_items( + self, db_session: AsyncSession, work_unit_key: str + ) -> list[models.QueueItem]: + db_session.expire_all() + return list( + ( + await db_session.execute( + select(models.QueueItem) + .where(models.QueueItem.work_unit_key == work_unit_key) + .order_by(models.QueueItem.id) + ) + ) + .scalars() + .all() + ) + + async def _aqs_rows(self, db_session: AsyncSession, work_unit_key: str) -> int: + return len( + ( + await db_session.execute( + select(models.ActiveQueueSession).where( + models.ActiveQueueSession.work_unit_key == work_unit_key + ) + ) + ) + .scalars() + .all() + ) + + async def _retry_attempts_on_items( + self, db_session: AsyncSession, work_unit_key: str + ) -> int | None: + items = await self._fetch_items(db_session, work_unit_key) + unprocessed = [item for item in items if not item.processed] + if not unprocessed: + return None + raw = (unprocessed[0].payload or {}).get("_retry_attempts") + return None if raw is None else int(raw) + + async def test_retryable_error_leaves_items_unprocessed( + self, + db_session: AsyncSession, + sample_session_with_peers: tuple[models.Session, list[models.Peer]], + create_queue_payload: Callable[..., Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A transient error stops the work unit after ONE batch fetch (no + tight loop), leaves items unprocessed with no error, and releases + the ActiveQueueSession row.""" + monkeypatch.setattr("src.deriver.queue_manager.RETRY_BACKOFF_SECONDS", 0.0) + qm, work_unit_key, worker_id, _ = await self._seed_work_unit( + db_session, sample_session_with_peers, create_queue_payload, n_messages=2 + ) + initial_semaphore_value = qm.semaphore._value + + batch_fetches = 0 + original_get_batch = qm.get_queue_item_batch + + async def counting_get_batch(*args: Any, **kwargs: Any) -> Any: + nonlocal batch_fetches + batch_fetches += 1 + return await original_get_batch(*args, **kwargs) + + with ( + patch.object(qm, "get_queue_item_batch", side_effect=counting_get_batch), + patch( + "src.deriver.queue_manager.process_representation_batch", + side_effect=self._retryable_error(), + ), + ): + await qm.process_work_unit(work_unit_key, worker_id) + + assert batch_fetches == 1 + items = await self._fetch_items(db_session, work_unit_key) + assert all(not item.processed for item in items) + assert all(item.error is None for item in items) + assert await self._aqs_rows(db_session, work_unit_key) == 0 + assert await self._retry_attempts_on_items(db_session, work_unit_key) == 1 + assert qm.semaphore._value == initial_semaphore_value + + async def test_retry_exhaustion_is_terminal( + self, + db_session: AsyncSession, + sample_session_with_peers: tuple[models.Session, list[models.Peer]], + create_queue_payload: Callable[..., Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """At the attempt cap a transient error burns the first item exactly + like today's terminal path and clears the counter.""" + from src.deriver.queue_manager import MAX_RETRYABLE_ATTEMPTS + + monkeypatch.setattr("src.deriver.queue_manager.RETRY_BACKOFF_SECONDS", 0.0) + qm, work_unit_key, worker_id, _ = await self._seed_work_unit( + db_session, sample_session_with_peers, create_queue_payload + ) + await qm._set_work_unit_retry_attempts( # pyright: ignore[reportPrivateUsage] + work_unit_key, MAX_RETRYABLE_ATTEMPTS - 1 + ) + + with patch( + "src.deriver.queue_manager.process_representation_batch", + side_effect=self._retryable_error(), + ): + await qm.process_work_unit(work_unit_key, worker_id) + + items = await self._fetch_items(db_session, work_unit_key) + assert len(items) == 1 + assert items[0].processed + assert items[0].error is not None + assert "OperationalError" in items[0].error + assert await self._retry_attempts_on_items(db_session, work_unit_key) is None + + async def test_non_retryable_error_burns_immediately( + self, + db_session: AsyncSession, + sample_session_with_peers: tuple[models.Session, list[models.Peer]], + create_queue_payload: Callable[..., Any], + ) -> None: + """A non-retryable error keeps today's behavior verbatim: the first + item is marked errored on the first attempt.""" + qm, work_unit_key, worker_id, _ = await self._seed_work_unit( + db_session, sample_session_with_peers, create_queue_payload + ) + + with patch( + "src.deriver.queue_manager.process_representation_batch", + side_effect=ValueError("bad batch"), + ): + await qm.process_work_unit(work_unit_key, worker_id) + + items = await self._fetch_items(db_session, work_unit_key) + assert len(items) == 1 + assert items[0].processed + assert items[0].error is not None + assert "ValueError" in items[0].error + assert await self._retry_attempts_on_items(db_session, work_unit_key) is None + + async def test_counter_cleared_after_success( + self, + db_session: AsyncSession, + sample_session_with_peers: tuple[models.Session, list[models.Peer]], + create_queue_payload: Callable[..., Any], + ) -> None: + """A success wipes the accumulated attempt count for the work unit.""" + qm, work_unit_key, worker_id, _ = await self._seed_work_unit( + db_session, sample_session_with_peers, create_queue_payload + ) + await qm._set_work_unit_retry_attempts(work_unit_key, 1) # pyright: ignore[reportPrivateUsage] + + async def noop_batch(*_args: Any, **_kwargs: Any) -> None: + return None + + with patch( + "src.deriver.queue_manager.process_representation_batch", + side_effect=noop_batch, + ): + await qm.process_work_unit(work_unit_key, worker_id) + + items = await self._fetch_items(db_session, work_unit_key) + assert all(item.processed for item in items) + assert all(item.error is None for item in items) + # Counter lives on the oldest unprocessed item; once that item is + # processed the budget is gone even if the payload key remains. + assert await self._retry_attempts_on_items(db_session, work_unit_key) is None + + async def test_retry_budget_survives_reclaim_by_another_manager( + self, + db_session: AsyncSession, + sample_session_with_peers: tuple[models.Session, list[models.Peer]], + create_queue_payload: Callable[..., Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A second QueueManager continues the durable attempt budget.""" + from src.deriver.queue_manager import MAX_RETRYABLE_ATTEMPTS + + monkeypatch.setattr("src.deriver.queue_manager.RETRY_BACKOFF_SECONDS", 0.0) + qm1, work_unit_key, worker_id, _ = await self._seed_work_unit( + db_session, sample_session_with_peers, create_queue_payload + ) + + with patch( + "src.deriver.queue_manager.process_representation_batch", + side_effect=self._retryable_error(), + ): + await qm1.process_work_unit(work_unit_key, worker_id) + + assert await self._retry_attempts_on_items(db_session, work_unit_key) == 1 + assert await self._aqs_rows(db_session, work_unit_key) == 0 + + # Seed the remaining budget so the next reclaim is the terminal attempt. + qm2 = QueueManager() + await qm2._set_work_unit_retry_attempts( # pyright: ignore[reportPrivateUsage] + work_unit_key, MAX_RETRYABLE_ATTEMPTS - 1 + ) + claimed = await qm2.claim_work_units(db_session, [work_unit_key]) + worker_id_2 = "test_worker_2" + qm2.worker_ownership[worker_id_2] = WorkerOwnership( + work_unit_key=work_unit_key, aqs_id=claimed[work_unit_key] + ) + await db_session.commit() + + with patch( + "src.deriver.queue_manager.process_representation_batch", + side_effect=self._retryable_error(), + ): + await qm2.process_work_unit(work_unit_key, worker_id_2) + + items = await self._fetch_items(db_session, work_unit_key) + assert len(items) == 1 + assert items[0].processed + assert items[0].error is not None + assert "OperationalError" in items[0].error + + async def test_process_item_strips_retry_counter_before_validation(self) -> None: + """A reclaimed non-representation item must survive its own retry counter. + + The counter is written onto an *unprocessed* item so the budget outlives + a work-unit reclaim -- which means the next claim re-reads it. Every + payload model sets ``extra="forbid"``, so without the strip in + ``process_item`` the reclaim raises extra_forbidden -> ValueError -> + not retryable -> the item is burned terminally on the very attempt that + was supposed to retry it. Representation tasks never hit this: their + batch path reads the payload with ``.get()`` instead of validating, + which is why the rest of this class cannot catch it. + """ + raw: dict[str, Any] = { + "task_type": "summary", + "session_name": "s", + "message_seq_in_session": 1, + "message_public_id": "msg-public-id", + "configuration": { + "reasoning": {"enabled": True}, + "peer_card": {"use": True, "create": True}, + "summary": { + "enabled": True, + "messages_per_short_summary": 20, + "messages_per_long_summary": 60, + }, + "dream": {"enabled": True}, + }, + RETRY_ATTEMPTS_PAYLOAD_KEY: 1, + } + + # Pin the premise: the payload model must keep rejecting the key, so + # this fails loudly if someone "fixes" the burn with extra="allow" + # instead of stripping. + with pytest.raises(ValidationError) as exc_info: + SummaryPayload.model_validate(raw) + assert any(err["type"] == "extra_forbidden" for err in exc_info.value.errors()) + + queue_item = models.QueueItem( + task_type="summary", + work_unit_key="summary:test-workspace:test-session", + payload=raw, + processed=False, + workspace_name="test-workspace", + message_id=1, + ) + + with patch( + "src.deriver.consumer.summarizer.summarize_if_needed", + new_callable=AsyncMock, + ) as mock_summarize: + await process_item(queue_item) + + mock_summarize.assert_awaited_once() + # The strip must happen on a copy: the counter has to stay on the row so + # the budget still advances if this attempt fails again. + assert raw[RETRY_ATTEMPTS_PAYLOAD_KEY] == 1 diff --git a/tests/utils/test_retryable_errors.py b/tests/utils/test_retryable_errors.py new file mode 100644 index 00000000..bead2d2e --- /dev/null +++ b/tests/utils/test_retryable_errors.py @@ -0,0 +1,117 @@ +"""DB-free unit tests for src/utils/retryable_errors.py.""" + +import asyncio +from typing import cast + +import httpx +import pytest +from sqlalchemy.exc import DBAPIError, OperationalError + +from src.utils.retryable_errors import is_retryable_db_error, is_retryable_error + + +class FakePGError(Exception): + """Stands in for a driver exception carrying a SQLSTATE.""" + + sqlstate: str | None + + def __init__(self, sqlstate: str | None) -> None: + super().__init__(f"fake pg error ({sqlstate})") + self.sqlstate = sqlstate + + +def _dbapi_error( + sqlstate: str | None, + *, + orig: BaseException | None = None, + connection_invalidated: bool = False, +) -> DBAPIError: + if orig is None and sqlstate is not None: + orig = FakePGError(sqlstate) + return OperationalError( + "SELECT 1", + {}, + cast(BaseException, orig), + connection_invalidated=connection_invalidated, + ) + + +@pytest.mark.parametrize( + ("sqlstate", "expected"), + [ + ("40P01", True), # deadlock_detected + ("40001", True), # serialization_failure + ("55P03", True), # lock_not_available + ("57014", True), # query_canceled + ("08006", True), # connection_failure + ("23505", False), # unique_violation + ("42P01", False), # undefined_table + ("22P02", False), # invalid_text_representation + ], +) +def test_sqlstate_classification(sqlstate: str, expected: bool): + exc = _dbapi_error(sqlstate) + assert is_retryable_db_error(exc) is expected + assert is_retryable_error(exc) is expected + + +def test_orig_none_is_terminal(): + assert not is_retryable_db_error(_dbapi_error(None)) + + +def test_sqlstate_on_orig_cause(): + """SQLSTATE found by walking orig.__cause__ when orig itself has none.""" + wrapper = Exception("driver wrapper") + wrapper.__cause__ = FakePGError("40P01") + assert is_retryable_db_error(_dbapi_error(None, orig=wrapper)) + + +def test_connection_invalidated_is_retryable(): + exc = _dbapi_error(None, connection_invalidated=True) + assert is_retryable_db_error(exc) + + +def test_dbapi_error_nested_in_cause_chain(): + outer = RuntimeError("save failed") + outer.__cause__ = _dbapi_error("40P01") + assert is_retryable_db_error(outer) + assert is_retryable_error(outer) + + +def test_non_db_exceptions_are_not_db_retryable(): + assert not is_retryable_db_error(ValueError("bad input")) + assert not is_retryable_db_error(httpx.ConnectTimeout("timed out")) + + +@pytest.mark.parametrize( + ("exc", "expected"), + [ + (httpx.ConnectTimeout("timed out"), True), + (httpx.ReadTimeout("timed out"), True), + (httpx.ConnectError("connection refused"), True), + (ConnectionResetError("reset"), True), + (asyncio.TimeoutError(), True), + (TimeoutError(), True), + (ValueError("bad input"), False), + (httpx.HTTPStatusError("401", request=None, response=None), False), # pyright: ignore[reportArgumentType] + ], +) +def test_transport_classification(exc: BaseException, expected: bool): + assert is_retryable_error(exc) is expected + assert not is_retryable_db_error(exc) + + +def test_transport_error_nested_in_cause_chain(): + """SDK wrappers (e.g. APIConnectionError) chain to httpx via __cause__.""" + wrapper = RuntimeError("provider call failed") + wrapper.__cause__ = httpx.ConnectError("connection refused") + assert is_retryable_error(wrapper) + assert not is_retryable_db_error(wrapper) + + +def test_cause_cycle_terminates(): + a = RuntimeError("a") + b = RuntimeError("b") + a.__cause__ = b + b.__cause__ = a + assert not is_retryable_error(a) From ced151420001e6c237eb148dd939c362b29617ed Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:19:42 -0400 Subject: [PATCH 38/50] =?UTF-8?q?chore(docs):=20Add=20section=20about=20ha?= =?UTF-8?q?rness=20integrations=20and=20deepseek=20harn=E2=80=A6=20(#1116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(docs): Add section about harness integrations and deepseek harness to docs * chore: Add section about harness integrations and deepseek harness to docs --- README.md | 49 ++++- docs/docs.json | 1 + .../guides/integrations/deepseek-harness.mdx | 198 ++++++++++++++++++ 3 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 docs/v3/guides/integrations/deepseek-harness.mdx diff --git a/README.md b/README.md index 2d354c66..056253cd 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,23 @@ See the full [SDK Reference](https://honcho.dev/docs/v3/documentation/reference/ ## Integrations +Honcho ships a first-party memory plugin for every major coding agent. They all read the same +`~/.honcho/config.json`, so one key configures all of them — and pointing two at the same `workspace` +gives them one shared memory. + +| Agent | Install | Source | +| ---------------- | ------------------------------------------------------- | ------------------------------------------------------------------ | +| Claude Code | `/plugin marketplace add plastic-labs/claude-honcho` | [claude-honcho](https://github.com/plastic-labs/claude-honcho) | +| Codex | `npm install -g @honcho-ai/codex-honcho` | [codex-honcho](https://github.com/plastic-labs/codex-honcho) | +| Cursor | `curl -fsSL .../cursor-honcho/main/install.sh \| bash` | [cursor-honcho](https://github.com/plastic-labs/cursor-honcho) | +| DeepSeek Harness | `dsh plugin --profile add @honcho-ai/dsh-honcho` | [dsh-honcho](https://github.com/plastic-labs/dsh-honcho) | +| OpenCode | `opencode plugin "@honcho-ai/opencode-honcho" --global` | [opencode-honcho](https://github.com/plastic-labs/opencode-honcho) | +| OpenClaw | `openclaw plugins install @honcho-ai/openclaw-honcho` | [openclaw-honcho](https://github.com/plastic-labs/openclaw-honcho) | +| Hermes | `hermes memory setup` | built in upstream | +| Any MCP client | `claude mcp add honcho --transport http ...` | [MCP guide](https://honcho.dev/docs/v3/guides/integrations/mcp) | + +Get a key at [app.honcho.dev](https://app.honcho.dev), then `honcho init` (or `uv tool install honcho-cli && honcho init`) writes it to `~/.honcho/config.json` once for every integration. + ### Claude Code Two ways, depending on how deep you want to go: @@ -194,7 +211,33 @@ claude mcp add honcho \ --header "X-Honcho-User-Name: YourName" ``` -Details: [Claude Code guide](https://honcho.dev/docs/v3/guides/integrations/claude-code) · [MCP guide](https://honcho.dev/docs/v3/guides/integrations/mcp). +Details: [Claude Code guide](https://honcho.dev/docs/v3/guides/integrations/claude-code) · [MCP guide](https://honcho.dev/docs/v3/guides/integrations/mcp) · [repo](https://github.com/plastic-labs/claude-honcho). + +### Codex + +```bash +npm install -g @honcho-ai/codex-honcho +codex-honcho install # registers hooks + MCP + skill in ~/.codex +``` + +Restart Codex to load the hooks. Details: [Codex guide](https://honcho.dev/docs/v3/guides/integrations/codex) · [repo](https://github.com/plastic-labs/codex-honcho). + +### Cursor + +```bash +curl -fsSL https://raw.githubusercontent.com/plastic-labs/cursor-honcho/main/install.sh | bash +``` + +Windows (PowerShell): `irm https://raw.githubusercontent.com/plastic-labs/cursor-honcho/main/install.ps1 | iex`. The installer wires global hooks and MCP config. Details: [cursor-honcho](https://github.com/plastic-labs/cursor-honcho). + +### DeepSeek Harness + +```bash +dsh plugin --profile add @honcho-ai/dsh-honcho +``` + +A native Cordis plugin. It injects memory into the system prompt and captures new information from the session event feed. The model gets three tools — honcho_search, honcho_chat, and honcho_remember — and you can run /honcho to check status. +Details: [DeepSeek Harness guide](https://honcho.dev/docs/v3/guides/integrations/deepseek-harness) · [repo](https://github.com/plastic-labs/dsh-honcho). ### OpenCode @@ -202,7 +245,7 @@ Details: [Claude Code guide](https://honcho.dev/docs/v3/guides/integrations/clau opencode plugin "@honcho-ai/opencode-honcho" --global ``` -Details: [OpenCode guide](https://honcho.dev/docs/v3/guides/integrations/opencode). +Details: [OpenCode guide](https://honcho.dev/docs/v3/guides/integrations/opencode) · [repo](https://github.com/plastic-labs/opencode-honcho). ### OpenClaw @@ -212,7 +255,7 @@ openclaw honcho setup openclaw gateway --force ``` -`openclaw honcho setup` prompts for your API key, writes the config, and optionally migrates legacy `MEMORY.md` / `USER.md` / `IDENTITY.md` files into Honcho (non-destructive — originals are never deleted). Details: [OpenClaw guide](https://honcho.dev/docs/v3/guides/integrations/openclaw). +`openclaw honcho setup` prompts for your API key, writes the config, and optionally migrates legacy `MEMORY.md` / `USER.md` / `IDENTITY.md` files into Honcho (non-destructive — originals are never deleted). Details: [OpenClaw guide](https://honcho.dev/docs/v3/guides/integrations/openclaw) · [repo](https://github.com/plastic-labs/openclaw-honcho). ### Hermes diff --git a/docs/docs.json b/docs/docs.json index f130a939..83b2475a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -104,6 +104,7 @@ "v3/guides/integrations/claude-code", "v3/guides/integrations/opencode", "v3/guides/integrations/codex", + "v3/guides/integrations/deepseek-harness", "v3/guides/integrations/vercel-ai-sdk", "v3/guides/integrations/crewai", "v3/guides/integrations/langgraph", diff --git a/docs/v3/guides/integrations/deepseek-harness.mdx b/docs/v3/guides/integrations/deepseek-harness.mdx new file mode 100644 index 00000000..fa612f6c --- /dev/null +++ b/docs/v3/guides/integrations/deepseek-harness.mdx @@ -0,0 +1,198 @@ +--- +title: "DeepSeek Harness" +icon: 'terminal' +description: "Add AI-native memory to DeepSeek Harness" +sidebarTitle: 'DeepSeek Harness' +--- + +`dsh` forgets everything when a session ends. This plugin gives it memory that doesn't: what you're building, how you like to work, and what you decided last week and why — carried across context resets, restarts, and fresh chats. + +It is a native [Cordis](https://github.com/cordiverse/cordis) plugin, not a hook bridge, so it hooks the harness's own extension points directly. + +## 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: Install the Plugin + + +This plugin requires a running [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness). Plugins install into a named profile, so pick the one you actually run — `web`, `headless`, `acp`, or your own. + + +```bash +dsh plugin --profile add @honcho-ai/dsh-honcho +``` + +`dsh plugin` forwards to your package manager and appends the plugin to that profile's bundle list. Because the package declares `dsh.bundle`, it activates as a configuration layer rather than sitting inert as a plain dependency. + +### Step 3: Configure + +Put your key and name in `~/.honcho/config.json`: + +```jsonc +{ + "peerName": "your-name", + "auth": { "apiKey": "${HONCHO_API_KEY}" }, + "hosts": { + "dsh": { "workspace": "dsh" } + } +} +``` + +`HONCHO_API_KEY` in the environment works on its own — the config file is only needed to change defaults. + +### Step 4: Verify + +Start `dsh` and run `/honcho`. You'll see your peer, workspace, session, and sync status, plus a link to the session in the Honcho dashboard. + + +In the `dsh` web client, `/honcho` output renders in the collapsed command panel rather than inline in the transcript. Expand the panel to read it. + + +## What You Get + +- **Memory at session start** — your profile, a summary of this project's session so far, and the conclusions relevant to what you just asked, shaped to a character budget in a single API call +- **Automatic capture** — user and assistant turns stream to Honcho in the background, debounced, and flushed at turn boundaries, before compaction, and on shutdown +- **Secret redaction** — messages are scrubbed before they leave your machine +- **Agent tools** — first-class search, reasoning, and conclusion-writing inside `dsh` +- **Shared configuration** — the same `~/.honcho/config.json` every other Honcho integration reads + +## Configuration + +Configuration lives in `~/.honcho/config.json`, shared with the other Honcho hosts. The root holds identity and connection; behavior lives under `hosts.dsh`. + +```jsonc +{ + "peerName": "your-name", + "workspace": "honcho", + "baseUrl": "https://api.honcho.dev", // bare host or …/v3 both fine + "timeoutMs": 30000, + "auth": { "apiKey": "${HONCHO_API_KEY}" }, + "enabled": true, // global kill switch + + "hosts": { + "dsh": { + "workspace": "dsh", + "aiPeer": "dsh", // defaults to the host name + "observationMode": "unified", // unified | directional + "sessionStrategy": "per-directory", + "sessionPeerPrefix": true, // session names are - + "sessions": { "/path/to/repo": "pinned-session-name" }, + + "injection": { + "sessionStart": ["directives", "summary", "peerCard"], + "perTurn": ["userContext", "dialectic"], + "tools": true, + "searchTopK": 10, + "searchMaxDistance": 0.6, + "maxConclusions": 15, // how many conclusions Honcho RETURNS + "maxRenderedConclusions": 4, // how many survive into the prompt + "contextTokens": 1500, + "cadence": { "dialectic": 5, "ttlSeconds": 300 }, + "dialectic": { + "reasoning": "low", // minimal | low | medium | high | max + "maxChars": 600 + } + }, + + "capture": { + "saveMessages": true, + "saveToolUse": false, // one-line summaries of tool activity + "writeFrequency": "async", // async | sync + "noisePatterns": [] // additive to the built-in secret patterns + }, + + "messageUpload": { + "maxUserTokens": 6000, + "maxAssistantTokens": 6000 + } + } + } +} +``` + + +Unsupported or renamed keys are reported at startup rather than silently ignored, so a stale config tells you what it is no longer doing. + + +### Injection Components + +The two menus differ in **cadence**, not in what they can carry. + +`injection.sessionStart` is injected once when a session opens: `directives`, `summary`, `peerCard`, `representation`. + +`injection.perTurn` refreshes as you work: + +| Component | Behavior | +| --- | --- | +| `userContext` | A fresh, prompt-scoped bundle of **representation + peer card**, retrieved using your current message as the search query — so recall is associative rather than merely recent | +| `dialectic` | A reasoned answer about you, run every `cadence.dialectic` turns. Nothing waits on it after the first turn, so a late answer reaches the next one | + +To get the representation without the peer card (or vice versa), name it in `sessionStart` and set `perTurn: []` — at the cost of per-turn refresh. + +### Session Strategies + +| Strategy | Session name | Notes | +| --- | --- | --- | +| `per-directory` (default) | `-` | Stable across restarts and branches | +| `per-repo` | `-` | Same memory from any subdirectory | +| `git-branch` | `--` | Falls back to `per-directory` outside a repo or on a detached HEAD | +| `per-session` | `-chat-` | A clean slate every restart | +| `global` | `` | One memory for everything | + + +Prefer the wider scopes. The background Deriver needs a single session to accumulate enough material before it can reason well. `git-branch` splits a project's memory per branch, and `per-session` discards it on every restart. + + +### Sharing Memory With Other Integrations + +Each integration defaults to its own Honcho `workspace` — `dsh` here, `claude_code` for claude-honcho — and a workspace is the isolation boundary, so **by default they do not see each other's memory.** Point them at the same `workspace` to merge them: + +```jsonc +"hosts": { + "dsh": { "workspace": "shared" }, + "claude_code": { "workspace": "shared" } +} +``` + +Keep `peerName` identical across them too, since conclusions are stored per peer. + +## Commands + +| Command | Description | +| --- | --- | +| `/honcho` | Status: peer, workspace, session, strategy, pending uploads, last sync, last fetch | +| `/honcho config` | Resolved settings, the file they came from, and any ignored injection components | +| `/honcho flush` | Sync now | + +## Agent Tools + +| Tool | Description | +| --- | --- | +| `honcho_search` | Look something up — searches raw messages **and** derived conclusions | +| `honcho_chat` | Ask a question of judgment. Reasons over everything Honcho knows; slower | +| `honcho_remember` | Save a durable fact, preference, or decision | + +Set `injection.tools` to `false` to inject memory without exposing tools. + +## Requirements + +- Node `^22.19.0 || >=24.0.0` +- A running `dsh` +- A Honcho API key, or a self-hosted Honcho at `baseUrl` + +## Next Steps + + + + Source code, issues, and README. + + + + Learn about peers, sessions, and dialectic reasoning. + + From 997b4764b926b13c23b72a034d5c96c1ac364862 Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Wed, 2 Sep 2026 12:37:17 -0400 Subject: [PATCH 39/50] chore: add changelog and version updates (#1117) API: 3.1.0 -> 3.1.1 Python/TS SDK: 2.4.0 -> 2.4.0 (unchanged) CLI: 0.1.4 -> 0.1.4 (unchanged) --- CHANGELOG.md | 15 +++++++++++++++ README.md | 2 +- docs/changelog/compatibility-guide.mdx | 3 ++- docs/changelog/introduction.mdx | 23 +++++++++++++++++++---- docs/docs.json | 2 +- docs/v3/openapi.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 8 files changed, 41 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bef42b6..96a8534f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ 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.1.1] - 2026-09-02 + +### Changed + +- Server `requires-python` is `>=3.13`, matching the production image. Self-hosters on 3.10–3.12 need to upgrade; SDK and CLI floors are unchanged (#1090) + +### Fixed + +- Concurrent `create_documents` writers to the same collection deadlocked on `times_derived` reinforcement UPDATEs issued in batch order; the error was swallowed per-document, the batch was lost, and the queue item was marked processed. Writers now lock target rows with `SELECT ... ORDER BY id FOR UPDATE` before applying, abort the batch on `SQLAlchemyError` instead of continuing through a dead session, and retry transient errors (deadlock, serialization failure, lock/statement timeout, lost connection) up to `MAX_RETRYABLE_ATTEMPTS` instead of burning the item (#1033) +- Scope backfill no longer embeds, writes, and syncs every planned copy at once. A 14k-document session is ~580MB of vectors; several concurrent backfills OOM-killed the deriver at its 1000Mi limit and crash-looped because the work units never completed. Phases 2–4 now run per chunk of 500 specs, reload source embeddings per chunk, and drop them once synced. Membership is locked across chunk writes so a concurrent leave cannot commit between the check and the inserts (#1104) +- Model-generated observations with NUL bytes (`\u0000`) no longer fail the exact-content dedup pre-fetch with a Postgres `DataError` that dropped the whole observer batch. Ingress already stripped NUL from user content; the deriver now strips it so stored text matches embedded text. All-NUL content is dropped rather than stored empty (#1095) +- `search_messages` no longer forwards `top_k=0` to Turbopuffer (which requires 1..10000). Zero/negative limits short-circuit to empty results; tool limits are floored at 1. The documents path was already guarded (#970); this closes the message path (#1084) +- OpenAI-compatible tool-call turns with `content=null` keep null through history replay instead of being coerced to `""`. Providers that bind reasoning state to the exact assistant message shape were breaking on the empty string. Tool-less null still becomes `""` (#1064) +- The production image now ships `pyproject.toml` in the runtime stage, so the service reports its real version instead of `unknown` in OpenAPI and telemetry (#1074) + ## [3.1.0] - 2026-08-25 ### Added diff --git a/README.md b/README.md index 056253cd..e9011cef 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ --- -![Static Badge](https://img.shields.io/badge/Server-3.1.0-blue) +![Static Badge](https://img.shields.io/badge/Server-3.1.1-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) [![CLI](https://img.shields.io/pypi/v/honcho-cli.svg?label=honcho-cli)](https://pypi.org/project/honcho-cli/) diff --git a/docs/changelog/compatibility-guide.mdx b/docs/changelog/compatibility-guide.mdx index abdd1361..20cf904f 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.1.0 (Current) | v2.4.0 | v2.4.0 | +| v3.1.1 (Current) | v2.4.0 | v2.4.0 | +| v3.1.0 | v2.4.0 | v2.4.0 | | v3.0.12 | v2.3.0 | v2.3.0 | | v3.0.11 | v2.1.2 | v2.1.2 | | v3.0.10 | v2.1.2 | v2.1.2 | diff --git a/docs/changelog/introduction.mdx b/docs/changelog/introduction.mdx index a152d16a..264f683b 100644 --- a/docs/changelog/introduction.mdx +++ b/docs/changelog/introduction.mdx @@ -27,7 +27,22 @@ Welcome to the Honcho changelog! This section documents all notable changes to t ### Honcho API and SDK Changelogs - + + ### Changed + + - Server `requires-python` is `>=3.13`, matching the production image. Self-hosters on 3.10–3.12 need to upgrade; SDK and CLI floors are unchanged (#1090) + + ### Fixed + + - Concurrent `create_documents` writers to the same collection deadlocked on `times_derived` reinforcement UPDATEs issued in batch order; the error was swallowed per-document, the batch was lost, and the queue item was marked processed. Writers now lock target rows with `SELECT ... ORDER BY id FOR UPDATE` before applying, abort the batch on `SQLAlchemyError` instead of continuing through a dead session, and retry transient errors (deadlock, serialization failure, lock/statement timeout, lost connection) up to `MAX_RETRYABLE_ATTEMPTS` instead of burning the item (#1033) + - Scope backfill no longer embeds, writes, and syncs every planned copy at once. A 14k-document session is ~580MB of vectors; several concurrent backfills OOM-killed the deriver at its 1000Mi limit and crash-looped because the work units never completed. Phases 2–4 now run per chunk of 500 specs, reload source embeddings per chunk, and drop them once synced. Membership is locked across chunk writes so a concurrent leave cannot commit between the check and the inserts (#1104) + - Model-generated observations with NUL bytes (`\u0000`) no longer fail the exact-content dedup pre-fetch with a Postgres `DataError` that dropped the whole observer batch. Ingress already stripped NUL from user content; the deriver now strips it so stored text matches embedded text. All-NUL content is dropped rather than stored empty (#1095) + - `search_messages` no longer forwards `top_k=0` to Turbopuffer (which requires 1..10000). Zero/negative limits short-circuit to empty results; tool limits are floored at 1. The documents path was already guarded (#970); this closes the message path (#1084) + - OpenAI-compatible tool-call turns with `content=null` keep null through history replay instead of being coerced to `""`. Providers that bind reasoning state to the exact assistant message shape were breaking on the empty string. Tool-less null still becomes `""` (#1064) + - The production image now ships `pyproject.toml` in the runtime stage, so the service reports its real version instead of `unknown` in OpenAPI and telemetry (#1074) + + + ### Added - Scopes: a named grouping of sessions that acts as a visibility boundary on recall, implemented as a facade over an observer peer (`scope.{name}` with `{"kind": "scope"}`). Developers manage them exclusively through `/v3/workspaces/{workspace_id}/scopes` (create-or-get, list, get, add/list/remove session membership) and an optional `scopes` field on session create — never through the observer/observed mechanics. Scope peers cannot author messages, cannot be a chat or representation `target`, are excluded from `peers.list` by default (`PeerGet.kind` = `"scope"` / `"all"` switches the view), and are rejected on the generic session-peer routes. Workspace-level key required; peer- and session-scoped keys get 401. Legacy peers occupying a reserved `scope.` name without the kind flag are refused with 409, never adopted (#884) @@ -785,7 +800,7 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [Python SDK](https://pypi.org/project/honcho-ai/) - + ### Added - Scopes: `Honcho.scope()` / `HonchoAio.scope()` get-or-create a named visibility boundary, `Honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `Honcho.session(..., scopes=[...])` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+). @@ -964,7 +979,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 - Scopes: `honcho.scope()` get-or-creates a named visibility boundary, `honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `honcho.session({ scopes: [...] })` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+). @@ -1170,7 +1185,7 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [Honcho CLI](https://pypi.org/project/honcho-cli/) - + ### Added - A TTY notice when a newer `honcho-cli` is on PyPI (`uv tool upgrade honcho-cli`). Skipped in JSON mode; disable with `HONCHO_NO_UPDATE_CHECK` diff --git a/docs/docs.json b/docs/docs.json index 83b2475a..a1dbb487 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -28,7 +28,7 @@ "navigation": { "versions": [ { - "version": "v3.1.0", + "version": "v3.1.1", "api": { "openapi": ["v3/openapi.json"] }, diff --git a/docs/v3/openapi.json b/docs/v3/openapi.json index b2bc0adf..e7a836ee 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.1.0" + "version": "3.1.1" }, "servers": [ { diff --git a/pyproject.toml b/pyproject.toml index a6681ceb..7c858e23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho" -version = "3.1.0" +version = "3.1.1" description = "Honcho Server" authors = [ {name = "Plastic Labs", email = "hello@plasticlabs.ai"}, diff --git a/uv.lock b/uv.lock index 0e84663f..a9a92a6d 100644 --- a/uv.lock +++ b/uv.lock @@ -824,7 +824,7 @@ wheels = [ [[package]] name = "honcho" -version = "3.1.0" +version = "3.1.1" source = { virtual = "." } dependencies = [ { name = "alembic" }, From 5d992bc65afcfbc05a5911ab4edbaa88ef64c690 Mon Sep 17 00:00:00 2001 From: Ulysse Pence <736903+ulyssepence@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:42:48 -0400 Subject: [PATCH 40/50] feat(api): Export deriver backlog as metrics from API endpoint (#1115) --- src/backlog.py | 146 +++++++++ src/config.py | 3 + src/crud/__init__.py | 7 +- src/crud/deriver.py | 152 ++++++++- src/deriver/queue_manager.py | 45 +-- src/dreamer/dream_due.py | 216 +++++++++++++ src/main.py | 12 + src/reconciler/embed_now.py | 4 +- src/reconciler/sync_vectors.py | 8 +- src/routers/deriver_metrics.py | 41 +++ src/schemas/__init__.py | 2 + src/schemas/internal.py | 11 + src/telemetry/prometheus/metrics.py | 107 ++++++ tests/crud/test_deriver_metrics_query.py | 394 +++++++++++++++++++++++ tests/dreamer/test_dream_due.py | 321 ++++++++++++++++++ tests/telemetry/test_metric_zero_init.py | 18 ++ tests/test_deriver_metrics.py | 207 ++++++++++++ 17 files changed, 1656 insertions(+), 38 deletions(-) create mode 100644 src/backlog.py create mode 100644 src/dreamer/dream_due.py create mode 100644 src/routers/deriver_metrics.py create mode 100644 tests/crud/test_deriver_metrics_query.py create mode 100644 tests/dreamer/test_dream_due.py create mode 100644 tests/test_deriver_metrics.py diff --git a/src/backlog.py b/src/backlog.py new file mode 100644 index 00000000..1180be57 --- /dev/null +++ b/src/backlog.py @@ -0,0 +1,146 @@ +"""Read-only polling of the deriver's outstanding work. Schedules nothing.""" + +import asyncio +import contextlib +import time +from dataclasses import dataclass, field +from logging import getLogger + +import sentry_sdk + +from src import crud, schemas +from src.config import settings +from src.dependencies import tracked_db +from src.dreamer.dream_due import count_due_dreams +from src.telemetry import prometheus_metrics + +logger = getLogger(__name__) + + +def active_work_seconds() -> float: + """The value reported when work is ready for a deriver now.""" + return float(max(settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS, 1)) + + +@dataclass +class DeriverMetricsSnapshot: + """The last good poll result, served to callers of the route.""" + + signal_seconds: float = 0.0 + dreams_due: int = 0 + stats: schemas.DeriverMetrics = field(default_factory=schemas.DeriverMetrics) + measured_at: float | None = None + + @property + def age_seconds(self) -> float | None: + if self.measured_at is None: + return None + return max(0.0, time.time() - self.measured_at) + + +def outstanding_work_seconds( + stats: schemas.DeriverMetrics, *, dreams_due: int +) -> float: + """Seconds of outstanding deriver work, 0 when there is nothing to do.""" + if ( + stats.eligible_work_units > 0 + or stats.claimed_work_units > 0 + or stats.embeddings_pending_due > 0 + or dreams_due > 0 + ): + return active_work_seconds() + if stats.pending_items > 0: + return stats.oldest_pending_age_seconds + return 0.0 + + +class DeriverMetricsPoller: + """Refreshes the deriver gauges and the cached snapshot on a timer.""" + + def __init__(self) -> None: + self._task: asyncio.Task[None] | None = None + self._shutdown_event: asyncio.Event = asyncio.Event() + self._snapshot: DeriverMetricsSnapshot = DeriverMetricsSnapshot() + self._next_dream_poll: float | None = None + self._dreams_due: int = 0 + + @property + def snapshot(self) -> DeriverMetricsSnapshot: + return self._snapshot + + async def start(self) -> None: + if self._task is not None: + logger.warning("DeriverMetricsPoller already running") + return + self._shutdown_event.clear() + self._task = asyncio.create_task(self._loop()) + logger.info( + "DeriverMetricsPoller started, interval %ss", + settings.DERIVER.BACKLOG_METRICS_POLL_INTERVAL_SECONDS, + ) + + async def shutdown(self) -> None: + if self._task is None: + return + logger.info("Shutting down DeriverMetricsPoller...") + self._shutdown_event.set() + try: + await asyncio.wait_for(self._task, timeout=5.0) + except TimeoutError: + logger.warning("DeriverMetricsPoller shutdown timed out, cancelling task") + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + self._task = None + logger.info("DeriverMetricsPoller stopped") + + async def _loop(self) -> None: + interval = settings.DERIVER.BACKLOG_METRICS_POLL_INTERVAL_SECONDS + while not self._shutdown_event.is_set(): + try: + await self.refresh() + except Exception as e: + logger.error("DeriverMetricsPoller refresh failed: %s", e) + if settings.SENTRY.ENABLED: + sentry_sdk.capture_exception(e) + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(self._shutdown_event.wait(), timeout=interval) + + async def refresh(self) -> None: + """One read-only pass. The snapshot only advances on a complete pass.""" + async with tracked_db("deriver_metrics", read_only=True) as db: + stats = await crud.get_deriver_metrics(db) + if self._dream_poll_due(): + self._dreams_due = await count_due_dreams(db) + self._next_dream_poll = ( + time.monotonic() + settings.DREAM.DUE_POLL_INTERVAL_SECONDS + ) + + signal = outstanding_work_seconds(stats, dreams_due=self._dreams_due) + measured_at = time.time() + + self._snapshot = DeriverMetricsSnapshot( + signal_seconds=signal, + dreams_due=self._dreams_due, + stats=stats, + measured_at=measured_at, + ) + + metrics = prometheus_metrics + metrics.set_deriver_metrics( + eligible_work_units=stats.eligible_work_units, + claimed_work_units=stats.claimed_work_units, + pending_items=stats.pending_items, + oldest_pending_age_seconds=stats.oldest_pending_age_seconds, + embeddings_pending=stats.embeddings_pending, + embeddings_pending_due=stats.embeddings_pending_due, + ) + metrics.set_dreams_due(count=self._dreams_due) + metrics.set_deriver_outstanding_work(seconds=signal) + metrics.set_deriver_metrics_last_success(timestamp=measured_at) + + def _dream_poll_due(self) -> bool: + """The dream query is far more expensive, so it runs on its own spacing.""" + return ( + self._next_dream_poll is None or time.monotonic() >= self._next_dream_poll + ) diff --git a/src/config.py b/src/config.py index 993f9bfb..80827327 100644 --- a/src/config.py +++ b/src/config.py @@ -972,6 +972,8 @@ class DeriverSettings(HonchoSettings): # When enabled, bypasses the batch token threshold and processes work immediately FLUSH_ENABLED: bool = False + BACKLOG_METRICS_POLL_INTERVAL_SECONDS: Annotated[int, Field(default=30, ge=1)] = 30 + @model_validator(mode="before") @classmethod def _merge_model_config_defaults(cls, data: Any) -> Any: @@ -1351,6 +1353,7 @@ class DreamSettings(HonchoSettings): DOCUMENT_THRESHOLD: Annotated[int, Field(default=50, gt=0, le=1000)] = 50 IDLE_TIMEOUT_MINUTES: Annotated[int, Field(default=60, gt=0, le=1440)] = 60 MIN_HOURS_BETWEEN_DREAMS: Annotated[int, Field(default=8, gt=0, le=72)] = 8 + DUE_POLL_INTERVAL_SECONDS: Annotated[int, Field(default=300, ge=1)] = 300 ENABLED_TYPES: list[str] = ["omni"] # Agent iteration limit - increased for extended reasoning workflow diff --git a/src/crud/__init__.py b/src/crud/__init__.py index 0e920717..ac17af3f 100644 --- a/src/crud/__init__.py +++ b/src/crud/__init__.py @@ -3,7 +3,11 @@ from .collection import ( get_or_create_collection, update_collection_internal_metadata, ) -from .deriver import get_deriver_status, get_queue_status +from .deriver import ( + get_deriver_metrics, + get_deriver_status, + get_queue_status, +) from .document import ( CreateDocumentsResult, create_documents, @@ -105,6 +109,7 @@ __all__ = [ "get_or_create_collection", "update_collection_internal_metadata", # Deriver + "get_deriver_metrics", "get_deriver_status", "get_queue_status", # Document diff --git a/src/crud/deriver.py b/src/crud/deriver.py index 0852a479..770ba929 100644 --- a/src/crud/deriver.py +++ b/src/crud/deriver.py @@ -1,15 +1,165 @@ from collections.abc import Sequence +from datetime import UTC, datetime, timedelta from logging import getLogger from typing import Any -from sqlalchemy import Select, case, func, or_, select +from sqlalchemy import ColumnElement, Select, case, func, or_, select from sqlalchemy.engine import Row from sqlalchemy.ext.asyncio import AsyncSession from src import models, schemas +from src.config import settings logger = getLogger(__name__) +REPRESENTATION_WORK_UNIT_PREFIX = "representation:" + + +def representation_batch_threshold_clause( + *, + work_unit_key: ColumnElement[str], + total_tokens: ColumnElement[Any], + oldest_created_at: ColumnElement[Any], +) -> ColumnElement[bool] | None: + """The batch gate a representation work unit passes before it is claimable, or None when no gate applies.""" + if settings.DERIVER.FLUSH_ENABLED: + return None + + target_tokens = settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS + if target_tokens <= 0: + return None + + threshold: ColumnElement[bool] = func.coalesce(total_tokens, 0) >= target_tokens + + max_age_seconds = settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS + if max_age_seconds > 0: + threshold = or_( + threshold, + oldest_created_at <= func.now() - timedelta(seconds=max_age_seconds), + ) + + return or_( + ~work_unit_key.startswith(REPRESENTATION_WORK_UNIT_PREFIX), + threshold, + ) + + +def unclaimed_work_unit_clause( + work_unit_key: ColumnElement[str], +) -> ColumnElement[bool]: + """No claim row exists for this work unit, stale ones included.""" + return ( + ~select(models.ActiveQueueSession.id) + .where(models.ActiveQueueSession.work_unit_key == work_unit_key) + .exists() + ) + + +def stale_claim_cutoff() -> datetime: + return datetime.now(UTC) - timedelta( + minutes=settings.DERIVER.STALE_SESSION_TIMEOUT_MINUTES + ) + + +def not_live_claimed_work_unit_clause( + work_unit_key: ColumnElement[str], +) -> ColumnElement[bool]: + """No claim refreshed inside the stale timeout exists, so a stale claim leaves its work unit claimable.""" + return ( + ~select(models.ActiveQueueSession.id) + .where( + models.ActiveQueueSession.work_unit_key == work_unit_key, + models.ActiveQueueSession.last_updated >= stale_claim_cutoff(), + ) + .exists() + ) + + +async def get_deriver_metrics(db: AsyncSession) -> schemas.DeriverMetrics: + """Count the outstanding deriver work in the whole database, read-only.""" + from src.reconciler.sync_vectors import backoff_eligible # noqa: PLC0415 + + token_stats = ( + 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, models.QueueItem.message_id == models.Message.id) + .where(~models.QueueItem.processed) + .where( + models.QueueItem.work_unit_key.startswith(REPRESENTATION_WORK_UNIT_PREFIX) + ) + .group_by(models.QueueItem.work_unit_key) + .subquery() + ) + + work_units = ( + select(models.QueueItem.work_unit_key) + .where(~models.QueueItem.processed) + .group_by(models.QueueItem.work_unit_key) + .subquery() + ) + + eligible = ( + select(func.count()) + .select_from(work_units) + .outerjoin( + token_stats, + work_units.c.work_unit_key == token_stats.c.work_unit_key, + ) + .where(not_live_claimed_work_unit_clause(work_units.c.work_unit_key)) + ) + + threshold_clause = representation_batch_threshold_clause( + work_unit_key=work_units.c.work_unit_key, + total_tokens=token_stats.c.total_tokens, + oldest_created_at=token_stats.c.oldest_created_at, + ) + if threshold_clause is not None: + eligible = eligible.where(threshold_clause) + + claimed = ( + select(func.count()) + .select_from(models.ActiveQueueSession) + .where(models.ActiveQueueSession.last_updated >= stale_claim_cutoff()) + ) + + pending = select( + func.count(models.QueueItem.id), + func.coalesce( + func.extract("epoch", func.now() - func.min(models.QueueItem.created_at)), + 0, + ), + ).where(~models.QueueItem.processed) + + embeddings = select( + func.count(), + func.coalesce( + func.sum( + case( + (backoff_eligible(models.MessageEmbedding.last_sync_at), 1), + else_=0, + ) + ), + 0, + ), + ).where(models.MessageEmbedding.sync_state == "pending") + + eligible_count = (await db.execute(eligible)).scalar_one() + claimed_count = (await db.execute(claimed)).scalar_one() + pending_count, oldest_age = (await db.execute(pending)).one() + embeddings_pending, embeddings_due = (await db.execute(embeddings)).one() + + return schemas.DeriverMetrics( + eligible_work_units=int(eligible_count), + claimed_work_units=int(claimed_count), + pending_items=int(pending_count), + oldest_pending_age_seconds=float(oldest_age), + embeddings_pending=int(embeddings_pending), + embeddings_pending_due=int(embeddings_due), + ) + async def get_queue_status( db: AsyncSession, diff --git a/src/deriver/queue_manager.py b/src/deriver/queue_manager.py index b98c0ef6..493cddfa 100644 --- a/src/deriver/queue_manager.py +++ b/src/deriver/queue_manager.py @@ -15,7 +15,7 @@ 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 Text, and_, delete, literal, or_, select, update +from sqlalchemy import Text, and_, delete, literal, select, update from sqlalchemy.dialects.postgresql import insert from sqlalchemy.engine import CursorResult from sqlalchemy.ext.asyncio import AsyncSession @@ -24,6 +24,11 @@ from sqlalchemy.sql import func from src import models from src.cache.client import close_cache, init_cache from src.config import settings +from src.crud.deriver import ( + REPRESENTATION_WORK_UNIT_PREFIX, + representation_batch_threshold_clause, + unclaimed_work_unit_clause, +) from src.dependencies import tracked_db from src.deriver.consumer import ( process_item, @@ -353,7 +358,7 @@ class QueueManager: ) async with tracked_db("get_available_work_units") as db: - representation_prefix = "representation:" + representation_prefix = REPRESENTATION_WORK_UNIT_PREFIX token_stats_subq = ( select( models.QueueItem.work_unit_key, @@ -390,14 +395,7 @@ class QueueManager: token_stats_subq, work_units_subq.c.work_unit_key == token_stats_subq.c.work_unit_key, ) - .where( - ~select(models.ActiveQueueSession.id) - .where( - models.ActiveQueueSession.work_unit_key - == work_units_subq.c.work_unit_key - ) - .exists() - ) + .where(unclaimed_work_unit_clause(work_units_subq.c.work_unit_key)) .order_by( work_units_subq.c.oldest_created_at.asc(), work_units_subq.c.work_unit_key.asc(), @@ -406,26 +404,13 @@ class QueueManager: ) # Apply batch threshold filter (skip if FLUSH_ENABLED is True) - 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) - >= work_unit_target_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 - ), - threshold_clause, - ) - ) + threshold_clause = representation_batch_threshold_clause( + work_unit_key=work_units_subq.c.work_unit_key, + total_tokens=token_stats_subq.c.total_tokens, + oldest_created_at=token_stats_subq.c.oldest_created_at, + ) + if threshold_clause is not None: + query = query.where(threshold_clause) result = await db.execute(query) available_rows = result.all() diff --git a/src/dreamer/dream_due.py b/src/dreamer/dream_due.py new file mode 100644 index 00000000..08b68e2c --- /dev/null +++ b/src/dreamer/dream_due.py @@ -0,0 +1,216 @@ +"""Read-only count of the collections whose next dream is due. Enqueues nothing.""" + +from datetime import UTC, datetime, timedelta +from logging import getLogger +from typing import Any, cast + +from sqlalchemy import func, select +from sqlalchemy.dialects.postgresql import aggregate_order_by +from sqlalchemy.ext.asyncio import AsyncSession + +from src import models +from src.config import settings +from src.schemas import DreamType +from src.utils.config_helpers import get_configuration +from src.utils.work_unit import construct_work_unit_key + +logger = getLogger(__name__) + + +async def count_due_dreams(db: AsyncSession) -> int: + """Count collections past the threshold, the idle timeout, the min-hours gate, any earlier attempt, and the session's dream setting.""" + dream_types = [ + DreamType(dream_type) + for dream_type in settings.DREAM.ENABLED_TYPES + if dream_type == DreamType.OMNI.value + ] + if not settings.DREAM.ENABLED or not dream_types: + return 0 + + explicit_counts = ( + select( + models.Document.workspace_name, + models.Document.observer, + models.Document.observed, + func.count(models.Document.id).label("explicit_count"), + func.max(models.Document.created_at).label("newest_created_at"), + func.array_agg( + aggregate_order_by( + models.Document.session_name, models.Document.created_at.desc() + ) + )[1].label("newest_session_name"), + ) + .where(models.Document.level == "explicit") + .group_by( + models.Document.workspace_name, + models.Document.observer, + models.Document.observed, + ) + .subquery() + ) + + rows = ( + await db.execute( + select( + models.Collection.workspace_name, + models.Collection.observer, + models.Collection.observed, + models.Collection.internal_metadata, + func.coalesce(explicit_counts.c.explicit_count, 0), + explicit_counts.c.newest_created_at, + explicit_counts.c.newest_session_name, + ).outerjoin( + explicit_counts, + (models.Collection.workspace_name == explicit_counts.c.workspace_name) + & (models.Collection.observer == explicit_counts.c.observer) + & (models.Collection.observed == explicit_counts.c.observed), + ) + ) + ).all() + + now = datetime.now(UTC) + idle_cutoff = now - timedelta(minutes=settings.DREAM.IDLE_TIMEOUT_MINUTES) + candidates: dict[str, tuple[str, str, datetime]] = {} + + for row in rows: + workspace_name = cast(str, row[0]) + observer = cast(str, row[1]) + observed = cast(str, row[2]) + internal_metadata = cast("dict[str, Any] | None", row[3]) + explicit_count = cast(int, row[4]) + newest_created_at = cast("datetime | None", row[5]) + newest_session_name = cast("str | None", row[6]) + + dream_metadata: dict[str, Any] = (internal_metadata or {}).get("dream", {}) + since_last_dream = explicit_count - int( + dream_metadata.get("last_dream_document_count", 0) + ) + if since_last_dream < settings.DREAM.DOCUMENT_THRESHOLD: + continue + + if newest_created_at is None or newest_created_at > idle_cutoff: + continue + + if newest_session_name is None: + continue + + last_dream_at = cast("str | None", dream_metadata.get("last_dream_at")) + if last_dream_at and _within_min_hours_gate(last_dream_at, now): + continue + + for dream_type in dream_types: + work_unit_key = construct_work_unit_key( + workspace_name, + { + "task_type": "dream", + "observer": observer, + "observed": observed, + "dream_type": dream_type.value, + }, + ) + candidates[work_unit_key] = ( + workspace_name, + newest_session_name, + newest_created_at, + ) + + if not candidates: + return 0 + + attempt_rows = ( + await db.execute( + select( + models.QueueItem.work_unit_key, + func.max(models.QueueItem.created_at), + ) + .where( + models.QueueItem.task_type == "dream", + models.QueueItem.work_unit_key.in_(candidates.keys()), + ) + .group_by(models.QueueItem.work_unit_key) + ) + ).all() + newest_attempts: dict[str, datetime] = { + cast(str, row[0]): cast(datetime, row[1]) for row in attempt_rows + } + + unattempted = [ + (workspace_name, session_name) + for work_unit_key, ( + workspace_name, + session_name, + newest_created_at, + ) in candidates.items() + if work_unit_key not in newest_attempts + or newest_attempts[work_unit_key] < newest_created_at + ] + if not unattempted: + return 0 + + return await _count_with_dreams_enabled(db, unattempted) + + +async def _count_with_dreams_enabled( + db: AsyncSession, candidates: list[tuple[str, str]] +) -> int: + """Drop candidates whose resolved configuration has dreams turned off.""" + workspace_names = {workspace_name for workspace_name, _ in candidates} + session_keys = set(candidates) + + workspaces = { + workspace.name: workspace + for workspace in ( + await db.execute( + select(models.Workspace).where( + models.Workspace.name.in_(workspace_names) + ) + ) + ) + .scalars() + .all() + } + + sessions: dict[tuple[str, str], models.Session] = {} + if session_keys: + session_rows = ( + ( + await db.execute( + select(models.Session).where( + models.Session.workspace_name.in_(workspace_names), + models.Session.name.in_( + {session_name for _, session_name in candidates} + ), + ) + ) + ) + .scalars() + .all() + ) + sessions = { + (session.workspace_name, session.name): session for session in session_rows + } + + enabled = 0 + for workspace_name, session_name in candidates: + configuration = get_configuration( + None, + sessions.get((workspace_name, session_name)), + workspaces.get(workspace_name), + ) + if configuration.dream.enabled: + enabled += 1 + return enabled + + +def _within_min_hours_gate(last_dream_at: str, now: datetime) -> bool: + """True when the last dream is too recent for another one.""" + try: + last_dream_time = datetime.fromisoformat(last_dream_at) + except (ValueError, TypeError): + return False + + if last_dream_time.tzinfo is None: + last_dream_time = last_dream_time.replace(tzinfo=UTC) + + hours_since = (now - last_dream_time).total_seconds() / 3600 + return hours_since < settings.DREAM.MIN_HOURS_BETWEEN_DREAMS diff --git a/src/main.py b/src/main.py index a1ec9765..9a1d7e64 100644 --- a/src/main.py +++ b/src/main.py @@ -15,6 +15,7 @@ from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.starlette import StarletteIntegration from src._version import HONCHO_VERSION +from src.backlog import DeriverMetricsPoller from src.cache.client import close_cache, init_cache from src.config import settings from src.db import ( @@ -26,6 +27,7 @@ from src.db import ( from src.exceptions import HonchoException from src.routers import ( conclusions, + deriver_metrics, keys, messages, peers, @@ -135,12 +137,21 @@ async def lifespan(_: FastAPI): "Error initializing cache in api process; proceeding without cache: %s", e ) + deriver_metrics_poller = DeriverMetricsPoller() + deriver_metrics.set_deriver_metrics_poller(deriver_metrics_poller) + try: + await deriver_metrics_poller.start() + except Exception as e: + logger.error("Failed to start backlog metrics poller: %s", e) + try: yield finally: # Import here to avoid circular import at module load time from src.vector_store import close_external_vector_store + await deriver_metrics_poller.shutdown() + deriver_metrics.set_deriver_metrics_poller(None) await close_external_vector_store() await close_cache() await engine.dispose() @@ -189,6 +200,7 @@ app.include_router(messages.router, prefix="/v3") app.include_router(conclusions.router, prefix="/v3") app.include_router(keys.router, prefix="/v3") app.include_router(webhooks.router, prefix="/v3") +app.include_router(deriver_metrics.router) # Prometheus metrics endpoint app.add_route("/metrics", metrics_endpoint, methods=["GET"]) diff --git a/src/reconciler/embed_now.py b/src/reconciler/embed_now.py index f76fa760..5308c062 100644 --- a/src/reconciler/embed_now.py +++ b/src/reconciler/embed_now.py @@ -33,7 +33,7 @@ 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] + backoff_eligible, build_message_vector_record, compute_chunk_positions, ) @@ -177,7 +177,7 @@ async def _claim_and_lease(message_ids: list[str]) -> list[_ClaimedChunk]: and_( models.MessageEmbedding.message_id.in_(message_ids), models.MessageEmbedding.sync_state == "pending", - _backoff_eligible(models.MessageEmbedding.last_sync_at), + backoff_eligible(models.MessageEmbedding.last_sync_at), ) ) .order_by(models.MessageEmbedding.message_id, models.MessageEmbedding.id) diff --git a/src/reconciler/sync_vectors.py b/src/reconciler/sync_vectors.py index 1a8e99b5..b9b06418 100644 --- a/src/reconciler/sync_vectors.py +++ b/src/reconciler/sync_vectors.py @@ -39,7 +39,7 @@ MAX_SYNC_ATTEMPTS = 20 # After this many failures, mark as failed SYNC_BACKOFF = datetime.timedelta(minutes=10) -def _backoff_eligible( +def backoff_eligible( last_sync_at: InstrumentedAttribute[datetime.datetime | None], ) -> ColumnElement[bool]: """Rows are eligible for sync if never attempted or past the backoff window.""" @@ -92,7 +92,7 @@ async def _get_documents_needing_sync( and_( models.Document.deleted_at.is_(None), models.Document.sync_state == "pending", # Only pending items - _backoff_eligible(models.Document.last_sync_at), + backoff_eligible(models.Document.last_sync_at), ) ) .order_by(models.Document.last_sync_at.asc().nullsfirst()) @@ -132,7 +132,7 @@ async def _get_message_embeddings_needing_sync( .where( and_( models.MessageEmbedding.sync_state == "pending", - _backoff_eligible(models.MessageEmbedding.last_sync_at), + backoff_eligible(models.MessageEmbedding.last_sync_at), ) ) .group_by(models.MessageEmbedding.message_id) @@ -153,7 +153,7 @@ async def _get_message_embeddings_needing_sync( and_( models.MessageEmbedding.message_id.in_(message_ids), models.MessageEmbedding.sync_state == "pending", - _backoff_eligible(models.MessageEmbedding.last_sync_at), + backoff_eligible(models.MessageEmbedding.last_sync_at), ) ) .order_by(models.MessageEmbedding.message_id, models.MessageEmbedding.id) diff --git a/src/routers/deriver_metrics.py b/src/routers/deriver_metrics.py new file mode 100644 index 00000000..547a5153 --- /dev/null +++ b/src/routers/deriver_metrics.py @@ -0,0 +1,41 @@ +"""Deriver work metrics as JSON, with the age of the measurement alongside them.""" + +from logging import getLogger + +from fastapi import APIRouter, HTTPException + +from src.backlog import DeriverMetricsPoller + +logger = getLogger(__name__) + +router = APIRouter(prefix="/deriver", tags=["deriver"]) + +_poller: DeriverMetricsPoller | None = None + + +def set_deriver_metrics_poller(poller: DeriverMetricsPoller | None) -> None: + global _poller + _poller = poller + + +@router.get("/metrics") +async def get_deriver_metrics_response() -> dict[str, float | int]: + """Seconds of outstanding deriver work, plus the raw counts behind it.""" + snapshot = _poller.snapshot if _poller is not None else None + if snapshot is None or snapshot.measured_at is None: + raise HTTPException( + status_code=503, detail="No deriver measurement available yet" + ) + + return { + "outstanding_work_seconds": snapshot.signal_seconds, + "eligible_work_units": snapshot.stats.eligible_work_units, + "claimed_work_units": snapshot.stats.claimed_work_units, + "pending_items": snapshot.stats.pending_items, + "oldest_pending_age_seconds": snapshot.stats.oldest_pending_age_seconds, + "embeddings_pending": snapshot.stats.embeddings_pending, + "embeddings_pending_due": snapshot.stats.embeddings_pending_due, + "dreams_due": snapshot.dreams_due, + "measured_at": snapshot.measured_at, + "measurement_age_seconds": snapshot.age_seconds or 0.0, + } diff --git a/src/schemas/__init__.py b/src/schemas/__init__.py index 9f414583..0f93278a 100644 --- a/src/schemas/__init__.py +++ b/src/schemas/__init__.py @@ -78,6 +78,7 @@ from src.schemas.configuration import ( WorkspaceConfiguration, ) from src.schemas.internal import ( + DeriverMetrics, DocumentBase, DocumentCreate, DocumentMetadata, @@ -163,6 +164,7 @@ __all__ = [ "WorkspaceMessageSearchOptions", "WorkspaceUpdate", # internal + "DeriverMetrics", "DocumentBase", "DocumentCreate", "DocumentMetadata", diff --git a/src/schemas/internal.py b/src/schemas/internal.py index 2d299feb..f6399435 100644 --- a/src/schemas/internal.py +++ b/src/schemas/internal.py @@ -140,6 +140,17 @@ class QueueCounts(BaseModel): sessions: dict[str, SessionCounts] +class DeriverMetrics(BaseModel): + """Database-wide view of the deriver's outstanding work.""" + + eligible_work_units: int = 0 + claimed_work_units: int = 0 + pending_items: int = 0 + oldest_pending_age_seconds: float = 0.0 + embeddings_pending: int = 0 + embeddings_pending_due: int = 0 + + class QueueStatusRow(BaseModel): """Represents a row from the queue status SQL query result.""" diff --git a/src/telemetry/prometheus/metrics.py b/src/telemetry/prometheus/metrics.py index cead893a..6859c7cd 100644 --- a/src/telemetry/prometheus/metrics.py +++ b/src/telemetry/prometheus/metrics.py @@ -199,6 +199,69 @@ message_embeddings_pending_gauge = NamespacedGauge( ["namespace"], ) +message_embeddings_pending_due_gauge = NamespacedGauge( + "message_embeddings_pending_due", + "Pending MessageEmbedding rows past their retry backoff, so a sync attempt " + + "is due. Service-wide DB count, reported independently by every API " + + "replica — aggregate with max() or avg(), never sum()", + ["namespace"], +) + +deriver_outstanding_work_seconds_gauge = NamespacedGauge( + "deriver_outstanding_work_seconds", + "Seconds of outstanding deriver work, 0 when a deriver has nothing to do. " + + "Service-wide DB value, reported independently by every API replica — " + + "aggregate with max(), never sum()", + ["namespace"], +) + +deriver_queue_work_units_eligible_gauge = NamespacedGauge( + "deriver_queue_work_units_eligible", + "Work units a deriver could claim right now, ignoring stale claims. " + + "Service-wide DB count, reported independently by every API replica — " + + "aggregate with max() or avg(), never sum()", + ["namespace"], +) + +deriver_queue_work_units_claimed_gauge = NamespacedGauge( + "deriver_queue_work_units_claimed", + "Work units held by a claim refreshed inside the stale timeout, so work is " + + "in flight. Service-wide DB count, reported independently by every API " + + "replica — aggregate with max() or avg(), never sum()", + ["namespace"], +) + +deriver_queue_items_pending_gauge = NamespacedGauge( + "deriver_queue_items_pending", + "Unprocessed queue rows, whether or not they are claimable yet. " + + "Service-wide DB count, reported independently by every API replica — " + + "aggregate with max() or avg(), never sum()", + ["namespace"], +) + +deriver_queue_oldest_pending_age_seconds_gauge = NamespacedGauge( + "deriver_queue_oldest_pending_age_seconds", + "Age of the oldest unprocessed queue row, 0 when the queue is empty. " + + "Service-wide DB value, reported independently by every API replica — " + + "aggregate with max() or avg(), never sum()", + ["namespace"], +) + +dreams_due_gauge = NamespacedGauge( + "dreams_due", + "Collections whose next dream is due and would actually run. " + + "Service-wide DB count, reported independently by every API replica — " + + "aggregate with max() or avg(), never sum()", + ["namespace"], +) + +deriver_metrics_last_success_timestamp_gauge = NamespacedGauge( + "deriver_metrics_last_success_timestamp_seconds", + "Unix time of the last successful deriver-metrics refresh in this replica. " + + "Alert on time() minus this value; a frozen value means the poller stopped", + ["namespace"], +) + # 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). @@ -508,6 +571,10 @@ class PrometheusMetrics: self._touch(embed_now_tasks_shed_counter) self.set_embed_now_tasks_in_flight(0) + self.set_deriver_metrics() + self.set_deriver_outstanding_work(seconds=0) + self.set_dreams_due(count=0) + elif instance_type == "deriver": # deriver tokens: only the valid (token_type, component) tuples per # task_type (see _DERIVER_TOKEN_COMBOS_BY_TASK). @@ -548,6 +615,46 @@ class PrometheusMetrics: except Exception as e: self._handle_metric_error("set_message_embeddings_pending", e) + def set_deriver_metrics( + self, + *, + eligible_work_units: int = 0, + claimed_work_units: int = 0, + pending_items: int = 0, + oldest_pending_age_seconds: float = 0.0, + embeddings_pending: int = 0, + embeddings_pending_due: int = 0, + ) -> None: + try: + deriver_queue_work_units_eligible_gauge.labels().set(eligible_work_units) + deriver_queue_work_units_claimed_gauge.labels().set(claimed_work_units) + deriver_queue_items_pending_gauge.labels().set(pending_items) + deriver_queue_oldest_pending_age_seconds_gauge.labels().set( + oldest_pending_age_seconds + ) + message_embeddings_pending_gauge.labels().set(embeddings_pending) + message_embeddings_pending_due_gauge.labels().set(embeddings_pending_due) + except Exception as e: + self._handle_metric_error("set_deriver_metrics", e) + + def set_deriver_outstanding_work(self, *, seconds: float) -> None: + try: + deriver_outstanding_work_seconds_gauge.labels().set(seconds) + except Exception as e: + self._handle_metric_error("set_deriver_outstanding_work", e) + + def set_dreams_due(self, *, count: int) -> None: + try: + dreams_due_gauge.labels().set(count) + except Exception as e: + self._handle_metric_error("set_dreams_due", e) + + def set_deriver_metrics_last_success(self, *, timestamp: float) -> None: + try: + deriver_metrics_last_success_timestamp_gauge.labels().set(timestamp) + except Exception as e: + self._handle_metric_error("set_deriver_metrics_last_success", e) + prometheus_metrics = PrometheusMetrics() diff --git a/tests/crud/test_deriver_metrics_query.py b/tests/crud/test_deriver_metrics_query.py new file mode 100644 index 00000000..a67c17a8 --- /dev/null +++ b/tests/crud/test_deriver_metrics_query.py @@ -0,0 +1,394 @@ +import datetime + +import pytest +from nanoid import generate as generate_nanoid +from sqlalchemy.ext.asyncio import AsyncSession + +from src import crud, models +from src.config import settings + +pytestmark = pytest.mark.asyncio + + +async def _make_session( + db: AsyncSession, workspace: models.Workspace +) -> models.Session: + session = models.Session(name=str(generate_nanoid()), workspace_name=workspace.name) + db.add(session) + await db.flush() + return session + + +async def _add_representation_item( + db: AsyncSession, + workspace: models.Workspace, + peer: models.Peer, + session: models.Session, + *, + work_unit_key: str, + token_count: int, + age_seconds: int = 0, + seq: int = 1, +) -> models.QueueItem: + message = models.Message( + session_name=session.name, + content="x", + token_count=token_count, + seq_in_session=seq, + peer_name=peer.name, + workspace_name=workspace.name, + ) + db.add(message) + await db.flush() + + item = models.QueueItem( + session_id=session.id, + work_unit_key=work_unit_key, + task_type="representation", + payload={}, + processed=False, + workspace_name=workspace.name, + message_id=message.id, + created_at=datetime.datetime.now(datetime.UTC) + - datetime.timedelta(seconds=age_seconds), + ) + db.add(item) + await db.flush() + return item + + +async def _add_message( + db: AsyncSession, + workspace: models.Workspace, + peer: models.Peer, + session: models.Session, + *, + seq: int = 1, +) -> models.Message: + message = models.Message( + session_name=session.name, + content="x", + token_count=1, + seq_in_session=seq, + peer_name=peer.name, + workspace_name=workspace.name, + ) + db.add(message) + await db.flush() + return message + + +def _stale_timestamp() -> datetime.datetime: + return datetime.datetime.now(datetime.UTC) - datetime.timedelta( + minutes=settings.DERIVER.STALE_SESSION_TIMEOUT_MINUTES + 1 + ) + + +class TestDeriverMetrics: + async def test_empty_queue_reports_zero( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], # pyright: ignore[reportUnusedParameter] + ): + stats = await crud.get_deriver_metrics(db_session) + + assert stats.eligible_work_units == 0 + assert stats.claimed_work_units == 0 + assert stats.pending_items == 0 + assert stats.oldest_pending_age_seconds == 0.0 + + async def test_sub_threshold_batch_is_pending_but_not_eligible( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """A small, fresh batch is real work that a deriver would not yet claim.""" + workspace, peer = sample_data + session = await _make_session(db_session, workspace) + + await _add_representation_item( + db_session, + workspace, + peer, + session, + work_unit_key="representation:small", + token_count=1, + ) + await db_session.commit() + + stats = await crud.get_deriver_metrics(db_session) + + assert stats.pending_items == 1 + assert stats.eligible_work_units == 0 + + async def test_token_threshold_makes_batch_eligible( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + workspace, peer = sample_data + session = await _make_session(db_session, workspace) + + await _add_representation_item( + db_session, + workspace, + peer, + session, + work_unit_key="representation:big", + token_count=settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS, + ) + await db_session.commit() + + stats = await crud.get_deriver_metrics(db_session) + + assert stats.eligible_work_units == 1 + + async def test_age_flush_makes_sub_threshold_batch_eligible( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + workspace, peer = sample_data + session = await _make_session(db_session, workspace) + + await _add_representation_item( + db_session, + workspace, + peer, + session, + work_unit_key="representation:old", + token_count=1, + age_seconds=settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS + 60, + ) + await db_session.commit() + + stats = await crud.get_deriver_metrics(db_session) + + assert stats.eligible_work_units == 1 + assert stats.oldest_pending_age_seconds >= ( + settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS + ) + + async def test_non_representation_work_is_eligible_immediately( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + workspace, _peer = sample_data + + db_session.add( + models.QueueItem( + work_unit_key="reconciler:sync_vectors", + task_type="reconciler", + payload={}, + processed=False, + workspace_name=workspace.name, + ) + ) + await db_session.commit() + + stats = await crud.get_deriver_metrics(db_session) + + assert stats.eligible_work_units == 1 + + async def test_live_claim_is_counted_as_work_in_flight( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """A claimed work unit is not claimable, but it is still outstanding work.""" + workspace, peer = sample_data + session = await _make_session(db_session, workspace) + + await _add_representation_item( + db_session, + workspace, + peer, + session, + work_unit_key="representation:claimed", + token_count=settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS, + ) + db_session.add( + models.ActiveQueueSession(work_unit_key="representation:claimed") + ) + await db_session.commit() + + stats = await crud.get_deriver_metrics(db_session) + + assert stats.eligible_work_units == 0 + assert stats.claimed_work_units == 1 + + async def test_stale_claim_does_not_hide_work_and_is_not_in_flight( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """A dead worker's claim must not read as in flight, and must not hide work.""" + workspace, peer = sample_data + session = await _make_session(db_session, workspace) + + await _add_representation_item( + db_session, + workspace, + peer, + session, + work_unit_key="representation:abandoned", + token_count=settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS, + ) + db_session.add( + models.ActiveQueueSession( + work_unit_key="representation:abandoned", + last_updated=_stale_timestamp(), + ) + ) + await db_session.commit() + + stats = await crud.get_deriver_metrics(db_session) + + assert stats.eligible_work_units == 1 + assert stats.claimed_work_units == 0 + + async def test_processed_items_are_not_counted( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + workspace, peer = sample_data + session = await _make_session(db_session, workspace) + + item = await _add_representation_item( + db_session, + workspace, + peer, + session, + work_unit_key="representation:done", + token_count=settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS, + ) + item.processed = True + await db_session.commit() + + stats = await crud.get_deriver_metrics(db_session) + + assert stats.pending_items == 0 + assert stats.eligible_work_units == 0 + assert stats.oldest_pending_age_seconds == 0.0 + + +class TestPendingEmbeddings: + async def test_never_attempted_row_is_due( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + workspace, peer = sample_data + session = await _make_session(db_session, workspace) + message = await _add_message(db_session, workspace, peer, session) + db_session.add( + models.MessageEmbedding( + content="x", + message_id=message.public_id, + workspace_name=workspace.name, + session_name=session.name, + peer_name=peer.name, + sync_state="pending", + ) + ) + await db_session.commit() + + stats = await crud.get_deriver_metrics(db_session) + + assert stats.embeddings_pending == 1 + assert stats.embeddings_pending_due == 1 + + async def test_row_inside_its_retry_wait_is_pending_but_not_due( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """A backing-off row is work the deriver cannot act on yet.""" + workspace, peer = sample_data + session = await _make_session(db_session, workspace) + message = await _add_message(db_session, workspace, peer, session) + db_session.add( + models.MessageEmbedding( + content="x", + message_id=message.public_id, + workspace_name=workspace.name, + session_name=session.name, + peer_name=peer.name, + sync_state="pending", + last_sync_at=datetime.datetime.now(datetime.UTC), + sync_attempts=1, + ) + ) + await db_session.commit() + + stats = await crud.get_deriver_metrics(db_session) + + assert stats.embeddings_pending == 1 + assert stats.embeddings_pending_due == 0 + + async def test_synced_rows_are_not_counted( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + workspace, peer = sample_data + session = await _make_session(db_session, workspace) + message = await _add_message(db_session, workspace, peer, session) + db_session.add( + models.MessageEmbedding( + content="x", + message_id=message.public_id, + workspace_name=workspace.name, + session_name=session.name, + peer_name=peer.name, + sync_state="synced", + ) + ) + await db_session.commit() + + stats = await crud.get_deriver_metrics(db_session) + + assert stats.embeddings_pending == 0 + assert stats.embeddings_pending_due == 0 + + +class TestMetricsAgreeWithDeriver: + @pytest.mark.parametrize( + "token_count,age_seconds", + [ + (1, 0), + (settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS, 0), + (1, settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS + 60), + ], + ids=["sub-threshold", "token-threshold", "age-flush"], + ) + async def test_eligible_count_matches_what_the_deriver_claims( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + token_count: int, + age_seconds: int, + ): + """The gauge is only trustworthy if it uses the deriver's own rule.""" + from src.deriver.queue_manager import QueueManager + + workspace, peer = sample_data + session = await _make_session(db_session, workspace) + + await _add_representation_item( + db_session, + workspace, + peer, + session, + work_unit_key="representation:agreement", + token_count=token_count, + age_seconds=age_seconds, + ) + await db_session.commit() + + expected = (await crud.get_deriver_metrics(db_session)).eligible_work_units + claimed = await QueueManager().get_and_claim_work_units() + + assert len(claimed) == expected diff --git a/tests/dreamer/test_dream_due.py b/tests/dreamer/test_dream_due.py new file mode 100644 index 00000000..dad75de6 --- /dev/null +++ b/tests/dreamer/test_dream_due.py @@ -0,0 +1,321 @@ +"""Tests for the read-only count of collections whose next dream is due.""" + +import datetime +from unittest.mock import patch + +import pytest +from nanoid import generate as generate_nanoid +from sqlalchemy.ext.asyncio import AsyncSession + +from src import models +from src.dreamer.dream_due import count_due_dreams +from src.schemas import DreamType +from src.utils.work_unit import construct_work_unit_key + + +def _now() -> datetime.datetime: + return datetime.datetime.now(datetime.UTC) + + +async def _make_collection( + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + internal_metadata: dict[str, object] | None = None, +) -> models.Collection: + workspace, peer = sample_data + collection = models.Collection( + observer=peer.name, + observed=peer.name, + workspace_name=workspace.name, + internal_metadata=internal_metadata or {}, + ) + db_session.add(collection) + await db_session.commit() + return collection + + +async def _make_session( + db_session: AsyncSession, + workspace_name: str, + configuration: dict[str, object] | None = None, +) -> str: + session = models.Session( + name=f"s-{generate_nanoid()}", + workspace_name=workspace_name, + configuration=configuration or {}, + ) + db_session.add(session) + await db_session.commit() + return session.name + + +async def _insert_docs( + db_session: AsyncSession, + collection: models.Collection, + level: str, + count: int, + *, + age_minutes: int = 0, + session_name: str | None = None, + sessionless: bool = False, +) -> None: + if session_name is None and not sessionless: + session_name = await _make_session(db_session, collection.workspace_name) + created_at = _now() - datetime.timedelta(minutes=age_minutes) + for _ in range(count): + db_session.add( + models.Document( + content="test", + level=level, + workspace_name=collection.workspace_name, + observer=collection.observer, + observed=collection.observed, + session_name=session_name, + created_at=created_at, + ) + ) + await db_session.commit() + + +async def _insert_dream_item( + db_session: AsyncSession, + collection: models.Collection, + *, + age_minutes: int, + processed: bool, + error: str | None = None, +) -> None: + work_unit_key = construct_work_unit_key( + collection.workspace_name, + { + "task_type": "dream", + "observer": collection.observer, + "observed": collection.observed, + "dream_type": DreamType.OMNI.value, + }, + ) + db_session.add( + models.QueueItem( + work_unit_key=work_unit_key, + payload={"task_type": "dream"}, + task_type="dream", + workspace_name=collection.workspace_name, + processed=processed, + error=error, + created_at=_now() - datetime.timedelta(minutes=age_minutes), + ) + ) + await db_session.commit() + + +@pytest.fixture(autouse=True) +def _pin_dream_config(): # pyright: ignore[reportUnusedFunction] + with ( + patch("src.dreamer.dream_due.settings.DREAM.ENABLED", True), + patch("src.dreamer.dream_due.settings.DREAM.DOCUMENT_THRESHOLD", 50), + patch("src.dreamer.dream_due.settings.DREAM.ENABLED_TYPES", ["omni"]), + patch("src.dreamer.dream_due.settings.DREAM.IDLE_TIMEOUT_MINUTES", 60), + patch("src.dreamer.dream_due.settings.DREAM.MIN_HOURS_BETWEEN_DREAMS", 8), + ): + yield + + +@pytest.mark.asyncio +class TestCountDueDreams: + async def test_below_threshold_is_not_due( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + collection = await _make_collection(db_session, sample_data) + await _insert_docs(db_session, collection, "explicit", 30, age_minutes=90) + + assert await count_due_dreams(db_session) == 0 + + async def test_derived_levels_do_not_count( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + collection = await _make_collection(db_session, sample_data) + await _insert_docs(db_session, collection, "explicit", 30, age_minutes=90) + await _insert_docs(db_session, collection, "deductive", 40, age_minutes=90) + + assert await count_due_dreams(db_session) == 0 + + async def test_threshold_met_but_not_idle_is_not_due( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """A collection still receiving documents is not idle yet.""" + collection = await _make_collection(db_session, sample_data) + await _insert_docs(db_session, collection, "explicit", 60, age_minutes=1) + + assert await count_due_dreams(db_session) == 0 + + async def test_threshold_met_and_idle_is_due( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + collection = await _make_collection(db_session, sample_data) + await _insert_docs(db_session, collection, "explicit", 60, age_minutes=90) + + assert await count_due_dreams(db_session) == 1 + + async def test_documents_since_last_dream_uses_stored_count( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + collection = await _make_collection( + db_session, sample_data, {"dream": {"last_dream_document_count": 40}} + ) + await _insert_docs(db_session, collection, "explicit", 60, age_minutes=90) + + assert await count_due_dreams(db_session) == 0 + + async def test_min_hours_gate_blocks_a_recent_dream( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + last_dream_at = (_now() - datetime.timedelta(hours=2)).isoformat() + collection = await _make_collection( + db_session, sample_data, {"dream": {"last_dream_at": last_dream_at}} + ) + await _insert_docs(db_session, collection, "explicit", 60, age_minutes=90) + + assert await count_due_dreams(db_session) == 0 + + async def test_naive_last_dream_at_is_read_as_utc( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """A stored timestamp with no offset must gate, not raise.""" + naive = (_now() - datetime.timedelta(hours=2)).replace(tzinfo=None).isoformat() + collection = await _make_collection( + db_session, sample_data, {"dream": {"last_dream_at": naive}} + ) + await _insert_docs(db_session, collection, "explicit", 60, age_minutes=90) + + assert await count_due_dreams(db_session) == 0 + + async def test_pending_dream_item_blocks( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + collection = await _make_collection(db_session, sample_data) + await _insert_docs(db_session, collection, "explicit", 60, age_minutes=90) + await _insert_dream_item( + db_session, collection, age_minutes=10, processed=False + ) + + assert await count_due_dreams(db_session) == 0 + + async def test_failed_dream_waits_for_new_documents( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """Without this the count never returns to zero.""" + collection = await _make_collection(db_session, sample_data) + await _insert_docs(db_session, collection, "explicit", 60, age_minutes=90) + await _insert_dream_item( + db_session, collection, age_minutes=80, processed=True, error="boom" + ) + + assert await count_due_dreams(db_session) == 0 + + async def test_failed_dream_retries_after_new_documents( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + collection = await _make_collection(db_session, sample_data) + await _insert_docs(db_session, collection, "explicit", 60, age_minutes=90) + await _insert_dream_item( + db_session, collection, age_minutes=80, processed=True, error="boom" + ) + await _insert_docs(db_session, collection, "explicit", 1, age_minutes=70) + + assert await count_due_dreams(db_session) == 1 + + async def test_sessionless_documents_are_not_due( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """The deriver's own enqueue path refuses these, so they must not count.""" + collection = await _make_collection(db_session, sample_data) + await _insert_docs( + db_session, collection, "explicit", 60, age_minutes=90, sessionless=True + ) + + assert await count_due_dreams(db_session) == 0 + + async def test_newest_document_decides_the_session( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + collection = await _make_collection(db_session, sample_data) + await _insert_docs(db_session, collection, "explicit", 60, age_minutes=120) + + assert await count_due_dreams(db_session) == 1 + + await _insert_docs( + db_session, collection, "explicit", 1, age_minutes=90, sessionless=True + ) + + assert await count_due_dreams(db_session) == 0 + + async def test_session_with_dreams_disabled_is_not_due( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """A dream the enqueue path would refuse must not be counted.""" + collection = await _make_collection(db_session, sample_data) + session_name = await _make_session( + db_session, + collection.workspace_name, + {"dream": {"enabled": False}}, + ) + await _insert_docs( + db_session, + collection, + "explicit", + 60, + age_minutes=90, + session_name=session_name, + ) + + assert await count_due_dreams(db_session) == 0 + + async def test_dreams_disabled_globally_returns_zero( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + collection = await _make_collection(db_session, sample_data) + await _insert_docs(db_session, collection, "explicit", 60, age_minutes=90) + + with patch("src.dreamer.dream_due.settings.DREAM.ENABLED", False): + assert await count_due_dreams(db_session) == 0 + + async def test_card_refresh_is_never_counted( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + collection = await _make_collection(db_session, sample_data) + await _insert_docs(db_session, collection, "explicit", 60, age_minutes=90) + + with patch( + "src.dreamer.dream_due.settings.DREAM.ENABLED_TYPES", ["card_refresh"] + ): + assert await count_due_dreams(db_session) == 0 diff --git a/tests/telemetry/test_metric_zero_init.py b/tests/telemetry/test_metric_zero_init.py index e69f50df..eb412287 100644 --- a/tests/telemetry/test_metric_zero_init.py +++ b/tests/telemetry/test_metric_zero_init.py @@ -131,6 +131,19 @@ def test_deriver_token_combos_are_valid_and_complete(): ) not in ingestion +_API_DERIVER_METRIC_GAUGES = ( + "deriver_outstanding_work_seconds", + "deriver_queue_work_units_eligible", + "deriver_queue_work_units_claimed", + "deriver_queue_items_pending", + "deriver_queue_oldest_pending_age_seconds", + "dreams_due", + "message_embeddings_pending_due", +) + +_SHARED_DERIVER_METRIC_GAUGES = ("message_embeddings_pending",) + + # --------------------------------------------------------------------------- # API-process zero-init # --------------------------------------------------------------------------- @@ -161,6 +174,8 @@ def test_api_init_materializes_dialectic_and_embed(): ) assert sample("embed_now_tasks_shed_total") is not None assert sample("embed_now_tasks_in_flight") == 0.0 # gauge, explicit .set(0) + for gauge in (*_API_DERIVER_METRIC_GAUGES, *_SHARED_DERIVER_METRIC_GAUGES): + assert sample(gauge) == 0.0, f"{gauge} was not zero-initialized" @pytest.mark.usefixtures("metrics_enabled") @@ -310,6 +325,9 @@ def test_deriver_init_does_not_touch_api_counters(): # the API-process embed_now counters are equally off-limits assert sample("embed_now_tasks_shed_total") is None assert sample("embed_now_tasks_in_flight") is None + # so are the deriver-work gauges: the deriver never measures its own backlog + for gauge in _API_DERIVER_METRIC_GAUGES: + assert sample(gauge) is None, f"{gauge} must be API-only" # --------------------------------------------------------------------------- diff --git a/tests/test_deriver_metrics.py b/tests/test_deriver_metrics.py new file mode 100644 index 00000000..acd97330 --- /dev/null +++ b/tests/test_deriver_metrics.py @@ -0,0 +1,207 @@ +"""Tests for the outstanding-work value, the poller and the JSON route.""" + +import time +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException + +from src import schemas +from src.backlog import ( + DeriverMetricsPoller, + DeriverMetricsSnapshot, + active_work_seconds, + outstanding_work_seconds, +) +from src.routers import deriver_metrics + + +class TestScaleSignal: + def test_nothing_outstanding_reads_zero(self): + assert outstanding_work_seconds(schemas.DeriverMetrics(), dreams_due=0) == 0.0 + + def test_claimable_work_reports_the_active_value(self): + stats = schemas.DeriverMetrics(eligible_work_units=1) + + assert outstanding_work_seconds(stats, dreams_due=0) == active_work_seconds() + + def test_work_in_flight_still_reports_the_active_value(self): + """A row claimed a moment ago has a small age and would read as idle.""" + stats = schemas.DeriverMetrics( + claimed_work_units=1, pending_items=1, oldest_pending_age_seconds=2.0 + ) + + assert outstanding_work_seconds(stats, dreams_due=0) == active_work_seconds() + + def test_waiting_batch_reports_its_real_age(self): + """The real age is what tells a caller how close the flush is.""" + stats = schemas.DeriverMetrics( + pending_items=3, oldest_pending_age_seconds=1234.0 + ) + + assert outstanding_work_seconds(stats, dreams_due=0) == 1234.0 + + def test_embeddings_due_an_attempt_report_the_active_value(self): + stats = schemas.DeriverMetrics(embeddings_pending=5, embeddings_pending_due=5) + + assert outstanding_work_seconds(stats, dreams_due=0) == active_work_seconds() + + def test_embeddings_inside_their_retry_wait_do_not(self): + """Otherwise one permanently failing row holds the value up for hours.""" + stats = schemas.DeriverMetrics(embeddings_pending=5) + + assert outstanding_work_seconds(stats, dreams_due=0) == 0.0 + + def test_a_due_dream_reports_the_active_value(self): + assert ( + outstanding_work_seconds(schemas.DeriverMetrics(), dreams_due=1) + == active_work_seconds() + ) + + def test_active_value_is_positive(self): + assert active_work_seconds() > 0 + + +@pytest.mark.asyncio +class TestPoller: + async def test_refresh_publishes_a_snapshot(self): + stats = schemas.DeriverMetrics(eligible_work_units=2, pending_items=4) + poller = DeriverMetricsPoller() + + with ( + patch( + "src.backlog.crud.get_deriver_metrics", + AsyncMock(return_value=stats), + ), + patch("src.backlog.count_due_dreams", AsyncMock(return_value=3)), + ): + await poller.refresh() + + snapshot = poller.snapshot + assert snapshot.measured_at is not None + assert snapshot.stats.eligible_work_units == 2 + assert snapshot.dreams_due == 3 + assert snapshot.signal_seconds == active_work_seconds() + + async def test_dream_query_runs_on_its_own_spacing(self): + """The dream query is the expensive one, so it must not run every pass.""" + stats = schemas.DeriverMetrics() + poller = DeriverMetricsPoller() + dream_count = AsyncMock(return_value=1) + + with ( + patch( + "src.backlog.crud.get_deriver_metrics", + AsyncMock(return_value=stats), + ), + patch("src.backlog.count_due_dreams", dream_count), + ): + await poller.refresh() + await poller.refresh() + + assert dream_count.await_count == 1 + assert poller.snapshot.dreams_due == 1 + + async def test_a_failed_dream_query_is_retried_on_the_next_pass(self): + """Advancing the deadline first would republish the old count for a whole interval.""" + stats = schemas.DeriverMetrics() + poller = DeriverMetricsPoller() + dream_count = AsyncMock(side_effect=[RuntimeError("db down"), 4]) + + with ( + patch( + "src.backlog.crud.get_deriver_metrics", + AsyncMock(return_value=stats), + ), + patch("src.backlog.count_due_dreams", dream_count), + ): + with pytest.raises(RuntimeError): + await poller.refresh() + await poller.refresh() + + assert dream_count.await_count == 2 + assert poller.snapshot.dreams_due == 4 + + async def test_a_failed_pass_leaves_the_previous_snapshot_alone(self): + """A half-finished pass must never be published as a measurement.""" + stats = schemas.DeriverMetrics(eligible_work_units=1) + poller = DeriverMetricsPoller() + + with ( + patch( + "src.backlog.crud.get_deriver_metrics", + AsyncMock(return_value=stats), + ), + patch("src.backlog.count_due_dreams", AsyncMock(return_value=0)), + ): + await poller.refresh() + + first = poller.snapshot + + with ( + patch( + "src.backlog.crud.get_deriver_metrics", + AsyncMock(side_effect=RuntimeError("db down")), + ), + pytest.raises(RuntimeError), + ): + await poller.refresh() + + assert poller.snapshot is first + + +@pytest.mark.asyncio +class TestDeriverMetricsRoute: + async def test_serves_the_cached_snapshot(self): + poller = DeriverMetricsPoller() + poller._snapshot = DeriverMetricsSnapshot( # pyright: ignore[reportPrivateUsage] + signal_seconds=1800.0, + dreams_due=1, + stats=schemas.DeriverMetrics(eligible_work_units=2, pending_items=5), + measured_at=time.time(), + ) + deriver_metrics.set_deriver_metrics_poller(poller) + try: + body = await deriver_metrics.get_deriver_metrics_response() + finally: + deriver_metrics.set_deriver_metrics_poller(None) + + assert body["outstanding_work_seconds"] == 1800.0 + assert body["eligible_work_units"] == 2 + assert body["pending_items"] == 5 + assert body["dreams_due"] == 1 + + async def test_errors_before_the_first_pass(self): + """A 503 tells the caller there is no measurement; a 0 would be a lie.""" + deriver_metrics.set_deriver_metrics_poller(DeriverMetricsPoller()) + try: + with pytest.raises(HTTPException) as excinfo: + await deriver_metrics.get_deriver_metrics_response() + finally: + deriver_metrics.set_deriver_metrics_poller(None) + + assert excinfo.value.status_code == 503 + + async def test_serves_an_old_snapshot_with_its_age(self): + """The caller decides what is too old, from measurement_age_seconds.""" + poller = DeriverMetricsPoller() + poller._snapshot = DeriverMetricsSnapshot( # pyright: ignore[reportPrivateUsage] + signal_seconds=7.0, + measured_at=time.time() - 3600, + ) + deriver_metrics.set_deriver_metrics_poller(poller) + try: + body = await deriver_metrics.get_deriver_metrics_response() + finally: + deriver_metrics.set_deriver_metrics_poller(None) + + assert body["outstanding_work_seconds"] == 7.0 + assert body["measurement_age_seconds"] >= 3600 + + async def test_errors_when_no_poller_is_registered(self): + deriver_metrics.set_deriver_metrics_poller(None) + + with pytest.raises(HTTPException) as excinfo: + await deriver_metrics.get_deriver_metrics_response() + + assert excinfo.value.status_code == 503 From 7d5d6109f7ab2ba0368b6723e9eb213955b579e8 Mon Sep 17 00:00:00 2001 From: steven-ji Date: Thu, 3 Sep 2026 05:05:02 +0800 Subject: [PATCH 41/50] feat(docker): make API worker count configurable (#1088) * feat(docker): make API worker count configurable Add API_WORKERS with a single-worker default and document database pool sizing. Refs #1063 * fix(docker): address API worker review feedback --- .env.template | 3 +++ docker/entrypoint.sh | 2 +- docs/v3/contributing/self-hosting.mdx | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.env.template b/.env.template index 2f737dba..5c2b19c5 100644 --- a/.env.template +++ b/.env.template @@ -9,6 +9,9 @@ # ============================================================================= LOG_LEVEL=INFO PERFORMANCE_LOG_FORMAT=compact # compact|rich +# API server processes used by the Docker entrypoint (default: 1). +# Each process owns a separate pool when connection pooling is enabled. +# API_WORKERS=1 # SESSION_OBSERVERS_LIMIT=10 # GET_CONTEXT_MAX_TOKENS=100000 # MAX_FILE_SIZE=5242880 # Bytes diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index bc8e3f37..a9f6ea78 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -5,4 +5,4 @@ echo "Running database migrations..." /app/.venv/bin/python scripts/provision_db.py echo "Starting API server..." -exec /app/.venv/bin/fastapi run --host 0.0.0.0 src/main.py +exec /app/.venv/bin/fastapi run --host 0.0.0.0 --workers "${API_WORKERS:-1}" src/main.py diff --git a/docs/v3/contributing/self-hosting.mdx b/docs/v3/contributing/self-hosting.mdx index 02d361f1..4992530e 100644 --- a/docs/v3/contributing/self-hosting.mdx +++ b/docs/v3/contributing/self-hosting.mdx @@ -389,6 +389,20 @@ The default compose file is already production-oriented — ports bound to `127. - You can also run multiple deriver processes across machines — they coordinate via the database queue - Monitor deriver logs for processing backlog +### Scaling the API + +Set `API_WORKERS` to run multiple API server processes in the Docker container. It defaults to `1`, preserving the existing single-process behavior. + +When connection pooling is enabled (`DB_POOL_CLASS` is not `null`), each API process creates its own SQLAlchemy connection pool. Keep the combined capacity below the PostgreSQL connection limit: + +```text +API_WORKERS * (DB_POOL_SIZE + DB_MAX_OVERFLOW) < PostgreSQL max_connections +``` + +With the default pooled settings (`10 + 20`), each API worker can open up to 30 connections. For example, `API_WORKERS=3` allows up to 90 API connections. Leave additional headroom for the deriver, migrations, administration, and monitoring. + +When `DB_POOL_CLASS=null`, SQLAlchemy uses `NullPool`; `DB_POOL_SIZE` and `DB_MAX_OVERFLOW` do not apply, and connections are opened and closed per use. + ### Caching - The production compose enables Redis caching by default (`CACHE_ENABLED=true`) - For the development compose, enable manually: `CACHE_ENABLED=true` From a5fa8c39621b9b06ec1a90515e1103996aff521a Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Wed, 2 Sep 2026 17:27:01 -0400 Subject: [PATCH 42/50] fix(dialectic): make workspace chat search before it answers (#1120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace agent's prefetch is an orientation overview — scale, active peers, their cards — not the corpus. `low` is the only reasoning level that explicitly sets TOOL_CHOICE="auto", so the model was free to skip tools entirely, and it did: every workspace_chat call in CI run 33662772219 made zero tool calls. It answered when the overview happened to carry the fact and otherwise wrote out the search it should have run, then asked the caller which option to take — at an endpoint with no caller to answer. Add a `_tool_choice` seam alongside `_select_tools` and override it on WorkspaceDialecticAgent to require a tool call. `execute_tool_loop` already relaxes "required"/"any" to "auto" after the first iteration, so this costs one search round rather than pinning the loop, and the model can still stop and synthesize. Any value a level configures other than None/"auto" passes through. The pair agent is unaffected: it prefetches the observations for its query and can legitimately answer from context alone. Also tell the workspace prompt it is non-interactive. It had "Do not narrate tool use" but never said the caller cannot reply, and three of the five traced responses ended in a menu of lookups. Unified subset goes 1/5 -> 5/5, and search_memory — the recall path that never once ran — now fires on 6 of 7 workspace queries. workspace_chat_scope is the notable one: its two not_contains assertions were passing vacuously because nothing was ever retrieved, and it now recalls the in-scope fact while still excluding the out-of-scope vault code. Co-authored-by: Claude Opus 5 (1M context) --- src/dialectic/core.py | 25 +++++++++++-- src/dialectic/prompts.py | 6 +++- src/dialectic/workspace.py | 26 +++++++++++++- tests/test_workspace_chat.py | 68 +++++++++++++++++++++++++++++++++--- 4 files changed, 116 insertions(+), 9 deletions(-) diff --git a/src/dialectic/core.py b/src/dialectic/core.py index 57964c87..95fe4cb5 100644 --- a/src/dialectic/core.py +++ b/src/dialectic/core.py @@ -14,7 +14,12 @@ from nanoid import generate as generate_nanoid from pydantic import BaseModel from src import crud -from src.config import ConfiguredModelSettings, ReasoningLevel, settings +from src.config import ( + ConfiguredModelSettings, + DialecticLevelSettings, + ReasoningLevel, + settings, +) from src.dependencies import tracked_db from src.dialectic import prompts from src.embedding_client import embedding_client @@ -139,6 +144,20 @@ class DialecticAgent: tools = [t for t in tools if t.get("name") != "get_reasoning_chain"] return tools + def _tool_choice( + self, level_settings: DialecticLevelSettings + ) -> str | dict[str, Any] | None: + """Pick the tool_choice for this query. + + Defaults to whatever the reasoning level configures. Subclasses override + when the agent has no prefetched corpus to fall back on and so must + search before it can answer. Forcing "required"/"any" here costs exactly + one tool round rather than pinning the loop: `execute_tool_loop` relaxes + it to "auto" after the first iteration so the model can still stop and + synthesize. + """ + return level_settings.TOOL_CHOICE + async def _initialize_session_history(self) -> None: """Fetch and inject session history into the system prompt if configured.""" if self._session_history_initialized: @@ -505,7 +524,7 @@ class DialecticAgent: prompt="", # Ignored since we pass messages max_tokens=max_tokens, tools=tools, - tool_choice=level_settings.TOOL_CHOICE, + tool_choice=self._tool_choice(level_settings), tool_executor=tool_executor, max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS, messages=self.messages, @@ -581,7 +600,7 @@ class DialecticAgent: stream=True, stream_final_only=True, tools=tools, - tool_choice=level_settings.TOOL_CHOICE, + tool_choice=self._tool_choice(level_settings), tool_executor=tool_executor, max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS, messages=self.messages, diff --git a/src/dialectic/prompts.py b/src/dialectic/prompts.py index 5dfe6604..4d2fbc70 100644 --- a/src/dialectic/prompts.py +++ b/src/dialectic/prompts.py @@ -396,7 +396,11 @@ If this query is restricted to a session or a set of sessions, message tools alr 4. **Attribute**. Every fact you state names the peer it is about. If it is a cross-peer view, also name whose model it came from. Example: "Alice is a violinist." / "From Bob's model of Alice, …" -5. **Synthesize**. Answer the question. Quote exact names, dates, and numbers. For aggregations, list findings per peer. Do not narrate tool use. +5. **Synthesize**. Answer the question. Quote exact names, dates, and numbers. For aggregations, list findings per peer. Do not narrate tool use, and do not describe a search you did not run. + +## NO CLARIFYING QUESTIONS + +Your answer goes to a program, not to someone who can reply. No one will answer a question you ask, approve a plan you propose, or pick from options you offer — your response ends the exchange. So never ask which lookup to run, never lay out a plan and stop, never present a menu. Run the searches yourself and answer from what they return. Empty results are a complete answer; an unanswered question is not. ## NEVER FABRICATE diff --git a/src/dialectic/workspace.py b/src/dialectic/workspace.py index 5383cd75..1a93d300 100644 --- a/src/dialectic/workspace.py +++ b/src/dialectic/workspace.py @@ -20,7 +20,7 @@ from collections.abc import Callable from typing import Any from src import crud -from src.config import ReasoningLevel, settings +from src.config import DialecticLevelSettings, ReasoningLevel, settings from src.dependencies import tracked_db from src.dialectic import prompts from src.dialectic.core import DialecticAgent @@ -161,6 +161,30 @@ class WorkspaceDialecticAgent(DialecticAgent): tools = [t for t in tools if t.get("name") not in unscopable] return tools + def _tool_choice( + self, level_settings: DialecticLevelSettings + ) -> str | dict[str, Any] | None: + """Require a tool call on the first turn. + + The pair agent prefetches the observations relevant to its query, so it + can legitimately answer from context alone. This agent's prefetch is an + orientation overview — scale, active peers, their cards — not the corpus. + Left free to skip tools, the model treats that overview as everything it + has: it answers when the overview happens to carry the fact, and + otherwise writes out the search it should have run and asks the caller + which option to take. Workspace chat has no caller to answer, so that + response is dead on arrival. + + Recall is the job, so make the first search mandatory and let the loop + relax to "auto" afterwards. Any other value a level configures is passed + through untouched, so this only overrides the two cases that let the + model opt out entirely. + """ + choice = level_settings.TOOL_CHOICE + if choice is None or choice == "auto": + return "required" + return choice + async def _create_tool_executor(self) -> Callable[[str, dict[str, Any]], Any]: return await create_workspace_tool_executor( workspace_name=self.workspace_name, diff --git a/tests/test_workspace_chat.py b/tests/test_workspace_chat.py index daed20e8..75462b17 100644 --- a/tests/test_workspace_chat.py +++ b/tests/test_workspace_chat.py @@ -9,7 +9,7 @@ import asyncio import json from collections.abc import Callable from contextlib import asynccontextmanager -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from types import SimpleNamespace from typing import Any @@ -82,7 +82,7 @@ async def workspace_test_data( await db_session.flush() # Create messages - now = datetime.now(timezone.utc) + now = datetime.now(UTC) messages: list[models.Message] = [] for i in range(6): peer_name = [peer1.name, peer2.name, peer3.name][i % 3] @@ -593,7 +593,7 @@ class TestSearchMemoryWorkspace: content="I really like programming in Python", seq_in_session=1, token_count=10, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) db_session.add(msg) await db_session.flush() @@ -919,7 +919,7 @@ class TestGetObservationContextWorkspace: content="LEAKED_FROM_OTHER_SESSION", seq_in_session=messages[0].seq_in_session, token_count=10, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) db_session.add(leaked_message) await db_session.commit() @@ -1253,3 +1253,63 @@ class TestWorkspaceChatPrompt: } assert agent.messages[0]["content"] == workspace_agent_system_prompt(offered) assert agent._prefetch_heading() == "Workspace overview (prefetched)" # pyright: ignore[reportPrivateUsage] + + def test_forbids_clarifying_questions(self) -> None: + """The endpoint is non-interactive, so the prompt must say so. + + Without this the model answers a recall query with a plan and a menu of + lookups for a caller that cannot reply. The pair agent talks to a peer + and is deliberately left alone. + """ + from src.dialectic.prompts import ( + agent_system_prompt, + workspace_agent_system_prompt, + ) + + prompt = workspace_agent_system_prompt() + assert "NO CLARIFYING QUESTIONS" in prompt + assert "NO CLARIFYING QUESTIONS" not in agent_system_prompt( + "alice", "alice", None, None + ) + + +class TestWorkspaceToolChoice: + """The workspace agent must search before it answers. + + Its prefetch is an orientation overview, not the corpus, so a turn with no + tool call ends the loop with whatever the overview happened to contain. + """ + + @pytest.mark.parametrize("level", ["minimal", "low", "medium", "high", "max"]) + def test_first_turn_requires_a_tool_call(self, level: str) -> None: + from src.config import settings + from src.dialectic.workspace import WorkspaceDialecticAgent + + agent = WorkspaceDialecticAgent(workspace_name="w", reasoning_level=level) # pyright: ignore[reportArgumentType] + level_settings = settings.DIALECTIC.LEVELS[level] # pyright: ignore[reportArgumentType] + assert agent._tool_choice(level_settings) == "required" # pyright: ignore[reportPrivateUsage] + + def test_pair_agent_keeps_the_configured_choice(self) -> None: + from src.config import settings + from src.dialectic.core import DialecticAgent + + agent = DialecticAgent( + workspace_name="w", session_name=None, observer="a", observed="a" + ) + level_settings = settings.DIALECTIC.LEVELS["low"] + assert ( + agent._tool_choice(level_settings) # pyright: ignore[reportPrivateUsage] + == level_settings.TOOL_CHOICE + ) + + def test_a_configured_non_auto_choice_is_passed_through(self) -> None: + from src.config import DialecticLevelSettings, settings + from src.dialectic.workspace import WorkspaceDialecticAgent + + agent = WorkspaceDialecticAgent(workspace_name="w") + pinned = DialecticLevelSettings( + MODEL_CONFIG=settings.DIALECTIC.LEVELS["low"].MODEL_CONFIG, + MAX_TOOL_ITERATIONS=5, + TOOL_CHOICE="none", + ) + assert agent._tool_choice(pinned) == "none" # pyright: ignore[reportPrivateUsage] From 55a0519bd2e9db615bf4ce3d492558ad96b9fc47 Mon Sep 17 00:00:00 2001 From: steven-ji Date: Thu, 3 Sep 2026 05:31:46 +0800 Subject: [PATCH 43/50] feat(sdk): add per-call peer chat timeout (#1098) Forward optional timeout overrides through sync and async Peer.chat while retaining client-wide defaults. Refs #734 --- docs/v3/documentation/reference/sdk.mdx | 10 ++++++++ sdks/python/CHANGELOG.md | 6 +++++ sdks/python/src/honcho/aio.py | 10 +++++++- sdks/python/src/honcho/peer.py | 9 +++++++ tests/sdk/test_peer.py | 34 +++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 1 deletion(-) diff --git a/docs/v3/documentation/reference/sdk.mdx b/docs/v3/documentation/reference/sdk.mdx index 432a4aab..66e08e89 100644 --- a/docs/v3/documentation/reference/sdk.mdx +++ b/docs/v3/documentation/reference/sdk.mdx @@ -276,6 +276,9 @@ response = alice.chat("What do I know about Bob?", target="bob") response = alice.chat("What happened in session-1?", session="session-1") response = alice.chat("Summarize what matters most to me.", reasoning_level="high") +# Override the timeout for one non-streaming dialectic request +response = alice.chat("Give me a quick summary.", timeout=5.0) + # Add content to a session with a peer session = honcho.session("session-1") session.add_messages([ @@ -378,6 +381,13 @@ const bobConclusions = await alice.conclusionsOf("bob").list(); // Conclusions ``` +For Python, `peer.chat(timeout=...)` and `await peer.aio.chat(timeout=...)` +accept a timeout in seconds for each HTTP attempt made by one non-streaming +request. Omit it or pass `None` to use the client-wide timeout configured on +`Honcho`. Retries still follow the client's `max_retries` setting and can extend +total elapsed time; use `max_retries=0` when a host shutdown budget permits only +one attempt. + ### Peer Context The `context()` method on peers retrieves both the working representation and peer card in a single API call: diff --git a/sdks/python/CHANGELOG.md b/sdks/python/CHANGELOG.md index 7fe5d8f7..2bea7442 100644 --- a/sdks/python/CHANGELOG.md +++ b/sdks/python/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/). +## [Unreleased] + +### Added + +- Optional per-call `timeout` on synchronous and asynchronous `Peer.chat()`. It overrides the timeout for each HTTP attempt; when omitted or set to `None`, the client-wide timeout configured on `Honcho` remains in effect. + ## [2.4.0] - 2026-08-25 ### Added diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py index f5148ee6..3629551f 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -777,6 +777,7 @@ class PeerAio(AsyncMetadataConfigMixin): reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[TResponseFormat], + timeout: float | None = None, ) -> TResponseFormat | None: ... @overload @@ -791,6 +792,7 @@ class PeerAio(AsyncMetadataConfigMixin): reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: dict[str, Any] | None = None, + timeout: float | None = None, ) -> str | None: ... @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) @@ -805,12 +807,17 @@ class PeerAio(AsyncMetadataConfigMixin): reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[BaseModel] | dict[str, Any] | None = None, + timeout: float | None = Field( + None, gt=0, description="Timeout in seconds for this chat request" + ), ) -> 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. + JSON Schema dict, the answer is a JSON string. When timeout is omitted, + the Honcho client's configured timeout is used; retries can extend total + elapsed time. """ await self._peer._honcho._ensure_workspace_async() target_id = resolve_id(target) @@ -835,6 +842,7 @@ class PeerAio(AsyncMetadataConfigMixin): data = await self._peer._honcho._async_http_client.post( routes.peer_chat(self._peer.workspace_id, self._peer.id), body=body, + timeout=timeout, ) content = data.get("content") if not content: diff --git a/sdks/python/src/honcho/peer.py b/sdks/python/src/honcho/peer.py index 38edf269..bf6cf2d7 100644 --- a/sdks/python/src/honcho/peer.py +++ b/sdks/python/src/honcho/peer.py @@ -246,6 +246,7 @@ class Peer(PeerBase, MetadataConfigMixin): reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[TResponseFormat], + timeout: float | None = None, ) -> TResponseFormat | None: ... @overload @@ -260,6 +261,7 @@ class Peer(PeerBase, MetadataConfigMixin): reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: dict[str, Any] | None = None, + timeout: float | None = None, ) -> str | None: ... @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) @@ -274,6 +276,9 @@ class Peer(PeerBase, MetadataConfigMixin): reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[BaseModel] | dict[str, Any] | None = None, + timeout: float | None = Field( + None, gt=0, description="Timeout in seconds for this chat request" + ), ) -> BaseModel | str | None: """ Query the peer's representation with a natural language question. @@ -310,6 +315,9 @@ class Peer(PeerBase, MetadataConfigMixin): 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. + timeout: Optional timeout in seconds for each HTTP attempt made by + this request. When omitted, the Honcho client's configured + timeout is used. Retries can extend total elapsed time. Returns: Response string containing the answer (a JSON string when a schema @@ -342,6 +350,7 @@ class Peer(PeerBase, MetadataConfigMixin): data = self._honcho._http.post( routes.peer_chat(self.workspace_id, self.id), body=body, + timeout=timeout, ) content = data.get("content") if not content: diff --git a/tests/sdk/test_peer.py b/tests/sdk/test_peer.py index 0c019d9b..d563d034 100644 --- a/tests/sdk/test_peer.py +++ b/tests/sdk/test_peer.py @@ -277,6 +277,40 @@ async def test_peer_chat_non_streaming( assert response is None or isinstance(response, str) +@pytest.mark.asyncio +@pytest.mark.parametrize("timeout", [None, 2.5]) +async def test_peer_chat_forwards_per_call_timeout( + client_fixture: tuple[Honcho, str], + timeout: float | None, +) -> None: + honcho_client, client_type = client_fixture + timeout_label = "default" if timeout is None else "override" + + if client_type == "async": + peer = await honcho_client.aio.peer(id=f"test-timeout-{timeout_label}-async") + + async def mock_post(*args: object, **kwargs: object) -> dict[str, str]: # pyright: ignore[reportUnusedParameter] + return {"content": "ok"} + + 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?", timeout=timeout) + else: + peer = honcho_client.peer(id=f"test-timeout-{timeout_label}-sync") + with patch.object( + peer._honcho._http, # pyright: ignore[reportPrivateUsage] + "post", + return_value={"content": "ok"}, + ) as mock: + result = peer.chat("What do I like?", timeout=timeout) + + assert result == "ok" + assert mock.call_args.kwargs["timeout"] == timeout + + @pytest.mark.asyncio async def test_peer_representation_no_params( client_fixture: tuple[Honcho, str], From b573a84806c1db317762bec65c69ce4ed808731a Mon Sep 17 00:00:00 2001 From: ajspig <46900795+ajspig@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:56:09 -0400 Subject: [PATCH 44/50] Harness core (#1110) * chore: scaffold @honcho-ai/harness-core * feat(harness-core): resolve shared root config * feat(harness-core): send client identity headers on SDK requests * feat(harness-core): drop cloud vs custom api header * feat(harness-core): migrating v0 config to schema v1 on read * chore(harness-core): clean up * feat(config): describe oauth and host overrides in the v1 schema * chore: rename to harness-plugin-core * feat(harness-plugin-core): update telemetry headers on a live client. --- harness-plugin-core/.gitignore | 2 + harness-plugin-core/CHANGELOG.md | 10 + harness-plugin-core/README.md | 70 ++++++ harness-plugin-core/bun.lock | 25 ++ harness-plugin-core/package.json | 31 +++ harness-plugin-core/src/config.ts | 239 ++++++++++++++++++++ harness-plugin-core/src/index.ts | 29 +++ harness-plugin-core/src/telemetry.ts | 64 ++++++ harness-plugin-core/tests/config.test.ts | 71 ++++++ harness-plugin-core/tests/telemetry.test.ts | 56 +++++ harness-plugin-core/tsconfig.json | 13 ++ schemas/config/v1.json | 43 ++++ 12 files changed, 653 insertions(+) create mode 100644 harness-plugin-core/.gitignore create mode 100644 harness-plugin-core/CHANGELOG.md create mode 100644 harness-plugin-core/README.md create mode 100644 harness-plugin-core/bun.lock create mode 100644 harness-plugin-core/package.json create mode 100644 harness-plugin-core/src/config.ts create mode 100644 harness-plugin-core/src/index.ts create mode 100644 harness-plugin-core/src/telemetry.ts create mode 100644 harness-plugin-core/tests/config.test.ts create mode 100644 harness-plugin-core/tests/telemetry.test.ts create mode 100644 harness-plugin-core/tsconfig.json create mode 100644 schemas/config/v1.json diff --git a/harness-plugin-core/.gitignore b/harness-plugin-core/.gitignore new file mode 100644 index 00000000..f06235c4 --- /dev/null +++ b/harness-plugin-core/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/harness-plugin-core/CHANGELOG.md b/harness-plugin-core/CHANGELOG.md new file mode 100644 index 00000000..ca098844 --- /dev/null +++ b/harness-plugin-core/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to `@honcho-ai/harness-plugin-core` 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/). + +This package versions independently of the Honcho API, `@honcho-ai/sdk`, and host plugins. + +## [Unreleased] diff --git a/harness-plugin-core/README.md b/harness-plugin-core/README.md new file mode 100644 index 00000000..0b4525de --- /dev/null +++ b/harness-plugin-core/README.md @@ -0,0 +1,70 @@ +# @honcho-ai/harness-plugin-core + +Shared runtime for Honcho harness plugins. + +```ts +import { loadConfig, resolveConfig } from '@honcho-ai/harness-plugin-core' + +const cfg = loadConfig({ host: 'harness' }) +// a harness can pass its plugin config as an overlay of the same six keys: +const cfg = resolveConfig(file, { host: 'harness', overlay: { workspace: 'harness', auth: { apiKey } } }) +``` + +Locally: `"@honcho-ai/harness-plugin-core": "file:../harness-plugin-core"` (bun imports the TypeScript source). + +## File shape + +```json +{ + "schemaVersion": 1, + "peerName": "user", + "workspace": "honcho", + "baseUrl": "https://api.honcho.dev", + "timeoutMs": 30000, + "auth": { "apiKey": "${HONCHO_API_KEY}" }, + "enabled": true, + "hosts": { + "test": { "workspace": "test" } + } +} +``` + +Missing `schemaVersion` is 0. On read, v0 keys (`environmentUrl`, `workspaceId`, top-level `apiKey`) are remapped in memory; the file is not rewritten. + +Resolution, highest wins: `HONCHO_*` env → overlay → `hosts.` → root → built-in. + +A host block may override the same six fields. + +Built-ins: `baseUrl = https://api.honcho.dev`, `timeoutMs = 30000`, `enabled = true`, `peerName = $USER`, `workspace` falls back to the host name. The SDK pins `/v3`; config stores the origin. + +## Telemetry headers + +Pass `telemetryHeaders()` as the SDK's `defaultHeaders`. Arbitrary headers are accepted by both the SDK and the Honcho API; missing identity fields are omitted. + +| Header | Meaning | Example | +|---|---|---| +| `X-Honcho-Host` | Agent host name, or `name/version` | `harness/1.3.13` | +| `X-Honcho-Plugin` | Honcho plugin version | `0.1.3` | +| `X-Honcho-Runtime` | This package's version (always sent) | `0.1.0` | +| `X-Honcho-Agent-Model` | The agent's completion model, not a Honcho model | `claude-sonnet-4-5` | + +```ts +import { Honcho } from '@honcho-ai/sdk' +import { loadConfig, setTelemetryHeaders, telemetryHeaders } from '@honcho-ai/harness-plugin-core' + +const cfg = loadConfig({ host: 'harness' }) +const honcho = new Honcho({ + apiKey: cfg.apiKey, + baseURL: cfg.baseUrl, + workspaceId: cfg.workspace, + timeout: cfg.timeoutMs, + defaultHeaders: telemetryHeaders({ + host: 'harness', + hostVersion: '1.3.13', + pluginVersion: '0.1.3', + model: 'claude-sonnet-4-5', + }), +}) + +setTelemetryHeaders(honcho.http.defaultHeaders, { model: 'claude-opus-4' }) +``` diff --git a/harness-plugin-core/bun.lock b/harness-plugin-core/bun.lock new file mode 100644 index 00000000..1522ce60 --- /dev/null +++ b/harness-plugin-core/bun.lock @@ -0,0 +1,25 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@honcho-ai/harness-plugin-core", + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^24.0.1", + "typescript": "^5.0.0", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/harness-plugin-core/package.json b/harness-plugin-core/package.json new file mode 100644 index 00000000..13e59083 --- /dev/null +++ b/harness-plugin-core/package.json @@ -0,0 +1,31 @@ +{ + "name": "@honcho-ai/harness-plugin-core", + "version": "0.1.0", + "description": "Shared runtime for Honcho harness plugins", + "author": "Plastic Labs ", + "license": "MIT", + "type": "module", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src", + "README.md", + "CHANGELOG.md" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/plastic-labs/honcho.git", + "directory": "harness-plugin-core" + }, + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^24.0.1", + "typescript": "^5.0.0" + } +} diff --git a/harness-plugin-core/src/config.ts b/harness-plugin-core/src/config.ts new file mode 100644 index 00000000..126cdb78 --- /dev/null +++ b/harness-plugin-core/src/config.ts @@ -0,0 +1,239 @@ +import { existsSync, readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +export interface AuthConfig { + apiKey?: string + oauth?: { accessToken?: string; refreshToken?: string; expiresAt?: string } +} + +/** Identity + connection + kill switch. Valid at root and as a host override. */ +export interface RootConfig { + peerName?: string + workspace?: string + baseUrl?: string + timeoutMs?: number + auth?: AuthConfig + enabled?: boolean +} + +export type HostBlock = RootConfig + +export interface FileConfig extends RootConfig { + schemaVersion?: number + hosts?: Record +} + +export interface ResolvedConfig { + host: string + peerName: string + workspace: string + baseUrl: string + timeoutMs: number + auth: AuthConfig + apiKey?: string + enabled: boolean + warnings: string[] +} + +export const DEFAULT_BASE_URL = 'https://api.honcho.dev' +export const DEFAULT_TIMEOUT_MS = 30_000 +export const CONFIG_SCHEMA_VERSION = 1 + +function isObj(v: unknown): v is Record { + return v !== null && typeof v === 'object' && !Array.isArray(v) +} + +/** Pre-schema files (no schemaVersion) → v1 keys. Host blocks included. */ +function migrate(file: unknown): Record { + if (!isObj(file)) return {} + const v = file.schemaVersion + if (typeof v === 'number' && v >= CONFIG_SCHEMA_VERSION) return { ...file } + const out: Record = { ...file } + const blocks: Record[] = [out] + if (isObj(out.hosts)) { + out.hosts = Object.fromEntries( + Object.entries(out.hosts).map(([k, block]) => { + if (!isObj(block)) return [k, block] + const next = { ...block } + blocks.push(next) + return [k, next] + }) + ) + } + for (const b of blocks) { + if (typeof b.baseUrl !== 'string') { + if (typeof b.environmentUrl === 'string') b.baseUrl = b.environmentUrl + else if (isObj(b.endpoint) && typeof b.endpoint.baseUrl === 'string') { + b.baseUrl = b.endpoint.baseUrl + } + } + if (typeof b.workspace !== 'string' && typeof b.workspaceId === 'string') { + b.workspace = b.workspaceId + } + const auth: Record = isObj(b.auth) ? { ...b.auth } : {} + if (typeof auth.apiKey !== 'string' && typeof b.apiKey === 'string') auth.apiKey = b.apiKey + if (!isObj(auth.oauth) && isObj(b.oauth)) auth.oauth = b.oauth + if (Object.keys(auth).length) b.auth = auth + delete b.environmentUrl + delete b.endpoint + delete b.workspaceId + delete b.apiKey + delete b.oauth + } + out.schemaVersion = 1 + return out +} + +function merge(base: T, over: unknown): T { + if (over === undefined || over === null) return base + if (Array.isArray(over) || !isObj(over)) return over as T + const out: Record = { ...(isObj(base) ? base : {}) } + for (const [k, v] of Object.entries(over)) { + if (v !== undefined) out[k] = k in out ? merge(out[k], v) : v + } + return out as T +} + +/** Make a value safe to pass to the SDK as `baseURL`. */ +export function normalizeBaseUrl(input: string): string { + let s = input.trim() + if (!s) return s + if (!s.startsWith('http://') && !s.startsWith('https://')) { + const host = s.split('/')[0].split(':')[0].toLowerCase() + const local = host === 'localhost' || host === '127.0.0.1' || host === '::1' + s = `${local ? 'http' : 'https'}://${s}` + } + try { + const u = new URL(s) + u.hostname = u.hostname.toLowerCase() + const path = u.pathname === '/' ? '' : u.pathname.replace(/\/+$/, '') + return `${u.protocol}//${u.host}${path}` + } catch { + return s + } +} + +function interpolate(value: string, env: NodeJS.Dict, warnings: string[]): string { + return value.replace(/\$\{([^}]+)\}/g, (m, name: string) => { + const v = env[name] + if (!v) { + warnings.push(`${m} is not set`) + return m + } + return v + }) +} + +function walkStrings(value: T, fn: (s: string) => string): T { + if (typeof value === 'string') return fn(value) as T + if (Array.isArray(value)) return value.map((x) => walkStrings(x, fn)) as T + if (isObj(value)) { + const out: Record = {} + for (const [k, v] of Object.entries(value)) out[k] = walkStrings(v, fn) + return out as T + } + return value +} + +/** Pull only the six root fields. Extra host keys (injection, observation, …) are ignored. */ +function pickRoot(block: unknown): RootConfig { + if (!isObj(block)) return {} + const auth: AuthConfig = isObj(block.auth) ? { ...(block.auth as AuthConfig) } : {} + const out: RootConfig = {} + if (typeof block.peerName === 'string') out.peerName = block.peerName + if (typeof block.workspace === 'string') out.workspace = block.workspace + if (typeof block.baseUrl === 'string') out.baseUrl = block.baseUrl + if (typeof block.timeoutMs === 'number') out.timeoutMs = block.timeoutMs + if (Object.keys(auth).length) out.auth = auth + if (typeof block.enabled === 'boolean') out.enabled = block.enabled + return out +} + +function pickHost(hosts: Record | undefined, name: string): RootConfig { + if (!hosts || !isObj(hosts[name])) return {} + return pickRoot(hosts[name]) +} + +/** + * Highest wins: HONCHO_* env → overlay → hosts. → root → built-in. + */ +export function resolveConfig( + file: unknown, + opts: { host: string; env?: NodeJS.Dict; overlay?: RootConfig } +): ResolvedConfig { + const warnings: string[] = [] + const env = opts.env ?? process.env + const host = opts.host + const raw = migrate(file) + if (typeof raw.schemaVersion === 'number' && raw.schemaVersion > CONFIG_SCHEMA_VERSION) { + warnings.push(`config schemaVersion ${raw.schemaVersion} is newer than ${CONFIG_SCHEMA_VERSION}`) + } + const hosts = isObj(raw.hosts) ? raw.hosts : undefined + + let acc: RootConfig = { + baseUrl: DEFAULT_BASE_URL, + timeoutMs: DEFAULT_TIMEOUT_MS, + enabled: true, + workspace: host, + } + acc = merge(acc, pickRoot(raw)) + acc = merge(acc, pickHost(hosts, host)) + acc = merge(acc, pickRoot(opts.overlay)) + + if (env.HONCHO_API_KEY) { + if (acc.auth?.apiKey) warnings.push('HONCHO_API_KEY shadows auth.apiKey') + acc = merge(acc, { auth: { apiKey: env.HONCHO_API_KEY } }) + } + if (env.HONCHO_BASE_URL || env.HONCHO_URL || env.HONCHO_ENDPOINT) { + const token = env.HONCHO_BASE_URL || env.HONCHO_URL || env.HONCHO_ENDPOINT || '' + acc.baseUrl = token === 'local' ? 'http://localhost:8000' : token + } + if (env.HONCHO_WORKSPACE || env.HONCHO_WORKSPACE_ID) { + acc.workspace = env.HONCHO_WORKSPACE || env.HONCHO_WORKSPACE_ID + } + if (env.HONCHO_PEER_NAME) acc.peerName = env.HONCHO_PEER_NAME + if (env.HONCHO_TIMEOUT_MS) { + const n = Number(env.HONCHO_TIMEOUT_MS) + if (Number.isFinite(n) && n > 0) acc.timeoutMs = n + } + if (env.HONCHO_ENABLED === 'false') acc.enabled = false + + acc = walkStrings(acc, (s) => interpolate(s, env, warnings)) + if (acc.baseUrl) acc.baseUrl = normalizeBaseUrl(acc.baseUrl) + + const auth = acc.auth ?? {} + return { + host, + peerName: acc.peerName || env.USER || env.USERNAME || 'user', + workspace: acc.workspace || host, + baseUrl: acc.baseUrl || DEFAULT_BASE_URL, + timeoutMs: acc.timeoutMs && acc.timeoutMs > 0 ? acc.timeoutMs : DEFAULT_TIMEOUT_MS, + auth, + apiKey: auth.apiKey, + enabled: acc.enabled !== false, + warnings, + } +} + +export function configPath(env: NodeJS.Dict = process.env): string { + return env.HONCHO_CONFIG_PATH || join(homedir(), '.honcho', 'config.json') +} + +export function loadConfig(opts: { + host: string + env?: NodeJS.Dict + overlay?: RootConfig +}): ResolvedConfig { + const env = opts.env ?? process.env + const path = configPath(env) + let file: unknown = {} + if (existsSync(path)) { + try { + file = JSON.parse(readFileSync(path, 'utf-8')) + } catch { + file = {} + } + } + return resolveConfig(file, { ...opts, env }) +} diff --git a/harness-plugin-core/src/index.ts b/harness-plugin-core/src/index.ts new file mode 100644 index 00000000..ab47a2b3 --- /dev/null +++ b/harness-plugin-core/src/index.ts @@ -0,0 +1,29 @@ +export const version = '0.1.0' + +export { + configPath, + loadConfig, + normalizeBaseUrl, + resolveConfig, + DEFAULT_BASE_URL, + DEFAULT_TIMEOUT_MS, +} from './config.ts' + +export type { + AuthConfig, + FileConfig, + HostBlock, + ResolvedConfig, + RootConfig, +} from './config.ts' + +export { + telemetryHeaders, + setTelemetryHeaders, + HEADER_AGENT_MODEL, + HEADER_HOST, + HEADER_PLUGIN, + HEADER_RUNTIME, +} from './telemetry.ts' + +export type { TelemetryIdentity } from './telemetry.ts' diff --git a/harness-plugin-core/src/telemetry.ts b/harness-plugin-core/src/telemetry.ts new file mode 100644 index 00000000..eb8b2dc3 --- /dev/null +++ b/harness-plugin-core/src/telemetry.ts @@ -0,0 +1,64 @@ +import { version } from './index.ts' + +/** Optional identity a host plugin knows at Honcho-client construction time. */ +export interface TelemetryIdentity { + /** Host app name, e.g. `cursor`, `opencode`. */ + host?: string + /** Host app version, e.g. `2026.8.1`. */ + hostVersion?: string + /** Honcho plugin version, e.g. `0.1.2`. */ + pluginVersion?: string + /** Agent completion model, e.g. `claude-sonnet-4-5`. Not a Honcho deriver/dialectic model. */ + model?: string +} + +export const HEADER_HOST = 'X-Honcho-Host' +export const HEADER_PLUGIN = 'X-Honcho-Plugin' +export const HEADER_RUNTIME = 'X-Honcho-Runtime' +export const HEADER_AGENT_MODEL = 'X-Honcho-Agent-Model' + +function sanitize(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const s = value.replace(/[\r\n]+/g, ' ').trim() + return s || undefined +} + +function hostValue(id: TelemetryIdentity): string | undefined { + const name = sanitize(id.host) + const ver = sanitize(id.hostVersion) + if (name && ver) return `${name}/${ver}` + return name || ver +} + +/** + * Headers to pass as the SDK's `defaultHeaders`. Missing fields are omitted. + * `X-Honcho-Runtime` is always this package's version. + */ +export function telemetryHeaders( + id: TelemetryIdentity = {}, + extra?: Record +): Record { + const headers: Record = { [HEADER_RUNTIME]: version } + const host = hostValue(id) + const plugin = sanitize(id.pluginVersion) + const model = sanitize(id.model) + if (host) headers[HEADER_HOST] = host + if (plugin) headers[HEADER_PLUGIN] = plugin + if (model) headers[HEADER_AGENT_MODEL] = model + if (extra) { + for (const [k, v] of Object.entries(extra)) { + const value = sanitize(v) + if (value) headers[k] = value + } + } + return headers +} + +/** Merge identity onto a live header map (e.g. `honcho.http.defaultHeaders`). */ +export function setTelemetryHeaders( + headers: Record, + id: TelemetryIdentity = {}, + extra?: Record +): Record { + return Object.assign(headers, telemetryHeaders(id, extra)) +} diff --git a/harness-plugin-core/tests/config.test.ts b/harness-plugin-core/tests/config.test.ts new file mode 100644 index 00000000..2c1b6a3e --- /dev/null +++ b/harness-plugin-core/tests/config.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test' +import { normalizeBaseUrl, resolveConfig } from '../src/index.ts' + +const emptyEnv = {} + +describe('normalizeBaseUrl', () => { + test('adds https and lowercases the host', () => { + expect(normalizeBaseUrl('api.honcho.dev')).toBe('https://api.honcho.dev') + expect(normalizeBaseUrl('API.honcho.dev')).toBe('https://api.honcho.dev') + expect(normalizeBaseUrl('https://api.honcho.dev/')).toBe('https://api.honcho.dev') + }) + + test('leaves /v3 alone — the SDK owns the API version', () => { + expect(normalizeBaseUrl('https://api.honcho.dev/v3')).toBe('https://api.honcho.dev/v3') + }) + + test('localhost stays http', () => { + expect(normalizeBaseUrl('localhost:8000')).toBe('http://localhost:8000') + }) +}) + +describe('resolveConfig', () => { + test('host block beats root; env beats host', () => { + const file = { + workspace: 'root-ws', + hosts: { a: { workspace: 'host-ws' } }, + } + expect(resolveConfig(file, { host: 'a', env: emptyEnv }).workspace).toBe('host-ws') + expect( + resolveConfig(file, { host: 'a', env: { HONCHO_WORKSPACE: 'env-ws' } }).workspace + ).toBe('env-ws') + }) + + test('root apiKey / workspaceId aliases still resolve', () => { + const cfg = resolveConfig( + { apiKey: 'hch_x', workspaceId: 'from-id' }, + { host: 'a', env: emptyEnv } + ) + expect(cfg.apiKey).toBe('hch_x') + expect(cfg.workspace).toBe('from-id') + }) + + test('v1 leftover environmentUrl is ignored', () => { + const cfg = resolveConfig( + { schemaVersion: 1, baseUrl: 'https://keep.example', environmentUrl: 'https://old.example' }, + { host: 'a', env: emptyEnv } + ) + expect(cfg.baseUrl).toBe('https://keep.example') + }) + + test('overlay sits below env', () => { + expect( + resolveConfig( + {}, + { host: 'a', overlay: { workspace: 'from-overlay' }, env: { HONCHO_WORKSPACE: 'from-env' } } + ).workspace + ).toBe('from-env') + expect( + resolveConfig({}, { host: 'a', overlay: { workspace: 'from-overlay' }, env: emptyEnv }).workspace + ).toBe('from-overlay') + }) + + test('empty file uses built-ins; host name is not rewritten', () => { + const cfg = resolveConfig({}, { host: 'my-host', env: emptyEnv }) + expect(cfg.baseUrl).toBe('https://api.honcho.dev') + expect(cfg.timeoutMs).toBe(30_000) + expect(cfg.enabled).toBe(true) + expect(cfg.host).toBe('my-host') + expect(cfg.workspace).toBe('my-host') + }) +}) diff --git a/harness-plugin-core/tests/telemetry.test.ts b/harness-plugin-core/tests/telemetry.test.ts new file mode 100644 index 00000000..170c9e7a --- /dev/null +++ b/harness-plugin-core/tests/telemetry.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test' +import { + HEADER_AGENT_MODEL, + HEADER_HOST, + HEADER_PLUGIN, + HEADER_RUNTIME, + setTelemetryHeaders, + telemetryHeaders, + version, +} from '../src/index.ts' + +describe('telemetryHeaders', () => { + test('empty identity still sends the runtime version', () => { + expect(telemetryHeaders()).toEqual({ [HEADER_RUNTIME]: version }) + }) + + test('maps identity to headers', () => { + expect( + telemetryHeaders({ + host: 'opencode', + hostVersion: '1.3.13', + pluginVersion: '0.1.3', + model: 'claude-sonnet-4-5', + }) + ).toEqual({ + [HEADER_RUNTIME]: version, + [HEADER_HOST]: 'opencode/1.3.13', + [HEADER_PLUGIN]: '0.1.3', + [HEADER_AGENT_MODEL]: 'claude-sonnet-4-5', + }) + }) + + test('merges extra headers last, skipping blanks', () => { + const headers = telemetryHeaders({ host: 'codex', pluginVersion: '0.1.1' }, { + 'X-Custom': 'yes', + [HEADER_PLUGIN]: 'override', + 'X-Empty': ' ', + }) + expect(headers[HEADER_HOST]).toBe('codex') + expect(headers[HEADER_PLUGIN]).toBe('override') + expect(headers['X-Custom']).toBe('yes') + expect(headers).not.toHaveProperty('X-Empty') + }) +}) + +describe('setTelemetryHeaders', () => { + test('mutates an existing header map in place', () => { + const headers = telemetryHeaders({ host: 'cursor', pluginVersion: '0.1.2' }) + const returned = setTelemetryHeaders(headers, { model: 'claude-opus-4' }) + expect(returned).toBe(headers) + expect(headers[HEADER_HOST]).toBe('cursor') + expect(headers[HEADER_PLUGIN]).toBe('0.1.2') + expect(headers[HEADER_RUNTIME]).toBe(version) + expect(headers[HEADER_AGENT_MODEL]).toBe('claude-opus-4') + }) +}) diff --git a/harness-plugin-core/tsconfig.json b/harness-plugin-core/tsconfig.json new file mode 100644 index 00000000..96d10fea --- /dev/null +++ b/harness-plugin-core/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/schemas/config/v1.json b/schemas/config/v1.json new file mode 100644 index 00000000..be1384db --- /dev/null +++ b/schemas/config/v1.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spec.honcho.dev/config/v1.json", + "type": "object", + "additionalProperties": true, + "$defs": { + "oauth": { + "type": "object", + "properties": { + "accessToken": { "type": "string" }, + "refreshToken": { "type": "string" }, + "expiresAt": { "type": "string" } + } + }, + "auth": { + "type": "object", + "properties": { + "apiKey": { "type": "string" }, + "oauth": { "$ref": "#/$defs/oauth" } + } + }, + "hostBlock": { + "type": "object", + "additionalProperties": true, + "properties": { + "peerName": { "type": "string" }, + "workspace": { "type": "string" }, + "baseUrl": { "type": "string" }, + "timeoutMs": { "type": "number" }, + "enabled": { "type": "boolean" }, + "auth": { "$ref": "#/$defs/auth" } + } + } + }, + "allOf": [{ "$ref": "#/$defs/hostBlock" }], + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "hosts": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/hostBlock" } + } + } +} From 2ad56a4d715b015d744ea86f05a287069d264898 Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Wed, 2 Sep 2026 17:56:51 -0400 Subject: [PATCH 45/50] feat(mcp): add stdio host for local clients (#1102) * feat(mcp): add stdio host for local clients * feat(mcp): add Streamable HTTP host and image Long-lived HTTP entry for Docker and other process hosts, reusing createServer(). Dedicated mcp/Dockerfile; compose service beside api. * fix(mcp): stdio launcher cwd/silent and HTTP session bounds Pin bun --cwd so bunfig loads. Silence bun run. Require Bearer on HTTP. Idle-expire and cap in-memory MCP sessions. * fix(mcp): re-check bearer on established HTTP sessions Session lookup returned early without Authorization, so a missing or wrong token still 200'd after initialize. Bind each session to the init key and 401 on mismatch. * fix: nit cleaning claude command --------- Co-authored-by: ajspig --- docker-compose.yml.example | 28 ++++ mcp/.dockerignore | 7 + mcp/Dockerfile | 19 +++ mcp/README.md | 74 ++++++++-- mcp/bunfig.toml | 5 + mcp/package.json | 4 +- mcp/src/config.ts | 25 +++- mcp/src/http.test.ts | 56 ++++++++ mcp/src/http.ts | 281 +++++++++++++++++++++++++++++++++++++ mcp/src/stdio.ts | 30 ++++ mcp/tsconfig.json | 2 +- 11 files changed, 517 insertions(+), 14 deletions(-) create mode 100644 mcp/.dockerignore create mode 100644 mcp/Dockerfile create mode 100644 mcp/bunfig.toml create mode 100644 mcp/src/http.test.ts create mode 100644 mcp/src/http.ts create mode 100644 mcp/src/stdio.ts diff --git a/docker-compose.yml.example b/docker-compose.yml.example index ee201e6f..4bfae9f7 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -80,6 +80,34 @@ services: required: false restart: unless-stopped + mcp: + build: + context: ./mcp + dockerfile: Dockerfile + depends_on: + api: + condition: service_healthy + ports: + - "127.0.0.1:3000:3000" + environment: + - HONCHO_API_URL=http://api:8000 + env_file: + - path: .env + required: false + healthcheck: + test: + [ + "CMD", + "bun", + "-e", + "fetch('http://127.0.0.1:3000/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + database: image: pgvector/pgvector:pg15 restart: unless-stopped diff --git a/mcp/.dockerignore b/mcp/.dockerignore new file mode 100644 index 00000000..62b547a4 --- /dev/null +++ b/mcp/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.wrangler +.dev.vars +.env +.env.* +*.log +dist diff --git a/mcp/Dockerfile b/mcp/Dockerfile new file mode 100644 index 00000000..60e0a0d1 --- /dev/null +++ b/mcp/Dockerfile @@ -0,0 +1,19 @@ +FROM oven/bun:1.2 + +WORKDIR /app + +RUN chown bun:bun /app +USER bun + +COPY --chown=bun:bun package.json bun.lock bunfig.toml tsconfig.json ./ +COPY --chown=bun:bun instructions.md ./ +COPY --chown=bun:bun src ./src + +RUN bun install --frozen-lockfile --production + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOST=0.0.0.0 + +CMD ["bun", "src/http.ts"] diff --git a/mcp/README.md b/mcp/README.md index 3bd24217..94abd88a 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -1,6 +1,6 @@ # Honcho MCP Server -A Cloudflare Worker that implements the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) for [Honcho](https://honcho.dev), providing AI memory and personalization tools to LLM clients like Claude Desktop. +A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for [Honcho](https://honcho.dev). The hosted path is a Cloudflare Worker; the same tools also run over stdio and over Streamable HTTP (`bun src/http.ts`) for Docker and other long-lived process hosts. ## Quickstart: Use the Hosted Server @@ -45,6 +45,8 @@ Every workspace-scoped tool takes a `workspace_id` argument. If you set `X-Honch ``` src/ index.ts # Worker entry point — parse config, delegate to MCP handler + stdio.ts # Local stdio host (bun src/stdio.ts) + http.ts # Streamable HTTP host (bun src/http.ts / Docker) server.ts # createServer() — registers all tools on an McpServer config.ts # HonchoConfig, parseConfig(), createClientFactory() types.ts # ToolContext, result helpers @@ -64,25 +66,75 @@ Built on: ## Self-Hosted Honcho -If you run Honcho yourself (for privacy, latency, or offline use), deploy the -MCP Worker alongside your instance and set `HONCHO_API_URL` in its -environment. +If you run Honcho yourself, point this server at it with `HONCHO_API_URL`. +When unset, requests go to `https://api.honcho.dev`. -**Local dev (`bun run dev`):** create `mcp/.dev.vars`: +**Cloudflare Worker (`bun run dev` / `bun run deploy`):** create `mcp/.dev.vars`: ``` HONCHO_API_URL=http://127.0.0.1:28000 ``` -**Deployed Worker:** +For a deployed Worker: `wrangler secret put HONCHO_API_URL`. + +## HTTP host + +For Docker or any platform that runs a long-lived process, use the Streamable +HTTP entry instead of the Worker. Clients keep the same `mcp-remote` shape as +`https://mcp.honcho.dev`. Sessions live in process memory — run one instance. ```bash -wrangler secret put HONCHO_API_URL -# paste your URL when prompted +cd mcp && bun install +HONCHO_API_URL=http://127.0.0.1:8000 bun run http ``` -When `HONCHO_API_URL` is unset the Worker routes to `https://api.honcho.dev`, -so this change is backward-compatible. +```bash +bunx mcp-remote http://127.0.0.1:3000 \ + --header "Authorization:Bearer " +``` + +Auth is the `Authorization: Bearer` header (same as the Worker). Established +sessions still require that same bearer. Optional `X-Honcho-Workspace-ID` +fills `workspace_id` when the tool argument is omitted. + +`HOST` defaults to `0.0.0.0`, `PORT` to `3000`. `GET /health` is unauthenticated. +MCP is served at `/` and `/mcp`. Idle sessions expire after +`MCP_SESSION_IDLE_MS` (default 30 minutes); `MCP_SESSION_MAX` (default 128) +caps concurrent sessions. + +A platform start command is `bun src/http.ts` (or `bun run http` from `mcp/`). +This repo does not ship a `vercel.json`; serverless replicas do not share the +in-memory session map. + +### Docker + +```bash +docker build -f mcp/Dockerfile -t honcho-mcp mcp +docker run --rm -p 3000:3000 \ + -e HONCHO_API_URL=http://host.docker.internal:8000 \ + honcho-mcp +``` + +`docker-compose.yml.example` includes an `mcp` service beside `api` and +`deriver` (`HONCHO_API_URL=http://api:8000`, port `127.0.0.1:3000`). + +## Local stdio + +For a local Honcho instance, or any MCP client that spawns a process, run the +stdio host. `--cwd` loads `mcp/bunfig.toml` (Markdown loader) from this package. + +```bash +cd mcp && bun install + +claude mcp add honcho \ + -e HONCHO_API_KEY=hch-your-key-here \ + -e HONCHO_API_URL=http://127.0.0.1:28000 \ + -e HONCHO_WORKSPACE_ID=my-workspace \ + -- bun --cwd "$(pwd)" src/stdio.ts +``` + +`HONCHO_API_URL` defaults to `https://api.honcho.dev`. `HONCHO_WORKSPACE_ID` is +optional; without it, pass `workspace_id` on each tool call. ## Development @@ -106,6 +158,8 @@ bun run tsc --noEmit ### Test locally +Worker (`bun dev`, port 8787) or HTTP host (`bun run http`, port 3000): + ```bash bunx mcp-remote http://localhost:8787 \ --header "Authorization:Bearer " diff --git a/mcp/bunfig.toml b/mcp/bunfig.toml new file mode 100644 index 00000000..9d1af97a --- /dev/null +++ b/mcp/bunfig.toml @@ -0,0 +1,5 @@ +[loader] +".md" = "text" + +[run] +silent = true diff --git a/mcp/package.json b/mcp/package.json index 900df5cf..231ec495 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,7 +1,7 @@ { "name": "honcho-mcp", "version": "3.0.0", - "description": "Honcho MCP Server — Cloudflare Worker", + "description": "Honcho MCP Server", "main": "src/index.ts", "packageManager": "bun@1.2.0", "engines": { @@ -11,6 +11,8 @@ "scripts": { "preinstall": "node -e \"const ua=process.env.npm_config_user_agent||'';if(ua.includes('npm')&&!ua.includes('bun')){console.error('❌ Please use bun instead of npm!\\n📦 Run: bun install\\n🌐 Install bun: https://bun.sh/');process.exit(1)}\"", "dev": "wrangler dev", + "stdio": "bun src/stdio.ts", + "http": "bun src/http.ts", "deploy": "wrangler deploy", "deploy:staging": "wrangler deploy --env staging" }, diff --git a/mcp/src/config.ts b/mcp/src/config.ts index ed70bbc1..ee8067e3 100644 --- a/mcp/src/config.ts +++ b/mcp/src/config.ts @@ -3,7 +3,7 @@ import { Honcho } from "@honcho-ai/sdk"; export interface HonchoConfig { apiKey: string; baseUrl: string; - /** From X-Honcho-Workspace-ID when set. */ + /** From X-Honcho-Workspace-ID (HTTP) or HONCHO_WORKSPACE_ID (stdio). */ workspaceId?: string; } @@ -12,6 +12,12 @@ export interface Env { ALERT_WEBHOOK_URL?: string; } +export interface EnvConfig { + HONCHO_API_KEY?: string; + HONCHO_API_URL?: string; + HONCHO_WORKSPACE_ID?: string; +} + /** * Parse configuration from request headers and Worker env bindings. * Throws only when the Authorization bearer token is missing/empty. @@ -48,8 +54,23 @@ export function parseConfig(request: Request, env: Env = {}): HonchoConfig { }; } +/** Parse configuration from process env. */ +export function parseEnvConfig(env: EnvConfig): HonchoConfig { + const apiKey = env.HONCHO_API_KEY?.trim(); + if (!apiKey) { + throw new Error( + "Missing HONCHO_API_KEY. Set HONCHO_API_KEY to your Honcho API key.", + ); + } + return { + apiKey, + baseUrl: env.HONCHO_API_URL?.trim() || "https://api.honcho.dev", + workspaceId: env.HONCHO_WORKSPACE_ID?.trim() || undefined, + }; +} + export const MISSING_WORKSPACE_ID_MESSAGE = - "Missing workspace_id. Pass workspace_id on the next tool call, or set the X-Honcho-Workspace-ID header on the connection so it is used automatically."; + "Missing workspace_id. Pass workspace_id on the next tool call, or set X-Honcho-Workspace-ID (HTTP) / HONCHO_WORKSPACE_ID (stdio)."; export function resolveWorkspaceId( config: HonchoConfig, diff --git a/mcp/src/http.test.ts b/mcp/src/http.test.ts new file mode 100644 index 00000000..385f84eb --- /dev/null +++ b/mcp/src/http.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import { fetch } from "./http.ts"; + +const origin = "http://127.0.0.1:3000"; + +const initializeBody = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test", version: "0.0.0" }, + }, +}; + +const pingBody = { jsonrpc: "2.0", id: 2, method: "ping" }; + +function mcpPost(headers: Record, body: unknown) { + return fetch( + new Request(`${origin}/mcp`, { + method: "POST", + headers: { + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + }), + ); +} + +test("established sessions require the initialize bearer", async () => { + const init = await mcpPost( + { Authorization: "Bearer key-a" }, + initializeBody, + ); + expect(init.status).toBe(200); + const sessionId = init.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + + const missing = await mcpPost({ "mcp-session-id": sessionId! }, pingBody); + expect(missing.status).toBe(401); + + const wrong = await mcpPost( + { Authorization: "Bearer key-b", "mcp-session-id": sessionId! }, + pingBody, + ); + expect(wrong.status).toBe(401); + + const ok = await mcpPost( + { Authorization: "Bearer key-a", "mcp-session-id": sessionId! }, + pingBody, + ); + expect(ok.status).toBe(200); +}); diff --git a/mcp/src/http.ts b/mcp/src/http.ts new file mode 100644 index 00000000..a33bd4a8 --- /dev/null +++ b/mcp/src/http.ts @@ -0,0 +1,281 @@ +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { + createClientFactory, + createUnscopedClient, + parseConfig, + type Env, + type HonchoConfig, +} from "./config.js"; +import { createServer } from "./server.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +declare const process: { + env: Record; +}; + +declare const Bun: { + serve(options: { + hostname: string; + port: number; + fetch(request: Request): Response | Promise; + }): { hostname: string; port: number }; +}; + +const CORS_ORIGIN = "*"; +const CORS_METHODS = "GET, POST, DELETE, OPTIONS"; +const CORS_ALLOWED_HEADERS = + "Content-Type, Authorization, X-Honcho-Workspace-ID, mcp-session-id, mcp-protocol-version, last-event-id"; + +const CORS_HEADERS: Record = { + "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, mcp-session-id", +}; + +const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"; +const MCP_PATHS = new Set(["/", "/mcp"]); + +type Session = { + transport: WebStandardStreamableHTTPServerTransport; + server: McpServer; + lastSeen: number; + apiKey: string; +}; + +const sessions = new Map(); +const DEFAULT_SESSION_IDLE_MS = 30 * 60 * 1000; +const DEFAULT_SESSION_MAX = 128; + +function envInt(name: string, fallback: number): number { + const n = Number(process.env[name]); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function dropSession(id: string): void { + const session = sessions.get(id); + if (!session) return; + sessions.delete(id); + void session.transport.close(); + void session.server.close(); +} + +function sweepSessions(): void { + const idleMs = envInt("MCP_SESSION_IDLE_MS", DEFAULT_SESSION_IDLE_MS); + const now = Date.now(); + for (const [id, session] of sessions) { + if (now - session.lastSeen > idleMs) dropSession(id); + } +} + +function envBindings(): Env { + return { HONCHO_API_URL: process.env.HONCHO_API_URL }; +} + +function authorizationServer(): string { + return process.env.HONCHO_API_URL?.trim() || "https://api.honcho.dev"; +} + +function withCors(response: Response): Response { + const headers = new Headers(response.headers); + for (const [key, value] of Object.entries(CORS_HEADERS)) { + headers.set(key, value); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +function jsonResponse( + body: unknown, + status: number, + extraHeaders?: Record, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json", + ...CORS_HEADERS, + ...extraHeaders, + }, + }); +} + +function configForRequest(request: Request) { + return parseConfig(request, envBindings()); +} + +function configOrUnauthorized(request: Request): HonchoConfig | Response { + try { + return configForRequest(request); + } catch (e) { + const message = e instanceof Error ? e.message : "Invalid request"; + return unauthorized(request, message); + } +} + +function unauthorized(request: Request, message: string): Response { + const resourceMetadata = `${new URL(request.url).origin}${PROTECTED_RESOURCE_PATH}`; + return jsonResponse( + { error: message }, + 401, + { + "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadata}"`, + }, + ); +} + +async function handleMcp(request: Request): Promise { + sweepSessions(); + const sessionId = request.headers.get("mcp-session-id"); + if (sessionId) { + const existing = sessions.get(sessionId); + if (existing) { + const config = configOrUnauthorized(request); + if (config instanceof Response) return config; + if (config.apiKey !== existing.apiKey) { + return unauthorized( + request, + "Authorization does not match this session.", + ); + } + existing.lastSeen = Date.now(); + return withCors(await existing.transport.handleRequest(request)); + } + } + + if (request.method !== "POST") { + return jsonResponse( + { + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: No valid session ID provided", + }, + id: null, + }, + 400, + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonResponse( + { + jsonrpc: "2.0", + error: { code: -32700, message: "Parse error: Invalid JSON" }, + id: null, + }, + 400, + ); + } + + const messages = Array.isArray(body) ? body : [body]; + if (!messages.some((message) => isInitializeRequest(message))) { + return jsonResponse( + { + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: No valid session ID provided", + }, + id: null, + }, + 400, + ); + } + + const config = configOrUnauthorized(request); + if (config instanceof Response) return config; + + const server = createServer({ + config, + clientFor: createClientFactory(config), + unscoped: createUnscopedClient(config), + }); + + const maxSessions = envInt("MCP_SESSION_MAX", DEFAULT_SESSION_MAX); + if (sessions.size >= maxSessions) { + return jsonResponse( + { + jsonrpc: "2.0", + error: { + code: -32000, + message: "Too many active sessions", + }, + id: null, + }, + 503, + ); + } + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + onsessioninitialized: (id) => { + sessions.set(id, { + transport, + server, + lastSeen: Date.now(), + apiKey: config.apiKey, + }); + }, + }); + transport.onclose = () => { + const id = transport.sessionId; + if (id) sessions.delete(id); + }; + + await server.connect(transport); + return withCors( + await transport.handleRequest(request, { parsedBody: body }), + ); +} + +export async function fetch(request: Request): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + const pathname = new URL(request.url).pathname; + + if (pathname === "/health") { + return jsonResponse({ status: "ok" }, 200); + } + + if (pathname === PROTECTED_RESOURCE_PATH) { + return jsonResponse( + { + resource: new URL(request.url).origin, + authorization_servers: [authorizationServer()], + bearer_methods_supported: ["header"], + scopes_supported: ["read", "write"], + }, + 200, + ); + } + + if (!MCP_PATHS.has(pathname)) { + return jsonResponse({ error: "Not Found" }, 404); + } + + try { + return await handleMcp(request); + } catch (e) { + const message = + e instanceof Error ? e.message : "Internal server error"; + return jsonResponse({ error: message }, 500); + } +} + +const isMain = Boolean((import.meta as { main?: boolean }).main); +if (isMain) { + const hostname = process.env.HOST?.trim() || "0.0.0.0"; + const port = Number(process.env.PORT) || 3000; + Bun.serve({ hostname, port, fetch }); + console.error(`honcho-mcp listening on http://${hostname}:${port}`); +} diff --git a/mcp/src/stdio.ts b/mcp/src/stdio.ts new file mode 100644 index 00000000..3a351651 --- /dev/null +++ b/mcp/src/stdio.ts @@ -0,0 +1,30 @@ +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + createClientFactory, + createUnscopedClient, + parseEnvConfig, +} from "./config.js"; +import { createServer } from "./server.js"; + +declare const process: { + env: Record; + exit(code?: number): never; +}; + +try { + const config = parseEnvConfig({ + HONCHO_API_KEY: process.env.HONCHO_API_KEY, + HONCHO_API_URL: process.env.HONCHO_API_URL, + HONCHO_WORKSPACE_ID: process.env.HONCHO_WORKSPACE_ID, + }); + const server = createServer({ + config, + clientFor: createClientFactory(config), + unscoped: createUnscopedClient(config), + }); + await server.connect(new StdioServerTransport()); +} catch (e) { + const message = e instanceof Error ? e.message : String(e); + console.error(message); + process.exit(1); +} diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json index 8ca7bfae..8bebbfa9 100644 --- a/mcp/tsconfig.json +++ b/mcp/tsconfig.json @@ -10,5 +10,5 @@ "types": ["@cloudflare/workers-types"] }, "include": ["src/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "src/**/*.test.ts"] } From 7cf865c9699f2e9e4abfa93185812ab05fb09f14 Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Thu, 3 Sep 2026 13:52:03 -0400 Subject: [PATCH 46/50] docs(contributing): update CONTRIBUTING.md with PR response time Added a note about responding to PRs within 7 days. --- CONTRIBUTING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b3e00a27..5964f9ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -335,6 +335,9 @@ either route works — but a bare `#123` mention is only a reference and does no If a PR goes quiet, nudge us in [Discord](https://discord.gg/honcho). +Please respond within 7 days - we may close any PRs that have seen no activity within a 7 day +window. If you need more time, let us know in the PR comments. + ## Reporting bugs and requesting features Use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). There is From afbc517cbc9352a924509b0da0c980ad124960e2 Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Thu, 3 Sep 2026 14:20:47 -0400 Subject: [PATCH 47/50] feat(mock-provider): deterministic OpenAI-compatible endpoint for local and CI use (#1094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mock-provider): deterministic OpenAI-compatible endpoint for local and CI use Adds src/mock_provider/, a standalone ASGI app that lets Honcho run with no model provider, no API key, and no spend. It answers /v1/chat/completions and /v1/embeddings with obviously-synthetic content derived from the request, so the same request always produces the same response. It runs as its own service from the standard Honcho image with a different entrypoint, the way api and deriver already differ, so there is no second image to build or keep in digest-sync. The app imports nothing from src.config or src.db, so it boots even when the rest of the stack is misconfigured. The chat endpoint generates from the JSON Schema it is sent rather than answering with prose. That matters because a prose answer does not fail loudly: repair_response_model_json swallows the parse error and returns an empty PromptRepresentation, which reads as "the deriver found nothing" rather than "the mock is wrong". Generation resolves $ref/$defs indirection, caps recursion for reasoning-tree schemas, and covers json_object mode by recovering the schema Honcho injects into the prompt. Embeddings are hash-derived, so identical input yields an identical vector. Tests drive the production OpenAIBackend and _EmbeddingClient against the app over ASGI, including the strict json_schema transform that chat.completions.parse() applies. Verified end to end against a real stack: messages in, conclusions and 1536-dim embeddings written to pgvector, with no calls to any real provider. Mock embeddings carry no semantic similarity, so recall against this provider must use lexical search. CONTRIBUTING notes that, and the load_dotenv(override= True) behaviour that lets a stale repo .env win over exported environment variables. Co-Authored-By: Claude Opus 5 (1M context) * refactor(mock-provider): validate requests with Pydantic models Review feedback: hand-coercing the request bodies was defended on the grounds that FastAPI answers a malformed body with a 422, and a 422 mid-deriver-run reads as a Honcho bug. That argues against the default handler, not against the models. Registering an exception handler fixes it — and the resulting behaviour is more faithful, not less, because the real API answers a bad request with a 400 and an `error` envelope, which is now exactly what the mock returns. Adds src/mock_provider/schemas.py with ChatCompletionRequest and EmbeddingsRequest. Every model allows extra fields and every field is optional, so validation fires on a wrong type rather than on a parameter the mock has not heard of — a new upstream parameter must not turn a working setup into a hard failure. dimensions is a StrictInt because bool is an int subclass and a JSON `true` would otherwise mean a one-dimensional vector. coerce.py stays, narrowed to serving schema_gen, which walks arbitrary caller-supplied JSON Schema and is untyped by nature. response_format likewise stays dict[str, Any]: only its envelope is worth typing. Also records why schema_gen does not reuse src/utils/schema_conversion.py despite the overlapping $ref/$defs handling — it builds a model class rather than an instance, raises by contract where a mock must degrade, and rejects both allOf and the recursive $ref that reasoning-tree schemas rely on. Documents that LLM_OPENAI_API_KEY is only tested for truthiness; the previous wording read as though the value had to be the literal string "sandbox". Re-verified end to end after the refactor: 6 messages in, 4 conclusions and 6 1536-dim embeddings out, every real request answered 200, no calls to any real provider. Co-Authored-By: Claude Opus 5 (1M context) * fix(mock-provider): honour include_usage, generate prefixItems tuples Three fidelity gaps where the mock answered a request differently from the API it stands in for: - The usage chunk was emitted on every stream. The real API sends it only when stream_options.include_usage is set, so a caller that did not opt in had to skip a trailing chunk with an empty choices array. stream_options is now a typed model, which also rejects a non-boolean include_usage instead of reading it as truthy. - A fixed-length tuple is prefixItems with no items, which is what Pydantic emits for tuple[str, int]. Reading only items returned [], failing the minItems the same schema carries — the silent-empty failure schema_gen exists to avoid. - A zero or negative dimensions was silently replaced with 1536, answering a bad request with a plausible-looking vector rather than a 400. Three further deviations from JSON Schema are left in place and documented where they occur: allOf merges properties first-wins, oneOf is treated as anyOf, and string pattern is ignored. None is reachable from a Honcho response model — no model emits prefixItems or oneOf, and the only pattern constraints are on API request models — and each fix costs more than the unreachable path is worth. Co-Authored-By: Claude Opus 5 (1M context) * fix(mock-provider): strict request booleans, bounded recursion, multipleOf Second CodeRabbit pass. All four findings reproduced first; none is reachable from a Honcho response model, but two trace back to the previous commit. - `include_usage` and `stream` were plain `bool`, which Pydantic coerces from "yes"/"on"/"true"/"1". The comment added last commit claimed a string had to fail here, and it did not — the test only passed because "definitely" is not a recognised bool literal. Both are StrictBool now, matching why `dimensions` is StrictInt, and the tests cover the truthy strings that actually coerced. - `_generate_array` returned the prefix alone when `items` was absent, so prefixItems plus a larger minItems undershot its own schema. Absent `items` leaves those positions unconstrained rather than disallowed, so the shortfall is filled to minItems — a bare `{"type": "array"}` still generates nothing. - A required, non-nullable recursive $ref hit RecursionError: MAX_DEPTH only terminates a cycle that offers a `default` or a nullable branch, and `_generate_object` keeps descending into required properties. HARD_MAX_DEPTH degrades to an empty container instead, since a mock must not turn its own defect into a 500. Bounded, not plumbed into an error response — the unreachable path does not justify touching the request path. - `_bounded_int` ignored `multipleOf` while honouring minimum, maximum and both exclusive bounds; 9 of 12 sampled paths produced a non-multiple. Values now snap onto a multiple inside the bounds, and an unsatisfiable window keeps the bounds. A fractional `multipleOf` is still ignored, as documented. Co-Authored-By: Claude Opus 5 (1M context) * docs(mock-provider): correct the reason fractional multipleOf is dropped The docstring claimed honouring it would mean returning a non-integer from an integer schema. That is wrong: 3 is an integer and a multiple of 1.5. The real reason is that it needs exact-decimal arithmetic to keep float drift from deciding validity, and no Honcho response model emits multipleOf at all. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- CONTRIBUTING.md | 39 + src/mock_provider/__init__.py | 1 + src/mock_provider/chat.py | 214 ++++++ src/mock_provider/coerce.py | 34 + src/mock_provider/embeddings.py | 97 +++ src/mock_provider/main.py | 93 +++ src/mock_provider/schema_gen.py | 373 ++++++++++ src/mock_provider/schemas.py | 71 ++ tests/conftest.py | 3 + tests/mock_provider/test_honcho_contract.py | 221 ++++++ tests/mock_provider/test_mock_provider.py | 742 ++++++++++++++++++++ 11 files changed, 1888 insertions(+) create mode 100644 src/mock_provider/__init__.py create mode 100644 src/mock_provider/chat.py create mode 100644 src/mock_provider/coerce.py create mode 100644 src/mock_provider/embeddings.py create mode 100644 src/mock_provider/main.py create mode 100644 src/mock_provider/schema_gen.py create mode 100644 src/mock_provider/schemas.py create mode 100644 tests/mock_provider/test_honcho_contract.py create mode 100644 tests/mock_provider/test_mock_provider.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5964f9ad..aa48b315 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -219,6 +219,45 @@ uv run python -m src.deriver # background worker Everything Python goes through `uv run`. Redis is optional for local development; without it caching is simply disabled. +### Running without a model provider + +`src/mock_provider/` is a deterministic, OpenAI-compatible endpoint, so you can run the full +stack with no provider account, no API key, and no spend. It answers `/v1/chat/completions` +and `/v1/embeddings` with obviously-synthetic content derived from the request, and the same +request always produces the same response. Run it from the standard image or the repo: + +```bash +uv run fastapi run --host 0.0.0.0 --port 8106 src/mock_provider/main.py +``` + +Then point Honcho at it. All three variables are required: + +```bash +export LLM_OPENAI_API_KEY=any-non-empty-string # only truthiness is checked +export LLM_OPENAI_BASE_URL=http://localhost:8106/v1 +export EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=http://localhost:8106/v1 +``` + +The key's *value* is never checked — the mock reads no Authorization header, and Honcho only +tests it for truthiness before building the client (`src/llm/registry.py`). Set the base URL +without it and the client is never constructed, so the base URL is silently ignored. Keep the +value obviously fake, so a module that ever escapes the override 401s rather than spends. + +Embeddings resolve through a separate client that reads the base URL only from the per-module +override, so without the third variable your embedding calls go to `api.openai.com` for real. +Do not set any per-module credential override (`..._OVERRIDES__API_KEY` / `API_KEY_ENV`) — +that makes the module ignore the global base URL. + +Two things to know: + +- **A repo `.env` beats your exported environment.** `src/config.py` calls + `load_dotenv(override=True)` at import, so a stale `.env` silently wins over the variables + above. Set `PYTHON_DOTENV_DISABLED=1` (and `HONCHO_CONFIG_TOML_DISABLED=1` for a local + `config.toml`) when you need the environment to be the only input. +- **Mock embeddings are hash-derived and carry no semantic similarity.** Two paraphrases are as + far apart as two unrelated strings. Recall against this provider must use lexical/full-text + search; anything asserting on vector ranking needs a real embedding provider. + ## Making the change ### Branches and commits diff --git a/src/mock_provider/__init__.py b/src/mock_provider/__init__.py new file mode 100644 index 00000000..3a085944 --- /dev/null +++ b/src/mock_provider/__init__.py @@ -0,0 +1 @@ +"""Deterministic OpenAI-compatible provider for local and CI use.""" diff --git a/src/mock_provider/chat.py b/src/mock_provider/chat.py new file mode 100644 index 00000000..8c982473 --- /dev/null +++ b/src/mock_provider/chat.py @@ -0,0 +1,214 @@ +"""OpenAI-compatible ``/chat/completions``, answered without inference.""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import AsyncIterator +from typing import Any + +from fastapi import APIRouter +from fastapi.responses import StreamingResponse + +from src.mock_provider.coerce import as_dict, as_str +from src.mock_provider.schema_gen import generate +from src.mock_provider.schemas import ChatCompletionRequest, ChatMessage + +router = APIRouter(tags=["mock-provider"]) + +# Honcho's json_object mode injects the schema into the prompt text rather than +# into response_format (see _apply_json_object_mode in the OpenAI backend), so +# the only machine-readable copy of the schema is inside a message. +_SCHEMA_HINT = re.compile(r"schema:\s*(\{)", re.IGNORECASE) + + +def _completion_id(body: ChatCompletionRequest) -> str: + """Stable id, so a replayed request is byte-identical.""" + digest = hashlib.sha256( + body.model_dump_json(exclude_none=True).encode() + ).hexdigest() + return f"chatcmpl-mock-{digest[:24]}" + + +def _extract_balanced_json(text: str, start: int) -> dict[str, Any] | None: + """Read one balanced ``{...}`` beginning at ``start`` and parse it. + + A plain regex cannot do this — a JSON Schema contains nested objects, and + braces inside string literals must not count toward the depth. + """ + depth = 0 + in_string = False + escaped = False + for index in range(start, len(text)): + char = text[index] + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + try: + parsed = json.loads(text[start : index + 1]) + except json.JSONDecodeError: + return None + return as_dict(parsed) + return None + + +def _schema_from_messages(messages: list[ChatMessage]) -> dict[str, Any] | None: + """Recover an injected schema from the prompt, for json_object mode.""" + for message in reversed(messages): + content = as_str(message.content) + if content is None: + continue + for match in _SCHEMA_HINT.finditer(content): + candidate = _extract_balanced_json(content, match.start(1)) + if candidate and ("properties" in candidate or "$defs" in candidate): + return candidate + return None + + +def _response_content(body: ChatCompletionRequest) -> str: + """The assistant message body: schema-conforming JSON, or prose.""" + response_format = body.response_format + + if response_format is not None: + kind = as_str(response_format.get("type")) + if kind == "json_schema": + wrapper = as_dict(response_format.get("json_schema")) + if wrapper is not None: + schema = as_dict(wrapper.get("schema")) + if schema is not None: + return json.dumps(generate(schema)) + # A json_schema request whose schema we cannot read must not fall + # through to prose — that is the silent-empty failure this mock + # exists to avoid. An empty object at least parses. + return "{}" + if kind == "json_object": + schema = _schema_from_messages(body.messages) + return json.dumps(generate(schema)) if schema else "{}" + + return ( + "[mock] This is a synthetic response from Honcho's mock provider. " + "No model was called." + ) + + +def _usage(body: ChatCompletionRequest, content: str) -> dict[str, int]: + """Rough token accounting, so cost telemetry has plausible numbers.""" + prompt_chars = 0 + for message in body.messages: + text = as_str(message.content) + if text is not None: + prompt_chars += len(text) + prompt_tokens = max(1, prompt_chars // 4) + completion_tokens = max(1, len(content) // 4) + return { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + } + + +def _created() -> int: + # Fixed rather than time-based: a mock that changes its output between + # identical calls defeats the point. + return 1577836800 # 2020-01-01T00:00:00Z + + +async def _stream( + completion_id: str, model: str, content: str, usage: dict[str, int] | None +) -> AsyncIterator[bytes]: + """Stream ``content``, ending on a usage chunk when ``usage`` is given.""" + + def chunk(payload: dict[str, Any]) -> bytes: + return f"data: {json.dumps(payload)}\n\n".encode() + + base = { + "id": completion_id, + "object": "chat.completion.chunk", + "created": _created(), + "model": model, + } + yield chunk( + { + **base, + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": ""}, + "finish_reason": None, + } + ], + } + ) + yield chunk( + { + **base, + "choices": [ + {"index": 0, "delta": {"content": content}, "finish_reason": None} + ], + } + ) + yield chunk( + { + **base, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + ) + # The usage chunk is conditional: the real API emits it only when + # stream_options.include_usage is set, and ends the stream on it — so it + # must come last and must carry choices: []. Honcho's own backend always + # asks for it (_build_params in the OpenAI backend), but a caller that does + # not must not receive a chunk it never requested. + if usage is not None: + yield chunk({**base, "choices": [], "usage": usage}) + yield b"data: [DONE]\n\n" + + +@router.post("/chat/completions") +async def chat_completions(body: ChatCompletionRequest) -> Any: + model = body.model or "mock-model" + content = _response_content(body) + usage = _usage(body, content) + completion_id = _completion_id(body) + + if body.stream: + include_usage = ( + body.stream_options is not None and body.stream_options.include_usage + ) + return StreamingResponse( + _stream(completion_id, model, content, usage if include_usage else None), + media_type="text/event-stream", + ) + + return { + "id": completion_id, + "object": "chat.completion", + "created": _created(), + "model": model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": content, + "refusal": None, + "tool_calls": None, + }, + "logprobs": None, + "finish_reason": "stop", + } + ], + "usage": usage, + } diff --git a/src/mock_provider/coerce.py b/src/mock_provider/coerce.py new file mode 100644 index 00000000..6b5b4cac --- /dev/null +++ b/src/mock_provider/coerce.py @@ -0,0 +1,34 @@ +"""Typed narrowing for values decoded from JSON. + +``isinstance(value, dict)`` on an ``Any`` narrows to ``dict[Unknown, Unknown]``, +which spreads unknown types through everything downstream. These helpers narrow +and pin the element types in one step. +""" + +from __future__ import annotations + +from typing import Any, cast + + +def as_dict(value: object) -> dict[str, Any] | None: + """The value as a JSON object, or None if it is not one.""" + return cast("dict[str, Any]", value) if isinstance(value, dict) else None + + +def as_list(value: object) -> list[Any] | None: + """The value as a JSON array, or None if it is not one.""" + return cast("list[Any]", value) if isinstance(value, list) else None + + +def as_str(value: object) -> str | None: + """The value as a JSON string, or None if it is not one.""" + return value if isinstance(value, str) else None + + +def as_int(value: object) -> int | None: + """The value as a JSON integer, or None if it is not one. + + ``bool`` is excluded: it is an ``int`` subclass, and a JSON ``true`` reaching + a size or dimension field is a malformed request, not the number one. + """ + return value if isinstance(value, int) and not isinstance(value, bool) else None diff --git a/src/mock_provider/embeddings.py b/src/mock_provider/embeddings.py new file mode 100644 index 00000000..8b9ee5eb --- /dev/null +++ b/src/mock_provider/embeddings.py @@ -0,0 +1,97 @@ +"""OpenAI-compatible ``/embeddings``, answered from a content hash.""" + +from __future__ import annotations + +import base64 +import hashlib +import struct +from typing import Any + +from fastapi import APIRouter + +from src.mock_provider.schemas import EmbeddingsRequest + +router = APIRouter(tags=["mock-provider"]) + +# Honcho's default. EmbeddingClient._validate_embedding_dimensions raises when a +# vector comes back at the wrong width, and validate_embedding_schema refuses to +# boot when the width disagrees with the pgvector column, so the request's own +# `dimensions` is honoured whenever it is present. +DEFAULT_DIMENSIONS = 1536 + + +def content_to_embedding(content: str, dimensions: int) -> list[float]: + """A deterministic vector for ``content``. + + Identical input yields an identical vector, and different inputs differ — + which is what deduplication logic needs. It carries no semantic similarity: + two paraphrases are as far apart as two unrelated strings. Anything + asserting on ranking quality must not use this provider. + + Mirrors ``_content_to_embedding`` in tests/conftest.py. + """ + digest = hashlib.sha256(content.encode()).digest() + return [(digest[i % len(digest)] / 255.0) * 2 - 1 for i in range(dimensions)] + + +def _encode_base64(vector: list[float]) -> str: + """Little-endian float32, which is what the OpenAI SDK decodes.""" + return base64.b64encode(struct.pack(f"<{len(vector)}f", *vector)).decode() + + +def _normalize_input( + raw: str | list[str] | list[int] | list[list[int]] | None, +) -> list[str]: + """Flatten the request input into one string per embedding to return. + + Token-array inputs are rendered back to a stable string rather than + rejected — the vector only has to be deterministic, not meaningful. + """ + if raw is None: + return [] + if isinstance(raw, str): + return [raw] + # A flat list of ints is one tokenized input, not many single-token ones. + if raw and all(isinstance(item, int) for item in raw): + return [",".join(str(item) for item in raw)] + + texts: list[str] = [] + for item in raw: + if isinstance(item, str): + texts.append(item) + elif isinstance(item, list): + texts.append(",".join(str(part) for part in item)) + else: + texts.append(str(item)) + return texts + + +@router.post("/embeddings") +async def embeddings(body: EmbeddingsRequest) -> Any: + texts = _normalize_input(body.input) + # A non-positive width is rejected by the request model, so absent is the + # only case left to fill in. + dimensions = body.dimensions if body.dimensions is not None else DEFAULT_DIMENSIONS + + data: list[dict[str, Any]] = [] + for index, text in enumerate(texts): + vector = content_to_embedding(text, dimensions) + data.append( + { + "object": "embedding", + "index": index, + "embedding": ( + vector + if body.encoding_format == "float" + else _encode_base64(vector) + ), + } + ) + + prompt_tokens = max(1, sum(len(text) for text in texts) // 4) + return { + "object": "list", + "data": data, + "model": body.model or "mock-embedding", + "usage": {"prompt_tokens": prompt_tokens, "total_tokens": prompt_tokens}, + } diff --git a/src/mock_provider/main.py b/src/mock_provider/main.py new file mode 100644 index 00000000..b14dcc34 --- /dev/null +++ b/src/mock_provider/main.py @@ -0,0 +1,93 @@ +"""A deterministic, OpenAI-compatible provider for local and CI use. + +Lets Honcho run with no model provider, no API key, and no spend. It answers +``/v1/chat/completions`` and ``/v1/embeddings`` with obviously-synthetic content +derived from the request, so the same request always produces the same response. + +Runs as its own service from the standard Honcho image: + + fastapi run --host 0.0.0.0 src/mock_provider/main.py + +Point Honcho at it with three variables — all three are required: + + LLM_OPENAI_API_KEY=any-non-empty-string # only truthiness is checked; value ignored + LLM_OPENAI_BASE_URL=http://mock-provider:8000/v1 + EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=http://mock-provider:8000/v1 + +The key's *value* is never checked — not by this mock, which reads no +Authorization header, and not by Honcho, which only tests it for truthiness +before constructing the client (``src/llm/registry.py``). Set the base URL +without it and the client is never built, so the base URL is silently ignored. +Keep the value obviously fake: if a module ever escapes the base-URL override it +then 401s against the real provider instead of spending. + +Embeddings resolve through a separate client that reads the base URL only from +the per-module override, so without the third variable embedding calls go to +api.openai.com for real. Do not set any per-module credential override +(``..._OVERRIDES__API_KEY`` / ``API_KEY_ENV``) — that makes the module ignore the +global base URL. + +Embeddings are hash-derived and carry no semantic similarity. Recall assertions +against this provider must use lexical/full-text search, not vector ranking. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from src.mock_provider import chat, embeddings + +app = FastAPI( + title="Honcho Mock Provider", + description="Deterministic OpenAI-compatible endpoint for local and CI use.", + version="1.0.0", +) + + +@app.exception_handler(RequestValidationError) +async def openai_error_response( + _request: Request, exc: RequestValidationError +) -> JSONResponse: + """Answer a malformed request the way the real API does. + + FastAPI's default is a 422 carrying its own error shape. Mid-run that reads + as a Honcho bug rather than a bad request, and it is not what an OpenAI + client expects — the real API returns 400 with an ``error`` envelope, so + that is what a faithful mock returns. + """ + return JSONResponse( + status_code=400, + content={ + "error": { + "message": f"Invalid request: {exc.errors()}", + "type": "invalid_request_error", + "param": None, + "code": None, + } + }, + ) + + +# Mounted at both prefixes so the base URL works with or without /v1. +for _router in (chat.router, embeddings.router): + app.include_router(_router, prefix="/v1") + app.include_router(_router) + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok", "provider": "mock"} + + +@app.get("/{path:path}") +async def catch_all(path: str) -> dict[str, Any]: + """Answer any other GET, so a bare ``/`` works as a container healthcheck. + + Deliberately GET-only: an unimplemented POST returns 405 rather than a + plausible-looking 200, so a missing endpoint fails loudly. + """ + return {"object": "mock", "path": path, "detail": "mock provider placeholder"} diff --git a/src/mock_provider/schema_gen.py b/src/mock_provider/schema_gen.py new file mode 100644 index 00000000..5a6339c0 --- /dev/null +++ b/src/mock_provider/schema_gen.py @@ -0,0 +1,373 @@ +"""Generate a conforming instance from a JSON Schema. + +The deriver is a structured-output caller: it sends a schema and parses the +reply back into a Pydantic model. A mock that answers with prose does not fail +loudly — ``repair_response_model_json`` swallows the error and hands back an +empty ``PromptRepresentation``, which reads as "the deriver found nothing" +rather than "the mock is wrong". So generation is driven by the schema that was +actually sent, ``$ref`` indirection and all. + +Values are derived from a hash of the property path, so the same schema always +produces the same instance and two different fields never collide. + +Not reused from ``src/utils/schema_conversion.py``, despite the overlapping +``$ref``/``$defs`` handling, because that module answers a different question and +does so under an incompatible contract. It builds a Pydantic *model class* where +this needs an *instance*; it raises by design (conversion doubles as validation, +surfaced to callers as a 422) where a mock must degrade rather than turn its own +defect into a 500; and it rejects both ``allOf`` and recursive ``$ref`` — the +latter being ordinary input here, since reasoning-tree schemas nest premises +inside conclusions. +""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from src.mock_provider.coerce import as_dict, as_int, as_list, as_str + +# Depth cap for self-referential schemas. Reasoning-tree models nest premises +# inside conclusions, so a $ref cycle is normal input, not a malformed schema. +MAX_DEPTH = 6 + +# Absolute cap. Past MAX_DEPTH a cycle is expected to terminate on a `default` +# or a nullable/optional branch; a required, non-nullable self-reference has +# neither and would recurse until Python raises RecursionError. Degrading to an +# empty container may violate the schema, but a mock must not turn its own +# defect into a 500. Set well clear of MAX_DEPTH so no schema that terminates +# on its own ever reaches it. +HARD_MAX_DEPTH = MAX_DEPTH * 4 + +_WORDS = ( + "synthetic", + "placeholder", + "mock", + "sample", + "fixture", + "stub", + "generated", + "example", + "inert", + "dummy", +) + + +def _seed(path: str) -> int: + return int.from_bytes(hashlib.sha256(path.encode()).digest()[:8], "big") + + +def _phrase(path: str, words: int = 6) -> str: + """An obviously-synthetic sentence, stable for a given path.""" + seed = _seed(path) + picked = [_WORDS[(seed >> (i * 5)) % len(_WORDS)] for i in range(words)] + return f"[mock] {' '.join(picked)}" + + +def _resolve(schema: dict[str, Any], root: dict[str, Any]) -> dict[str, Any]: + """Follow a local ``$ref`` chain to the schema it points at. + + Only local refs are supported: the mock never fetches over the network, and + Pydantic's ``model_json_schema()`` only ever emits ``#/$defs/...``. + """ + seen: set[str] = set() + current = schema + while "$ref" in current: + ref = as_str(current["$ref"]) + if ref is None or not ref.startswith("#/") or ref in seen: + return {} + seen.add(ref) + + target: dict[str, Any] | None = root + for part in ref[2:].split("/"): + if target is None or part not in target: + return {} + target = as_dict(target[part]) + if target is None: + return {} + current = target + return current + + +def _merge_all_of(schema: dict[str, Any], root: dict[str, Any]) -> dict[str, Any]: + """Flatten ``allOf`` into the parent so one pass can read properties off it.""" + branches = as_list(schema.get("allOf")) + if branches is None: + return schema + + merged: dict[str, Any] = {k: v for k, v in schema.items() if k != "allOf"} + for branch in branches: + resolved_branch = as_dict(branch) + if resolved_branch is None: + continue + resolved = _resolve(resolved_branch, root) + for key, value in resolved.items(): + if key == "properties": + properties = as_dict(value) + if properties is not None: + # First branch to define a property wins. Strictly, `allOf` + # requires every branch's constraints to apply, so a schema + # splitting `minimum` and `maximum` for one property across + # two branches generates a value satisfying only one of + # them. Not merged recursively because Pydantic's `allOf` is + # always a $ref plus sibling annotations — it never repeats + # a property key, let alone with conflicting constraints. + existing = as_dict(merged.get("properties")) or {} + merged["properties"] = {**properties, **existing} + continue + if key == "required": + required = as_list(value) + if required is not None: + previous = as_list(merged.get("required")) or [] + merged["required"] = list({*previous, *required}) + continue + merged.setdefault(key, value) + return merged + + +def _infer_type(schema: dict[str, Any]) -> str: + """Best-effort type when the schema omits an explicit ``type``.""" + declared = schema.get("type") + if (name := as_str(declared)) is not None: + return name + if (names := as_list(declared)) is not None: + # Nullable unions arrive as ["string", "null"]; prefer the real type. + for candidate in names: + if (candidate_name := as_str(candidate)) and candidate_name != "null": + return candidate_name + return "null" + if "properties" in schema: + return "object" + if "items" in schema: + return "array" + return "string" + + +def generate(schema: dict[str, Any], root: dict[str, Any] | None = None) -> Any: + """Build a value satisfying ``schema``. + + ``root`` carries the document that ``$ref`` resolves against; it defaults to + ``schema`` itself, which is the shape Pydantic emits. + """ + return _generate(schema, root if root is not None else schema, "$", 0) + + +def _generate( + schema: dict[str, Any], root: dict[str, Any], path: str, depth: int +) -> Any: + resolved = _merge_all_of(_resolve(schema, root), root) + + if "const" in resolved: + return resolved["const"] + + enum = as_list(resolved.get("enum")) + if enum: + return enum[_seed(path) % len(enum)] + + if depth >= MAX_DEPTH and "default" in resolved: + return resolved["default"] + + # `oneOf` is treated as `anyOf`: a branch is picked without checking that + # the result matches only that one. A `oneOf` whose branches overlap can + # therefore yield a value matching several, which `oneOf` forbids. Enforcing + # the cardinality needs a full JSON Schema validator to test the candidate + # against every branch, and Pydantic emits `anyOf` for unions — never + # `oneOf` — so nothing Honcho sends reaches the distinction. + for key in ("anyOf", "oneOf"): + branches = as_list(resolved.get(key)) + if branches: + return _generate(_pick_branch(branches, root, depth), root, path, depth) + + kind = _infer_type(resolved) + # Only the two recursive kinds need the absolute cap; scalars terminate. + if kind == "object": + if depth >= HARD_MAX_DEPTH: + return {} + return _generate_object(resolved, root, path, depth) + if kind == "array": + if depth >= HARD_MAX_DEPTH: + return [] + return _generate_array(resolved, root, path, depth) + if kind == "integer": + return _bounded_int(resolved, path) + if kind == "number": + return float(_bounded_int(resolved, path)) + if kind == "boolean": + return _seed(path) % 2 == 0 + if kind == "null": + return None + return _generate_string(resolved, path) + + +def _pick_branch( + branches: list[Any], root: dict[str, Any], depth: int +) -> dict[str, Any]: + """Choose a union member, preferring a non-null one. + + Past the depth cap the order flips: a nullable recursive field terminates on + ``null`` instead of nesting another level. + """ + resolved: list[dict[str, Any]] = [] + for branch in branches: + branch_dict = as_dict(branch) + if branch_dict is not None: + resolved.append(_resolve(branch_dict, root)) + if not resolved: + return {} + + if depth >= MAX_DEPTH: + nulls = [b for b in resolved if _infer_type(b) == "null"] + if nulls: + return nulls[0] + non_null = [b for b in resolved if _infer_type(b) != "null"] + return non_null[0] if non_null else resolved[0] + + +def _generate_object( + schema: dict[str, Any], root: dict[str, Any], path: str, depth: int +) -> dict[str, Any]: + properties = as_dict(schema.get("properties")) + if properties is None: + return {} + + # OpenAI structured outputs run in strict mode, where every property is + # required. Emitting the full property set satisfies both strict and loose + # schemas, so `required` is only consulted to decide what to drop once the + # depth cap has been hit. + declared_required = as_list(schema.get("required")) + required: set[str] = ( + {name for name in (as_str(item) for item in declared_required) if name} + if declared_required is not None + else set(properties) + ) + + result: dict[str, Any] = {} + for name, subschema in properties.items(): + if depth >= MAX_DEPTH and name not in required: + continue + child = as_dict(subschema) + if child is None: + continue + result[name] = _generate(child, root, f"{path}.{name}", depth + 1) + return result + + +def _generate_array( + schema: dict[str, Any], root: dict[str, Any], path: str, depth: int +) -> list[Any]: + # A fixed-length tuple is `prefixItems` with no `items`, which is what + # Pydantic emits for `tuple[str, int]`. Reading only `items` would return [] + # for it and fail the minItems/maxItems the same schema carries. + prefix: list[Any] = [] + prefix_items = as_list(schema.get("prefixItems")) + if prefix_items is not None: + for index, entry in enumerate(prefix_items): + child = as_dict(entry) + if child is not None: + prefix.append(_generate(child, root, f"{path}[{index}]", depth + 1)) + + items = as_dict(schema.get("items")) + min_items = as_int(schema.get("minItems")) + max_items = as_int(schema.get("maxItems")) + + count = 2 + if min_items is not None: + count = max(count, min_items) + if max_items is not None: + count = min(count, max_items) + if depth >= MAX_DEPTH: + count = min_items or 0 + # `items` describes the positions after the prefix, so only the shortfall is + # filled. With `items` absent those positions are unconstrained rather than + # disallowed: an empty schema stands in, and the target drops to whatever + # minItems demands, so a bare `{"type": "array"}` still generates nothing. + trailing = items if items is not None else {} + target = count if items is not None else min(count, min_items or 0) + return prefix + [ + _generate(trailing, root, f"{path}[{len(prefix) + i}]", depth + 1) + for i in range(max(0, target - len(prefix))) + ] + + +def _generate_string(schema: dict[str, Any], path: str) -> str: + fmt = as_str(schema.get("format")) + if fmt == "date-time": + return "2020-01-01T00:00:00Z" + if fmt == "date": + return "2020-01-01" + if fmt == "uuid": + stem = hashlib.sha256(path.encode()).hexdigest()[:8] + return f"{stem}-0000-4000-8000-000000000000" + if fmt in ("uri", "url"): + return "https://mock.invalid/placeholder" + if fmt == "email": + return "placeholder@mock.invalid" + + # `pattern` is not honoured: this phrase fails any regex narrower than it, + # so a pattern-constrained string generates a value its own schema rejects. + # Satisfying an arbitrary regex needs a generator library, and no Honcho + # response model carries a `pattern` — the only ones in the codebase are on + # API request models, which are never sent as a response_format. + value = _phrase(path) + min_length = as_int(schema.get("minLength")) + max_length = as_int(schema.get("maxLength")) + if min_length is not None and len(value) < min_length: + value = value.ljust(min_length, "x") + if max_length is not None and len(value) > max_length: + value = value[:max_length] + return value + + +def _bounded_int(schema: dict[str, Any], path: str) -> int: + low = as_int(schema.get("minimum")) + if ( + low is None + and (exclusive := as_int(schema.get("exclusiveMinimum"))) is not None + ): + low = exclusive + 1 + high = as_int(schema.get("maximum")) + if ( + high is None + and (exclusive := as_int(schema.get("exclusiveMaximum"))) is not None + ): + high = exclusive - 1 + + if low is not None and high is not None: + span = high - low + value = low + (_seed(path) % (span + 1) if span > 0 else 0) + elif low is not None: + value = low + (_seed(path) % 8) + elif high is not None: + value = high - (_seed(path) % 8) + else: + value = _seed(path) % 100 + + return _snap_to_multiple(value, as_int(schema.get("multipleOf")), low, high) + + +def _snap_to_multiple( + value: int, multiple: int | None, low: int | None, high: int | None +) -> int: + """Move ``value`` onto a multiple of ``multiple``, staying within bounds. + + Integer ``multipleOf`` only. The spec allows a fractional one, and an + integer can satisfy it (3 is a multiple of 1.5), but honouring it needs + exact-decimal arithmetic to avoid float drift deciding validity. ``as_int`` + rejects it, so the constraint is dropped rather than approximated — no + Honcho response model emits ``multipleOf`` at all. + """ + if multiple is None or multiple <= 0: + return value + + # Floor division, so a negative value snaps down to the next multiple below. + snapped = (value // multiple) * multiple + if low is not None and snapped < low: + snapped = -(-low // multiple) * multiple # smallest multiple >= low + if high is not None and snapped > high: + snapped = (high // multiple) * multiple # largest multiple <= high + + # No multiple exists in the window, so the schema is unsatisfiable. An + # in-range value breaks the constraint the caller is less likely to check. + if (low is not None and snapped < low) or (high is not None and snapped > high): + return value + return snapped diff --git a/src/mock_provider/schemas.py b/src/mock_provider/schemas.py new file mode 100644 index 00000000..0135a1e5 --- /dev/null +++ b/src/mock_provider/schemas.py @@ -0,0 +1,71 @@ +"""Request models for the mock provider's OpenAI-compatible endpoints. + +Validating the request envelope rather than hand-coercing it makes the mock +behave like the thing it mocks: real OpenAI answers a malformed request with a +400 and an error envelope, and ``openai_error_response`` in ``main`` turns +Pydantic's failure into exactly that. + +Two deliberate choices: + +- ``extra="allow"`` on every model, and every field optional. Validation should + fire on a wrong *type* (a string where a list belongs), never on a field this + mock has not heard of — otherwise a new upstream parameter turns a working + setup into a hard failure. +- Open-ended payloads stay ``dict[str, Any]``. ``response_format`` carries an + arbitrary caller-supplied JSON Schema, so only its envelope is worth typing; + ``schema_gen`` walks the rest. +""" + +from __future__ import annotations + +from typing import Annotated, Any, ClassVar, Literal + +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt + + +class MockRequest(BaseModel): + """Permissive base: unknown fields pass through untouched.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow") + + +class ChatMessage(MockRequest): + role: str | None = None + # Multimodal requests send a list of content parts rather than a string, so + # this cannot narrow further. + content: Any = None + + +class StreamOptions(MockRequest): + # Typed rather than left as a dict because the usage chunk is conditional on + # it. StrictBool for the same reason `dimensions` is StrictInt: plain `bool` + # coerces "yes"/"on"/"true"/"1", so a string would quietly decide the shape + # of the stream instead of failing the way the real API does. + include_usage: StrictBool = False + + +class ChatCompletionRequest(MockRequest): + model: str | None = None + messages: list[ChatMessage] = [] + response_format: dict[str, Any] | None = None + tools: list[dict[str, Any]] | None = None + # StrictBool because this one field decides between two response *shapes* — + # a JSON body or an SSE stream — so coercing a string here is the difference + # between a working client and one that hangs waiting for events. + stream: StrictBool = False + stream_options: StreamOptions | None = None + + +class EmbeddingsRequest(MockRequest): + # Every input shape the OpenAI embeddings API accepts. Pydantic's smart + # union keeps list[str] and list[int] apart instead of coercing one to the + # other. + input: str | list[str] | list[int] | list[list[int]] | None = None + model: str | None = None + # StrictInt because bool is an int subclass: a JSON `true` here would + # otherwise silently become a one-dimensional vector. gt=0 because the real + # API rejects a non-positive width, and substituting the default instead + # would answer a bad request with a plausible-looking vector. + dimensions: Annotated[StrictInt, Field(gt=0)] | None = None + # The SDK omits this only when it wants base64, so absent means base64. + encoding_format: Literal["float", "base64"] = "base64" diff --git a/tests/conftest.py b/tests/conftest.py index 090d5395..411f5210 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -91,6 +91,9 @@ _RUNTIME_MOCK_TEST_BLOCKLIST_PREFIXES = ( # Pure JWT scope tests — operate on src.security directly, no DB needed. "tests/test_security.py", "tests/test_generate_jwt_script.py", + # The mock provider is a standalone ASGI app with no database or LLM of its + # own; the runtime mocks would patch the very seams it exists to replace. + "tests/mock_provider/", ) _LIVE_LLM_MARKER = "live_llm" diff --git a/tests/mock_provider/test_honcho_contract.py b/tests/mock_provider/test_honcho_contract.py new file mode 100644 index 00000000..6c362603 --- /dev/null +++ b/tests/mock_provider/test_honcho_contract.py @@ -0,0 +1,221 @@ +"""Drive Honcho's real provider clients against the mock over ASGI. + +The unit tests assert the mock's own output. These assert the hop that actually +matters: ``OpenAIBackend`` and ``EmbeddingClient`` — the production classes, +unpatched — talking to the mock through the genuine OpenAI SDK, including the +``strict: true`` json_schema transform that ``chat.completions.parse()`` applies +on the way out and the Pydantic validation it applies on the way back. +""" + +from __future__ import annotations + +from typing import Any + +import httpx +import pytest +from openai import AsyncOpenAI + +from src.config import EmbeddingModelConfig +from src.embedding_client import _EmbeddingClient # pyright: ignore[reportPrivateUsage] +from src.llm.backends.openai import OpenAIBackend +from src.mock_provider.embeddings import content_to_embedding +from src.mock_provider.main import app +from src.utils.representation import PromptRepresentation + +MESSAGES: list[dict[str, Any]] = [ + {"role": "user", "content": "I switched the service from pip to uv last week."} +] + + +@pytest.fixture +def openai_client() -> AsyncOpenAI: + return AsyncOpenAI( + api_key="sandbox", + base_url="http://mock-provider.invalid/v1", + http_client=httpx.AsyncClient(transport=httpx.ASGITransport(app=app)), + ) + + +@pytest.mark.asyncio +async def test_backend_parses_the_deriver_response_model( + openai_client: AsyncOpenAI, +) -> None: + """The production path: parse() with a Pydantic response_format.""" + backend = OpenAIBackend(openai_client) + + result = await backend.complete( + model="mock-model", + messages=MESSAGES, + max_tokens=512, + response_format=PromptRepresentation, + ) + + assert isinstance(result.content, PromptRepresentation) + # An empty explicit list is what a prose-answering mock silently produces, + # so it is the specific thing worth asserting against. + assert result.content.explicit + assert result.output_tokens > 0 + + +@pytest.mark.asyncio +async def test_backend_json_object_mode_recovers_the_schema( + openai_client: AsyncOpenAI, +) -> None: + """json_object mode carries the schema in the prompt, not response_format.""" + backend = OpenAIBackend(openai_client) + + result = await backend.complete( + model="mock-model", + messages=MESSAGES, + max_tokens=512, + response_format=PromptRepresentation, + extra_params={"structured_output_mode": "json_object"}, + ) + + assert isinstance(result.content, PromptRepresentation) + assert result.content.explicit + + +@pytest.mark.asyncio +async def test_backend_with_tools_uses_json_schema_and_still_parses( + openai_client: AsyncOpenAI, +) -> None: + """Non-strict tools force create() + explicit json_schema instead of parse().""" + backend = OpenAIBackend(openai_client) + + result = await backend.complete( + model="mock-model", + messages=MESSAGES, + max_tokens=512, + response_format=PromptRepresentation, + tools=[ + { + "type": "function", + "function": { + "name": "search_memory", + "description": "Search memory", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + }, + }, + } + ], + ) + + assert isinstance(result.content, PromptRepresentation) + assert result.content.explicit + + +@pytest.mark.asyncio +async def test_backend_plain_completion(openai_client: AsyncOpenAI) -> None: + backend = OpenAIBackend(openai_client) + + result = await backend.complete( + model="mock-model", messages=MESSAGES, max_tokens=128 + ) + + assert isinstance(result.content, str) + assert "[mock]" in result.content + assert result.finish_reason == "stop" + + +@pytest.mark.asyncio +async def test_backend_stream_yields_content_then_a_usage_terminator( + openai_client: AsyncOpenAI, +) -> None: + backend = OpenAIBackend(openai_client) + + chunks = [ + chunk + async for chunk in backend.stream( + model="mock-model", messages=MESSAGES, max_tokens=128 + ) + ] + + assert "[mock]" in "".join(chunk.content or "" for chunk in chunks) + + terminator = chunks[-1] + assert terminator.is_done + assert terminator.finish_reason == "stop" + # None here means the stream ended without a usage chunk, which is the + # failure mode when stream_options.include_usage goes unanswered. + assert terminator.output_tokens is not None + assert terminator.output_tokens > 0 + + +def _embedding_client(dimensions: int, encoding_format: str) -> _EmbeddingClient: + # The public EmbeddingClient is a settings-driven singleton wrapper; the + # transport behaviour under test lives on the implementation it wraps. + return _EmbeddingClient( + EmbeddingModelConfig( + model="text-embedding-3-small", + transport="openai", + api_key="sandbox", + base_url="http://mock-provider.invalid/v1", + ), + vector_dimensions=dimensions, + max_input_tokens=8192, + max_tokens_per_request=300000, + send_dimensions=True, + encoding_format=encoding_format, # pyright: ignore[reportArgumentType] + ) + + +@pytest.fixture(autouse=True) +def _route_embedding_client_over_asgi( # pyright: ignore[reportUnusedFunction] + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Give the embedding client's AsyncOpenAI an ASGI transport. + + EmbeddingClient builds its own client internally, so the transport has to be + injected at construction rather than passed in. + """ + original = AsyncOpenAI.__init__ + + def patched(self: AsyncOpenAI, *args: Any, **kwargs: Any) -> None: + kwargs.setdefault( + "http_client", + httpx.AsyncClient(transport=httpx.ASGITransport(app=app)), + ) + original(self, *args, **kwargs) + + monkeypatch.setattr(AsyncOpenAI, "__init__", patched) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("encoding_format", ["float", "base64"]) +async def test_embedding_client_round_trip(encoding_format: str) -> None: + """Covers both wire encodings; base64 is what the SDK uses by default.""" + client = _embedding_client(1536, encoding_format) + + vector = await client.embed("I switched the service from pip to uv.") + + # _validate_embedding_dimensions raises on a width mismatch, so reaching + # here already proves the width is right; assert the values too. + assert len(vector) == 1536 + assert vector == pytest.approx( # pyright: ignore[reportUnknownMemberType] + content_to_embedding("I switched the service from pip to uv.", 1536), + abs=1e-6, + ) + + +@pytest.mark.asyncio +async def test_embedding_client_honours_a_non_default_dimension() -> None: + """send_dimensions=True forwards `dimensions`; the mock must obey it.""" + client = _embedding_client(256, "float") + + assert len(await client.embed("hello")) == 256 + + +@pytest.mark.asyncio +async def test_embedding_client_batches() -> None: + """_validate_embedding_count rejects a mismatched count.""" + client = _embedding_client(1536, "float") + texts = [f"observation number {index}" for index in range(12)] + + vectors = await client.simple_batch_embed(texts) + + assert len(vectors) == len(texts) + assert all(len(vector) == 1536 for vector in vectors) + assert len({tuple(vector) for vector in vectors}) == len(texts) diff --git a/tests/mock_provider/test_mock_provider.py b/tests/mock_provider/test_mock_provider.py new file mode 100644 index 00000000..ade916a0 --- /dev/null +++ b/tests/mock_provider/test_mock_provider.py @@ -0,0 +1,742 @@ +"""Contract tests for the mock provider. + +The failure this guards against is silent: when the mock answers a structured +request with something the deriver cannot parse, ``repair_response_model_json`` +falls back to an empty ``PromptRepresentation`` and the run looks like "the +deriver found nothing" rather than "the mock is broken". So the assertions here +are about parseability against real Honcho models, not about response shape. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import struct +from collections.abc import Callable +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field + +from src.mock_provider.coerce import as_dict +from src.mock_provider.embeddings import content_to_embedding +from src.mock_provider.main import app +from src.mock_provider.schema_gen import HARD_MAX_DEPTH, MAX_DEPTH, generate +from src.utils.representation import PromptRepresentation + +# A $ref/$defs schema, which is what Pydantic emits for any nested model and the +# indirection a naive generator silently drops. +PROBE_SCHEMA: dict[str, Any] = { + "$defs": { + "Item": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "count": {"type": "integer", "minimum": 1, "maximum": 5}, + }, + "required": ["name", "count"], + } + }, + "type": "object", + "properties": { + "label": {"type": "string"}, + "items": {"type": "array", "items": {"$ref": "#/$defs/Item"}}, + }, + "required": ["label", "items"], +} + + +class ProbeItem(BaseModel): + name: str + count: int = Field(ge=1, le=5) + + +class Probe(BaseModel): + label: str + items: list[ProbeItem] + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def _post_chat(client: TestClient, **payload: Any) -> dict[str, Any]: + payload.setdefault("model", "mock-model") + payload.setdefault("messages", [{"role": "user", "content": "hello"}]) + response = client.post("/v1/chat/completions", json=payload) + assert response.status_code == 200, response.text + return response.json() + + +def _json_schema_format(schema: dict[str, Any], name: str) -> dict[str, Any]: + return { + "type": "json_schema", + "json_schema": {"name": name, "schema": schema, "strict": True}, + } + + +# --- structured output ------------------------------------------------------ + + +def test_json_schema_request_round_trips_into_its_pydantic_model( + client: TestClient, +) -> None: + body = _post_chat( + client, response_format=_json_schema_format(PROBE_SCHEMA, "Probe") + ) + content = body["choices"][0]["message"]["content"] + + probe = Probe.model_validate_json(content) + assert probe.label + assert probe.items, "$ref array must not come back empty" + assert all(1 <= item.count <= 5 for item in probe.items) + + +def test_deriver_response_model_round_trips() -> None: + """The real model the deriver parses, not a stand-in.""" + schema = PromptRepresentation.model_json_schema() + content = json.dumps(generate(schema)) + + representation = PromptRepresentation.model_validate_json(content) + assert representation.explicit, ( + "an empty explicit list is exactly the silent failure this mock avoids" + ) + + +def test_json_schema_response_is_never_prose(client: TestClient) -> None: + body = _post_chat( + client, response_format=_json_schema_format(PROBE_SCHEMA, "Probe") + ) + json.loads(body["choices"][0]["message"]["content"]) + + +def test_unreadable_json_schema_still_returns_parseable_json( + client: TestClient, +) -> None: + body = _post_chat( + client, + response_format={"type": "json_schema", "json_schema": {"name": "Broken"}}, + ) + assert json.loads(body["choices"][0]["message"]["content"]) == {} + + +def test_json_object_mode_recovers_the_schema_from_the_prompt( + client: TestClient, +) -> None: + """json_object mode puts the schema in the prompt, not in response_format.""" + body = _post_chat( + client, + messages=[ + {"role": "user", "content": "Extract facts."}, + { + "role": "user", + "content": "Respond with valid JSON matching this schema:\n" + + json.dumps(PROBE_SCHEMA), + }, + ], + response_format={"type": "json_object"}, + ) + Probe.model_validate_json(body["choices"][0]["message"]["content"]) + + +def test_json_object_mode_without_a_schema_returns_an_empty_object( + client: TestClient, +) -> None: + body = _post_chat(client, response_format={"type": "json_object"}) + assert json.loads(body["choices"][0]["message"]["content"]) == {} + + +def test_plain_request_returns_prose(client: TestClient) -> None: + body = _post_chat(client) + content = body["choices"][0]["message"]["content"] + assert "[mock]" in content + with pytest.raises(json.JSONDecodeError): + json.loads(content) + + +def test_tools_request_does_not_emit_tool_calls(client: TestClient) -> None: + """The tool loop must terminate; a mock that calls tools would spin.""" + body = _post_chat( + client, + tools=[ + { + "type": "function", + "function": {"name": "search_memory", "parameters": {}}, + } + ], + ) + assert body["choices"][0]["message"]["tool_calls"] is None + assert body["choices"][0]["finish_reason"] == "stop" + + +def test_identical_requests_are_byte_identical(client: TestClient) -> None: + payload: dict[str, Any] = { + "model": "mock-model", + "messages": [{"role": "user", "content": "determinism"}], + "response_format": _json_schema_format(PROBE_SCHEMA, "Probe"), + } + first = client.post("/v1/chat/completions", json=payload).json() + second = client.post("/v1/chat/completions", json=payload).json() + assert first == second + + +def test_usage_is_reported(client: TestClient) -> None: + body = _post_chat(client) + usage = body["usage"] + assert usage["total_tokens"] == usage["prompt_tokens"] + usage["completion_tokens"] + assert usage["completion_tokens"] > 0 + + +# --- schema generation edge cases ------------------------------------------- + + +def test_recursive_schema_terminates() -> None: + """Reasoning trees nest premises inside conclusions, so cycles are normal.""" + schema: dict[str, Any] = { + "$defs": { + "Node": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "child": {"anyOf": [{"$ref": "#/$defs/Node"}, {"type": "null"}]}, + }, + "required": ["value", "child"], + } + }, + "$ref": "#/$defs/Node", + } + node: dict[str, Any] | None = generate(schema) + depth = 0 + while node is not None and node.get("child") is not None: + node = node["child"] + depth += 1 + assert depth < 50, "recursive schema did not terminate" + + +_SCALAR_CASES: list[tuple[str, dict[str, Any], Callable[[Any], bool]]] = [ + ("enum", {"type": "string", "enum": ["a", "b"]}, lambda v: v in ("a", "b")), + ("const", {"const": 7}, lambda v: v == 7), + ("boolean", {"type": "boolean"}, lambda v: isinstance(v, bool)), + ("null", {"type": "null"}, lambda v: v is None), + ("number", {"type": "number"}, lambda v: isinstance(v, float)), + ("nullable-union", {"type": ["string", "null"]}, lambda v: isinstance(v, str)), + ("pinned-int", {"type": "integer", "minimum": 3, "maximum": 3}, lambda v: v == 3), + ( + "exclusive-bounds", + {"type": "integer", "exclusiveMinimum": 1, "exclusiveMaximum": 3}, + lambda v: v == 2, + ), + ( + "date-time", + {"type": "string", "format": "date-time"}, + lambda v: str(v).endswith("Z"), + ), + ("min-length", {"type": "string", "minLength": 400}, lambda v: len(v) >= 400), + ("max-length", {"type": "string", "maxLength": 4}, lambda v: len(v) == 4), + ( + "min-items", + {"type": "array", "items": {"type": "string"}, "minItems": 3}, + lambda v: len(v) >= 3, + ), + ( + "max-items", + {"type": "array", "items": {"type": "string"}, "maxItems": 1}, + lambda v: len(v) == 1, + ), +] + + +@pytest.mark.parametrize( + ("schema", "check"), + [(schema, check) for _, schema, check in _SCALAR_CASES], + ids=[name for name, _, _ in _SCALAR_CASES], +) +def test_scalar_schema_forms( + schema: dict[str, Any], check: Callable[[Any], bool] +) -> None: + assert check(generate(schema)) + + +def test_all_of_is_flattened() -> None: + schema: dict[str, Any] = { + "allOf": [ + { + "type": "object", + "properties": {"a": {"type": "string"}}, + "required": ["a"], + }, + { + "type": "object", + "properties": {"b": {"type": "integer"}}, + "required": ["b"], + }, + ] + } + result = generate(schema) + assert isinstance(result["a"], str) + assert isinstance(result["b"], int) + + +def test_fixed_tuple_schema_round_trips() -> None: + """Pydantic emits a fixed tuple as `prefixItems` with no `items`. + + Reading only `items` yields [], which fails the minItems the same schema + carries — the silent-empty failure this module exists to avoid. + """ + + class Tupled(BaseModel): + pair: tuple[str, int] + + schema = Tupled.model_json_schema() + assert "prefixItems" in schema["properties"]["pair"] + + result = generate(schema) + assert isinstance(result["pair"], list) + Tupled.model_validate(result) + + +def test_prefix_items_are_followed_by_homogeneous_items() -> None: + """A variadic tuple constrains leading positions and the rest by `items`.""" + schema: dict[str, Any] = { + "type": "array", + "prefixItems": [{"type": "string"}, {"type": "integer"}], + "items": {"type": "boolean"}, + "minItems": 4, + } + result = generate(schema) + + assert len(result) == 4 + assert isinstance(result[0], str) + assert isinstance(result[1], int) + assert all(isinstance(value, bool) for value in result[2:]) + + +def test_min_items_is_met_when_items_is_omitted() -> None: + """Absent `items` leaves trailing positions unconstrained, not disallowed.""" + schema: dict[str, Any] = { + "type": "array", + "prefixItems": [{"type": "string"}], + "minItems": 3, + } + result = generate(schema) + + assert len(result) == 3 + assert isinstance(result[0], str) + + +@pytest.mark.parametrize( + ("constraints", "multiple"), + [ + ({"minimum": 0, "maximum": 100, "multipleOf": 10}, 10), + ({"minimum": 7, "maximum": 9, "multipleOf": 4}, 4), + ({"minimum": -100, "maximum": 0, "multipleOf": 25}, 25), + ({"minimum": 5, "multipleOf": 3}, 3), + ({"maximum": -5, "multipleOf": 3}, 3), + ({"multipleOf": 6}, 6), + ], +) +def test_multiple_of_is_honoured_within_bounds( + constraints: dict[str, Any], multiple: int +) -> None: + """Path-seeded values land off the multiple unless snapped back onto it.""" + low = constraints.get("minimum") + high = constraints.get("maximum") + + # Several paths, because a single one can satisfy the constraint by luck. + for index in range(12): + schema: dict[str, Any] = { + "type": "object", + "properties": {f"f{index}": {"type": "integer", **constraints}}, + "required": [f"f{index}"], + } + value = generate(schema)[f"f{index}"] + + assert value % multiple == 0, f"{value} is not a multiple of {multiple}" + if low is not None: + assert value >= low + if high is not None: + assert value <= high + + +def test_unsatisfiable_multiple_of_stays_within_bounds() -> None: + """No multiple of 10 lies in [3, 7], so the bounds win over the multiple.""" + schema: dict[str, Any] = { + "type": "integer", + "minimum": 3, + "maximum": 7, + "multipleOf": 10, + } + result = generate(schema) + assert 3 <= result <= 7 + + +def test_required_recursive_ref_terminates_instead_of_overflowing() -> None: + """A required, non-nullable cycle has no `default` or null branch to stop on. + + MAX_DEPTH alone does not save it — `_generate_object` keeps descending into + required properties — so the absolute cap has to. + """ + schema: dict[str, Any] = { + "$defs": { + "Node": { + "type": "object", + "properties": {"child": {"$ref": "#/$defs/Node"}}, + "required": ["child"], + } + }, + "$ref": "#/$defs/Node", + } + node = as_dict(generate(schema)) + + depth = 0 + # The cap returns {}, so an empty dict is the terminator. + while node: + node = as_dict(node["child"]) + depth += 1 + assert depth <= HARD_MAX_DEPTH, "absolute depth cap did not hold" + assert depth > MAX_DEPTH, "should descend past the soft cap before stopping" + + +def test_required_recursive_array_terminates_instead_of_overflowing() -> None: + """minItems >= 1 keeps `_generate_array` from emptying out at the soft cap.""" + schema: dict[str, Any] = { + "$defs": { + "Node": { + "type": "object", + "properties": { + "kids": { + "type": "array", + "items": {"$ref": "#/$defs/Node"}, + "minItems": 1, + } + }, + "required": ["kids"], + } + }, + "$ref": "#/$defs/Node", + } + generate(schema) # must not raise RecursionError + + +def test_generation_is_stable_across_calls() -> None: + assert generate(PROBE_SCHEMA) == generate(PROBE_SCHEMA) + + +def test_sibling_fields_of_the_same_type_differ() -> None: + """Path-seeded, so a schema of identical fields is not all one value.""" + schema: dict[str, Any] = { + "type": "object", + "properties": { + "first": {"type": "string"}, + "second": {"type": "string"}, + }, + "required": ["first", "second"], + } + result = generate(schema) + assert result["first"] != result["second"] + + +# --- embeddings ------------------------------------------------------------- + + +def test_embeddings_default_to_1536_and_are_stable(client: TestClient) -> None: + payload = { + "model": "text-embedding-3-small", + "input": "hello", + "encoding_format": "float", + } + first = client.post("/v1/embeddings", json=payload) + assert first.status_code == 200, first.text + vector = first.json()["data"][0]["embedding"] + + assert len(vector) == 1536 + assert all(-1.0 <= value <= 1.0 for value in vector) + assert client.post("/v1/embeddings", json=payload).json() == first.json() + + +def test_embeddings_honour_the_requested_dimension(client: TestClient) -> None: + """A width mismatch raises in EmbeddingClient and blocks startup.""" + response = client.post( + "/v1/embeddings", + json={"input": "hello", "dimensions": 256, "encoding_format": "float"}, + ) + assert len(response.json()["data"][0]["embedding"]) == 256 + + +def test_different_inputs_give_different_vectors(client: TestClient) -> None: + response = client.post( + "/v1/embeddings", + json={"input": ["alpha", "beta"], "encoding_format": "float"}, + ) + data = response.json()["data"] + assert len(data) == 2 + assert [item["index"] for item in data] == [0, 1] + assert data[0]["embedding"] != data[1]["embedding"] + + +def test_batch_returns_one_embedding_per_input(client: TestClient) -> None: + """EmbeddingClient._validate_embedding_count rejects any other count.""" + texts = [f"text-{index}" for index in range(17)] + response = client.post( + "/v1/embeddings", json={"input": texts, "encoding_format": "float"} + ) + assert len(response.json()["data"]) == len(texts) + + +def test_base64_is_the_default_encoding_and_decodes_to_the_float_vector( + client: TestClient, +) -> None: + """The SDK omits encoding_format precisely when it wants base64.""" + response = client.post("/v1/embeddings", json={"input": "hello"}) + encoded = response.json()["data"][0]["embedding"] + assert isinstance(encoded, str) + + raw = base64.b64decode(encoded) + decoded = list(struct.unpack(f"<{len(raw) // 4}f", raw)) + assert len(decoded) == 1536 + expected = content_to_embedding("hello", 1536) + assert decoded == pytest.approx(expected, abs=1e-6) # pyright: ignore[reportUnknownMemberType] + + +def test_embedding_matches_the_test_suite_helper() -> None: + """Kept in step with _content_to_embedding in tests/conftest.py. + + Both must derive the same vector from the same text, so a suite that mocks + the embedding client in-process and one that talks to this provider over + HTTP agree on what a given string embeds to. + """ + digest = hashlib.sha256(b"hello").digest() + expected = [(digest[i % len(digest)] / 255.0) * 2 - 1 for i in range(8)] + + assert content_to_embedding("hello", 8) == pytest.approx(expected) # pyright: ignore[reportUnknownMemberType] + + +# --- routing ---------------------------------------------------------------- + + +def test_routes_are_mounted_with_and_without_the_v1_prefix( + client: TestClient, +) -> None: + for path in ("/v1/chat/completions", "/chat/completions"): + response = client.post( + path, json={"model": "m", "messages": [{"role": "user", "content": "x"}]} + ) + assert response.status_code == 200, path + + +def test_unimplemented_post_returns_405_not_a_plausible_200( + client: TestClient, +) -> None: + """A catch-all POST would make a missing endpoint look like it worked.""" + assert client.post("/v1/completions", json={}).status_code == 405 + + +def test_health_and_catch_all_get(client: TestClient) -> None: + assert client.get("/health").json()["status"] == "ok" + assert client.get("/").status_code == 200 + + +# --- request validation ----------------------------------------------------- + + +def test_malformed_body_returns_an_openai_error_envelope(client: TestClient) -> None: + """A bad request must look like the real API's, not like FastAPI's 422. + + Mid-run, a 422 in FastAPI's own error shape reads as a Honcho bug rather + than a bad request, and no OpenAI client knows how to interpret it. + """ + response = client.post( + "/v1/embeddings", json={"input": "hello", "dimensions": "not-a-number"} + ) + + assert response.status_code == 400 + error = response.json()["error"] + assert error["type"] == "invalid_request_error" + assert error["message"] + assert set(error) == {"message", "type", "param", "code"} + + +def test_boolean_dimensions_is_rejected_not_silently_coerced( + client: TestClient, +) -> None: + """bool is an int subclass, so `true` would otherwise mean 1 dimension.""" + response = client.post( + "/v1/embeddings", json={"input": "hello", "dimensions": True} + ) + + assert response.status_code == 400 + + +@pytest.mark.parametrize("dimensions", [0, -1]) +def test_non_positive_dimensions_is_rejected_not_defaulted( + client: TestClient, dimensions: int +) -> None: + """Substituting 1536 would answer a bad request with a plausible vector.""" + response = client.post( + "/v1/embeddings", + json={"input": "hello", "dimensions": dimensions, "encoding_format": "float"}, + ) + + assert response.status_code == 400 + assert response.json()["error"]["type"] == "invalid_request_error" + + +def test_unknown_fields_are_accepted(client: TestClient) -> None: + """Validation must fire on wrong types, never on unrecognised parameters. + + A new upstream parameter should not turn a working setup into a hard + failure, so every model allows extras. + """ + body = _post_chat( + client, + temperature=0.7, + max_completion_tokens=256, + reasoning_effort="minimal", + some_parameter_invented_next_year=True, + ) + assert body["choices"][0]["finish_reason"] == "stop" + + +def test_wrongly_typed_messages_are_rejected(client: TestClient) -> None: + response = client.post( + "/v1/chat/completions", json={"model": "m", "messages": "not-a-list"} + ) + + assert response.status_code == 400 + assert response.json()["error"]["type"] == "invalid_request_error" + + +@pytest.mark.parametrize( + ("payload", "expected"), + [ + ({"input": "solo"}, 1), + ({"input": ["a", "b", "c"]}, 3), + ({"input": [1, 2, 3]}, 1), + ({"input": [[1, 2], [3, 4]]}, 2), + ({"input": None}, 0), + ], + ids=["string", "list-of-strings", "token-array", "token-arrays", "null"], +) +def test_every_documented_input_shape_is_accepted( + client: TestClient, payload: dict[str, Any], expected: int +) -> None: + """A flat int list is one tokenized input, not many single-token ones.""" + response = client.post( + "/v1/embeddings", json={**payload, "encoding_format": "float"} + ) + + assert response.status_code == 200, response.text + assert len(response.json()["data"]) == expected + + +# --- streaming -------------------------------------------------------------- + + +def _stream_chunks( + client: TestClient, stream_options: dict[str, Any] | None = None +) -> list[dict[str, Any]]: + """The SSE payloads of a streaming completion, `[DONE]` asserted and dropped.""" + body: dict[str, Any] = { + "model": "mock-model", + "messages": [{"role": "user", "content": "stream please"}], + "stream": True, + } + if stream_options is not None: + body["stream_options"] = stream_options + + with client.stream("POST", "/v1/chat/completions", json=body) as response: + assert response.status_code == 200 + lines = [ + line[len("data: ") :] + for line in response.iter_lines() + if line.startswith("data: ") + ] + + assert lines[-1] == "[DONE]" + return [json.loads(line) for line in lines[:-1]] + + +def test_stream_emits_content_then_a_final_usage_chunk(client: TestClient) -> None: + """The backend ends the stream on the usage chunk, so it must come last.""" + chunks = _stream_chunks(client, {"include_usage": True}) + + content = "".join( + chunk["choices"][0]["delta"].get("content", "") + for chunk in chunks + if chunk["choices"] + ) + assert "[mock]" in content + + assert any( + chunk["choices"] and chunk["choices"][0]["finish_reason"] == "stop" + for chunk in chunks + ) + + usage_chunk = chunks[-1] + assert usage_chunk["usage"]["completion_tokens"] > 0 + assert usage_chunk["choices"] == [] + + +@pytest.mark.parametrize( + "stream_options", + [None, {}, {"include_usage": False}], + ids=["absent", "empty", "false"], +) +def test_stream_without_include_usage_emits_no_usage_chunk( + client: TestClient, stream_options: dict[str, Any] | None +) -> None: + """The real API sends the usage chunk only when asked, so neither does this. + + A caller that did not opt in must not have to skip a trailing chunk with an + empty `choices` array. + """ + chunks = _stream_chunks(client, stream_options) + + assert all("usage" not in chunk for chunk in chunks) + assert chunks[-1]["choices"][0]["finish_reason"] == "stop" + + content = "".join( + chunk["choices"][0]["delta"].get("content", "") for chunk in chunks + ) + assert "[mock]" in content + + +@pytest.mark.parametrize("value", ["definitely", "yes", "on", "true", "1", 1]) +def test_non_boolean_include_usage_is_rejected(client: TestClient, value: Any) -> None: + """The usage chunk is conditional on this, so a wrong type must 400. + + The truthy strings matter more than the nonsense one: plain `bool` coerces + "yes"/"on"/"true"/"1", so without StrictBool a string would silently decide + whether the stream carries usage. + """ + response = client.post( + "/v1/chat/completions", + json={ + "model": "mock-model", + "messages": [{"role": "user", "content": "x"}], + "stream": True, + "stream_options": {"include_usage": value}, + }, + ) + + assert response.status_code == 400 + assert response.json()["error"]["type"] == "invalid_request_error" + + +@pytest.mark.parametrize("value", ["yes", "true", "1", 1]) +def test_non_boolean_stream_is_rejected(client: TestClient, value: Any) -> None: + """`stream` picks between a JSON body and an SSE stream, so it must be exact.""" + response = client.post( + "/v1/chat/completions", + json={ + "model": "mock-model", + "messages": [{"role": "user", "content": "x"}], + "stream": value, + }, + ) + + assert response.status_code == 400 + assert response.json()["error"]["type"] == "invalid_request_error" From 235900b9e508a86e2b795a969d7b1c83292bbf14 Mon Sep 17 00:00:00 2001 From: Rajat Ahuja Date: Thu, 3 Sep 2026 16:01:54 -0400 Subject: [PATCH 48/50] docs for programmatically creating api key (#1133) * docs for programmatically creating api key * chore: adtl reference to the create-key endpoint * fix: enhance docs --------- Co-authored-by: ajspig --- docs/v3/api-reference/endpoint/keys/create-key.mdx | 14 ++++++++------ docs/v3/documentation/reference/platform.mdx | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/v3/api-reference/endpoint/keys/create-key.mdx b/docs/v3/api-reference/endpoint/keys/create-key.mdx index 9f9b0470..db6a34b1 100644 --- a/docs/v3/api-reference/endpoint/keys/create-key.mdx +++ b/docs/v3/api-reference/endpoint/keys/create-key.mdx @@ -3,11 +3,13 @@ openapi: post /v3/keys --- -**Self-hosted only.** This endpoint is not available on Honcho Cloud -(`api.honcho.dev`) — requests to it return `405 Method Not Allowed`. Create and -manage keys for a cloud instance from the -[API Keys page](https://app.honcho.dev/api-keys) in the dashboard. +Requires an admin key. On Honcho Cloud (`api.honcho.dev`) the returned key is a +real cloud key on the calling key's instance, attributed to its owner and +revocable from the [API Keys page](https://app.honcho.dev/api-keys). On a +self-hosted instance it returns an error when `AUTH_USE_AUTH` is disabled. -On a self-hosted instance it requires an admin key, and returns an error when -`AUTH_USE_AUTH` is disabled. +Provide at least one of `workspace_id`, `peer_id`, or `session_id` — a request +carrying none of them is rejected. A key scoped to a peer or a session must also +carry its `workspace_id`. On Honcho Cloud, pass either `admin=true` or a +`workspace_id`. diff --git a/docs/v3/documentation/reference/platform.mdx b/docs/v3/documentation/reference/platform.mdx index 28042d4c..8e32d3d8 100644 --- a/docs/v3/documentation/reference/platform.mdx +++ b/docs/v3/documentation/reference/platform.mdx @@ -62,7 +62,7 @@ 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 a specific `Workspace`, `Peer`, or `Session`. -Keys for a cloud instance can only be created here, not through the API — `POST /v3/keys` is disabled on `api.honcho.dev` and returns `405`. The same applies to the webhook management endpoints, which live on the [Webhooks](https://app.honcho.dev/webhooks) page. +Keys can also be created programmatically. `POST /v3/keys` with an admin key returns a real cloud key on that key's instance, attributed to its owner and revocable from the [API Keys](https://app.honcho.dev/api-keys) page. Scoped keys are authorized by their narrowest claim and never widen to the whole workspace: From 9677f3d80c4e6cb7152700b356282b6e920a20d3 Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Thu, 3 Sep 2026 16:12:02 -0400 Subject: [PATCH 49/50] fix(tests): repair unsatisfiable unified-test assertions and record why tests fail (#1123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tests): repair unsatisfiable unified-test assertions and surface traces Five of the eight persistent `unified-tests` failures assert things the code cannot produce. None are regressions. Raise the queue-drain timeout to 600s on the three large longmem fixtures. They ingest 484-550 messages across ~50 sessions, then wait on the 60s `WaitAction` default; the deriver is still working normally when the timer fires. Matches the sibling 550-message case that already passes. Raise `max_tokens` to 2500 in the two config-summary fixtures. Context allocates 40% of the limit to the summary, so the previous 400 gave a 160-token budget while `SUMMARY.MAX_TOKENS_SHORT` is 1000 — no conforming summary could ever fit, and the query returned `summary=None` even though the summary was created. Drop `session_id` from the dream test's `get_representation` step. A bare session id becomes a one-element allowlist, and an allowlist narrows levels to `ALLOWLIST_SAFE_LEVELS` (`explicit`), so the deductive and inductive observations the step asserts on are excluded by design. The unscoped representation is where the dreamer's conclusions are actually served. Delete `WaitAction.flush`. Flush is process-wide — the harness starts the deriver with `DERIVER_FLUSH_ENABLED=true` — and there is no per-request flush, so the field never had an effect despite being set in 47 places. `TestStep` now forbids extra fields so a dead knob cannot silently accumulate again. Presign the reasoning traces alongside `results.json` and report both to the Discord webhook and a GitHub job summary. The traces hold the full prompts and model outputs and were already uploaded, but only `results.json` was surfaced. Co-Authored-By: Claude Opus 5 (1M context) * fix(tests): record why a unified test failed, not just that it did `results.json` carried only name, status and duration, so a red run said which test failed and nothing about why. The reason existed solely in the job log, where the secrets action's masking can render it unreadable — diagnosing a failure meant re-reading GHA logs that had digits redacted out of them. `execute` now returns the `StepFailure` that stopped the test (step index, step type, and the exception message) instead of a bare bool. Assertion failures already raised useful text, including the LLM judge's own reasoning; that text now reaches `results.json`, the console output, the job summary and the Discord message rather than being discarded at the call site. `results` moves from a `(status, duration)` tuple to a `TestOutcome` with named fields so the failure can ride along. Co-Authored-By: Claude Opus 5 (1M context) * fix(tests): keep the Discord report inside the webhook size limit The failure reasons added to the Discord message pushed it past Discord's 2000-character content limit, and the webhook answered 400 — run 33779689337 sent no notification at all. Six LLM-judge verdicts run to ~2760 characters; capping the count at ten did nothing because the length was never the count. Reasons are now clipped per line for Discord only; the job summary, the console and results.json keep them whole. `send_discord_message` also clamps the assembled content, so an over-long report loses its tail rather than the entire notification. Co-Authored-By: Claude Opus 5 (1M context) * fix(tests): keep the Discord report short and link to the Actions run The Discord message restated every failure, which pushed it past Discord's 2000-character limit and returned a 400 — run 33779689337 sent no notification at all. The report is now the headline, the results link, an Actions run link, and the traces S3 key. Per-test failure reasons stay in the job summary that the Actions link points at, along with both presigned URLs, so nothing is lost by not repeating them in chat. Restating failures was not the only size risk. A presigned URL carries an OIDC session token and can run past a thousand characters by itself, so two of them exceeded the limit unaided — which is why the traces go in as their S3 key, the `aws s3 cp` path, at ~90 characters instead of ~1500. `clamp_lines` drops whole lines rather than characters, since half a presigned URL is useless and renders as broken markdown, and drops the longest line first so an overlong URL cannot evict the short Actions link that leads to everything else. Co-Authored-By: Claude Opus 5 (1M context) * fix(api): say when a session summary is dropped for budget `get_context` allocates 40% of the token limit to the summary, but that limit is what remains *after* the peer representation and peer card are subtracted, not the `tokens` the caller asked for. When nothing fits, the caller receives `summary: null` — indistinguishable from a session that has no summary — and the only trace was a debug line in a different module. `_select_summary_for_context` now logs at info when summaries exist and none was chosen, with the budget and the sizes that missed it. The two `config_summary_control` fixtures go to 4000. Measured against CI run 33779689337, their 12 messages produce 12 explicit observations costing ~1176 tokens, so the original `max_tokens: 400` left a budget of -776: no summary of any size could have been served, and the earlier reading of this failure — a 160-token budget against a 388-token summary — had the mechanism wrong. 2500 was also short, leaving 529 against a `SUMMARY.MAX_TOKENS_SHORT` of 1000; 3676 is the minimum that guarantees a conforming summary fits. Tests cover the budget arithmetic at each of those limits, the new log line, and that a stored summary is served through the route with and without an observer — the retrieval path itself was never at fault and had no coverage. Co-Authored-By: Claude Opus 5 (1M context) * fix(api): report a dropped summary on the path get_context actually takes The previous commit added this log to `_select_summary_for_context`, which only runs when `get_context` is given a `peer_target`. The unified `config_summary` fixtures set `observer_peer_id`, but the runner does not forward it, so those requests take `summarizer.get_session_context` instead — where the same outcome was reported at debug and stayed invisible. That also retracts the representation-budget explanation for those fixtures. Nothing is subtracted from the limit on this path: the summary gets 40% of the requested tokens outright, so at `max_tokens: 4000` a 99-token summary has a 1600-token budget and fits comfortably. The reason it is still absent is not the budget, and the log now says so on the right path. Tests cover both paths, and record that the fixtures exercise the one without a representation. Co-Authored-By: Claude Opus 5 (1M context) * fix(tests): drop the ignored observer from the config_summary fixtures `observer_peer_id` has no effect on a `get_context` step — the runner does not forward it — so it read as scoping a request that was never scoped. The step description now records that these are unscoped reads and what naming an observer would change. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- src/routers/sessions.py | 13 + src/utils/summarizer.py | 15 +- tests/routes/test_session_context_summary.py | 167 ++++++++++ tests/unified/runner.py | 298 ++++++++++++++---- tests/unified/schema.py | 8 +- .../test_cases/config_deriver_hierarchy.json | 6 +- .../config_message_positive_override.json | 3 +- .../test_cases/config_peercard_control.json | 3 +- .../test_cases/config_summary_control.json | 7 +- .../config_summary_control_deriver_off.json | 7 +- .../dialectic_reasoning_levels.json | 3 +- .../dialectic_structured_output.json | 3 +- .../test_cases/dialectic_tool_calls.json | 3 +- .../dream_knowledge_updates_and_patterns.json | 13 +- tests/unified/test_cases/longmem_ancash.json | 3 +- .../longmem_ancash_directional.json | 3 +- .../test_cases/longmem_ancash_no_session.json | 3 +- .../unified/test_cases/longmem_giftcard.json | 2 +- tests/unified/test_cases/longmem_plank.json | 3 +- ...ple_7161e7e2_single-session-assistant.json | 2 +- ...m_triple_e47becba_single-session-user.json | 3 +- ...iple_gpt4_59149c77_temporal-reasoning.json | 2 +- .../test_cases/message_deriver_disabled.json | 3 +- .../observation_2peer_bidirectional.json | 3 +- ...servation_2peer_both_observe_me_false.json | 3 +- .../test_cases/observation_2peer_default.json | 3 +- ...r_observe_me_false_blocks_observation.json | 3 +- ...me_false_but_can_still_observe_others.json | 3 +- ...eer_unidirectional_alice_observes_bob.json | 3 +- ...eer_unidirectional_bob_observes_alice.json | 3 +- ...ervation_3peer_all_observe_each_other.json | 3 +- .../observation_3peer_circular.json | 3 +- ...3peer_multiple_observers_one_observed.json | 6 +- ..._3peer_one_observer_multiple_observed.json | 3 +- ...servation_3peer_selective_observation.json | 3 +- .../observation_4peer_complex_matrix.json | 3 +- .../observation_asymmetric_visibility.json | 6 +- ...bservation_isolation_between_sessions.json | 6 +- .../test_cases/peer_isolation_test.json | 6 +- .../test_cases/scope_confines_recall.json | 3 +- .../test_cases/session_deriver_disabled.json | 3 +- .../test_cases/workspace_chat_cross_peer.json | 3 +- .../workspace_chat_from_observations.json | 3 +- .../workspace_deriver_disabled.json | 3 +- tests/unified/test_reporting.py | 143 +++++++++ 45 files changed, 626 insertions(+), 165 deletions(-) create mode 100644 tests/routes/test_session_context_summary.py create mode 100644 tests/unified/test_reporting.py diff --git a/src/routers/sessions.py b/src/routers/sessions.py index f0d5b056..41f2fb67 100644 --- a/src/routers/sessions.py +++ b/src/routers/sessions.py @@ -248,6 +248,19 @@ def _select_summary_for_context( token_limit - short_len, ) + if short_summary or long_summary: + # A summary exists but none fits. The caller sees `summary: null`, which + # is indistinguishable from "this session has no summary", so say so. + # `token_limit` here is already net of the representation and peer card, + # which is usually why the budget is smaller than the request suggests. + logger.info( + "Summary dropped: budget %s too small (short=%s, long=%s, limit=%s)", + summary_budget, + short_len or None, + long_len or None, + token_limit, + ) + return None, 0, token_limit diff --git a/src/utils/summarizer.py b/src/utils/summarizer.py index 2abdb0f9..1bf4bd4f 100644 --- a/src/utils/summarizer.py +++ b/src/utils/summarizer.py @@ -886,12 +886,21 @@ async def get_session_context( ) messages_tokens = token_limit - latest_short_summary["token_count"] messages_start_id = latest_short_summary["message_id"] + elif latest_short_summary or latest_long_summary: + # A summary exists but does not fit the 40% allocation. The caller + # receives `summary: null`, which is indistinguishable from a session + # that has none, so this is reported rather than left at debug. + logger.info( + "Summary dropped: budget %s too small (short=%s, long=%s, limit=%s)", + summary_tokens_limit, + short_len or None, + long_len or None, + token_limit, + ) else: logger.debug( - "No summary available for get_context call with token limit %s, returning empty string. Normal if brand-new session. long_summary_len: %s, short_summary_len: %s", + "No summary for get_context with token limit %s. Normal for a new session.", token_limit, - long_len, - short_len, ) # Get recent messages after summary diff --git a/tests/routes/test_session_context_summary.py b/tests/routes/test_session_context_summary.py new file mode 100644 index 00000000..7c4f0fcc --- /dev/null +++ b/tests/routes/test_session_context_summary.py @@ -0,0 +1,167 @@ +"""How `get_context` decides whether to serve a session summary. + +Two paths, and which one runs depends on `peer_target`: + +- Without it, `summarizer.get_session_context` gives the summary 40% of the + requested `tokens`. +- With it, `sessions._select_summary_for_context` gives it 40% of what remains + *after* the peer representation and card are subtracted, so an observer with + many observations can starve a perfectly valid summary. + +Either way the caller just sees `summary: null`, indistinguishable from a +session that has none, which is why both paths now log when they drop one. +""" + +from __future__ import annotations + +import datetime as dt + +import pytest +from fastapi.testclient import TestClient +from nanoid import generate as generate_nanoid +from sqlalchemy.ext.asyncio import AsyncSession + +from src import schemas +from src.models import Peer, Workspace +from src.routers.sessions import ( + _select_summary_for_context, # pyright: ignore[reportPrivateUsage] +) +from src.utils.summarizer import ( + Summary, + SummaryType, + _save_summary, # pyright: ignore[reportPrivateUsage] +) + +# Measured from CI run 33779689337: 12 messages from one peer produce 12 +# explicit observations costing ~1176 tokens. Only the `peer_target` path pays +# this, and the unified `config_summary` fixtures do not take that path. +_FIXTURE_REPRESENTATION_TOKENS = 1176 +_SHORT_SUMMARY_CAP = 1000 # SUMMARY.MAX_TOKENS_SHORT default + + +def _summary_schema(token_count: int) -> schemas.Summary: + return schemas.Summary( + content="A summary of the conversation so far.", + message_id=1, + summary_type="short", + created_at=dt.datetime.now(dt.UTC).isoformat(), + token_count=token_count, + message_public_id="msg_public", + ) + + +def _stored_summary(token_count: int) -> Summary: + return Summary( + content="A summary of the conversation so far. " * 5, + message_id=1, + summary_type=SummaryType.SHORT.value, + created_at=dt.datetime.now(dt.UTC).isoformat(), + token_count=token_count, + message_public_id="msg_public", + ) + + +def test_representation_can_exhaust_the_budget_entirely() -> None: + """A large representation can leave a negative budget on the observer path.""" + adjusted = 400 - _FIXTURE_REPRESENTATION_TOKENS + assert adjusted < 0 + chosen, _, _ = _select_summary_for_context( + _summary_schema(99), None, adjusted, True + ) + assert chosen is None + + +def test_a_conforming_summary_can_still_be_dropped() -> None: + """With that representation, 2500 leaves 529 — under `SUMMARY.MAX_TOKENS_SHORT`.""" + adjusted = 2500 - _FIXTURE_REPRESENTATION_TOKENS + chosen, _, _ = _select_summary_for_context( + _summary_schema(_SHORT_SUMMARY_CAP), None, adjusted, True + ) + assert chosen is None + + +def test_fixture_limit_fits_any_conforming_summary() -> None: + """4000 leaves room even when a representation is subtracted.""" + adjusted = 4000 - _FIXTURE_REPRESENTATION_TOKENS + assert int(adjusted * 0.4) >= _SHORT_SUMMARY_CAP + chosen, _, _ = _select_summary_for_context( + _summary_schema(_SHORT_SUMMARY_CAP), None, adjusted, True + ) + assert chosen is not None + + +def test_zero_token_summary_is_never_served() -> None: + chosen, _, _ = _select_summary_for_context(_summary_schema(0), None, 4000, True) + assert chosen is None + + +def test_dropped_summary_is_logged_not_silent( + caplog: pytest.LogCaptureFixture, +) -> None: + """`summary: null` is indistinguishable from 'no summary exists' otherwise.""" + with caplog.at_level("INFO", logger="src.routers.sessions"): + _select_summary_for_context(_summary_schema(900), None, 1000, True) + assert "Summary dropped" in caplog.text + + +def test_no_log_when_the_session_simply_has_no_summary( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level("INFO", logger="src.routers.sessions"): + _select_summary_for_context(None, None, 1000, True) + assert "Summary dropped" not in caplog.text + + +@pytest.mark.parametrize("with_observer", [False, True]) +async def test_a_stored_summary_is_served( + client: TestClient, + sample_data: tuple[Workspace, Peer], + db_session: AsyncSession, + with_observer: bool, +) -> None: + """Retrieval itself works: a saved summary comes back through the route.""" + workspace, peer = sample_data + session_id = str(generate_nanoid()) + client.post( + f"/v3/workspaces/{workspace.name}/sessions", + json={"id": session_id, "peers": {peer.name: {}}}, + ) + await _save_summary(db_session, _stored_summary(99), workspace.name, session_id) + await db_session.commit() + + url = ( + f"/v3/workspaces/{workspace.name}/sessions/{session_id}/context" + "?summary=true&tokens=4000" + ) + if with_observer: + url += f"&peer_target={peer.name}" + + data = client.get(url).json() + assert data["summary"] is not None + assert data["summary"]["token_count"] == 99 + + +async def test_fixture_path_ignores_representation_budget( + client: TestClient, + sample_data: tuple[Workspace, Peer], + db_session: AsyncSession, +) -> None: + """Without `peer_target`, the summary gets 40% of `tokens` outright. + + The unified `config_summary` fixtures set `observer_peer_id`, but the runner + does not forward it to `get_context`, so this is the path they exercise. + """ + workspace, peer = sample_data + session_id = str(generate_nanoid()) + client.post( + f"/v3/workspaces/{workspace.name}/sessions", + json={"id": session_id, "peers": {peer.name: {}}}, + ) + await _save_summary(db_session, _stored_summary(99), workspace.name, session_id) + await db_session.commit() + + url = f"/v3/workspaces/{workspace.name}/sessions/{session_id}/context" + data = client.get(f"{url}?summary=true&tokens=2500").json() + + assert data["summary"] is not None + assert data.get("peer_representation") is None diff --git a/tests/unified/runner.py b/tests/unified/runner.py index c9e9d2e3..8fa0374c 100644 --- a/tests/unified/runner.py +++ b/tests/unified/runner.py @@ -5,9 +5,10 @@ import os import sys import threading import time -from datetime import datetime, timezone +from dataclasses import dataclass +from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, ClassVar import httpx from anthropic import AsyncAnthropic @@ -76,28 +77,167 @@ class TestExecutionError(Exception): pass -async def send_discord_message(webhook_url: str, message: str) -> None: - """Send a message to Discord via webhook.""" +# Discord rejects a webhook payload whose content exceeds this with a 400. +DISCORD_MAX_CONTENT = 2000 + + +def clamp_lines(lines: list[str], limit: int) -> str: + """Join `lines` within `limit`, dropping the longest ones first if needed. + + Whole lines rather than characters: a presigned URL cut in half is useless + and renders as broken markdown. Longest-first rather than last-first because + the only lines that can blow the budget are presigned URLs — dropping one of + those keeps every short, always-valid link, the Actions run link above all, + instead of losing them to a long URL that merely came first. + """ + kept = list(range(len(lines))) + + def size() -> int: + return sum(len(lines[i]) for i in kept) + max(0, len(kept) - 1) + + while kept and size() > limit: + kept.remove(max(kept, key=lambda i: len(lines[i]))) + return "\n".join(lines[i] for i in sorted(kept)) + + +async def send_discord_message(webhook_url: str, lines: list[str]) -> None: + """Send a report to Discord via webhook. + + Clamped to Discord's content limit here rather than at the call site: a + presigned URL carries an OIDC session token and can run past a thousand + characters on its own, and a 400 loses the whole notification. + """ try: async with httpx.AsyncClient() as client: - response = await client.post(webhook_url, json={"content": message}) + content = clamp_lines(lines, DISCORD_MAX_CONTENT) + response = await client.post(webhook_url, json={"content": content}) response.raise_for_status() logger.info("Discord notification sent successfully") except Exception: logger.exception("Failed to send Discord notification") +@dataclass +class StepFailure: + """Why a test stopped: the step that raised, and what it said.""" + + step_index: int + step_type: str + message: str + + def describe(self) -> str: + return f"step {self.step_index} ({self.step_type}): {self.message}" + + +@dataclass +class TestOutcome: + """One test's result. `failure` carries the reason whenever status isn't PASS.""" + + # Not a pytest case despite the name; keeps collection from warning on it. + __test__: ClassVar[bool] = False + + status: str + duration: float + failure: StepFailure | None = None + + +@dataclass +class RunArtifact: + """One uploaded file: its S3 key, and a presigned URL when one could be made.""" + + key: str + url: str | None = None + + +@dataclass +class RunArtifacts: + """Artifacts published for a run. Any field is None when its upload failed.""" + + results: RunArtifact | None = None + traces: RunArtifact | None = None + + +# 3 days. Long enough to survive a weekend before someone reads the report. +PRESIGN_EXPIRY_SECONDS = 259200 + + +def presign(s3_client: Any, bucket: str, key: str) -> RunArtifact: + """Wrap an uploaded key with a presigned URL, or just the key if signing fails.""" + try: + url: str = s3_client.generate_presigned_url( + "get_object", + Params={"Bucket": bucket, "Key": key}, + ExpiresIn=PRESIGN_EXPIRY_SECONDS, + ) + return RunArtifact(key=key, url=url) + except Exception as e: + logger.warning(f"Could not generate S3 presigned URL for {key}: {e}") + return RunArtifact(key=key) + + +def artifact_line(label: str, artifact: RunArtifact | None) -> list[str]: + """One markdown line for an artifact: a link when presigned, the key otherwise.""" + if artifact is None: + return [] + if artifact.url: + return [f"[{label}]({artifact.url}) — `{artifact.key}`"] + return [f"{label}: `{artifact.key}`"] + + +def artifact_lines(artifacts: RunArtifacts) -> list[str]: + """Both uploaded artifacts. The reasoning traces carry the full prompts and + model outputs for the run, which is what a failure usually needs to diagnose. + """ + return artifact_line("View Complete Results", artifacts.results) + artifact_line( + "Reasoning traces", artifacts.traces + ) + + +def gha_run_lines() -> list[str]: + """Link to this run's Actions page, which hosts the job summary. + + That summary carries the per-test failure reasons in full, so the Discord + message can stay short and point at it instead of restating them. + """ + run_id = os.getenv("GITHUB_RUN_ID") + repository = os.getenv("GITHUB_REPOSITORY") + if not run_id or not repository: + return [] + server = os.getenv("GITHUB_SERVER_URL", "https://github.com") + return [f"[View GHA]({server}/{repository}/actions/runs/{run_id})"] + + +def failure_lines(results: dict[str, "TestOutcome"]) -> list[str]: + """One markdown bullet per failing test, naming the step and the reason.""" + failed = [(name, o) for name, o in results.items() if o.status != "PASS"] + if not failed: + return [] + lines = ["", "**Failures**"] + for name, outcome in failed: + reason = outcome.failure.describe() if outcome.failure else outcome.status + lines.append(f"- `{name}` — {reason}") + return lines + + +def write_job_summary(lines: list[str]) -> None: + """Append a markdown block to the GitHub Actions job summary; a no-op locally.""" + summary_path = os.getenv("GITHUB_STEP_SUMMARY") + if not summary_path: + return + try: + with open(summary_path, "a", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") + except OSError as e: + logger.warning(f"Could not write job summary: {e}") + + async def save_results_to_s3( - results: dict[str, tuple[str, float]], + results: dict[str, TestOutcome], failed_count: int, total_count: int, execution_time: float, -) -> tuple[str | None, str | None]: - """Save comprehensive test results to S3. - - Returns: - Tuple of (presigned_url, s3_key). Either or both may be None if upload/URL generation fails. - """ +) -> RunArtifacts: + """Save comprehensive test results and reasoning traces to S3.""" try: import boto3 @@ -112,13 +252,13 @@ async def save_results_to_s3( credentials = session.get_credentials() # pyright: ignore if not credentials: logger.warning("No AWS credentials available, skipping S3 upload") - return None, None + return RunArtifacts() except Exception as e: logger.warning(f"Could not verify AWS credentials: {e}, skipping S3 upload") - return None, None + return RunArtifacts() # Create comprehensive results object - timestamp = datetime.now(timezone.utc).isoformat() + timestamp = datetime.now(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") @@ -141,16 +281,27 @@ async def save_results_to_s3( "tests": [ { "name": name, - "status": status, - "duration": duration, + "status": outcome.status, + "duration": outcome.duration, + # The reason a test failed lives only in the job log otherwise, + # where secret masking can render it unreadable. + "failure": ( + { + "step_index": outcome.failure.step_index, + "step_type": outcome.failure.step_type, + "message": outcome.failure.message, + } + if outcome.failure + else None + ), } - for name, (status, duration) in results.items() + for name, outcome in results.items() ], } # 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") + date_str = datetime.now(UTC).strftime("%Y-%m-%d") sha_short = github_sha[:7] if github_sha != "unknown" else "unknown" ref_slug = github_ref.replace("/", "-") # branch names may contain "/" run_slug = f"{ref_slug}-{sha_short}-{github_run_id}-{github_run_attempt}" @@ -169,6 +320,7 @@ async def save_results_to_s3( # 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: RunArtifact | None = None traces_path_str = os.getenv("REASONING_TRACES_FILE") if traces_path_str: traces_path = Path(traces_path_str) @@ -182,6 +334,7 @@ async def save_results_to_s3( ExtraArgs={"ContentType": "application/x-ndjson"}, ) logger.info(f"Saved reasoning traces to S3 key {traces_key}") + traces = presign(s3_client, s3_bucket, traces_key) except Exception as e: logger.error( f"Failed to upload reasoning traces: {e}", exc_info=True @@ -191,20 +344,13 @@ async def save_results_to_s3( 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": results_key}, - ExpiresIn=259200, # 3 days - ) - return url, results_key # pyright: ignore - except Exception as e: - logger.warning(f"Could not generate S3 presigned URL: {e}") - return None, results_key + return RunArtifacts( + results=presign(s3_client, s3_bucket, results_key), traces=traces + ) except Exception as e: logger.error(f"Failed to save results to S3: {e}", exc_info=True) - return None, None + return RunArtifacts() class UnifiedTestExecutor: @@ -248,7 +394,10 @@ class UnifiedTestExecutor: ) return response - async def execute(self, test_def: TestDefinition, test_name: str) -> bool: + async def execute( + self, test_def: TestDefinition, test_name: str + ) -> StepFailure | None: + """Run every step. Returns None on success, or the failure that stopped it.""" logger.info(f"Starting test: {test_name}") # 1. Apply workspace config if present @@ -264,10 +413,12 @@ class UnifiedTestExecutor: await self.execute_step(step) except Exception as e: logger.error(f"Step {i + 1} failed: {e}", exc_info=False) - return False + return StepFailure( + step_index=i + 1, step_type=step.step_type, message=str(e) + ) logger.info(f"Test {test_name} PASSED") - return True + return None async def execute_step(self, step: Any): if isinstance(step, SetWorkspaceConfigAction): @@ -357,7 +508,9 @@ class UnifiedTestExecutor: if step.duration: await asyncio.sleep(step.duration) if step.target == "queue_empty": - # Flush mode is enabled by default in the harness (DERIVER_FLUSH_ENABLED=true) + # Flush is process-wide, not per-step: the harness starts the + # deriver with DERIVER_FLUSH_ENABLED=true so batches never wait + # on the token threshold. See tests/bench/harness.py. await self.wait_for_queue(step.timeout) elif isinstance(step, ScheduleDreamAction): @@ -692,7 +845,7 @@ class UnifiedTestRunner: raise ValueError("tests_dir must be set if test_file is not") test_files = sorted(list(self.tests_dir.glob("*.json"))) - results: dict[str, tuple[str, float]] = {} + results: dict[str, TestOutcome] = {} logger.info(f"Found {len(test_files)} test(s)") @@ -721,23 +874,28 @@ class UnifiedTestRunner: workspace_id=f"test_{test_name}_{int(time.time())}", ) - success = await executor.execute(test_def, test_name) + failure = await executor.execute(test_def, test_name) test_duration = time.time() - test_start_time - results[test_file.name] = ( - "PASS" if success else "FAIL", - test_duration, + results[test_file.name] = TestOutcome( + status="PASS" if failure is None else "FAIL", + duration=test_duration, + failure=failure, ) except ValidationError as e: logger.error(f"Schema validation failed for {test_file}: {e}") test_duration = time.time() - test_start_time - results[test_file.name] = ("INVALID SCHEMA", test_duration) + results[test_file.name] = TestOutcome( + status="INVALID SCHEMA", duration=test_duration + ) except Exception as e: logger.error( f"Test {test_file.name} failed with error: {e}", exc_info=True ) test_duration = time.time() - test_start_time - results[test_file.name] = (f"ERROR: {str(e)}", test_duration) + results[test_file.name] = TestOutcome( + status=f"ERROR: {str(e)}", duration=test_duration + ) total_suite_time = time.time() - suite_start_time @@ -752,16 +910,18 @@ class UnifiedTestRunner: # Calculate max name length for alignment max_name_length = max(len(name) for name in results) if results else 0 - for name, (status, duration) in results.items(): - duration_str = f"({duration:.2f}s)" - if status == "PASS": + for name, outcome in results.items(): + duration_str = f"({outcome.duration:.2f}s)" + if outcome.status == "PASS": print( - f"{name:<{max_name_length}} {GREEN}{status:<15}{RESET} {duration_str}" + f"{name:<{max_name_length}} {GREEN}{outcome.status:<15}{RESET} {duration_str}" ) else: print( - f"{name:<{max_name_length}} {RED}{status:<15}{RESET} {duration_str}" + f"{name:<{max_name_length}} {RED}{outcome.status:<15}{RESET} {duration_str}" ) + if outcome.failure: + print(f"{'':<{max_name_length}} {outcome.failure.describe()}") failed_count += 1 print("=" * 60) @@ -771,30 +931,46 @@ class UnifiedTestRunner: # 5. Save results and send notifications # Always attempt S3 upload - save_results_to_s3 will check for credentials - url: str | None - s3_key: str | None - url, s3_key = await save_results_to_s3( + artifacts = await save_results_to_s3( results, failed_count, total_count, total_suite_time ) - # 6. Send Discord notification + # 6. Report the run: GitHub job summary, then Discord. + passed_count = total_count - failed_count + status_emoji = "✅" if failed_count == 0 else "⚠️" + headline = ( + f"Results: {passed_count}/{total_count} passed, " + f"{failed_count}/{total_count} failed" + ) + + write_job_summary( + [ + f"## {status_emoji} Unified Test Results", + "", + headline, + "", + f"Execution time: {total_suite_time:.2f}s", + *failure_lines(results), + "", + *artifact_lines(artifacts), + ] + ) + discord_webhook_url = os.getenv("TEST_DISCORD_WEBHOOK_URL") if discord_webhook_url: - passed_count = total_count - failed_count - status_emoji = "✅" if failed_count == 0 else "⚠️" - message_lines = [ f"{status_emoji} **Unified Test Results**", - f"Results: {passed_count}/{total_count} passed, {failed_count}/{total_count} failed", + headline, f"Execution time: {total_suite_time:.2f}s", + *artifact_line("View Complete Results", artifacts.results), + *gha_run_lines(), + *( + [f"Reasoning traces: `{artifacts.traces.key}`"] + if artifacts.traces + else [] + ), ] - if s3_key: - message_lines.append(f"File: `{s3_key}`") - if url: - message_lines.append(f"[View Complete Results]({url})") - message = "\n".join(message_lines) - - await send_discord_message(discord_webhook_url, message) + await send_discord_message(discord_webhook_url, message_lines) return failed_count diff --git a/tests/unified/schema.py b/tests/unified/schema.py index 31e30946..bb54da71 100644 --- a/tests/unified/schema.py +++ b/tests/unified/schema.py @@ -1,7 +1,7 @@ import datetime from typing import Annotated, Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from src.config import ReasoningLevel from src.schemas import ( @@ -14,6 +14,8 @@ from src.schemas import ( class TestStep(BaseModel): + model_config = ConfigDict(extra="forbid") # pyright: ignore + description: str | None = None @@ -89,10 +91,6 @@ class WaitAction(TestStep): ) target: Literal["queue_empty"] = "queue_empty" timeout: int = 60 - flush: bool = Field( - False, - description="Enable flush mode to bypass batch token threshold before waiting", - ) # --- Dream Actions --- diff --git a/tests/unified/test_cases/config_deriver_hierarchy.json b/tests/unified/test_cases/config_deriver_hierarchy.json index 1ab883db..8eb7bc9d 100644 --- a/tests/unified/test_cases/config_deriver_hierarchy.json +++ b/tests/unified/test_cases/config_deriver_hierarchy.json @@ -34,8 +34,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", @@ -86,8 +85,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/config_message_positive_override.json b/tests/unified/test_cases/config_message_positive_override.json index 911f21b5..a9a46623 100644 --- a/tests/unified/test_cases/config_message_positive_override.json +++ b/tests/unified/test_cases/config_message_positive_override.json @@ -36,8 +36,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/config_peercard_control.json b/tests/unified/test_cases/config_peercard_control.json index 11db312a..04145446 100644 --- a/tests/unified/test_cases/config_peercard_control.json +++ b/tests/unified/test_cases/config_peercard_control.json @@ -38,8 +38,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/config_summary_control.json b/tests/unified/test_cases/config_summary_control.json index f71cdeee..69eb9ffd 100644 --- a/tests/unified/test_cases/config_summary_control.json +++ b/tests/unified/test_cases/config_summary_control.json @@ -77,16 +77,15 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", "target": "get_context", "session_id": "session_summary", "summary": true, - "max_tokens": 400, - "observer_peer_id": "eve", + "max_tokens": 4000, + "description": "Unscoped read, so the summary gets 40% of max_tokens. Naming an observer would route through the peer_target path, where the representation and peer card are subtracted from the budget first.", "assertions": [ { "assertion_type": "llm_judge", diff --git a/tests/unified/test_cases/config_summary_control_deriver_off.json b/tests/unified/test_cases/config_summary_control_deriver_off.json index 7a788990..32642457 100644 --- a/tests/unified/test_cases/config_summary_control_deriver_off.json +++ b/tests/unified/test_cases/config_summary_control_deriver_off.json @@ -76,16 +76,15 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", "target": "get_context", "session_id": "session_summary", "summary": true, - "max_tokens": 400, - "observer_peer_id": "eve", + "max_tokens": 4000, + "description": "Unscoped read, so the summary gets 40% of max_tokens. Naming an observer would route through the peer_target path, where the representation and peer card are subtracted from the budget first.", "assertions": [ { "assertion_type": "llm_judge", diff --git a/tests/unified/test_cases/dialectic_reasoning_levels.json b/tests/unified/test_cases/dialectic_reasoning_levels.json index 509e0c39..13cf9780 100644 --- a/tests/unified/test_cases/dialectic_reasoning_levels.json +++ b/tests/unified/test_cases/dialectic_reasoning_levels.json @@ -45,8 +45,7 @@ { "step_type": "wait", "target": "queue_empty", - "timeout": 120, - "flush": true + "timeout": 120 }, { "step_type": "query", diff --git a/tests/unified/test_cases/dialectic_structured_output.json b/tests/unified/test_cases/dialectic_structured_output.json index 59cc1365..bfcb094e 100644 --- a/tests/unified/test_cases/dialectic_structured_output.json +++ b/tests/unified/test_cases/dialectic_structured_output.json @@ -80,8 +80,7 @@ { "step_type": "wait", "target": "queue_empty", - "timeout": 180, - "flush": true + "timeout": 180 }, { "step_type": "query", diff --git a/tests/unified/test_cases/dialectic_tool_calls.json b/tests/unified/test_cases/dialectic_tool_calls.json index 0ae3b923..77845a11 100644 --- a/tests/unified/test_cases/dialectic_tool_calls.json +++ b/tests/unified/test_cases/dialectic_tool_calls.json @@ -80,8 +80,7 @@ { "step_type": "wait", "target": "queue_empty", - "timeout": 180, - "flush": true + "timeout": 180 }, { "step_type": "query", diff --git a/tests/unified/test_cases/dream_knowledge_updates_and_patterns.json b/tests/unified/test_cases/dream_knowledge_updates_and_patterns.json index 7a6f1764..28e262b7 100644 --- a/tests/unified/test_cases/dream_knowledge_updates_and_patterns.json +++ b/tests/unified/test_cases/dream_knowledge_updates_and_patterns.json @@ -44,8 +44,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "add_messages", @@ -75,8 +74,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "add_messages", @@ -114,8 +112,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "schedule_dream", @@ -127,8 +124,7 @@ { "step_type": "wait", "target": "queue_empty", - "timeout": 180, - "flush": true + "timeout": 180 }, { "step_type": "query", @@ -148,7 +144,6 @@ "target": "get_representation", "observer_peer_id": "assistant", "observed_peer_id": "maya", - "session_id": "maya_life_story", "assertions": [ { "assertion_type": "llm_judge", diff --git a/tests/unified/test_cases/longmem_ancash.json b/tests/unified/test_cases/longmem_ancash.json index ef455eb8..52ed0523 100644 --- a/tests/unified/test_cases/longmem_ancash.json +++ b/tests/unified/test_cases/longmem_ancash.json @@ -75,8 +75,7 @@ { "step_type": "wait", "target": "queue_empty", - "timeout": 180, - "flush": true + "timeout": 180 }, { "step_type": "query", diff --git a/tests/unified/test_cases/longmem_ancash_directional.json b/tests/unified/test_cases/longmem_ancash_directional.json index 2871a9a0..b27adf04 100644 --- a/tests/unified/test_cases/longmem_ancash_directional.json +++ b/tests/unified/test_cases/longmem_ancash_directional.json @@ -74,8 +74,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/longmem_ancash_no_session.json b/tests/unified/test_cases/longmem_ancash_no_session.json index b0462d4a..97c7292c 100644 --- a/tests/unified/test_cases/longmem_ancash_no_session.json +++ b/tests/unified/test_cases/longmem_ancash_no_session.json @@ -74,8 +74,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/longmem_giftcard.json b/tests/unified/test_cases/longmem_giftcard.json index 6ef43463..e1fa8fe6 100644 --- a/tests/unified/test_cases/longmem_giftcard.json +++ b/tests/unified/test_cases/longmem_giftcard.json @@ -3664,7 +3664,7 @@ { "step_type": "wait", "target": "queue_empty", - "flush": true + "timeout": 600 }, { "step_type": "query", diff --git a/tests/unified/test_cases/longmem_plank.json b/tests/unified/test_cases/longmem_plank.json index bbdcd765..a9426bbc 100644 --- a/tests/unified/test_cases/longmem_plank.json +++ b/tests/unified/test_cases/longmem_plank.json @@ -154,8 +154,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/longmem_triple_7161e7e2_single-session-assistant.json b/tests/unified/test_cases/longmem_triple_7161e7e2_single-session-assistant.json index 1564b289..c9ed3d39 100644 --- a/tests/unified/test_cases/longmem_triple_7161e7e2_single-session-assistant.json +++ b/tests/unified/test_cases/longmem_triple_7161e7e2_single-session-assistant.json @@ -3719,7 +3719,7 @@ { "step_type": "wait", "target": "queue_empty", - "flush": true + "timeout": 600 }, { "step_type": "query", diff --git a/tests/unified/test_cases/longmem_triple_e47becba_single-session-user.json b/tests/unified/test_cases/longmem_triple_e47becba_single-session-user.json index 48911d89..01165256 100644 --- a/tests/unified/test_cases/longmem_triple_e47becba_single-session-user.json +++ b/tests/unified/test_cases/longmem_triple_e47becba_single-session-user.json @@ -3833,8 +3833,7 @@ { "step_type": "wait", "target": "queue_empty", - "timeout": 600, - "flush": true + "timeout": 600 }, { "step_type": "query", diff --git a/tests/unified/test_cases/longmem_triple_gpt4_59149c77_temporal-reasoning.json b/tests/unified/test_cases/longmem_triple_gpt4_59149c77_temporal-reasoning.json index ded356dd..427f6b6d 100644 --- a/tests/unified/test_cases/longmem_triple_gpt4_59149c77_temporal-reasoning.json +++ b/tests/unified/test_cases/longmem_triple_gpt4_59149c77_temporal-reasoning.json @@ -3431,7 +3431,7 @@ { "step_type": "wait", "target": "queue_empty", - "flush": true + "timeout": 600 }, { "step_type": "query", diff --git a/tests/unified/test_cases/message_deriver_disabled.json b/tests/unified/test_cases/message_deriver_disabled.json index 315c0995..fa6937c9 100644 --- a/tests/unified/test_cases/message_deriver_disabled.json +++ b/tests/unified/test_cases/message_deriver_disabled.json @@ -40,8 +40,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_2peer_bidirectional.json b/tests/unified/test_cases/observation_2peer_bidirectional.json index 17d85a68..ce453889 100644 --- a/tests/unified/test_cases/observation_2peer_bidirectional.json +++ b/tests/unified/test_cases/observation_2peer_bidirectional.json @@ -40,8 +40,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_2peer_both_observe_me_false.json b/tests/unified/test_cases/observation_2peer_both_observe_me_false.json index 5e33adb2..c12c2e50 100644 --- a/tests/unified/test_cases/observation_2peer_both_observe_me_false.json +++ b/tests/unified/test_cases/observation_2peer_both_observe_me_false.json @@ -40,8 +40,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_2peer_default.json b/tests/unified/test_cases/observation_2peer_default.json index ba771615..debd86a9 100644 --- a/tests/unified/test_cases/observation_2peer_default.json +++ b/tests/unified/test_cases/observation_2peer_default.json @@ -40,8 +40,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_2peer_observe_me_false_blocks_observation.json b/tests/unified/test_cases/observation_2peer_observe_me_false_blocks_observation.json index f153c5ea..cf1f7c80 100644 --- a/tests/unified/test_cases/observation_2peer_observe_me_false_blocks_observation.json +++ b/tests/unified/test_cases/observation_2peer_observe_me_false_blocks_observation.json @@ -40,8 +40,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_2peer_observe_me_false_but_can_still_observe_others.json b/tests/unified/test_cases/observation_2peer_observe_me_false_but_can_still_observe_others.json index a5c3de23..b5e606aa 100644 --- a/tests/unified/test_cases/observation_2peer_observe_me_false_but_can_still_observe_others.json +++ b/tests/unified/test_cases/observation_2peer_observe_me_false_but_can_still_observe_others.json @@ -40,8 +40,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_2peer_unidirectional_alice_observes_bob.json b/tests/unified/test_cases/observation_2peer_unidirectional_alice_observes_bob.json index 944efacb..7c1c8703 100644 --- a/tests/unified/test_cases/observation_2peer_unidirectional_alice_observes_bob.json +++ b/tests/unified/test_cases/observation_2peer_unidirectional_alice_observes_bob.json @@ -40,8 +40,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_2peer_unidirectional_bob_observes_alice.json b/tests/unified/test_cases/observation_2peer_unidirectional_bob_observes_alice.json index 99538fa4..c7d411ba 100644 --- a/tests/unified/test_cases/observation_2peer_unidirectional_bob_observes_alice.json +++ b/tests/unified/test_cases/observation_2peer_unidirectional_bob_observes_alice.json @@ -40,8 +40,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_3peer_all_observe_each_other.json b/tests/unified/test_cases/observation_3peer_all_observe_each_other.json index e201309c..fa6cae29 100644 --- a/tests/unified/test_cases/observation_3peer_all_observe_each_other.json +++ b/tests/unified/test_cases/observation_3peer_all_observe_each_other.json @@ -52,8 +52,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_3peer_circular.json b/tests/unified/test_cases/observation_3peer_circular.json index 4ae12689..f7c95ddf 100644 --- a/tests/unified/test_cases/observation_3peer_circular.json +++ b/tests/unified/test_cases/observation_3peer_circular.json @@ -52,8 +52,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_3peer_multiple_observers_one_observed.json b/tests/unified/test_cases/observation_3peer_multiple_observers_one_observed.json index 8449df1f..08768e84 100644 --- a/tests/unified/test_cases/observation_3peer_multiple_observers_one_observed.json +++ b/tests/unified/test_cases/observation_3peer_multiple_observers_one_observed.json @@ -48,8 +48,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", @@ -130,8 +129,7 @@ { "step_type": "wait", "target": "queue_empty", - "timeout": 180, - "flush": true + "timeout": 180 }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_3peer_one_observer_multiple_observed.json b/tests/unified/test_cases/observation_3peer_one_observer_multiple_observed.json index e329b4bc..9a2d060b 100644 --- a/tests/unified/test_cases/observation_3peer_one_observer_multiple_observed.json +++ b/tests/unified/test_cases/observation_3peer_one_observer_multiple_observed.json @@ -48,8 +48,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_3peer_selective_observation.json b/tests/unified/test_cases/observation_3peer_selective_observation.json index b0c08325..f39fef80 100644 --- a/tests/unified/test_cases/observation_3peer_selective_observation.json +++ b/tests/unified/test_cases/observation_3peer_selective_observation.json @@ -48,8 +48,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_4peer_complex_matrix.json b/tests/unified/test_cases/observation_4peer_complex_matrix.json index 23a4c70b..17d723a2 100644 --- a/tests/unified/test_cases/observation_4peer_complex_matrix.json +++ b/tests/unified/test_cases/observation_4peer_complex_matrix.json @@ -52,8 +52,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_asymmetric_visibility.json b/tests/unified/test_cases/observation_asymmetric_visibility.json index 24710477..46acf71a 100644 --- a/tests/unified/test_cases/observation_asymmetric_visibility.json +++ b/tests/unified/test_cases/observation_asymmetric_visibility.json @@ -40,8 +40,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "create_session", @@ -77,8 +76,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_isolation_between_sessions.json b/tests/unified/test_cases/observation_isolation_between_sessions.json index 6ed6c0eb..dddb8eab 100644 --- a/tests/unified/test_cases/observation_isolation_between_sessions.json +++ b/tests/unified/test_cases/observation_isolation_between_sessions.json @@ -40,8 +40,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "create_session", @@ -77,8 +76,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/peer_isolation_test.json b/tests/unified/test_cases/peer_isolation_test.json index 2c87fced..54bc3e8e 100644 --- a/tests/unified/test_cases/peer_isolation_test.json +++ b/tests/unified/test_cases/peer_isolation_test.json @@ -48,8 +48,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "create_session", @@ -81,8 +80,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/scope_confines_recall.json b/tests/unified/test_cases/scope_confines_recall.json index 77589ca8..725cd376 100644 --- a/tests/unified/test_cases/scope_confines_recall.json +++ b/tests/unified/test_cases/scope_confines_recall.json @@ -55,8 +55,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/session_deriver_disabled.json b/tests/unified/test_cases/session_deriver_disabled.json index 2c613fb7..0af60379 100644 --- a/tests/unified/test_cases/session_deriver_disabled.json +++ b/tests/unified/test_cases/session_deriver_disabled.json @@ -30,8 +30,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/workspace_chat_cross_peer.json b/tests/unified/test_cases/workspace_chat_cross_peer.json index 3d474c1d..a611948e 100644 --- a/tests/unified/test_cases/workspace_chat_cross_peer.json +++ b/tests/unified/test_cases/workspace_chat_cross_peer.json @@ -68,8 +68,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/workspace_chat_from_observations.json b/tests/unified/test_cases/workspace_chat_from_observations.json index f40eae04..8142317b 100644 --- a/tests/unified/test_cases/workspace_chat_from_observations.json +++ b/tests/unified/test_cases/workspace_chat_from_observations.json @@ -52,8 +52,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_cases/workspace_deriver_disabled.json b/tests/unified/test_cases/workspace_deriver_disabled.json index ce1a054c..b30e9953 100644 --- a/tests/unified/test_cases/workspace_deriver_disabled.json +++ b/tests/unified/test_cases/workspace_deriver_disabled.json @@ -30,8 +30,7 @@ }, { "step_type": "wait", - "target": "queue_empty", - "flush": true + "target": "queue_empty" }, { "step_type": "query", diff --git a/tests/unified/test_reporting.py b/tests/unified/test_reporting.py new file mode 100644 index 00000000..48734232 --- /dev/null +++ b/tests/unified/test_reporting.py @@ -0,0 +1,143 @@ +"""Tests for how a unified run is reported to Discord and the job summary. + +Discord rejects an over-long payload with a 400, which loses the whole +notification, so the size behavior here is worth pinning down. +""" + +from __future__ import annotations + +import pytest + +from tests.unified.runner import ( + DISCORD_MAX_CONTENT, + RunArtifact, + RunArtifacts, + StepFailure, + TestOutcome, + artifact_line, + artifact_lines, + clamp_lines, + failure_lines, + gha_run_lines, +) + +_PREFIX = "unified-test-results/2026-09-03/1123-merge-abc1234-33779689337-1" + + +def _presigned(name: str, token_len: int) -> RunArtifact: + """A presigned URL of realistic shape; OIDC session tokens dominate its length.""" + url = ( + f"https://honcho-unified-tests.s3.amazonaws.com/{_PREFIX}/{name}" + "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=259200" + f"&X-Amz-Security-Token={'t' * token_len}&X-Amz-Signature={'0' * 64}" + ) + return RunArtifact(key=f"{_PREFIX}/{name}", url=url) + + +def _discord_lines(artifacts: RunArtifacts) -> list[str]: + """Mirror of the Discord report the runner assembles.""" + return [ + "⚠️ **Unified Test Results**", + "Results: 35/41 passed, 6/41 failed", + "Execution time: 1015.42s", + *artifact_line("View Complete Results", artifacts.results), + *gha_run_lines(), + *([f"Reasoning traces: `{artifacts.traces.key}`"] if artifacts.traces else []), + ] + + +@pytest.fixture +def in_actions(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_RUN_ID", "33779689337") + monkeypatch.setenv("GITHUB_REPOSITORY", "plastic-labs/honcho") + + +def test_clamp_lines_leaves_a_short_report_alone() -> None: + lines = ["one", "two", "three"] + assert clamp_lines(lines, DISCORD_MAX_CONTENT) == "one\ntwo\nthree" + + +def test_clamp_lines_drops_the_longest_line_not_the_last() -> None: + """The Actions link is short and leads everywhere; a presigned URL is neither.""" + lines = ["head", "x" * 100, "[View GHA](url)"] + assert clamp_lines(lines, 40) == "head\n[View GHA](url)" + + +def test_clamp_lines_preserves_display_order() -> None: + lines = ["a", "y" * 50, "b", "c"] + assert clamp_lines(lines, 10) == "a\nb\nc" + + +@pytest.mark.usefixtures("in_actions") +@pytest.mark.parametrize("token_len", [0, 400, 900, 1400, 1800]) +def test_discord_report_never_exceeds_the_webhook_limit(token_len: int) -> None: + """Regression: six judge verdicts plus two presigned URLs returned a 400.""" + artifacts = RunArtifacts( + results=_presigned("results.json", token_len), + traces=_presigned("unified-reasoning-traces.jsonl", token_len), + ) + sent = clamp_lines(_discord_lines(artifacts), DISCORD_MAX_CONTENT) + assert len(sent) <= DISCORD_MAX_CONTENT + + +@pytest.mark.usefixtures("in_actions") +@pytest.mark.parametrize("token_len", [0, 400, 900, 1400, 1800]) +def test_actions_link_always_survives_clamping(token_len: int) -> None: + """However long the presigned URLs get, the run stays reachable.""" + artifacts = RunArtifacts( + results=_presigned("results.json", token_len), + traces=_presigned("unified-reasoning-traces.jsonl", token_len), + ) + sent = clamp_lines(_discord_lines(artifacts), DISCORD_MAX_CONTENT) + assert ( + "[View GHA](https://github.com/plastic-labs/honcho/actions/runs/33779689337)" + in sent + ) + + +@pytest.mark.usefixtures("in_actions") +def test_discord_report_omits_per_test_failures() -> None: + """Failure detail belongs in the job summary the Actions link points at.""" + artifacts = RunArtifacts(results=_presigned("results.json", 400)) + assert not any("**Failures**" in line for line in _discord_lines(artifacts)) + + +def test_gha_lines_are_empty_outside_actions(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("GITHUB_RUN_ID", raising=False) + monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) + assert gha_run_lines() == [] + + +def test_artifact_line_falls_back_to_the_key_when_presigning_failed() -> None: + assert artifact_line("Traces", RunArtifact(key="k/x.jsonl")) == [ + "Traces: `k/x.jsonl`" + ] + assert artifact_line("Traces", None) == [] + + +def test_job_summary_keeps_both_signed_links() -> None: + artifacts = RunArtifacts( + results=_presigned("results.json", 900), + traces=_presigned("unified-reasoning-traces.jsonl", 900), + ) + lines = artifact_lines(artifacts) + assert len(lines) == 2 + assert all("https://" in line for line in lines) + + +def test_failure_lines_reports_every_failure_in_full() -> None: + reason = "LLM Judge failed: " + "the model did not recall the fact. " * 20 + results = { + "a.json": TestOutcome("FAIL", 1.0, StepFailure(4, "query", reason)), + "b.json": TestOutcome("PASS", 1.0), + "c.json": TestOutcome("INVALID SCHEMA", 0.1), + } + lines = failure_lines(results) + assert lines[:2] == ["", "**Failures**"] + assert len(lines) == 4 # blank, header, and one bullet per non-PASS + assert reason in lines[2] # untruncated + assert "INVALID SCHEMA" in lines[3] # falls back to status when no StepFailure + + +def test_failure_lines_empty_when_everything_passed() -> None: + assert failure_lines({"a.json": TestOutcome("PASS", 1.0)}) == [] From 699c99368d575cf83d7f881da86f3d7c52a4168a Mon Sep 17 00:00:00 2001 From: ajspig <46900795+ajspig@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:19:15 -0400 Subject: [PATCH 50/50] feat(harness-plugin-core): simplify telemetry headers and HOME-first config path (#1124) * feat(harness-plugin-core): simplify telemetry headers and HOME-first config path - Identity is three headers: X-Honcho-Host `name/version (platform)`, X-Honcho-Plugin `name/version`, X-Honcho-Agent-Model. X-Honcho-Runtime is dropped. TelemetryIdentity gains `plugin` and `platform`. - configPath() resolves env.HOME (then USERPROFILE) before os.homedir(), since Bun's homedir() ignores in-process HOME changes and plugin tests were hitting the real ~/.honcho/config.json. - Extensionless internal imports so consumers no longer need allowImportingTsExtensions to type-check against the source exports. Co-Authored-By: Claude Fable 5.1 * chore: minor nits --------- Co-authored-by: Claude Fable 5.1 --- harness-plugin-core/README.md | 8 ++- harness-plugin-core/src/config.ts | 12 +++- harness-plugin-core/src/index.ts | 13 ++-- harness-plugin-core/src/telemetry.ts | 48 +++++++++----- harness-plugin-core/tests/config.test.ts | 12 +++- harness-plugin-core/tests/telemetry.test.ts | 70 ++++++++++----------- harness-plugin-core/tsconfig.json | 1 - 7 files changed, 100 insertions(+), 64 deletions(-) diff --git a/harness-plugin-core/README.md b/harness-plugin-core/README.md index 0b4525de..60cfefc5 100644 --- a/harness-plugin-core/README.md +++ b/harness-plugin-core/README.md @@ -43,11 +43,12 @@ Pass `telemetryHeaders()` as the SDK's `defaultHeaders`. Arbitrary headers are a | Header | Meaning | Example | |---|---|---| -| `X-Honcho-Host` | Agent host name, or `name/version` | `harness/1.3.13` | -| `X-Honcho-Plugin` | Honcho plugin version | `0.1.3` | -| `X-Honcho-Runtime` | This package's version (always sent) | `0.1.0` | +| `X-Honcho-Host` | Host harness, `name/version (platform)` | `harness/2.1.3 (darwin)` | +| `X-Honcho-Plugin` | Honcho integration, `name/version` | `harness-honcho/0.2.11` | | `X-Honcho-Agent-Model` | The agent's completion model, not a Honcho model | `claude-sonnet-4-5` | +Omit `hostVersion` when the harness does not expose it; `platform` defaults to `process.platform`. + ```ts import { Honcho } from '@honcho-ai/sdk' import { loadConfig, setTelemetryHeaders, telemetryHeaders } from '@honcho-ai/harness-plugin-core' @@ -61,6 +62,7 @@ const honcho = new Honcho({ defaultHeaders: telemetryHeaders({ host: 'harness', hostVersion: '1.3.13', + plugin: 'harness-honcho', pluginVersion: '0.1.3', model: 'claude-sonnet-4-5', }), diff --git a/harness-plugin-core/src/config.ts b/harness-plugin-core/src/config.ts index 126cdb78..8e3647ae 100644 --- a/harness-plugin-core/src/config.ts +++ b/harness-plugin-core/src/config.ts @@ -216,8 +216,18 @@ export function resolveConfig( } } +/** + * `HONCHO_CONFIG_PATH` if set, returned verbatim. Otherwise `.honcho/config.json` + * under `HOME`, then `USERPROFILE` (Windows), then `os.homedir()`. + * + * `env.HOME` is consulted before `os.homedir()` because Bun's `homedir()` ignores + * in-process changes to `process.env.HOME`, so tests that redirect HOME would + * otherwise read and write the real config file. + */ export function configPath(env: NodeJS.Dict = process.env): string { - return env.HONCHO_CONFIG_PATH || join(homedir(), '.honcho', 'config.json') + if (env.HONCHO_CONFIG_PATH) return env.HONCHO_CONFIG_PATH + const home = env.HOME || env.USERPROFILE || homedir() + return join(home, '.honcho', 'config.json') } export function loadConfig(opts: { diff --git a/harness-plugin-core/src/index.ts b/harness-plugin-core/src/index.ts index ab47a2b3..c5894b49 100644 --- a/harness-plugin-core/src/index.ts +++ b/harness-plugin-core/src/index.ts @@ -1,5 +1,3 @@ -export const version = '0.1.0' - export { configPath, loadConfig, @@ -7,7 +5,7 @@ export { resolveConfig, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, -} from './config.ts' +} from './config' export type { AuthConfig, @@ -15,15 +13,16 @@ export type { HostBlock, ResolvedConfig, RootConfig, -} from './config.ts' +} from './config' export { + hostHeaderValue, + pluginHeaderValue, telemetryHeaders, setTelemetryHeaders, HEADER_AGENT_MODEL, HEADER_HOST, HEADER_PLUGIN, - HEADER_RUNTIME, -} from './telemetry.ts' +} from './telemetry' -export type { TelemetryIdentity } from './telemetry.ts' +export type { TelemetryIdentity } from './telemetry' diff --git a/harness-plugin-core/src/telemetry.ts b/harness-plugin-core/src/telemetry.ts index eb8b2dc3..1eef504d 100644 --- a/harness-plugin-core/src/telemetry.ts +++ b/harness-plugin-core/src/telemetry.ts @@ -1,12 +1,14 @@ -import { version } from './index.ts' - /** Optional identity a host plugin knows at Honcho-client construction time. */ export interface TelemetryIdentity { - /** Host app name, e.g. `cursor`, `opencode`. */ + /** Host harness name, e.g. `harness`. */ host?: string - /** Host app version, e.g. `2026.8.1`. */ + /** Host harness version, e.g. `2.1.3`. Omit when the harness does not expose it. */ hostVersion?: string - /** Honcho plugin version, e.g. `0.1.2`. */ + /** OS platform. Defaults to `process.platform`. */ + platform?: string + /** Integration (plugin) name, e.g. `harness-honcho`. */ + plugin?: string + /** Integration version, e.g. `0.2.11`. */ pluginVersion?: string /** Agent completion model, e.g. `claude-sonnet-4-5`. Not a Honcho deriver/dialectic model. */ model?: string @@ -14,7 +16,6 @@ export interface TelemetryIdentity { export const HEADER_HOST = 'X-Honcho-Host' export const HEADER_PLUGIN = 'X-Honcho-Plugin' -export const HEADER_RUNTIME = 'X-Honcho-Runtime' export const HEADER_AGENT_MODEL = 'X-Honcho-Agent-Model' function sanitize(value: unknown): string | undefined { @@ -23,24 +24,39 @@ function sanitize(value: unknown): string | undefined { return s || undefined } -function hostValue(id: TelemetryIdentity): string | undefined { - const name = sanitize(id.host) - const ver = sanitize(id.hostVersion) - if (name && ver) return `${name}/${ver}` - return name || ver +/** A `name/version` product token. Characters that would break parsing become `-`. */ +function token(name: unknown, ver: unknown): string | undefined { + const clean = (v: unknown) => sanitize(v)?.replace(/[\s()/;]+/g, '-') + const n = clean(name) + const v = clean(ver) + if (n && v) return `${n}/${v}` + return n || v +} + +/** `X-Honcho-Host` value: `harness/2.1.3 (darwin)`. Undefined when the host is unknown. */ +export function hostHeaderValue(id: TelemetryIdentity = {}): string | undefined { + const host = token(id.host, id.hostVersion) + if (!host) return undefined + const platform = token(id.platform ?? process.platform, undefined) + return platform ? `${host} (${platform})` : host +} + +/** `X-Honcho-Plugin` value: `harness-honcho/0.2.11`. Undefined when the plugin is unknown. */ +export function pluginHeaderValue(id: TelemetryIdentity = {}): string | undefined { + return token(id.plugin, id.pluginVersion) } /** - * Headers to pass as the SDK's `defaultHeaders`. Missing fields are omitted. - * `X-Honcho-Runtime` is always this package's version. + * Headers to pass as the SDK's `defaultHeaders`. Fields are omitted when unknown, so a + * partial identity (e.g. just `model`) only touches the headers it names. */ export function telemetryHeaders( id: TelemetryIdentity = {}, extra?: Record ): Record { - const headers: Record = { [HEADER_RUNTIME]: version } - const host = hostValue(id) - const plugin = sanitize(id.pluginVersion) + const headers: Record = {} + const host = hostHeaderValue(id) + const plugin = pluginHeaderValue(id) const model = sanitize(id.model) if (host) headers[HEADER_HOST] = host if (plugin) headers[HEADER_PLUGIN] = plugin diff --git a/harness-plugin-core/tests/config.test.ts b/harness-plugin-core/tests/config.test.ts index 2c1b6a3e..e091f6df 100644 --- a/harness-plugin-core/tests/config.test.ts +++ b/harness-plugin-core/tests/config.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from 'bun:test' -import { normalizeBaseUrl, resolveConfig } from '../src/index.ts' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { configPath, normalizeBaseUrl, resolveConfig } from '../src/index' const emptyEnv = {} @@ -69,3 +71,11 @@ describe('resolveConfig', () => { expect(cfg.workspace).toBe('my-host') }) }) + +describe('configPath', () => { + test('HONCHO_CONFIG_PATH, then $HOME, then os.homedir()', () => { + expect(configPath({ HONCHO_CONFIG_PATH: '/x/cfg.json', HOME: '/h' })).toBe('/x/cfg.json') + expect(configPath({ HOME: '/scratch' })).toBe('/scratch/.honcho/config.json') + expect(configPath({})).toBe(join(homedir(), '.honcho', 'config.json')) + }) +}) diff --git a/harness-plugin-core/tests/telemetry.test.ts b/harness-plugin-core/tests/telemetry.test.ts index 170c9e7a..fa67e489 100644 --- a/harness-plugin-core/tests/telemetry.test.ts +++ b/harness-plugin-core/tests/telemetry.test.ts @@ -3,54 +3,54 @@ import { HEADER_AGENT_MODEL, HEADER_HOST, HEADER_PLUGIN, - HEADER_RUNTIME, setTelemetryHeaders, telemetryHeaders, - version, -} from '../src/index.ts' +} from '../src/index' describe('telemetryHeaders', () => { - test('empty identity still sends the runtime version', () => { - expect(telemetryHeaders()).toEqual({ [HEADER_RUNTIME]: version }) - }) - - test('maps identity to headers', () => { - expect( - telemetryHeaders({ - host: 'opencode', - hostVersion: '1.3.13', - pluginVersion: '0.1.3', - model: 'claude-sonnet-4-5', - }) - ).toEqual({ - [HEADER_RUNTIME]: version, - [HEADER_HOST]: 'opencode/1.3.13', - [HEADER_PLUGIN]: '0.1.3', + test('maps identity to the three headers', () => { + const headers = telemetryHeaders({ + host: 'harness', + hostVersion: '2.1.3', + platform: 'darwin', + plugin: 'harness-honcho', + pluginVersion: '0.2.11', + model: 'claude-sonnet-4-5', + }) + expect(headers).toEqual({ + [HEADER_HOST]: 'harness/2.1.3 (darwin)', + [HEADER_PLUGIN]: 'harness-honcho/0.2.11', [HEADER_AGENT_MODEL]: 'claude-sonnet-4-5', }) }) - test('merges extra headers last, skipping blanks', () => { - const headers = telemetryHeaders({ host: 'codex', pluginVersion: '0.1.1' }, { - 'X-Custom': 'yes', + test('omits unknown fields and defaults platform', () => { + expect(telemetryHeaders()).toEqual({}) + expect(telemetryHeaders({ host: 'harness' })).toEqual({ + [HEADER_HOST]: `harness (${process.platform})`, + }) + }) + + test('strips separators that would break parsing', () => { + expect(telemetryHeaders({ host: 'a b;(c)/d', hostVersion: '1\r\n2', platform: 'darwin' })).toEqual({ + [HEADER_HOST]: 'a-b-c-d/1-2 (darwin)', + }) + }) + + test('extra headers win, blanks are dropped', () => { + const headers = telemetryHeaders({ plugin: 'harness-honcho' }, { [HEADER_PLUGIN]: 'override', 'X-Empty': ' ', }) - expect(headers[HEADER_HOST]).toBe('codex') - expect(headers[HEADER_PLUGIN]).toBe('override') - expect(headers['X-Custom']).toBe('yes') - expect(headers).not.toHaveProperty('X-Empty') + expect(headers).toEqual({ [HEADER_PLUGIN]: 'override' }) }) }) -describe('setTelemetryHeaders', () => { - test('mutates an existing header map in place', () => { - const headers = telemetryHeaders({ host: 'cursor', pluginVersion: '0.1.2' }) - const returned = setTelemetryHeaders(headers, { model: 'claude-opus-4' }) - expect(returned).toBe(headers) - expect(headers[HEADER_HOST]).toBe('cursor') - expect(headers[HEADER_PLUGIN]).toBe('0.1.2') - expect(headers[HEADER_RUNTIME]).toBe(version) - expect(headers[HEADER_AGENT_MODEL]).toBe('claude-opus-4') +test('setTelemetryHeaders updates only the named fields in place', () => { + const headers = telemetryHeaders({ plugin: 'harness-honcho', pluginVersion: '0.1.2' }) + expect(setTelemetryHeaders(headers, { model: 'claude-opus-4' })).toBe(headers) + expect(headers).toEqual({ + [HEADER_PLUGIN]: 'harness-honcho/0.1.2', + [HEADER_AGENT_MODEL]: 'claude-opus-4', }) }) diff --git a/harness-plugin-core/tsconfig.json b/harness-plugin-core/tsconfig.json index 96d10fea..be81d664 100644 --- a/harness-plugin-core/tsconfig.json +++ b/harness-plugin-core/tsconfig.json @@ -3,7 +3,6 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "allowImportingTsExtensions": true, "noEmit": true, "strict": true, "skipLibCheck": true,