Commit Graph

213 Commits

Author SHA1 Message Date
ajspig 96100839a7 Merge remote-tracking branch 'origin/main' into abigail/dev-2219 2026-08-04 15:25:37 -04:00
ajspig 634202bccc feat: conclusion reverse traversal, source_ids filters, SDK batch fetch
- Add GET /conclusions/{id}/derived to walk the reasoning tree upward
  (conclusions that list the given one in their source_ids), paginated,
  wired to the previously-unused get_child_observations crud (now returns
  a Select for pagination; the get_reasoning_chain agent tool updated to
  execute the statement itself)
- Support source_ids in the filter DSL: JSONB containment (@>) instead of
  invalid ILIKE SQL, with explicit JSONB binds for scalars; also fixes
  'contains' on metadata/configuration JSONB columns
- Make id, level, source_ids, times_derived explicit entries in the
  documents filter allowlist
- Python SDK: derived() and get_many() (batch fetch premises via the list
  endpoint with an id-in filter), sync + async
- Tests for the new endpoint, filters, SDK methods, and previously
  uncovered get_reasoning_chain tool handler

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:25:28 -04:00
Alexei Vedernikov d815c8b8dc
fix(llm): support per-request provider timeouts (#832)
* fix(llm): support per-request provider timeouts

* fix(llm): convert Gemini timeout to milliseconds

* fix(llm): validate Gemini HTTP options

* test(llm): type Anthropic stream context args

* test(llm): live per-request timeout coverage for all providers

Two live checks per provider: a generous timeout asserted at the SDK
call boundary, and a tight timeout that must abort well under the 600s
client default. Gemini's async transport can be aiohttp, so its tight
timeout surfaces as asyncio.TimeoutError rather than httpx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(tests): drop extra blank line in anthropic backend test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(llm): validate provider_params.timeout at config load

Move the timeout coercion into src.config as coerce_provider_timeout and
run it from a field validator on ModelOverrideSettings.provider_params, so
a bad value in config.toml/env fails at startup with the exact config path
instead of surfacing per-request as a retried 500. Good values normalize
to float seconds at load. The per-request guard in src.llm.backend now
delegates to the same coercion (wrapping ValueError in ValidationException)
and continues to cover extra_params passed programmatically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: document provider_params.timeout load-time validation and gotchas

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(llm): address review nits on timeout plumbing

Apply eisene's review feedback:
- Rename PROVIDER_TIMEOUT_ERROR → PROVIDER_TIMEOUT_ERROR_TEXT
- Move request_timeout_from_extra_params from backend.py (pure
  dataclasses) to request_builder.py (request assembly)
- Add comment explaining Gemini's ms timeout conversion
- Generalize _normalize_extra_params with _strip_none_params helper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Aakash Kattelu <aakash@plasticlabs.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 12:43:00 -04:00
Rajat Ahuja 148f646796
Fix: vector query top k zero (#970)
* fix: guard against top_k=0 reaching the vector store

Turbopuffer rejects top_k=0 with a 400 ('top_k must be between 1 and
10000'). The fix returns [] for a non-positive top_k, before the embedding call), and
floor the semantic budget at 1 so an explicitly requested search isn't
silently allocated zero.

* fix: comments
2026-08-03 12:31:03 -04:00
ajspig 5eee2eee30 feat: exposing conclusion attribution (source & times derived) 2026-07-29 16:40:11 -04:00
Vineeth Voruganti 4d3ab1c36b
fix: increase throughput of unit tests by changing behavior db teardown (#949)
* fix: increase throughput of unit tests by changing behavior db teardown

* fix: address review comments
2026-07-29 11:19:41 -04:00
Vineeth Voruganti e7cbcc8432
feat: session allowlist on dialectic and representation via filters (#882)
* fix: apply session scoping to all working-representation query paths

session_name was only applied to the recent-documents query in
RepresentationManager; the semantic and most-derived paths ignored it,
so limit_to_session leaked cross-session conclusions into perspectives.

- Thread a session allowlist (session_names) uniformly through all
  three query paths; pushed down to pgvector and external vector stores
- Accept a list so the upcoming session-allowlist API reuses this path
- Fail closed on an empty allowlist (downstream stores drop empty IN
  clauses, which would silently widen scope)

Fixes DEV-1994

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: bare-list membership sugar in the filter DSL

{"session_id": ["s1", "s2"]} is now shorthand for
{"session_id": {"in": [...]}} on regular columns, generically
(peer_id, etc.). JSONB metadata columns are excluded — a bare list
there keeps JSONB containment semantics, unchanged.

Previously a bare list on a regular column compiled to a type-mismatched
equality that matched nothing, so this is strictly additive.

Also translates the same shape in the turbopuffer/lancedb filter
builders, and fixes lancedb dropping empty IN clauses (fail-open) —
an empty membership list now emits an always-false condition.

Groundwork for DEV-1995 (session allowlist via the existing filters
DSL, no new API params)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: session allowlist on dialectic and representation via filters

Adds a constrained 'filters' body to peer.chat and /representation —
the same DSL search and conclusions already accept, supporting only the
session_id key (a session id, a bare list, or {"in": [...]}).
Unsupported keys and shapes are rejected with 422, never silently
ignored. Composes with session_id (must be included in the allowlist
when both are given). Capped at 1,000 sessions per request.

Enforcement is uniform at every recall chokepoint, fail-closed:
- dialectic prefetch + search_memory: conclusion recall restricted to
  the allowlist; dream docs (session_name IS NULL) excluded
- message tools (search/grep/date-range/temporal/context/history):
  strict intersection of allowlist and observer session membership
- get_reasoning_chain: unavailable under an allowlist (chains traverse
  provenance across sessions and cannot be scoped without leaking)
- empty allowlist short-circuits to empty results everywhere

Auth: workspace keys pass the allowlist as-given; peer-scoped JWTs must
be a member of every allowlisted session (403 otherwise), mirroring the
existing single-session check.

Fixes DEV-1995

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: fail closed on empty session allowlist across all filter builders

Empty session allowlists relied solely on the early-return guard in
_get_working_representation_internal. The layers below it were
inconsistent, so a future direct caller (the DEV-1995 allowlist API)
would silently widen scope instead of failing closed:

- _build_filter_conditions used a truthiness check; an empty list was
  treated like None and dropped the filter. Now uses `is not None`,
  matching the recent/most-derived SQL paths.
- turbopuffer emitted a bare `In []` with undocumented (possibly
  fail-open) semantics. Now emits an explicit always-false predicate,
  mirroring lancedb's `1 = 0`.

Also extract the duplicated JSONB column tuple in filter.py to a
JSONB_COLUMNS constant.

Tests exercise each fail-closed guarantee at the layer it lives, rather
than masking it behind the early-return guard.

* fix: address tests

* fix(crud): fail closed when session_name is outside the allowlist

search/grep/history helpers scoped to a single session_name ignored the
session_names allowlist entirely — a caller could read a session the
allowlist forbids. The API routes guarded this with a 422, but the
dialectic tools call these CRUD functions directly and bypassed it.

Enforce it at the boundary: return [] when session_name is set and not in
the allowlist, across _semantic_search_messages (covers search_messages +
search_messages_temporal), grep_messages, get_messages_by_date_range,
get_recent_history, and get_observation_context.

Also rename the public param allowed_sessions -> session_names for
consistency with representation.py / chat.py / peers.py; the resolved
intersection keeps its distinct name allowed_session_names.

* fix: test

* fix(scopes): tighten and consolidate session allowlist per review

Addresses review feedback on the session allowlist (DEV-1995).

Behavior changes:

- Auth gate on peers.chat now uses active membership (left_at IS NULL)
  via get_peer_session_names(active_only=True), matching the adjacent
  is_peer_in_session check on options.session_id. Previously a peer that
  had left a session was denied when naming it directly but permitted
  when naming it in filters.session_id.
- Scoped conclusion recall is restricted to level == "explicit"
  (ALLOWLIST_SAFE_LEVELS). Dream-derived conclusions are stamped with a
  single session_name but synthesized across all sessions, so that stamp
  can't be scoped on. Applied at all four recall paths. Unscoped recall
  is unchanged. Follow-up to give conclusions an authoritative
  source-session set is tracked in DEV-2201.
- The allowlist gate checks `is not None` rather than truthiness, so
  filters={"session_id": []} reaches it instead of being skipped.

Refactors:

- New crud.message.resolve_session_scope replaces four near-identical
  copies of the allowlist-membership intersection. Returns
  (allowlist, deny) and never returns an empty list, so the None vs []
  distinction that external stores fail open on lives in one tested
  place. Takes db=None and opens its own short-lived session only when
  distinction that external stores fail open on lives in one tested
  place. Takes db=None and opens its own short-lived session only when
  an observer lookup is needed, preserving external-lookup-first
  ordering on the vector-store path.
- extract_session_allowlist takes must_include, collapsing the
  session_id-in-allowlist check duplicated across both peer routes.
- DialecticAgent._select_tools dedupes the two toolset-selection blocks
  and drops get_reasoning_chain under an allowlist, rather than paying
  for the schema plus a wasted turn to return a refusal.
- Rename session_names -> session_allowlist across crud, agent tools,
  dialectic and routes, to remove the one-character ambiguity with
  session_name. Internal only; the public filters.session_id surface is
  unchanged.

Docs:

- session_allowlist documented across all message and recall entry
  points, including the None / [] / populated contract.
- session_name marked deprecated for scoping. Not removed and not
  aliased: it also pins the query to one session, bypasses observer
  scoping, and drives session-history injection into the dialectic
  prompt, so it has no drop-in replacement.
- Note at the Document branch in utils/filter.py that the raw-key
  fallback is load-bearing for session scoping.

Tests: 20 -> 39 in tests/test_session_allowlist.py, covering the
peer-scoped JWT gate (member, non-member, left-session, workspace-key
bypass, empty allowlist), the resolve_session_scope tri-state including
the no-DB-checkout path, must_include, and the level narrowing.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:55:02 -04:00
Vineeth Voruganti 3ee890fa6f
Vineeth/sentry filter consolidation (#934)
* fix: centralize sentry before_send filter config

* chore: comply with linter

* fix: default sentry filters
2026-07-24 15:42:41 -04:00
Leonardo Baray d7b64116ac
fix: redact Redis password from cache connection logs (#869)
* fix: redact Redis password from cache connection logs

The cache client logged the full Redis URL — including the password —
at INFO and WARNING levels on every connection attempt and failure.
This exposed the live Redis credential in stdout/container logs and any
downstream log aggregation.

Add _redact_cache_url() to mask the password component before logging.
URLs without a password are returned unchanged.

Closes #866

* fix: handle malformed URLs and IPv6 in _redact_cache_url

Address review feedback from VVoruganti and CodeRabbit:

- Wrap urlparse/urlunparse in try/except so malformed URLs (e.g. invalid
  port) don't raise ValueError inside except blocks, which would crash
  startup instead of degrading gracefully
- Preserve IPv6 brackets (e.g. [::1]) in reconstructed URLs
- Add Google-style Args/Returns docstring sections
- Add unit tests for password masking, no-password URLs, IPv6,
  malformed inputs, and the invalid-port regression

* test: use real secrets in redaction test fixtures

Three fixtures were weakened by copy-paste mangling: literal '***'
placeholders instead of real passwords (assertions trivially true),
an unescaped '#' that truncated netloc parsing via the URL fragment,
and a no-password case that actually contained userinfo. Restore
inputs that genuinely exercise the masking paths.

* fix: never leak password through malformed-URL fallback

The catch-all fallback returned the original URL when parsing failed,
so a Redis URL with a password and an invalid port (typo, out-of-range)
was logged in clear text - the exact leak #866 exists to fix. Narrow
the handling: .port access gets its own try/except (invalid port is
omitted from the output; userinfo/hostname masking never raises), and
the outer fallback now returns a generic placeholder instead of the
raw input. Tightened the invalid-port test to assert the password is
absent and added out-of-range-port and unparseable-URL cases.

* fix: redact secrets in query params and scheme-less URLs

_redact_cache_url only masked userinfo, but a credential can reach the
URL through two other real configuration paths: redis-py accepts
?password= (all querystring options become client kwargs) and cashews
accepts ?secret= (its HMAC signing key) - and honcho's own default
CACHE.URL already uses a query param (?suppress=true), so this is the
expected configuration style. Separately, a URL missing its scheme
(':pass@host:6379/0') parses with an empty netloc, making the password
invisible to .password and echoing it back verbatim.

Mask sensitive query values in place on the raw query string (no
decode/re-encode, so non-secret params are preserved byte-for-byte)
and return the generic placeholder for @-carrying strings with no
parseable authority. Verified with a 20k-case randomized fuzz run in
addition to the unit tests: no functional credential reaches the
output.
2026-07-24 12:54:31 -04:00
Vineeth Voruganti 672f4c6637
fix: apply session scoping to all working-representation query paths (#881)
* fix: apply session scoping to all working-representation query paths

session_name was only applied to the recent-documents query in
RepresentationManager; the semantic and most-derived paths ignored it,
so limit_to_session leaked cross-session conclusions into perspectives.

- Thread a session allowlist (session_names) uniformly through all
  three query paths; pushed down to pgvector and external vector stores
- Accept a list so the upcoming session-allowlist API reuses this path
- Fail closed on an empty allowlist (downstream stores drop empty IN
  clauses, which would silently widen scope)

Fixes DEV-1994

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: bare-list membership sugar in the filter DSL

{"session_id": ["s1", "s2"]} is now shorthand for
{"session_id": {"in": [...]}} on regular columns, generically
(peer_id, etc.). JSONB metadata columns are excluded — a bare list
there keeps JSONB containment semantics, unchanged.

Previously a bare list on a regular column compiled to a type-mismatched
equality that matched nothing, so this is strictly additive.

Also translates the same shape in the turbopuffer/lancedb filter
builders, and fixes lancedb dropping empty IN clauses (fail-open) —
an empty membership list now emits an always-false condition.

Groundwork for DEV-1995 (session allowlist via the existing filters
DSL, no new API params)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: fail closed on empty session allowlist across all filter builders

Empty session allowlists relied solely on the early-return guard in
_get_working_representation_internal. The layers below it were
inconsistent, so a future direct caller (the DEV-1995 allowlist API)
would silently widen scope instead of failing closed:

- _build_filter_conditions used a truthiness check; an empty list was
  treated like None and dropped the filter. Now uses `is not None`,
  matching the recent/most-derived SQL paths.
- turbopuffer emitted a bare `In []` with undocumented (possibly
  fail-open) semantics. Now emits an explicit always-false predicate,
  mirroring lancedb's `1 = 0`.

Also extract the duplicated JSONB column tuple in filter.py to a
JSONB_COLUMNS constant.

Tests exercise each fail-closed guarantee at the layer it lives, rather
than masking it behind the early-return guard.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:41:34 -04:00
Vineeth Voruganti a15c782985
Session-purity invariant + card_refresh dream type (DEV-2000) (#883)
* fix: enforce explicit-document session purity in dedup/merge paths

Audit for DEV-2000 (Scopes RFC prerequisite): explicit-level documents must
stay session-pure so scope memory can be built by copying explicit documents
between collections. Two classes of violation were possible:

- Exact-content and semantic dedup in crud/document.py matched candidates
  with no level or session scoping, so an explicit document could be
  reinforced by — or soft-deleted in favor of — a same-content document from
  a different session or a different level (silently merging cross-session
  derivations into one row).
- The generic create_observations tool handler accepted level='explicit'
  from agents with no message context (dreamer/dialectic), which would mint
  session-less explicit documents.

Enforcement (refuse, never rewrite):
- create_documents refuses explicit documents with a null session_name
- exact dedup keys on (content, level, session-for-explicit); derived levels
  keep cross-session consolidation
- is_rejected_duplicate scopes candidate search to the same level, and the
  same session for explicit documents
- the create_observations tool rejects explicit-level input outside message
  ingestion (deriver) context

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: add card_refresh dream type for event-driven peer-card updates

Adds a lightweight dream variant (DEV-2000, Scopes RFC prerequisite) that
runs ONLY the peer-card update — for event-driven refreshes such as scope
membership changes and cold starts:

- DreamType.CARD_REFRESH alongside OMNI; dispatched by process_dream to a
  new run_card_refresh_dream orchestration
- CardRefreshSpecialist: restricted to get_recent_observations,
  search_memory, and update_peer_card (no observation-mutating tools), with
  a low tool-iteration cap of min(6, DREAM.MAX_TOOL_ITERATIONS)
- rebuild=True mode carried in the dream payload: the existing card is NOT
  injected into the prompt and the specialist rebuilds it solely from
  observations present in the collection (for use after removals)
- enqueue-able via the manual enqueue_dream path (bypasses volume gates);
  the work-unit key already embeds the dream type so a card refresh never
  collides with a pending omni dream. POST /v3/workspaces/{id}/schedule_dream
  accepts dream_type=card_refresh plus the rebuild flag
- card refreshes never advance the omni dream guard pair
  (last_dream_at / last_dream_document_count)
- shared PEER CARD prompt section extracted (verbatim) from
  DeductionSpecialist for reuse; CallPurpose gains dream.card_refresh

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: fix tests

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:22:07 -04:00
Eugene Eisenstein 063aaa97a6
feat(dialectic): optional structured outputs with limited schema for Dialectic calls (#896)
* Structured outputs for dialectic

* cleanup

* rename json_schema_to_pydantic to clarify it's not a general schema converter

* clean up schema DoS guards

* simplification and cleanup of schema conversion

* chore: ruff and pyproject toml

* chore: basedpyright cleanup in test

* fix: some needed unrelated test failures

* test(schema_conversion-and-anthropic-backend): expand test coverage

include table tests

* fix(llm): support combined tool calling and structured output across backends

- OpenAI: parse() 500s on non-strict function tools; route tool-carrying
  structured requests through create() with an explicit json_schema
  response_format (mirrors the streaming path)
- Anthropic: skip the '{' JSON prefill when tools are present so tool_use
  blocks stay reachable; make the schema instruction conditional and rely
  on parse + repair
- Gemini: native response_schema + function calling is rejected before
  Gemini 3; with tools present, inject a schema instruction into the final
  turn instead and rely on parse + repair
- All backends: tool-call turns carry no consumable content, so skip
  structured-output parsing on them

Extracted from the dialectic structured-output branch (DEV-1652) so the
transport layer can land independently.

DEV-2035

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(live_llm): exercise combined tools + structured output per provider

Two-turn live flow per backend: a forced tool-call turn (structured
parsing must be skipped) followed by a replay turn that must return a
schema-conforming answer with tools still attached. Asserts the
provider-specific request shaping: no parse() for OpenAI (500s on
non-strict tools), no '{' prefill for Anthropic, no native
response_schema for Gemini.

Verified against live OpenAI (gpt-4.1, gpt-5, gpt-5.4, gpt-5.4-mini)
and Gemini (gemini-2.5-flash).

DEV-2035

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(unified): dialectic chat with response_format schema under tool use

Adds response_format pass-through to the unified runner's chat query and
a test case that forces the dialectic tool loop (reasoning off + global
enumeration question) while requiring a schema-conforming JSON answer —
end-to-end coverage of the combined tools + structured output transport
path on whichever provider each level is configured with.

Verified locally against a full harness run (json_match assertions pass;
the llm_judge assertion additionally runs in CI where the Anthropic key
is available).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: some needed unrelated test failures

* ci: add label-triggered live LLM test workflow

Adding the run-live-llm label to a PR (or workflow_dispatch) runs
tests/live_llm/ against real provider APIs — the only place the
--live-llm suite runs in CI. Reuses the unified-tests environment and
its Secrets Manager staging-dotenv resolution for provider keys; runs
on ubuntu-latest (no Fly runner, no Docker — the suite only touches the
LLM backends). Pins LIVE_LLM_ANTHROPIC_45_PLUS_MODELS=claude-sonnet-4-5
since the Anthropic family has no default models and would otherwise
silently collect empty.

Opt-in by design: live model behavior is variable, so this is a signal,
not a required check.

DEV-2035

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run live LLM tests on main pushes touching the transport

Mirrors unified-tests' push trigger, scoped to paths that can affect
the live suite (src/llm/, config, the tests, deps, and the workflow
itself) so provider API calls aren't spent on unrelated changes.

DEV-2035

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: disable auth in live LLM test environment

The staging dotenv sets AUTH_USE_AUTH=true without a usable JWT secret,
and src/config.py validates the pair at import time — the same reason
unified-tests overrides it. This suite never runs the API server.

DEV-2035

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(live_llm): fix gpt-5.4 reasoning_effort and gemini replay-turn flake

- test_live_openai: gpt-5.4 dropped 'minimal' from the reasoning_effort
  vocabulary, so the gpt5 caching test 400'd — and the OpenAI backend's
  BadRequestError terminal swallowed it into an empty CompletionResult.
  Pick the effort per model generation.
- test_live_tools_structured_output: use tool_choice='auto' on the
  replay turn, matching the production dialectic loop (which never
  forces 'none') — NONE mode is what provoked gemini-2.5-flash's empty
  candidates. Drop the temperature pin so retries actually resample,
  and treat a repeat tool call as a retryable attempt.

Verified live: full suite green, gemini 4/4 consecutive passes.

DEV-2035

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: fail live LLM run when no staging secret was loaded

If the latest-tag fetch fails and no second tag exists, the fallback
step is skipped rather than failed, and the job would proceed without
provider keys — every test then skips via require_provider_key and the
run goes green. Guard on both fetch outcomes so that path fails loudly.

DEV-2035

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(live-llm-tests-GHA): remove extra comments

* feat(structured-output): enable non-recursive schema references

* docs(structured-outputs): clean up new doc

* test(structured-output): fix caching refs memory leak, add tests

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:46:49 -04:00
Ulysse Pence d2d397f14a Counts documents deduped during representation, exact and semantically similar 2026-07-16 14:30:55 -02:00
Eugene Eisenstein 9e087e8771
feat(llm backend): enable combined tool calling + structured output in the LLM backend transport layer (#907)
* fix(llm): support combined tool calling and structured output across backends

- OpenAI: parse() 500s on non-strict function tools; route tool-carrying
  structured requests through create() with an explicit json_schema
  response_format (mirrors the streaming path)
- Anthropic: skip the '{' JSON prefill when tools are present so tool_use
  blocks stay reachable; make the schema instruction conditional and rely
  on parse + repair
- Gemini: native response_schema + function calling is rejected before
  Gemini 3; with tools present, inject a schema instruction into the final
  turn instead and rely on parse + repair
- All backends: tool-call turns carry no consumable content, so skip
  structured-output parsing on them

Extracted from the dialectic structured-output branch (DEV-1652) so the
transport layer can land independently.

DEV-2035

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(live_llm): exercise combined tools + structured output per provider

Two-turn live flow per backend: a forced tool-call turn (structured
parsing must be skipped) followed by a replay turn that must return a
schema-conforming answer with tools still attached. Asserts the
provider-specific request shaping: no parse() for OpenAI (500s on
non-strict tools), no '{' prefill for Anthropic, no native
response_schema for Gemini.

Verified against live OpenAI (gpt-4.1, gpt-5, gpt-5.4, gpt-5.4-mini)
and Gemini (gemini-2.5-flash).

DEV-2035

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: some needed unrelated test failures

* ci: add label-triggered live LLM test workflow

Adding the run-live-llm label to a PR (or workflow_dispatch) runs
tests/live_llm/ against real provider APIs — the only place the
--live-llm suite runs in CI. Reuses the unified-tests environment and
its Secrets Manager staging-dotenv resolution for provider keys; runs
on ubuntu-latest (no Fly runner, no Docker — the suite only touches the
LLM backends). Pins LIVE_LLM_ANTHROPIC_45_PLUS_MODELS=claude-sonnet-4-5
since the Anthropic family has no default models and would otherwise
silently collect empty.

Opt-in by design: live model behavior is variable, so this is a signal,
not a required check.

DEV-2035

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run live LLM tests on main pushes touching the transport

Mirrors unified-tests' push trigger, scoped to paths that can affect
the live suite (src/llm/, config, the tests, deps, and the workflow
itself) so provider API calls aren't spent on unrelated changes.

DEV-2035

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: disable auth in live LLM test environment

The staging dotenv sets AUTH_USE_AUTH=true without a usable JWT secret,
and src/config.py validates the pair at import time — the same reason
unified-tests overrides it. This suite never runs the API server.

DEV-2035

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(live_llm): fix gpt-5.4 reasoning_effort and gemini replay-turn flake

- test_live_openai: gpt-5.4 dropped 'minimal' from the reasoning_effort
  vocabulary, so the gpt5 caching test 400'd — and the OpenAI backend's
  BadRequestError terminal swallowed it into an empty CompletionResult.
  Pick the effort per model generation.
- test_live_tools_structured_output: use tool_choice='auto' on the
  replay turn, matching the production dialectic loop (which never
  forces 'none') — NONE mode is what provoked gemini-2.5-flash's empty
  candidates. Drop the temperature pin so retries actually resample,
  and treat a repeat tool call as a retryable attempt.

Verified live: full suite green, gemini 4/4 consecutive passes.

DEV-2035

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: fail live LLM run when no staging secret was loaded

If the latest-tag fetch fails and no second tag exists, the fallback
step is skipped rather than failed, and the job would proceed without
provider keys — every test then skips via require_provider_key and the
run goes green. Guard on both fetch outcomes so that path fails loudly.

DEV-2035

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(live-llm-tests-GHA): remove extra comments

* ci(CODEOWNERS): introduce CODEOWNERS and gate GHA heavy test runs behind being a CODEOWNER

* ci(GHA-live-LLM-tests): consolidate common GHA steps

* test(test_live_openai): fix reasoning level adjustment for gpt-5

* test(live-llm-tests): temporary removal of gate to test the workflow

* test(live-llm-tests): revert removal of gate

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:47:49 -04:00
Eugene Eisenstein c7c1597d2c
Fix `unified-tests.yml` secrets (#895)
* Structured outputs for dialectic

* Fix unified-tests.yml secrets overrides

* Revert "Structured outputs for dialectic"

This reverts commit a221e2c282.

* remove throwing validator, document assumption that config overrides be backward compatible

* fix typo

* fix: ruff format config file

* fix: update pyproject.toml to include exclude-newer

---------

Co-authored-by: Rajat Ahuja <rahuja445@gmail.com>
2026-07-13 10:36:34 -04:00
Chris Caldwell de1b4101a6
fix(llm): satisfy lowercase json_object prompt checks (#887)
* fix(llm): satisfy lowercase json_object prompt checks

* style(llm): preserve JSON acronym in prompt
2026-07-12 11:38:31 -07:00
Eugene Eisenstein 6d19c46861
Merge pull request #889 from plastic-labs/eugene/dev-1989
Split `REPRESENTATION_BATCH_MAX_TOKENS` into a "minimum work unit" setting on the producer side and a "maximum LLM tokens" setting on the consumer side
2026-07-09 15:12:27 -04:00
Eugene Eisenstein dcde4b2907 add prometheus metric for in_flight 2026-07-09 12:01:09 -04:00
Eugene Eisenstein 43d962d160 rename to REPRESENTATION_BATCH_TARGET_INPUT_TOKENS 2026-07-09 10:42:52 -04:00
Eugene Eisenstein c9bf53ac06 prevent hammering with message tasks 2026-07-08 18:28:10 -04:00
Eugene Eisenstein be26c859ad split config entry into 2 2026-07-08 12:24:55 -04:00
Aakash Kattelu 602347d76c
feat(telemetry): CloudEvents + Langfuse tracing as projections over a captured LLM stream (#845)
* feat(telemetry): CloudEvents + Langfuse tracing as projections over a captured LLM stream

Capture each LLM call once (CapturedLLMCall) and fan it out to multiple
exporters -- "one data model, two projections": a CloudEvents trace stream
(llm.call.traced / trace.content) and a Langfuse projection, both reconstructing
trace -> run -> step -> generation from the same source of truth.

- Capture seam (src/llm/capture.py): one canonicalization + content-addressed
  hashing point, with an O(N) per-span memo so repeated context isn't re-hashed.
- Session correlation threaded telemetry -> captured call -> exporters,
  namespaced only at the Langfuse export boundary.
- Span identity consolidated onto LLMTelemetryContext; dropped TRACE_ENDPOINT.
- Canonical generation/step names; dreamer branches nest under one dream trace;
  tool calls become spans under their step.
- LANGFUSE_EXPORTER_MODE toggle ("exporter" default; "inline" kept one release
  for side-by-side validation), centralized into computed settings predicates.
- Per-run/per-trace dedup registries (trace_session, langfuse_session) bounded
  by an LRU so dedup and span grouping survive long-running workers.
- Embedding-call tracing; deterministic high-volume event sampling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(telemetry): address trace-review findings (span/step_seq collisions, test, logging)

- Dreamer specialists mint a distinct span_id per execution (trace_id stays the
  shared dream run_id), so their CloudEvents trace resource ids no longer collide
  between deduction and induction.
- Tool-loop no-tool early-return streams the tail with the next ordinal
  (iteration+2) instead of reusing the in-loop call's step_seq, avoiding a
  colliding trace resource id; mirrors the synthesis path.
- Tighten test_clips_oversized_string to assert output stays within TRACE_MAX_BYTES.
- emit_trace logs the swallowed exception with exc_info for debuggability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(telemetry): silence exporter-mode Langfuse warning + drop summarizer run_id placeholder

Two CloudEvents/Langfuse correctness fixes, independent of the trace viewer.

Langfuse exporter-mode gating: annotate_current_generation_io (and its two
executor.py call-site guards) were gated on LANGFUSE_PUBLIC_KEY instead of
langfuse_inline_enabled. In the default `exporter` mode they called
get_client().update_current_generation() with no active @observe span, logging
"No active span in current context" (~14 per dialectic run) and building
throwaway model_dump payloads on every LLM call. The LangfuseExporter projects
I/O from the captured stream, so these helpers must no-op in exporter mode.
Gated all three on langfuse_inline_enabled; added a regression test; fixed a
stale conditional_observe docstring.

Summarizer run_id placeholder: AgentToolSummaryCreatedEvent hardcoded
run_id="deriver"/iteration=0 because summarization is a single LLM call, not an
agentic run. That placeholder pollutes run_id grouping in the CloudEvents stream
(any consumer that groups by run_id sees a phantom "deriver" run). Made
run_id/iteration optional (None) and re-keyed get_resource_id on
message_id:summary_type (the real per-summary identity; run_id/iteration can no
longer identify it); bumped schema_version 2->3. Xatu ingestion stores only the
CloudEvent envelope, so the field/resource_id/version changes are transparent to it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: update docstrings to be less verbose

* fix(telemetry): address PR review on captured-stream tracing

- embedding traces get a fresh span_id under parent_span_id=run_id, so
  sibling embeddings in one run no longer share a span/idempotency key
- capture the provider finish_reason from stream chunks instead of
  hardcoding "stop" on a successful drain
- gate the Langfuse exporter behind TELEMETRY.ENABLED (master switch) so
  disabling telemetry sends no traces at all
- rename _emit_derived_content -> _emit_hashed_content
- inline the _emit_trace wrapper; drop unused trace_session.end_run

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: rename TELEMETRY_TRACE_PAYLOADS to TELEMETRY_TRACE_PAYLOADS_ENABLED

* fix(telemetry): capture provider tool calls in trace stream

The captured trace stream dropped assistant tool calls for openai/gemini:
build_captured_messages only read {role, content, tool_call_id}, but those
providers keep tool calls outside content (openai's tool_calls, gemini's
parts), so replayed tool-call turns landed as empty content and gemini lost
its text and tool results entirely. Anthropic (tool_use in content) was fine.

Normalize each input message per provider into a unified tool_calls
[{id, name, input}] field on CapturedMessage/TraceContentEvent, recovering
gemini text/results along the way, and fold tool_calls into
compute_content_hash so empty-content openai turns no longer collide in the
dedup store. langfuse_exporter._input now surfaces the calls.

Also fix a silent serialization drop: gemini thought_signature is bytes, so
model_dump(mode="json") on the traced event raised UnicodeDecodeError and
emit_trace swallowed it -- dropping the whole tool-calling iteration from the
trace stream (billing and Langfuse were unaffected). base64-encode the
signature on the telemetry path; replay keeps the raw bytes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(telemetry): type replay tool-call dict for bytes signature

thought_signature widened to str | bytes | None, but
_tool_call_result_to_dict's literal was inferred as
dict[str, str | dict[str, Any]], so the bytes assignment failed project-wide
basedpyright (the per-file pre-commit hook didn't catch it). Annotate the
dict as dict[str, Any]; the replay path keeps the raw bytes unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: remove 3 tests

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:49:53 -04:00
ajspig 14538cfc90
Abigail/conclusions level filter (#851)
* feat(conclusions): expose reasoning level + allow filtering by level

The `level` of a conclusion (explicit / deductive / inductive /
contradiction) was filterable server-side but stripped from the
`Conclusion` response and not surfaced in either SDK. This adds it
end-to-end so callers can list explicit-only ("not dreamed on")
conclusions without dropping to raw HTTP.

- api: add `level` to the Conclusion response schema
- python sdk: `ConclusionLevel` type, `level` on Conclusion/response,
  `level=` kwarg on ConclusionScope.list() and the async variant
- ts sdk: `ConclusionLevel` type, `level` on Conclusion/response,
  `level` option on list()
- tests: assert level is exposed; add level-filter list test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(conclusions): use generic filters= on list() instead of level= kwarg

Match the documented SDK convention (peers/sessions/messages all take a
generic `filters` dict passed through to the same dynamic server-side
filter logic) instead of a one-off `level=` kwarg. `level` filtering now
works as `list(filters={"level": "explicit"})` alongside any other
supported filter/operator.

The `level` field on the Conclusion response (added in the previous
commit) is kept — it's still not otherwise returned by the API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(conclusions): allow filtering by level on query() in py + ts SDKs

The branch's level-filter work exposed `filters=` on `list()` but left
`query()` (semantic search) hardcoding `{observer, observed}`, so callers
could filter the list endpoint by reasoning level but not semantic search —
asymmetric in both SDKs.

- Python: add keyword-only `filters` to `ConclusionScope.query` and
  `ConclusionScopeAio.query`, merged over the scope's observer/observed.
- TypeScript: add optional `filters` arg to `ConclusionScope.query`,
  mirroring the existing `list()` change.

The server `/conclusions/query` endpoint already honors filters in the body
(verified against production), so this is purely SDK surface parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(filters): document filtering conclusions by reasoning level

The using-filters page covered workspaces/peers/sessions/messages but not
conclusions. Add a "Filtering Conclusions" section showing level-based
filtering on both list() and query(), including the common "explicit only"
(exclude dream-derived) case and the in[deductive,inductive] inverse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(conclusions): simplify filter merge to a single dict spread

Replace the merged_filters + if-block pattern in list()/query() (py sync,
aio, ts) with a single dict spread that layers the caller's filters over the
scope's observer/observed (and session). No behavior change — same merge
order (caller wins) — just less code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(conclusions): reject scope-managed keys in SDK conclusion filters

The generic filters= argument on ConclusionScope.list()/query() spread
user-supplied filters last, so a stray observer/observed/session key
silently overrode the scope and returned data from a different peer
pair. Add a fail-loud guard in both the Python and TypeScript SDKs that
rejects scope-managed filter keys with a clear error, directing callers
to peer.conclusions / conclusions_of(target) and the session= parameter.
session_id remains a valid filter on query() (which has no dedicated
session parameter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
2026-07-01 10:48:01 -04:00
Rajat Ahuja 2583c126f0
feat: add exact content deduplication in document creation (#861)
* feat: add exact content deduplication in document creation

* feat: add comment for index

* fix: harden times_derived logic across all callers to use max of inputs and existing + 1

---------

Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
2026-07-01 10:42:56 -04:00
Vineeth Voruganti eb386c3ceb
fix: translate canonical tool_choice in OpenAI backend for cross-provider fallback (#850)
The OpenAI backend passed tool_choice through raw while the Anthropic and
Gemini backends translate Honcho's canonical vocabulary to their native
form. On a mixed-provider fallback chain (e.g. Gemini primary -> OpenAI
backup), a canonical "any" reached OpenAI unchanged and was rejected as an
invalid param, since OpenAI only accepts none/auto/required.

Add a _convert_tool_choice to the OpenAI backend mirroring the others so a
single TOOL_CHOICE value resolves correctly regardless of which provider a
fallback lands on. "any"/"required" -> "required", auto/none pass through,
a tool-name string or {"name": ...} dict -> a function selection.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:48:02 -04:00
Vineeth Voruganti 2f3a478948
fix(llm): stop capturing live LLM clients in Langfuse generation spans (#849)
* fix(llm): stop capturing live LLM clients in Langfuse generation spans

honcho_llm_call_inner is the @observe generation boundary, and default
auto-capture serialized every argument into the span input -- including
client_override (a live AsyncOpenAI/genai client) and selected_config
(which carries api_key). Auto-capture deep-copies the client into a
half-constructed object whose teardown raises:

  - AsyncHttpxClientWrapper ... no attribute '_state'      (OpenAI, stderr flood)
  - BaseApiClient ... no attribute '_http_options'         (Gemini, HONCHO-4HA)

and it leaked ModelConfig.api_key into traces.

Switch from auto-capture (denylist) to explicit annotation (allowlist):
disable capture_input/capture_output on the decorator and stamp curated,
serializable input (messages) and output (HonchoLLMCallResponse) via the
new annotate_current_generation_io helper. Full trace fidelity is
preserved; no client object or secret can reach a trace.

Fixes HONCHO-4HA

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(llm): track call tuning knobs as Langfuse model_parameters

Restore full trace fidelity after disabling @observe auto-capture: surface
every tuning knob (temperature, max_tokens, tools, reasoning effort, ...) on
the generation via model_parameters, sourced from the resolved effective
config instead of the raw function args.

Use a deny-list, not an allow-list: dump the whole ModelConfig and exclude
only secret-bearing fields (api_key, base_url, fallback, provider_params), so
new config knobs are traced automatically without keeping a hand-written list
in sync. The live client is never passed -- there is no useful trace
representation of it and serializing it is what triggered HONCHO-4HA.

Adds a deny-list test proving secrets never leak even when the config carries
a real api_key/base_url/provider_params (the production override-client path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(llm): duplicate token usage to Langfuse + skip payload build when disabled

Mirror per-call token usage (input, output, prompt-cache read/creation) onto
the Langfuse generation via usage_details, so Langfuse renders native tokens
and cost in addition to the CloudEvents accounting.

Also guard both generation-annotation blocks behind settings.LANGFUSE_PUBLIC_KEY
so the model_dump-backed model_parameters payload (and the usage dict) are only
built when Langfuse is actually configured (addresses CodeRabbit: the annotate
helper no-ops when disabled, but the payload was still being constructed every
call).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:25:26 -04:00
Rajat Ahuja 1c23a2e24a
chore: use git tags to fetch secrets for unified test (#838)
* chore: use git tags to fetch secrets for unified test

* fix: test failure

* fix: override AUTH_USE_AUTH and SENTRY_ENABLED

* fix: upload traces

* chore: rm run on PR

* fix: rm bucket from logs
2026-06-24 10:29:52 -04:00
TcDrozd 70ce692079
fix(agent_tools): strip display-format "id:" prefix from source_ids and get_reasoning_chain lookups (#795)
* fix(agent_tools): strip display-format "id:" prefix from model-supplied observation IDs

Observations are presented to agents as [id:xxx], and models sometimes
copy the prefix verbatim despite tool-schema instructions to pass the
bare ID. This silently corrupts source_ids provenance on
create_observations_* (broken links stored in document metadata) and
breaks get_reasoning_chain lookups.

Normalize at both entry points. delete_observations is intentionally
not touched here since #746 already covers it.

Only the "id:" prefix is stripped: document IDs are nanoids whose
alphabet includes "-" and "_", so more aggressive cleanup could mangle
legitimate IDs.

Related to #719.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agent_tools): strip whitespace remaining after "id:" prefix removal

Addresses CodeRabbit review: defends against "id: xxx" with a space
after the colon, and matches the docstring, which already promised
surrounding-whitespace stripping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-23 16:49:17 -04:00
Ken Weiner a65a406301
feat: send OpenRouter app-attribution headers on OpenAI-compatible clients (#805)
* feat: send OpenRouter app-attribution headers on OpenAI-compatible clients

Sets HTTP-Referer and X-Title on every AsyncOpenAI client constructed in
src/llm/registry.py (default, override-cached, and module-level CLIENTS) and
in the embedding client, so OpenRouter attributes Honcho's requests to the
"Honcho" app in its dashboard/analytics. Other OpenAI-compatible providers
ignore unrecognized headers, so this is safe to send unconditionally.

* fix: scope OpenRouter attribution headers to OpenRouter base URL only

Address review feedback on #805:
- Only inject attribution headers when the configured base_url starts
  with https://openrouter.ai (via new _openrouter_headers() helper)
- Rename X-Title to X-Openrouter-Title per OpenRouter docs recommendation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: drive default headers from base-URL map, drop embedding path

Replace the OpenRouter-specific _openrouter_headers helper with a generic
_DEFAULT_HEADERS_BY_BASE_URL prefix map + _default_headers_for lookup, so
OpenRouter always receives its attribution headers and another provider can be
added with a single map entry. Revert the embedding-client change (OpenRouter
has no embeddings endpoint, so that gate was dead code) and add a unit test for
the lookup helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 16:11:36 -04:00
Aakash Kattelu a0cc938f4a
feat: add model config option for json_object mode (#820)
* feat: add model config option for json_object mode

* fix: catch possible validation error from structured output

* fix(llm): harden structured_output_mode json_object path

Follow-up fixes to the json_object structured-output mode for
OpenAI-compatible providers without Structured Outputs support:

- runtime: carry structured_output_mode onto the per-attempt fallback
  config (select_model_config_for_attempt dropped it, silently sending
  json_schema to a provider that can't parse it)
- backend: return a graceful empty on a contentless json_object
  response instead of raising, matching the json_schema path, and
  preserve token usage by normalizing the response
- backend: narrow the parse-failure catch to BadRequestError only, so
  transient JSONDecodeError/ValidationError propagate to retry/fallback
  instead of being swallowed to empty on the first attempt
- config: reject structured_output_mode on non-openai transports
  (silent no-op otherwise); trim docs to the deriver, the only
  structured-output feature
- backend: validate clean JSON before repair, cache the schema
  instruction, and share json_object setup between complete()/stream()

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(llm): consolidate structured-output repair, drop dead seam

Fold the OpenAI backend's three structured-output repair sites
(LengthFinishReasonError, parsed=None, json_object) into the one shared
_parse_or_repair_structured_content helper, gated by an empty_on_missing
flag: json_object returns a graceful empty on a contentless response so a
loose provider can't crash the call, while json_schema raises so the
retry/fallback chain engages.

Delete the dead execute_structured_output_call seam and its only
collaborators (attempt_structured_output_repair, StructuredOutputFailurePolicy)
— it was never called and its single-shot validate/repair/empty model
conflicts with the retry behavior in honcho_llm_call.

No behavior change. Adds tests covering the json_schema parse fallbacks
(repair, refusal passthrough, no-content raise).

---------

Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:42:03 -04:00
Aakash Kattelu e8ef1a06e5
Track user and session ID on Langfuse traces (#814)
* telemetry: use session and user IDs in langfuse

* test: update old span test

* fix: disable langfuse in unit tests

* fix: add post-loop synthesis span

* refactor: address PR review feedback on langfuse tracing

- Consolidate track_name onto LLMTelemetryContext as the sole home;
  remove the honcho_llm_call kwarg and update 4 callers to set it on
  telemetry directly. Sentry ai_track now reads telemetry.track_name.
- Decouple escaped-stream self-stamping from run-context exit ordering:
  stream_final_response now resets _in_agent_run explicitly around drain.
- Narrow langfuse_agent_step wrap in the tool loop — between-turn
  bookkeeping (iteration_callback, choice switch, increment) lifted
  outside the span so it scopes only the LLM call + tools.
- Reword test conftest comment to behavior-only language.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: switch langfuse spans to imperative handles

Replaces the context-manager-based langfuse_agent_run/step with imperative
LangfuseAgentRun/Step handles so the run span can outlive the function that
opens it. Streaming responses now own the run handle from construction and
close it after drain, stamping the accumulated streamed text as trace output
(previously blank). Multi-turn generations always stamp provider/model and
step metadata, fixing the regression where only the first turn was annotated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(llm): record effective prompt-only input on run span

The run-level Langfuse span recorded the raw messages parameter, which is
None for prompt-only calls. Mirror execute_tool_loop's handling and record
the synthesized user message so the trace input isn't blank.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(llm): drop StreamingResponseWithMetadata.__anext__ to prevent span leak

The standalone __anext__ delegated straight to the inner stream, bypassing
the token-folding and Langfuse run-handle close that live only in the
__aiter__ generator. Any caller driving the wrapper via anext() instead of
`async for` would leak the run span and lose final-stream token accounting.
Latent today (all callers use `async for`), removed to close the footgun.

Add tests covering the run-handle drain path: full drain stamps the
accumulated streamed text as the span output and closes once; an abandoned
stream still closes via the finally rather than leaking.

* chore(llm): document intentional empty-body propagate_attributes block

The `with propagate_attributes(...): pass` stamps the active @observe trace
root via the context manager's __enter__ side effect; the empty body reads
as deletable dead code. Add a comment so it isn't removed. Addresses PR review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(llm): restore api.py types after __anext__ removal

Dropping StreamingResponseWithMetadata.__anext__ made it stop satisfying
the AsyncIterator protocol, breaking the result annotation and the
isinstance narrowing in honcho_llm_call. Widen the tool-less result
annotation to include StreamingResponseWithMetadata and narrow positively
to HonchoLLMCallResponse before reading .content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
2026-06-23 00:03:21 -04:00
Vineeth Voruganti e2ff106f28
Filter noisy sentry traces/profiles (#834)
* perf(reconciler): only trace Sentry transactions when work is found

The reconciler enqueues sync_vectors every ~5 min per deriver instance.
process_item wrapped every dequeued reconciler task in a single
process_reconciler_task transaction, so idle cycles (the common case,
where the cycle finds no rows and exits immediately) still created and
sampled a transaction + profile, draining Sentry tracing/profiling quota.

Remove the top-level transaction and push tracing into the sync batch
helpers, starting a per-batch transaction only after rows are confirmed.
Idle cycles now emit zero transactions; busy sweeps emit one smaller
transaction per batch operation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(telemetry): drop infra/scrape transactions via a Sentry traces sampler

Sentry was sampling every transaction at a flat traces_sample_rate with no
sampler. The Prometheus /metrics scrape endpoint alone accounted for ~92% of
all traced transactions (and their profiles), with /openapi.json and the
deriver metrics server adding more pure noise.

Add a traces_sampler that returns 0.0 for infra/scrape endpoints (/metrics,
/health, /openapi.json, /docs, /redoc, and metrics/openapi transaction names)
and the configured rate for real traffic. Sampling here (vs
before_send_transaction) means dropped transactions are never recorded or
profiled and the decision propagates to child spans. Shared init covers both
the API server and the deriver worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:14:55 -04:00
Harish Kukreja 414e31c960
feat(deriver): age-flush stalled representation batches (#826)
Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
2026-06-22 17:49:08 -04:00
Rajat Ahuja 326a757cdb
Fix scoped JWTs (#679)
* Peer- and session-scoped JWTs were effectively workspace-scoped: auth() walked the route's declared scope and fell through to a workspace match, so a {w: ws-a, p: alice} token could act on any peer in ws-a.

* feat: peer keys can read sessions they belong to; require workspace on scoped keys

* fix: authorize JWTs by narrowest scope and gate member reads

Follow-up hardening on the narrowest-claim auth fix:

- Scope get_peer_config member-read to the caller's own peer; a session
  member could previously read a co-member's per-session config.
- Enforce session membership on POST /peers/{id}/chat: the session_id
  arrives in the body (invisible to require_auth), so a peer key could
  read any session's injected message history. Check is_peer_in_session
  in the handler before the dialectic runs.
- Consolidate the workspace-match check in auth() to a single hoisted
  guard so no branch can silently re-open cross-workspace access.
- Normalize empty-string scope claims to None in verify_jwt so a blank
  workspace can't satisfy the peer/session token-shape invariant.
- Extract scope_requires_workspace(), shared by verify_jwt and the keys
  API so the creation-time guard and verification invariant can't drift.
  route requires auth) and CLAUDE.md auth-scoping guidance.
- docs: describe narrow-scope key semantics in the platform reference.

---------

Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
2026-06-22 17:30:00 -04:00
Vineeth Voruganti f8bcfa4aa5
Forward provider_params to underlying transport (#821)
* feat(llm): forward provider_params passthroughs (extra_body/headers/query) across backends

* refactor(llm): share provider_params passthrough merge + validate shapes

Extract apply_sdk_passthroughs/coerce_passthrough_mapping into
request_builder; reject non-mapping passthrough values with a clear
ValidationException; cover the Anthropic stream() path; document the
keys in configuration.mdx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: address coderabbit docs nitpicks

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:48:59 -04:00
Aru Sharma a2adeb9f45
Fix surprisal tree kwarg mismatch (#749)
* bug fix

* test: add tests for create_tree k-kwarg handling
2026-06-19 13:49:04 -04:00
adavyas 40513845f8
fix: optimize deriver and dreamer prompt cache prefixes (#806)
* Optimize dreamer prompt cache prefix

* Optimize deriver prompt prefix caching

* fix: specifying target peer

* fix: specifying target observee
2026-06-15 10:58:02 -07:00
Eri Barrett f20a13926e
fix(dedup): reinforce times_derived on duplicate detection (#768)
* fix(dedup): reinforce times_derived on duplicate detection

times_derived was never incremented: the reject-new branch dropped the
reinforcement and the new-wins branch reset the count to 1, so the column
stayed pinned at 1 for nearly every conclusion. With every value equal,
ORDER BY times_derived DESC resolved to arbitrary heap order (oldest rows
first), which froze stale conclusions to the front of injected context.

- reject-new: increment existing_doc.times_derived
- new-wins: carry existing count forward onto the replacement
- add created_at DESC tiebreaker to both most_derived queries

* test(dedup): guard times_derived reinforcement + recency tiebreak

Three regression tests, each fails on pre-fix code:
- most-derived ties break toward recency, not insertion order
- rejecting a duplicate reinforces the surviving doc
- a winning duplicate inherits the replaced doc's count + 1

* fix(dedup): atomic reinforcement increment + deterministic tiebreak

---------

Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
2026-06-11 12:37:19 -04:00
Rajat Ahuja 6aa6033a16
feat: defer embedding messages (#704)
* feat: defer embedding messages

* fix: rm gauges

* feat: embed messages immediately on create with reconciler fallback (#766)

Adds embed_messages_now background task so newly created messages are
searchable within seconds instead of waiting up to the reconciler
interval. Three-phase claim/lease → embed → persist never holds a DB
session across the embedding call; the reconciler remains the fallback
for failures and stragglers.

* fix: harden immediate-embed fast path and cover its error branches

Wrap embed_messages_now in a top-level try/except so a failure in the
claim or persist phase degrades to "reconciler will retry" instead of
escaping into the background-task runner; the rows stay pending+leased
and the reconciler heals them.

Add tests for the previously-uncovered branches: external-store-unavailable
persist path, the file-upload endpoint's embed scheduling, and direct unit
tests for the shared compute_chunk_positions / build_message_vector_record
helpers.

Document the semantic-search eventual-consistency window in search.mdx
(keyword matches are immediate; vector matches lag creation by seconds).

* fix: don't hold DB session across vector-store upserts

* fix: align semantic-search function to filter null rows

---------

Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
2026-06-11 10:31:04 -04:00
Rajat Ahuja bf494257b8
add read db (#773)
* feat: implement read DB and fix queue stale cleanup

* fix: use read_db in internal methods

* fix: mention read db in the CLAUDE.md

* fix: make TRACING checkout hook autocommit-safe; sample cleanup-gate jitter once

The DB.TRACING checkout hook ran `SELECT set_config(...)` at pool checkout,
before the dialect applies the read engine's AUTOCOMMIT isolation level. That
statement autobegins a transaction, and psycopg then refuses to switch the
connection into AUTOCOMMIT ("can't change 'autocommit' now: connection in
transaction status INTRANS"), so every read_only session 500s under TRACING and
the INTRANS connection leaks back to poison later write checkouts. Run the hook
in autocommit and restore the prior mode so it never leaves an open transaction;
set_config(..., is_local=false) is session-scoped and survives the boundary.
Add a regression test (fails without the fix) covering read_only + TRACING.

Also sample the stale-cleanup gate's jittered interval once per attempt instead
of re-rolling it every poll, so the spacing is a fixed deadline per cycle rather
than a random walk (and is testable at non-zero jitter ratios).

* fix: reset request_context in TRACING checkout-hook test

---------

Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
2026-06-10 13:28:36 -04:00
Anthony Yuan f75b336a3c
feat: add generate_jwt.py script for creating scoped JWTs (#757)
* feat: add generate_jwt.py script for creating scoped JWTs

Adds a CLI utility script for generating Honcho JWTs without needing
to call the /v1/keys API endpoint. Useful for local development and
bootstrapping admin tokens.

Features:
- --admin flag for full-access tokens
- --workspace / --peer / --session flags for scoped tokens
- --expires flag with human-friendly duration syntax (e.g. 5h, 30d, 1y)
- --print-only flag for scripting (outputs bare token)

Examples:
  uv run python scripts/generate_jwt.py --admin
  uv run python scripts/generate_jwt.py --admin --expires 24h
  uv run python scripts/generate_jwt.py --workspace my-ws --expires 30d
  uv run python scripts/generate_jwt.py --workspace my-ws --peer my-peer --expires 1y

* docs: document generate_jwt.py in README auth setup section

* fix: remove t='' override to preserve utc_now_iso default in JWTParams

Per CodeRabbit review: explicitly setting t="" bypasses JWTParams's
default utc_now_iso timestamp, causing tokens for the same scope to
become byte-identical. Omitting t lets the default apply, ensuring
each generated token is unique.

* fix: address JWT script review feedback

* fix: type, lint

---------

Co-authored-by: Rajat Ahuja <rahuja445@gmail.com>
2026-06-09 13:49:55 -04:00
Vineeth Voruganti 9f26fdd2ea
Deriver Jitter (#765)
* fix(deriver): Remove connection retry logic and add jitter to polling interval

* chore(docs): Update changelog and document new configurations

* chore: increment version numbers
2026-06-02 11:48:36 -04:00
Vineeth Voruganti 396976db34
Connection Exponential Backoff (#758)
* feat(db): add connection retry, adaptive deriver polling, and pool metrics

Add resilience and visibility for DB connection handling under transaction-
pooler (Supavisor) saturation, where client-connection limits get exhausted
across many tenants.

- get_db/tracked_db now force an eager pool checkout with bounded exponential
  backoff (tenacity), retrying SQLAlchemy TimeoutError + OperationalError so
  transient pooler rejections degrade gracefully instead of 500ing. Toggle via
  DB_CONNECTION_RETRY_ENABLED (+ delay/backoff knobs); ~10s default budget.
- Deriver polling backs off when idle or erroring (base -> max, x2 each cycle)
  and snaps back to base on claimed work, cutting steady-state query load.
  Toggle via DERIVER_POLLING_BACKOFF_ENABLED (+ max/multiplier).
- Add scrape-time db_pool_connections Prometheus gauge (checked_out/checked_in/
  size/overflow, labeled api|deriver), registered in both the API lifespan and
  the deriver metrics server.
- Make SqlalchemyIntegration explicit in both Sentry inits; wrap connection
  acquisition in a db.pool.acquire span and capture live pool stats on
  retry-exhaustion.

* feat(db): add acquisition counter and in-flight query gauge

Build on the pool-connection metrics with two signals that turn detection
into diagnosis under transaction-pooler saturation:

- db_connection_acquisitions{outcome=ok|retried|exhausted}: counts how often
  connection checkout retries through pooler rejection — the alertable early
  warning before requests start failing.
- db_queries_in_flight: statements actually executing on the wire (via
  SQLAlchemy cursor-execute events, drift-proof across query errors). Pairs
  with checked_out: the gap reveals connections held but parked (the "idle in
  transaction during an external call" antipattern). Labeled namespace +
  instance_type only; gated on METRICS.ENABLED for zero overhead when off.

Add DB-free unit tests for retry outcomes, polling backoff, and in-flight
gauge drift handling.

* fix: address CodeRabbit review on PR #758

- db: roll back the session on a retryable checkout failure before
  retrying — a failed autobegin can leave it pending-rollback, making the
  next db.connection() raise instead of re-checking-out cleanly. Cheap
  Python-side cleanup when no connection was bound.
- metrics: guard DBPoolCollector.collect() so a pool-read/import hiccup
  can't raise and abort the whole /metrics scrape (Prometheus drops ALL
  metrics if any collector raises) — log and fall back to empty.

* fix(db): lazy retrying session + review fixes for connection backoff

Address Codex/CodeRabbit review on PR #758.

- Replace eager checkout with HonchoAsyncSession: a lazy AsyncSession that
  checks out its connection (with retry) on the first DB-touching call, not at
  construction. Request handlers doing non-DB work (embedding/file/LLM) before
  their first query no longer pin a connection across it, while the API path
  still gets checkout retry. Only the checkout is retried — the statement runs
  once via super(), so writes are never duplicated. Tracing's set_config moves
  into the same lazy acquire hook.
- Roll the session back on a retryable checkout failure before retrying, so a
  failed autobegin can't leave it pending-rollback.
- Lower default POOL_TIMEOUT to 5s and validate it stays under the retry budget
  for pooled (non-null) POOL_CLASS; update config.toml.example and v2/v3 docs.
- Clamp pool overflow gauge to >= 0 (was negative before the pool fills).
- Remove double-sleep in the deriver idle poll (true backoff cap, not 2x);
  make in-flight instrumentation registration idempotent.
- Tests: HonchoAsyncSession lazy/idempotent acquire, statement-runs-once,
  tracing, commit/rollback flag reset, get_db no-acquire-at-entry, polling-loop
  single-sleep, and the POOL_TIMEOUT/retry-budget validator.

* fix(db): cover all DB-touching session methods; clear flag on close/reset

Address Codex follow-up review on PR #758 (polish, no behavior-critical bug).

- HonchoAsyncSession: wrap get/get_one/stream/stream_scalars/delete in addition
  to execute/scalar/scalars/flush/merge/refresh/commit, so the "lazy checkout
  with retry on first DB use" guarantee has no holes. connection() stays
  unwrapped (acquire_connection_with_retry calls it — wrapping would recurse).
- Reset the acquired flag on close()/reset() too, so a session reused after
  close/reset re-acquires (and re-wraps retry) on its next DB use.
- Fix stale comments: connection retry now applies lazily to the request path
  via HonchoAsyncSession (config.py), and the FakeSession helper note.
- Tests: close/reset flag reset, and get/delete route through acquisition.
2026-06-01 12:57:07 -04:00
adavyas 0cf63c10da
feat(api): restore reverse pagination (#685)
* feat(api): restore v3 reverse pagination

* docs: add reverse pagination docstrings

* docs: document session reverse parameter

* fix: add fallback column for ties

* refactor: tighten reverse query typing

* chore: pre-commit styling

* chore(tests): Add additional validation tests and update changelogs

---------

Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
2026-05-21 13:40:47 -04:00
Vineeth Voruganti 4f579d5c66
fix: reframe peer card prompts as stable identity markers (#686)
* fix: reframe peer card prompts as stable identity markers

* fix: remove strict parameter validation for thinking on anthropic and openai transports

* fix(dreamer): Add backwards compatability instructions for peer card prompt
2026-05-21 13:25:34 -04:00
Vineeth Voruganti 10f72a7d0d
fix(sdk): add peer field to session creation methods (#705) 2026-05-21 12:31:53 -04:00
Rajat Ahuja b5f24a6ac5
feat: add new cloudevents for api routes (#637)
* feat: add new cloudevents for api routes

* fix: add total input tokens to RepresentationCompletedEvent

* feat(telemetry): inject honcho_version + emitter health metrics

* feat(telemetry): per-LLM-call event with try/finally emission + sampler

Adds LLMCallCompletedEvent (llm.call.completed) — fires once per provider hit
with full cost-attribution context: transport/provider_label, model, token
counts with cache breakdown, finish_reason, outcome (success or error),
is_final_attempt flag, retry/fallback state, duration, tool-call shape,
streaming flag, and agent correlation (run_id + iteration).

- src/telemetry/events/llm.py: new event class + CallPurpose closed enum
  (deriver.representation, dialectic.answer, dream.deduction|induction,
  summary.short|long). Resource id includes attempt so multi-attempt retries
  in one iteration get distinct deterministic ids.
- src/telemetry/events/base.py: BaseEvent._volume_class ClassVar (default
  "ground_truth"); the new event opts into "high_volume".
- src/config.py: TelemetrySettings.HIGH_VOLUME_SAMPLE_RATE (default 1.0).
- src/telemetry/emitter.py: deterministic sampler keyed on run_id (so an
  entire agent trace is kept or dropped together). Aggregate envelopes
  bypass the sampler. Sampled-out events increment the dedicated counter
  separate from buffer_full/send_failed drops.
- src/llm/runtime.py: AttemptPlan gains attempt/retry_attempts/is_fallback
  so the executor reads retry state without re-deriving it.
- src/llm/types.py: LLMTelemetryContext dataclass carrying workspace,
  call_purpose, run_id, iteration, peer fields. Iteration is mutable so
  the tool loop can set it per inner call.
- src/llm/executor.py: honcho_llm_call_inner wraps the backend call in
  try/finally — emits on success AND on exception, with is_final_attempt
  computed from AttemptPlan. Stream path emits a was_stream=True placeholder
  (token totals deferred until streaming completion is wired through).
  Telemetry failures swallowed.
- src/llm/api.py: threads telemetry kwarg through all 4 signatures into
  both honcho_llm_call_inner and execute_tool_loop.
- src/llm/tool_loop.py: _telemetry_for_iteration helper copies the caller
  context with iteration set per call — covers both the normal iteration
  loop AND the max-iteration synthesis call (iteration N+1).

Tests cover success/error emission, sampler trace-coherence (same run_id →
same decision), volume_class enforcement, unknown call_purpose tolerance,
provider_label inference, and telemetry failure isolation. 378/378 pass.

* feat(telemetry): emit agent.iteration on every LLM response + synthesis

AgentIterationEvent was defined but never emitted on this branch. Phase 2
wires it up in execute_tool_loop so every LLM call inside an agentic loop
produces one event — including the no-tool terminating iteration and the
max-iteration synthesis call — and threads LLMTelemetryContext from dialectic
and dreamer specialists down through honcho_llm_call.

- src/telemetry/events/agent.py: AgentIterationEvent opts into
  _volume_class="high_volume" so the Phase 1 sampler throttles it.
- src/llm/tool_loop.py: _emit_agent_iteration() helper fires once per
  honcho_llm_call_inner response, BEFORE the no-tool early return so the
  terminating iteration is counted. A second emission fires for the
  max-iteration synthesis call BEFORE final_response is mutated with
  cumulative totals (otherwise the per-iteration counts would double-count).
  Emission is defensively skipped when telemetry context lacks run_id /
  agent_type / parent_category / workspace_name; emit failures are swallowed.
- src/dreamer/specialists.py: BaseSpecialist.run passes LLMTelemetryContext
  with parent_category="dream", agent_type=self.name, observer/observed,
  call_purpose=f"dream.{self.name}".
- src/dialectic/core.py: _telemetry_context() builds a shared context for
  both answer() and answer_stream(), using self._run_id (always set) +
  workspace + observed peer.

Tests cover fresh-copy semantics, per-iteration vs terminating emission,
defensive skip cases, telemetry-failure isolation, and volume_class. 408/408
pass across telemetry + llm + utils + dreamer + dialectic.

* feat(telemetry): agent.tool.call.completed event + ToolResult metadata

Adds the missing generic per-tool-call event so read-only tools (search_*,
get_recent_history, get_observation_context, etc.) and the four existing
state-change tools all produce a telemetry record. Built on a new internal
ToolResult(content, metadata) contract so handlers can surface
search-specific fields (top_k/used_embedding/query_tokens/results_count)
to Phase 3 and create/delete counts to Phase 5's specialist rollups.

- src/telemetry/events/agent.py: AgentToolCallCompletedEvent at v1 with
  _volume_class="high_volume". Resource id = {run_id}:{iteration}:{tool_call_seq}
  so two calls to the same tool in one iteration don't collide
  deterministic ids and get dedup-dropped downstream.
- src/utils/types.py: ToolResult dataclass; two new ContextVars
  (_current_tool_call_seq + _last_tool_metadata) so tool_loop and the
  execute_tool closure can communicate per-call telemetry without changing
  the public Callable[[str, dict], Any] signature.
- src/utils/agent_tools.py: execute_tool times handlers, unwraps ToolResult,
  publishes metadata, emits the event. Handlers updated to ToolResult
  where useful: create/delete observations, update_peer_card, search_memory,
  search_messages. Other handlers continue to return str.
- src/llm/tool_loop.py: set_current_tool_call_seq before each executor call;
  read get_last_tool_metadata after and stash on all_tool_calls[i] for
  Phase 5 rollups.

Tests cover ToolResult str-likeness, ContextVar round-trip, full-context
emission with search metadata, resource-id disambiguation, defensive skip
cases, telemetry isolation, truncation metadata, volume_class. 420/420 pass.

* feat(telemetry): RepresentationCompletedEvent v2 token breakdown + tool-less truncation

Bulks out the deriver's per-batch telemetry without bumping the event schema
version. New additive fields capture the full token breakdown (queued vs.
extra-context vs. scaffold), the cap configuration (batch_max_tokens,
max_input_tokens, was_flush_enabled), real cap-hit flags, and observer
fanout. `input_tokens` stays unchanged as the queued-message-tokens billing
key Xatu's Stripe meter reads.

The big enabler: src/llm/api.py now actually enforces max_input_tokens on
the tool-less LLM path. Before this, the deriver passed the kwarg but the
path silently dropped it — so the configured cap was advisory and
hit_input_token_cap couldn't be measured. Phase 4 wires truncation through
the same truncate_messages_to_fit helper the tool loop uses and surfaces
input_was_truncated on HonchoLLMCallResponse.

- src/telemetry/events/representation.py: 12 additive fields, schema_version
  stays at 2.
- src/llm/types.py: input_was_truncated on HonchoLLMCallResponse.
- src/llm/api.py: tool-less path truncates messages before dispatch, flips
  input_was_truncated on the response when clamping occurs. Split into
  Literal[True]/Literal[False] branches for typecheck.
- src/deriver/queue_manager.py: QueueBatchResult dataclass replaces the
  3-tuple return from get_queue_item_batch; carries hit_batch_token_cap
  (computed from cumulative token sum vs cap), was_flush_enabled snapshot,
  and batch_max_tokens. Worker loop unpacks + forwards.
- src/deriver/consumer.py: process_representation_batch gains the three
  flag kwargs and forwards.
- src/deriver/deriver.py: derives the breakdown fields locally, populates
  the new fields on emit, sources hit_input_token_cap from
  response.input_was_truncated.

Tests cover schema stability, defaultable fields, input_tokens semantic
preservation, cap-hit flag round-trip, model_dump completeness, and
HonchoLLMCallResponse.input_was_truncated mutability. Existing
test_queue_processing.py tests updated for QueueBatchResult and mock
process_representation_batch signature. 479/479 pass.

* feat(telemetry): DreamRunEvent v2 scheduler reasons + DreamSpecialistEvent v2 rollups

Bumps both dream events to v2 with additive fields. DreamRunEvent gains
scheduler context (threshold_reason / delay_reason / documents_since_last_dream_at_schedule /
document_threshold / dream_type / enabled_types_count) threaded through the
dream queue payload — the two scheduler gates stay as separate fields rather
than collapsing into one trigger_reason, preserving the WHY-vs-WHEN
semantics. DreamSpecialistEvent gains denormalized rollups
(created_observation_count / deleted_observation_count / peer_card_updated /
search_tool_calls_count) sourced from Phase 3's ToolResult.metadata so the
counts reflect observation truth, not call truth.

- src/telemetry/events/dream.py: schema_version → 2 for both events; new
  fields all defaultable so older producers still construct valid events.
- src/utils/queue_payload.py: DreamPayload + create_dream_payload accept
  threshold_reason / delay_reason / documents_since_last_dream_at_schedule /
  document_threshold.
- src/dreamer/dream_scheduler.py: check_and_schedule_dream computes the two
  reasons at decision time and threads them through schedule_dream →
  _delayed_dream → execute_dream → enqueue_dream.
- src/deriver/enqueue.py: create_dream_record / enqueue_dream gain the
  kwargs and persist on the queue payload.
- src/dreamer/orchestrator.py: process_dream unpacks the payload; run_dream
  accepts the kwargs and stamps them on DreamRunEvent.
- src/dreamer/specialists.py: BaseSpecialist.run walks response.tool_calls_made
  and sums ToolResult.metadata.created_count / .deleted_count, sets
  peer_card_updated, counts search-tool calls by name.

Tests cover schema_version bumps, defaultable Phase 5 fields,
threshold-vs-delay semantics, observation-vs-call-count rollup distinction,
and DreamPayload round-trip. Existing tests updated for the schema bump
and the new enqueue_dream kwargs. 488/488 pass.

* feat(telemetry): AgentToolSummaryCreatedEvent v2 token breakdown

Bumps schema_version to 2 and adds three additive breakdown fields so
analytics can answer "how much of a summary call's cost was the previous-
summary rollup vs. the new messages vs. the scaffold instructions".

- src/telemetry/events/agent.py: previous_summary_tokens, message_tokens,
  prompt_scaffold_tokens added with sensible 0 defaults. input_tokens
  retains its current semantic (provider-side LLM tokens) — the plan's
  proposed `provider_input_tokens` was omitted because input_tokens
  already serves that purpose and a duplicate would fork queries.
- src/utils/summarizer.py: emit now populates the three new fields from
  values already in scope (messages_tokens, previous_summary_tokens,
  prompt_tokens). Hoisted prompt_tokens calculation out of the
  is_fallback conditional so both the save-summary path and the emit
  share one binding — basedpyright couldn't prove the sibling-scope
  binding was safe, and the compute is cheap + idempotent.

Tests cover schema bump, defaultable fields, input_tokens semantic
preservation, first-summary edge case, and breakdown round-trip.
493/493 pass.

* feat(telemetry): embedding.call.completed event + call-purpose ContextVar

Adds the final piece of cost-attribution telemetry: per-embedding-call
events covering every provider hit (single + batch + retry attempts).
Embedding calls are real provider spend that was invisible before this
phase; search-heavy paths (dialectic agentic) can produce more embedding
calls than LLM calls, so the new event participates in the shared
HIGH_VOLUME_SAMPLE_RATE.

- src/telemetry/events/llm.py: EmbeddingCallCompletedEvent at v1 with
  _volume_class="high_volume". EmbeddingCallPurpose closed enum
  (search_memory / search_messages / create_observations / vector_sync /
  summary / message_create). Resource id = run:purpose:provider:model:input_count
  so per-iteration calls in one agentic run don't collide.
- src/utils/types.py: _embedding_call_purpose ContextVar plus
  @contextmanager wrapper. Nesting-safe via ContextVar.reset(token).
  Callers wrap embedding-driving operations in
  `with embedding_call_purpose("search_memory"): ...` — no changes to
  the embedding client signature.
- src/embedding_client.py: _emit_embedding_call wraps each provider hit
  with try/finally so success AND error paths emit. Errors propagate
  unchanged. Each retry attempt of _process_batch emits its own event.
  Unknown call_purpose slugs drop to None (validation against the enum
  happens at emit time, not at context-manager-set time).
- src/utils/agent_tools.py: search_memory / search_messages /
  search_messages_temporal / create_observations (batch + fallback) all
  tag their embedding calls.
- src/crud/representation.py: save_representation tags with
  CREATE_OBSERVATIONS; get_working_representation precompute tags with
  SEARCH_MEMORY.
- src/crud/message.py: create_messages batch embed tags with
  MESSAGE_CREATE; search_messages/temporal fallback tags with
  SEARCH_MESSAGES.

Tests cover event shape, enum closure, ContextVar nesting/exception
cleanup, wrapper success+error emission, unknown-purpose graceful
fallback, telemetry-failure isolation. 550/550 pass across the full
telemetry+llm+utils+dreamer+dialectic+deriver+crud test set.

* chore: fix tests

* fix(telemetry): address review findings on stream events, context propagation, and cap detection

Five findings from a post-Phase-7 review (one resolved by the merge from
main, four addressed here):

- src/llm/executor.py: stream-path LLMCallCompletedEvent now fires AFTER
  the stream is set up and drained (or on exception), with real duration
  and accurate outcome. Previously the event was emitted before
  execute_stream() ran and was always recorded as outcome="success" with
  duration_ms=0, which silently masked stream-setup and stream-drain
  failures. Wrapping the async generator in try/finally surfaces the real
  outcome; token counts stay 0 because we still don't have them at stream
  end (aggregate envelopes carry totals).

- src/deriver/deriver.py + src/utils/summarizer.py: deriver and summarizer
  LLM calls now thread LLMTelemetryContext into honcho_llm_call. Before
  this, the closed CallPurpose enum had DERIVER_REPRESENTATION /
  SUMMARY_SHORT / SUMMARY_LONG slugs but those production call sites
  didn't actually pass `telemetry=`, so their LLMCallCompletedEvents lost
  workspace_name, parent_category, and call_purpose. summarizer threads
  workspace_name through _create_and_save_summary → _create_summary →
  create_short_summary / create_long_summary.

- src/utils/types.py + src/embedding_client.py: embedding_call_purpose
  ctx manager now accepts workspace_name and run_id kwargs, backed by
  two new ContextVars. EmbeddingCallCompletedEvent's publisher reads
  both via get_embedding_workspace_name / get_embedding_run_id so
  embedding events carry workspace and run correlation. All call sites
  updated: search_memory / search_messages / search_messages_temporal /
  _handle_create_observations_impl pass ctx.workspace_name +
  ctx.run_id; create_observations standalone and create_messages pass
  workspace_name; RepresentationManager.save_representation and
  get_working_representation pass self.workspace_name.

- src/deriver/queue_manager.py: hit_batch_token_cap detection rewritten.
  Previously summed kept-rows' token_count and checked against
  batch_max_tokens, but the SQL filter `cumulative_token_count <= cap`
  guarantees kept rows stay under the cap, so the flag almost never
  fired. Now uses two follow-up queries: total token_count across the
  included id range + EXISTS check for any session message past the
  last-kept id. Both true → cap was actually binding.

(The fifth finding — deriver scaffold-token computation needing
estimate_deriver_prompt_tokens(custom_instructions) — was resolved by
the merge from main; the Phase 4 emit at src/deriver/deriver.py:283
already sources prompt_scaffold_tokens from the wrapped helper.)

567/567 telemetry+llm+utils+dreamer+dialectic+deriver+crud tests pass.
ruff + basedpyright clean.

* chore: ruff linting

* chore: clean AI generated comments references specs

* fix: address coderabbit changes

* fix(telemetry): address remaining PR review findings

Six findings from the PR 637 telemetry review batched into one commit.

- src/llm/executor.py + src/embedding_client.py: asyncio.CancelledError
  now surfaces as outcome="cancelled" on both stream and sync paths,
  distinct from "error". Client disconnects mid-stream and server
  shutdowns are normal control flow and should not feed error-rate
  alerting. LLMCallCompletedEvent and EmbeddingCallCompletedEvent
  outcome Literal extended; docstrings + tests cover the new state.

- src/utils/types.py + src/llm/tool_loop.py: new iteration_scope()
  context manager captures and resets the four per-tool-loop
  ContextVars (_current_iteration, _current_tool_call_seq,
  _current_provider_tool_call_id, _last_tool_metadata). Applied as a
  typed decorator to execute_tool_loop so back-to-back loops in the
  same asyncio Task (worker batches, tests) don't observe stale state.

- src/telemetry/events/api.py + src/routers/messages.py:
  MessageCreatedEvent schema v1 → v2. Added required last_message_id
  (nanoid public_id of the trailing message); get_resource_id now keys
  on it instead of message_count, eliminating the collision case where
  two same-size batches in the same session+source produced identical
  event ids. message_count stays on the body for analytics.

- src/deriver/queue_manager.py: hit_batch_token_cap now computed from
  the FINAL post-config-filter batch. Previously the flag used the
  pre-filter messages_context[-1].id, which produced false positives
  when _resolve_batch_configuration trimmed the trailing queue item —
  telemetry reported a cap-hit when the actual returned batch was
  short for unrelated reasons. Cap-detection block moved inside the
  async with after the filter; no extra DB connection.

- src/config.py + src/telemetry/emitter.py: documented the
  HIGH_VOLUME_SAMPLE_RATE orphan trade-off (rate<1.0 keeps aggregates
  but drops children, so JOIN ON run_id queries see partial traces).
  Behavior unchanged — rate defaults to 1.0.

- src/deriver/deriver.py: WARNING-level invariant logs when
  response.input_tokens < messages_tokens (provider tokenization
  drift) or prompt_scaffold_tokens <= 0 (estimator silent failure).
  Best-effort — telemetry never bleeds into the deriver path

* fix(telemetry): stream retry, embed attempts, truncation, dedup

Address remaining audit findings on the cloudevents PR:

- Stream setup now runs inside the awaited honcho_llm_call_inner so
  tenacity's retry wrapper in stream_final_response catches transient
  setup failures (rate-limit, auth, network). Previously the returned
  generator deferred execute_stream until first iteration — outside
  the retry wrapper — crashing the request and bypassing telemetry.
- Embedding _emit_embedding_call gains an is_final_attempt parameter;
  _process_batch threads the real retry index so dashboards stop
  conflating one-shot, mid-retry, and exhausted-retry calls.
- _truncate_tool_output returns (text, original_chars, was_truncated)
  and a new _maybe_truncated_result helper wraps in ToolResult when
  truncation happens. Five handlers migrated. AgentToolCallCompletedEvent
  fields was_truncated and result_chars_before_truncation are now
  populated instead of always None/False.
- execute_tool_loop tracks any_iteration_truncated and stamps
  input_was_truncated on the final response (both HonchoLLMCallResponse
  and StreamingResponseWithMetadata). Dialectic now reports
  hit_input_token_cap correctly.
- GetContextEvent.get_resource_id uses empty-string sentinel instead
  of literal "none" so a peer named "none" can't collide with absent.
- generate_event_id folds honcho_version into the deterministic id so
  same logical event from different deploys produces distinct ids.

* fix(telemetry): address audit findings across LLM/embed/event paths

Three rounds of telemetry audit findings, grouped by area:

Retry correctness
- Stream LLM setup now runs inside the awaited honcho_llm_call_inner so
  tenacity's outer retry catches setup failures (Fix 1). Previously the
  inner generator deferred execute_stream past the retry wrapper.
- stream_final_response bumps the per-retry attempt index via
  dataclasses.replace so emitted events show [1, 2, 3] instead of
  [1, 1, 1] (Fix 13).
- Embedding _emit_embedding_call takes is_final_attempt; _process_batch
  threads the real retry index (Fix 2).

Token + cost reporting
- HonchoLLMCallResponse.hit_input_token_cap (renamed from
  input_was_truncated) uses a token-based rule so single-message
  over-cap inputs are correctly flagged — the deriver's prompt-only
  path used to silently fly through. Propagated through tool_loop's
  per-iteration check (Fix 4) and into RepresentationCompletedEvent.
- DialecticCompletedEvent gains hit_input_token_cap; output_tokens now
  folds in the final-stream's cumulative usage via
  StreamingResponseWithMetadata.__aiter__ (Fix 7).

Event emission completeness
- AgentToolCallCompletedEvent's was_truncated /
  result_chars_before_truncation populated by _truncate_tool_output via
  a new _maybe_truncated_result wrapper; 5 handlers migrated (Fix 3).
- DreamSpecialistEvent emits on failure with success=False + new
  error_class field, via try/finally (Fix 11).
- DeletionCompletedEvent emits on failure paths via try/finally
  (Fix 12).
- CleanupStaleItemsCompletedEvent.queue_items_cleaned populated from
  deleted_count (Fix 8).

Embedding call attribution (Fix 9)
- embedding_call_purpose context manager accepts parent_category.
- 4 new EmbeddingCallPurpose enum values: DIALECTIC_PREFETCH,
  SESSION_CONTEXT_SEARCH, PREFERENCE_EXTRACTION, GENERIC_DOCUMENT_SEARCH.
- Wrapped previously-unattributed sites: dialectic prefetch, session
  context search, preference extraction, conclusions search, vector
  sync (×2).

Deterministic event ID + dedup
- generate_event_id folds honcho_version into the hash so cross-deploy
  events don't silently collide on ID (Fix 6).
- GetContextEvent resource_id uses empty-string sentinel instead of
  "none" so a peer literally named "none" can't collide (Fix 5).

Queue batch cap detection (P2.1)
- hit_batch_token_cap keys on the pre-config-filter SQL boundary so the
  "kept=900 of 1000 cap, next=300 excluded by cap" case reports True
  while still avoiding the config-filter false positive.

Tool result metadata
- search_messages_temporal returns ToolResult with the same search_meta
  shape as search_memory / search_messages (P2.3) — top_k,
  used_embedding, embedding_query_count, query_tokens, results_count.

Tests: stream-setup retry, stream-retry attempt sequence, post-stream
output_tokens write-back, is_final_attempt matrix, truncation E2E,
tool-loop hit_input_token_cap propagation, honcho_version in event id,
GetContextEvent disambiguation, queue_items_cleaned round-trip.

* fix(telemetry): address audit findings across LLM/embed/event paths

Four rounds of telemetry audit findings (initial + 3 follow-ups), grouped
by area:

Retry correctness
- Stream LLM setup now runs inside the awaited honcho_llm_call_inner so
  tenacity's outer retry catches setup failures (Fix 1). The inner
  generator previously deferred execute_stream past the retry wrapper.
- stream_final_response bumps the per-retry attempt index via
  dataclasses.replace so emitted events show [1, 2, 3] instead of
  [1, 1, 1] (Fix 13).
- Embedding _emit_embedding_call takes is_final_attempt; _process_batch
  threads the real retry index (Fix 2).

Token + cost reporting
- HonchoLLMCallResponse.hit_input_token_cap (renamed from
  input_was_truncated) uses a token-based rule so single-message
  over-cap inputs are correctly flagged — the deriver's prompt-only
  path used to silently fly through. Propagated through tool_loop's
  per-iteration check (Fix 4) and into RepresentationCompletedEvent.
- DialecticCompletedEvent gains hit_input_token_cap; output_tokens now
  folds in the final-stream's cumulative usage via
  StreamingResponseWithMetadata.__aiter__ (Fix 7).

Queue batch cap detection
- hit_batch_token_cap previously required total_in_range >= cap, which
  produced false negatives whenever the kept range didn't fully exhaust
  the budget. Replaced with a pre-config-filter SQL boundary check
  (P2.1), then further refined to a queue-item boundary comparison
  (Fix 14) so trailing-context trimming doesn't false-negative either.

Event emission completeness
- AgentToolCallCompletedEvent's was_truncated /
  result_chars_before_truncation now populated by _truncate_tool_output
  via _maybe_truncated_result; 5 handlers migrated (Fix 3).
- DreamSpecialistEvent emits on failure with success=False + new
  error_class field, via try/finally (Fix 11). except BaseException
  catches cancellations too (Fix 16).
- DeletionCompletedEvent emits on failure paths via try/finally
  (Fix 12), and uses ValidationException for unsupported types per
  project guideline (Fix 17).
- CleanupStaleItemsCompletedEvent.queue_items_cleaned populated from
  deleted_count (Fix 8).

Embedding call attribution (Fix 9)
- embedding_call_purpose accepts parent_category.
- 4 new EmbeddingCallPurpose values: DIALECTIC_PREFETCH,
  SESSION_CONTEXT_SEARCH, PREFERENCE_EXTRACTION, GENERIC_DOCUMENT_SEARCH.
- Wrapped previously-unattributed sites: dialectic prefetch, session
  context search, preference extraction, conclusions search, vector
  sync (×2).

Reconciler no longer holds DB session during embedding (Fix 15)
- _sync_documents and _sync_message_embeddings refactored into
  three phases per CLAUDE.md guideline: fetch+detach in a small DB
  scope, external embedding call without DB locks, writes in a fresh
  short-lived DB scope. New _apply_*_sync helpers; orchestrators
  expunge ORM objects before invoking. Vector store upsert + sync_state
  updates stay in the apply phase together.

Deterministic event ID + dedup
- generate_event_id folds honcho_version into the hash so cross-deploy
  events don't silently collide on ID (Fix 6).
- GetContextEvent resource_id uses empty-string sentinel instead of
  "none" so a peer literally named "none" can't collide (Fix 5).

Tool result metadata
- search_messages_temporal returns ToolResult with the same search_meta
  shape as search_memory / search_messages (P2.3).
- Dialectic.prefetched_conclusion_count uses Representation.len() so
  inductive + contradiction observations count too (Fix 10).

* fix(telemetry): orchestrator emit + review feedback

Three more rounds of audit findings + inline PR review, grouped:

Orchestration / emit reliability
- run_dream wrapped in try/finally so DreamRunEvent always emits, even
  on unexpected exceptions including CancelledError (`finally` still
  runs while cancellation propagates). Specialist except clauses
  broadened from SpecialistExecutionError (never raised in src/) to
  Exception so provider/DB/tool failures are recorded with
  deduction_success=False / induction_success=False instead of crashing
  past the emit.
- BaseSpecialist.run() telemetry state initialization + try/finally
  hoisted above the preflight phase (peer lookup, peer-card preload,
  create_tool_executor, get_model_config, prompt construction) so
  preflight failures emit DreamSpecialistEvent(success=False) instead
  of being dropped on the floor.
- Reverted the Round-4 _sync_documents / _sync_message_embeddings
  phase split. The split introduced a race: rows were released from
  FOR UPDATE SKIP LOCKED before the embed call, allowing two workers
  to claim and clobber the same batch. Long-held DB transaction
  restored (pre-existing CLAUDE.md violation accepted as a deliberate
  trade-off; proper fix requires a claim/in_flight migration tracked
  separately).

Schema + naming (PR-internal — none of these have shipped)
- threshold_reason → trigger_reason on DreamRunEvent, DreamPayload, and
  every emit/scheduler/router/test call site (~45 src + 21 test lines).
  Name now accurately reflects the field's role across "manual",
  "surprisal", and "document_threshold" values.
- MessageCreatedEvent reset to schema v1 (was internally bumped to v2
  for last_message_id but never shipped at v1 — downstream sees it
  for the first time at merge).
- DreamSpecialistEvent gains created_counts_by_level /
  deleted_counts_by_level: dict[str, int] keyed on the closed
  level taxonomy. Per-tool-call events use list[str] (≤10 items),
  but specialist runs aggregate 20+ — dict keeps emissions compact.
- QueueBatchResult marked frozen=True.

Per-call embedding attribution
- Agent tool embedding_call_purpose wraps for search_memory,
  search_messages, search_messages_temporal, create_observations now
  driven embedding cost rolls up under the right workflow.
- create_observations() signature gains parent_category kwarg
  (mirrors existing run_id pattern).

Manual dream scheduling
- Manual /schedule_dream route now passes trigger_reason="manual" and
  delay_reason="immediate". Previously both arrived as null in
  DreamRunEvent, breaking analytics joins.

Queue-batch SQL perf
- next_exists_check folded into the main CTE query via
  bool_or(cumulative_token_count > batch_max_tokens) OVER () in a
  nested subquery. Cap detection is now one roundtrip per batch
  instead of two.

Code/doc cleanup
- representation.py docstring uses generic "downstream metering key"
  language (was "Xatu's Stripe meter"). bench runner --base-url help
  uses a generic example host (was "groudon.fly.dev"). Public-facing
  code/docs shouldn't reference internal service names.

Tests added for: orchestrator failure-path DreamRunEvent emission,
specialists preflight try/finally coverage, manual-dream
trigger_reason/delay_reason round-trip, dict-rollup accumulation across
multiple tool calls in a specialist run, CTE-fold one-roundtrip
behavior. Full Python suite passes (1236).

* fix(telemetry): correctness + attribution + emitter robustness

- Dreamer iteration count: read response.iterations directly so
  one-shot runs no longer report iterations=0 and tool-using runs
  include the terminal/synthesis LLM call.
- RepresentationCompletedEvent.observer_count counts successful
  saves, not attempts.
- search_memory empty-memory fallback reports the snippet count when
  message context is returned (was always 0).
- Wire parent_category through every embedding emit path: message
  create (api), save_representation (representation), per-observation
  fallback (caller-supplied), and the peer/session context routes
  (api). get_working_representation accepts parent_category and
  embedding_purpose so the internal fallback embed lands in the same
  analytics bucket as the route-level precompute even when the
  precompute is suppressed.
- BatchItem carries token_count so _process_batch reuses chunk-prep
  counts instead of re-encoding every chunk for the telemetry proxy.
- Drop vestigial EmbeddingCallCompletedEvent.batch_size (always ==
  input_count).
- Emitter: release the lock during HTTP send so a failing endpoint's
  retry+backoff (~36s worst case) doesn't block other flushers;
  edge-trigger the 80%-capacity warning so sustained backpressure
  doesn't flood logs; defer event_id generation past the high-volume
  sampler for events with run_id so sampled-out children don't pay
  the sha256; harden emit() against sync callers with no running
  loop; track threshold-flush tasks so shutdown() drains in-flight
  sends before closing the HTTP client.

* fix(telemetry): tool cancellation emit, nanoid run_ids, version unification

- execute_tool: wrap post-work in finally so AgentToolCallCompletedEvent
  fires on CancelledError; explicit handler sets is_error/result_str
  before re-raising.
- run_id: replace str(uuid.uuid4())[:8] with generate_nanoid() across
  dialectic/dreamer/specialists; matches project-wide nanoid convention.
- Bump _schema_version on events touched by run_id widening:
  DialecticCompletedEvent v1→v2 (also covers hit_input_token_cap field),
  AgentIterationEvent v1→v2, AgentToolConclusionsCreatedEvent v1→v2,
  AgentToolConclusionsDeletedEvent v2→v3, AgentToolPeerCardUpdatedEvent
  v1→v2.
- Unify honcho_version: single HONCHO_VERSION constant in src/_version.py
  read from pyproject.toml (importlib.metadata fallback). Drop
  TELEMETRY.HONCHO_VERSION setting. Use the constant for the FastAPI app
  version (no more hardcoded "3.0.6") and for emitter body injection.
- Delete 17 tautological per-event test_schema_version methods; the
  parametrized contract test still enforces version >= 1 across all events.

---------

Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
2026-05-20 18:25:30 -04:00
Vineeth Voruganti 8fcbb54a49
Align API contract with DB contract for IDs (#684)
* fix: update api schema to support full 512 ids

* fix: update tests and increment docs version
2026-05-14 16:37:39 -04:00
Vineeth Voruganti b84da15d03
Make embeddings configurable (#678)
* feat(embedding): add dimensions_mode for OpenAI dimensions= forwarding

Add EMBEDDING_MODEL_CONFIG__DIMENSIONS_MODE (auto|always|never) controlling
  whether the dimensions= parameter is forwarded on OpenAI embeddings.create
  calls. auto (default) sends it when the operator explicitly set
  EMBEDDING_VECTOR_DIMENSIONS and the configured model is not on the
  known-rejecting allowlist (currently text-embedding-ada-002).

  The provenance check (was VECTOR_DIMENSIONS explicitly set?) lives as
  EmbeddingSettings.resolve_send_dimensions() because it needs access to
  model_fields_set, which the standalone resolver does not have. The
  resolved boolean is passed into _EmbeddingClient at construction time;
  the client never inspects mode or provenance.

  Also pins cloudevents <2.0 — 2.0.0 reorganized the package and dropped
  cloudevents.conversion and cloudevents.http, which src/telemetry/emitter.py
  imports. The original `>=1.12.0` constraint allowed the broken 2.0 resolve.
  With the pin, the imports resolve cleanly and the basedpyright warning
  cascade (37+ warnings about unknown types) disappears.

  Drive-by cleanups (all unnecessary cast/ignore comments flagged by
  basedpyright after the cloudevents downgrade):
  - vector_store/lancedb.py, tests/conftest.py, and
    tests/deriver/test_vector_reconciliation.py — drop dead pyright ignores
  - sdks/python/src/honcho/http/{async_,}client.py — drop unnecessary
    cast(datetime, ...) (parsedate_to_datetime already returns datetime)
  - vector_store/turbopuffer.py — cast(Any, rows) for the upsert_rows
    TypedDict that the SDK exposes but our row builder doesn't satisfy
  - tests/test_datetime_parsing.py — ignore reportArgumentType on the
    test that deliberately passes wrong types to assert raises

* feat(models): honor EMBEDDING_VECTOR_DIMENSIONS in pgvector columns

* feat(startup): atomic swap dim-vs-MIGRATED guard for runtime schema validator

Add src/startup/embedding_validator.py that introspects the actual pgvector
  column dim at boot and refuses to start if it does not match
  EMBEDDING_VECTOR_DIMENSIONS. Runs after the DB pool is up and before the
  embedding client is constructed, in both src/main.py (FastAPI lifespan) and
  src/deriver/__main__.py.

  Implementation details:
  - Schema-qualified pg_attribute join through pg_class/pg_namespace respects
    DB.SCHEMA rather than relying on search_path
  - Bounded retry (3 attempts, 1s backoff) for transient introspection failure,
    then fail-closed with "could not validate embedding schema" — uncertainty
    is not a green light to serve traffic
  - External-store sampler (turbopuffer, lancedb) enumerates workspaces from
    the application DB and probes their lazy-created namespaces; current
    per-namespace probe is a no-op stub since the SDKs do not expose
    uniform dim introspection — full enumeration is left to
    `configure_embeddings --report` in Phase 3

  Atomic guard swap: deletes the old dim-vs-MIGRATED config validator (which
  forbade non-1536 pgvector unless MIGRATED=True) in the same commit as the
  new runtime validator. There is no release window where non-1536 pgvector
  can start unprotected. The 9 dual-write branches that use VECTOR_STORE.MIGRATED
  remain untouched and load-bearing for legacy-tenant backend swaps.

  VECTOR_STORE_DIMENSIONS deprecation: drop the "must match" raise; in
  propagate_namespace, check model_fields_set and emit logger.warning +
  DeprecationWarning (DeprecationWarning alone is filtered by Python's default
  config and would not reach operators). Always overwrite with
  EMBEDDING.VECTOR_DIMENSIONS regardless.

  Test changes:
  - tests/test_models_vector_dim.py: Phase 1's VECTOR_STORE_TYPE=lancedb +
    MIGRATED=true escape hatches removed; the test now passes on plain
    EMBEDDING_VECTOR_DIMENSIONS=768
  - tests/llm/test_model_config.py: the two tests asserting the old guards
    replaced with tests for the new deprecation + acceptance behavior
  - tests/startup/test_embedding_validator.py: 10 new tests — dim assertion
    logic (pass/mismatch/missing/unbounded/non-public-schema), fail-closed
    retry budget, real-test-DB pass, real-DB ALTER-then-validate, deprecation
    warning capture, non-1536 + pgvector + MIGRATED=false at config time

* feat(scripts): add configure_embeddings bootstrap CLI

Adds scripts/configure_embeddings.py alongside the other one-off scripts
  (provision_db, migrate_db, generate_jwt_secret, etc.). Invoked as
  `uv run python scripts/configure_embeddings.py` — same convention as the
  existing scripts in that directory, including the sys.path shim that
  lets src.* imports resolve when run directly.

  Bootstrap step for self-hosted installs at a non-default
  EMBEDDING_VECTOR_DIMENSIONS — runs between `alembic upgrade head` and
  starting the API/deriver.

  pgvector ALTER safety (single transaction):
  - LOCK TABLE {schema}.documents, {schema}.message_embeddings IN ACCESS
    EXCLUSIVE MODE — closes the TOCTOU window between population check
    and ALTER
  - COUNT(*) WHERE embedding IS NOT NULL on both tables; refuse with a
    non-zero exit if either is populated (ALTER ... USING NULL would
    silently wipe those vectors)
  - Snapshot HNSW index DDL from pg_indexes; drop, ALTER, recreate from
    the captured DDL so operator-set HNSW params (m, ef_construction)
    survive the round trip

  External vector stores (turbopuffer, lancedb) are never created or
  modified — namespaces are per-workspace and lazy-created on first write.
  The --report mode enumerates workspaces and collections from the
  application DB, derives the expected namespaces via
  get_vector_namespace(), and prints a per-namespace status table.

  CLI modes (mutually exclusive):
  - (default) interactive: print plan, prompt to confirm
  - --dry-run: print plan and exit 0 without touching the DB
  - --yes: apply without prompt
  - --report: print external-store namespace inventory and exit

  Also updates src/startup/embedding_validator.py error-message paths and
  docs/v3/contributing/configuration.mdx invocations to point at the new
  script location.

  Tests cover plan no-op, plan needs-alter, plan raises on missing column,
  ALTER + HNSW round-trip, refuse-when-populated (monkeypatched count to
  avoid wiring the full workspace/peer/collection/document FK chain just
  to land one vector row), and idempotency.

* docs: add changing-embeddings operations page

Document the supported way to change EMBEDDING_VECTOR_DIMENSIONS or
EMBEDDING_MODEL_CONFIG__MODEL on a Honcho deployment: provision a new
deployment at the desired configuration, replay source data out of
band, cut over at the application layer.

The page explains the asymmetry:
- Dimension is machine-enforced as immutable. The startup validator
  introspects pg_attribute and crashes the API/deriver on mismatch.
- Model is operator-owned. There is no persistent metadata recording
  which model produced each vector, so a same-dim model swap is
  silently undetectable — flagged with a Warning callout.

Also documents the truncation edge case (text-embedding-3-large                                                                                                truncated to 1536 with EMBEDDING_VECTOR_DIMENSIONS left at default)
and the DIMENSIONS_MODE=always mitigation, plus a pointer that
storage-backend swap (VECTOR_STORE_MIGRATED + reconciler) is a                                                                                                 distinct operation unaffected by this work.
Registers the page in docs/docs.json under the Self-Hosting nav group
and cross-links from configuration.mdx.

* fix(embedding): correct turbopuffer regex + tighten DIMENSIONS_MODE docs

- Turbopuffer attribute type for a vector column is `[N]f32` / `[N]f16` /
  `[N]i8`, not `f32_vector(N)` as the earlier probe assumed. The earlier
  regex returned None for the real SDK format, so existing Turbopuffer
  namespaces would have been reported as "missing" instead of validated
  for mismatch. Regex switched to `\[(\d+)\]` which is the
  vendor-stable shape. Test cases rewritten to lock the actual format.

- docs/v3/contributing/configuration.mdx had a contradictory pair of
  bullets: 223 said explicit 1536 makes `auto` forward dimensions=, 224
  said `auto` would skip the parameter because 1536 is the default.
  Operators reading both would (rightly) conclude they need `always`
  even when `auto` would work. Rewrote both bullets so:
  - `auto` is provenance-driven (explicit-set, not non-default-value).
  - `always` is positioned as defense-in-depth for config layers that
    might strip explicit default-valued envs, not the only path for
    same-as-default truncation.

* fix(embedding): address PR #678 review comments

CodeRabbit + Rajat review feedback. All actionable items addressed
except two false-positives (responded on PR).

Bug fixes:
- deriver telemetry leak: validator was called outside try/finally so
  shutdown_telemetry() did not run on validation failure. Moved inside.
- _emit_report printed "no effect with pgvector" unconditionally,
  including from implicit post-apply calls. Added is_report_mode flag;
  only print on explicit --report.
- LanceDB and Turbopuffer probes returned None when the namespace
  existed but its schema was malformed (no vector field / unparseable
  type string), silently bucketing real corruption as "missing"
  (lazy-create) and letting it pass the startup validator. Now raise
  VectorStoreError with actionable diagnostics; None remains valid only
  for "namespace does not exist."
- Startup validator only sampled message namespaces; added a parallel
  Collection-row sample so document namespaces are probed too, with the
  same dim assertion. Mirrors the --report path.

Hygiene:
- StartupValidationError now subclasses HonchoException so existing
  exception handlers recognize it. ValidationException is @final and
  has 422 request-validation semantics that would be misleading here.
- scripts/configure_embeddings.py main() no longer spins up two event
  loops. engine.dispose() moved into a try/finally inside _async_main
  so cleanup runs in the same loop as the pipeline.
- Replaced hand-rolled retry loop with tenacity.AsyncRetrying; same
  fail-closed semantics, less code, before_sleep_log for visibility.
- Added _validate_identifier() defense-in-depth: DB.SCHEMA and HNSW
  index names are regex-checked against [A-Za-z_][A-Za-z0-9_]* before
  SQL interpolation. Operator config + DB catalog are not user input
  under the current threat model, but the constraint is cheap to gate.

Test + docs:
- test_app_settings_accepts_non_1536_with_any_vector_store_configuration
  now actually exercises turbopuffer (was missing); supplies a dummy
  TURBOPUFFER_API_KEY to satisfy the model_validator.
- changing-embeddings.mdx: hyphenated "out-of-band" per reviewer style.

* fix: modify conftest to fix ci

* fix: ci tests for typescript server
2026-05-14 15:03:35 -04:00
Rajat Ahuja 092b60520f
feat: stop fetching embedding vectors on vector store query - DEV-1727 (#682)
* feat: stop fetching embedding vectors on vector store query

* fix: add similar filtering for lancedb

* fix: add lancedb tests

---------

Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
2026-05-14 13:14:15 -04:00