Merge branch 'main' into main
This commit is contained in:
commit
4e53912a0e
|
|
@ -8,6 +8,7 @@
|
|||
# Application Settings
|
||||
# =============================================================================
|
||||
LOG_LEVEL=INFO
|
||||
PERFORMANCE_LOG_FORMAT=compact # compact|rich
|
||||
# SESSION_OBSERVERS_LIMIT=10
|
||||
# GET_CONTEXT_MAX_TOKENS=100000
|
||||
# MAX_FILE_SIZE=5242880 # Bytes
|
||||
|
|
@ -25,6 +26,7 @@ LOG_LEVEL=INFO
|
|||
|
||||
# LANGFUSE_HOST=
|
||||
# LANGFUSE_PUBLIC_KEY=
|
||||
# LANGFUSE_SECRET_KEY=
|
||||
|
||||
# COLLECT_METRICS_LOCAL=false
|
||||
# LOCAL_METRICS_FILE=metrics.jsonl
|
||||
|
|
@ -44,12 +46,15 @@ DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
|
|||
# DB_POOL_CLASS=default
|
||||
# DB_POOL_SIZE=10
|
||||
# DB_MAX_OVERFLOW=20
|
||||
# DB_POOL_TIMEOUT=30
|
||||
# DB_POOL_TIMEOUT=5 # seconds a pooled checkout waits for a free connection (QueuePool only)
|
||||
# DB_POOL_RECYCLE=300
|
||||
# DB_POOL_PRE_PING=true
|
||||
# DB_POOL_USE_LIFO=true
|
||||
# DB_SQL_DEBUG=false
|
||||
# DB_TRACING=false
|
||||
# Per-connection establish timeout (seconds) so a single connection attempt
|
||||
# fails fast instead of hanging when the server/pooler is unreachable.
|
||||
# DB_CONNECT_TIMEOUT_SECONDS=2
|
||||
|
||||
# =============================================================================
|
||||
# Authentication Settings
|
||||
|
|
@ -104,18 +109,31 @@ LLM_OPENAI_API_KEY=your-api-key-here
|
|||
# DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
|
||||
# DERIVER_WORKERS=1
|
||||
# DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0
|
||||
# Adaptive polling: grows the idle/error sleep from the base toward the max by
|
||||
# the multiplier each cycle, snapping back to base when work is found.
|
||||
# DERIVER_POLLING_BACKOFF_ENABLED=true
|
||||
# DERIVER_POLLING_SLEEP_MAX_INTERVAL_SECONDS=30.0
|
||||
# DERIVER_POLLING_BACKOFF_MULTIPLIER=2.0
|
||||
# Jitter so instances that start together don't poll in lockstep. Startup: sleep
|
||||
# a random delay in [0, value] before the first poll (0.0 disables). Per-cycle:
|
||||
# multiply every poll sleep by a random factor in [1-ratio, 1+ratio] (0.0 disables).
|
||||
# DERIVER_POLLING_STARTUP_JITTER_SECONDS=30.0
|
||||
# DERIVER_POLLING_JITTER_RATIO=0.5
|
||||
# DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5
|
||||
# DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000 # 30 days
|
||||
# DERIVER_MODEL_CONFIG__TEMPERATURE=
|
||||
# DERIVER_MODEL_CONFIG__THINKING_EFFORT=minimal
|
||||
# DERIVER_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024 # Gemini/Anthropic only
|
||||
# DERIVER_MODEL_CONFIG__STRUCTURED_OUTPUT_MODE=json_object # for providers without json_schema support
|
||||
# DERIVER_DEDUPLICATE=true
|
||||
# DERIVER_MODEL_CONFIG__MAX_OUTPUT_TOKENS=4096
|
||||
# DERIVER_LOG_OBSERVATIONS=false
|
||||
# DERIVER_MAX_INPUT_TOKENS=25000
|
||||
# DERIVER_MAX_CUSTOM_INSTRUCTIONS_TOKENS=2000
|
||||
# DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100
|
||||
# DERIVER_REPRESENTATION_BATCH_MAX_TOKENS=1024
|
||||
# DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=512 # Min tokens a work unit accumulates before the deriver claims it; 0 disables the gate
|
||||
# DERIVER_REPRESENTATION_BATCH_TARGET_INPUT_TOKENS=1024 # Max context-window tokens per deriver LLM call
|
||||
# DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS=1800
|
||||
# DERIVER_FLUSH_ENABLED=false # Bypass batch token threshold, process work immediately
|
||||
# DERIVER_MODEL_CONFIG__FALLBACK__MODEL=
|
||||
# DERIVER_MODEL_CONFIG__FALLBACK__TRANSPORT=
|
||||
|
|
@ -157,12 +175,17 @@ LLM_OPENAI_API_KEY=your-api-key-here
|
|||
# DIALECTIC_LEVELS__max__MODEL_CONFIG__TRANSPORT=openai
|
||||
# DIALECTIC_LEVELS__max__MODEL_CONFIG__MODEL=gpt-5.4-mini
|
||||
# DIALECTIC_LEVELS__max__MAX_TOOL_ITERATIONS=10
|
||||
# Optional overrides:
|
||||
# Optional overrides (model and OpenAI-compatible base URL are per-level):
|
||||
# DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__minimal__MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
|
||||
# DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__low__MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
|
||||
# DIALECTIC_LEVELS__medium__MODEL_CONFIG__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__medium__MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
|
||||
# DIALECTIC_LEVELS__high__MODEL_CONFIG__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__high__MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
|
||||
# DIALECTIC_LEVELS__max__MODEL_CONFIG__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__max__MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
|
||||
# DIALECTIC_LEVELS__max__MODEL_CONFIG__THINKING_EFFORT=medium
|
||||
# DIALECTIC_LEVELS__max__MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024
|
||||
# Optional backup per level (must set both or neither):
|
||||
|
|
@ -254,14 +277,28 @@ LLM_OPENAI_API_KEY=your-api-key-here
|
|||
# TELEMETRY_MAX_BUFFER_SIZE=10000
|
||||
# TELEMETRY_NAMESPACE=honcho # Inherits from NAMESPACE if not set
|
||||
|
||||
# Full-fidelity payload tracing (llm.call.traced / trace.content). Default-off
|
||||
# TELEMETRY_TRACE_PAYLOADS_ENABLED=false # Trace events ship to TELEMETRY_ENDPOINT
|
||||
# TELEMETRY_TRACE_MAX_BYTES=262144 # Per-message cap; oversized content is clipped
|
||||
# TELEMETRY_TRACE_PURPOSES=[] # JSON list of CallPurpose values to capture; empty = all
|
||||
|
||||
# =============================================================================
|
||||
# Cache
|
||||
# =============================================================================
|
||||
# CACHE_ENABLED=false
|
||||
# CACHE_URL="redis://localhost:6379/0?suppress=true"
|
||||
# CACHE_CLUSTER=false # true when CACHE_URL is a Redis Cluster (e.g. Memorystore for Redis Cluster)
|
||||
# CACHE_NAMESPACE="honcho" # Inherits from NAMESPACE if not set
|
||||
# CACHE_DEFAULT_TTL_SECONDS=300
|
||||
# CACHE_DEFAULT_LOCK_TTL_SECONDS=5
|
||||
# CACHE_LOCK_WAIT_CHECK_INTERVAL_SECONDS=0.1
|
||||
|
||||
# =============================================================================
|
||||
# CORS Settings
|
||||
# =============================================================================
|
||||
# JSON array of origins allowed by the FastAPI CORSMiddleware. Defaults match
|
||||
# the previously hardcoded list: localhost, 127.0.0.1:8000 and api.honcho.dev.
|
||||
# CORS_ORIGINS=["http://localhost","http://127.0.0.1:8000","https://api.honcho.dev"]
|
||||
|
||||
# =============================================================================
|
||||
# Vector Store Settings
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
# Code owners for Honcho.
|
||||
#
|
||||
# Beyond review routing, this file is the allowlist for manually triggering
|
||||
# the live-llm-tests and unified-tests workflows (via their PR labels and
|
||||
# workflow_dispatch). The gate jobs grep every @username in this file —
|
||||
# regardless of which path pattern it sits on — and read it from `main`,
|
||||
# never from the PR branch, so additions only take effect once merged.
|
||||
#
|
||||
# The workflow gates only understand individual @usernames (no @org/team
|
||||
# entries).
|
||||
|
||||
# Reviewers auto-requested on changes under .github/ (workflows, this file,
|
||||
# templates).
|
||||
/.github/ @akattelu @eisene @Rajat-Ahuja1997 @VVoruganti
|
||||
|
||||
# CI-trigger allowlist only: this path matches no real file, so these people
|
||||
# are never auto-requested for review, but the workflow gates still pick
|
||||
# them up.
|
||||
/ci-trigger-allowlist @3un01a @adavyas @ajspig @courtlandleer @erosika @lowyelling @matthewlanders @vintrocode
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
name: Load staging secrets
|
||||
description: >-
|
||||
Resolve staging secret ids from the two newest v<major.minor.patch> git tags,
|
||||
then load the newest fetchable secret's keys into the job environment from
|
||||
AWS Secrets Manager. Falls back to the second-latest tag when the latest
|
||||
tag's secret isn't published yet, and fails the job loudly when neither can
|
||||
be fetched. Requires the repository to be checked out and AWS credentials to
|
||||
be configured beforehand.
|
||||
|
||||
inputs:
|
||||
secret-prefix:
|
||||
description: >-
|
||||
Secret-name prefix combined with a resolved tag version to form the full
|
||||
secret id. Masked so it stays out of public CI logs.
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve secret ids from latest git tags
|
||||
id: resolve-secret
|
||||
shell: bash
|
||||
env:
|
||||
SECRET_PREFIX: ${{ inputs.secret-prefix }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${SECRET_PREFIX:?secret-prefix input is empty — is the STAGING_SECRET_PREFIX secret set for this environment?}"
|
||||
# Keep the secret-name prefix out of public CI logs.
|
||||
echo "::add-mask::${SECRET_PREFIX}"
|
||||
|
||||
# Two newest v<major.minor.patch> tags, highest first (tags are public).
|
||||
versions="$(git ls-remote --tags origin 'v*' \
|
||||
| sed -n 's#.*refs/tags/v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$#\1#p' \
|
||||
| sort -t. -k1,1nr -k2,2nr -k3,3nr -u)"
|
||||
latest="$(printf '%s\n' "$versions" | sed -n '1p')"
|
||||
second="$(printf '%s\n' "$versions" | sed -n '2p')"
|
||||
if [ -z "${latest:-}" ]; then
|
||||
echo "::error::No v<semver> git tags found to resolve a secret version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
latest_id="${SECRET_PREFIX}${latest}"
|
||||
echo "::add-mask::${latest_id}"
|
||||
echo "latest-id=${latest_id}" >> "$GITHUB_OUTPUT"
|
||||
echo "Latest version: ${latest}"
|
||||
if [ -n "${second:-}" ]; then
|
||||
second_id="${SECRET_PREFIX}${second}"
|
||||
echo "::add-mask::${second_id}"
|
||||
echo "second-id=${second_id}" >> "$GITHUB_OUTPUT"
|
||||
echo "Fallback version: ${second}"
|
||||
fi
|
||||
|
||||
# Fetch the latest tag's secret. continue-on-error so a not-yet-published
|
||||
# latest falls through to the second-latest instead of failing the job.
|
||||
- name: Fetch staging secret (latest)
|
||||
id: fetch-latest
|
||||
continue-on-error: true
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@v2
|
||||
with:
|
||||
secret-ids: |
|
||||
,${{ steps.resolve-secret.outputs.latest-id }}
|
||||
parse-json-secrets: true
|
||||
|
||||
# Runs only if the latest fetch failed; this one is NOT continue-on-error,
|
||||
# so if the fallback also fails the job fails loudly.
|
||||
- name: Fetch staging secret (fallback to second-latest)
|
||||
id: fetch-fallback
|
||||
if: steps.fetch-latest.outcome == 'failure' && steps.resolve-secret.outputs.second-id != ''
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@v2
|
||||
with:
|
||||
secret-ids: |
|
||||
,${{ steps.resolve-secret.outputs.second-id }}
|
||||
parse-json-secrets: true
|
||||
|
||||
# If the latest fetch failed and the fallback was skipped (no second tag),
|
||||
# no secret keys were loaded — fail here instead of letting the job run
|
||||
# without staging config (e.g. every live LLM test would silently skip via
|
||||
# require_provider_key and the run would go green).
|
||||
- name: Verify staging secrets were loaded
|
||||
if: steps.fetch-latest.outcome != 'success' && steps.fetch-fallback.outcome != 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::error::No staging secret could be fetched (latest failed; fallback skipped or failed)"
|
||||
exit 1
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
name: Live LLM Tests
|
||||
|
||||
on:
|
||||
# Runs on main pushes that can affect the LLM transport (narrower than
|
||||
# unified-tests' src/** — live provider calls aren't worth burning on
|
||||
# changes that can't reach the backends).
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/llm/**'
|
||||
- 'src/config.py'
|
||||
- 'tests/live_llm/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- '.github/workflows/live-llm-tests.yml'
|
||||
# Manual trigger for PRs: add the `run-live-llm` label to run the suite
|
||||
# against the PR's merge commit. The label is purged as soon as the run
|
||||
# starts so it can be re-added to trigger another run.
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
|
||||
# Cap spend: at most one active run per PR (per ref for push/dispatch).
|
||||
# Re-triggering a PR run cancels the in-flight one instead of stacking live
|
||||
# provider calls; pushes to main queue instead of cancelling so main CI
|
||||
# results aren't lost.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name != 'push' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Only code owners (.github/CODEOWNERS) may trigger the suite manually via
|
||||
# the label or workflow_dispatch; the gate also purges the trigger label so
|
||||
# it can be re-added to trigger another run.
|
||||
gate:
|
||||
name: Gate manual trigger
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/manual-trigger-gate.yml
|
||||
with:
|
||||
label: run-live-llm
|
||||
allow-workflow-dispatch: true
|
||||
|
||||
live-llm-tests:
|
||||
name: Run Live LLM Tests
|
||||
needs: gate
|
||||
# always() lets this run on push events, where the gate's jobs are skipped.
|
||||
# Manual triggers (label / workflow_dispatch) additionally require the
|
||||
# gate's CODEOWNERS check to have passed.
|
||||
if: >-
|
||||
always() &&
|
||||
(github.event_name == 'push' ||
|
||||
((github.event_name == 'workflow_dispatch' ||
|
||||
github.event.label.name == 'run-live-llm') &&
|
||||
needs.gate.outputs.authorized == 'true'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
environment: unified-tests
|
||||
permissions:
|
||||
id-token: write # Required for OIDC authentication with AWS
|
||||
contents: read
|
||||
env:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: ${{ vars.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-1
|
||||
role-duration-seconds: 3600
|
||||
|
||||
# Resolves secret ids from the newest release tags, fetches the newest
|
||||
# available staging secret into the job env, and fails if none loaded.
|
||||
- name: Load staging secrets
|
||||
uses: ./.github/actions/load-staging-secrets
|
||||
with:
|
||||
secret-prefix: ${{ secrets.STAGING_SECRET_PREFIX }}
|
||||
|
||||
# Configure the test environment. Sentry/CloudEvents endpoints aren't
|
||||
# reachable from CI. LIVE_LLM_ANTHROPIC_45_PLUS_MODELS must be set for
|
||||
# the Anthropic tests to materialize — the claude_4_5_plus family has no
|
||||
# default models, so with only the API key they'd silently collect as
|
||||
# empty parameter sets.
|
||||
- name: Configure test environment
|
||||
run: |
|
||||
{
|
||||
# The staging dotenv carries AUTH_USE_AUTH=true without a usable
|
||||
# JWT secret; src/config.py validates the pair at import time, so
|
||||
# disable auth (this suite never runs the API server anyway).
|
||||
echo "AUTH_USE_AUTH=false"
|
||||
echo "SENTRY_ENABLED=false"
|
||||
echo "TELEMETRY_ENABLED=false"
|
||||
echo "LIVE_LLM_ANTHROPIC_45_PLUS_MODELS=claude-sonnet-4-5"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v2
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
- name: Install the project
|
||||
run: uv sync --all-extras
|
||||
|
||||
# -n 0 overrides the `-n auto` xdist default from pyproject: ~15 short
|
||||
# tests gain nothing from parallelism, serial execution avoids bursting
|
||||
# every provider at once, and flake diagnosis gets ordered output.
|
||||
- name: Run live LLM tests
|
||||
run: uv run --frozen pytest tests/live_llm/ --live-llm -n 0 -v
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
name: Manual Trigger Gate
|
||||
|
||||
# Shared gate for workflows that can be triggered manually on PRs by adding a
|
||||
# label (and optionally via workflow_dispatch): verifies the actor is a code
|
||||
# owner and purges the trigger label so it can be re-added for another run.
|
||||
#
|
||||
# Callers must grant `pull-requests: write` on the calling job so the
|
||||
# remove-label job can delete the label, and should gate downstream jobs on
|
||||
# the `authorized` output rather than this workflow's conclusion.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
label:
|
||||
description: PR label that triggers the calling workflow
|
||||
required: true
|
||||
type: string
|
||||
allow-workflow-dispatch:
|
||||
description: Whether workflow_dispatch events may pass the gate
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
outputs:
|
||||
authorized:
|
||||
description: >-
|
||||
'true' when the manual trigger's actor passed the CODEOWNERS check.
|
||||
Empty on events where the check did not run (e.g. push).
|
||||
value: ${{ jobs.check-actor.outputs.authorized }}
|
||||
|
||||
jobs:
|
||||
# Only code owners (.github/CODEOWNERS) may trigger the calling workflow
|
||||
# manually.
|
||||
check-actor:
|
||||
name: Verify actor is a code owner
|
||||
if: >-
|
||||
(inputs.allow-workflow-dispatch && github.event_name == 'workflow_dispatch') ||
|
||||
(github.event_name == 'pull_request' && github.event.label.name == inputs.label)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
authorized: ${{ steps.codeowners.outputs.authorized }}
|
||||
steps:
|
||||
- name: Check actor against CODEOWNERS on main
|
||||
id: codeowners
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Usernames are case-insensitive on GitHub; compare lowercased.
|
||||
owners="$(gh api -H "Accept: application/vnd.github.raw" \
|
||||
"repos/${{ github.repository }}/contents/.github/CODEOWNERS?ref=main" \
|
||||
| sed 's/#.*//' | grep -oE '@[A-Za-z0-9-]+' | tr -d '@' \
|
||||
| tr '[:upper:]' '[:lower:]' | sort -u)"
|
||||
actor_lc="$(printf '%s' "$ACTOR" | tr '[:upper:]' '[:lower:]')"
|
||||
if printf '%s\n' "$owners" | grep -qxF "$actor_lc"; then
|
||||
echo "@${ACTOR} is a code owner; proceeding"
|
||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::@${ACTOR} is not listed in .github/CODEOWNERS on main — only code owners may trigger this workflow manually"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Purge the trigger label first thing. Best-effort: failing to remove the
|
||||
# label (e.g. read-only token on a fork PR) doesn't block the tests.
|
||||
remove-label:
|
||||
name: Remove trigger label
|
||||
if: github.event_name == 'pull_request' && github.event.label.name == inputs.label
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Remove trigger label
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if ! gh api --method DELETE \
|
||||
"repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels/${{ inputs.label }}"; then
|
||||
echo "::warning::Could not remove the ${{ inputs.label }} label (it may have been removed already)"
|
||||
fi
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
name: Build and Push to GCP Artifact Registry (production)
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to deploy (without v prefix)"
|
||||
required: true
|
||||
type: string
|
||||
default: "manual"
|
||||
|
||||
env:
|
||||
GCP_PROJECT_ID: ${{ secrets.PROD_GCP_PROJECT_ID }}
|
||||
GCP_AR_LOCATION: ${{ secrets.PROD_GCP_AR_LOCATION }}
|
||||
GCP_AR_REPO: ${{ secrets.PROD_GCP_AR_REPO }}
|
||||
IMAGE_NAME: ${{ secrets.PROD_IMAGE_NAME }}
|
||||
GCP_SA_KEY: ${{ secrets.PROD_GCP_SA_KEY }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Authenticate to GCP
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
credentials_json: ${{ env.GCP_SA_KEY }}
|
||||
|
||||
- name: Set up Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v2
|
||||
|
||||
- name: Configure Docker for Artifact Registry
|
||||
run: gcloud auth configure-docker ${{ env.GCP_AR_LOCATION }}-docker.pkg.dev --quiet
|
||||
|
||||
- name: Build and push image
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
# Determine the image label based on trigger type
|
||||
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
|
||||
IMAGE_LABEL="deployment-${VERSION}"
|
||||
else
|
||||
IMAGE_LABEL="deployment-${GITHUB_REF_NAME}"
|
||||
fi
|
||||
BASE="${{ env.GCP_AR_LOCATION }}-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/${{ env.GCP_AR_REPO }}/${{ env.IMAGE_NAME }}"
|
||||
TAG="$BASE:$IMAGE_LABEL"
|
||||
docker build -t "$TAG" .
|
||||
docker push "$TAG"
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
name: Build and Push to GCP Artifact Registry (Staging)
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to deploy (without v prefix)"
|
||||
required: true
|
||||
type: string
|
||||
default: "manual"
|
||||
|
||||
env:
|
||||
GCP_PROJECT_ID: ${{ secrets.STAGING_GCP_PROJECT_ID }}
|
||||
GCP_AR_LOCATION: ${{ secrets.STAGING_GCP_AR_LOCATION }}
|
||||
GCP_AR_REPO: ${{ secrets.STAGING_GCP_AR_REPO }}
|
||||
IMAGE_NAME: ${{ secrets.STAGING_IMAGE_NAME }}
|
||||
GCP_SA_KEY: ${{ secrets.STAGING_GCP_SA_KEY }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Authenticate to GCP
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
credentials_json: ${{ env.GCP_SA_KEY }}
|
||||
|
||||
- name: Set up Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v2
|
||||
|
||||
- name: Configure Docker for Artifact Registry
|
||||
run: gcloud auth configure-docker ${{ env.GCP_AR_LOCATION }}-docker.pkg.dev --quiet
|
||||
|
||||
- name: Build and push image
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
# Determine the image label based on trigger type
|
||||
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
|
||||
IMAGE_LABEL="deployment-${VERSION}"
|
||||
else
|
||||
IMAGE_LABEL="deployment-${GITHUB_REF_NAME}"
|
||||
fi
|
||||
BASE="${{ env.GCP_AR_LOCATION }}-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/${{ env.GCP_AR_REPO }}/${{ env.IMAGE_NAME }}"
|
||||
TAG="$BASE:$IMAGE_LABEL"
|
||||
docker build -t "$TAG" .
|
||||
docker push "$TAG"
|
||||
|
|
@ -6,14 +6,50 @@ on:
|
|||
paths:
|
||||
- 'src/**'
|
||||
- 'tests/**'
|
||||
# Manual trigger for PRs: add the `run-unified-tests` label to run the suite
|
||||
# against the PR's merge commit. The label is purged as soon as the run
|
||||
# starts so it can be re-added to trigger another run.
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
# Cap spend: at most one active run per PR (per ref for push). Re-triggering
|
||||
# a PR run cancels the in-flight one instead of stacking Fly machines; pushes
|
||||
# to main queue instead of cancelling so main CI results aren't lost. The
|
||||
# cleanup-machine job runs `if: always()`, which still executes on cancelled
|
||||
# runs, so a cancelled run's Fly machine and runner are still torn down.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name != 'push' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
# Only code owners (.github/CODEOWNERS) may trigger the suite manually via
|
||||
# the label; the gate also purges the trigger label so it can be re-added
|
||||
# to trigger another run.
|
||||
gate:
|
||||
name: Gate manual trigger
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/manual-trigger-gate.yml
|
||||
with:
|
||||
label: run-unified-tests
|
||||
|
||||
start-runner:
|
||||
name: Start Fly Runner
|
||||
needs: gate
|
||||
# always() lets this run on push events, where the gate's jobs are skipped.
|
||||
# Label adds other than run-unified-tests trigger the workflow but skip
|
||||
# every job here; the run-unified-tests label additionally requires the
|
||||
# gate's CODEOWNERS check to have passed.
|
||||
if: >-
|
||||
always() &&
|
||||
(github.event_name == 'push' ||
|
||||
(github.event.label.name == 'run-unified-tests' &&
|
||||
needs.gate.outputs.authorized == 'true'))
|
||||
uses: ./.github/workflows/start-fly-runner.yml
|
||||
secrets: inherit
|
||||
|
||||
|
|
@ -40,17 +76,72 @@ jobs:
|
|||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::444554165670:role/GitHubActionsS3Role
|
||||
role-to-assume: ${{ vars.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-1
|
||||
role-duration-seconds: 43200 # 12 hours
|
||||
|
||||
- name: Fetch secrets from AWS Secrets Manager
|
||||
# Resolves secret ids from the newest release tags, fetches the newest
|
||||
# available staging secret into the job env, and fails if none loaded.
|
||||
- name: Load staging secrets
|
||||
uses: ./.github/actions/load-staging-secrets
|
||||
with:
|
||||
secret-prefix: ${{ secrets.STAGING_SECRET_PREFIX }}
|
||||
|
||||
# Layer test-specific overrides on top of the staging secret. The staging
|
||||
# dotenv tracks the deployed release and can drift from what main's config
|
||||
# expects; the TESTING_SECRET_ID secret holds only the keys (flat JSON,
|
||||
# exact env var names) the unified tests need to pin. The get-secrets
|
||||
# action refuses to inject an env var that already exists, so the
|
||||
# overrides are fetched under a prefix alias here and promoted over the
|
||||
# staging values in the next step.
|
||||
- name: Fetch testing secret overrides
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@v2
|
||||
with:
|
||||
secret-ids: |
|
||||
,testing/unified/tests
|
||||
HONCHO_TEST_OVERRIDE,${{ secrets.TESTING_SECRET_ID }}
|
||||
parse-json-secrets: true
|
||||
|
||||
# Re-export each HONCHO_TEST_OVERRIDE_* var under its real name; the
|
||||
# later $GITHUB_ENV write wins over the value loaded from the staging
|
||||
# secret. Values are already masked by the fetch step above.
|
||||
- name: Apply testing secret overrides
|
||||
run: |
|
||||
set -euo pipefail
|
||||
applied=0
|
||||
while IFS= read -r -d '' entry; do
|
||||
name="${entry%%=*}"
|
||||
value="${entry#*=}"
|
||||
case "$name" in
|
||||
HONCHO_TEST_OVERRIDE_*)
|
||||
target="${name#HONCHO_TEST_OVERRIDE_}"
|
||||
{
|
||||
echo "${target}<<__HONCHO_OVERRIDE_EOF__"
|
||||
printf '%s\n' "$value"
|
||||
echo "__HONCHO_OVERRIDE_EOF__"
|
||||
} >> "$GITHUB_ENV"
|
||||
echo "Overriding ${target}"
|
||||
applied=$((applied + 1))
|
||||
;;
|
||||
esac
|
||||
done < <(env -0)
|
||||
echo "Applied ${applied} override(s)"
|
||||
|
||||
# Configure the test environment. Disables auth/Sentry/CloudEvents telemetry
|
||||
# (their endpoints aren't reachable from CI), and points REASONING_TRACES_FILE
|
||||
# at a shared path so the API + deriver record full LLM I/O for auditing — the
|
||||
# runner uploads it to S3. Written after the fetch steps so these win over the
|
||||
# values loaded from Secrets Manager (last $GITHUB_ENV write wins). Stale
|
||||
# config keys loaded from the staging secret (e.g. settings that have since
|
||||
# been renamed or removed on main) must always be ignored by the app config.
|
||||
- name: Configure test environment
|
||||
run: |
|
||||
{
|
||||
echo "AUTH_USE_AUTH=false"
|
||||
echo "SENTRY_ENABLED=false"
|
||||
echo "TELEMETRY_ENABLED=false"
|
||||
echo "REASONING_TRACES_FILE=unified-reasoning-traces.jsonl"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Verify Docker is available
|
||||
run: docker info
|
||||
|
||||
|
|
@ -110,7 +201,7 @@ jobs:
|
|||
exit 0
|
||||
fi
|
||||
|
||||
RUNNER_ID=""
|
||||
RUNNER_ID=""
|
||||
if [ -n "$RUNNER_NAME" ]; then
|
||||
RUNNER_ID=$(echo "$RUNNERS_RESPONSE" | jq -r --arg name "$RUNNER_NAME" '.runners[]? | select(.name == $name) | .id')
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -193,3 +193,6 @@ metrics.jsonl
|
|||
AGENTS.md
|
||||
lancedb_data/
|
||||
grafana-data/
|
||||
|
||||
# Claude Code addon stuff
|
||||
.omc
|
||||
|
|
|
|||
88
CHANGELOG.md
88
CHANGELOG.md
|
|
@ -5,6 +5,94 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [3.0.11] - 2026-06-24
|
||||
|
||||
### Added
|
||||
|
||||
- `api_request_duration_seconds` Prometheus histogram tracking per-route request latency, labeled by method and endpoint (#837)
|
||||
- LLM `provider_params` passthroughs (`extra_body` / `extra_headers` / `extra_query`) are now forwarded to the underlying provider transport across all backends, with shape validation that rejects non-mapping values (#821)
|
||||
- `structured_output_mode` model-config option to use `json_object` mode for OpenAI-compatible providers that lack native Structured Outputs support (used by the deriver) (#820)
|
||||
- OpenRouter app-attribution headers (`HTTP-Referer` / `X-Openrouter-Title`) are now sent on OpenAI-compatible clients when the configured base URL is OpenRouter, so requests are attributed to "Honcho" in OpenRouter's dashboard (#805)
|
||||
- Langfuse traces are now tagged with user and session IDs for easier trace filtering (#814)
|
||||
- `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800s) lets sub-threshold representation work units flush once their oldest unprocessed queue item ages out. Set it to `0` to keep the legacy behavior where sub-threshold tails wait indefinitely unless `DERIVER_FLUSH_ENABLED=true` (#826)
|
||||
- Conclusion responses now include a `level` field (`explicit`, `deductive`, `inductive`, `contradiction`); list/query endpoints support filtering by `level` via `filters`, with reserved filter keys protected from being overridden by user-supplied filters (#851)
|
||||
|
||||
### Changed
|
||||
|
||||
- Peer-scoped JWTs now get read-only access to the sessions their peer is an active member of (session context, summaries, peers, their own per-session config, search, and message reads). Session-scoped JWTs remain confined to their session and cannot reach peer routes (#679)
|
||||
- Compacted Honcho's log output, with guarded ms/s metric formatting that falls back to a plain string for non-numeric values (#836)
|
||||
- Sentry now drops noisy infra/scrape transactions: the reconciler opens a transaction only once a batch has rows (idle cycles emit none), and a `traces_sampler` returns `0.0` for `/metrics`, `/health`, `/openapi.json`, `/docs`, `/redoc`, and the deriver metrics server. `SENTRY.TRACES_SAMPLE_RATE` still governs real traffic (#834)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Peer- and session-scoped JWTs were effectively workspace-scoped: authorization walked the route's declared scope and fell through to a workspace match, so a `{w, p: alice}` token could act on any peer in the workspace. JWTs are now authorized by their narrowest claim and never widen to workspace access (#679)
|
||||
- The keys API now rejects creating a peer- or session-scoped key without a workspace. Such keys were minted successfully but failed verification on every request (#679)
|
||||
- Agent-supplied observation IDs carrying the display-format `id:` prefix are now normalized (prefix and trailing whitespace stripped) before `source_ids` are stored and on `get_reasoning_chain` lookups, fixing corrupted provenance links and broken reasoning-chain traversal (#795)
|
||||
- Fixed a `create_tree` keyword-argument mismatch in the Dreamer's surprisal tree construction (#749)
|
||||
- Providers that omit output-token counts (observed with Gemini on tool-loop completions) returned `output_tokens=None`, which raised a Pydantic validation error that aborted the call and crashed the Dreamer's induction phase before inductive conclusions were persisted. `None` is now coerced to `0` so token accounting degrades gracefully (#809)
|
||||
- Document creation now performs exact (case-insensitive, whitespace-trimmed) content deduplication before the existing semantic dedup step: exact duplicates within a batch collapse to a single insert, and an exact match against a live document reinforces it (atomic `times_derived` increment) instead of creating a new row (#861)
|
||||
|
||||
## [3.0.10] - 2026-06-15
|
||||
|
||||
### Added
|
||||
|
||||
- Messages are now embedded via a background task rather than blocking API request
|
||||
- Read-only DB session mode (`get_read_db` / `tracked_db(..., read_only=True)`) so reads don't hold a transaction open across the work
|
||||
- `CORS_ORIGINS` env var to configure CORS allowed origins without editing source; defaults match the prior hardcoded list, so self-hosted deployments behind custom domains can whitelist their frontend (#697)
|
||||
- `scripts/generate_jwt.py` — utility for minting scoped or admin Honcho JWTs (`--admin`, `--workspace`/`--peer`/`--session`, `--expires` with human-friendly durations, `--print-only`) without calling the keys API (#757)
|
||||
- `STALE_WORK_UNIT_CLEANUP_INTERVAL_SECONDS` (default 60s) — minimum jittered spacing between deriver stale-work-unit cleanup runs, so cleanup no longer runs on every seconds-scale poll (`0.0` keeps the legacy every-poll behavior) (#773)
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized the deriver and dreamer prompt cache prefixes to improve prompt-cache hit rates (#806)
|
||||
|
||||
### Fixed
|
||||
|
||||
- `times_derived` is now properly reinforced when a duplicate conclusion is detected. It had been pinned at 1 for nearly every conclusion (the reject-new branch dropped the increment and the new-wins branch reset the count to 1), so `ORDER BY times_derived DESC` fell back to arbitrary heap order and froze stale conclusions to the front of injected context. Reinforcement is now an atomic increment and both most-derived queries gained a `created_at DESC` recency tiebreaker (#768)
|
||||
- Webhook creation now correctly rejects private/internal IP addresses (#793)
|
||||
|
||||
## [3.0.9] - 2026-06-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Connection acquisition is now a single attempt with no server-side retry, on a vanilla `AsyncSession`. A new `DB_CONNECT_TIMEOUT_SECONDS` (default 2s) bounds the attempt so a saturated or unreachable pooler fails fast instead of holding a client connection open to re-knock. A saturated DB now surfaces to the caller — the API returns an error and the deriver backs off and retries on a later poll — which lets the pooler drain rather than amplifying saturation.
|
||||
|
||||
### Added
|
||||
|
||||
- Deriver poll jitter so instances that start together don't poll in lockstep: `DERIVER_POLLING_STARTUP_JITTER_SECONDS` (random delay before the first poll, default 30s) and `DERIVER_POLLING_JITTER_RATIO` (±fraction applied to every poll sleep, default 0.5). Both disable at `0.0`; the underlying backoff schedule is unchanged.
|
||||
|
||||
### Removed
|
||||
|
||||
- Reverted the connection-checkout retry and `HonchoAsyncSession` custom session introduced in 3.0.8. Removed the `DB_CONNECTION_RETRY_ENABLED` / `DB_CONNECTION_RETRY_MAX_DELAY_SECONDS` / `DB_CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS` / `DB_CONNECTION_RETRY_BACKOFF_MAX_SECONDS` settings, the `db_connection_acquisitions{outcome=...}` Prometheus counter, and the `db.pool.acquire` Sentry span. Alerting built on `db_connection_acquisitions` should migrate to `db_pool_connections` / `db_queries_in_flight`.
|
||||
|
||||
## [3.0.8] - 2026-06-01
|
||||
|
||||
### Added
|
||||
|
||||
- Connection-checkout retry with bounded exponential backoff (tenacity) on `get_db`/`tracked_db`: transient transaction-pooler (Supavisor) rejections — SQLAlchemy `TimeoutError` and `OperationalError` — now retry with backoff instead of surfacing as 500s under client-connection saturation. Gated by
|
||||
`DB_CONNECTION_RETRY_ENABLED` with configurable delay/backoff knobs; ~10s default budget (#758)
|
||||
- `HonchoAsyncSession` — a lazy `AsyncSession` that checks out its pooled connection (with retry) on the first DB-touching call rather than at construction. Request handlers doing non-DB work (embedding, file, LLM) before their first query no longer pin a pooler connection across it. Only the checkout is retried;
|
||||
the statement still runs exactly once, so writes are never duplicated (#758)
|
||||
- Adaptive deriver queue polling: the poll interval backs off when the queue is idle or erroring (base → max, doubling each cycle) and snaps back to base the moment work is claimed, cutting steady-state query load against the DB. Gated by `DERIVER_POLLING_BACKOFF_ENABLED` with configurable max/multiplier (#758)
|
||||
- New Prometheus `db_pool_connections` gauge (checked_out / checked_in / size / overflow), labeled `api`|`deriver`, registered in both the API lifespan and the deriver metrics server (#758)
|
||||
- New Prometheus `db_connection_acquisitions{outcome=ok|retried|exhausted}` counter — the alertable early-warning signal that connection checkouts are retrying through pooler rejection, before requests start failing (#758)
|
||||
- New Prometheus `db_queries_in_flight` gauge — statements actually executing on the wire (via SQLAlchemy cursor-execute events). Paired with `checked_out`, the gap reveals connections held but parked (the "idle in transaction during an external call" antipattern). Gated on `METRICS.ENABLED` for zero overhead when
|
||||
off (#758)
|
||||
- Explicit `SqlalchemyIntegration` in both the API and deriver Sentry inits; connection acquisition wrapped in a `db.pool.acquire` span with live pool stats captured on retry exhaustion (#758)
|
||||
|
||||
### Changed
|
||||
|
||||
- Default `POOL_TIMEOUT` lowered to 5s, with validation that it stays under the connection-retry budget when a pooled (non-null) `POOL_CLASS` is configured; `config.toml.example` and the v2/v3 configuration docs updated to match (#758)
|
||||
- `HonchoAsyncSession` wraps every DB-touching session method (execute / scalar / scalars / flush / merge / refresh / commit / get / get_one / stream / stream_scalars / delete) so the lazy-checkout-with-retry guarantee has no holes; the acquired flag resets on `close()`/`reset()` so a reused session re-acquires on
|
||||
next use (#758)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Roll the session back on a retryable checkout failure before retrying — a failed autobegin could otherwise leave it pending-rollback, making the next connection attempt raise instead of cleanly re-checking-out (#758)
|
||||
- Guard `DBPoolCollector.collect()` so a pool-read/import hiccup can't raise and abort the entire `/metrics` scrape (Prometheus drops all metrics if any collector raises) (#758)
|
||||
- Clamp the pool overflow gauge to ≥ 0 (it could report negative before the pool fills) (#758)
|
||||
- Removed a double-sleep in the deriver idle poll so the backoff cap is a true cap rather than 2× (#758)
|
||||
|
||||
## [3.0.7] - 2026-05-21
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -116,6 +116,12 @@ cd sdks/typescript && bun run tsc --noEmit
|
|||
- Explicit error handling with appropriate exception types
|
||||
- Docstrings: Use Google style docstrings
|
||||
- **Never hold a DB session during external calls** (LLM, embedding, HTTP). If a function needs both a DB session and an external call result, compute the external result first and pass it as a parameter. This avoids tying up DB connections during slow network I/O. Use `tracked_db` for short-lived, DB-only operations; pass a shared session when multiple DB-only calls can reuse one connection.
|
||||
- **Never write through a read-only session** (`tracked_db(..., read_only=True)`, `get_read_db`, `ReadSessionLocal`). These run in AUTOCOMMIT mode with no transaction: writes are NOT blocked by the database — they silently commit immediately, and `begin_nested()` savepoints break. There is no runtime guard; this is enforced by convention only. Use `read_only=True` strictly for SELECT-only windows; anything that mutates (including get-or-create paths) must use a regular write session.
|
||||
|
||||
#### Auth scoping
|
||||
|
||||
- **`allow_member_read=True` (in `require_auth(...)`) is read-only — NEVER set it on a route that mutates state.** It lets a peer-scoped key reach a session route when its peer is an active member of the session, so on a mutating route it would hand any session member write access (message injection, config mutation, deletion). HTTP method is not a reliable read/write signal here (some read routes use POST for a richer body), so this is enforced by an explicit allowlist in `tests/routes/test_auth_route_policy.py` — adding the flag to a new route fails that test until you consciously add the route to `EXPECTED_MEMBER_READ_ROUTES`, and you must never add a mutating method there.
|
||||
- **When a member-read route is keyed by another sub-resource** (e.g. `peers/{peer_id}/config`), the handler must additionally confirm a peer-scoped caller only reads its OWN resource (`jwt_params.p == peer_id`, else raise `AuthenticationException`). Membership grants session access, not access to a co-member's data. See `get_peer_config` in `src/routers/sessions.py`.
|
||||
|
||||
### Runtime Architecture
|
||||
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
---
|
||||
|
||||

|
||||

|
||||
[](https://pypi.org/project/honcho-ai/)
|
||||
[](https://npmjs.org/package/@honcho-ai/sdk)
|
||||
[](https://discord.gg/honcho)
|
||||
|
|
@ -394,6 +394,26 @@ the `AUTH_JWT_SECRET` environment variable. This is required for `AUTH_USE_AUTH`
|
|||
AUTH_JWT_SECRET=<generated_secret>
|
||||
```
|
||||
|
||||
Once auth is enabled, use `scripts/generate_jwt.py` to mint tokens for local
|
||||
development and scripting:
|
||||
|
||||
```bash
|
||||
# Admin token (full access, no expiry)
|
||||
uv run python scripts/generate_jwt.py --admin
|
||||
|
||||
# Admin token expiring in 24 hours
|
||||
uv run python scripts/generate_jwt.py --admin --expires 24h
|
||||
|
||||
# Workspace-scoped token
|
||||
uv run python scripts/generate_jwt.py --workspace my-workspace --expires 30d
|
||||
|
||||
# Capture a token for use in curl/scripts
|
||||
TOKEN=$(uv run python scripts/generate_jwt.py --admin --print-only)
|
||||
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/v3/workspaces
|
||||
```
|
||||
|
||||
Duration units: `s` (seconds), `m` (minutes), `h` (hours), `d` (days), `w` (weeks), `y` (years).
|
||||
|
||||
5. **Run database migrations**
|
||||
|
||||
With the database set up and environment variables configured, run the migrations
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
# Application-level settings
|
||||
[app]
|
||||
LOG_LEVEL = "INFO"
|
||||
PERFORMANCE_LOG_FORMAT = "compact" # "compact" for single-line logs, "rich" for local panels
|
||||
SESSION_OBSERVERS_LIMIT = 10
|
||||
GET_CONTEXT_MAX_TOKENS = 100000
|
||||
MAX_FILE_SIZE = 5242880 # 5MB
|
||||
|
|
@ -26,11 +27,14 @@ POOL_CLASS = "default"
|
|||
POOL_PRE_PING = true
|
||||
POOL_SIZE = 10
|
||||
MAX_OVERFLOW = 20
|
||||
POOL_TIMEOUT = 30 # seconds
|
||||
POOL_TIMEOUT = 5 # seconds a pooled checkout waits for a free connection (QueuePool only)
|
||||
POOL_RECYCLE = 300 # seconds
|
||||
POOL_USE_LIFO = true
|
||||
SQL_DEBUG = false
|
||||
TRACING = false
|
||||
# Per-connection establish timeout (seconds) so a single connection attempt
|
||||
# fails fast instead of hanging when the server/pooler is unreachable.
|
||||
CONNECT_TIMEOUT_SECONDS = 2
|
||||
|
||||
# Authentication settings
|
||||
[auth]
|
||||
|
|
@ -81,14 +85,33 @@ model = "text-embedding-3-small"
|
|||
ENABLED = true
|
||||
WORKERS = 1
|
||||
POLLING_SLEEP_INTERVAL_SECONDS = 1.0
|
||||
# Adaptive polling: when idle/erroring, the sleep interval grows from
|
||||
# POLLING_SLEEP_INTERVAL_SECONDS toward POLLING_SLEEP_MAX_INTERVAL_SECONDS by
|
||||
# POLLING_BACKOFF_MULTIPLIER each cycle, then snaps back to base when work is
|
||||
# found. Cuts steady-state query load against the shared DB/pooler.
|
||||
POLLING_BACKOFF_ENABLED = true
|
||||
POLLING_SLEEP_MAX_INTERVAL_SECONDS = 30.0
|
||||
POLLING_BACKOFF_MULTIPLIER = 2.0
|
||||
# Jitter so instances that start together don't poll in lockstep. Startup:
|
||||
# sleep a random delay in [0, POLLING_STARTUP_JITTER_SECONDS] before the first
|
||||
# poll (0.0 disables). Per-cycle: multiply every poll sleep by a random factor
|
||||
# in [1 - ratio, 1 + ratio] (0.5 -> [0.5x, 1.5x]; 0.0 disables).
|
||||
POLLING_STARTUP_JITTER_SECONDS = 30.0
|
||||
POLLING_JITTER_RATIO = 0.5
|
||||
STALE_SESSION_TIMEOUT_MINUTES = 5
|
||||
# Minimum (jittered) spacing between stale-work-unit cleanup runs per instance.
|
||||
# Staleness is a minutes-timescale condition, so cleanup doesn't need to run on
|
||||
# every seconds-scale poll (0.0 = run every poll, legacy behavior).
|
||||
STALE_WORK_UNIT_CLEANUP_INTERVAL_SECONDS = 60.0
|
||||
# QUEUE_ERROR_RETENTION_SECONDS = 2592000 # 30 days
|
||||
DEDUPLICATE = true
|
||||
LOG_OBSERVATIONS = false
|
||||
MAX_INPUT_TOKENS = 25000
|
||||
MAX_CUSTOM_INSTRUCTIONS_TOKENS = 2000
|
||||
WORKING_REPRESENTATION_MAX_OBSERVATIONS = 100
|
||||
REPRESENTATION_BATCH_MAX_TOKENS = 1024
|
||||
REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS = 512 # Min tokens a work unit accumulates before the deriver claims it; 0 disables the gate
|
||||
REPRESENTATION_BATCH_TARGET_INPUT_TOKENS = 1024 # Max context-window tokens per deriver LLM call
|
||||
REPRESENTATION_BATCH_MAX_AGE_SECONDS = 1800
|
||||
FLUSH_ENABLED = false # Bypass batch token threshold, process work immediately
|
||||
|
||||
[deriver.model_config]
|
||||
|
|
|
|||
|
|
@ -30,7 +30,11 @@ This guide helps you match the right SDK version to your Honcho API version. New
|
|||
|
||||
| Honcho API Version | TypeScript SDK | Python SDK |
|
||||
|-------------------|---------------|------------|
|
||||
| v3.0.7 (Current) | v2.1.2 | v2.1.2 |
|
||||
| v3.0.11 (Current) | v2.1.2 | v2.1.2 |
|
||||
| v3.0.10 | v2.1.2 | v2.1.2 |
|
||||
| v3.0.9 | v2.1.2 | v2.1.2 |
|
||||
| v3.0.8 | v2.1.2 | v2.1.2 |
|
||||
| v3.0.7 | v2.1.2 | v2.1.2 |
|
||||
| v3.0.6 | v2.1.1 | v2.1.1 |
|
||||
| v3.0.5 | v2.1.0 | v2.1.0 |
|
||||
| v3.0.4 | v2.1.0 | v2.1.0 |
|
||||
|
|
|
|||
|
|
@ -27,7 +27,95 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
|
|||
### Honcho API and SDK Changelogs
|
||||
<Tabs>
|
||||
<Tab title="Honcho API">
|
||||
<Update label="v3.0.7 (Current)">
|
||||
<Update label="v3.0.11 (Current)">
|
||||
### Added
|
||||
|
||||
- `api_request_duration_seconds` Prometheus histogram tracking per-route request latency, labeled by method and endpoint (#837)
|
||||
- LLM `provider_params` passthroughs (`extra_body` / `extra_headers` / `extra_query`) are now forwarded to the underlying provider transport across all backends, with shape validation that rejects non-mapping values (#821)
|
||||
- `structured_output_mode` model-config option to use `json_object` mode for OpenAI-compatible providers that lack native Structured Outputs support (used by the deriver) (#820)
|
||||
- OpenRouter app-attribution headers (`HTTP-Referer` / `X-Openrouter-Title`) are now sent on OpenAI-compatible clients when the configured base URL is OpenRouter, so requests are attributed to "Honcho" in OpenRouter's dashboard (#805)
|
||||
- Langfuse traces are now tagged with user and session IDs for easier trace filtering (#814)
|
||||
- `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800s) lets sub-threshold representation work units flush once their oldest unprocessed queue item ages out. Set it to `0` to keep the legacy behavior where sub-threshold tails wait indefinitely unless `DERIVER_FLUSH_ENABLED=true` (#826)
|
||||
- Conclusion responses now include a `level` field (`explicit`, `deductive`, `inductive`, `contradiction`); list/query endpoints support filtering by `level` via `filters`, with reserved filter keys protected from being overridden by user-supplied filters (#851)
|
||||
|
||||
### Changed
|
||||
|
||||
- Peer-scoped JWTs now get read-only access to the sessions their peer is an active member of (session context, summaries, peers, their own per-session config, search, and message reads). Session-scoped JWTs remain confined to their session and cannot reach peer routes (#679)
|
||||
- Compacted Honcho's log output, with guarded ms/s metric formatting that falls back to a plain string for non-numeric values (#836)
|
||||
- Sentry now drops noisy infra/scrape transactions: the reconciler opens a transaction only once a batch has rows (idle cycles emit none), and a `traces_sampler` returns `0.0` for `/metrics`, `/health`, `/openapi.json`, `/docs`, `/redoc`, and the deriver metrics server. `SENTRY.TRACES_SAMPLE_RATE` still governs real traffic (#834)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Peer- and session-scoped JWTs were effectively workspace-scoped: authorization walked the route's declared scope and fell through to a workspace match, so a `{w, p: alice}` token could act on any peer in the workspace. JWTs are now authorized by their narrowest claim and never widen to workspace access (#679)
|
||||
- The keys API now rejects creating a peer- or session-scoped key without a workspace. Such keys were minted successfully but failed verification on every request (#679)
|
||||
- Agent-supplied observation IDs carrying the display-format `id:` prefix are now normalized (prefix and trailing whitespace stripped) before `source_ids` are stored and on `get_reasoning_chain` lookups, fixing corrupted provenance links and broken reasoning-chain traversal (#795)
|
||||
- Fixed a `create_tree` keyword-argument mismatch in the Dreamer's surprisal tree construction (#749)
|
||||
- Providers that omit output-token counts (observed with Gemini on tool-loop completions) returned `output_tokens=None`, which raised a Pydantic validation error that aborted the call and crashed the Dreamer's induction phase before inductive conclusions were persisted. `None` is now coerced to `0` so token accounting degrades gracefully (#809)
|
||||
- Document creation now performs exact (case-insensitive, whitespace-trimmed) content deduplication before the existing semantic dedup step: exact duplicates within a batch collapse to a single insert, and an exact match against a live document reinforces it (atomic `times_derived` increment) instead of creating a new row (#861)
|
||||
</Update>
|
||||
|
||||
<Update label="v3.0.10">
|
||||
### Added
|
||||
|
||||
- Messages are now embedded via a background task rather than blocking API request
|
||||
- Read-only DB session mode (`get_read_db` / `tracked_db(..., read_only=True)`) so reads don't hold a transaction open across the work
|
||||
- `CORS_ORIGINS` env var to configure CORS allowed origins without editing source; defaults match the prior hardcoded list, so self-hosted deployments behind custom domains can whitelist their frontend (#697)
|
||||
- `scripts/generate_jwt.py` — utility for minting scoped or admin Honcho JWTs (`--admin`, `--workspace`/`--peer`/`--session`, `--expires` with human-friendly durations, `--print-only`) without calling the keys API (#757)
|
||||
- `STALE_WORK_UNIT_CLEANUP_INTERVAL_SECONDS` (default 60s) — minimum jittered spacing between deriver stale-work-unit cleanup runs, so cleanup no longer runs on every seconds-scale poll (`0.0` keeps the legacy every-poll behavior) (#773)
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized the deriver and dreamer prompt cache prefixes to improve prompt-cache hit rates (#806)
|
||||
|
||||
### Fixed
|
||||
|
||||
- `times_derived` is now properly reinforced when a duplicate conclusion is detected. It had been pinned at 1 for nearly every conclusion (the reject-new branch dropped the increment and the new-wins branch reset the count to 1), so `ORDER BY times_derived DESC` fell back to arbitrary heap order and froze stale conclusions to the front of injected context. Reinforcement is now an atomic increment and both most-derived queries gained a `created_at DESC` recency tiebreaker (#768)
|
||||
- Webhook creation now correctly rejects private/internal IP addresses (#793)
|
||||
</Update>
|
||||
|
||||
<Update label="v3.0.9">
|
||||
### Changed
|
||||
|
||||
- Connection acquisition is now a single attempt with no server-side retry, on a vanilla `AsyncSession`. A new `DB_CONNECT_TIMEOUT_SECONDS` (default 2s) bounds the attempt so a saturated or unreachable pooler fails fast instead of holding a client connection open to re-knock. A saturated DB now surfaces to the caller — the API returns an error and the deriver backs off and retries on a later poll — which lets the pooler drain rather than amplifying saturation.
|
||||
|
||||
### Added
|
||||
|
||||
- Deriver poll jitter so instances that start together don't poll in lockstep: `DERIVER_POLLING_STARTUP_JITTER_SECONDS` (random delay before the first poll, default 30s) and `DERIVER_POLLING_JITTER_RATIO` (±fraction applied to every poll sleep, default 0.5). Both disable at `0.0`; the underlying backoff schedule is unchanged.
|
||||
|
||||
### Removed
|
||||
|
||||
- Reverted the connection-checkout retry and `HonchoAsyncSession` custom session introduced in 3.0.8. Removed the `DB_CONNECTION_RETRY_ENABLED` / `DB_CONNECTION_RETRY_MAX_DELAY_SECONDS` / `DB_CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS` / `DB_CONNECTION_RETRY_BACKOFF_MAX_SECONDS` settings, the `db_connection_acquisitions{outcome=...}` Prometheus counter, and the `db.pool.acquire` Sentry span. Alerting built on `db_connection_acquisitions` should migrate to `db_pool_connections` / `db_queries_in_flight`.
|
||||
</Update>
|
||||
|
||||
<Update label="v3.0.8">
|
||||
### Added
|
||||
|
||||
- Connection-checkout retry with bounded exponential backoff (tenacity) on `get_db`/`tracked_db`: transient transaction-pooler (Supavisor) rejections — SQLAlchemy `TimeoutError` and `OperationalError` — now retry with backoff instead of surfacing as 500s under client-connection saturation. Gated by
|
||||
`DB_CONNECTION_RETRY_ENABLED` with configurable delay/backoff knobs; ~10s default budget (#758)
|
||||
- `HonchoAsyncSession` — a lazy `AsyncSession` that checks out its pooled connection (with retry) on the first DB-touching call rather than at construction. Request handlers doing non-DB work (embedding, file, LLM) before their first query no longer pin a pooler connection across it. Only the checkout is retried;
|
||||
the statement still runs exactly once, so writes are never duplicated (#758)
|
||||
- Adaptive deriver queue polling: the poll interval backs off when the queue is idle or erroring (base → max, doubling each cycle) and snaps back to base the moment work is claimed, cutting steady-state query load against the DB. Gated by `DERIVER_POLLING_BACKOFF_ENABLED` with configurable max/multiplier (#758)
|
||||
- New Prometheus `db_pool_connections` gauge (checked_out / checked_in / size / overflow), labeled `api`|`deriver`, registered in both the API lifespan and the deriver metrics server (#758)
|
||||
- New Prometheus `db_connection_acquisitions{outcome=ok|retried|exhausted}` counter — the alertable early-warning signal that connection checkouts are retrying through pooler rejection, before requests start failing (#758)
|
||||
- New Prometheus `db_queries_in_flight` gauge — statements actually executing on the wire (via SQLAlchemy cursor-execute events). Paired with `checked_out`, the gap reveals connections held but parked (the "idle in transaction during an external call" antipattern). Gated on `METRICS.ENABLED` for zero overhead when
|
||||
off (#758)
|
||||
- Explicit `SqlalchemyIntegration` in both the API and deriver Sentry inits; connection acquisition wrapped in a `db.pool.acquire` span with live pool stats captured on retry exhaustion (#758)
|
||||
|
||||
### Changed
|
||||
|
||||
- Default `POOL_TIMEOUT` lowered to 5s, with validation that it stays under the connection-retry budget when a pooled (non-null) `POOL_CLASS` is configured; `config.toml.example` and the v2/v3 configuration docs updated to match (#758)
|
||||
- `HonchoAsyncSession` wraps every DB-touching session method (execute / scalar / scalars / flush / merge / refresh / commit / get / get_one / stream / stream_scalars / delete) so the lazy-checkout-with-retry guarantee has no holes; the acquired flag resets on `close()`/`reset()` so a reused session re-acquires on
|
||||
next use (#758)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Roll the session back on a retryable checkout failure before retrying — a failed autobegin could otherwise leave it pending-rollback, making the next connection attempt raise instead of cleanly re-checking-out (#758)
|
||||
- Guard `DBPoolCollector.collect()` so a pool-read/import hiccup can't raise and abort the entire `/metrics` scrape (Prometheus drops all metrics if any collector raises) (#758)
|
||||
- Clamp the pool overflow gauge to ≥ 0 (it could report negative before the pool fills) (#758)
|
||||
- Removed a double-sleep in the deriver idle poll so the backoff cap is a true cap rather than 2× (#758)
|
||||
</Update>
|
||||
|
||||
<Update label="v3.0.7">
|
||||
### Added
|
||||
|
||||
- New `src/llm/` package as the single owner of provider runtime: clients, backends, history adapters, tool loop, request builder, credentials, and caching policy (#459)
|
||||
|
|
@ -610,7 +698,17 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
|
|||
|
||||
<Tab title="Python SDK">
|
||||
[Python SDK](https://pypi.org/project/honcho-ai/)
|
||||
<Update label="v2.1.2 (Current)">
|
||||
<Update label="v2.2.0">
|
||||
### Added
|
||||
|
||||
- `ConclusionLevel` type (`explicit`, `deductive`, `inductive`, `contradiction`) and a `level` field on `Conclusion`, exposing the reasoning level the server already tracked but previously stripped from responses.
|
||||
- `filters` parameter on `ConclusionScope.list()` and `ConclusionScope.query()` (sync and async), passed through to the same dynamic server-side filter logic as `peers()`/`sessions()`/`messages()`. Filter explicit-only conclusions with `filters={"level": "explicit"}`, or by any other supported field/operator. Requires a Honcho server with the matching API support (Honcho v3.0.11+).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Scope-managed filter keys (`observer`, `observed`, `session`) are now rejected with a clear `ValueError` if passed in `filters`, instead of silently overriding the scope and returning conclusions from a different peer pair. Use `peer.conclusions` / `conclusions_of(target)` and the `session=` parameter instead. `session_id` remains a valid filter on `query()`.
|
||||
</Update>
|
||||
<Update label="v2.1.2">
|
||||
### Added
|
||||
|
||||
- `page`, `size`, and `reverse` pagination parameters on `Honcho.workspaces()` and `HonchoAio.workspaces()`, closing the gap from 2.1.0 which added these to other list methods but not to `workspaces()`. Honoring `reverse` on the workspace/peer/session list routes also requires a Honcho server with the matching API fix; older servers silently ignore the parameter.
|
||||
|
|
@ -762,7 +860,17 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
|
|||
|
||||
<Tab title="TypeScript SDK">
|
||||
[TypeScript SDK](https://www.npmjs.com/package/@honcho-ai/sdk)
|
||||
<Update label="v2.1.2 (Current)">
|
||||
<Update label="v2.2.0">
|
||||
### Added
|
||||
|
||||
- `ConclusionLevel` type (`explicit`, `deductive`, `inductive`, `contradiction`) and a `level` field on `Conclusion`, exposing the reasoning level the server already tracked but previously stripped from responses.
|
||||
- `filters` option on `conclusions.list()` and `conclusions.query()`, passed through to the same dynamic server-side filter logic as the other list endpoints. Filter explicit-only conclusions with `{ filters: { level: 'explicit' } }`, or by any other supported field/operator. Requires a Honcho server with the matching API support (Honcho v3.0.11+).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Scope-managed filter keys (`observer`, `observed`, `session`) are now rejected with a clear error if passed in `filters`, instead of silently overriding the scope and returning conclusions from a different peer pair. Use `peer.conclusions` / `peer.conclusionsOf(target)` and the dedicated `session` option instead. `session_id` remains a valid filter on `query()`.
|
||||
</Update>
|
||||
<Update label="v2.1.2">
|
||||
### Added
|
||||
|
||||
- `peers` option on `Honcho.session()` — attach peers to a session at creation time instead of needing a follow-up `session.addPeers()` call. Accepts the same `PeerAddition` shape as `session.addPeers()` (peer ID strings, `Peer` objects, arrays of either, or a record with per-peer `observe_me`/`observe_others` config).
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
"navigation": {
|
||||
"versions": [
|
||||
{
|
||||
"version": "v3.0.7",
|
||||
"version": "v3.0.11",
|
||||
"api": {
|
||||
"openapi": ["v3/openapi.json"]
|
||||
},
|
||||
|
|
@ -67,6 +67,7 @@
|
|||
"v3/documentation/features/advanced/queue-status",
|
||||
"v3/documentation/features/advanced/search",
|
||||
"v3/documentation/features/advanced/using-filters",
|
||||
"v3/documentation/features/advanced/structured-outputs",
|
||||
"v3/documentation/features/advanced/streaming-response",
|
||||
"v3/documentation/features/advanced/file-uploads"
|
||||
]
|
||||
|
|
@ -95,6 +96,7 @@
|
|||
"pages": [
|
||||
"v3/guides/integrations/claude-code",
|
||||
"v3/guides/integrations/opencode",
|
||||
"v3/guides/integrations/codex",
|
||||
"v3/guides/integrations/vercel-ai-sdk",
|
||||
"v3/guides/integrations/crewai",
|
||||
"v3/guides/integrations/langgraph",
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
|
|
@ -161,7 +161,7 @@ DB_CONNECTION_URI=postgresql+psycopg://honcho_user:secure_password@db.example.co
|
|||
DB_SCHEMA=public
|
||||
DB_POOL_SIZE=10
|
||||
DB_MAX_OVERFLOW=20
|
||||
DB_POOL_TIMEOUT=30
|
||||
DB_POOL_TIMEOUT=5
|
||||
DB_POOL_RECYCLE=300
|
||||
DB_POOL_PRE_PING=true
|
||||
DB_SQL_DEBUG=false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
openapi: get /health
|
||||
---
|
||||
|
|
@ -60,6 +60,12 @@ You can mix providers freely — for example, use Gemini for the deriver and Cla
|
|||
|
||||
For OpenAI-compatible proxies (OpenRouter, vLLM, Ollama, etc.), use `transport = "openai"` and set `MODEL_CONFIG__OVERRIDES__BASE_URL` on each feature to point at your endpoint.
|
||||
|
||||
<Note>
|
||||
Some OpenAI-compatible providers don't support OpenAI Structured Outputs (`json_schema`). Set `DERIVER_MODEL_CONFIG__STRUCTURED_OUTPUT_MODE=json_object` to request loose JSON mode and inject the schema into the prompt instead.
|
||||
|
||||
This setting only applies to the **deriver** on the **`openai`** transport — it is the only feature that uses structured output. The dialectic, summarizer, and dreamer don't request structured output, so the setting has no effect there, and the anthropic/gemini transports reject it.
|
||||
</Note>
|
||||
|
||||
### Tiered Model Setup
|
||||
|
||||
Once you're past initial setup, you can assign different models per feature for better cost/quality tradeoffs. This example uses OpenRouter with light/medium/heavy tiers:
|
||||
|
|
@ -185,6 +191,33 @@ Each model config supports an `overrides.provider_params` dict for passing arbit
|
|||
verbosity = "low"
|
||||
```
|
||||
|
||||
#### Transport passthrough keys
|
||||
|
||||
Three keys inside `provider_params` are recognized as request-level escape hatches and forwarded to the underlying transport. Where a transport actually validates and merges one of these keys, its value must be a mapping — a non-mapping value raises a configuration error (see the per-transport behavior below; a key a transport ignores is not validated):
|
||||
|
||||
- **`extra_body`** — merged into the request body
|
||||
- **`extra_headers`** — extra HTTP headers
|
||||
- **`extra_query`** — extra URL query parameters
|
||||
|
||||
How each transport forwards them differs:
|
||||
|
||||
- **OpenAI and Anthropic** forward all three as identically-named SDK kwargs (`extra_body`, `extra_headers`, `extra_query`).
|
||||
- **Gemini** has no SDK kwargs for these. It merges `extra_body` into the `GenerateContentConfig` dict and folds `extra_headers` into `http_options.headers`; `extra_query` is **unsupported and silently ignored**.
|
||||
|
||||
The merge is shallow and **operator-wins**: if Honcho and your config both set the same top-level key inside `extra_body`, your value replaces Honcho's. You are responsible for choosing a coherent combination — e.g. unset `thinking_budget_tokens` when supplying an `extra_body.thinking` for Anthropic-via-proxy, since Honcho will not translate between the two shapes.
|
||||
|
||||
Because Gemini merges `extra_body` directly into `GenerateContentConfig` (rather than a nested request body), an `extra_body` written for OpenAI/Anthropic generally will not transfer to Gemini unchanged — and a key collision there can overwrite a field Honcho manages (`thinking_config`, `response_schema`, `tools`, …).
|
||||
|
||||
```toml
|
||||
# Example: route an OpenAI-compatible proxy and tag requests for tracing
|
||||
[deriver.model_config.overrides.provider_params.extra_headers]
|
||||
X-Proxy-Route = "vertex"
|
||||
|
||||
[deriver.model_config.overrides.provider_params.extra_body]
|
||||
# Provider-native body fields the standard config doesn't expose
|
||||
anthropic_beta = ["context-1m-2025-01-15"]
|
||||
```
|
||||
|
||||
### Changing Transport
|
||||
|
||||
When changing a feature's `transport`, always specify `model` explicitly. Partial overrides that change transport without model will keep the previous model name, which may not be valid for the new provider.
|
||||
|
|
@ -346,6 +379,7 @@ DERIVER_MAX_CUSTOM_INSTRUCTIONS_TOKENS=2000
|
|||
# DERIVER_MODEL_CONFIG__THINKING_EFFORT=minimal
|
||||
# DERIVER_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024
|
||||
# DERIVER_MODEL_CONFIG__TEMPERATURE=0.7 # Optional temperature override
|
||||
# DERIVER_MODEL_CONFIG__STRUCTURED_OUTPUT_MODE=json_object # for providers without json_schema support
|
||||
|
||||
# Backup model (optional)
|
||||
# DERIVER_MODEL_CONFIG__FALLBACK__MODEL=claude-haiku-4-5
|
||||
|
|
@ -354,6 +388,17 @@ DERIVER_MAX_CUSTOM_INSTRUCTIONS_TOKENS=2000
|
|||
# Worker settings
|
||||
DERIVER_WORKERS=1 # Increase for higher throughput
|
||||
DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0
|
||||
# Adaptive polling: when idle/erroring, the sleep interval grows from the base
|
||||
# toward DERIVER_POLLING_SLEEP_MAX_INTERVAL_SECONDS by the multiplier each cycle,
|
||||
# then snaps back to base when work is found. Cuts steady-state query load.
|
||||
DERIVER_POLLING_BACKOFF_ENABLED=true
|
||||
DERIVER_POLLING_SLEEP_MAX_INTERVAL_SECONDS=30.0
|
||||
DERIVER_POLLING_BACKOFF_MULTIPLIER=2.0
|
||||
# Jitter so instances that start together don't poll in lockstep. Startup: sleep
|
||||
# a random delay in [0, value] before the first poll (0.0 disables). Per-cycle:
|
||||
# multiply every poll sleep by a random factor in [1-ratio, 1+ratio] (0.0 disables).
|
||||
DERIVER_POLLING_STARTUP_JITTER_SECONDS=30.0
|
||||
DERIVER_POLLING_JITTER_RATIO=0.5
|
||||
DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5
|
||||
|
||||
# Queue management
|
||||
|
|
@ -363,7 +408,9 @@ DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000 # 30 days
|
|||
DERIVER_DEDUPLICATE=true
|
||||
DERIVER_LOG_OBSERVATIONS=false
|
||||
DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100
|
||||
DERIVER_REPRESENTATION_BATCH_MAX_TOKENS=1024
|
||||
DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=512
|
||||
DERIVER_REPRESENTATION_BATCH_TARGET_INPUT_TOKENS=1024
|
||||
DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS=1800
|
||||
```
|
||||
|
||||
**Peer Card:**
|
||||
|
|
@ -459,10 +506,13 @@ DB_SCHEMA=public
|
|||
DB_POOL_PRE_PING=true
|
||||
DB_POOL_SIZE=10
|
||||
DB_MAX_OVERFLOW=20
|
||||
DB_POOL_TIMEOUT=30
|
||||
DB_POOL_TIMEOUT=5
|
||||
DB_POOL_RECYCLE=300
|
||||
DB_POOL_USE_LIFO=true
|
||||
DB_SQL_DEBUG=false
|
||||
# Per-connection establish timeout (seconds) so a single connection attempt
|
||||
# fails fast instead of hanging when the server/pooler is unreachable.
|
||||
DB_CONNECT_TIMEOUT_SECONDS=2
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ Messages are stored but no observations, summaries, or representations are being
|
|||
```bash
|
||||
DERIVER_WORKERS=4
|
||||
```
|
||||
5. **Representation Batch Max** — By default the deriver is set to buffer its operations until there are enough tokens for a given representation in a session. This is set via the `REPRESENTATION_BATCH_MAX_TOKENS` environment variable. If you aren't seeing tasks continue it may be that the batch size is set too high or enough data hasn't flowed into to the session yet. See [token batching](/v3/documentation/core-concepts/reasoning#token-batching) for more details
|
||||
5. **Representation Batching** — By default the deriver buffers representation work until a work unit has accumulated enough tokens, set via `DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS` (`0` disables the accumulation gate). A separate setting, `DERIVER_REPRESENTATION_BATCH_TARGET_INPUT_TOKENS`, caps the conversation window fed to each deriver LLM call when draining a claimed work unit. Sub-threshold tails become eligible after `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800 seconds), so quiet sessions eventually flush without disabling batching globally. Set the age to `0` for legacy behavior where sub-threshold tails wait indefinitely. See [token batching](/v3/documentation/core-concepts/reasoning#token-batching) for more details
|
||||
|
||||
## Alternative Provider Issues
|
||||
|
||||
|
|
@ -142,7 +142,19 @@ If calls to an OpenAI-compatible proxy fail:
|
|||
DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=http://host.docker.internal:8000/v1
|
||||
```
|
||||
|
||||
3. **Structured output failures** — vLLM's structured output support is limited to certain response formats. If you see JSON parsing errors, check the deriver/dream logs for the raw response.
|
||||
3. **Structured output failures** — vLLM's structured output support is limited to certain response formats. If you see JSON parsing errors, check the deriver/dream logs for the raw response. See [Deriver produces no observations](#deriver-produces-no-observations) below.
|
||||
|
||||
### Deriver produces no observations
|
||||
|
||||
If messages are processed (the queue drains, no errors in logs) but peers never accumulate observations — and you're using an OpenAI-compatible provider — the likely cause is that the provider doesn't support OpenAI Structured Outputs (`json_schema`). The OpenAI backend requests `json_schema` by default; providers like **Z.AI GLM** and some **Ollama/vLLM** deployments either reject it or silently ignore it and return prose, which the deriver can't parse into observations.
|
||||
|
||||
**Fix:** set `STRUCTURED_OUTPUT_MODE=json_object` on the deriver's model config to request loose JSON mode, which injects the schema into the prompt instead:
|
||||
|
||||
```bash
|
||||
DERIVER_MODEL_CONFIG__STRUCTURED_OUTPUT_MODE=json_object
|
||||
```
|
||||
|
||||
This is a per-model-config setting on the OpenAI transport; set it on whichever features use the affected provider (e.g. `DREAM_DEDUCTION_MODEL_CONFIG__STRUCTURED_OUTPUT_MODE`).
|
||||
|
||||
### Thinking budget errors with non-Anthropic providers
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,20 @@ When you need context from Honcho, you query through the "Chat" endpoint or "Get
|
|||
|
||||
The diagram above shows how agents write messages to Honcho, which triggers reasoning that updates peer representations. Agents can then query representations to get additional context for their next response. Black arrows represent read/write of regular data (messages, storage), while red arrows represent read/write of reasoned-over data (logic, peer representations).
|
||||
|
||||
### Under the Hood: Write, Reasoning, and Query Paths
|
||||
|
||||
Honcho runs as two cooperating processes: an **API server** that handles requests and enqueues background work, and a **worker** that consumes that work off the queue. This split is what keeps the write path fast--your request never waits on an LLM call.
|
||||
|
||||

|
||||
|
||||
**Write path (synchronous).** A message is stored and a reasoning task is enqueued in the same request; the API returns immediately. Nothing about the reasoning that follows blocks the caller.
|
||||
|
||||
**Deriver + Summarizer (async, per-message).** The worker picks up queued tasks in small batches. The Deriver reads new messages and extracts conclusions about the peer--explicit statements and direct deductions. In parallel, the Summarizer periodically rolls up recent messages into short- and long-form session summaries. Both run per-message (well, per-batch) rather than on a schedule.
|
||||
|
||||
**Dreamer (periodic).** On a schedule (or triggered on demand), the Dreamer revisits existing conclusions to consolidate and deepen them: removing redundant or stale ones, drawing inductive conclusions across patterns that span multiple messages, and updating peer cards--compact biographical summaries of a peer. This is where memory gets richer over time, not just larger.
|
||||
|
||||
**Query path (Dialectic).** A `chat()` call spawns a Dialectic agent that answers your question by exploring memory--searching conclusions semantically, pulling supporting messages, and tracing a conclusion back to the premises it was drawn from--before synthesizing a grounded answer. This all happens inline during the request, since answering well is worth the latency that write-path reasoning is designed to avoid.
|
||||
|
||||
## Configuration & Extensibility
|
||||
|
||||
Honcho is designed to be flexible. Settings cascade hierarchically from workspace to peer to session, so you can set defaults at the workspace level and override them for specific peers or sessions. Feature flags let you enable or disable reasoning modes, perspective tracking, and other capabilities. You can bring your own LLM provider--OpenAI, Anthropic, or custom endpoints--and metadata fields let you extend any primitive with custom JSON data. Batch operations let you create up to 100 messages in a single API call for efficient bulk ingestion.
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ Sessions define the temporal boundaries of an interaction. How you scope them af
|
|||
Create a **new** session when context resets (new conversation, new day, new topic); **reuse** one when context should keep accumulating (ongoing channel, persistent thread).
|
||||
|
||||
<Warning>
|
||||
**Don't scope sessions too thin.** Honcho only reasons over a peer once it accumulates ~1,000 tokens *within a single session* ([token batching](/v3/documentation/core-concepts/reasoning#token-batching)). Many tiny sessions each stall below that threshold, so low-volume or trickle inputs should append to one ongoing session rather than fragment across many (nothing is lost — it just waits).
|
||||
**Don't scope sessions too thin.** Honcho batches reasoning until a peer accumulates ~1,000 tokens *within a single session*, with a default age-based flush for quiet tails ([token batching](/v3/documentation/core-concepts/reasoning#token-batching)). Low-volume or trickle inputs should still append to one ongoing session rather than fragment across many, so reasoning runs with useful context instead of many small delayed batches.
|
||||
</Warning>
|
||||
|
||||
**How cross-session reasoning works**
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ The explicit reasoning model ([Neuromancer XR](https://blog.plasticlabs.ai/resea
|
|||
|
||||
The reasoning that Honcho does is something we're constantly iterating and improving on. Our goal is simple--provide the richest, most relevant context in the fastest, cheapest way possible in order to simulate statefulness in whatever setting you need.
|
||||
|
||||
Two components produce this logic: the **Deriver** extracts explicit and deductive conclusions from incoming messages as they arrive, and the **Dreamer** periodically revisits stored conclusions to consolidate them and draw inductive conclusions across patterns spanning multiple messages. See [Architecture](/v3/documentation/core-concepts/architecture) for how these fit into the request/background split.
|
||||
|
||||
## How It Works
|
||||
|
||||
When you write messages to Honcho, they're stored immediately and enqueued for background processing. Reasoning asynchronously ensures fast writes while still providing rich reasoning capabilities. Messages are stored immediately without blocking, and session-based queues maintain chronological consistency so reasoning tasks affecting the same peer representation are always processed in order.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ icon: 'magnifying-glass'
|
|||
|
||||
Honcho's search functionality allows you to find relevant messages and conversations across different scopes - from entire workspaces down to specific peers or sessions.
|
||||
|
||||
<Note>
|
||||
Search is hybrid: it combines full-text (keyword) matching with semantic (vector) similarity. Keyword matches are available the instant a message is created. Semantic matches depend on the message's embedding, which is generated in the background, so a freshly created message may take a few seconds to surface in semantic results. If you need to assert on semantic results immediately after writing (for example in tests), wait briefly or poll.
|
||||
</Note>
|
||||
|
||||
## Search Scopes
|
||||
|
||||
### Workspace Search
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
---
|
||||
title: "Structured Outputs"
|
||||
description: "Get chat endpoint answers as typed, machine-readable JSON"
|
||||
icon: "brackets-curly"
|
||||
---
|
||||
|
||||
By default, the [chat endpoint](/v3/documentation/features/chat) returns a free-form natural language answer. When your application needs machine-readable output, parsing that string yourself can be fragile and model-dependent. In this case you can use structured Dialectic outputs: pass a schema with your query, and the answer is guaranteed to conform to it. The agent still runs its full reasoning loop and only the final synthesized answer is formatted to your schema.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
Pass a Pydantic model (Python) or Zod schema (TypeScript) as `response_format`, and the SDK returns a parsed, typed instance:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
from typing import Literal
|
||||
from pydantic import BaseModel, Field
|
||||
from honcho import Honcho
|
||||
|
||||
class FoodPreference(BaseModel):
|
||||
food: str
|
||||
sentiment: Literal["loves", "likes", "neutral", "dislikes", "hates"]
|
||||
confidence: float = Field(description="0-1, how certain the evidence is")
|
||||
|
||||
class FoodPreferences(BaseModel):
|
||||
preferences: list[FoodPreference]
|
||||
summary: str
|
||||
|
||||
honcho = Honcho()
|
||||
peer = honcho.peer("user-123")
|
||||
|
||||
result = peer.chat(
|
||||
"What are this user's top 3 food preferences?",
|
||||
response_format=FoodPreferences,
|
||||
)
|
||||
|
||||
# result is a FoodPreferences instance (or None if no relevant information)
|
||||
if result:
|
||||
for pref in result.preferences:
|
||||
print(f"{pref.food}: {pref.sentiment} ({pref.confidence})")
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
import { z } from 'zod';
|
||||
import { Honcho } from '@honcho-ai/sdk';
|
||||
|
||||
const FoodPreferences = z.object({
|
||||
preferences: z.array(z.object({
|
||||
food: z.string(),
|
||||
sentiment: z.enum(["loves", "likes", "neutral", "dislikes", "hates"]),
|
||||
confidence: z.number(),
|
||||
})),
|
||||
summary: z.string(),
|
||||
});
|
||||
|
||||
const honcho = new Honcho({});
|
||||
const peer = await honcho.peer("user-123");
|
||||
|
||||
const result = await peer.chat(
|
||||
"What are this user's top 3 food preferences?",
|
||||
{ responseFormat: FoodPreferences },
|
||||
);
|
||||
|
||||
// result is typed as z.infer<typeof FoodPreferences> (or null)
|
||||
if (result) {
|
||||
console.log(result.summary);
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Using a Raw JSON Schema
|
||||
|
||||
You can also pass a plain JSON Schema object instead of a Pydantic/Zod schema. In that case the SDK returns the answer as a JSON **string** and leaves parsing to you. This is also the shape the REST API accepts directly:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
result = peer.chat(
|
||||
"What are this user's food preferences?",
|
||||
response_format={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"foods": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["foods"],
|
||||
},
|
||||
)
|
||||
# result is a JSON string, e.g. '{"foods": ["dark roast coffee", "sushi"]}'
|
||||
```
|
||||
|
||||
```bash cURL
|
||||
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/peers/user-123/chat" \
|
||||
-H "Authorization: Bearer $HONCHO_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "What are this user'\''s food preferences?",
|
||||
"response_format": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"foods": { "type": "array", "items": { "type": "string" } }
|
||||
},
|
||||
"required": ["foods"]
|
||||
}
|
||||
}'
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
At the API level, `content` in the response is always a string. When `response_format` is set, it is a JSON-encoded object conforming to your schema.
|
||||
|
||||
## Streaming
|
||||
|
||||
`response_format` works with streaming. The stream emits the JSON answer incrementally as raw text chunks; the accumulated text is a valid JSON string once the stream completes. To enable streaming the SDKs cannot parse streamed responses for you, you parse the final string yourself:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
response_stream = peer.chat(
|
||||
"What are this user's food preferences?",
|
||||
stream=True,
|
||||
response_format=FoodPreferences,
|
||||
)
|
||||
|
||||
chunks = []
|
||||
for chunk in response_stream.iter_text():
|
||||
chunks.append(chunk)
|
||||
|
||||
result = FoodPreferences.model_validate_json("".join(chunks))
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
const responseStream = await peer.chat(
|
||||
"What are this user's food preferences?",
|
||||
{ stream: true, responseFormat: FoodPreferences },
|
||||
);
|
||||
|
||||
let text = "";
|
||||
for await (const chunk of responseStream.iter_text()) {
|
||||
text += chunk;
|
||||
}
|
||||
|
||||
const result = FoodPreferences.parse(JSON.parse(text));
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Supported Schema Subset
|
||||
|
||||
Honcho supports a conservative subset of JSON Schema that enables the kind of Pydantic models used for structured LLM outputs. Schemas outside this subset are rejected with a `422` validation error before any reasoning runs.
|
||||
|
||||
The root of the schema must be `"type": "object"`.
|
||||
|
||||
| Construct | Support |
|
||||
|-----------|---------|
|
||||
| `string`, `number`, `integer`, `boolean`, `null` | Supported |
|
||||
| `object` with `properties` (nested recursively) | Supported |
|
||||
| `array` with `items` (missing `items` yields an untyped list) | Supported |
|
||||
| `enum` of strings, integers, booleans, or null | Supported |
|
||||
| `anyOf` / `oneOf` unions (a `null` member makes the field optional) | Supported |
|
||||
| `type` given as a list (e.g. `["string", "null"]`) | Supported |
|
||||
| `required`, `default`, `description` | Supported |
|
||||
| Boolean `additionalProperties` | Accepted and ignored |
|
||||
| `$ref` into root-level `$defs` / `definitions` | Supported — resolved by inlining (this is what Pydantic and Zod emit) |
|
||||
| Recursive `$ref` (a definition that references itself, directly or indirectly) | Rejected (422) — the error will identify the cycle |
|
||||
| Other `$ref` forms (external URLs, arbitrary JSON pointers) | Rejected (422) |
|
||||
| `allOf`, `not`, `if` / `then` / `else` | Rejected (422) |
|
||||
| `patternProperties`, schema-valued `additionalProperties` | Rejected (422) |
|
||||
|
||||
Schemas may nest at most 20 levels deep and contain at most 500 total nodes.
|
||||
|
||||
<Note>
|
||||
Constraint keywords like `minItems`, `maxLength`, `minimum`, `pattern`, and `format` are passed through to the model as hints but are **not enforced server-side**. If you need hard guarantees on these, validate the returned object in your application.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
**Recursive schemas are not supported.** A self-referential Pydantic model (`Node.children: list[Node]`) or a recursive Zod schema (`z.lazy(...)`) produces a recursive `$ref`, which is rejected with a 422 naming the cycle. Restructure recursive shapes as explicit nesting with a fixed depth.
|
||||
</Note>
|
||||
|
||||
## Optional Fields and Unions
|
||||
|
||||
Two distinct mechanisms control "optionality" in a raw JSON Schema:
|
||||
|
||||
- **Omission** is controlled by `required`. A property not listed in `required` may be left out by the model entirely; the parsed answer will contain it as `null`.
|
||||
- **Nullability** is controlled by the field's type. An `anyOf`/`oneOf` with a `{"type": "null"}` member (or the shorthand `"type": ["string", "null"]`) means the field's *value* may be `null` even when the field itself is required.
|
||||
|
||||
`anyOf` and `oneOf` are treated identically: a plain union of the member schemas. Unions of non-null types (e.g. a string-or-integer field) are also supported.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"favorite_food": { "type": "string" },
|
||||
"dietary_restriction": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"description": "The user's dietary restriction, or null if none is known"
|
||||
},
|
||||
"years_vegetarian": { "type": ["integer", "null"] },
|
||||
"confidence": { "type": "number" }
|
||||
},
|
||||
"required": ["favorite_food", "dietary_restriction", "years_vegetarian"]
|
||||
}
|
||||
```
|
||||
|
||||
In this schema:
|
||||
|
||||
- `favorite_food` is required and must be a string.
|
||||
- `dietary_restriction` is required but **nullable**: the key is always present in the answer, and the model can answer `null` when it has no evidence. This is the recommended way to give the model an escape hatch (see [Best Practices](#model-uncertainty-explicitly)).
|
||||
- `years_vegetarian` is the same thing written with the `type`-list shorthand (`["integer", "null"]` is equivalent to an `anyOf` of the two).
|
||||
- `confidence` is not in `required`, so the model may omit it; if it does, the field comes back as `null`.
|
||||
|
||||
If a property declares a `default`, that default is used whenever the model omits the field _even if_ the property is listed in `required`.
|
||||
|
||||
Pydantic and Zod produce these shapes for you: `str | None` in Pydantic emits the `anyOf` form above, and `z.string().nullable()` does the same in Zod (`z.string().optional()` controls presence in `required`).
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Condition | Result |
|
||||
|-----------|--------|
|
||||
| `response_format` is not a valid JSON Schema object | `422` validation error |
|
||||
| Root type is not `"object"` | `422` validation error |
|
||||
| Schema uses an unsupported construct | `422` identifying the construct and its path |
|
||||
| Schema contains a recursive `$ref` | `422` identifying the cycle (e.g. `cycle: Node -> Node`) |
|
||||
| Model fails to produce valid structured output after retries | `500`, same as any LLM failure |
|
||||
|
||||
## How It Works
|
||||
|
||||
Structured output constrains the final synthesis step. The reasoning itself works the same in both settings.
|
||||
|
||||
1. The dialectic agent runs its normal tool loop in free-form text. It will search conclusions, grep messages, and traverse reasoning chains
|
||||
2. Once the agent has gathered enough context, the final answer generation is constrained to your schema using the provider's native structured output support.
|
||||
3. The conforming JSON is returned as the response `content` and parsed into a typed object by the SDK when you passed a Pydantic model or Zod schema.
|
||||
|
||||
This means answer *quality* is unaffected by the schema: the agent reasons exactly as it would for a free-form answer, and reasoning levels (`minimal` through `max`) work the same way alongside `response_format`.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Add descriptions to your fields
|
||||
Field `description`s are visible to the model when it formats the answer. `confidence: float` with a provided description of "score how certain the evidence is from 0-5" gets meaningfully better output than a bare field.
|
||||
|
||||
### Model uncertainty explicitly
|
||||
The chat endpoint returns `None`/`null` when it has no relevant information. With a schema, you can force an answer even when evidence is thin. To avoid hallucinations, consider including an escape hatch as an optional field, a `"confidence"` score, or an enum member like `"unknown"` so the model isn't forced to fabricate.
|
||||
|
||||
### Keep schemas focused
|
||||
A schema with three well-described fields outperforms one with twenty. If you need many distinct insights, consider making separate chat calls.
|
||||
|
|
@ -614,6 +614,65 @@ messages = session.messages(filters={
|
|||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Filtering Conclusions
|
||||
|
||||
Conclusions are scoped to an observer/observed peer pair (accessed via
|
||||
`peer.conclusions` for self-conclusions or `peer.conclusions_of(target)` for
|
||||
conclusions about another peer). The observer and observed are filled in
|
||||
automatically by the scope, so the `filters` you pass add to them.
|
||||
|
||||
The most useful conclusion-specific field is `level`, the reasoning level:
|
||||
|
||||
- `explicit` — extracted directly from messages
|
||||
- `deductive` / `inductive` / `contradiction` — derived later during dreaming
|
||||
|
||||
A common request is to surface only the directly-stated facts and exclude
|
||||
anything inferred during dreaming — filter `level` to `explicit`:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
# Only conclusions extracted directly from messages (exclude dream-derived)
|
||||
explicit = peer.conclusions.list(filters={"level": "explicit"})
|
||||
|
||||
# Only dream-derived conclusions
|
||||
derived = peer.conclusions.list(filters={"level": {"in": ["deductive", "inductive"]}})
|
||||
|
||||
# Same filtering on semantic search
|
||||
results = peer.conclusions.query(
|
||||
"food preferences",
|
||||
filters={"level": "deductive"},
|
||||
)
|
||||
|
||||
# Conclusions about another peer, explicit only
|
||||
bob_explicit = peer.conclusions_of("bob").list(filters={"level": "explicit"})
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
(async () => {
|
||||
// Only conclusions extracted directly from messages (exclude dream-derived)
|
||||
const explicit = await peer.conclusions.list({ filters: { level: "explicit" } });
|
||||
|
||||
// Only dream-derived conclusions
|
||||
const derived = await peer.conclusions.list({
|
||||
filters: { level: { in: ["deductive", "inductive"] } }
|
||||
});
|
||||
|
||||
// Same filtering on semantic search (query, topK, distance, filters)
|
||||
const results = await peer.conclusions.query(
|
||||
"food preferences",
|
||||
10,
|
||||
undefined,
|
||||
{ level: "deductive" }
|
||||
);
|
||||
|
||||
// Conclusions about another peer, explicit only
|
||||
const bobExplicit = await peer.conclusionsOf("bob").list({
|
||||
filters: { level: "explicit" }
|
||||
});
|
||||
})();
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Error Handling
|
||||
|
||||
Handle filter errors gracefully:
|
||||
|
|
|
|||
|
|
@ -94,6 +94,43 @@ for await (const chunk of responseStream.iter_text()) {
|
|||
|
||||
Streaming is useful for displaying real-time responses in chat interfaces or when asking complex questions that require longer answers.
|
||||
|
||||
## Structured Outputs
|
||||
|
||||
When your application needs a machine-readable answer instead of prose, pass a schema as `response_format` and the answer is guaranteed to conform to it:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
from pydantic import BaseModel
|
||||
|
||||
class OnboardingStatus(BaseModel):
|
||||
completed: bool
|
||||
remaining_steps: list[str]
|
||||
|
||||
status = peer.chat(
|
||||
"Has the user completed the onboarding flow?",
|
||||
response_format=OnboardingStatus,
|
||||
)
|
||||
# status is a parsed OnboardingStatus instance
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
import { z } from 'zod';
|
||||
|
||||
const OnboardingStatus = z.object({
|
||||
completed: z.boolean(),
|
||||
remainingSteps: z.array(z.string()),
|
||||
});
|
||||
|
||||
const status = await peer.chat(
|
||||
"Has the user completed the onboarding flow?",
|
||||
{ responseFormat: OnboardingStatus },
|
||||
);
|
||||
// status is typed as z.infer<typeof OnboardingStatus>
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The agent runs its full reasoning loop either way — only the final answer is formatted to your schema. See [Structured Outputs](/v3/documentation/features/advanced/structured-outputs) for the supported schema subset, streaming behavior, and best practices.
|
||||
|
||||
## Integration Patterns
|
||||
|
||||
### Dynamic Prompt Enhancement
|
||||
|
|
|
|||
|
|
@ -60,7 +60,13 @@ The **Performance** page provides comprehensive monitoring with usage metrics, h
|
|||
</Frame>
|
||||
|
||||
## 3. Manage API Keys
|
||||
The [API Keys](https://app.honcho.dev/api-keys) page allows you to create and manage authentication tokens for different environments. You can create admin-level keys with full instance access or scope keys to specific `Workspaces`, `Peers`, or `Sessions`.
|
||||
The [API Keys](https://app.honcho.dev/api-keys) page allows you to create and manage authentication tokens for different environments. You can create admin-level keys with full instance access or scope keys to a specific `Workspace`, `Peer`, or `Session`.
|
||||
|
||||
Scoped keys are authorized by their narrowest claim and never widen to the whole workspace:
|
||||
|
||||
- A **peer-scoped** key acts on its own peer, plus **read-only** access to the sessions its peer is an active member of (context, summaries, peers, its own per-session config, search, and message reads). It cannot write to those sessions or act on other peers.
|
||||
- A **session-scoped** key is confined to its own session and cannot reach peer routes.
|
||||
- Peer- and session-scoped keys **must carry their parent workspace** — creating one without a workspace is rejected.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/app-screenshots/api-keys.png" alt="API Key Management Dashboard" width="1200" height="800" loading="lazy" decoding="async" fetchpriority="low" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
---
|
||||
title: "Codex"
|
||||
icon: 'square-terminal'
|
||||
description: "Add AI-native memory to OpenAI Codex"
|
||||
sidebarTitle: 'Codex'
|
||||
---
|
||||
|
||||
Give Codex long-term memory that survives context resets, session restarts, and fresh conversations. Codex remembers what you're working on, your preferences, and the decisions you've made — across every project. Lifecycle hooks capture each session to Honcho and inject the relevant context back at session start, so you never have to repeat yourself.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **[Codex](https://developers.openai.com/codex) ≥ 0.136.0**
|
||||
- **[Node](https://nodejs.org)** on your `PATH` (runs the installer and the hooks)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Step 1: Get Your Honcho API Key
|
||||
|
||||
1. Go to **[app.honcho.dev](https://app.honcho.dev)**
|
||||
2. Sign up or log in
|
||||
3. Copy your API key (starts with `hch-`)
|
||||
|
||||
### Step 2: Save Your API Key
|
||||
|
||||
Your key lives in **`~/.honcho/config.json`** — the single config file every Honcho integration reads. codex-honcho takes the key straight from there.
|
||||
|
||||
<Note>
|
||||
**Already have your key in `~/.honcho/config.json`?** If another Honcho integration already wrote it there, there's nothing to do — skip to Step 3 and `install` picks it up automatically.
|
||||
</Note>
|
||||
|
||||
**First time?** Create it with the Honcho CLI:
|
||||
|
||||
```bash
|
||||
honcho init # prompts for your key, writes ~/.honcho/config.json
|
||||
# no CLI yet? uv tool install honcho-cli && honcho init
|
||||
```
|
||||
|
||||
If you'd rather write the file yourself:
|
||||
|
||||
```jsonc
|
||||
// ~/.honcho/config.json
|
||||
{ "apiKey": "hch-your-api-key-here" }
|
||||
```
|
||||
|
||||
### Step 3: Install the Plugin
|
||||
|
||||
```bash
|
||||
npm install -g @honcho-ai/codex-honcho
|
||||
codex-honcho install # registers hooks + MCP + skill in ~/.codex
|
||||
```
|
||||
|
||||
`install` copies your resolved key into `~/.codex/config.toml` so the Honcho MCP server authenticates with no environment variable to set. If you ever rotate your key, re-run `codex-honcho install` to refresh it.
|
||||
|
||||
### Step 4: Restart Codex
|
||||
|
||||
Restart Codex (or start a new session) to load the hooks and the `[features].hooks` flag. On your next session start you'll see Honcho memory load into context.
|
||||
|
||||
### Step 5: (Optional) Tell Codex to use its memory
|
||||
|
||||
The bundled `honcho-memory` skill already nudges Codex to recall and save actively. To reinforce it, add a short directive to your global Codex instructions (`~/.codex/AGENTS.md`):
|
||||
|
||||
```markdown
|
||||
# Honcho Memory
|
||||
|
||||
You have persistent memory via Honcho. Context about me is loaded at the start
|
||||
of every session — trust it and act on it; don't ask me what you already know.
|
||||
Use the Honcho MCP tools (`search`, `chat`) to recall more mid-task, and
|
||||
`create_conclusions` to save new preferences, decisions, and patterns as you learn them.
|
||||
```
|
||||
|
||||
## What You Get
|
||||
|
||||
- **Persistent Memory** — Codex remembers your preferences, projects, and context across sessions
|
||||
- **Survives Context Resets** — Memory persists through `/clear`, compaction, and restarts
|
||||
- **Active Recall** — Codex can search your history and query what Honcho knows about you mid-task, not just at startup
|
||||
- **Git Awareness** — Optionally scope memory per branch, so feature work keeps its own context
|
||||
- **Flexible Sessions** — Map memory per directory, per git branch, or per chat instance
|
||||
- **Local-First Capture** — Conversations are queued to disk instantly and uploaded in the background — capture never blocks your turn or hits the network mid-conversation
|
||||
- **Cross-Tool Context** — Shares `~/.honcho/config.json` with other Honcho integrations (Claude Code, Cursor, …), so context can follow you between tools
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings live in `~/.honcho/config.json` (shared with other Honcho integrations). Codex-specific settings go under `hosts.codex`, falling back to the root fields. The hooks only ever read this file; `install` is the only writer.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"apiKey": "hch-…",
|
||||
"peerName": "alice", // your identity (default: $USER)
|
||||
"hosts": {
|
||||
"codex": {
|
||||
"workspace": "codex", // Honcho workspace for Codex memory
|
||||
"sessionStrategy": "per-directory",
|
||||
"injectPerPrompt": false, // re-inject context every turn (off by default)
|
||||
"saveMessages": true // false = read memory but never write
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Session Strategies
|
||||
|
||||
Controls how Codex conversations map to Honcho sessions:
|
||||
|
||||
| Strategy | Session name | Best for |
|
||||
| --- | --- | --- |
|
||||
| `per-directory` (default) | `my-app` | Most users — each project accumulates its own memory |
|
||||
| `git-branch` | `my-app-main` | Feature-branch workflows where context per branch matters |
|
||||
| `chat-instance` | `my-app-019ea7df` | Ephemeral usage — a clean slate per conversation |
|
||||
|
||||
An explicit `sessions[cwd]` mapping overrides all strategies. Environment overrides: `HONCHO_API_KEY`, `HONCHO_PEER_NAME`, `HONCHO_CONFIG_DIR`.
|
||||
|
||||
## Building with Teammates
|
||||
|
||||
Because `~/.honcho/config.json` is shared across Honcho hosts, teammates can collaborate by pointing at the same workspace while keeping their own identities. Each person uses their own `peerName`, so their contributions are attributed to distinct peers even when they work in the same repo.
|
||||
|
||||
**Alice** (`~/.honcho/config.json`):
|
||||
```json
|
||||
{
|
||||
"apiKey": "hch-team-key...",
|
||||
"peerName": "alice",
|
||||
"hosts": {
|
||||
"codex": { "workspace": "team-acme" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Bob** (`~/.honcho/config.json`):
|
||||
```json
|
||||
{
|
||||
"apiKey": "hch-team-key...",
|
||||
"peerName": "bob",
|
||||
"hosts": {
|
||||
"codex": { "workspace": "team-acme" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Both Alice and Bob write to the `team-acme` workspace. Working in the same repo, they share a session (named by directory, e.g. `my-app`) but appear in it as separate peers — so Honcho's dialectic reasoning can draw on context from both.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
Once installed, Codex can call these Honcho tools directly:
|
||||
|
||||
| Tool | Description |
|
||||
| --- | --- |
|
||||
| `search` | Semantic search across your session messages |
|
||||
| `chat` | Ask Honcho a natural-language question about you |
|
||||
| `get_peer_context` | Fetch the current model of you (representation + peer card) |
|
||||
| `get_representation` | Lightweight representation string |
|
||||
| `create_conclusions` | Save durable insights to memory |
|
||||
| `list_conclusions` | List saved conclusions |
|
||||
| `query_conclusions` | Semantic search across derived conclusions |
|
||||
| `delete_conclusion` | Remove a conclusion by ID |
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Effect |
|
||||
| --- | --- |
|
||||
| `codex-honcho install` | Install hooks + MCP + skill |
|
||||
| `codex-honcho status` | Installed components, pending queue depth, GUI link |
|
||||
| `codex-honcho remove` | Strip only what this installs |
|
||||
|
||||
## What Install Writes
|
||||
|
||||
| Path | Change |
|
||||
| --- | --- |
|
||||
| `~/.codex/honcho/` | staged copy of the bundle the hooks run (kept stable across npm/npx cache eviction) |
|
||||
| `~/.codex/hooks.json` | adds the four hook entries (merged; your own hooks untouched) |
|
||||
| `~/.codex/config.toml` | sets `[features].hooks = true`; registers `[mcp_servers.honcho]` → `mcp.honcho.dev` (native HTTP) |
|
||||
| `~/.codex/skills/honcho-memory/` | the active-recall skill |
|
||||
| `~/.honcho/config.json` | persists the resolved `apiKey` + `peerName` (other fields and `hosts.*` blocks preserved) |
|
||||
|
||||
`codex-honcho remove` reverses exactly these.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**No memory loading / MCP not registered.** Confirm your key is in `~/.honcho/config.json` (`codex-honcho status` shows `honcho config: found`). If it's missing, run `honcho init` (or add `{ "apiKey": "hch-…" }` to the file yourself), then re-run `codex-honcho install` — without a key, install registers the hooks and skill but skips the MCP server.
|
||||
|
||||
**Hooks aren't firing.** Restart Codex after installing so it loads `hooks.json` and the `[features].hooks` flag. Check `codex-honcho status` for installed components and pending queue depth.
|
||||
|
||||
**Memory not persisting.** Make sure `saveMessages` isn't set to `false` under `hosts.codex`.
|
||||
|
||||
## Install from a GitHub Clone (no npm)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/plastic-labs/codex-honcho
|
||||
cd codex-honcho
|
||||
./install.sh # bun install + bun run bin/codex-honcho.ts install
|
||||
```
|
||||
|
||||
The clone path runs the TypeScript source directly and so requires **[bun](https://bun.sh)**; it wires the hooks to `bun run <this dir>/bin/codex-honcho.ts`, so keep the clone in place. The npm install instead stages the bundled `dist/codex-honcho.mjs` to `~/.codex/honcho/` and wires hooks to `node` — node-only, and stable across `npm update`, npx cache eviction, or removing the package.
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="GitHub Repository" icon="github" href="https://github.com/plastic-labs/codex-honcho">
|
||||
Source code, issues, and README.
|
||||
</Card>
|
||||
|
||||
<Card title="Honcho Architecture" icon="sitemap" href="../../documentation/core-concepts/architecture">
|
||||
Learn about peers, sessions, and dialectic reasoning.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
|
@ -224,6 +224,32 @@ Add to `~/.config/zed/settings.json`:
|
|||
Zed uses `context_servers` instead of `mcpServers`. Native HTTP support requires Zed v0.214.5 or later.
|
||||
</Note>
|
||||
|
||||
### Goose
|
||||
|
||||
[Goose](https://goose-docs.ai/) supports remote MCP servers natively over Streamable HTTP.
|
||||
|
||||
The easiest way is to run `goose configure`, choose **Add Extension → Remote Extension (Streamable HTTP)**, and enter the name `honcho`, the URI `https://mcp.honcho.dev`, and the headers `Authorization: Bearer hch-your-key-here` and `X-Honcho-User-Name: YourName`.
|
||||
|
||||
Or edit your `config.yaml` directly (on Linux, `~/.config/goose/config.yaml`):
|
||||
|
||||
```yaml
|
||||
extensions:
|
||||
honcho:
|
||||
enabled: true
|
||||
type: streamable_http
|
||||
name: honcho
|
||||
description: Honcho persistent memory & personalization
|
||||
uri: https://mcp.honcho.dev
|
||||
headers:
|
||||
Authorization: "Bearer hch-your-key-here"
|
||||
X-Honcho-User-Name: "YourName"
|
||||
timeout: 60
|
||||
```
|
||||
|
||||
<Tip>
|
||||
To teach Goose the recommended memory flow, save the [instructions](https://raw.githubusercontent.com/plastic-labs/honcho/refs/heads/main/mcp/instructions.md) into a `.goosehints` file in your Goose config directory (or a project root). This is Goose's equivalent of Claude Desktop's "Project Instructions". Not sure of your config path? Run `goose info`.
|
||||
</Tip>
|
||||
|
||||
---
|
||||
|
||||
## Optional Configuration
|
||||
|
|
|
|||
|
|
@ -131,16 +131,17 @@ for i in range(0, len(messages), 100):
|
|||
session.add_messages(messages[i:i + 100])
|
||||
```
|
||||
|
||||
Honcho only reasons over a peer once it accumulates ~1,000 tokens *within a single session*
|
||||
([token batching](/v3/documentation/core-concepts/reasoning#token-batching)). Scope
|
||||
the session to the volume you ingest:
|
||||
Honcho batches reasoning until a peer accumulates ~1,000 tokens *within a single session*,
|
||||
with a default age-based flush for quiet tails
|
||||
([token batching](/v3/documentation/core-concepts/reasoning#token-batching)). Scope the
|
||||
session to the volume you ingest:
|
||||
|
||||
- **High-volume runs** (a day of emails, a CRM export) clear the threshold easily — a
|
||||
per-run session like `email-import-{date}` is fine.
|
||||
- **Low-volume or trickle imports** (a few short records at a time) should append to
|
||||
one **ongoing per-source session** (e.g. `email-import-gmail`), so content
|
||||
accumulates across runs instead of fragmenting into thin sessions that each stall
|
||||
below the threshold (nothing is lost — it just waits).
|
||||
accumulates across runs instead of fragmenting into thin sessions that each flush
|
||||
later with little context.
|
||||
|
||||
The [Gmail](/v3/guides/gmail) and [Granola](/v3/guides/granola) guides are related
|
||||
import examples.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"url": "https://honcho.dev/",
|
||||
"email": "hello@plasticlabs.ai"
|
||||
},
|
||||
"version": "3.0.7"
|
||||
"version": "3.0.11"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
|
|
@ -1574,7 +1574,7 @@
|
|||
"get": {
|
||||
"tags": ["sessions"],
|
||||
"summary": "Get Peer Config",
|
||||
"description": "Get the configuration for a Peer in a Session.",
|
||||
"description": "Get the configuration for a Peer in a Session.\n\nMember-read lets a peer-scoped key reach this route, but a peer may only\nread its own per-session config — not a co-member's. Workspace/admin and\nsession-scoped tokens (which already span the whole session) are unaffected.",
|
||||
"operationId": "get_peer_config_v3_workspaces__workspace_id__sessions__session_id__peers__peer_id__config_get",
|
||||
"security": [{ "HTTPBearer": [] }],
|
||||
"parameters": [
|
||||
|
|
@ -2783,6 +2783,13 @@
|
|||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Session Id"
|
||||
},
|
||||
"level": {
|
||||
"type": "string",
|
||||
"enum": ["explicit", "deductive", "inductive", "contradiction"],
|
||||
"title": "Level",
|
||||
"description": "Reasoning level of the conclusion: 'explicit' (directly extracted from messages) or 'deductive'/'inductive'/'contradiction' (derived during dreaming).",
|
||||
"default": "explicit"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [0.1.2] - 2026-07-20
|
||||
|
||||
### Added
|
||||
|
||||
- Device-code OAuth login for managed Honcho servers. `honcho init` now offers browser-based login (RFC 8628 device authorization grant) when the host advertises the device grant in its OAuth authorization-server metadata; tokens are persisted to `~/.honcho/config.json` and auto-refreshed (#891)
|
||||
|
||||
## [0.1.1] - 2026-06-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Declare `click` as an explicit dependency. The CLI imported `click` directly but relied on it being pulled in transitively, so installs without it on the path could fail at runtime (#787)
|
||||
|
||||
## [0.1.0] - 2026-04-20
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release of `honcho-cli` — a terminal for inspecting and managing a Honcho deployment (#424)
|
||||
- `workspace`, `peer`, `session`, `message`, `conclusion`, and `config` command groups for managing resources against any Honcho server
|
||||
- `init` onboarding flow that prompts for and persists connection settings, with flag/env-var pre-seeding for non-interactive use
|
||||
- Per-command flags, environment variables, and a config file for pointing the CLI at different servers (local, self-hosted, or hosted)
|
||||
- Rich terminal output and an agent-usage mode for scripting against the CLI
|
||||
- Documentation and an agent skill for the CLI (#589)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "honcho-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
description = "A terminal for Honcho — memory that reasons."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
|
@ -17,6 +17,7 @@ classifiers = [
|
|||
"Topic :: Software Development :: Libraries",
|
||||
]
|
||||
dependencies = [
|
||||
"click>=8.0.0",
|
||||
"typer>=0.15.0",
|
||||
"honcho-ai>=2.0.0",
|
||||
"rich>=13.0.0",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""Honcho CLI — a terminal for Honcho."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.2"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import webbrowser
|
||||
|
||||
import typer
|
||||
from honcho import (
|
||||
|
|
@ -19,13 +21,14 @@ from honcho import (
|
|||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
from honcho_cli import __version__
|
||||
from honcho_cli import __version__, oauth
|
||||
from honcho_cli.branding import BANNER, BRAND, ICON_FAIL, ICON_OK, ICON_RUN
|
||||
from honcho_cli.common import get_resolved_config
|
||||
from honcho_cli.common import get_resolved_config, maybe_refresh_token
|
||||
from honcho_cli.config import (
|
||||
CONFIG_FILE,
|
||||
DEFAULT_BASE_URL,
|
||||
CLIConfig,
|
||||
OAuthTokens,
|
||||
)
|
||||
from honcho_cli.output import print_error, print_result, set_json_mode, use_json
|
||||
|
||||
|
|
@ -117,21 +120,142 @@ def init(
|
|||
_console.print()
|
||||
_console.print()
|
||||
|
||||
# Non-interactive (JSON/piped) or an explicit --api-key: manual-key path.
|
||||
# Device login needs a human at a browser, so it's TTY-only.
|
||||
if use_json() or api_key:
|
||||
_init_manual_key(key_val, url_val, file_key, file_url)
|
||||
else:
|
||||
_init_interactive(key_val, url_val, file_url)
|
||||
|
||||
|
||||
def _init_manual_key(key_val: str, url_val: str, file_key: str, file_url: str) -> None:
|
||||
"""Non-interactive path: confirm/save apiKey + URL, no device login."""
|
||||
final_key = _prompt_api_key(key_val)
|
||||
final_url = _prompt_url(url_val)
|
||||
|
||||
# Persist if anything changed or if the value came from env/flag.
|
||||
if final_key != file_key or final_url != file_url:
|
||||
CLIConfig(base_url=final_url, api_key=final_key).save()
|
||||
if not use_json():
|
||||
_console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]")
|
||||
|
||||
_check_connection(final_url, final_key)
|
||||
|
||||
if use_json():
|
||||
print_result({"apiKey": _redact(final_key), "baseUrl": final_url})
|
||||
|
||||
|
||||
def _init_interactive(key_val: str, url_val: str, file_url: str) -> None:
|
||||
"""Interactive path: URL first (device flow needs the host), then auth method."""
|
||||
final_url = _prompt_url(url_val)
|
||||
existing = CLIConfig.load()
|
||||
has_creds = bool(key_val) or bool(existing.oauth and existing.oauth.access_token)
|
||||
# only offer browser login if the host advertises the device grant (managed)
|
||||
device_available = oauth.supports_device_login(final_url)
|
||||
method = _prompt_auth_method(has_creds, device_available)
|
||||
|
||||
if method == "keep":
|
||||
if final_url != file_url:
|
||||
existing.base_url = final_url
|
||||
existing.save()
|
||||
_console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]")
|
||||
# refresh an expired token so "keep" behaves like every live command;
|
||||
# a failed refresh surfaces as the connectivity check below, not an abort
|
||||
try:
|
||||
maybe_refresh_token(existing)
|
||||
except typer.Exit:
|
||||
pass
|
||||
_check_connection(final_url, existing.resolved_api_key())
|
||||
return
|
||||
|
||||
if method == "device":
|
||||
tokens = _device_login(final_url)
|
||||
CLIConfig(base_url=final_url, oauth=tokens).save()
|
||||
_console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]")
|
||||
_check_connection(final_url, tokens.access_token)
|
||||
return
|
||||
|
||||
# paste a key
|
||||
final_key = _prompt_api_key("")
|
||||
CLIConfig(base_url=final_url, api_key=final_key).save()
|
||||
_console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]")
|
||||
_check_connection(final_url, final_key)
|
||||
|
||||
|
||||
def _prompt_auth_method(has_creds: bool, device_available: bool) -> str:
|
||||
"""Ask how to authenticate. Returns ``device`` / ``key`` / ``keep``.
|
||||
|
||||
``device`` is only offered when the host advertises the device grant; when
|
||||
it doesn't, pasting a key is the only login path.
|
||||
"""
|
||||
_console.print(" [dim]How do you want to authenticate?[/dim]")
|
||||
options: list[str] = []
|
||||
if device_available:
|
||||
options.append("device")
|
||||
_console.print(f" [dim]({len(options)})[/dim] Log in with your browser (device code)")
|
||||
options.append("key")
|
||||
_console.print(f" [dim]({len(options)})[/dim] Paste an API key")
|
||||
if has_creds:
|
||||
options.append("keep")
|
||||
_console.print(f" [dim]({len(options)})[/dim] Keep current credentials")
|
||||
# default to keeping existing creds so a returning user pressing Enter doesn't
|
||||
# get dropped into an unwanted browser login that overwrites them
|
||||
default = str(options.index("keep") + 1) if "keep" in options else "1"
|
||||
choice = typer.prompt(" Choice", default=default, show_default=True, prompt_suffix=": ").strip()
|
||||
try:
|
||||
idx = int(choice)
|
||||
except ValueError:
|
||||
return options[0]
|
||||
# explicit 1..len bounds — bare `options[idx - 1]` would let "0"/negatives
|
||||
# wrap to the tail of the list via Python's negative indexing
|
||||
if 1 <= idx <= len(options):
|
||||
return options[idx - 1]
|
||||
return options[0]
|
||||
|
||||
|
||||
def _device_login(base_url: str) -> OAuthTokens:
|
||||
"""Run the device-authorization flow and return the minted tokens.
|
||||
|
||||
Prints the user code + verification URL, opens the browser best-effort, and
|
||||
blocks on the poll loop until the user approves. Exits non-zero on denial,
|
||||
expiry, or interrupt.
|
||||
"""
|
||||
endpoints = oauth.resolve_endpoints(base_url)
|
||||
try:
|
||||
device = oauth.request_device_code(endpoints)
|
||||
except oauth.OAuthFlowError as e:
|
||||
_console.print(f" {ICON_FAIL} [red]Could not start device login[/red]: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
_console.print()
|
||||
_console.print(f" Enter this code to authorize: [bold {BRAND}]{device.user_code}[/bold {BRAND}]")
|
||||
_console.print(f" [dim]at[/dim] {device.verification_uri}")
|
||||
_console.print()
|
||||
try:
|
||||
webbrowser.open(device.verification_uri_complete)
|
||||
except Exception:
|
||||
pass # headless is expected — the URL is printed above
|
||||
|
||||
try:
|
||||
with _console.status("Waiting for approval…", spinner="dots"):
|
||||
tokens = oauth.poll_for_token(endpoints, device)
|
||||
except oauth.AccessDenied:
|
||||
_console.print(f" {ICON_FAIL} [red]Authorization denied[/red]")
|
||||
raise typer.Exit(1)
|
||||
except (oauth.DeviceCodeExpired, oauth.AuthorizationTimeout):
|
||||
_console.print(f" {ICON_FAIL} [red]Code expired[/red] — run `honcho init` to try again")
|
||||
raise typer.Exit(1)
|
||||
except oauth.OAuthFlowError as e:
|
||||
_console.print(f" {ICON_FAIL} [red]Login failed[/red]: {e}")
|
||||
raise typer.Exit(1)
|
||||
except KeyboardInterrupt:
|
||||
_console.print(f" {ICON_FAIL} [red]Cancelled[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
return OAuthTokens.from_response(
|
||||
tokens,
|
||||
client_id=endpoints.client_id,
|
||||
scope_fallback=endpoints.scope,
|
||||
host=base_url,
|
||||
)
|
||||
|
||||
|
||||
def _prompt_api_key(value: str) -> str:
|
||||
"""Prompt for API key.
|
||||
|
||||
|
|
@ -206,6 +330,21 @@ def _check_connection(base_url: str, api_key: str) -> None:
|
|||
# --------------------------------------------------------------------------- #
|
||||
# honcho doctor
|
||||
|
||||
def _auth_mode_detail(config: CLIConfig) -> str:
|
||||
"""Human summary of which credential the CLI will use."""
|
||||
tokens = config.usable_oauth()
|
||||
if tokens is not None:
|
||||
if tokens.access_valid():
|
||||
secs = max(int(tokens.access_expires_at - time.time()), 0)
|
||||
return f"OAuth device token (expires in {secs // 60}m)"
|
||||
if config.api_key:
|
||||
return "API key (OAuth token expired)"
|
||||
return "OAuth device token (expired — will refresh)"
|
||||
if config.api_key:
|
||||
return "API key"
|
||||
return "missing — run `honcho init`"
|
||||
|
||||
|
||||
def doctor(
|
||||
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
||||
) -> None:
|
||||
|
|
@ -230,23 +369,30 @@ def doctor(
|
|||
_console.print(f"\n[bold {BRAND}]Honcho Doctor[/bold {BRAND}]\n")
|
||||
|
||||
config = get_resolved_config()
|
||||
# Refresh an expired OAuth token if we can; a failure surfaces as a failed
|
||||
# connectivity check below rather than aborting the diagnostic.
|
||||
try:
|
||||
maybe_refresh_token(config)
|
||||
except typer.Exit:
|
||||
pass
|
||||
key = config.resolved_api_key()
|
||||
|
||||
_add("Config file", CONFIG_FILE.exists(),
|
||||
str(CONFIG_FILE) if CONFIG_FILE.exists() else f"{CONFIG_FILE} not found")
|
||||
_add("API key configured", bool(config.api_key),
|
||||
"set" if config.api_key else "missing — run `honcho init`")
|
||||
_add("Credentials configured", bool(key), _auth_mode_detail(config))
|
||||
|
||||
if config.base_url and config.api_key:
|
||||
_add("API connectivity", *_test_connection(config.base_url, config.api_key))
|
||||
if config.base_url and key:
|
||||
_add("API connectivity", *_test_connection(config.base_url, key))
|
||||
else:
|
||||
_add("API connectivity", False, "skipped — no base_url or api_key")
|
||||
_add("API connectivity", False, "skipped — no base_url or credentials")
|
||||
|
||||
# Workspace / peer / queue run only when scoped via -w / -p.
|
||||
ws_ok, client = False, None
|
||||
if config.workspace_id and config.api_key:
|
||||
if config.workspace_id and key:
|
||||
try:
|
||||
|
||||
|
||||
client = Honcho(base_url=config.base_url, api_key=config.api_key, workspace_id=config.workspace_id)
|
||||
client = Honcho(base_url=config.base_url, api_key=key, workspace_id=config.workspace_id)
|
||||
client.get_configuration()
|
||||
ws_ok = True
|
||||
_add("Workspace reachable", True, config.workspace_id)
|
||||
|
|
@ -280,7 +426,7 @@ def doctor(
|
|||
_console.print(f"\n [{color}]{passed}/{total}[/{color}] checks passed{hint}\n")
|
||||
|
||||
# Config file + API connectivity are hard requirements.
|
||||
critical = {"Config file", "API key configured", "API connectivity"}
|
||||
critical = {"Config file", "Credentials configured", "API connectivity"}
|
||||
if config.workspace_id:
|
||||
critical.add("Workspace reachable")
|
||||
if any(not c["ok"] for c in checks if c["check"] in critical):
|
||||
|
|
|
|||
|
|
@ -12,13 +12,15 @@ no-op if the same flag was already set at an outer level.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from honcho import Honcho
|
||||
|
||||
from honcho_cli.config import CLIConfig, get_client_kwargs
|
||||
from honcho_cli import oauth
|
||||
from honcho_cli.config import CLIConfig, OAuthTokens, get_client_kwargs
|
||||
from honcho_cli.output import print_error, set_json_mode
|
||||
from honcho_cli.validation import validate_resource_id
|
||||
|
||||
|
|
@ -50,6 +52,50 @@ def get_resolved_config():
|
|||
return config
|
||||
|
||||
|
||||
def maybe_refresh_token(config: CLIConfig) -> None:
|
||||
"""Refresh an expired OAuth access token in place and persist it.
|
||||
|
||||
No-op when there is no grant for the current host or the token is still
|
||||
valid. A dead grant degrades to the saved apiKey with a warning; exits
|
||||
only when nothing is left to authenticate with.
|
||||
"""
|
||||
tokens = config.usable_oauth()
|
||||
if tokens is None or tokens.access_valid():
|
||||
return
|
||||
|
||||
if tokens.refresh_token:
|
||||
endpoints = oauth.resolve_endpoints(config.base_url)
|
||||
if tokens.client_id:
|
||||
endpoints = replace(endpoints, client_id=tokens.client_id)
|
||||
try:
|
||||
refreshed = oauth.refresh_access_token(endpoints, tokens.refresh_token)
|
||||
except oauth.OAuthFlowError:
|
||||
refreshed = None
|
||||
if refreshed is not None:
|
||||
# rotation-safe: persist the (possibly new) refresh token before
|
||||
# it's reused; keep the old one if the server didn't rotate
|
||||
# (refresh_token is optional)
|
||||
config.oauth = OAuthTokens.from_response(
|
||||
refreshed,
|
||||
client_id=tokens.client_id,
|
||||
scope_fallback=tokens.scope,
|
||||
refresh_fallback=tokens.refresh_token,
|
||||
host=tokens.host,
|
||||
)
|
||||
config.save()
|
||||
return
|
||||
|
||||
if config.api_key:
|
||||
typer.echo(
|
||||
"OAuth session expired; using the saved API key. "
|
||||
"Run `honcho init` to log in again.",
|
||||
err=True,
|
||||
)
|
||||
return
|
||||
print_error("SESSION_EXPIRED", "OAuth session expired. Run `honcho init` to log in again.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def get_client(*, require_workspace: bool = True):
|
||||
"""Create a Honcho client from resolved config.
|
||||
|
||||
|
|
@ -65,6 +111,7 @@ def get_client(*, require_workspace: bool = True):
|
|||
"No workspace scoped. Pass --workspace/-w or set HONCHO_WORKSPACE_ID.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
maybe_refresh_token(config)
|
||||
return Honcho(**get_client_kwargs(config)), config
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
"""Configuration management for Honcho CLI.
|
||||
|
||||
Config stored at ``~/.honcho/config.json`` with env var overrides.
|
||||
Config stored at ``~/.honcho/config.json`` with env var overrides. The config
|
||||
directory defaults to ``~/.honcho`` and can be relocated with `HONCHO_CONFIG_DIR`
|
||||
|
||||
The CLI owns exactly two top-level keys in that file:
|
||||
The CLI owns these top-level keys in that file:
|
||||
|
||||
apiKey -- Honcho admin JWT
|
||||
environmentUrl -- Honcho API URL (full URL, e.g. https://api.honcho.dev)
|
||||
oauth -- OAuth device-grant tokens (accessToken, refreshToken,
|
||||
accessExpiresAt, clientId, scope, host), written by
|
||||
device login
|
||||
|
||||
``apiKey`` (manual admin JWT) is shared with sibling tools: the CLI writes it
|
||||
on paste-key login and reads it as a fallback, but never deletes it. A live
|
||||
OAuth token takes precedence over ``apiKey`` for the CLI's own calls.
|
||||
|
||||
All other top-level keys (``hosts``, ``sessions``, ``saveMessages``,
|
||||
``sessionStrategy``, …) are written by sibling Honcho tools and are
|
||||
|
|
@ -20,14 +27,44 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, fields
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
CONFIG_DIR = Path.home() / ".honcho"
|
||||
if TYPE_CHECKING:
|
||||
from honcho_cli.oauth import TokenResponse
|
||||
|
||||
def _config_dir() -> Path:
|
||||
"""Config directory: ``$HONCHO_CONFIG_DIR`` if set, else ``~/.honcho``."""
|
||||
override = os.environ.get("HONCHO_CONFIG_DIR")
|
||||
return Path(override).expanduser() if override else Path.home() / ".honcho"
|
||||
|
||||
|
||||
CONFIG_DIR = _config_dir()
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
|
||||
DEFAULT_BASE_URL = "https://api.honcho.dev"
|
||||
|
||||
|
||||
def _redact_token(token: str) -> str:
|
||||
"""Show ``***<last4>`` — enough to compare tokens without leaking the body."""
|
||||
if not token:
|
||||
return ""
|
||||
return "***" + token[-4:] if len(token) > 4 else "***"
|
||||
|
||||
|
||||
def _coerce_epoch(value: object) -> float:
|
||||
"""Parse a persisted epoch-seconds value, treating garbage as expired (0)."""
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
# Env var mapping for runtime overrides.
|
||||
#
|
||||
# Resolution order: flag > env var > config file > default.
|
||||
|
|
@ -40,6 +77,59 @@ ENV_MAP: dict[str, str] = {
|
|||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OAuthTokens:
|
||||
"""Device-grant tokens persisted under the config ``oauth`` key."""
|
||||
|
||||
access_token: str = ""
|
||||
refresh_token: str = ""
|
||||
access_expires_at: float = 0.0 # epoch seconds
|
||||
client_id: str = ""
|
||||
scope: str = ""
|
||||
host: str = "" # base_url the grant was minted against
|
||||
|
||||
def matches_host(self, base_url: str) -> bool:
|
||||
"""True when the grant belongs to ``base_url``.
|
||||
|
||||
Tokens are host-scoped — a staging grant must not be sent to prod.
|
||||
Legacy blocks with no recorded host are trusted.
|
||||
"""
|
||||
return not self.host or self.host.rstrip("/") == base_url.rstrip("/")
|
||||
|
||||
def access_valid(self, skew: int = 60) -> bool:
|
||||
"""True while the access token is present and not within ``skew`` of expiry.
|
||||
|
||||
Checks the expiry timestamp recorded at mint time, not the token
|
||||
itself — the server is the real authority, so a wrong answer here
|
||||
costs at most an extra refresh or a 401.
|
||||
"""
|
||||
return bool(self.access_token) and time.time() < self.access_expires_at - skew
|
||||
|
||||
@classmethod
|
||||
def from_response(
|
||||
cls,
|
||||
resp: TokenResponse,
|
||||
*,
|
||||
client_id: str,
|
||||
scope_fallback: str = "",
|
||||
refresh_fallback: str = "",
|
||||
host: str = "",
|
||||
) -> OAuthTokens:
|
||||
"""Build persisted tokens from a token response.
|
||||
|
||||
``refresh_fallback`` keeps the prior refresh token when the server
|
||||
doesn't rotate one (optional on the refresh grant, RFC 6749 §5.1).
|
||||
"""
|
||||
return cls(
|
||||
access_token=resp.access_token,
|
||||
refresh_token=resp.refresh_token or refresh_fallback,
|
||||
access_expires_at=time.time() + resp.expires_in,
|
||||
client_id=client_id,
|
||||
scope=resp.scope or scope_fallback,
|
||||
host=host,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CLIConfig:
|
||||
"""CLI configuration with layered resolution: flag > env > file > default.
|
||||
|
|
@ -54,6 +144,33 @@ class CLIConfig:
|
|||
workspace_id: str = ""
|
||||
peer_id: str = ""
|
||||
session_id: str = ""
|
||||
oauth: OAuthTokens | None = None
|
||||
|
||||
def usable_oauth(self) -> OAuthTokens | None:
|
||||
"""The OAuth grant, if present and bound to the current host."""
|
||||
if (
|
||||
self.oauth
|
||||
and self.oauth.access_token
|
||||
and self.oauth.matches_host(self.base_url)
|
||||
):
|
||||
return self.oauth
|
||||
return None
|
||||
|
||||
def resolved_api_key(self) -> str:
|
||||
"""The key handed to the SDK: a live OAuth token wins, else apiKey.
|
||||
|
||||
An expired grant loses to a saved apiKey (a dead grant degrades to the
|
||||
shared key) but still wins over nothing, since the server is the final
|
||||
judge.
|
||||
"""
|
||||
tokens = self.usable_oauth()
|
||||
if tokens and tokens.access_valid():
|
||||
return tokens.access_token
|
||||
if self.api_key:
|
||||
return self.api_key
|
||||
if tokens:
|
||||
return tokens.access_token
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> CLIConfig:
|
||||
|
|
@ -74,6 +191,16 @@ class CLIConfig:
|
|||
key = data.get("apiKey")
|
||||
if isinstance(key, str):
|
||||
config.api_key = key
|
||||
oauth = data.get("oauth")
|
||||
if isinstance(oauth, dict) and oauth.get("accessToken"):
|
||||
config.oauth = OAuthTokens(
|
||||
access_token=str(oauth.get("accessToken", "")),
|
||||
refresh_token=str(oauth.get("refreshToken", "")),
|
||||
access_expires_at=_coerce_epoch(oauth.get("accessExpiresAt")),
|
||||
client_id=str(oauth.get("clientId", "")),
|
||||
scope=str(oauth.get("scope", "")),
|
||||
host=str(oauth.get("host", "")),
|
||||
)
|
||||
|
||||
for fld_name, env_var in ENV_MAP.items():
|
||||
val = os.environ.get(env_var)
|
||||
|
|
@ -88,10 +215,12 @@ class CLIConfig:
|
|||
return config
|
||||
|
||||
def save(self) -> None:
|
||||
"""Write ``apiKey`` + ``environmentUrl`` to config.json.
|
||||
"""Write ``environmentUrl`` + credentials to config.json.
|
||||
|
||||
Preserves unrelated top-level keys (``hosts``, ``sessions``,
|
||||
``saveMessages``, ``sessionStrategy``, …) that other tools write.
|
||||
``apiKey`` is written when set but never removed — sibling tools read
|
||||
it. The ``oauth`` block is CLI-owned and dropped when empty.
|
||||
"""
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
|
@ -108,8 +237,18 @@ class CLIConfig:
|
|||
data["environmentUrl"] = self.base_url
|
||||
if self.api_key:
|
||||
data["apiKey"] = self.api_key
|
||||
|
||||
if self.oauth and self.oauth.access_token:
|
||||
data["oauth"] = {
|
||||
"accessToken": self.oauth.access_token,
|
||||
"refreshToken": self.oauth.refresh_token,
|
||||
"accessExpiresAt": self.oauth.access_expires_at,
|
||||
"clientId": self.oauth.client_id,
|
||||
"scope": self.oauth.scope,
|
||||
"host": self.oauth.host,
|
||||
}
|
||||
else:
|
||||
data.pop("apiKey", None)
|
||||
data.pop("oauth", None)
|
||||
|
||||
CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n")
|
||||
# API key in plaintext — restrict to the owner on multi-user hosts.
|
||||
|
|
@ -124,7 +263,7 @@ class CLIConfig:
|
|||
Only includes fields that have a value set — per-command fields
|
||||
(workspace_id, peer_id, session_id) are omitted when empty.
|
||||
"""
|
||||
d: dict[str, str] = {}
|
||||
result: dict[str, str] = {}
|
||||
for fld in fields(self):
|
||||
val = getattr(self, fld.name)
|
||||
if not val:
|
||||
|
|
@ -132,10 +271,12 @@ class CLIConfig:
|
|||
if fld.name == "api_key":
|
||||
# Show ``***<last4>`` only — enough to compare keys without
|
||||
# leaking the header or body of the JWT.
|
||||
d[fld.name] = "***" + val[-4:] if len(val) > 4 else "***"
|
||||
result[fld.name] = _redact_token(val)
|
||||
elif fld.name == "oauth":
|
||||
result[fld.name] = _redact_token(val.access_token)
|
||||
else:
|
||||
d[fld.name] = val
|
||||
return d
|
||||
result[fld.name] = val
|
||||
return result
|
||||
|
||||
|
||||
def get_client_kwargs(config: CLIConfig) -> dict:
|
||||
|
|
@ -143,8 +284,9 @@ def get_client_kwargs(config: CLIConfig) -> dict:
|
|||
kwargs: dict = {}
|
||||
if config.base_url:
|
||||
kwargs["base_url"] = config.base_url
|
||||
if config.api_key:
|
||||
kwargs["api_key"] = config.api_key
|
||||
api_key = config.resolved_api_key()
|
||||
if api_key:
|
||||
kwargs["api_key"] = api_key
|
||||
if config.workspace_id:
|
||||
kwargs["workspace_id"] = config.workspace_id
|
||||
return kwargs
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
"""OAuth 2.0 Device Authorization Grant (RFC 8628) client for the CLI.
|
||||
|
||||
Transport-only: HTTP calls plus the poll loop, no Typer or config writes, so it
|
||||
can be unit-tested by mocking httpx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
|
||||
DEFAULT_CLIENT_ID = "honcho-cli"
|
||||
DEFAULT_SCOPE = "write"
|
||||
|
||||
# self-declared requesting surface; tells the consent screen not to offer config
|
||||
# delivery (a CLI has nowhere to write it)
|
||||
DEVICE_SOURCE = "honcho-cli"
|
||||
|
||||
# extra seconds added to the poll interval on a slow_down response (RFC 8628 §3.5)
|
||||
SLOW_DOWN_STEP = 5
|
||||
|
||||
|
||||
class OAuthFlowError(Exception):
|
||||
"""A device-flow request failed. ``error`` is the RFC error code when known."""
|
||||
|
||||
def __init__(self, error: str, description: str | None = None):
|
||||
self.error: str = error
|
||||
self.description: str | None = description
|
||||
super().__init__(description or error)
|
||||
|
||||
|
||||
class AccessDenied(OAuthFlowError):
|
||||
"""The user denied the authorization request."""
|
||||
|
||||
|
||||
class DeviceCodeExpired(OAuthFlowError):
|
||||
"""The device code expired before the user approved it."""
|
||||
|
||||
|
||||
class AuthorizationTimeout(OAuthFlowError):
|
||||
"""Polling ran past the device code's lifetime with no decision."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Endpoints:
|
||||
"""Resolved authorization-server URLs and client identity."""
|
||||
|
||||
device_auth_url: str
|
||||
token_url: str
|
||||
client_id: str
|
||||
scope: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceCode:
|
||||
"""RFC 8628 §3.2 device authorization response."""
|
||||
|
||||
device_code: str
|
||||
user_code: str
|
||||
verification_uri: str
|
||||
verification_uri_complete: str
|
||||
expires_in: int
|
||||
interval: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TokenResponse:
|
||||
"""An access/refresh token pair minted for a grant."""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_in: int
|
||||
scope: str
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def resolve_endpoints(base_url: str) -> Endpoints:
|
||||
"""Derive OAuth endpoints and client identity from the API ``base_url``."""
|
||||
host = base_url.rstrip("/")
|
||||
return Endpoints(
|
||||
device_auth_url=f"{host}/oauth/device_authorization",
|
||||
token_url=f"{host}/oauth/token",
|
||||
client_id=DEFAULT_CLIENT_ID,
|
||||
scope=DEFAULT_SCOPE,
|
||||
)
|
||||
|
||||
|
||||
# RFC 8414 authorization-server metadata; presence of the device grant tells us
|
||||
# whether this host can do browser login at all (managed only, not core)
|
||||
AUTH_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"
|
||||
|
||||
|
||||
def supports_device_login(base_url: str, *, timeout: float = 5.0) -> bool:
|
||||
"""Whether the host advertises the device grant in its RFC 8414 metadata.
|
||||
|
||||
Fails closed: any connection error, non-200, unparseable body, or missing
|
||||
capability returns False, so self-hosted / non-managed instances simply
|
||||
don't offer device login.
|
||||
"""
|
||||
host = base_url.rstrip("/")
|
||||
try:
|
||||
resp = httpx.get(f"{host}{AUTH_SERVER_METADATA_PATH}", timeout=timeout)
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
return False
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return False
|
||||
grants = body.get("grant_types_supported") if isinstance(body, dict) else None
|
||||
return isinstance(grants, list) and DEVICE_GRANT_TYPE in grants
|
||||
|
||||
|
||||
def _post(url: str, data: dict[str, str]) -> httpx.Response:
|
||||
"""POST form data, surfacing transport failures as ``OAuthFlowError``.
|
||||
|
||||
Connection refusals, DNS failures, and timeouts would otherwise escape as
|
||||
raw ``httpx.HTTPError`` past callers that only catch ``OAuthFlowError``.
|
||||
"""
|
||||
try:
|
||||
return httpx.post(url, data=data)
|
||||
except httpx.HTTPError as e:
|
||||
raise OAuthFlowError("connection_error", f"could not reach {url}: {e}") from e
|
||||
|
||||
|
||||
def _error_from_response(resp: httpx.Response) -> tuple[str, str | None]:
|
||||
"""Pull ``(error, error_description)`` out of an OAuth error body."""
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return "invalid_response", resp.text[:200] or None
|
||||
if isinstance(body, dict) and body.get("error"):
|
||||
return str(body["error"]), body.get("error_description")
|
||||
return "invalid_response", None
|
||||
|
||||
|
||||
def request_device_code(endpoints: Endpoints) -> DeviceCode:
|
||||
"""Request a device + user code pair (RFC 8628 §3.1)."""
|
||||
resp = _post(
|
||||
endpoints.device_auth_url,
|
||||
{
|
||||
"client_id": endpoints.client_id,
|
||||
"scope": endpoints.scope,
|
||||
"source": DEVICE_SOURCE,
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
error, desc = _error_from_response(resp)
|
||||
raise OAuthFlowError(error, desc)
|
||||
try:
|
||||
body = resp.json()
|
||||
return DeviceCode(
|
||||
device_code=body["device_code"],
|
||||
user_code=body["user_code"],
|
||||
verification_uri=body["verification_uri"],
|
||||
verification_uri_complete=body.get(
|
||||
"verification_uri_complete", body["verification_uri"]
|
||||
),
|
||||
expires_in=int(body["expires_in"]),
|
||||
interval=int(body["interval"]),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
raise OAuthFlowError(
|
||||
"invalid_response", f"malformed device authorization response: {e}"
|
||||
) from e
|
||||
|
||||
|
||||
def _token_from_body(body: dict[str, Any]) -> TokenResponse:
|
||||
# refresh_token is optional on the refresh grant (RFC 6749 §5.1); a
|
||||
# malformed/missing field is a server fault, surfaced as OAuthFlowError so
|
||||
# callers' existing handling catches it instead of a raw KeyError/ValueError
|
||||
try:
|
||||
return TokenResponse(
|
||||
access_token=body["access_token"],
|
||||
refresh_token=body.get("refresh_token", ""),
|
||||
expires_in=int(body["expires_in"]),
|
||||
scope=body.get("scope", ""),
|
||||
config=body.get("config") or {},
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
raise OAuthFlowError("invalid_response", f"malformed token response: {e}") from e
|
||||
|
||||
|
||||
def poll_for_token(
|
||||
endpoints: Endpoints,
|
||||
device: DeviceCode,
|
||||
*,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
) -> TokenResponse:
|
||||
"""Poll the token endpoint until the grant is approved (RFC 8628 §3.4/§3.5).
|
||||
|
||||
Sleeps ``interval`` between polls, bumping it on ``slow_down``. Raises
|
||||
``AccessDenied`` / ``DeviceCodeExpired`` / ``AuthorizationTimeout`` on the
|
||||
terminal outcomes. ``sleep`` / ``monotonic`` are injectable for tests.
|
||||
"""
|
||||
interval = device.interval
|
||||
deadline = monotonic() + device.expires_in
|
||||
while True:
|
||||
if monotonic() >= deadline:
|
||||
raise AuthorizationTimeout("expired_token", "Timed out waiting for approval")
|
||||
sleep(interval)
|
||||
resp = _post(
|
||||
endpoints.token_url,
|
||||
{
|
||||
"grant_type": DEVICE_GRANT_TYPE,
|
||||
"device_code": device.device_code,
|
||||
"client_id": endpoints.client_id,
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError as e:
|
||||
raise OAuthFlowError("invalid_response", "non-JSON token response") from e
|
||||
return _token_from_body(body)
|
||||
|
||||
error, desc = _error_from_response(resp)
|
||||
if error == "authorization_pending":
|
||||
continue
|
||||
if error == "slow_down":
|
||||
interval += SLOW_DOWN_STEP
|
||||
continue
|
||||
if error == "access_denied":
|
||||
raise AccessDenied(error, desc)
|
||||
if error == "expired_token":
|
||||
raise DeviceCodeExpired(error, desc)
|
||||
raise OAuthFlowError(error, desc)
|
||||
|
||||
|
||||
def refresh_access_token(endpoints: Endpoints, refresh_token: str) -> TokenResponse:
|
||||
"""Exchange a refresh token for a fresh access/refresh pair.
|
||||
|
||||
The response may rotate the refresh token; the caller must persist the
|
||||
returned ``refresh_token`` before reusing it — replaying a superseded one
|
||||
revokes the grant.
|
||||
"""
|
||||
resp = _post(
|
||||
endpoints.token_url,
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": endpoints.client_id,
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
error, desc = _error_from_response(resp)
|
||||
raise OAuthFlowError(error, desc)
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError as e:
|
||||
raise OAuthFlowError("invalid_response", "non-JSON token response") from e
|
||||
return _token_from_body(body)
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
"""Shared test fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from honcho_cli import common
|
||||
from honcho_cli.output import set_json_mode
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_cli_globals():
|
||||
"""Reset process-global CLI state between tests.
|
||||
|
||||
``_global_overrides`` (set by ``-w``/``-p``/``-s`` flags) and the JSON-mode
|
||||
flag are module globals that leak across tests otherwise — a workspace set
|
||||
by one test would silently satisfy the next test's workspace check.
|
||||
"""
|
||||
yield
|
||||
common._global_overrides.update(workspace=None, peer=None, session=None)
|
||||
set_json_mode(False)
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
"""Tests for the client factory's transparent OAuth refresh."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
from honcho_cli import common
|
||||
from honcho_cli.config import CLIConfig, OAuthTokens
|
||||
from honcho_cli.oauth import OAuthFlowError, TokenResponse
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg_path(tmp_path, monkeypatch):
|
||||
f = tmp_path / "config.json"
|
||||
monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f)
|
||||
monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path)
|
||||
for k in [k for k in os.environ if k.startswith("HONCHO_")]:
|
||||
monkeypatch.delenv(k)
|
||||
return f
|
||||
|
||||
|
||||
def _cfg(expires_at: float) -> CLIConfig:
|
||||
return CLIConfig(
|
||||
base_url="http://localhost:8000",
|
||||
oauth=OAuthTokens(
|
||||
access_token="old-at",
|
||||
refresh_token="old-rt",
|
||||
access_expires_at=expires_at,
|
||||
client_id="honcho-cli",
|
||||
scope="write",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_valid_token_is_not_refreshed(cfg_path):
|
||||
config = _cfg(time.time() + 3600)
|
||||
with patch("honcho_cli.oauth.refresh_access_token") as refresh:
|
||||
common.maybe_refresh_token(config)
|
||||
refresh.assert_not_called()
|
||||
|
||||
|
||||
def test_expired_token_refreshes_despite_manual_key(cfg_path):
|
||||
"""OAuth wins over apiKey now, so the grant is kept alive even with a key set."""
|
||||
config = _cfg(time.time() - 100)
|
||||
config.api_key = "manual"
|
||||
rotated = TokenResponse(
|
||||
access_token="new-at", refresh_token="new-rt", expires_in=3600, scope="write"
|
||||
)
|
||||
with patch("honcho_cli.oauth.refresh_access_token", return_value=rotated) as refresh:
|
||||
common.maybe_refresh_token(config)
|
||||
refresh.assert_called_once()
|
||||
assert config.resolved_api_key() == "new-at"
|
||||
|
||||
|
||||
def test_host_mismatch_skips_refresh(cfg_path):
|
||||
"""A grant minted for another host is ignored — no refresh, apiKey covers this one."""
|
||||
config = _cfg(time.time() - 100)
|
||||
config.oauth.host = "https://staging.example.com"
|
||||
config.api_key = "manual"
|
||||
with patch("honcho_cli.oauth.refresh_access_token") as refresh:
|
||||
common.maybe_refresh_token(config)
|
||||
refresh.assert_not_called()
|
||||
assert config.resolved_api_key() == "manual"
|
||||
|
||||
|
||||
def test_expired_token_refreshes_and_persists(cfg_path):
|
||||
config = _cfg(time.time() - 100)
|
||||
rotated = TokenResponse(
|
||||
access_token="new-at",
|
||||
refresh_token="new-rt",
|
||||
expires_in=3600,
|
||||
scope="write",
|
||||
)
|
||||
with patch("honcho_cli.oauth.refresh_access_token", return_value=rotated) as refresh:
|
||||
common.maybe_refresh_token(config)
|
||||
|
||||
# used the stored refresh token + client_id
|
||||
_endpoints, sent_rt = refresh.call_args.args
|
||||
assert sent_rt == "old-rt"
|
||||
assert _endpoints.client_id == "honcho-cli"
|
||||
|
||||
# in-memory config updated with the rotated pair
|
||||
assert config.oauth.access_token == "new-at"
|
||||
assert config.oauth.refresh_token == "new-rt"
|
||||
assert config.oauth.access_valid()
|
||||
|
||||
# rotation persisted to disk before reuse
|
||||
on_disk = json.loads(cfg_path.read_text())["oauth"]
|
||||
assert on_disk["accessToken"] == "new-at"
|
||||
assert on_disk["refreshToken"] == "new-rt"
|
||||
|
||||
|
||||
def test_refresh_failure_exits(cfg_path):
|
||||
config = _cfg(time.time() - 100)
|
||||
with patch("honcho_cli.oauth.refresh_access_token", side_effect=OAuthFlowError("invalid_grant")):
|
||||
with pytest.raises(typer.Exit):
|
||||
common.maybe_refresh_token(config)
|
||||
|
||||
|
||||
def test_refresh_failure_falls_back_to_api_key(cfg_path):
|
||||
"""A dead grant degrades to the saved apiKey instead of aborting."""
|
||||
config = _cfg(time.time() - 100)
|
||||
config.api_key = "manual"
|
||||
with patch("honcho_cli.oauth.refresh_access_token", side_effect=OAuthFlowError("invalid_grant")):
|
||||
common.maybe_refresh_token(config) # must not raise
|
||||
assert config.resolved_api_key() == "manual"
|
||||
|
||||
|
||||
def test_missing_refresh_token_exits(cfg_path):
|
||||
config = _cfg(time.time() - 100)
|
||||
config.oauth.refresh_token = ""
|
||||
with pytest.raises(typer.Exit):
|
||||
common.maybe_refresh_token(config)
|
||||
|
||||
|
||||
def test_missing_refresh_token_falls_back_to_api_key(cfg_path):
|
||||
config = _cfg(time.time() - 100)
|
||||
config.oauth.refresh_token = ""
|
||||
config.api_key = "manual"
|
||||
common.maybe_refresh_token(config) # must not raise
|
||||
assert config.resolved_api_key() == "manual"
|
||||
|
|
@ -2,9 +2,12 @@
|
|||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from honcho_cli.config import CLIConfig
|
||||
from honcho_cli.config import CLIConfig, OAuthTokens, _config_dir
|
||||
from honcho_cli.oauth import TokenResponse
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -18,6 +21,20 @@ def cfg_path(tmp_path, monkeypatch):
|
|||
return f
|
||||
|
||||
|
||||
class TestConfigDir:
|
||||
def test_defaults_to_dot_honcho(self, monkeypatch):
|
||||
monkeypatch.delenv("HONCHO_CONFIG_DIR", raising=False)
|
||||
assert _config_dir() == Path.home() / ".honcho"
|
||||
|
||||
def test_honcho_config_dir_override(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HONCHO_CONFIG_DIR", str(tmp_path / "profile"))
|
||||
assert _config_dir() == tmp_path / "profile"
|
||||
|
||||
def test_expands_user_in_override(self, monkeypatch):
|
||||
monkeypatch.setenv("HONCHO_CONFIG_DIR", "~/.honcho-test")
|
||||
assert _config_dir() == Path.home() / ".honcho-test"
|
||||
|
||||
|
||||
class TestLoad:
|
||||
def test_defaults_when_no_file(self, cfg_path):
|
||||
loaded = CLIConfig.load()
|
||||
|
|
@ -44,6 +61,31 @@ class TestLoad:
|
|||
assert loaded.api_key == "env-key"
|
||||
assert loaded.base_url == "http://localhost:8000"
|
||||
|
||||
def test_empty_env_var_popped_from_environ(self, cfg_path, monkeypatch):
|
||||
"""Empty HONCHO_* vars are removed so the SDK doesn't crash on them."""
|
||||
cfg_path.write_text(json.dumps({"apiKey": "file-key"}))
|
||||
monkeypatch.setenv("HONCHO_API_KEY", "")
|
||||
loaded = CLIConfig.load()
|
||||
assert "HONCHO_API_KEY" not in os.environ
|
||||
assert loaded.api_key == "file-key"
|
||||
|
||||
def test_garbage_access_expires_at_treated_as_expired(self, cfg_path):
|
||||
"""Hand-edited/corrupt expiry degrades to the refresh path, not a crash."""
|
||||
cfg_path.write_text(json.dumps(
|
||||
{"oauth": {"accessToken": "x", "accessExpiresAt": "not-a-number"}}
|
||||
))
|
||||
loaded = CLIConfig.load()
|
||||
assert loaded.oauth is not None
|
||||
assert loaded.oauth.access_valid() is False
|
||||
|
||||
def test_numeric_string_access_expires_at_parses(self, cfg_path):
|
||||
cfg_path.write_text(json.dumps(
|
||||
{"oauth": {"accessToken": "x", "accessExpiresAt": "12345"}}
|
||||
))
|
||||
loaded = CLIConfig.load()
|
||||
assert loaded.oauth is not None
|
||||
assert loaded.oauth.access_expires_at == 12345.0
|
||||
|
||||
|
||||
class TestSave:
|
||||
def test_writes_only_cli_owned_keys(self, cfg_path):
|
||||
|
|
@ -104,6 +146,117 @@ def test_api_key_redaction_empty_omitted():
|
|||
assert "api_key" not in CLIConfig(api_key="").redacted()
|
||||
|
||||
|
||||
class TestOAuth:
|
||||
def _tokens(self, expires_at: float) -> OAuthTokens:
|
||||
return OAuthTokens(
|
||||
access_token="hch-at-x",
|
||||
refresh_token="hch-rt-x",
|
||||
access_expires_at=expires_at,
|
||||
client_id="honcho-cli",
|
||||
scope="write",
|
||||
)
|
||||
|
||||
def test_round_trips_oauth_block(self, cfg_path):
|
||||
CLIConfig(base_url="http://localhost:8000", oauth=self._tokens(9999999999)).save()
|
||||
loaded = CLIConfig.load()
|
||||
assert loaded.oauth is not None
|
||||
assert loaded.oauth.access_token == "hch-at-x"
|
||||
assert loaded.oauth.refresh_token == "hch-rt-x"
|
||||
assert loaded.oauth.client_id == "honcho-cli"
|
||||
|
||||
def test_oauth_persists_camelcase_keys(self, cfg_path):
|
||||
CLIConfig(base_url="http://localhost:8000", oauth=self._tokens(1234)).save()
|
||||
on_disk = json.loads(cfg_path.read_text())["oauth"]
|
||||
assert set(on_disk) == {"accessToken", "refreshToken", "accessExpiresAt", "clientId", "scope", "host"}
|
||||
|
||||
def test_save_preserves_foreign_keys_with_oauth(self, cfg_path):
|
||||
cfg_path.write_text(json.dumps({"hosts": {"claude_code": {"peerName": "u"}}}))
|
||||
CLIConfig(base_url="http://localhost:8000", oauth=self._tokens(1234)).save()
|
||||
on_disk = json.loads(cfg_path.read_text())
|
||||
assert on_disk["hosts"] == {"claude_code": {"peerName": "u"}}
|
||||
assert "oauth" in on_disk
|
||||
|
||||
def test_empty_oauth_is_dropped(self, cfg_path):
|
||||
cfg_path.write_text(json.dumps({"oauth": {"accessToken": "old"}}))
|
||||
CLIConfig(base_url="http://localhost:8000").save()
|
||||
assert "oauth" not in json.loads(cfg_path.read_text())
|
||||
|
||||
def test_api_key_preserved_on_device_login(self, cfg_path):
|
||||
"""apiKey is shared with sibling tools — device login must not delete it."""
|
||||
cfg_path.write_text(json.dumps({"apiKey": "shared-key"}))
|
||||
CLIConfig(base_url="http://localhost:8000", oauth=self._tokens(9999999999)).save()
|
||||
on_disk = json.loads(cfg_path.read_text())
|
||||
assert on_disk["apiKey"] == "shared-key"
|
||||
assert on_disk["oauth"]["accessToken"] == "hch-at-x"
|
||||
|
||||
def test_resolved_api_key_prefers_live_oauth(self, cfg_path):
|
||||
cfg = CLIConfig(api_key="manual", oauth=self._tokens(9999999999))
|
||||
assert cfg.resolved_api_key() == "hch-at-x"
|
||||
|
||||
def test_resolved_api_key_expired_oauth_falls_back_to_api_key(self, cfg_path):
|
||||
cfg = CLIConfig(api_key="manual", oauth=self._tokens(time.time() - 100))
|
||||
assert cfg.resolved_api_key() == "manual"
|
||||
|
||||
def test_resolved_api_key_host_mismatch_falls_back_to_api_key(self, cfg_path):
|
||||
tokens = self._tokens(9999999999)
|
||||
tokens.host = "https://staging.example.com"
|
||||
cfg = CLIConfig(
|
||||
base_url="https://api.honcho.dev", api_key="manual", oauth=tokens
|
||||
)
|
||||
assert cfg.resolved_api_key() == "manual"
|
||||
|
||||
def test_resolved_api_key_expired_oauth_wins_over_nothing(self, cfg_path):
|
||||
cfg = CLIConfig(oauth=self._tokens(time.time() - 100))
|
||||
assert cfg.resolved_api_key() == "hch-at-x"
|
||||
|
||||
def test_resolved_api_key_falls_back_to_oauth(self, cfg_path):
|
||||
cfg = CLIConfig(oauth=self._tokens(9999999999))
|
||||
assert cfg.resolved_api_key() == "hch-at-x"
|
||||
|
||||
def test_access_valid_expiry_and_skew(self):
|
||||
assert self._tokens(time.time() + 3600).access_valid()
|
||||
assert not self._tokens(time.time() - 10).access_valid()
|
||||
# inside the default 60s skew window → treated as invalid
|
||||
assert not self._tokens(time.time() + 30).access_valid()
|
||||
|
||||
def test_access_valid_false_without_token(self):
|
||||
"""A missing token is invalid even with a far-future expiry."""
|
||||
tokens = OAuthTokens(access_token="", access_expires_at=time.time() + 3600)
|
||||
assert tokens.access_valid() is False
|
||||
|
||||
def test_from_response_keeps_prior_refresh_token_when_not_rotated(self):
|
||||
"""Refresh-token rotation is optional (RFC 6749 §5.1) — keep the old one."""
|
||||
resp = TokenResponse(
|
||||
access_token="new-at", refresh_token="", expires_in=3600, scope=""
|
||||
)
|
||||
tokens = OAuthTokens.from_response(
|
||||
resp,
|
||||
client_id="honcho-cli",
|
||||
scope_fallback="write",
|
||||
refresh_fallback="prior-rt",
|
||||
host="https://staging.example.com",
|
||||
)
|
||||
assert tokens.refresh_token == "prior-rt"
|
||||
assert tokens.scope == "write"
|
||||
assert tokens.host == "https://staging.example.com"
|
||||
|
||||
def test_host_round_trips_and_legacy_matches_all(self, cfg_path):
|
||||
tokens = self._tokens(9999999999)
|
||||
tokens.host = "https://staging.example.com"
|
||||
CLIConfig(base_url="https://staging.example.com", oauth=tokens).save()
|
||||
loaded = CLIConfig.load()
|
||||
assert loaded.oauth is not None
|
||||
assert loaded.oauth.host == "https://staging.example.com"
|
||||
# trailing-slash normalization + legacy blocks (no host) trust any host
|
||||
assert loaded.oauth.matches_host("https://staging.example.com/")
|
||||
assert not loaded.oauth.matches_host("https://api.honcho.dev")
|
||||
assert OAuthTokens(access_token="x").matches_host("https://anything.dev")
|
||||
|
||||
def test_redacted_masks_oauth_token(self):
|
||||
red = CLIConfig(oauth=self._tokens(1234)).redacted()
|
||||
assert red["oauth"] == "***at-x"
|
||||
|
||||
|
||||
def test_save_sets_600_permissions(cfg_path):
|
||||
"""Config with plaintext API key must be owner-readable only on POSIX."""
|
||||
import stat
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
"""Tests for the device-authorization OAuth engine (transport-only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from honcho_cli import oauth
|
||||
from honcho_cli.oauth import (
|
||||
AccessDenied,
|
||||
AuthorizationTimeout,
|
||||
DeviceCode,
|
||||
DeviceCodeExpired,
|
||||
Endpoints,
|
||||
OAuthFlowError,
|
||||
)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code: int, body):
|
||||
self.status_code = status_code
|
||||
self._body = body
|
||||
self.text = str(body)
|
||||
|
||||
def json(self):
|
||||
if isinstance(self._body, Exception):
|
||||
raise self._body
|
||||
return self._body
|
||||
|
||||
|
||||
def _endpoints() -> Endpoints:
|
||||
return Endpoints(
|
||||
device_auth_url="https://api.honcho.dev/oauth/device_authorization",
|
||||
token_url="https://api.honcho.dev/oauth/token",
|
||||
client_id="honcho-cli",
|
||||
scope="write",
|
||||
)
|
||||
|
||||
|
||||
DEVICE = DeviceCode(
|
||||
device_code="dev-abc",
|
||||
user_code="WXYZ-1234",
|
||||
verification_uri="https://app.honcho.dev/device",
|
||||
verification_uri_complete="https://app.honcho.dev/device?user_code=WXYZ-1234",
|
||||
expires_in=600,
|
||||
interval=5,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# resolve_endpoints
|
||||
|
||||
class TestResolveEndpoints:
|
||||
def test_derives_urls_from_base_url(self):
|
||||
ep = oauth.resolve_endpoints("https://api.honcho.dev")
|
||||
assert ep.device_auth_url == "https://api.honcho.dev/oauth/device_authorization"
|
||||
assert ep.token_url == "https://api.honcho.dev/oauth/token"
|
||||
assert ep.client_id == "honcho-cli"
|
||||
assert ep.scope == "write"
|
||||
|
||||
def test_strips_trailing_slash(self):
|
||||
ep = oauth.resolve_endpoints("http://localhost:8000/")
|
||||
assert ep.token_url == "http://localhost:8000/oauth/token"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# supports_device_login
|
||||
|
||||
class TestSupportsDeviceLogin:
|
||||
def test_true_when_device_grant_advertised(self):
|
||||
body = {"grant_types_supported": ["authorization_code", oauth.DEVICE_GRANT_TYPE]}
|
||||
with patch("honcho_cli.oauth.httpx.get", return_value=FakeResponse(200, body)):
|
||||
assert oauth.supports_device_login("https://api.honcho.dev") is True
|
||||
|
||||
def test_false_when_device_grant_absent(self):
|
||||
body = {"grant_types_supported": ["authorization_code", "refresh_token"]}
|
||||
with patch("honcho_cli.oauth.httpx.get", return_value=FakeResponse(200, body)):
|
||||
assert oauth.supports_device_login("https://api.honcho.dev") is False
|
||||
|
||||
@pytest.mark.parametrize("status", [404, 500])
|
||||
def test_false_on_non_200(self, status):
|
||||
with patch("honcho_cli.oauth.httpx.get", return_value=FakeResponse(status, "")):
|
||||
assert oauth.supports_device_login("http://localhost:8000") is False
|
||||
|
||||
def test_false_on_connection_error(self):
|
||||
with patch("honcho_cli.oauth.httpx.get", side_effect=httpx.ConnectError("no route")):
|
||||
assert oauth.supports_device_login("http://localhost:8000") is False
|
||||
|
||||
def test_false_on_unparseable_body(self):
|
||||
with patch("honcho_cli.oauth.httpx.get", return_value=FakeResponse(200, ValueError())):
|
||||
assert oauth.supports_device_login("https://api.honcho.dev") is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# request_device_code
|
||||
|
||||
class TestRequestDeviceCode:
|
||||
def test_success(self):
|
||||
body = {
|
||||
"device_code": "dev-abc",
|
||||
"user_code": "WXYZ-1234",
|
||||
"verification_uri": "https://app.honcho.dev/device",
|
||||
"verification_uri_complete": "https://app.honcho.dev/device?user_code=WXYZ-1234",
|
||||
"expires_in": 600,
|
||||
"interval": 5,
|
||||
}
|
||||
with patch("honcho_cli.oauth.httpx.post", return_value=FakeResponse(200, body)) as post:
|
||||
dc = oauth.request_device_code(_endpoints())
|
||||
assert dc.device_code == "dev-abc"
|
||||
assert dc.user_code == "WXYZ-1234"
|
||||
assert dc.interval == 5
|
||||
assert post.call_args.kwargs["data"]["source"] == "honcho-cli"
|
||||
|
||||
def test_error_raises(self):
|
||||
body = {"error": "invalid_client", "error_description": "unknown client"}
|
||||
with patch("honcho_cli.oauth.httpx.post", return_value=FakeResponse(401, body)):
|
||||
with pytest.raises(OAuthFlowError) as exc:
|
||||
oauth.request_device_code(_endpoints())
|
||||
assert exc.value.error == "invalid_client"
|
||||
|
||||
def test_transport_failure_wrapped(self):
|
||||
with patch("honcho_cli.oauth.httpx.post", side_effect=httpx.ConnectError("no route")):
|
||||
with pytest.raises(OAuthFlowError) as exc:
|
||||
oauth.request_device_code(_endpoints())
|
||||
assert exc.value.error == "connection_error"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# poll_for_token
|
||||
|
||||
class TestPollForToken:
|
||||
def _run(self, responses, monotonic_vals=None):
|
||||
"""Poll with a scripted response sequence, capturing sleep durations."""
|
||||
sleeps: list[float] = []
|
||||
clock = iter(monotonic_vals or [0.0] * (len(responses) + 2))
|
||||
with patch("honcho_cli.oauth.httpx.post", side_effect=responses):
|
||||
token = oauth.poll_for_token(
|
||||
_endpoints(),
|
||||
DEVICE,
|
||||
sleep=sleeps.append,
|
||||
monotonic=lambda: next(clock),
|
||||
)
|
||||
return token, sleeps
|
||||
|
||||
def test_pending_then_slowdown_then_success(self):
|
||||
success = {
|
||||
"access_token": "hch-at-1",
|
||||
"refresh_token": "hch-rt-1",
|
||||
"expires_in": 3600,
|
||||
"scope": "write",
|
||||
"config": {"k": "v"},
|
||||
}
|
||||
responses = [
|
||||
FakeResponse(400, {"error": "authorization_pending"}),
|
||||
FakeResponse(400, {"error": "slow_down"}),
|
||||
FakeResponse(200, success),
|
||||
]
|
||||
token, sleeps = self._run(responses)
|
||||
assert token.access_token == "hch-at-1"
|
||||
assert token.refresh_token == "hch-rt-1"
|
||||
assert token.config == {"k": "v"}
|
||||
# interval starts at 5, bumps by 5 after slow_down → third sleep is 10
|
||||
assert sleeps == [5, 5, 10]
|
||||
|
||||
def test_access_denied(self):
|
||||
responses = [FakeResponse(400, {"error": "access_denied"})]
|
||||
with pytest.raises(AccessDenied):
|
||||
self._run(responses)
|
||||
|
||||
def test_expired_token(self):
|
||||
responses = [FakeResponse(400, {"error": "expired_token"})]
|
||||
with pytest.raises(DeviceCodeExpired):
|
||||
self._run(responses)
|
||||
|
||||
def test_unexpected_error_raises_generic(self):
|
||||
responses = [FakeResponse(400, {"error": "invalid_grant"})]
|
||||
with pytest.raises(OAuthFlowError) as exc:
|
||||
self._run(responses)
|
||||
assert exc.value.error == "invalid_grant"
|
||||
|
||||
def test_transport_failure_wrapped(self):
|
||||
# an exception in the side_effect list is raised on that poll
|
||||
responses = [httpx.ReadTimeout("timed out")]
|
||||
with pytest.raises(OAuthFlowError) as exc:
|
||||
self._run(responses)
|
||||
assert exc.value.error == "connection_error"
|
||||
|
||||
def test_times_out_past_deadline(self):
|
||||
# monotonic jumps past deadline (0 + expires_in) on the first check
|
||||
with patch("honcho_cli.oauth.httpx.post") as post:
|
||||
with pytest.raises(AuthorizationTimeout):
|
||||
oauth.poll_for_token(
|
||||
_endpoints(),
|
||||
DEVICE,
|
||||
sleep=lambda _s: None,
|
||||
monotonic=iter([0.0, 9999.0]).__next__,
|
||||
)
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# refresh_access_token
|
||||
|
||||
class TestRefresh:
|
||||
def test_success_returns_rotated_pair(self):
|
||||
body = {
|
||||
"access_token": "hch-at-2",
|
||||
"refresh_token": "hch-rt-2",
|
||||
"expires_in": 3600,
|
||||
"scope": "write",
|
||||
}
|
||||
with patch("honcho_cli.oauth.httpx.post", return_value=FakeResponse(200, body)) as post:
|
||||
token = oauth.refresh_access_token(_endpoints(), "hch-rt-1")
|
||||
assert token.access_token == "hch-at-2"
|
||||
assert token.refresh_token == "hch-rt-2"
|
||||
sent = post.call_args.kwargs["data"]
|
||||
assert sent["grant_type"] == "refresh_token"
|
||||
assert sent["refresh_token"] == "hch-rt-1"
|
||||
|
||||
def test_error_raises(self):
|
||||
body = {"error": "invalid_grant", "error_description": "revoked"}
|
||||
with patch("honcho_cli.oauth.httpx.post", return_value=FakeResponse(400, body)):
|
||||
with pytest.raises(OAuthFlowError) as exc:
|
||||
oauth.refresh_access_token(_endpoints(), "stale")
|
||||
assert exc.value.error == "invalid_grant"
|
||||
|
||||
def test_transport_failure_wrapped(self):
|
||||
with patch("honcho_cli.oauth.httpx.post", side_effect=httpx.ConnectError("no route")):
|
||||
with pytest.raises(OAuthFlowError) as exc:
|
||||
oauth.refresh_access_token(_endpoints(), "hch-rt-1")
|
||||
assert exc.value.error == "connection_error"
|
||||
|
|
@ -88,9 +88,10 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "honcho-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "honcho-ai" },
|
||||
{ name = "httpx" },
|
||||
{ name = "rich" },
|
||||
|
|
@ -105,6 +106,7 @@ dev = [
|
|||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8.0.0" },
|
||||
{ name = "honcho-ai", specifier = ">=0.1.0" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
|
|
|
|||
|
|
@ -16,13 +16,10 @@ A Cloudflare Worker that implements the [Model Context Protocol (MCP)](https://m
|
|||
"mcp-remote",
|
||||
"https://mcp.honcho.dev",
|
||||
"--header",
|
||||
"Authorization:${AUTH_HEADER}",
|
||||
"--header",
|
||||
"X-Honcho-User-Name:${USER_NAME}"
|
||||
"Authorization:${AUTH_HEADER}"
|
||||
],
|
||||
"env": {
|
||||
"AUTH_HEADER": "Bearer <your-honcho-key>",
|
||||
"USER_NAME": "<your-name>"
|
||||
"AUTH_HEADER": "Bearer <your-honcho-key>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -115,8 +112,7 @@ bun run tsc --noEmit
|
|||
|
||||
```bash
|
||||
bunx mcp-remote http://localhost:8787 \
|
||||
--header "Authorization:Bearer <key>" \
|
||||
--header "X-Honcho-User-Name:test"
|
||||
--header "Authorization:Bearer <key>"
|
||||
```
|
||||
|
||||
### Deploy
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ import { Honcho } from "@honcho-ai/sdk";
|
|||
|
||||
export interface HonchoConfig {
|
||||
apiKey: string;
|
||||
userName: string;
|
||||
assistantName: string;
|
||||
baseUrl: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
|
@ -14,7 +12,7 @@ export interface Env {
|
|||
|
||||
/**
|
||||
* Parse configuration from request headers and Worker env bindings.
|
||||
* Throws on missing required fields so callers get clear errors.
|
||||
* Throws only when the Authorization bearer token is missing/empty.
|
||||
*
|
||||
* The Honcho API URL is read from the `HONCHO_API_URL` env var when set,
|
||||
* allowing operators to run this Worker alongside a self-hosted Honcho
|
||||
|
|
@ -24,29 +22,19 @@ export interface Env {
|
|||
*/
|
||||
export function parseConfig(request: Request, env: Env = {}): HonchoConfig {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const trimmedAuthHeader = authHeader?.trim();
|
||||
if (!trimmedAuthHeader?.startsWith("Bearer ")) {
|
||||
const bearerMatch = authHeader?.trim().match(/^Bearer\s+(.*)$/i);
|
||||
if (!bearerMatch) {
|
||||
throw new Error(
|
||||
"Missing Authorization header. Provide 'Authorization: Bearer <your-honcho-key>'.",
|
||||
);
|
||||
}
|
||||
const apiKey = trimmedAuthHeader.substring(7).trim();
|
||||
const apiKey = bearerMatch[1].trim();
|
||||
if (!apiKey) {
|
||||
throw new Error("Authorization header is empty after 'Bearer '.");
|
||||
}
|
||||
|
||||
const rawUserName = request.headers.get("X-Honcho-User-Name");
|
||||
const userName = rawUserName?.trim();
|
||||
if (!userName) {
|
||||
throw new Error(
|
||||
"Missing X-Honcho-User-Name header. Provide 'X-Honcho-User-Name: <your-name>'.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
userName,
|
||||
assistantName: request.headers.get("X-Honcho-Assistant-Name")?.trim() || "Assistant",
|
||||
baseUrl: env.HONCHO_API_URL?.trim() || "https://api.honcho.dev",
|
||||
workspaceId: request.headers.get("X-Honcho-Workspace-ID")?.trim() || "default",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,14 +5,25 @@ import { createServer } from "./server.js";
|
|||
const CORS_ORIGIN = "*";
|
||||
const CORS_METHODS = "GET, POST, DELETE, OPTIONS";
|
||||
const CORS_ALLOWED_HEADERS =
|
||||
"Content-Type, Authorization, X-Honcho-User-Name, X-Honcho-Workspace-ID, X-Honcho-Assistant-Name";
|
||||
"Content-Type, Authorization, X-Honcho-Workspace-ID";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": CORS_METHODS,
|
||||
"Access-Control-Allow-Headers": CORS_ALLOWED_HEADERS,
|
||||
"Access-Control-Expose-Headers": "WWW-Authenticate",
|
||||
};
|
||||
|
||||
const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
|
||||
|
||||
function resourceUrl(request: Request): string {
|
||||
return new URL(request.url).origin;
|
||||
}
|
||||
|
||||
function authorizationServer(env: Env): string {
|
||||
return env.HONCHO_API_URL?.trim() || "https://api.honcho.dev";
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(
|
||||
request: Request,
|
||||
|
|
@ -23,15 +34,34 @@ export default {
|
|||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
// Protected Resource Metadata (RFC 9728) — served without auth so clients
|
||||
// can discover the authorization server.
|
||||
if (new URL(request.url).pathname === PROTECTED_RESOURCE_PATH) {
|
||||
return Response.json(
|
||||
{
|
||||
resource: resourceUrl(request),
|
||||
authorization_servers: [authorizationServer(env)],
|
||||
bearer_methods_supported: ["header"],
|
||||
},
|
||||
{ headers: CORS_HEADERS },
|
||||
);
|
||||
}
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = parseConfig(request, env);
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof Error ? e.message : "Invalid request";
|
||||
// WWW-Authenticate points clients at the metadata so they start the OAuth flow.
|
||||
const resourceMetadata = `${resourceUrl(request)}${PROTECTED_RESOURCE_PATH}`;
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadata}"`,
|
||||
...CORS_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "honcho"
|
||||
version = "3.0.7"
|
||||
version = "3.0.11"
|
||||
description = "Honcho Server"
|
||||
authors = [
|
||||
{name = "Plastic Labs", email = "hello@plasticlabs.ai"},
|
||||
|
|
@ -32,11 +32,11 @@ dependencies = [
|
|||
"typing-extensions>=4.11.0",
|
||||
"json-repair>=0.49.0",
|
||||
"turbopuffer>=1.8.1",
|
||||
"lancedb>=0.25.3",
|
||||
"qdrant-client>=1.18.0",
|
||||
"lancedb>=0.25.3; sys_platform != \"darwin\" or platform_machine != \"x86_64\"",
|
||||
"pyarrow>=19.0.0",
|
||||
"redis>=7.0.0,<8.0.0",
|
||||
"cashews[redis]==7.4.4",
|
||||
"cashews[redis]==7.5.0",
|
||||
"scikit-learn>=1.6.0",
|
||||
"prometheus_client>=0.21.0",
|
||||
"cloudevents>=1.12.0,<2.0",
|
||||
|
|
@ -60,6 +60,9 @@ dev = [
|
|||
"pytest-xdist>=3.8.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
exclude-newer = "5 days"
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = [
|
||||
"sdks/python",
|
||||
|
|
|
|||
|
|
@ -170,10 +170,10 @@ def calculate_level_cost(
|
|||
realistic_final_answer=realistic_final,
|
||||
)
|
||||
|
||||
model = level_config.MODEL
|
||||
model = level_config.MODEL_CONFIG.model
|
||||
max_iterations = level_config.MAX_TOOL_ITERATIONS
|
||||
thinking_budget = level_config.THINKING_BUDGET_TOKENS
|
||||
provider = level_config.PROVIDER
|
||||
thinking_budget = level_config.MODEL_CONFIG.thinking_budget_tokens or 0
|
||||
provider = level_config.MODEL_CONFIG.transport
|
||||
|
||||
# Get pricing for this model
|
||||
pricing = MODEL_PRICING.get(model, {"input": 0, "output": 0, "cached": 0})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
#!/usr/bin/env uv run python
|
||||
"""
|
||||
Utility script to generate scoped JWTs for Honcho.
|
||||
|
||||
Examples:
|
||||
# Admin JWT (no expiry)
|
||||
uv run python scripts/generate_jwt.py --admin
|
||||
|
||||
# Admin JWT expiring in 24 hours
|
||||
uv run python scripts/generate_jwt.py --admin --expires 24h
|
||||
|
||||
# Workspace-scoped JWT expiring in 30 days
|
||||
uv run python scripts/generate_jwt.py --workspace my-workspace --expires 30d
|
||||
|
||||
# Peer-scoped JWT expiring in 1 year
|
||||
uv run python scripts/generate_jwt.py --workspace my-workspace --peer my-peer --expires 1y
|
||||
|
||||
# Session-scoped JWT
|
||||
uv run python scripts/generate_jwt.py --workspace my-workspace --session my-session --expires 8h
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# Allow running from repo root without installing
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from src.security import JWTParams, create_jwt
|
||||
from src.utils.formatting import format_datetime_utc
|
||||
|
||||
DURATION_UNITS = {
|
||||
"s": datetime.timedelta(seconds=1),
|
||||
"m": datetime.timedelta(minutes=1),
|
||||
"h": datetime.timedelta(hours=1),
|
||||
"d": datetime.timedelta(days=1),
|
||||
"w": datetime.timedelta(weeks=1),
|
||||
"y": datetime.timedelta(days=365),
|
||||
}
|
||||
|
||||
|
||||
def parse_duration(value: str) -> datetime.timedelta:
|
||||
"""Parse a duration string like '5h', '1d', '2w', '1y' into a timedelta."""
|
||||
match = re.fullmatch(r"(\d+)([smhdwy])", value.strip().lower())
|
||||
if not match:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"Invalid duration '{value}'. Use format like: 30s, 5m, 2h, 7d, 2w, 1y"
|
||||
)
|
||||
amount, unit = int(match.group(1)), match.group(2)
|
||||
return DURATION_UNITS[unit] * amount
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate a scoped JWT for Honcho authentication.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--admin",
|
||||
action="store_true",
|
||||
help="Generate an admin JWT (full access)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workspace",
|
||||
"-w",
|
||||
metavar="NAME",
|
||||
help="Scope the JWT to a workspace",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--peer",
|
||||
"-p",
|
||||
metavar="NAME",
|
||||
help="Scope the JWT to a peer (requires --workspace)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--session",
|
||||
"-s",
|
||||
metavar="NAME",
|
||||
help="Scope the JWT to a session (requires --workspace)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expires",
|
||||
"-e",
|
||||
metavar="DURATION",
|
||||
type=parse_duration,
|
||||
help="Token expiry duration. Units: s=seconds, m=minutes, h=hours, d=days, w=weeks, y=years. E.g. 5h, 30d, 1y",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--print-only",
|
||||
action="store_true",
|
||||
help="Only print the token, no labels",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.admin and not any([args.workspace, args.peer, args.session]):
|
||||
parser.error(
|
||||
"Specify --admin or at least one of --workspace, --peer, --session"
|
||||
)
|
||||
|
||||
if args.admin and any([args.workspace, args.peer, args.session]):
|
||||
parser.error(
|
||||
"--admin cannot be combined with --workspace, --peer, or --session"
|
||||
)
|
||||
|
||||
if (args.peer or args.session) and not args.workspace:
|
||||
parser.error("--peer and --session require --workspace")
|
||||
|
||||
exp_str: str | None = None
|
||||
if args.expires:
|
||||
expiry = datetime.datetime.now(datetime.timezone.utc) + args.expires
|
||||
exp_str = format_datetime_utc(expiry)
|
||||
|
||||
params = JWTParams(
|
||||
ad=True if args.admin else None,
|
||||
w=args.workspace,
|
||||
p=args.peer,
|
||||
s=args.session,
|
||||
exp=exp_str,
|
||||
)
|
||||
|
||||
token = create_jwt(params)
|
||||
|
||||
if args.print_only:
|
||||
print(token)
|
||||
else:
|
||||
scope_parts: list[str] = []
|
||||
if args.admin:
|
||||
scope_parts.append("admin")
|
||||
if args.workspace:
|
||||
scope_parts.append(f"workspace={args.workspace}")
|
||||
if args.peer:
|
||||
scope_parts.append(f"peer={args.peer}")
|
||||
if args.session:
|
||||
scope_parts.append(f"session={args.session}")
|
||||
|
||||
print(f"Scope: {', '.join(scope_parts)}")
|
||||
if exp_str:
|
||||
print(f"Expires: {exp_str}")
|
||||
else:
|
||||
print("Expires: never")
|
||||
print(f"Token: {token}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -6,6 +6,7 @@ import json
|
|||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
|
@ -106,7 +107,7 @@ def load_locomo(
|
|||
print(f" Created session: {session_id}")
|
||||
|
||||
# Build message batch
|
||||
msg_batch = []
|
||||
msg_batch: list[dict[str, Any]] = []
|
||||
for i, msg in enumerate(messages):
|
||||
msg_time = base_time + timedelta(seconds=i * 2)
|
||||
msg_batch.append(
|
||||
|
|
@ -134,7 +135,7 @@ def load_locomo(
|
|||
|
||||
def chat(
|
||||
client: httpx.Client, workspace_id: str, peer_id: str, query: str, level: str
|
||||
) -> dict:
|
||||
) -> dict[str, Any]:
|
||||
"""Call the chat endpoint with a specific reasoning level."""
|
||||
resp = client.post(
|
||||
f"{BASE_URL}/workspaces/{workspace_id}/peers/{peer_id}/chat",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ This script helps update version numbers across the Honcho repository.
|
|||
It handles the main API, Python SDK, and TypeScript SDK in a single operation.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
|
@ -17,11 +18,11 @@ from datetime import datetime
|
|||
|
||||
class VersionUpdater:
|
||||
def __init__(self, base_path: str):
|
||||
self.base_path = base_path
|
||||
self.base_path: str = base_path
|
||||
|
||||
def get_current_versions(self) -> dict[str, str]:
|
||||
"""Get current version numbers from the repository."""
|
||||
versions = {}
|
||||
versions: dict[str, str] = {}
|
||||
|
||||
# Main API version
|
||||
with open(os.path.join(self.base_path, "pyproject.toml")) as f:
|
||||
|
|
@ -89,7 +90,7 @@ TYPESCRIPT_VERSION=
|
|||
os.unlink(temp_file)
|
||||
|
||||
# Extract all versions and changelogs
|
||||
updates = {}
|
||||
updates: dict[str, dict[str, str]] = {}
|
||||
|
||||
# Parse API version
|
||||
api_match = re.search(r"^API_VERSION=(.*)$", content, re.MULTILINE)
|
||||
|
|
@ -131,7 +132,7 @@ TYPESCRIPT_VERSION=
|
|||
) -> str:
|
||||
"""Extract changelog content between markers."""
|
||||
lines = content.split("\n")
|
||||
changelog_lines = []
|
||||
changelog_lines: list[str] = []
|
||||
in_section = False
|
||||
|
||||
for line in lines:
|
||||
|
|
@ -158,8 +159,8 @@ TYPESCRIPT_VERSION=
|
|||
"""Remove empty changelog sections."""
|
||||
sections = ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"]
|
||||
lines = changelog.split("\n")
|
||||
cleaned_lines = []
|
||||
current_section = None
|
||||
cleaned_lines: list[str] = []
|
||||
current_section: str | None = None
|
||||
section_has_content = False
|
||||
section_start_idx = -1
|
||||
for i, line in enumerate(lines):
|
||||
|
|
@ -346,28 +347,30 @@ TYPESCRIPT_VERSION=
|
|||
self._update_compatibility_guide("typescript", new_version)
|
||||
|
||||
def _update_docs_json(self, new_version: str):
|
||||
"""Update docs.json - only update versions with same major version."""
|
||||
"""Update docs.json version label(s) sharing the new version's major.
|
||||
|
||||
Uses a targeted regex replacement rather than a JSON round-trip so the
|
||||
file's existing formatting (compact inline arrays) is preserved instead
|
||||
of being reflowed.
|
||||
"""
|
||||
file_path = os.path.join(self.base_path, "docs/docs.json")
|
||||
|
||||
with open(file_path) as f:
|
||||
data = json.load(f)
|
||||
content = f.read()
|
||||
|
||||
# Get major version of new version
|
||||
new_major = new_version.split(".")[0]
|
||||
|
||||
# Update only matching major versions
|
||||
if "navigation" in data and "versions" in data["navigation"]:
|
||||
for version_entry in data["navigation"]["versions"]:
|
||||
if "version" in version_entry:
|
||||
current_version = version_entry["version"].lstrip("v")
|
||||
current_major = current_version.split(".")[0]
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
# Only update labels whose major version matches the new version's.
|
||||
if match.group(1) == new_major:
|
||||
return f'"version": "v{new_version}"'
|
||||
return match.group(0)
|
||||
|
||||
if current_major == new_major:
|
||||
version_entry["version"] = f"v{new_version}"
|
||||
content = re.sub(r'"version": "v(\d+)\.\d+\.\d+"', _replace, content)
|
||||
|
||||
with open(file_path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
f.write(content)
|
||||
|
||||
def _update_sdk_changelog(self, version: str, changelog: str, relative_path: str):
|
||||
"""Update an SDK's CHANGELOG.md file."""
|
||||
|
|
@ -405,12 +408,39 @@ TYPESCRIPT_VERSION=
|
|||
f.write(new_content)
|
||||
|
||||
def _update_changelog_md(self, version: str, changelog: str):
|
||||
"""Update the main CHANGELOG.md file."""
|
||||
"""Update the main CHANGELOG.md file.
|
||||
|
||||
If an ``## [Unreleased]`` section is present, it is promoted to the new
|
||||
version (its contents replaced by ``changelog``, which the caller is
|
||||
expected to have already merged). Otherwise a new version entry is
|
||||
prepended above the most recent release, preserving the legacy behavior.
|
||||
"""
|
||||
file_path = os.path.join(self.base_path, "CHANGELOG.md")
|
||||
|
||||
with open(file_path) as f:
|
||||
content = f.read()
|
||||
|
||||
date = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Ensure changelog content is properly formatted
|
||||
if changelog.strip():
|
||||
formatted_changelog = changelog.strip()
|
||||
else:
|
||||
formatted_changelog = "### Changed\n\n- Updated version"
|
||||
|
||||
# Promote an existing [Unreleased] section if one exists. Match from the
|
||||
# "## [Unreleased]" header up to (but not including) the next release
|
||||
# heading, and replace the whole block with the new version section.
|
||||
unreleased_re = re.compile(
|
||||
r"\n## \[Unreleased\][\s\S]*?(?=\n## \[)", re.IGNORECASE
|
||||
)
|
||||
if unreleased_re.search(content):
|
||||
replacement = f"\n## [{version}] - {date}\n\n{formatted_changelog}\n"
|
||||
new_content = unreleased_re.sub(replacement, content, count=1)
|
||||
with open(file_path, "w") as f:
|
||||
f.write(new_content)
|
||||
return
|
||||
|
||||
# Find the position after the header
|
||||
header_end = content.find("\n## [")
|
||||
if header_end == -1:
|
||||
|
|
@ -420,15 +450,6 @@ TYPESCRIPT_VERSION=
|
|||
# No existing entries, add after title
|
||||
header_end = content.find("\n", content.find("# Changelog"))
|
||||
|
||||
# Create new entry with proper formatting
|
||||
date = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Ensure changelog content is properly formatted
|
||||
if changelog.strip():
|
||||
formatted_changelog = changelog.strip()
|
||||
else:
|
||||
formatted_changelog = "### Changed\n\n- Updated version"
|
||||
|
||||
new_entry = f"\n\n## [{version}] - {date}\n\n{formatted_changelog}\n"
|
||||
|
||||
# Insert the new entry
|
||||
|
|
@ -624,7 +645,68 @@ TYPESCRIPT_VERSION=
|
|||
f.write(content)
|
||||
|
||||
|
||||
def _resolve_changelog(value: str | None) -> str:
|
||||
"""Resolve a changelog argument that is either inline text or a file path."""
|
||||
if not value:
|
||||
return ""
|
||||
if os.path.isfile(value):
|
||||
with open(value) as f:
|
||||
return f.read().strip()
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _updates_from_args(args: argparse.Namespace) -> dict[str, dict[str, str]]:
|
||||
"""Build the updates dict from CLI flags (headless mode)."""
|
||||
updates: dict[str, dict[str, str]] = {}
|
||||
if args.api_version:
|
||||
updates["api"] = {
|
||||
"version": args.api_version,
|
||||
"changelog": _resolve_changelog(args.api_changelog),
|
||||
}
|
||||
if args.python_version:
|
||||
updates["python_sdk"] = {
|
||||
"version": args.python_version,
|
||||
"changelog": _resolve_changelog(args.python_changelog),
|
||||
}
|
||||
if args.typescript_version:
|
||||
updates["typescript_sdk"] = {
|
||||
"version": args.typescript_version,
|
||||
"changelog": _resolve_changelog(args.typescript_changelog),
|
||||
}
|
||||
return updates
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Update Honcho version numbers and changelogs. With no version "
|
||||
"flags, opens an interactive editor; pass one or more --*-version "
|
||||
"flags to run headless (agent-friendly)."
|
||||
)
|
||||
)
|
||||
parser.add_argument("--api-version", help="New Main API version.")
|
||||
parser.add_argument("--python-version", help="New Python SDK version.")
|
||||
parser.add_argument("--typescript-version", help="New TypeScript SDK version.")
|
||||
parser.add_argument(
|
||||
"--api-changelog",
|
||||
help="API changelog markdown, or a path to a file containing it.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--python-changelog",
|
||||
help="Python SDK changelog markdown, or a path to a file containing it.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--typescript-changelog",
|
||||
help="TypeScript SDK changelog markdown, or a path to a file containing it.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-y",
|
||||
"--yes",
|
||||
action="store_true",
|
||||
help="Skip the confirmation prompt (implied in headless mode).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Get the parent directory of the scripts folder (the project root)
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
base_path = os.path.dirname(script_dir)
|
||||
|
|
@ -633,6 +715,8 @@ def main():
|
|||
# Get current versions
|
||||
current_versions = updater.get_current_versions()
|
||||
|
||||
headless = any([args.api_version, args.python_version, args.typescript_version])
|
||||
|
||||
print("Honcho Version Updater")
|
||||
print("=" * 50)
|
||||
print("\nCurrent versions:")
|
||||
|
|
@ -640,12 +724,15 @@ def main():
|
|||
print(f" Python SDK: {current_versions['python_sdk']}")
|
||||
print(f" TypeScript SDK: {current_versions['typescript_sdk']}")
|
||||
print()
|
||||
print("Opening editor for version updates...")
|
||||
print("Leave version fields blank to skip updating that component.")
|
||||
print()
|
||||
|
||||
# Get all updates at once
|
||||
updates = updater.get_all_versions_from_editor(current_versions)
|
||||
if headless:
|
||||
updates = _updates_from_args(args)
|
||||
else:
|
||||
print("Opening editor for version updates...")
|
||||
print("Leave version fields blank to skip updating that component.")
|
||||
print()
|
||||
# Get all updates at once
|
||||
updates = updater.get_all_versions_from_editor(current_versions)
|
||||
|
||||
if not updates:
|
||||
print("No versions specified. Exiting...")
|
||||
|
|
@ -661,11 +748,12 @@ def main():
|
|||
}[component]
|
||||
print(f" {component_name}: {current_versions[component]} → {info['version']}")
|
||||
|
||||
# Confirm
|
||||
response = input("\nProceed with updates? (y/n): ").strip().lower()
|
||||
if response != "y":
|
||||
print("Cancelled.")
|
||||
sys.exit(0)
|
||||
# Confirm (skipped in headless mode or with --yes)
|
||||
if not headless and not args.yes:
|
||||
response = input("\nProceed with updates? (y/n): ").strip().lower()
|
||||
if response != "y":
|
||||
print("Cancelled.")
|
||||
sys.exit(0)
|
||||
|
||||
# Apply all updates
|
||||
updater.update_all(updates, current_versions)
|
||||
|
|
@ -673,6 +761,7 @@ def main():
|
|||
print("\nVersion updates complete!")
|
||||
print("\nDon't forget to:")
|
||||
print(" - Review the changes with `git diff`")
|
||||
print(" - Run `uv lock` to refresh the lockfile")
|
||||
print(" - Commit the changes")
|
||||
print(" - Create git tags for the new versions")
|
||||
print(" - Push the changes and tags")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [2.2.0] - 2026-07-02
|
||||
|
||||
### Added
|
||||
|
||||
- `ConclusionLevel` type (`explicit`, `deductive`, `inductive`, `contradiction`) and a `level` field on `Conclusion`, exposing the reasoning level the server already tracked but previously stripped from responses.
|
||||
- `filters` parameter on `ConclusionScope.list()` and `ConclusionScope.query()` (sync and async), passed through to the same dynamic server-side filter logic as `peers()`/`sessions()`/`messages()`. Filter explicit-only conclusions with `filters={"level": "explicit"}`, or by any other supported field/operator. Requires a Honcho server with the matching API support (Honcho v3.0.11+).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Scope-managed filter keys (`observer`, `observed`, `session`) are now rejected with a clear `ValueError` if passed in `filters`, instead of silently overriding the scope and returning conclusions from a different peer pair. Use `peer.conclusions` / `conclusions_of(target)` and the `session=` parameter instead. `session_id` remains a valid filter on `query()`.
|
||||
|
||||
## [2.1.2] - 2026-05-21
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "honcho-ai"
|
||||
version = "2.1.2"
|
||||
version = "2.2.0"
|
||||
description = "Official DX Optimized Python SDK for Honcho"
|
||||
dynamic = ["readme"]
|
||||
license = "Apache-2.0"
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ import logging
|
|||
import warnings
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload
|
||||
|
||||
from pydantic import ConfigDict, Field, validate_call
|
||||
from pydantic import BaseModel, ConfigDict, Field, validate_call
|
||||
|
||||
from .api_types import (
|
||||
ConclusionResponse,
|
||||
|
|
@ -47,7 +47,11 @@ from .api_types import (
|
|||
WorkspaceResponse,
|
||||
)
|
||||
from .base import PeerBase, SessionBase
|
||||
from .conclusions import Conclusion
|
||||
from .conclusions import (
|
||||
_SCOPE_RESERVED,
|
||||
Conclusion,
|
||||
_reject_reserved_filter_keys,
|
||||
)
|
||||
from .http import routes
|
||||
from .message import Message
|
||||
from .mixins import AsyncMetadataConfigMixin
|
||||
|
|
@ -67,7 +71,7 @@ if TYPE_CHECKING:
|
|||
from .conclusions import ConclusionScope
|
||||
|
||||
from .conclusions import ConclusionCreateParams
|
||||
from .peer import Peer
|
||||
from .peer import Peer, TResponseFormat, serialize_response_format
|
||||
from .session import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -573,6 +577,30 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
)
|
||||
self._peer._configuration = configuration
|
||||
|
||||
@overload
|
||||
async def chat(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
target: str | PeerBase | None = None,
|
||||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[TResponseFormat],
|
||||
) -> TResponseFormat | None: ...
|
||||
|
||||
@overload
|
||||
async def chat(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
target: str | PeerBase | None = None,
|
||||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: dict[str, Any] | None = None,
|
||||
) -> str | None: ...
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
async def chat(
|
||||
self,
|
||||
|
|
@ -582,8 +610,14 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
) -> str | None:
|
||||
"""Query the peer's representation asynchronously."""
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
) -> BaseModel | str | None:
|
||||
"""Query the peer's representation asynchronously.
|
||||
|
||||
See Peer.chat for parameter details. When response_format is a Pydantic
|
||||
model class, the answer is parsed into an instance of it; when it is a
|
||||
JSON Schema dict, the answer is a JSON string.
|
||||
"""
|
||||
await self._peer._honcho._ensure_workspace_async()
|
||||
target_id = resolve_id(target)
|
||||
resolved_session_id = resolve_id(session)
|
||||
|
|
@ -595,6 +629,9 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
body["session_id"] = resolved_session_id
|
||||
if reasoning_level:
|
||||
body["reasoning_level"] = reasoning_level
|
||||
response_format_schema = serialize_response_format(response_format)
|
||||
if response_format_schema is not None:
|
||||
body["response_format"] = response_format_schema
|
||||
|
||||
data = await self._peer._honcho._async_http_client.post(
|
||||
routes.peer_chat(self._peer.workspace_id, self._peer.id),
|
||||
|
|
@ -603,6 +640,8 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
content = data.get("content")
|
||||
if not content:
|
||||
return None
|
||||
if isinstance(response_format, type):
|
||||
return response_format.model_validate_json(content)
|
||||
return content
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
|
|
@ -614,8 +653,14 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
) -> AsyncDialecticStreamResponse:
|
||||
"""Query the peer's representation with streaming asynchronously."""
|
||||
"""Query the peer's representation with streaming asynchronously.
|
||||
|
||||
See Peer.chat_stream for parameter details. With response_format set,
|
||||
chunks stay raw text that accumulates to a JSON string; parse it after
|
||||
the stream completes.
|
||||
"""
|
||||
await self._peer._honcho._ensure_workspace_async()
|
||||
target_id = resolve_id(target)
|
||||
resolved_session_id = resolve_id(session)
|
||||
|
|
@ -627,6 +672,9 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
body["session_id"] = resolved_session_id
|
||||
if reasoning_level:
|
||||
body["reasoning_level"] = reasoning_level
|
||||
response_format_schema = serialize_response_format(response_format)
|
||||
if response_format_schema is not None:
|
||||
body["response_format"] = response_format_schema
|
||||
|
||||
async def stream_response() -> AsyncGenerator[str, None]:
|
||||
async for content in parse_sse_astream(
|
||||
|
|
@ -1460,17 +1508,28 @@ class ConclusionScopeAio:
|
|||
size: int = 50,
|
||||
session: str | SessionBase | None = None,
|
||||
*,
|
||||
filters: dict[str, Any] | None = None,
|
||||
reverse: bool = False,
|
||||
) -> AsyncPage[ConclusionResponse, Conclusion]:
|
||||
"""List conclusions in this scope asynchronously."""
|
||||
"""List conclusions in this scope asynchronously.
|
||||
|
||||
Pass ``filters`` to add criteria merged with this scope's
|
||||
observer/observed (and session, if given) — e.g.
|
||||
``{"level": "explicit"}`` to get only conclusions extracted directly
|
||||
from messages (i.e. not derived during dreaming). See
|
||||
https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
|
||||
"""
|
||||
_reject_reserved_filter_keys(
|
||||
filters, _SCOPE_RESERVED + ("session", "session_id")
|
||||
)
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
resolved_session_id = resolve_id(session)
|
||||
filters: dict[str, Any] = {
|
||||
filters = {
|
||||
"observer_id": self._scope.observer,
|
||||
"observed_id": self._scope.observed,
|
||||
**({"session_id": resolved_session_id} if resolved_session_id else {}),
|
||||
**(filters or {}),
|
||||
}
|
||||
if resolved_session_id:
|
||||
filters["session_id"] = resolved_session_id
|
||||
|
||||
query: dict[str, Any] = {"page": page, "size": size}
|
||||
if reverse:
|
||||
|
|
@ -1504,12 +1563,24 @@ class ConclusionScopeAio:
|
|||
query: str,
|
||||
top_k: int = 10,
|
||||
distance: float | None = None,
|
||||
*,
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> list[Conclusion]:
|
||||
"""Semantic search for conclusions asynchronously."""
|
||||
"""Semantic search for conclusions asynchronously.
|
||||
|
||||
Args:
|
||||
query: The search query string
|
||||
top_k: Maximum number of results to return
|
||||
distance: Maximum cosine distance threshold (0.0-1.0)
|
||||
filters: Optional dictionary of additional filter criteria, merged
|
||||
with this scope's observer/observed (e.g. ``{"level": "deductive"}``).
|
||||
"""
|
||||
_reject_reserved_filter_keys(filters, _SCOPE_RESERVED)
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
filters: dict[str, Any] = {
|
||||
filters = {
|
||||
"observer_id": self._scope.observer,
|
||||
"observed_id": self._scope.observed,
|
||||
**(filters or {}),
|
||||
}
|
||||
|
||||
body: dict[str, Any] = {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ from typing import Any, Literal
|
|||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# Reasoning level of a conclusion. "explicit" conclusions are extracted directly
|
||||
# from messages; the others are derived during dreaming.
|
||||
ConclusionLevel = Literal["explicit", "deductive", "inductive", "contradiction"]
|
||||
|
||||
# ==============================================================================
|
||||
# Configuration Types
|
||||
# ==============================================================================
|
||||
|
|
@ -414,6 +418,7 @@ class ConclusionResponse(BaseModel):
|
|||
observer_id: str
|
||||
observed_id: str
|
||||
session_id: str | None = None
|
||||
level: ConclusionLevel = "explicit"
|
||||
created_at: datetime.datetime
|
||||
|
||||
|
||||
|
|
@ -498,6 +503,7 @@ class DialecticParams(BaseModel):
|
|||
query: str = Field(min_length=1, max_length=10000)
|
||||
stream: bool = False
|
||||
reasoning_level: ReasoningLevel = "low"
|
||||
response_format: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class DialecticResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
|
|||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .api_types import ConclusionResponse, RepresentationResponse
|
||||
from .api_types import ConclusionLevel, ConclusionResponse, RepresentationResponse
|
||||
from .base import SessionBase
|
||||
from .http import routes
|
||||
from .pagination import SyncPage
|
||||
|
|
@ -24,6 +24,34 @@ __all__ = [
|
|||
"ConclusionCreateParams",
|
||||
]
|
||||
|
||||
# Filter keys that define a conclusion scope (the observer/observed peer pair).
|
||||
# They are set from the scope itself, so a caller must not pass them in `filters`.
|
||||
_SCOPE_RESERVED = ("observer", "observed", "observer_id", "observed_id")
|
||||
|
||||
|
||||
def _reject_reserved_filter_keys(
|
||||
filters: dict[str, Any] | None, reserved: tuple[str, ...]
|
||||
) -> None:
|
||||
"""Raise if ``filters`` contains keys managed by the conclusion scope.
|
||||
|
||||
The observer/observed peer pair (and, on ``list``, the session) is fixed by
|
||||
the scope, so letting a user filter override it would silently return data
|
||||
from a different scope than requested. Fail loud instead.
|
||||
"""
|
||||
if not filters:
|
||||
return
|
||||
clash = sorted(k for k in reserved if k in filters)
|
||||
if clash:
|
||||
guidance = (
|
||||
"Choose the peer pair via peer.conclusions / peer.conclusions_of(target)"
|
||||
)
|
||||
if "session" in reserved or "session_id" in reserved:
|
||||
guidance += "; use the session= parameter to filter by session"
|
||||
raise ValueError(
|
||||
f"Filter key(s) {clash} are managed by this conclusion scope and "
|
||||
+ f"cannot be passed in filters. {guidance}."
|
||||
)
|
||||
|
||||
|
||||
class ConclusionCreateParams(BaseModel):
|
||||
content: str
|
||||
|
|
@ -43,6 +71,9 @@ class Conclusion:
|
|||
observer_id: The peer ID who made this conclusion
|
||||
observed_id: The peer ID this conclusion is about
|
||||
session_id: The session this conclusion relates to
|
||||
level: Reasoning level ("explicit", "deductive", "inductive",
|
||||
"contradiction"). "explicit" conclusions are extracted directly
|
||||
from messages; the others are derived during dreaming.
|
||||
created_at: Timestamp for when the conclusion was created
|
||||
"""
|
||||
|
||||
|
|
@ -51,6 +82,7 @@ class Conclusion:
|
|||
observer_id: str
|
||||
observed_id: str
|
||||
session_id: str | None = None
|
||||
level: ConclusionLevel = "explicit"
|
||||
created_at: datetime.datetime
|
||||
|
||||
def __init__(
|
||||
|
|
@ -61,12 +93,14 @@ class Conclusion:
|
|||
observed_id: str,
|
||||
session_id: str | None,
|
||||
created_at: datetime.datetime,
|
||||
level: ConclusionLevel = "explicit",
|
||||
) -> None:
|
||||
self.id = id
|
||||
self.content = content
|
||||
self.observer_id = observer_id
|
||||
self.observed_id = observed_id
|
||||
self.session_id = session_id
|
||||
self.level = level
|
||||
self.created_at = created_at
|
||||
|
||||
@classmethod
|
||||
|
|
@ -78,6 +112,7 @@ class Conclusion:
|
|||
observer_id=data.observer_id,
|
||||
observed_id=data.observed_id,
|
||||
session_id=data.session_id,
|
||||
level=data.level,
|
||||
created_at=data.created_at,
|
||||
)
|
||||
|
||||
|
|
@ -169,6 +204,7 @@ class ConclusionScope:
|
|||
size: int = 50,
|
||||
session: str | SessionBase | None = None,
|
||||
*,
|
||||
filters: dict[str, Any] | None = None,
|
||||
reverse: bool = False,
|
||||
) -> SyncPage[ConclusionResponse, Conclusion]:
|
||||
"""
|
||||
|
|
@ -178,19 +214,28 @@ class ConclusionScope:
|
|||
page: Page number (1-indexed)
|
||||
size: Number of results per page
|
||||
session: Optional session (ID string or Session object) to filter by
|
||||
filters: Optional dictionary of additional filter criteria, merged
|
||||
with this scope's observer/observed (and session, if given).
|
||||
Supports the same operators as other list endpoints — e.g.
|
||||
``{"level": "explicit"}`` to get only conclusions extracted
|
||||
directly from messages (i.e. not derived during dreaming). See
|
||||
https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
|
||||
reverse: If True, reverses the default ordering. Default: False.
|
||||
|
||||
Returns:
|
||||
Paginated response containing Conclusion objects
|
||||
"""
|
||||
_reject_reserved_filter_keys(
|
||||
filters, _SCOPE_RESERVED + ("session", "session_id")
|
||||
)
|
||||
self._honcho._ensure_workspace()
|
||||
resolved_session_id = resolve_id(session)
|
||||
filters: dict[str, Any] = {
|
||||
filters = {
|
||||
"observer_id": self.observer,
|
||||
"observed_id": self.observed,
|
||||
**({"session_id": resolved_session_id} if resolved_session_id else {}),
|
||||
**(filters or {}),
|
||||
}
|
||||
if resolved_session_id:
|
||||
filters["session_id"] = resolved_session_id
|
||||
|
||||
query: dict[str, Any] = {"page": page, "size": size}
|
||||
if reverse:
|
||||
|
|
@ -224,6 +269,8 @@ class ConclusionScope:
|
|||
query: str,
|
||||
top_k: int = 10,
|
||||
distance: float | None = None,
|
||||
*,
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> list[Conclusion]:
|
||||
"""
|
||||
Semantic search for conclusions in this scope.
|
||||
|
|
@ -232,14 +279,21 @@ class ConclusionScope:
|
|||
query: The search query string
|
||||
top_k: Maximum number of results to return
|
||||
distance: Maximum cosine distance threshold (0.0-1.0)
|
||||
filters: Optional dictionary of additional filter criteria, merged
|
||||
with this scope's observer/observed. Supports the same operators
|
||||
as the list endpoint — e.g. ``{"level": "deductive"}`` to search
|
||||
only conclusions derived during dreaming. See
|
||||
https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
|
||||
|
||||
Returns:
|
||||
List of matching Conclusion objects
|
||||
"""
|
||||
_reject_reserved_filter_keys(filters, _SCOPE_RESERVED)
|
||||
self._honcho._ensure_workspace()
|
||||
filters: dict[str, Any] = {
|
||||
filters = {
|
||||
"observer_id": self.observer,
|
||||
"observed_id": self.observed,
|
||||
**(filters or {}),
|
||||
}
|
||||
|
||||
body: dict[str, Any] = {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import datetime
|
|||
import logging
|
||||
import warnings
|
||||
from collections.abc import Generator
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
|
||||
|
||||
from pydantic import ConfigDict, Field, PrivateAttr, validate_call
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
|
||||
|
||||
from .api_types import (
|
||||
MessageCreateParams,
|
||||
|
|
@ -38,6 +38,19 @@ if TYPE_CHECKING:
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TResponseFormat = TypeVar("TResponseFormat", bound=BaseModel)
|
||||
|
||||
|
||||
def serialize_response_format(
|
||||
response_format: type[BaseModel] | dict[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Convert a chat response_format argument to a JSON Schema dict."""
|
||||
if response_format is None:
|
||||
return None
|
||||
if isinstance(response_format, type):
|
||||
return response_format.model_json_schema()
|
||||
return response_format
|
||||
|
||||
|
||||
class Peer(PeerBase, MetadataConfigMixin):
|
||||
"""
|
||||
|
|
@ -221,6 +234,30 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
self._configuration = configuration # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
self._created_at = created_at
|
||||
|
||||
@overload
|
||||
def chat(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
target: str | PeerBase | None = None,
|
||||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[TResponseFormat],
|
||||
) -> TResponseFormat | None: ...
|
||||
|
||||
@overload
|
||||
def chat(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
target: str | PeerBase | None = None,
|
||||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: dict[str, Any] | None = None,
|
||||
) -> str | None: ...
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
def chat(
|
||||
self,
|
||||
|
|
@ -230,7 +267,8 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
) -> str | None:
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
) -> BaseModel | str | None:
|
||||
"""
|
||||
Query the peer's representation with a natural language question.
|
||||
|
||||
|
|
@ -249,9 +287,15 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
ID string or a Session object.
|
||||
reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium",
|
||||
"high", or "max". Defaults to "low" if not provided.
|
||||
response_format: Optional structure for the answer. Pass a Pydantic
|
||||
model class to get a parsed instance back, or a raw
|
||||
JSON Schema dict (root type "object") to get the
|
||||
answer as a JSON string.
|
||||
|
||||
Returns:
|
||||
Response string containing the answer, or None if no relevant information
|
||||
Response string containing the answer (a JSON string when a schema
|
||||
dict was given), a parsed model instance when a Pydantic model class
|
||||
was given, or None if no relevant information.
|
||||
"""
|
||||
self._honcho._ensure_workspace()
|
||||
target_id = resolve_id(target)
|
||||
|
|
@ -264,6 +308,9 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
body["session_id"] = resolved_session_id
|
||||
if reasoning_level:
|
||||
body["reasoning_level"] = reasoning_level
|
||||
response_format_schema = serialize_response_format(response_format)
|
||||
if response_format_schema is not None:
|
||||
body["response_format"] = response_format_schema
|
||||
|
||||
data = self._honcho._http.post(
|
||||
routes.peer_chat(self.workspace_id, self.id),
|
||||
|
|
@ -272,6 +319,8 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
content = data.get("content")
|
||||
if not content:
|
||||
return None
|
||||
if isinstance(response_format, type):
|
||||
return response_format.model_validate_json(content)
|
||||
return content
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
|
|
@ -283,6 +332,7 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
) -> DialecticStreamResponse:
|
||||
"""
|
||||
Query the peer's representation with a natural language question, streaming the response.
|
||||
|
|
@ -302,6 +352,11 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
ID string or a Session object.
|
||||
reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium",
|
||||
"high", or "max". Defaults to "low" if not provided.
|
||||
response_format: Optional structure for the answer: a Pydantic model
|
||||
class or a JSON Schema dict (root type "object").
|
||||
Streamed chunks stay raw text that accumulates to a
|
||||
JSON string; parse it yourself (e.g. with
|
||||
Model.model_validate_json) once the stream completes.
|
||||
|
||||
Returns:
|
||||
DialecticStreamResponse object that can be iterated over and provides final response
|
||||
|
|
@ -317,6 +372,9 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
body["session_id"] = resolved_session_id
|
||||
if reasoning_level:
|
||||
body["reasoning_level"] = reasoning_level
|
||||
response_format_schema = serialize_response_format(response_format)
|
||||
if response_format_schema is not None:
|
||||
body["response_format"] = response_format_schema
|
||||
|
||||
def stream_response() -> Generator[str, None, None]:
|
||||
yield from parse_sse_stream(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [2.2.0] - 2026-07-02
|
||||
|
||||
### Added
|
||||
|
||||
- `ConclusionLevel` type (`explicit`, `deductive`, `inductive`, `contradiction`) and a `level` field on `Conclusion`, exposing the reasoning level the server already tracked but previously stripped from responses.
|
||||
- `filters` option on `conclusions.list()` and `conclusions.query()`, passed through to the same dynamic server-side filter logic as the other list endpoints. Filter explicit-only conclusions with `{ filters: { level: 'explicit' } }`, or by any other supported field/operator. Requires a Honcho server with the matching API support (Honcho v3.0.11+).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Scope-managed filter keys (`observer`, `observed`, `session`) are now rejected with a clear error if passed in `filters`, instead of silently overriding the scope and returning conclusions from a different peer pair. Use `peer.conclusions` / `peer.conclusionsOf(target)` and the dedicated `session` option instead. `session_id` remains a valid filter on `query()`.
|
||||
|
||||
## [2.1.2] - 2026-05-21
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -278,6 +278,61 @@ describe('Conclusions', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// Scope-reserved filter guard
|
||||
// ===========================================================================
|
||||
|
||||
describe('reserved filter keys', () => {
|
||||
test('list rejects observer/observed scope keys in filters', async () => {
|
||||
const peer = await client.peer('reserved-list-peer', { metadata: {} })
|
||||
|
||||
for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) {
|
||||
await expect(
|
||||
peer.conclusions.list({ filters: { [key]: 'someone-else' } })
|
||||
).rejects.toThrow(/managed by this conclusion scope/)
|
||||
}
|
||||
})
|
||||
|
||||
test('list rejects session keys in filters (use the session option)', async () => {
|
||||
const peer = await client.peer('reserved-list-session-peer', { metadata: {} })
|
||||
|
||||
await expect(
|
||||
peer.conclusions.list({ filters: { session_id: 'sess' } })
|
||||
).rejects.toThrow(/managed by this conclusion scope/)
|
||||
await expect(
|
||||
peer.conclusions.list({ filters: { session: 'sess' } })
|
||||
).rejects.toThrow(/managed by this conclusion scope/)
|
||||
})
|
||||
|
||||
test('query rejects observer/observed scope keys in filters', async () => {
|
||||
const peer = await client.peer('reserved-query-peer', { metadata: {} })
|
||||
|
||||
for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) {
|
||||
await expect(
|
||||
peer.conclusions.query('q', 10, undefined, { [key]: 'someone-else' })
|
||||
).rejects.toThrow(/managed by this conclusion scope/)
|
||||
}
|
||||
})
|
||||
|
||||
test('query allows session_id in filters (no dedicated session param)', async () => {
|
||||
const peer = await client.peer('reserved-query-session-peer', { metadata: {} })
|
||||
|
||||
// Should not throw the reserved-key guard; session_id is a normal filter
|
||||
// for query. The call may return no matches, which is fine.
|
||||
await expect(
|
||||
peer.conclusions.query('q', 10, undefined, { session_id: 'sess' })
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test('non-reserved filters (level) still work on list', async () => {
|
||||
const peer = await client.peer('reserved-allowed-peer', { metadata: {} })
|
||||
|
||||
await expect(
|
||||
peer.conclusions.list({ filters: { level: 'explicit' } })
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// Conclusion Deletion (DELETE /conclusions/:id)
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
|
||||
import { z } from 'zod'
|
||||
import { Honcho, Peer } from '../src'
|
||||
import { createTestClient, generateId, requireServer } from './setup'
|
||||
import {
|
||||
|
|
@ -624,6 +625,44 @@ describe('Peer', () => {
|
|||
expect(response === null || typeof response === 'string').toBe(true)
|
||||
})
|
||||
|
||||
test('chat with responseFormat as JSON schema object returns JSON string', async () => {
|
||||
const peer = await client.peer('chat-rf-peer')
|
||||
|
||||
const response = await peer.chat('What do you know?', {
|
||||
responseFormat: {
|
||||
type: 'object',
|
||||
properties: { summary: { type: 'string' } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(response === null || typeof response === 'string').toBe(true)
|
||||
if (response !== null) {
|
||||
expect(() => JSON.parse(response)).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
test('chat with responseFormat as Zod schema returns parsed object', async () => {
|
||||
const peer = await client.peer('chat-rf-zod-peer')
|
||||
const ResultSchema = z.object({ summary: z.string().optional() })
|
||||
|
||||
const response = await peer.chat('What do you know?', {
|
||||
responseFormat: ResultSchema,
|
||||
})
|
||||
|
||||
expect(response === null || typeof response === 'object').toBe(true)
|
||||
})
|
||||
|
||||
test('chat with unsupported responseFormat is rejected by the server', async () => {
|
||||
const peer = await client.peer('chat-rf-invalid-peer')
|
||||
|
||||
// Non-object root is rejected with 422
|
||||
await expect(
|
||||
peer.chat('What do you know?', {
|
||||
responseFormat: { type: 'string' },
|
||||
})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
// Streaming tests are in streaming.test.ts
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { ZodError } from 'zod'
|
||||
import { z, ZodError } from 'zod'
|
||||
import {
|
||||
ChatQuerySchema,
|
||||
ContextParamsSchema,
|
||||
|
|
@ -67,6 +67,24 @@ describe('ChatQuerySchema', () => {
|
|||
}
|
||||
)
|
||||
|
||||
test('responseFormat as plain JSON schema object is valid', () => {
|
||||
const schema = { type: 'object', properties: { a: { type: 'string' } } }
|
||||
const result = ChatQuerySchema.parse({ query: 'hello', responseFormat: schema })
|
||||
expect(result.responseFormat).toEqual(schema)
|
||||
})
|
||||
|
||||
test('responseFormat as Zod schema instance is valid and passed through', () => {
|
||||
const schema = z.object({ a: z.string() })
|
||||
const result = ChatQuerySchema.parse({ query: 'hello', responseFormat: schema })
|
||||
expect(result.responseFormat).toBe(schema)
|
||||
})
|
||||
|
||||
test('responseFormat as a non-object throws', () => {
|
||||
expect(() =>
|
||||
ChatQuerySchema.parse({ query: 'hello', responseFormat: 'not-a-schema' })
|
||||
).toThrow(ZodError)
|
||||
})
|
||||
|
||||
// --- Missing required fields ---
|
||||
|
||||
test('missing query throws', () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@honcho-ai/sdk",
|
||||
"version": "2.1.2",
|
||||
"version": "2.2.0",
|
||||
"description": "Official DX Optimized TypeScript SDK for Honcho",
|
||||
"author": "Plastic Labs <hello@plasticlabs.ai>",
|
||||
"license": "Apache-2.0",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { HonchoHTTPClient } from './http/client'
|
|||
import { Page } from './pagination'
|
||||
import type { Session } from './session'
|
||||
import type {
|
||||
ConclusionLevel,
|
||||
ConclusionResponse,
|
||||
PageResponse,
|
||||
RepresentationOptions,
|
||||
|
|
@ -10,6 +11,43 @@ import type {
|
|||
} from './types/api'
|
||||
import { normalizeSearchQuery, RepresentationOptionsSchema } from './validation'
|
||||
|
||||
/**
|
||||
* Filter keys that define a conclusion scope (the observer/observed peer pair).
|
||||
* They are set from the scope itself, so a caller must not pass them in `filters`.
|
||||
*/
|
||||
const SCOPE_RESERVED_KEYS = [
|
||||
'observer',
|
||||
'observed',
|
||||
'observer_id',
|
||||
'observed_id',
|
||||
]
|
||||
|
||||
/**
|
||||
* Throw if `filters` contains keys managed by the conclusion scope.
|
||||
*
|
||||
* The observer/observed peer pair (and, on `list`, the session) is fixed by the
|
||||
* scope, so letting a user filter override it would silently return data from a
|
||||
* different scope than requested. Fail loud instead.
|
||||
*/
|
||||
function rejectReservedFilterKeys(
|
||||
filters: Record<string, unknown> | undefined,
|
||||
reserved: string[]
|
||||
): void {
|
||||
if (!filters) return
|
||||
const clash = reserved.filter((k) => k in filters).sort()
|
||||
if (clash.length > 0) {
|
||||
let guidance =
|
||||
'Choose the peer pair via peer.conclusions / peer.conclusionsOf(target)'
|
||||
if (reserved.includes('session') || reserved.includes('session_id')) {
|
||||
guidance += '; use the session option to filter by session'
|
||||
}
|
||||
throw new Error(
|
||||
`Filter key(s) ${clash.join(', ')} are managed by this conclusion scope ` +
|
||||
`and cannot be passed in filters. ${guidance}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for creating a conclusion.
|
||||
*/
|
||||
|
|
@ -32,6 +70,12 @@ export class Conclusion {
|
|||
readonly observerId: string
|
||||
readonly observedId: string
|
||||
readonly sessionId: string | null
|
||||
/**
|
||||
* Reasoning level: 'explicit' conclusions are extracted directly from
|
||||
* messages; 'deductive'/'inductive'/'contradiction' are derived during
|
||||
* dreaming.
|
||||
*/
|
||||
readonly level: ConclusionLevel
|
||||
readonly createdAt: string
|
||||
|
||||
constructor(
|
||||
|
|
@ -40,13 +84,15 @@ export class Conclusion {
|
|||
observerId: string,
|
||||
observedId: string,
|
||||
sessionId: string | null,
|
||||
createdAt: string
|
||||
createdAt: string,
|
||||
level: ConclusionLevel = 'explicit'
|
||||
) {
|
||||
this.id = id
|
||||
this.content = content
|
||||
this.observerId = observerId
|
||||
this.observedId = observedId
|
||||
this.sessionId = sessionId
|
||||
this.level = level
|
||||
this.createdAt = createdAt
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +103,8 @@ export class Conclusion {
|
|||
data.observer_id,
|
||||
data.observed_id,
|
||||
data.session_id,
|
||||
data.created_at
|
||||
data.created_at,
|
||||
data.level
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -182,14 +229,26 @@ export class ConclusionScope {
|
|||
* @param options.page - Page number (1-indexed, default: 1)
|
||||
* @param options.size - Number of items per page (default: 50)
|
||||
* @param options.session - Optional session (ID string or Session object) to filter by
|
||||
* @param options.filters - Optional additional filter criteria, merged with
|
||||
* this scope's observer/observed (and session, if given). Supports the same
|
||||
* operators as other list endpoints — e.g. `{ level: 'explicit' }` to get
|
||||
* only conclusions extracted directly from messages (i.e. not derived during
|
||||
* dreaming). See
|
||||
* https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
|
||||
* @returns Promise resolving to a Page of Conclusion objects
|
||||
*/
|
||||
async list(options?: {
|
||||
page?: number
|
||||
size?: number
|
||||
session?: string | Session
|
||||
filters?: Record<string, unknown>
|
||||
reverse?: boolean
|
||||
}): Promise<Page<Conclusion, ConclusionResponse>> {
|
||||
rejectReservedFilterKeys(options?.filters, [
|
||||
...SCOPE_RESERVED_KEYS,
|
||||
'session',
|
||||
'session_id',
|
||||
])
|
||||
const resolvedSessionId = options?.session
|
||||
? typeof options.session === 'string'
|
||||
? options.session
|
||||
|
|
@ -198,9 +257,8 @@ export class ConclusionScope {
|
|||
const filters: Record<string, unknown> = {
|
||||
observer_id: this.observer,
|
||||
observed_id: this.observed,
|
||||
}
|
||||
if (resolvedSessionId) {
|
||||
filters.session_id = resolvedSessionId
|
||||
...(resolvedSessionId ? { session_id: resolvedSessionId } : {}),
|
||||
...options?.filters,
|
||||
}
|
||||
const reverse = options?.reverse
|
||||
|
||||
|
|
@ -227,22 +285,32 @@ export class ConclusionScope {
|
|||
|
||||
/**
|
||||
* Semantic search for conclusions in this scope.
|
||||
*
|
||||
* @param query - The search query string
|
||||
* @param topK - Maximum number of results to return (default: 10)
|
||||
* @param distance - Maximum cosine distance threshold (0.0-1.0)
|
||||
* @param filters - Optional additional filter criteria, merged with this
|
||||
* scope's observer/observed. Supports the same operators as the list
|
||||
* endpoint — e.g. `{ level: 'deductive' }` to search only conclusions
|
||||
* derived during dreaming. See
|
||||
* https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
|
||||
*/
|
||||
async query(
|
||||
query: string,
|
||||
topK: number = 10,
|
||||
distance?: number
|
||||
distance?: number,
|
||||
filters?: Record<string, unknown>
|
||||
): Promise<Conclusion[]> {
|
||||
const filters: Record<string, unknown> = {
|
||||
observer_id: this.observer,
|
||||
observed_id: this.observed,
|
||||
}
|
||||
|
||||
rejectReservedFilterKeys(filters, SCOPE_RESERVED_KEYS)
|
||||
const response = await this._query({
|
||||
query,
|
||||
top_k: topK,
|
||||
distance,
|
||||
filters,
|
||||
filters: {
|
||||
observer_id: this.observer,
|
||||
observed_id: this.observed,
|
||||
...filters,
|
||||
},
|
||||
})
|
||||
|
||||
return (response ?? []).map((item) => Conclusion.fromApiResponse(item))
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export {
|
|||
|
||||
// API types (snake_case, for advanced usage)
|
||||
export type {
|
||||
ConclusionLevel,
|
||||
ConclusionQueryParams,
|
||||
ConclusionResponse,
|
||||
MessageResponse,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { ZodType, z } from 'zod'
|
||||
import { API_VERSION } from './api-version'
|
||||
import { ConclusionScope } from './conclusions'
|
||||
import type { HonchoHTTPClient } from './http/client'
|
||||
|
|
@ -229,12 +230,29 @@ export class Peer {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a responseFormat option (Zod schema or raw JSON Schema object)
|
||||
* to the JSON Schema dict the API expects.
|
||||
*/
|
||||
private static toResponseFormatSchema(
|
||||
responseFormat: ZodType | Record<string, unknown> | undefined
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!responseFormat) {
|
||||
return undefined
|
||||
}
|
||||
if (responseFormat instanceof ZodType) {
|
||||
return z.toJSONSchema(responseFormat) as Record<string, unknown>
|
||||
}
|
||||
return responseFormat
|
||||
}
|
||||
|
||||
private async _chat(params: {
|
||||
query: string
|
||||
stream?: boolean
|
||||
target?: string
|
||||
session_id?: string
|
||||
reasoning_level?: string
|
||||
response_format?: Record<string, unknown>
|
||||
}): Promise<PeerChatResponse> {
|
||||
await this._ensureWorkspace()
|
||||
return this._http.post<PeerChatResponse>(
|
||||
|
|
@ -248,6 +266,7 @@ export class Peer {
|
|||
target?: string
|
||||
session_id?: string
|
||||
reasoning_level?: string
|
||||
response_format?: Record<string, unknown>
|
||||
}): Promise<Response> {
|
||||
await this._ensureWorkspace()
|
||||
return this._http.stream(
|
||||
|
|
@ -362,14 +381,33 @@ export class Peer {
|
|||
* })
|
||||
* ```
|
||||
*/
|
||||
async chat<T>(
|
||||
query: string,
|
||||
options: {
|
||||
target?: string | Peer
|
||||
session?: string | Session
|
||||
reasoningLevel?: string
|
||||
responseFormat: ZodType<T>
|
||||
}
|
||||
): Promise<T | null>
|
||||
async chat(
|
||||
query: string,
|
||||
options?: {
|
||||
target?: string | Peer
|
||||
session?: string | Session
|
||||
reasoningLevel?: string
|
||||
responseFormat?: Record<string, unknown>
|
||||
}
|
||||
): Promise<string | null> {
|
||||
): Promise<string | null>
|
||||
async chat<T>(
|
||||
query: string,
|
||||
options?: {
|
||||
target?: string | Peer
|
||||
session?: string | Session
|
||||
reasoningLevel?: string
|
||||
responseFormat?: ZodType<T> | Record<string, unknown>
|
||||
}
|
||||
): Promise<T | string | null> {
|
||||
const targetId = options?.target
|
||||
? typeof options.target === 'string'
|
||||
? options.target
|
||||
|
|
@ -386,18 +424,28 @@ export class Peer {
|
|||
target: targetId,
|
||||
session: resolvedSessionId,
|
||||
reasoningLevel: options?.reasoningLevel,
|
||||
responseFormat: options?.responseFormat,
|
||||
})
|
||||
|
||||
const zodSchema =
|
||||
options?.responseFormat instanceof ZodType
|
||||
? options.responseFormat
|
||||
: undefined
|
||||
|
||||
const response = await this._chat({
|
||||
query: chatParams.query,
|
||||
stream: false,
|
||||
target: chatParams.target,
|
||||
session_id: chatParams.session,
|
||||
reasoning_level: chatParams.reasoningLevel,
|
||||
response_format: Peer.toResponseFormatSchema(options?.responseFormat),
|
||||
})
|
||||
if (!response.content) {
|
||||
return null
|
||||
}
|
||||
if (zodSchema) {
|
||||
return zodSchema.parse(JSON.parse(response.content))
|
||||
}
|
||||
return response.content
|
||||
}
|
||||
|
||||
|
|
@ -442,6 +490,7 @@ export class Peer {
|
|||
target?: string | Peer
|
||||
session?: string | Session
|
||||
reasoningLevel?: string
|
||||
responseFormat?: ZodType | Record<string, unknown>
|
||||
}
|
||||
): Promise<DialecticStreamResponse> {
|
||||
const targetId = options?.target
|
||||
|
|
@ -460,6 +509,7 @@ export class Peer {
|
|||
target: targetId,
|
||||
session: resolvedSessionId,
|
||||
reasoningLevel: options?.reasoningLevel,
|
||||
responseFormat: options?.responseFormat,
|
||||
})
|
||||
|
||||
const response = await this._chatStream({
|
||||
|
|
@ -467,6 +517,7 @@ export class Peer {
|
|||
target: chatParams.target,
|
||||
session_id: chatParams.session,
|
||||
reasoning_level: chatParams.reasoningLevel,
|
||||
response_format: Peer.toResponseFormatSchema(options?.responseFormat),
|
||||
})
|
||||
|
||||
return createDialecticStream(response)
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export interface PeerChatParams {
|
|||
session_id?: string
|
||||
target?: string
|
||||
reasoning_level?: 'minimal' | 'low' | 'medium' | 'high' | 'max'
|
||||
response_format?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface PeerChatResponse {
|
||||
|
|
@ -242,12 +243,23 @@ export interface MessageSearchParams {
|
|||
// Conclusion Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Reasoning level of a conclusion. "explicit" conclusions are extracted
|
||||
* directly from messages; the others are derived during dreaming.
|
||||
*/
|
||||
export type ConclusionLevel =
|
||||
| 'explicit'
|
||||
| 'deductive'
|
||||
| 'inductive'
|
||||
| 'contradiction'
|
||||
|
||||
export interface ConclusionResponse {
|
||||
id: string
|
||||
content: string
|
||||
observer_id: string
|
||||
observed_id: string
|
||||
session_id: string | null
|
||||
level: ConclusionLevel
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -312,6 +312,11 @@ export const ChatQuerySchema = z
|
|||
reasoningLevel: z
|
||||
.enum(['minimal', 'low', 'medium', 'high', 'max'])
|
||||
.optional(),
|
||||
// A Zod schema (checked first — it is itself an object) or a raw JSON
|
||||
// Schema object describing the desired response structure.
|
||||
responseFormat: z
|
||||
.union([z.instanceof(z.ZodType), z.record(z.string(), z.unknown())])
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import sentry_sdk
|
||||
from cashews import cache
|
||||
|
|
@ -20,10 +21,99 @@ from src.config import settings
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
|
||||
# Query parameters that carry secrets when configured via URL:
|
||||
# redis-py accepts ``?password=`` (all querystring options become client
|
||||
# kwargs) and cashews accepts ``?secret=`` (HMAC key for value signing).
|
||||
_SENSITIVE_QUERY_PARAMS = frozenset({"password", "secret"})
|
||||
|
||||
|
||||
def _mask_sensitive_query(query: str) -> str:
|
||||
"""Mask values of secret-bearing query parameters.
|
||||
|
||||
Operates on the raw query string (no decode/re-encode round trip)
|
||||
so non-secret parameters are preserved byte-for-byte.
|
||||
|
||||
Args:
|
||||
query: The raw query string from a parsed URL.
|
||||
|
||||
Returns:
|
||||
The query string with sensitive values replaced by ``***``, or
|
||||
the original string if no sensitive parameter is present.
|
||||
"""
|
||||
if not query:
|
||||
return query
|
||||
parts: list[str] = []
|
||||
changed = False
|
||||
for part in query.split("&"):
|
||||
name, sep, _value = part.partition("=")
|
||||
if sep and name.lower() in _SENSITIVE_QUERY_PARAMS:
|
||||
parts.append(f"{name}=***")
|
||||
changed = True
|
||||
else:
|
||||
parts.append(part)
|
||||
return "&".join(parts) if changed else query
|
||||
|
||||
|
||||
def _redact_cache_url(url: str) -> str:
|
||||
"""Mask credentials in a Redis connection URL before logging.
|
||||
|
||||
Given ``redis://:password@host:port/db`` returns
|
||||
``redis://:***@host:port/db``; secret-bearing query parameters
|
||||
(``?password=``, ``?secret=``) are masked as well. A URL carrying
|
||||
no credentials is returned unchanged. This function never raises
|
||||
and never returns a credential: an invalid port is omitted from
|
||||
the output, and a URL that cannot be parsed at all is replaced by
|
||||
a generic placeholder rather than echoed back, so that logging
|
||||
inside ``except`` blocks can neither crash startup nor leak the
|
||||
secrets this helper exists to hide.
|
||||
|
||||
Args:
|
||||
url: The Redis connection URL to redact.
|
||||
|
||||
Returns:
|
||||
The URL with its credentials masked, the original URL if it
|
||||
carries none, or ``"<redacted-unparseable-url>"`` if parsing
|
||||
fails entirely.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
query = _mask_sensitive_query(parsed.query)
|
||||
# .password only splits netloc and never raises, unlike .port
|
||||
if parsed.password is None and query == parsed.query:
|
||||
# A string with an "@" but no parsed authority (e.g. a URL
|
||||
# missing its scheme, ":pass@host:6379/0") may still carry
|
||||
# userinfo that urlparse could not see — never echo it.
|
||||
if "@" in url and not parsed.netloc:
|
||||
return "<redacted-unparseable-url>"
|
||||
return url
|
||||
netloc = parsed.netloc
|
||||
if parsed.password is not None:
|
||||
userinfo = parsed.username or ""
|
||||
hostname = parsed.hostname or ""
|
||||
# Preserve IPv6 brackets (urlparse strips them from .hostname)
|
||||
if hostname and ":" in hostname and not hostname.startswith("["):
|
||||
hostname = f"[{hostname}]"
|
||||
netloc = f"{userinfo}:***@{hostname}"
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
# Invalid or out-of-range port: omit it rather than let
|
||||
# the outer fallback echo the raw URL (and its password)
|
||||
# back.
|
||||
port = None
|
||||
if port is not None:
|
||||
netloc += f":{port}"
|
||||
parsed = parsed._replace(netloc=netloc, query=query)
|
||||
return urlunparse(parsed)
|
||||
except (ValueError, TypeError):
|
||||
# Unparseable URL: never return the raw input — it may contain
|
||||
# the very password this helper exists to hide.
|
||||
return "<redacted-unparseable-url>"
|
||||
|
||||
|
||||
def is_cache_enabled() -> bool:
|
||||
return settings.CACHE.ENABLED
|
||||
|
||||
|
|
@ -45,17 +135,21 @@ async def init_cache() -> None:
|
|||
cache.setup("mem://", pickle_type=PicklerType.SQLALCHEMY)
|
||||
return
|
||||
|
||||
# Setup cache with Redis backend
|
||||
# Setup cache with Redis backend. CACHE_CLUSTER selects the
|
||||
# cluster-aware client, which follows the MOVED redirects a Redis
|
||||
# Cluster returns for keys hashed to another shard; the standalone
|
||||
# client treats those as command errors.
|
||||
try:
|
||||
cache.setup(
|
||||
settings.CACHE.URL,
|
||||
pickle_type=PicklerType.SQLALCHEMY,
|
||||
cluster=settings.CACHE.CLUSTER,
|
||||
)
|
||||
|
||||
except Exception as setup_err:
|
||||
logger.warning(
|
||||
"Cache setup failed for %s: %s. Falling back to in-memory cache",
|
||||
settings.CACHE.URL,
|
||||
_redact_cache_url(settings.CACHE.URL),
|
||||
setup_err,
|
||||
)
|
||||
if settings.SENTRY.ENABLED:
|
||||
|
|
@ -83,7 +177,10 @@ async def init_cache() -> None:
|
|||
with attempt:
|
||||
async with asyncio.timeout(2):
|
||||
await cache.ping()
|
||||
logger.info("Connected to cache at %s", settings.CACHE.URL)
|
||||
logger.info(
|
||||
"Connected to cache at %s",
|
||||
_redact_cache_url(settings.CACHE.URL),
|
||||
)
|
||||
except (
|
||||
redis_exc.TimeoutError,
|
||||
redis_exc.ConnectionError,
|
||||
|
|
@ -92,7 +189,7 @@ async def init_cache() -> None:
|
|||
) as e:
|
||||
logger.warning(
|
||||
"Failed to connect to cache at %s: %s. Falling back to in-memory cache",
|
||||
settings.CACHE.URL,
|
||||
_redact_cache_url(settings.CACHE.URL),
|
||||
e,
|
||||
)
|
||||
if settings.SENTRY.ENABLED:
|
||||
|
|
@ -103,7 +200,7 @@ async def init_cache() -> None:
|
|||
except Exception as e:
|
||||
logger.warning(
|
||||
"Unexpected cache error at %s: %s. Falling back to in-memory cache",
|
||||
settings.CACHE.URL,
|
||||
_redact_cache_url(settings.CACHE.URL),
|
||||
e,
|
||||
)
|
||||
if settings.SENTRY.ENABLED:
|
||||
|
|
|
|||
192
src/config.py
192
src/config.py
|
|
@ -61,6 +61,10 @@ ThinkingEffortLevel = Literal[
|
|||
"none", "minimal", "low", "medium", "high", "xhigh", "max"
|
||||
]
|
||||
|
||||
# "json_object" injects the schema into the prompt for OpenAI-compatible
|
||||
# providers that don't support json_schema (Structured Outputs).
|
||||
StructuredOutputMode = Literal["json_schema", "json_object"]
|
||||
|
||||
|
||||
class ModelOverrideSettings(BaseModel):
|
||||
"""Advanced module-level transport overrides."""
|
||||
|
|
@ -69,7 +73,23 @@ class ModelOverrideSettings(BaseModel):
|
|||
api_key_env: str | None = None
|
||||
base_url: str | None = None
|
||||
|
||||
provider_params: dict[str, Any] = Field(default_factory=dict)
|
||||
provider_params: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description=(
|
||||
"Operator escape hatch for provider-specific request fields. "
|
||||
"Three recognized keys: `extra_body` (merged into the request body), "
|
||||
"`extra_headers` (HTTP headers), `extra_query` (URL query params). "
|
||||
"OpenAI and Anthropic transports forward these as identically-named "
|
||||
"SDK kwargs. The Gemini transport merges `extra_body` into the "
|
||||
"GenerateContentConfig dict and folds `extra_headers` into "
|
||||
"`http_options.headers`; `extra_query` is unsupported. Shallow merge "
|
||||
"with operator-wins — if Honcho and the operator both set the same "
|
||||
"key inside `extra_body`, the operator's value replaces Honcho's. "
|
||||
"Operators are responsible for picking a coherent combination of "
|
||||
"this and other config (e.g. unset `thinking_budget_tokens` when "
|
||||
"supplying an `extra_body.thinking` for Anthropic-via-proxy)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PromptCachePolicy(BaseModel):
|
||||
|
|
@ -117,6 +137,23 @@ def _validate_thinking_constraints(
|
|||
raise ValueError("thinking_budget_tokens must be >= 1024 for Anthropic models")
|
||||
|
||||
|
||||
def _validate_structured_output_mode(
|
||||
transport: ModelTransport, structured_output_mode: StructuredOutputMode | None
|
||||
) -> None:
|
||||
"""Reject ``structured_output_mode`` on transports that ignore it.
|
||||
|
||||
Only the OpenAI backend honors this setting (it controls the json_schema vs
|
||||
json_object structured-output path). On the anthropic/gemini transports it is
|
||||
a silent no-op, so a value set there is a misconfiguration — fail fast at
|
||||
startup rather than letting the operator wonder why it has no effect.
|
||||
"""
|
||||
if structured_output_mode is not None and transport != "openai":
|
||||
raise ValueError(
|
||||
"structured_output_mode is only supported on the 'openai' transport; "
|
||||
+ f"remove it from the '{transport}' model config"
|
||||
)
|
||||
|
||||
|
||||
class FallbackModelSettings(BaseModel):
|
||||
"""Independent fallback model configuration. No inheritance from primary."""
|
||||
|
||||
|
|
@ -136,6 +173,8 @@ class FallbackModelSettings(BaseModel):
|
|||
)
|
||||
thinking_budget_tokens: int | None = None
|
||||
|
||||
structured_output_mode: StructuredOutputMode | None = None
|
||||
|
||||
max_output_tokens: int | None = None
|
||||
stop_sequences: list[str] | None = None
|
||||
|
||||
|
|
@ -155,6 +194,7 @@ class FallbackModelSettings(BaseModel):
|
|||
@model_validator(mode="after")
|
||||
def _validate_runtime_shape(self) -> "FallbackModelSettings":
|
||||
_validate_thinking_constraints(self.transport, self.thinking_budget_tokens)
|
||||
_validate_structured_output_mode(self.transport, self.structured_output_mode)
|
||||
return self
|
||||
|
||||
|
||||
|
|
@ -179,6 +219,8 @@ class ConfiguredModelSettings(BaseModel):
|
|||
)
|
||||
thinking_budget_tokens: int | None = None
|
||||
|
||||
structured_output_mode: StructuredOutputMode | None = None
|
||||
|
||||
max_output_tokens: int | None = None
|
||||
stop_sequences: list[str] | None = None
|
||||
|
||||
|
|
@ -199,6 +241,7 @@ class ConfiguredModelSettings(BaseModel):
|
|||
@model_validator(mode="after")
|
||||
def _validate_runtime_shape(self) -> "ConfiguredModelSettings":
|
||||
_validate_thinking_constraints(self.transport, self.thinking_budget_tokens)
|
||||
_validate_structured_output_mode(self.transport, self.structured_output_mode)
|
||||
return self
|
||||
|
||||
|
||||
|
|
@ -223,6 +266,7 @@ class ResolvedFallbackConfig(BaseModel):
|
|||
validation_alias=AliasChoices("thinking_effort", "reasoning_effort"),
|
||||
)
|
||||
thinking_budget_tokens: int | None = None
|
||||
structured_output_mode: StructuredOutputMode | None = None
|
||||
provider_params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
max_output_tokens: int | None = None
|
||||
|
|
@ -258,6 +302,7 @@ class ModelConfig(BaseModel):
|
|||
validation_alias=AliasChoices("thinking_effort", "reasoning_effort"),
|
||||
)
|
||||
thinking_budget_tokens: int | None = None
|
||||
structured_output_mode: StructuredOutputMode | None = None
|
||||
provider_params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
max_output_tokens: int | None = None
|
||||
|
|
@ -394,6 +439,7 @@ def _resolve_fallback_config(
|
|||
seed=fallback.seed,
|
||||
thinking_effort=fallback.thinking_effort,
|
||||
thinking_budget_tokens=fallback.thinking_budget_tokens,
|
||||
structured_output_mode=fallback.structured_output_mode,
|
||||
provider_params=fallback.overrides.provider_params,
|
||||
max_output_tokens=fallback.max_output_tokens,
|
||||
stop_sequences=fallback.stop_sequences,
|
||||
|
|
@ -427,6 +473,7 @@ def resolve_model_config(configured: ConfiguredModelSettings) -> ModelConfig:
|
|||
seed=configured.seed,
|
||||
thinking_effort=configured.thinking_effort,
|
||||
thinking_budget_tokens=configured.thinking_budget_tokens,
|
||||
structured_output_mode=configured.structured_output_mode,
|
||||
provider_params=configured.overrides.provider_params,
|
||||
max_output_tokens=configured.max_output_tokens,
|
||||
stop_sequences=configured.stop_sequences,
|
||||
|
|
@ -612,8 +659,9 @@ class DBSettings(HonchoSettings):
|
|||
POOL_PRE_PING: bool = True
|
||||
POOL_SIZE: Annotated[int, Field(default=10, gt=0, le=1000)] = 10
|
||||
MAX_OVERFLOW: Annotated[int, Field(default=20, ge=0, le=1000)] = 20
|
||||
POOL_TIMEOUT: Annotated[int, Field(default=30, gt=0, le=300)] = (
|
||||
30 # seconds (max 5 minutes)
|
||||
POOL_TIMEOUT: Annotated[int, Field(default=5, gt=0, le=300)] = (
|
||||
5 # seconds a pooled checkout may wait for a free connection (QueuePool
|
||||
# only; NullPool has no local queue wait)
|
||||
)
|
||||
POOL_RECYCLE: Annotated[int, Field(default=300, gt=0, le=7200)] = (
|
||||
300 # seconds (max 2 hours)
|
||||
|
|
@ -622,6 +670,13 @@ class DBSettings(HonchoSettings):
|
|||
SQL_DEBUG: bool = False
|
||||
TRACING: bool = False
|
||||
|
||||
# Per-connection establish timeout (seconds) passed to the driver, so a
|
||||
# single connection attempt fails fast instead of hanging when the server or
|
||||
# pooler is unreachable or stalled. Connection acquisition is a single
|
||||
# attempt with no retry; callers handle failure (the API surfaces it, the
|
||||
# deriver backs off and retries on a later poll).
|
||||
CONNECT_TIMEOUT_SECONDS: Annotated[int, Field(default=2, gt=0, le=60)] = 2
|
||||
|
||||
|
||||
class AuthSettings(HonchoSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="AUTH_", extra="ignore") # pyright: ignore
|
||||
|
|
@ -696,6 +751,13 @@ class EmbeddingSettings(HonchoSettings):
|
|||
VECTOR_DIMENSIONS: Annotated[int, Field(default=1536, gt=0)] = 1536
|
||||
MAX_INPUT_TOKENS: Annotated[int, Field(default=8192, gt=0)] = 8192
|
||||
MAX_TOKENS_PER_REQUEST: Annotated[int, Field(default=300_000, gt=0)] = 300_000
|
||||
# Caps concurrent message-embedding fan-out on the API request path (the
|
||||
# immediate-embed background task). The reconciler is unaffected.
|
||||
MAX_CONCURRENT_EMBEDDINGS: Annotated[int, Field(default=10, gt=0, le=100)] = 10
|
||||
# Caps in-flight immediate-embed background tasks per API process. When
|
||||
# saturated, message creation skips the fast path entirely and the
|
||||
# reconciler embeds on its next cycle. 0 disables the fast path.
|
||||
MAX_PENDING_EMBED_TASKS: Annotated[int, Field(default=50, ge=0)] = 50
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
|
@ -737,7 +799,34 @@ class DeriverSettings(HonchoSettings):
|
|||
POLLING_SLEEP_INTERVAL_SECONDS: Annotated[
|
||||
float, Field(default=1.0, gt=0.0, le=60.0)
|
||||
] = 1.0
|
||||
# Adaptive polling: when the queue is idle (or the loop is erroring) the
|
||||
# sleep interval grows from POLLING_SLEEP_INTERVAL_SECONDS toward
|
||||
# POLLING_SLEEP_MAX_INTERVAL_SECONDS by POLLING_BACKOFF_MULTIPLIER each
|
||||
# cycle, then snaps back to the base interval as soon as work is found.
|
||||
# Reduces steady-state query load against the (shared) DB/pooler.
|
||||
POLLING_BACKOFF_ENABLED: bool = True
|
||||
POLLING_SLEEP_MAX_INTERVAL_SECONDS: Annotated[
|
||||
float, Field(default=30.0, gt=0.0, le=300.0)
|
||||
] = 30.0
|
||||
POLLING_BACKOFF_MULTIPLIER: Annotated[
|
||||
float, Field(default=2.0, ge=1.0, le=10.0)
|
||||
] = 2.0
|
||||
# Sleep a uniform-random delay in [0, POLLING_STARTUP_JITTER_SECONDS] before
|
||||
# the first poll so instances that start together don't poll in lockstep.
|
||||
# Set to 0.0 to disable.
|
||||
POLLING_STARTUP_JITTER_SECONDS: Annotated[
|
||||
float, Field(default=30.0, ge=0.0, le=300.0)
|
||||
] = 30.0
|
||||
# Multiply every poll sleep by a random factor in [1 - ratio, 1 + ratio]
|
||||
# (0.5 -> [0.5x, 1.5x]) so poll loops don't re-converge over time. The
|
||||
# backoff schedule is unchanged; only the returned sleep is scattered. Set
|
||||
# to 0.0 to disable.
|
||||
POLLING_JITTER_RATIO: Annotated[float, Field(default=0.5, ge=0.0, le=1.0)] = 0.5
|
||||
STALE_SESSION_TIMEOUT_MINUTES: Annotated[int, Field(default=5, gt=0, le=1440)] = 5
|
||||
# Minimum (jittered) spacing between stale-work-unit cleanup runs
|
||||
STALE_WORK_UNIT_CLEANUP_INTERVAL_SECONDS: Annotated[
|
||||
float, Field(default=60.0, ge=0.0, le=3600.0)
|
||||
] = 60.0
|
||||
|
||||
# Retention window (seconds) for keeping errored items in the queue
|
||||
QUEUE_ERROR_RETENTION_SECONDS: Annotated[
|
||||
|
|
@ -772,10 +861,28 @@ class DeriverSettings(HonchoSettings):
|
|||
int, Field(default=100, gt=0, le=1000)
|
||||
] = 100
|
||||
|
||||
REPRESENTATION_BATCH_MAX_TOKENS: Annotated[
|
||||
# Minimum tokens a representation work unit must accumulate (summed over
|
||||
# its own unprocessed messages) before it becomes claimable. Bypassed by
|
||||
# FLUSH_ENABLED and by REPRESENTATION_BATCH_MAX_AGE_SECONDS age-flushing.
|
||||
# 0 disables the accumulation gate entirely (equivalent to FLUSH_ENABLED
|
||||
# for claiming): work units are claimable as soon as anything is pending.
|
||||
REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS: Annotated[
|
||||
int,
|
||||
Field(default=512, ge=0, le=16_384),
|
||||
] = 512
|
||||
# Cumulative-token cap on the conversation window (queued messages plus
|
||||
# interleaved context) fed to a single deriver LLM call when draining a
|
||||
# claimed work unit. The first unprocessed message is always included,
|
||||
# even if it alone exceeds the cap.
|
||||
REPRESENTATION_BATCH_TARGET_INPUT_TOKENS: Annotated[
|
||||
int,
|
||||
Field(default=1024, ge=128, le=16_384),
|
||||
] = 1024
|
||||
# Sub-threshold work units become eligible once their oldest unprocessed
|
||||
# item exceeds this age. 0 disables age-based flushing.
|
||||
REPRESENTATION_BATCH_MAX_AGE_SECONDS: Annotated[int, Field(default=1800, ge=0)] = (
|
||||
1800
|
||||
)
|
||||
|
||||
# When enabled, bypasses the batch token threshold and processes work immediately
|
||||
FLUSH_ENABLED: bool = False
|
||||
|
|
@ -793,9 +900,9 @@ class DeriverSettings(HonchoSettings):
|
|||
|
||||
@model_validator(mode="after")
|
||||
def validate_batch_tokens_vs_context_limit(self):
|
||||
if self.REPRESENTATION_BATCH_MAX_TOKENS > self.MAX_INPUT_TOKENS:
|
||||
if self.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS > self.MAX_INPUT_TOKENS:
|
||||
raise ValueError(
|
||||
f"REPRESENTATION_BATCH_MAX_TOKENS ({self.REPRESENTATION_BATCH_MAX_TOKENS}) cannot exceed max deriver input tokens ({self.MAX_INPUT_TOKENS})"
|
||||
f"REPRESENTATION_BATCH_TARGET_INPUT_TOKENS ({self.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS}) cannot exceed max deriver input tokens ({self.MAX_INPUT_TOKENS})"
|
||||
)
|
||||
return self
|
||||
|
||||
|
|
@ -1086,12 +1193,29 @@ class TelemetrySettings(HonchoSettings):
|
|||
# that join high-volume events to aggregate envelopes first.
|
||||
HIGH_VOLUME_SAMPLE_RATE: Annotated[float, Field(default=1.0, ge=0.0, le=1.0)] = 1.0
|
||||
|
||||
# --- Full-fidelity payload tracing (llm.call.traced / trace.content) ---
|
||||
# Master toggle for replay-grade content capture. Default-off.
|
||||
TRACE_PAYLOADS_ENABLED: bool = False
|
||||
|
||||
# Per-message cap (bytes) for captured content; oversized string content is
|
||||
# clipped (with a marker) and the call is flagged was_truncated.
|
||||
TRACE_MAX_BYTES: Annotated[int, Field(default=262144, gt=0)] = 262144
|
||||
|
||||
# Allowlist of CallPurpose values to capture; empty = all. Typed as str to
|
||||
# keep the enum out of config (validated against CallPurpose at the producer,
|
||||
# same pattern as LLMTelemetryContext.call_purpose).
|
||||
TRACE_PURPOSES: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CacheSettings(HonchoSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="CACHE_", extra="ignore") # pyright: ignore
|
||||
|
||||
ENABLED: bool = False
|
||||
URL: str = "redis://localhost:6379/0?suppress=true"
|
||||
# URL points at a Redis Cluster (OSS cluster protocol, e.g. GCP Memorystore
|
||||
# for Redis Cluster). A standalone client cannot follow the MOVED redirects
|
||||
# such deployments return for keys hashed to another shard.
|
||||
CLUSTER: bool = False
|
||||
NAMESPACE: str | None = None
|
||||
DEFAULT_TTL_SECONDS: Annotated[int, Field(default=300, ge=1, le=86_400)] = (
|
||||
300 # how long to keep items in cache
|
||||
|
|
@ -1101,6 +1225,12 @@ class CacheSettings(HonchoSettings):
|
|||
5 # how long to hold a lock on a resource when fetching DB after cache miss
|
||||
)
|
||||
|
||||
# Polling interval while waiting for another worker's fetch lock. cashews
|
||||
# defaults to 0, which busy-spins the event loop for the whole wait.
|
||||
LOCK_WAIT_CHECK_INTERVAL_SECONDS: Annotated[
|
||||
float, Field(default=0.1, gt=0, le=5)
|
||||
] = 0.1
|
||||
|
||||
|
||||
class SurprisalSettings(BaseModel):
|
||||
"""Settings for tree-based surprisal sampling during dreams."""
|
||||
|
|
@ -1264,6 +1394,17 @@ class VectorStoreSettings(HonchoSettings):
|
|||
return self
|
||||
|
||||
|
||||
class TraceViewerSettings(HonchoSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="TRACE_VIEWER_", extra="ignore") # pyright: ignore
|
||||
|
||||
ENABLED: bool = False
|
||||
HOST: str = "127.0.0.1"
|
||||
PORT: int = 8002
|
||||
STORAGE_DIR: str = "./traces"
|
||||
MAX_REQUEST_BYTES: int = 10 * 1024 * 1024 # 10 MB
|
||||
VENDOR_CDN_BASE: str = "https://cdn.jsdelivr.net/npm"
|
||||
|
||||
|
||||
class AppSettings(HonchoSettings):
|
||||
# No env_prefix for app-level settings
|
||||
model_config = SettingsConfigDict( # pyright: ignore
|
||||
|
|
@ -1272,6 +1413,7 @@ class AppSettings(HonchoSettings):
|
|||
|
||||
# Application-wide settings
|
||||
LOG_LEVEL: str = "INFO"
|
||||
PERFORMANCE_LOG_FORMAT: str = "compact"
|
||||
SESSION_OBSERVERS_LIMIT: Annotated[int, Field(default=10, gt=0)] = 10
|
||||
MAX_FILE_SIZE: Annotated[int, Field(default=5_242_880, gt=0)] = 5_242_880 # 5MB
|
||||
GET_CONTEXT_MAX_TOKENS: Annotated[int, Field(default=100_000, gt=0, le=250_000)] = (
|
||||
|
|
@ -1282,6 +1424,36 @@ class AppSettings(HonchoSettings):
|
|||
EMBED_MESSAGES: bool = True
|
||||
LANGFUSE_HOST: str | None = None
|
||||
LANGFUSE_PUBLIC_KEY: str | None = None
|
||||
# How Langfuse traces are produced:
|
||||
# "exporter" (default) — Langfuse is a projection over the captured
|
||||
# CapturedLLMCall stream (LangfuseExporter), the same source of truth as
|
||||
# the CloudEvents trace stream.
|
||||
# "inline" — legacy live instrumentation (@observe + propagate_attributes
|
||||
# spans during execution). Kept one release for side-by-side validation.
|
||||
LANGFUSE_EXPORTER_MODE: Literal["inline", "exporter"] = "exporter"
|
||||
|
||||
@property
|
||||
def langfuse_inline_enabled(self) -> bool:
|
||||
"""True when the legacy inline Langfuse instrumentation is active
|
||||
(keys configured + ``LANGFUSE_EXPORTER_MODE == "inline"``)."""
|
||||
return (
|
||||
bool(self.LANGFUSE_PUBLIC_KEY) and self.LANGFUSE_EXPORTER_MODE == "inline"
|
||||
)
|
||||
|
||||
@property
|
||||
def langfuse_exporter_enabled(self) -> bool:
|
||||
"""True when the Langfuse exporter (a projection over the captured call
|
||||
stream) is active (keys configured + ``LANGFUSE_EXPORTER_MODE == "exporter"``)."""
|
||||
return (
|
||||
bool(self.LANGFUSE_PUBLIC_KEY) and self.LANGFUSE_EXPORTER_MODE == "exporter"
|
||||
)
|
||||
|
||||
# Origins allowed by the FastAPI CORSMiddleware
|
||||
CORS_ORIGINS: list[str] = [
|
||||
"http://localhost",
|
||||
"http://127.0.0.1:8000",
|
||||
"https://api.honcho.dev",
|
||||
]
|
||||
|
||||
COLLECT_METRICS_LOCAL: bool = False
|
||||
LOCAL_METRICS_FILE: str = "metrics.jsonl"
|
||||
|
|
@ -1305,6 +1477,7 @@ class AppSettings(HonchoSettings):
|
|||
CACHE: CacheSettings = Field(default_factory=CacheSettings)
|
||||
DREAM: DreamSettings = Field(default_factory=DreamSettings)
|
||||
VECTOR_STORE: VectorStoreSettings = Field(default_factory=VectorStoreSettings)
|
||||
TRACE_VIEWER: TraceViewerSettings = Field(default_factory=TraceViewerSettings)
|
||||
|
||||
@field_validator("LOG_LEVEL")
|
||||
def validate_log_level(cls, v: str) -> str:
|
||||
|
|
@ -1313,6 +1486,13 @@ class AppSettings(HonchoSettings):
|
|||
raise ValueError(f"Invalid log level: {v}")
|
||||
return log_level
|
||||
|
||||
@field_validator("PERFORMANCE_LOG_FORMAT")
|
||||
def validate_performance_log_format(cls, v: str) -> str:
|
||||
log_format = v.lower()
|
||||
if log_format not in ["compact", "rich"]:
|
||||
raise ValueError(f"Invalid performance log format: {v}")
|
||||
return log_format
|
||||
|
||||
@model_validator(mode="after")
|
||||
def propagate_namespace(self) -> "AppSettings":
|
||||
"""Propagate top-level NAMESPACE to nested settings if not explicitly set."""
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from .collection import (
|
|||
)
|
||||
from .deriver import get_deriver_status, get_queue_status
|
||||
from .document import (
|
||||
CreateDocumentsResult,
|
||||
create_documents,
|
||||
create_observations,
|
||||
delete_document,
|
||||
|
|
@ -83,6 +84,7 @@ __all__ = [
|
|||
"get_deriver_status",
|
||||
"get_queue_status",
|
||||
# Document
|
||||
"CreateDocumentsResult",
|
||||
"create_documents",
|
||||
"create_observations",
|
||||
"fetch_documents_by_ids",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ def collection_cache_key(workspace_name: str, observer: str, observed: str) -> s
|
|||
key=COLLECTION_CACHE_KEY_TEMPLATE,
|
||||
ttl=f"{settings.CACHE.DEFAULT_LOCK_TTL_SECONDS}s",
|
||||
prefix=COLLECTION_LOCK_PREFIX,
|
||||
check_interval=settings.CACHE.LOCK_WAIT_CHECK_INTERVAL_SECONDS,
|
||||
)
|
||||
async def _fetch_collection(
|
||||
db: AsyncSession,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import datetime
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from logging import getLogger
|
||||
from typing import Any, cast
|
||||
|
||||
|
|
@ -176,7 +178,8 @@ async def query_documents_most_derived(
|
|||
limit: Maximum number of documents to return
|
||||
|
||||
Returns:
|
||||
Sequence of documents ordered by times_derived descending
|
||||
Sequence of documents ordered by times_derived descending,
|
||||
ties broken by created_at descending (most recent first)
|
||||
"""
|
||||
stmt = (
|
||||
select(models.Document)
|
||||
|
|
@ -186,7 +189,13 @@ async def query_documents_most_derived(
|
|||
models.Document.observed == observed,
|
||||
models.Document.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(models.Document.times_derived.desc())
|
||||
.order_by(
|
||||
models.Document.times_derived.desc(),
|
||||
models.Document.created_at.desc(),
|
||||
# created_at is the transaction timestamp, so documents created in
|
||||
# the same batch share it -- id keeps the order deterministic.
|
||||
models.Document.id,
|
||||
)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
|
|
@ -369,7 +378,7 @@ async def query_documents(
|
|||
max_distance,
|
||||
top_k,
|
||||
)
|
||||
async with tracked_db("query_documents.pgvector") as managed_db:
|
||||
async with tracked_db("query_documents.pgvector", read_only=True) as managed_db:
|
||||
docs = await _query_documents_pgvector(
|
||||
managed_db,
|
||||
workspace_name,
|
||||
|
|
@ -407,7 +416,7 @@ async def query_documents(
|
|||
document_ids=document_ids,
|
||||
filters=filters,
|
||||
)
|
||||
async with tracked_db("query_documents.fetch") as managed_db:
|
||||
async with tracked_db("query_documents.fetch", read_only=True) as managed_db:
|
||||
docs = await fetch_documents_by_ids(
|
||||
db=managed_db,
|
||||
workspace_name=workspace_name,
|
||||
|
|
@ -421,6 +430,52 @@ async def query_documents(
|
|||
return docs
|
||||
|
||||
|
||||
def _normalize_content(content: str) -> str:
|
||||
"""Normalize document content for exact-match deduplication.
|
||||
|
||||
Content is compared after trimming surrounding whitespace and lowercasing
|
||||
|
||||
The SQL filter in ``create_documents`` must stay in sync with this:
|
||||
``lower(regexp_replace(content, '^\\s+|\\s+$', '', 'g'))``. Postgres'
|
||||
``trim()`` only strips spaces, so a regex is used to match Python's
|
||||
``str.strip()`` across all whitespace.
|
||||
"""
|
||||
return content.strip().lower()
|
||||
|
||||
|
||||
def _dedup_key(
|
||||
content: str, level: str, session_name: str | None
|
||||
) -> tuple[str, str, str | None]:
|
||||
"""Build the exact-match dedup key for a document.
|
||||
|
||||
Dedup never crosses levels: a same-content document at a different level is
|
||||
a different kind of record (an explicit fact is not interchangeable with a
|
||||
deductive conclusion that happens to share its text).
|
||||
|
||||
For **explicit** documents dedup additionally never crosses sessions.
|
||||
Explicit documents are session-pure records of what was derived from that
|
||||
session's messages — the Scopes copy-by-session model depends on this — so
|
||||
a repeat of the same fact in a different session must produce a new
|
||||
document in that session rather than reinforce another session's row.
|
||||
Derived levels (deductive/inductive/contradiction) are consolidations and
|
||||
may still dedup across sessions.
|
||||
"""
|
||||
return (
|
||||
_normalize_content(content),
|
||||
level,
|
||||
session_name if level == "explicit" else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateDocumentsResult:
|
||||
created_documents: list[schemas.DocumentCreate] = field(default_factory=list)
|
||||
exact_dup_in_batch_count: int = 0
|
||||
exact_dup_existing_count: int = 0
|
||||
semantic_dup_rejected_count: int = 0
|
||||
semantic_dup_replaced_count: int = 0
|
||||
|
||||
|
||||
async def create_documents(
|
||||
db: AsyncSession,
|
||||
documents: list[schemas.DocumentCreate],
|
||||
|
|
@ -429,16 +484,21 @@ async def create_documents(
|
|||
observer: str,
|
||||
observed: str,
|
||||
deduplicate: bool = False,
|
||||
) -> list[schemas.DocumentCreate]:
|
||||
) -> CreateDocumentsResult:
|
||||
"""
|
||||
Create multiple documents with optional duplicate detection.
|
||||
|
||||
The ``deduplicate`` flag additionally enables semantic (cosine-similarity)
|
||||
dedup via ``is_rejected_duplicate`` for documents that survive the exact
|
||||
deduplication check.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
documents: List of document creation schemas
|
||||
workspace_name: Name of the workspace
|
||||
observer: Name of the observing peer
|
||||
observed: Name of the observed peer
|
||||
deduplicate: Enable semantic duplicate detection
|
||||
|
||||
Returns:
|
||||
List of DocumentCreate schemas that were actually inserted (excludes
|
||||
|
|
@ -449,16 +509,116 @@ async def create_documents(
|
|||
# Store (document_model, embedding) pairs - IDs aren't available until after commit
|
||||
docs_with_embeddings: list[tuple[models.Document, list[float]]] = []
|
||||
|
||||
# exact-content dedup (independent of `deduplicate`): pre-fetch
|
||||
# existing live documents whose normalized content matches anything in this
|
||||
# batch, scoped to (workspace, observer, observed). The SQL normalization must
|
||||
# mirror _normalize_content. Matching is further scoped per-document by
|
||||
# level (always) and session (for explicit documents) via _dedup_key.
|
||||
batch_normalized: set[str] = {_normalize_content(d.content) for d in documents}
|
||||
existing_by_key: dict[tuple[str, str, str | None], models.Document] = {}
|
||||
if batch_normalized:
|
||||
# The `normalized_content_sql.in_(...)` filter below narrows to the
|
||||
# (workspace, observer, observed) partition via the single-column indexes,
|
||||
# then evaluates lower(regexp_replace(...)) per row.
|
||||
# TODO: add a partial expression index matching
|
||||
# this filter exactly
|
||||
# CREATE INDEX ix_documents_normalized_content
|
||||
# ON documents (
|
||||
# workspace_name,
|
||||
# observer,
|
||||
# observed,
|
||||
# (lower(regexp_replace(content, '^\s+|\s+$', '', 'g')))
|
||||
# )
|
||||
# WHERE deleted_at IS NULL;
|
||||
normalized_content_sql = func.lower(
|
||||
func.regexp_replace(models.Document.content, r"^\s+|\s+$", "", "g")
|
||||
)
|
||||
existing_result = await db.execute(
|
||||
select(models.Document).where(
|
||||
models.Document.workspace_name == workspace_name,
|
||||
models.Document.observer == observer,
|
||||
models.Document.observed == observed,
|
||||
models.Document.deleted_at.is_(None),
|
||||
normalized_content_sql.in_(batch_normalized),
|
||||
)
|
||||
)
|
||||
for existing_doc in existing_result.scalars():
|
||||
# If multiple historical rows share a dedup key, reinforcing
|
||||
# one is sufficient; keep the first.
|
||||
existing_by_key.setdefault(
|
||||
_dedup_key(
|
||||
existing_doc.content,
|
||||
existing_doc.level,
|
||||
existing_doc.session_name,
|
||||
),
|
||||
existing_doc,
|
||||
)
|
||||
|
||||
# Tracks dedup keys already accepted from this batch so exact
|
||||
# duplicates within a single inference call collapse to one document.
|
||||
seen_in_batch: set[tuple[str, str, str | None]] = set()
|
||||
|
||||
exact_dup_existing_count = 0
|
||||
exact_dup_in_batch_count = 0
|
||||
semantic_dup_rejected_count = 0
|
||||
semantic_dup_replaced_count = 0
|
||||
for doc in documents:
|
||||
try:
|
||||
# Session-purity invariant: an explicit document must always carry
|
||||
# the session it was derived from. Refuse to write session-less
|
||||
# explicit documents rather than silently minting global explicit
|
||||
# memory (the Scopes copy-by-session model depends on explicit
|
||||
# documents staying session-pure).
|
||||
if doc.level == "explicit" and doc.session_name is None:
|
||||
logger.error(
|
||||
"Refusing to create explicit document without session_name in %s/%s/%s (session-purity invariant): %r",
|
||||
workspace_name,
|
||||
observer,
|
||||
observed,
|
||||
doc.content[:80],
|
||||
)
|
||||
continue
|
||||
|
||||
dedup_key = _dedup_key(doc.content, doc.level, doc.session_name)
|
||||
|
||||
# Exact-match dedup, always on:
|
||||
# 1) collapse exact duplicates within this batch (drop silently).
|
||||
if dedup_key in seen_in_batch:
|
||||
exact_dup_in_batch_count += 1
|
||||
continue
|
||||
seen_in_batch.add(dedup_key)
|
||||
|
||||
# 2) drop exact duplicates of an existing live document, recording
|
||||
# the re-derivation as reinforcement on the existing row.
|
||||
existing_match = existing_by_key.get(dedup_key)
|
||||
if existing_match is not None:
|
||||
# Reinforce the existing row. greatest(...) keeps the bump atomic
|
||||
# server-side (concurrent workers can't lose an increment) while
|
||||
# still honoring an incoming doc that already carries accumulated
|
||||
# reinforcement (times_derived > 1, e.g. a future re-ingestion or
|
||||
# collection-merge path). Mirrors the superior-replacement branch
|
||||
# in is_rejected_duplicate.
|
||||
existing_match.times_derived = func.greatest(
|
||||
models.Document.times_derived + 1,
|
||||
doc.times_derived,
|
||||
)
|
||||
await db.flush()
|
||||
exact_dup_existing_count += 1
|
||||
continue
|
||||
|
||||
# for each document, if deduplicate is True, perform a process
|
||||
# that checks against existing documents and either rejects this document
|
||||
# as a duplicate OR deletes an existing document that is a duplicate.
|
||||
if deduplicate:
|
||||
is_duplicate = await is_rejected_duplicate(
|
||||
duplicate_result = await is_rejected_duplicate(
|
||||
db, doc, workspace_name, observer=observer, observed=observed
|
||||
)
|
||||
if is_duplicate:
|
||||
if duplicate_result is SemanticRejectionResult.REPLACED_EXISTING:
|
||||
# Existing doc was soft-deleted in favor of this one; the
|
||||
# new doc still gets inserted below.
|
||||
semantic_dup_replaced_count += 1
|
||||
elif duplicate_result is SemanticRejectionResult.REJECTED:
|
||||
semantic_dup_rejected_count += 1
|
||||
continue
|
||||
|
||||
metadata_dict = doc.metadata.model_dump(exclude_none=True)
|
||||
|
|
@ -610,7 +770,13 @@ async def create_documents(
|
|||
"Failed to create documents due to integrity constraint violation"
|
||||
) from e
|
||||
|
||||
return accepted_documents
|
||||
return CreateDocumentsResult(
|
||||
created_documents=accepted_documents,
|
||||
exact_dup_existing_count=exact_dup_existing_count,
|
||||
exact_dup_in_batch_count=exact_dup_in_batch_count,
|
||||
semantic_dup_rejected_count=semantic_dup_rejected_count,
|
||||
semantic_dup_replaced_count=semantic_dup_replaced_count,
|
||||
)
|
||||
|
||||
|
||||
async def delete_document(
|
||||
|
|
@ -960,6 +1126,12 @@ async def create_observations(
|
|||
return honcho_documents
|
||||
|
||||
|
||||
class SemanticRejectionResult(Enum):
|
||||
NOT_DUPLICATE = 0
|
||||
REPLACED_EXISTING = 1
|
||||
REJECTED = 2
|
||||
|
||||
|
||||
async def is_rejected_duplicate(
|
||||
db: AsyncSession,
|
||||
doc: schemas.DocumentCreate,
|
||||
|
|
@ -967,7 +1139,7 @@ async def is_rejected_duplicate(
|
|||
*,
|
||||
observer: str,
|
||||
observed: str,
|
||||
) -> bool:
|
||||
) -> SemanticRejectionResult:
|
||||
"""
|
||||
Check if a document is a duplicate of an existing document.
|
||||
|
||||
|
|
@ -980,8 +1152,27 @@ async def is_rejected_duplicate(
|
|||
If the document is not a duplicate, returns False.
|
||||
|
||||
If the document is a duplicate AND the new document is superior,
|
||||
deletes the existing document and returns False.
|
||||
deletes the existing document and returns False. In this case
|
||||
``doc.times_derived`` is updated in place to carry the replaced
|
||||
document's reinforcement count forward.
|
||||
|
||||
If the document is a duplicate AND the existing document is superior,
|
||||
increments the existing document's ``times_derived`` to record the
|
||||
reinforcement, then returns True.
|
||||
|
||||
Merges are scoped so they never cross document levels, and never cross
|
||||
sessions for explicit-level documents (session-purity invariant: an
|
||||
explicit document records what was derived from exactly one session, so
|
||||
a near-duplicate from another session must not reinforce or replace it).
|
||||
"""
|
||||
filters: dict[str, Any] = {"level": doc.level}
|
||||
if doc.level == "explicit":
|
||||
if doc.session_name is None:
|
||||
# create_documents refuses session-less explicit documents; if one
|
||||
# reaches here anyway it has no valid merge partner.
|
||||
return SemanticRejectionResult.NOT_DUPLICATE
|
||||
filters["session_name"] = doc.session_name
|
||||
|
||||
# Step 1: Find potential duplicates using cosine similarity
|
||||
similar_docs = await query_documents(
|
||||
db=db,
|
||||
|
|
@ -989,13 +1180,14 @@ async def is_rejected_duplicate(
|
|||
query=doc.content,
|
||||
observer=observer,
|
||||
observed=observed,
|
||||
filters=filters,
|
||||
max_distance=0.05,
|
||||
top_k=1,
|
||||
embedding=doc.embedding,
|
||||
)
|
||||
|
||||
if not similar_docs:
|
||||
return False
|
||||
return SemanticRejectionResult.NOT_DUPLICATE
|
||||
|
||||
existing_doc = similar_docs[0]
|
||||
|
||||
|
|
@ -1011,19 +1203,37 @@ async def is_rejected_duplicate(
|
|||
|
||||
# If new document has more or equal information, keep it and delete existing
|
||||
if score_new >= score_existing:
|
||||
logger.warning(
|
||||
f"[DUPLICATE DETECTION] Deleting existing in favor of new. new='{doc.content}', existing='{existing_doc.content}'."
|
||||
logger.debug(
|
||||
"[DUPLICATE DETECTION] Deleting existing in favor of new. new=%r, existing=%r.",
|
||||
doc.content,
|
||||
existing_doc.content,
|
||||
)
|
||||
# Carry the reinforcement count forward so replacing a duplicate counts as
|
||||
# another derivation rather than resetting times_derived to 1.
|
||||
doc.times_derived = max(doc.times_derived, existing_doc.times_derived + 1)
|
||||
# Soft-delete the existing document - reconciliation will clean up vectors and hard-delete
|
||||
existing_doc.deleted_at = datetime.datetime.now(datetime.timezone.utc)
|
||||
await db.flush()
|
||||
return False # Don't reject the new document
|
||||
return (
|
||||
SemanticRejectionResult.REPLACED_EXISTING
|
||||
) # Don't reject the new document
|
||||
|
||||
# Existing document has more information, reject the new one
|
||||
logger.warning(
|
||||
f"[DUPLICATE DETECTION] Rejecting new in favor of existing. new='{doc.content}', existing='{existing_doc.content}'."
|
||||
# Existing document has more information, reject the new one but record the
|
||||
# reinforcement: a semantic duplicate was derived again. greatest(...) keeps
|
||||
# the increment atomic server-side -- concurrent workers reinforcing the same
|
||||
# document must not lose updates -- while still honoring an incoming doc that
|
||||
# already carries accumulated reinforcement (times_derived > 1).
|
||||
existing_doc.times_derived = func.greatest(
|
||||
models.Document.times_derived + 1,
|
||||
doc.times_derived,
|
||||
)
|
||||
return True
|
||||
await db.flush()
|
||||
logger.debug(
|
||||
"[DUPLICATE DETECTION] Rejecting new in favor of existing. new=%r, existing=%r.",
|
||||
doc.content,
|
||||
existing_doc.content,
|
||||
)
|
||||
return SemanticRejectionResult.REJECTED
|
||||
|
||||
|
||||
async def cleanup_soft_deleted_documents(
|
||||
|
|
|
|||
|
|
@ -4,19 +4,18 @@ from logging import getLogger
|
|||
from typing import Any
|
||||
|
||||
from nanoid import generate as generate_nanoid
|
||||
from sqlalchemy import ColumnElement, Select, and_, func, or_, select, text, update
|
||||
from sqlalchemy import ColumnElement, Select, and_, func, or_, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models, schemas
|
||||
from src.config import settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.embedding_client import embedding_client
|
||||
from src.exceptions import VectorStoreError
|
||||
from src.telemetry.events import EmbeddingCallPurpose
|
||||
from src.utils.filter import apply_filter
|
||||
from src.utils.formatting import ILIKE_ESCAPE_CHAR, escape_ilike_pattern
|
||||
from src.utils.types import embedding_call_purpose
|
||||
from src.vector_store import VectorRecord, get_external_vector_store
|
||||
from src.vector_store import get_external_vector_store
|
||||
|
||||
from .session import get_or_create_session
|
||||
|
||||
|
|
@ -56,11 +55,28 @@ async def get_peer_session_names(
|
|||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
peer_name: str,
|
||||
*,
|
||||
active_only: bool = False,
|
||||
) -> list[str]:
|
||||
"""Get all session names where a peer has any membership record.
|
||||
"""Get all session names where a peer has a membership record.
|
||||
|
||||
Any membership record (regardless of joined_at/left_at) grants visibility
|
||||
to all messages in that session.
|
||||
By default any membership record (regardless of joined_at/left_at) grants
|
||||
visibility to all messages in that session — this is the loose definition
|
||||
recall scoping uses.
|
||||
|
||||
Pass ``active_only=True`` for the strict definition (``left_at IS NULL``),
|
||||
matching :func:`src.crud.session.is_peer_in_session`. The auth layer must
|
||||
use the strict one so that a single peer-scoped key gets the same answer
|
||||
whether it names a session directly or via a filter allowlist.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
peer_name: Name of the peer
|
||||
active_only: Restrict to sessions the peer has not left
|
||||
|
||||
Returns:
|
||||
Distinct session names the peer has a matching membership record in.
|
||||
"""
|
||||
stmt = (
|
||||
select(models.session_peers_table.c.session_name)
|
||||
|
|
@ -68,10 +84,80 @@ async def get_peer_session_names(
|
|||
.where(models.session_peers_table.c.peer_name == peer_name)
|
||||
.distinct()
|
||||
)
|
||||
if active_only:
|
||||
stmt = stmt.where(models.session_peers_table.c.left_at.is_(None))
|
||||
result = await db.execute(stmt)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
async def resolve_session_scope(
|
||||
db: AsyncSession | None,
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
session_allowlist: list[str] | None,
|
||||
observer: str | None,
|
||||
*,
|
||||
operation_name: str = "resolve_session_scope",
|
||||
) -> tuple[list[str] | None, bool]:
|
||||
"""Resolve the effective session scope for a message query.
|
||||
|
||||
Returns ``(allowed_session_names, deny)``:
|
||||
|
||||
- ``allowed_session_names is None`` — apply no allowlist filter. Either the
|
||||
query is unrestricted, or ``session_name`` already pins it to one session.
|
||||
- a populated list — restrict the query to exactly these sessions.
|
||||
- ``deny=True`` — the caller must return an empty result *without* querying.
|
||||
|
||||
The distinction between ``None`` and an empty list is load-bearing: the
|
||||
external vector stores drop an empty ``IN`` clause rather than matching
|
||||
nothing, so collapsing the two would fail open. This function therefore
|
||||
never returns an empty list — it returns ``deny=True`` instead.
|
||||
|
||||
Touches the database only when an observer lookup is actually required, so
|
||||
callers on the external-vector-store path don't check out a connection
|
||||
before their network call.
|
||||
|
||||
Args:
|
||||
db: Database session to reuse. Pass None to let this function open its
|
||||
own short-lived read-only session if (and only if) it needs one.
|
||||
workspace_name: Name of the workspace
|
||||
session_name: A single pinned session, if the caller named one
|
||||
session_allowlist: Optional session allowlist. ``None`` is unrestricted;
|
||||
an empty list fails closed.
|
||||
observer: When set, scope is limited to this peer's sessions and then
|
||||
intersected with ``session_allowlist``
|
||||
operation_name: Label for the self-managed DB session, when one is opened
|
||||
|
||||
Returns:
|
||||
Tuple of (allowlist to filter on or None, whether to deny outright).
|
||||
"""
|
||||
if session_name:
|
||||
# A specific session was requested. Fail closed when the allowlist
|
||||
# forbids it — routes guard this too, but other CRUD callers (the
|
||||
# dialectic tools) don't, so enforce it at the boundary.
|
||||
if session_allowlist is not None and session_name not in session_allowlist:
|
||||
return None, True
|
||||
return None, False
|
||||
|
||||
if observer is None:
|
||||
if session_allowlist is None:
|
||||
return None, False
|
||||
allowed = list(session_allowlist)
|
||||
return (allowed, False) if allowed else (None, True)
|
||||
|
||||
if db is not None:
|
||||
allowed = await get_peer_session_names(db, workspace_name, observer)
|
||||
else:
|
||||
async with tracked_db(f"{operation_name}.peer_scope", read_only=True) as own_db:
|
||||
allowed = await get_peer_session_names(own_db, workspace_name, observer)
|
||||
|
||||
if session_allowlist is not None:
|
||||
scope = set(session_allowlist)
|
||||
allowed = [s for s in allowed if s in scope]
|
||||
|
||||
return (allowed, False) if allowed else (None, True)
|
||||
|
||||
|
||||
def _apply_token_limit(
|
||||
base_conditions: list[ColumnElement[Any]], token_limit: int
|
||||
) -> Select[tuple[models.Message]]:
|
||||
|
|
@ -276,158 +362,37 @@ async def create_messages(
|
|||
|
||||
db.add_all(message_objects)
|
||||
|
||||
# Commit here to release the advisory lock before generating embeddings
|
||||
await db.commit()
|
||||
try:
|
||||
if settings.EMBED_MESSAGES:
|
||||
id_resource_dict = {
|
||||
message.public_id: message.content
|
||||
for message in message_objects
|
||||
if message.content and message.content.strip()
|
||||
}
|
||||
if id_resource_dict:
|
||||
with embedding_call_purpose(
|
||||
EmbeddingCallPurpose.MESSAGE_CREATE.value,
|
||||
workspace_name=workspace_name,
|
||||
parent_category="api",
|
||||
):
|
||||
embedding_dict = await embedding_client.batch_embed(
|
||||
id_resource_dict
|
||||
)
|
||||
else:
|
||||
embedding_dict = {}
|
||||
|
||||
external_vector_store = get_external_vector_store()
|
||||
|
||||
# Determine if we need to persist embeddings to postgres
|
||||
# True when: TYPE=pgvector OR still migrating (dual-write to both stores)
|
||||
store_embeddings_in_postgres = (
|
||||
settings.VECTOR_STORE.TYPE == "pgvector"
|
||||
or not settings.VECTOR_STORE.MIGRATED
|
||||
)
|
||||
|
||||
# Create MessageEmbedding entries
|
||||
embedding_objects: list[models.MessageEmbedding] = []
|
||||
# Maps emb index -> (chunk_position, embedding vector)
|
||||
pending_embedding_data: dict[int, tuple[int, list[float]]] = {}
|
||||
# If embedding is enabled, locally chunk the content and insert
|
||||
# one pending MessageEmbedding row per chunk in chunk order. The actual
|
||||
# embedding work is deferred to the reconciler
|
||||
if settings.EMBED_MESSAGES:
|
||||
id_resource_dict = {
|
||||
message_obj.public_id: message_obj.content
|
||||
for message_obj in message_objects
|
||||
if message_obj.content and message_obj.content.strip()
|
||||
}
|
||||
if id_resource_dict:
|
||||
chunks_by_id = embedding_client.prepare_chunks(id_resource_dict)
|
||||
peer_by_id = {m.public_id: m.peer_name for m in message_objects}
|
||||
pending_rows: list[models.MessageEmbedding] = []
|
||||
for message_obj in message_objects:
|
||||
embeddings = embedding_dict.get(message_obj.public_id, [])
|
||||
for chunk_position, embedding in enumerate(embeddings):
|
||||
embedding_obj = models.MessageEmbedding(
|
||||
content=message_obj.content,
|
||||
message_id=message_obj.public_id,
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
peer_name=message_obj.peer_name,
|
||||
sync_state="pending",
|
||||
embedding=embedding if store_embeddings_in_postgres else None,
|
||||
)
|
||||
emb_idx = len(embedding_objects)
|
||||
pending_embedding_data[emb_idx] = (chunk_position, embedding)
|
||||
embedding_objects.append(embedding_obj)
|
||||
|
||||
# Always create MessageEmbedding rows so reconciliation can track sync state
|
||||
# even when embeddings aren't stored in postgres
|
||||
embedding_ids: list[int] = []
|
||||
if embedding_objects:
|
||||
db.add_all(embedding_objects)
|
||||
await db.flush()
|
||||
embedding_ids = [emb.id for emb in embedding_objects]
|
||||
|
||||
await db.commit()
|
||||
|
||||
# If no external vector store (pgvector-only mode), mark as synced immediately
|
||||
if external_vector_store is None:
|
||||
if embedding_ids:
|
||||
await db.execute(
|
||||
update(models.MessageEmbedding)
|
||||
.where(models.MessageEmbedding.id.in_(embedding_ids))
|
||||
.values(
|
||||
sync_state="synced",
|
||||
last_sync_at=func.now(),
|
||||
sync_attempts=0,
|
||||
chunks = chunks_by_id.get(message_obj.public_id, [])
|
||||
for chunk_text in chunks:
|
||||
pending_rows.append(
|
||||
models.MessageEmbedding(
|
||||
content=chunk_text,
|
||||
message_id=message_obj.public_id,
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
peer_name=peer_by_id[message_obj.public_id],
|
||||
sync_state="pending",
|
||||
embedding=None,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
else:
|
||||
# External vector store - build and upsert vector records
|
||||
namespace = external_vector_store.get_vector_namespace(
|
||||
"message", workspace_name
|
||||
)
|
||||
if pending_rows:
|
||||
db.add_all(pending_rows)
|
||||
|
||||
# Build vector records with {message_id}_{chunk_position} as vector ID
|
||||
vector_records: list[VectorRecord] = []
|
||||
for emb_idx, emb in enumerate(embedding_objects):
|
||||
chunk_position, embedding = pending_embedding_data[emb_idx]
|
||||
vector_id = f"{emb.message_id}_{chunk_position}"
|
||||
vector_records.append(
|
||||
VectorRecord(
|
||||
id=vector_id,
|
||||
embedding=list(embedding),
|
||||
metadata={
|
||||
"message_id": emb.message_id,
|
||||
"session_name": emb.session_name,
|
||||
"peer_name": emb.peer_name,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Upsert to external vector store and update sync state
|
||||
if vector_records:
|
||||
try:
|
||||
await external_vector_store.upsert_many(
|
||||
namespace, vector_records
|
||||
)
|
||||
# Success: mark as synced if we have DB rows
|
||||
if embedding_ids:
|
||||
await db.execute(
|
||||
update(models.MessageEmbedding)
|
||||
.where(models.MessageEmbedding.id.in_(embedding_ids))
|
||||
.values(
|
||||
sync_state="synced",
|
||||
last_sync_at=func.now(),
|
||||
sync_attempts=0,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
except VectorStoreError:
|
||||
logger.warning(
|
||||
"Vector store unavailable; leaving message vectors unsynced"
|
||||
)
|
||||
if embedding_ids:
|
||||
await db.execute(
|
||||
update(models.MessageEmbedding)
|
||||
.where(models.MessageEmbedding.id.in_(embedding_ids))
|
||||
.values(
|
||||
sync_attempts=models.MessageEmbedding.sync_attempts
|
||||
+ 1,
|
||||
last_sync_at=func.now(),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
except Exception:
|
||||
logger.exception("Unexpected error upserting message vectors")
|
||||
if embedding_ids:
|
||||
await db.execute(
|
||||
update(models.MessageEmbedding)
|
||||
.where(models.MessageEmbedding.id.in_(embedding_ids))
|
||||
.values(
|
||||
sync_attempts=models.MessageEmbedding.sync_attempts
|
||||
+ 1,
|
||||
last_sync_at=func.now(),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to generate message embeddings for %s messages in workspace %s and session %s.",
|
||||
len(message_objects),
|
||||
workspace_name,
|
||||
session_name,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return message_objects
|
||||
|
||||
|
|
@ -770,6 +735,9 @@ async def _search_messages_pgvector(
|
|||
models.MessageEmbedding,
|
||||
models.Message.public_id == models.MessageEmbedding.message_id,
|
||||
)
|
||||
# Exclude pending rows that haven't been embedded yet: their NULL
|
||||
# distance sorts last and would pad the window with unranked messages.
|
||||
.where(models.MessageEmbedding.embedding.isnot(None))
|
||||
.where(models.MessageEmbedding.workspace_name == workspace_name)
|
||||
.order_by(models.MessageEmbedding.embedding.cosine_distance(query_embedding))
|
||||
.limit(limit * 2)
|
||||
|
|
@ -808,21 +776,29 @@ async def _semantic_search_messages(
|
|||
after_date: datetime | None = None,
|
||||
before_date: datetime | None = None,
|
||||
observer: str | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
"""Run semantic message search with optional temporal filters.
|
||||
|
||||
When observer is provided and session_name is None, results are
|
||||
scoped to sessions the observer has any membership record in.
|
||||
scoped to sessions the observer has any membership record in. When
|
||||
session_allowlist is provided, that membership scope is further
|
||||
intersected with the allowlist (fail-closed: empty result on empty
|
||||
intersection).
|
||||
"""
|
||||
# Pre-fetch peer session scope if needed (short-lived DB session)
|
||||
allowed_session_names: list[str] | None = None
|
||||
if observer and not session_name:
|
||||
async with tracked_db(f"{operation_name}.peer_scope") as db:
|
||||
allowed_session_names = await get_peer_session_names(
|
||||
db, workspace_name, observer
|
||||
)
|
||||
if not allowed_session_names:
|
||||
return []
|
||||
# db=None: the helper opens its own short-lived session only if it needs
|
||||
# an observer lookup, so the external-store path below stays the first
|
||||
# thing that happens when no observer scoping applies.
|
||||
allowed_session_names, deny = await resolve_session_scope(
|
||||
None,
|
||||
workspace_name,
|
||||
session_name,
|
||||
session_allowlist,
|
||||
observer,
|
||||
operation_name=operation_name,
|
||||
)
|
||||
if deny:
|
||||
return []
|
||||
|
||||
if settings.VECTOR_STORE.TYPE != "pgvector" and settings.VECTOR_STORE.MIGRATED:
|
||||
message_ids = await _search_messages_external(
|
||||
|
|
@ -837,7 +813,7 @@ async def _semantic_search_messages(
|
|||
if not message_ids:
|
||||
return []
|
||||
|
||||
async with tracked_db(operation_name) as db:
|
||||
async with tracked_db(operation_name, read_only=True) as db:
|
||||
matched_messages = (
|
||||
await _fetch_messages_by_ids(
|
||||
db,
|
||||
|
|
@ -853,7 +829,7 @@ async def _semantic_search_messages(
|
|||
_expunge_snippets(db, snippets)
|
||||
return snippets
|
||||
|
||||
async with tracked_db(operation_name) as db:
|
||||
async with tracked_db(operation_name, read_only=True) as db:
|
||||
snippets = await _search_messages_pgvector(
|
||||
db,
|
||||
workspace_name,
|
||||
|
|
@ -877,6 +853,7 @@ async def search_messages(
|
|||
context_window: int = 2,
|
||||
embedding: list[float] | None = None,
|
||||
observer: str | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
"""
|
||||
Search for messages using semantic similarity and return conversation snippets.
|
||||
|
|
@ -887,12 +864,19 @@ async def search_messages(
|
|||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session (optional)
|
||||
Deprecated for *scoping*: prefer session_allowlist, which
|
||||
intersects with observer membership. This parameter also pins
|
||||
the query to one session and bypasses observer scoping, so it
|
||||
is not a drop-in equivalent and is not removed.
|
||||
query: Search query text
|
||||
limit: Maximum number of matching messages to return
|
||||
context_window: Number of messages before/after each match to include
|
||||
embedding: Optional pre-computed embedding
|
||||
observer: When provided and session_name is None, scope results
|
||||
to sessions this peer belongs to
|
||||
session_allowlist: Optional session allowlist. None is unrestricted; an
|
||||
empty list fails closed (empty result); a populated list is
|
||||
intersected with the observer's session scope when observer is set
|
||||
|
||||
Returns:
|
||||
List of tuples: (matched_messages, context_messages)
|
||||
|
|
@ -918,6 +902,7 @@ async def search_messages(
|
|||
context_window=context_window,
|
||||
operation_name="message.search_messages",
|
||||
observer=observer,
|
||||
session_allowlist=session_allowlist,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -965,6 +950,7 @@ async def grep_messages(
|
|||
limit: int = 10,
|
||||
context_window: int = 2,
|
||||
observer: str | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
"""
|
||||
Search for messages containing specific text (case-insensitive substring match).
|
||||
|
|
@ -975,25 +961,29 @@ async def grep_messages(
|
|||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session (optional - searches all sessions if None)
|
||||
Deprecated for *scoping*: prefer session_allowlist, which
|
||||
intersects with observer membership. This parameter also pins
|
||||
the query to one session and bypasses observer scoping, so it
|
||||
is not a drop-in equivalent and is not removed.
|
||||
text: Text to search for (case-insensitive)
|
||||
limit: Maximum number of matching messages to return
|
||||
context_window: Number of messages before/after each match to include
|
||||
observer: When provided and session_name is None, scope results
|
||||
to sessions this peer belongs to
|
||||
session_allowlist: Optional session allowlist. None is unrestricted; an
|
||||
empty list fails closed (empty result); a populated list is
|
||||
intersected with the observer's session scope when observer is set
|
||||
|
||||
Returns:
|
||||
List of tuples: (matched_messages, context_messages)
|
||||
Each snippet may contain multiple matches if they were close together.
|
||||
"""
|
||||
async with tracked_db("message.grep_messages") as db:
|
||||
# Pre-fetch peer session scope if needed
|
||||
allowed_session_names = None
|
||||
if observer and not session_name:
|
||||
allowed_session_names = await get_peer_session_names(
|
||||
db, workspace_name, observer
|
||||
)
|
||||
if not allowed_session_names:
|
||||
return []
|
||||
async with tracked_db("message.grep_messages", read_only=True) as db:
|
||||
allowed_session_names, deny = await resolve_session_scope(
|
||||
db, workspace_name, session_name, session_allowlist, observer
|
||||
)
|
||||
if deny:
|
||||
return []
|
||||
|
||||
snippets = await _grep_messages_internal(
|
||||
db,
|
||||
|
|
@ -1017,6 +1007,7 @@ async def get_messages_by_date_range(
|
|||
limit: int = 20,
|
||||
order: str = "desc",
|
||||
observer: str | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
) -> list[models.Message]:
|
||||
"""
|
||||
Get messages within a date range.
|
||||
|
|
@ -1025,24 +1016,28 @@ async def get_messages_by_date_range(
|
|||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session (optional - searches all sessions if None)
|
||||
Deprecated for *scoping*: prefer session_allowlist, which
|
||||
intersects with observer membership. This parameter also pins
|
||||
the query to one session and bypasses observer scoping, so it
|
||||
is not a drop-in equivalent and is not removed.
|
||||
after_date: Return messages after this datetime
|
||||
before_date: Return messages before this datetime
|
||||
limit: Maximum messages to return
|
||||
order: Sort order - 'asc' for oldest first, 'desc' for newest first
|
||||
observer: When provided and session_name is None, scope results
|
||||
to sessions this peer belongs to
|
||||
session_allowlist: Optional session allowlist. None is unrestricted; an
|
||||
empty list fails closed (empty result); a populated list is
|
||||
intersected with the observer's session scope when observer is set
|
||||
|
||||
Returns:
|
||||
List of messages within the date range
|
||||
"""
|
||||
# Pre-fetch peer session scope if needed
|
||||
allowed_session_names = None
|
||||
if observer and not session_name:
|
||||
allowed_session_names = await get_peer_session_names(
|
||||
db, workspace_name, observer
|
||||
)
|
||||
if not allowed_session_names:
|
||||
return []
|
||||
allowed_session_names, deny = await resolve_session_scope(
|
||||
db, workspace_name, session_name, session_allowlist, observer
|
||||
)
|
||||
if deny:
|
||||
return []
|
||||
|
||||
stmt = select(models.Message).where(models.Message.workspace_name == workspace_name)
|
||||
|
||||
|
|
@ -1076,6 +1071,7 @@ async def search_messages_temporal(
|
|||
context_window: int = 2,
|
||||
embedding: list[float] | None = None,
|
||||
observer: str | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
"""
|
||||
Search for messages using semantic similarity with optional date filtering.
|
||||
|
|
@ -1086,6 +1082,10 @@ async def search_messages_temporal(
|
|||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session (optional)
|
||||
Deprecated for *scoping*: prefer session_allowlist, which
|
||||
intersects with observer membership. This parameter also pins
|
||||
the query to one session and bypasses observer scoping, so it
|
||||
is not a drop-in equivalent and is not removed.
|
||||
query: Search query text
|
||||
after_date: Only return messages after this datetime
|
||||
before_date: Only return messages before this datetime
|
||||
|
|
@ -1094,6 +1094,9 @@ async def search_messages_temporal(
|
|||
embedding: Optional pre-computed embedding for the query
|
||||
observer: When provided and session_name is None, scope results
|
||||
to sessions this peer belongs to
|
||||
session_allowlist: Optional session allowlist. None is unrestricted; an
|
||||
empty list fails closed (empty result); a populated list is
|
||||
intersected with the observer's session scope when observer is set
|
||||
|
||||
Returns:
|
||||
List of tuples: (matched_messages, context_messages)
|
||||
|
|
@ -1120,4 +1123,5 @@ async def search_messages_temporal(
|
|||
context_window=context_window,
|
||||
operation_name="message.search_messages_temporal",
|
||||
observer=observer,
|
||||
session_allowlist=session_allowlist,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ async def get_or_create_peers(
|
|||
key=PEER_CACHE_KEY_TEMPLATE,
|
||||
ttl=f"{settings.CACHE.DEFAULT_LOCK_TTL_SECONDS}s",
|
||||
prefix=PEER_LOCK_PREFIX,
|
||||
check_interval=settings.CACHE.LOCK_WAIT_CHECK_INTERVAL_SECONDS,
|
||||
)
|
||||
async def _fetch_peer(
|
||||
db: AsyncSession,
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ from src.telemetry.events import EmbeddingCallPurpose
|
|||
from src.telemetry.logging import accumulate_metric
|
||||
from src.utils.formatting import format_datetime_utc
|
||||
from src.utils.representation import (
|
||||
ALLOWLIST_SAFE_LEVELS,
|
||||
DeductiveObservation,
|
||||
ExplicitObservation,
|
||||
Representation,
|
||||
allowlist_safe_levels,
|
||||
)
|
||||
from src.utils.types import embedding_call_purpose
|
||||
|
||||
|
|
@ -64,7 +66,7 @@ class RepresentationManager:
|
|||
session_name: str,
|
||||
message_created_at: datetime.datetime,
|
||||
message_level_configuration: ResolvedConfiguration,
|
||||
) -> int:
|
||||
) -> crud.CreateDocumentsResult:
|
||||
"""
|
||||
Save Representation objects to the collection as a set of documents.
|
||||
|
||||
|
|
@ -75,14 +77,15 @@ class RepresentationManager:
|
|||
message_created_at: Timestamp when the message was created
|
||||
|
||||
Returns:
|
||||
The number of *new documents saved*
|
||||
The result of document creation, including saved documents and
|
||||
deduplication counts.
|
||||
"""
|
||||
|
||||
new_documents = 0
|
||||
empty_result = crud.CreateDocumentsResult()
|
||||
|
||||
if not representation.deductive and not representation.explicit:
|
||||
logger.debug("No observations to save")
|
||||
return new_documents
|
||||
return empty_result
|
||||
|
||||
all_observations = [
|
||||
_normalized_observation(obs)
|
||||
|
|
@ -91,7 +94,7 @@ class RepresentationManager:
|
|||
]
|
||||
if not all_observations:
|
||||
logger.debug("No non-empty observations to save")
|
||||
return new_documents
|
||||
return empty_result
|
||||
|
||||
# Batch embed all observations
|
||||
batch_embed_start = time.perf_counter()
|
||||
|
|
@ -123,7 +126,7 @@ class RepresentationManager:
|
|||
# Batch create document objects
|
||||
create_document_start = time.perf_counter()
|
||||
async with tracked_db("representation_manager.save_representation") as db:
|
||||
new_documents = await self._save_representation_internal(
|
||||
new_documents_result = await self._save_representation_internal(
|
||||
db,
|
||||
all_observations,
|
||||
embeddings,
|
||||
|
|
@ -141,7 +144,7 @@ class RepresentationManager:
|
|||
"ms",
|
||||
)
|
||||
|
||||
return new_documents
|
||||
return new_documents_result
|
||||
|
||||
async def _save_representation_internal(
|
||||
self,
|
||||
|
|
@ -152,7 +155,7 @@ class RepresentationManager:
|
|||
session_name: str,
|
||||
message_created_at: datetime.datetime,
|
||||
message_level_configuration: ResolvedConfiguration,
|
||||
) -> int:
|
||||
) -> crud.CreateDocumentsResult:
|
||||
# get_or_create_collection already handles IntegrityError with rollback and a retry
|
||||
collection = await crud.get_or_create_collection(
|
||||
db,
|
||||
|
|
@ -191,7 +194,7 @@ class RepresentationManager:
|
|||
)
|
||||
|
||||
# Use bulk creation with optional duplicate detection
|
||||
accepted_documents = await crud.create_documents(
|
||||
accepted_documents_result = await crud.create_documents(
|
||||
db,
|
||||
documents_to_create,
|
||||
self.workspace_name,
|
||||
|
|
@ -206,13 +209,13 @@ class RepresentationManager:
|
|||
except Exception as e:
|
||||
logger.warning(f"Failed to check dream scheduling: {e}")
|
||||
|
||||
return len(accepted_documents)
|
||||
return accepted_documents_result
|
||||
|
||||
async def get_working_representation(
|
||||
self,
|
||||
*,
|
||||
db: AsyncSession | None = None,
|
||||
session_name: str | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
include_semantic_query: str | None = None,
|
||||
embedding: list[float] | None = None,
|
||||
semantic_search_top_k: int | None = None,
|
||||
|
|
@ -228,7 +231,10 @@ class RepresentationManager:
|
|||
Args:
|
||||
db: Optional database session. If provided, uses it directly;
|
||||
otherwise creates a new session via tracked_db.
|
||||
session_name: Optional session to filter by
|
||||
session_allowlist: Optional session allowlist to filter by. Applied
|
||||
uniformly to every query path (semantic, most-derived, and
|
||||
recent). None means no session restriction; an empty list
|
||||
fail-closes to an empty representation.
|
||||
include_semantic_query: Query for semantic search
|
||||
embedding: Pre-computed embedding for the semantic query.
|
||||
semantic_search_top_k: Number of semantic results
|
||||
|
|
@ -266,7 +272,7 @@ class RepresentationManager:
|
|||
if db is not None:
|
||||
return await self._get_working_representation_internal(
|
||||
db,
|
||||
session_name=session_name,
|
||||
session_allowlist=session_allowlist,
|
||||
include_semantic_query=include_semantic_query,
|
||||
embedding=embedding,
|
||||
semantic_search_top_k=semantic_search_top_k,
|
||||
|
|
@ -276,11 +282,11 @@ class RepresentationManager:
|
|||
)
|
||||
|
||||
async with tracked_db(
|
||||
"representation_manager.get_working_representation"
|
||||
"representation_manager.get_working_representation", read_only=True
|
||||
) as new_db:
|
||||
return await self._get_working_representation_internal(
|
||||
new_db,
|
||||
session_name=session_name,
|
||||
session_allowlist=session_allowlist,
|
||||
include_semantic_query=include_semantic_query,
|
||||
embedding=embedding,
|
||||
semantic_search_top_k=semantic_search_top_k,
|
||||
|
|
@ -295,7 +301,7 @@ class RepresentationManager:
|
|||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
session_name: str | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
include_semantic_query: str | None = None,
|
||||
embedding: list[float] | None = None,
|
||||
semantic_search_top_k: int | None = None,
|
||||
|
|
@ -304,6 +310,12 @@ class RepresentationManager:
|
|||
max_observations: int = settings.DERIVER.WORKING_REPRESENTATION_MAX_OBSERVATIONS,
|
||||
) -> Representation:
|
||||
"""Internal implementation of get_working_representation."""
|
||||
# Fail closed on an empty allowlist. This must short-circuit before
|
||||
# any query: downstream stores drop an `IN ()` clause with an empty
|
||||
# list (lancedb), which would silently widen the scope instead.
|
||||
if session_allowlist is not None and not session_allowlist:
|
||||
return Representation()
|
||||
|
||||
total = max_observations
|
||||
|
||||
# Calculate how many observations to get from each source
|
||||
|
|
@ -344,6 +356,7 @@ class RepresentationManager:
|
|||
top_k=semantic_observations,
|
||||
max_distance=semantic_search_max_distance,
|
||||
embedding=embedding,
|
||||
session_allowlist=session_allowlist,
|
||||
)
|
||||
representation.merge_representation(
|
||||
Representation.from_documents(semantic_docs)
|
||||
|
|
@ -352,7 +365,7 @@ class RepresentationManager:
|
|||
# Get most derived observations if requested
|
||||
if include_most_derived:
|
||||
derived_docs = await self._query_documents_most_derived(
|
||||
db, top_k=top_observations
|
||||
db, top_k=top_observations, session_allowlist=session_allowlist
|
||||
)
|
||||
representation.merge_representation(
|
||||
Representation.from_documents(derived_docs)
|
||||
|
|
@ -360,7 +373,7 @@ class RepresentationManager:
|
|||
|
||||
# Get recent observations
|
||||
recent_docs = await self._query_documents_recent(
|
||||
db, top_k=recent_observations, session_name=session_name
|
||||
db, top_k=recent_observations, session_allowlist=session_allowlist
|
||||
)
|
||||
|
||||
representation.merge_representation(Representation.from_documents(recent_docs))
|
||||
|
|
@ -375,6 +388,7 @@ class RepresentationManager:
|
|||
max_distance: float | None = None,
|
||||
level: str | None = None,
|
||||
embedding: list[float] | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
) -> list[models.Document]:
|
||||
"""Query documents by semantic similarity."""
|
||||
try:
|
||||
|
|
@ -386,6 +400,7 @@ class RepresentationManager:
|
|||
top_k,
|
||||
max_distance,
|
||||
embedding=embedding,
|
||||
session_allowlist=session_allowlist,
|
||||
)
|
||||
else:
|
||||
documents = await crud.query_documents(
|
||||
|
|
@ -397,6 +412,10 @@ class RepresentationManager:
|
|||
max_distance=max_distance,
|
||||
top_k=top_k,
|
||||
embedding=embedding,
|
||||
filters=self._build_filter_conditions(
|
||||
session_allowlist=session_allowlist
|
||||
)
|
||||
or None,
|
||||
)
|
||||
db.expunge_all()
|
||||
return list(documents)
|
||||
|
|
@ -406,7 +425,7 @@ class RepresentationManager:
|
|||
return []
|
||||
|
||||
async def _query_documents_recent(
|
||||
self, db: AsyncSession, top_k: int, session_name: str | None = None
|
||||
self, db: AsyncSession, top_k: int, session_allowlist: list[str] | None = None
|
||||
) -> list[models.Document]:
|
||||
"""Query most recent documents."""
|
||||
stmt = (
|
||||
|
|
@ -418,8 +437,13 @@ class RepresentationManager:
|
|||
models.Document.observed == self.observed,
|
||||
models.Document.deleted_at.is_(None),
|
||||
*(
|
||||
[models.Document.session_name == session_name]
|
||||
if session_name is not None
|
||||
[
|
||||
models.Document.session_name.in_(session_allowlist),
|
||||
# Only levels with a trustworthy session stamp are
|
||||
# scopeable — see ALLOWLIST_SAFE_LEVELS.
|
||||
models.Document.level.in_(ALLOWLIST_SAFE_LEVELS),
|
||||
]
|
||||
if session_allowlist is not None
|
||||
else []
|
||||
),
|
||||
)
|
||||
|
|
@ -432,7 +456,7 @@ class RepresentationManager:
|
|||
return list(documents)
|
||||
|
||||
async def _query_documents_most_derived(
|
||||
self, db: AsyncSession, top_k: int
|
||||
self, db: AsyncSession, top_k: int, session_allowlist: list[str] | None = None
|
||||
) -> list[models.Document]:
|
||||
"""Query most derived documents."""
|
||||
stmt = (
|
||||
|
|
@ -443,8 +467,24 @@ class RepresentationManager:
|
|||
models.Document.observer == self.observer,
|
||||
models.Document.observed == self.observed,
|
||||
models.Document.deleted_at.is_(None),
|
||||
*(
|
||||
[
|
||||
models.Document.session_name.in_(session_allowlist),
|
||||
# Only levels with a trustworthy session stamp are
|
||||
# scopeable — see ALLOWLIST_SAFE_LEVELS.
|
||||
models.Document.level.in_(ALLOWLIST_SAFE_LEVELS),
|
||||
]
|
||||
if session_allowlist is not None
|
||||
else []
|
||||
),
|
||||
)
|
||||
.order_by(
|
||||
models.Document.times_derived.desc(),
|
||||
models.Document.created_at.desc(),
|
||||
# created_at is the transaction timestamp, so documents created
|
||||
# in the same batch share it -- id keeps the order deterministic.
|
||||
models.Document.id,
|
||||
)
|
||||
.order_by(models.Document.times_derived.desc())
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
|
|
@ -473,6 +513,7 @@ class RepresentationManager:
|
|||
count: int,
|
||||
max_distance: float | None = None,
|
||||
embedding: list[float] | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
) -> list[models.Document]:
|
||||
"""Query documents for a specific level."""
|
||||
documents = await crud.query_documents(
|
||||
|
|
@ -483,7 +524,9 @@ class RepresentationManager:
|
|||
query=query,
|
||||
max_distance=max_distance,
|
||||
top_k=count,
|
||||
filters=self._build_filter_conditions(level),
|
||||
filters=self._build_filter_conditions(
|
||||
level, session_allowlist=session_allowlist
|
||||
),
|
||||
embedding=embedding,
|
||||
)
|
||||
|
||||
|
|
@ -496,17 +539,32 @@ class RepresentationManager:
|
|||
def _build_filter_conditions(
|
||||
self,
|
||||
level: str | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build filter conditions for document queries.
|
||||
|
||||
Returns a flat dict of key-value pairs for vector store filtering.
|
||||
Callers must not pass an empty session_allowlist list — empty allowlists
|
||||
fail closed before any query is issued (see
|
||||
_get_working_representation_internal).
|
||||
"""
|
||||
filters: dict[str, Any] = {}
|
||||
|
||||
if level:
|
||||
filters["level"] = level
|
||||
|
||||
# `is not None` (not truthiness): an explicit empty allowlist must emit
|
||||
# an empty `in` so downstream stores fail closed, matching
|
||||
# _query_documents_recent / _query_documents_most_derived. Truthiness
|
||||
# here would silently drop the filter and widen scope.
|
||||
if session_allowlist is not None:
|
||||
filters["session_name"] = {"in": session_allowlist}
|
||||
# Only levels with a trustworthy session stamp are scopeable. This
|
||||
# overrides any narrower `level` above; an empty intersection emits
|
||||
# `{"in": []}`, which matches nothing rather than everything.
|
||||
filters["level"] = {"in": allowlist_safe_levels([level] if level else None)}
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
|
|
@ -519,7 +577,7 @@ async def get_working_representation(
|
|||
db: AsyncSession | None = None,
|
||||
observer: str,
|
||||
observed: str,
|
||||
session_name: str | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
include_semantic_query: str | None = None,
|
||||
embedding: list[float] | None = None,
|
||||
semantic_search_top_k: int | None = None,
|
||||
|
|
@ -552,7 +610,7 @@ async def get_working_representation(
|
|||
)
|
||||
return await manager.get_working_representation(
|
||||
db=db,
|
||||
session_name=session_name,
|
||||
session_allowlist=session_allowlist,
|
||||
include_semantic_query=include_semantic_query,
|
||||
embedding=embedding,
|
||||
semantic_search_top_k=semantic_search_top_k,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ def session_cache_key(workspace_name: str, session_name: str) -> str:
|
|||
key=SESSION_CACHE_KEY_TEMPLATE,
|
||||
ttl=f"{settings.CACHE.DEFAULT_LOCK_TTL_SECONDS}s",
|
||||
prefix=SESSION_LOCK_PREFIX,
|
||||
check_interval=settings.CACHE.LOCK_WAIT_CHECK_INTERVAL_SECONDS,
|
||||
)
|
||||
async def _fetch_session(
|
||||
db: AsyncSession,
|
||||
|
|
@ -834,6 +835,38 @@ async def get_peers_from_session(
|
|||
)
|
||||
|
||||
|
||||
async def is_peer_in_session(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
peer_name: str,
|
||||
) -> bool:
|
||||
"""Return whether a peer is an active member of a session.
|
||||
|
||||
Active membership means a `SessionPeer` row exists with `left_at IS NULL`.
|
||||
Used by the auth layer to grant a peer-scoped key read access to the
|
||||
sessions that peer belongs to.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
peer_name: Name of the peer
|
||||
|
||||
Returns:
|
||||
True if the peer is currently a member of the session.
|
||||
"""
|
||||
result = await db.scalar(
|
||||
select(models.SessionPeer.peer_name)
|
||||
.where(models.SessionPeer.workspace_name == workspace_name)
|
||||
.where(models.SessionPeer.session_name == session_name)
|
||||
.where(models.SessionPeer.peer_name == peer_name)
|
||||
.where(models.SessionPeer.left_at.is_(None))
|
||||
.limit(1)
|
||||
)
|
||||
return result is not None
|
||||
|
||||
|
||||
async def get_session_peer_configuration(
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ def workspace_cache_key(workspace_name: str) -> str:
|
|||
key=WORKSPACE_CACHE_KEY_TEMPLATE,
|
||||
ttl=f"{settings.CACHE.DEFAULT_LOCK_TTL_SECONDS}s",
|
||||
prefix=WORKSPACE_LOCK_PREFIX,
|
||||
check_interval=settings.CACHE.LOCK_WAIT_CHECK_INTERVAL_SECONDS,
|
||||
)
|
||||
async def _fetch_workspace(
|
||||
db: AsyncSession, workspace_name: str
|
||||
|
|
|
|||
183
src/db.py
183
src/db.py
|
|
@ -1,13 +1,27 @@
|
|||
import contextvars
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import MetaData, text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy import MetaData, event, text
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy.pool import NullPool
|
||||
from sqlalchemy.pool import NullPool, QueuePool
|
||||
|
||||
from src.config import settings
|
||||
from src.telemetry.prometheus.metrics import db_queries_in_flight_gauge
|
||||
|
||||
connect_args = {"prepare_threshold": None}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
connect_args = {
|
||||
"prepare_threshold": None,
|
||||
# Bound a single connection attempt so it fails fast instead of hanging when
|
||||
# the server/pooler is unreachable or stalled (psycopg, seconds).
|
||||
"connect_timeout": settings.DB.CONNECT_TIMEOUT_SECONDS,
|
||||
}
|
||||
|
||||
# Context variable to store request context
|
||||
request_context: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
||||
|
|
@ -38,13 +52,174 @@ engine = create_async_engine(
|
|||
**engine_kwargs,
|
||||
)
|
||||
|
||||
# A vanilla AsyncSession is lazy: it checks out a pooled connection on the first
|
||||
# DB-touching call (not at construction) and couples the checkout to the
|
||||
# statement, so a handler doing non-DB work (embedding/file/LLM) before its
|
||||
# first query does not pin a connection across it. Connection acquisition is a
|
||||
# single attempt with no retry — callers handle a saturated/unreachable DB (the
|
||||
# API surfaces the error; the deriver backs off and retries on a later poll).
|
||||
SessionLocal = async_sessionmaker(
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
expire_on_commit=False,
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
)
|
||||
|
||||
# Read-only engine: shares `engine`'s pool, but checks connections out in DBAPI
|
||||
# AUTOCOMMIT mode, so psycopg emits NO BEGIN — a SELECT never autobegins a
|
||||
# transaction. The backend therefore returns to state 'idle' (not 'idle in
|
||||
# transaction') the moment a statement completes.
|
||||
read_engine = engine.execution_options(isolation_level="AUTOCOMMIT")
|
||||
|
||||
# Sessions for SELECT-only work (same lazy-checkout semantics as SessionLocal).
|
||||
# MUST NOT be used for writes: with no enclosing transaction, begin_nested()
|
||||
# savepoints (see the crud get-or-create paths) break, and every flush would
|
||||
# commit immediately. Use SessionLocal for anything that mutates.
|
||||
ReadSessionLocal = async_sessionmaker(
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
expire_on_commit=False,
|
||||
bind=read_engine,
|
||||
class_=AsyncSession,
|
||||
)
|
||||
|
||||
|
||||
def _set_application_name_on_checkout(
|
||||
dbapi_connection: Any, _connection_record: Any, _connection_proxy: Any
|
||||
) -> None:
|
||||
"""Tag each checked-out connection with the current request context.
|
||||
|
||||
Registered only when ``DB.TRACING`` is on. Fires on every pool checkout (so a
|
||||
reused pooled connection is re-tagged for the new caller), reading the
|
||||
per-task ``request_context`` the request/task scope has already set.
|
||||
Best-effort: a failure here must never break the checkout.
|
||||
|
||||
Runs in autocommit so it never leaves the connection 'idle in transaction'
|
||||
at checkout: this hook fires BEFORE the dialect applies execution-option
|
||||
isolation levels, and psycopg refuses to switch a connection into AUTOCOMMIT
|
||||
(which the read engine does) while a transaction opened by this statement is
|
||||
still in progress. set_config(..., is_local=false) is session-scoped, so it
|
||||
persists past the autocommit boundary.
|
||||
"""
|
||||
context = request_context.get() or "unknown"
|
||||
try:
|
||||
previous_autocommit = dbapi_connection.autocommit
|
||||
if not previous_autocommit:
|
||||
dbapi_connection.autocommit = True
|
||||
try:
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
cursor.execute(
|
||||
"SELECT set_config('application_name', %s, false)", (context,)
|
||||
)
|
||||
finally:
|
||||
cursor.close()
|
||||
finally:
|
||||
if not previous_autocommit:
|
||||
dbapi_connection.autocommit = False
|
||||
except Exception:
|
||||
logger.debug("setting application_name on checkout failed", exc_info=True)
|
||||
|
||||
|
||||
if settings.DB.TRACING:
|
||||
event.listen(engine.sync_engine, "checkout", _set_application_name_on_checkout)
|
||||
|
||||
|
||||
def get_pool_stats() -> dict[str, int]:
|
||||
"""Return live connection-pool stats for this process.
|
||||
|
||||
``engine.pool`` is the AsyncEngine's pool (the same object as
|
||||
``engine.sync_engine.pool``); its stat methods are synchronous counter
|
||||
reads with no I/O, so they are safe to call without ``await``. Returns
|
||||
zeros for pools that do not track connections (e.g. ``NullPool``).
|
||||
"""
|
||||
zeros = {"checked_out": 0, "checked_in": 0, "size": 0, "overflow": 0}
|
||||
pool = engine.pool
|
||||
# Only QueuePool (and its AsyncAdaptedQueuePool subclass) tracks connection
|
||||
# counts; NullPool and others have no meaningful stats.
|
||||
if not isinstance(pool, QueuePool):
|
||||
return zeros
|
||||
try:
|
||||
# overflow() is negative until the base pool fills (it starts at
|
||||
# -pool_size); clamp to the count of overflow connections actually open.
|
||||
return {
|
||||
"checked_out": pool.checkedout(),
|
||||
"checked_in": pool.checkedin(),
|
||||
"size": pool.size(),
|
||||
"overflow": max(0, pool.overflow()),
|
||||
}
|
||||
except Exception:
|
||||
return zeros
|
||||
|
||||
|
||||
class DBQueryInflightTracker:
|
||||
"""Tracks statements executing on the wire via SQLAlchemy cursor events.
|
||||
|
||||
Drift-proof: marks ``Connection.info`` when a statement starts and clears it
|
||||
on completion OR error, so the gauge can't leak upward (an errored statement
|
||||
skips ``after_cursor_execute``) or go negative (a connect-time error has no
|
||||
matching start). Bound to a pre-resolved labeled gauge child so the
|
||||
per-statement hot path does no label resolution.
|
||||
"""
|
||||
|
||||
# Marker on Connection.info recording that we incremented for the current
|
||||
# statement, so we decrement exactly once on completion or error.
|
||||
INFLIGHT_KEY: str = "_honcho_inflight"
|
||||
|
||||
def __init__(self, gauge_child: Any) -> None:
|
||||
self._child: Any = gauge_child
|
||||
|
||||
def on_before(self, conn: Any, *_: Any) -> None:
|
||||
try:
|
||||
conn.info[self.INFLIGHT_KEY] = True
|
||||
self._child.inc()
|
||||
except Exception:
|
||||
logger.debug("in-flight gauge inc failed", exc_info=True)
|
||||
|
||||
def on_after(self, conn: Any, *_: Any) -> None:
|
||||
try:
|
||||
if conn.info.pop(self.INFLIGHT_KEY, False):
|
||||
self._child.dec()
|
||||
except Exception:
|
||||
logger.debug("in-flight gauge dec failed", exc_info=True)
|
||||
|
||||
def on_error(self, exception_context: Any) -> None:
|
||||
try:
|
||||
conn = exception_context.connection
|
||||
if conn is not None and conn.info.pop(self.INFLIGHT_KEY, False):
|
||||
self._child.dec()
|
||||
except Exception:
|
||||
logger.debug("in-flight gauge error-path dec failed", exc_info=True)
|
||||
|
||||
|
||||
# Process-wide tracker, created at registration (None until then / if metrics off).
|
||||
_inflight_tracker: DBQueryInflightTracker | None = None
|
||||
|
||||
|
||||
_db_query_instrumentation_registered = False
|
||||
|
||||
|
||||
def register_db_query_instrumentation(instance_type: str) -> None:
|
||||
"""Attach per-statement in-flight tracking to the engine (no-op if off).
|
||||
|
||||
Gated on METRICS.ENABLED so there is zero overhead — not even attached event
|
||||
listeners — when metrics are disabled. Idempotent: repeated calls (e.g. a
|
||||
re-run lifespan or test startup) won't attach duplicate listeners, which
|
||||
would double-count in-flight statements.
|
||||
"""
|
||||
global _inflight_tracker, _db_query_instrumentation_registered
|
||||
if not settings.METRICS.ENABLED or _db_query_instrumentation_registered:
|
||||
return
|
||||
child = db_queries_in_flight_gauge.labels(instance_type=instance_type)
|
||||
_inflight_tracker = DBQueryInflightTracker(child)
|
||||
sync_engine = engine.sync_engine
|
||||
event.listen(sync_engine, "before_cursor_execute", _inflight_tracker.on_before)
|
||||
event.listen(sync_engine, "after_cursor_execute", _inflight_tracker.on_after)
|
||||
event.listen(sync_engine, "handle_error", _inflight_tracker.on_error)
|
||||
_db_query_instrumentation_registered = True
|
||||
|
||||
|
||||
# Define your naming convention
|
||||
convention = {
|
||||
"ix": "ix_%(table_name)s_%(column_0_N_name)s", # Index - supports multi-column
|
||||
|
|
|
|||
|
|
@ -2,25 +2,21 @@ import uuid
|
|||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.config import settings
|
||||
from src.db import SessionLocal, request_context
|
||||
from src.db import ReadSessionLocal, SessionLocal, request_context
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""FastAPI Dependency Generator for Database"""
|
||||
|
||||
context = request_context.get() or "unknown"
|
||||
"""FastAPI Dependency Generator for Database.
|
||||
|
||||
The session is lazy: it does NOT check out a pooled connection here. The
|
||||
AsyncSession checks one out on the first DB-touching call, so a handler doing
|
||||
non-DB work (embedding/file/LLM) before its first query does not pin a
|
||||
connection across it.
|
||||
"""
|
||||
db: AsyncSession = SessionLocal()
|
||||
try:
|
||||
if settings.DB.TRACING:
|
||||
await db.execute(
|
||||
text("SELECT set_config('application_name', :name, false)"),
|
||||
{"name": context},
|
||||
)
|
||||
yield db
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
|
|
@ -30,14 +26,46 @@ async def get_db():
|
|||
# is closed before the TCP connection drops. Supavisor v2 does NOT
|
||||
# clean up orphaned transactions on client disconnect in transaction-
|
||||
# pooling mode, so relying on `in_transaction()` (Python-side state)
|
||||
# can leave the backend pinned with an open BEGIN.
|
||||
# can leave the backend pinned with an open BEGIN. (Cheap no-op if the
|
||||
# lazy session never checked out a connection.)
|
||||
await db.rollback()
|
||||
await db.close()
|
||||
|
||||
|
||||
async def get_read_db():
|
||||
"""FastAPI Dependency Generator for SELECT-only handlers.
|
||||
|
||||
Same lazy-checkout semantics as get_db, but the session is bound to the
|
||||
AUTOCOMMIT read engine: no BEGIN is ever emitted, so the connection can not
|
||||
sit 'idle in transaction' between the query and this teardown — a delayed
|
||||
finally here is harmless (the backend is plain 'idle'). close() is still
|
||||
required to release the connection itself back to the pool.
|
||||
|
||||
MUST only be used by handlers that never mutate; see ReadSessionLocal.
|
||||
"""
|
||||
db: AsyncSession = ReadSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
# rollback is a wire-level no-op under AUTOCOMMIT; kept to reset any
|
||||
# Python-side session state before close, mirroring get_db.
|
||||
await db.rollback()
|
||||
await db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def tracked_db(operation_name: str | None = None):
|
||||
"""Context manager for tracked database sessions"""
|
||||
async def tracked_db(operation_name: str | None = None, *, read_only: bool = False):
|
||||
"""Context manager for tracked database sessions.
|
||||
|
||||
Sets a task-scoped request_context so the lazy session picks it up for
|
||||
tracing/attribution, then yields a lazy session (see get_db).
|
||||
|
||||
Pass read_only=True for SELECT-only windows: the session is then bound to
|
||||
the AUTOCOMMIT read engine, so the work inside the block never holds an
|
||||
open transaction (no idle-in-transaction parking; the pooler can reclaim
|
||||
the backend between statements). Never use read_only=True on a path that
|
||||
mutates — see ReadSessionLocal.
|
||||
"""
|
||||
# Get request ID if available, or create operation-specific one
|
||||
context = request_context.get()
|
||||
token = None
|
||||
|
|
@ -46,22 +74,15 @@ async def tracked_db(operation_name: str | None = None):
|
|||
context = f"task:{operation_name}:{str(uuid.uuid4())[:8]}"
|
||||
token = request_context.set(context)
|
||||
|
||||
# Create session with tracking info
|
||||
db = SessionLocal()
|
||||
|
||||
db = (ReadSessionLocal if read_only else SessionLocal)()
|
||||
try:
|
||||
if settings.DB.TRACING:
|
||||
await db.execute(
|
||||
text("SELECT set_config('application_name', :name, false)"),
|
||||
{"name": context or f"task:{operation_name}"},
|
||||
)
|
||||
|
||||
yield db
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
finally:
|
||||
# Always send ROLLBACK unconditionally — see get_db() comment.
|
||||
# Always send ROLLBACK unconditionally — see get_db() comment. (Under
|
||||
# read_only/AUTOCOMMIT it is a wire-level no-op.)
|
||||
await db.rollback()
|
||||
await db.close()
|
||||
if token: # Only reset if we set it
|
||||
|
|
@ -69,3 +90,4 @@ async def tracked_db(operation_name: str | None = None):
|
|||
|
||||
|
||||
db: AsyncSession = Depends(get_db)
|
||||
read_db: AsyncSession = Depends(get_read_db)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,13 @@ import uvloop
|
|||
from prometheus_client import start_http_server
|
||||
|
||||
from src.config import settings
|
||||
from src.db import engine
|
||||
from src.db import engine, register_db_query_instrumentation
|
||||
from src.startup import validate_embedding_schema
|
||||
from src.telemetry import initialize_telemetry_async, shutdown_telemetry
|
||||
from src.telemetry import (
|
||||
initialize_telemetry_async,
|
||||
register_db_pool_collector,
|
||||
shutdown_telemetry,
|
||||
)
|
||||
|
||||
from .queue_manager import main
|
||||
|
||||
|
|
@ -18,6 +22,9 @@ logger = logging.getLogger(__name__)
|
|||
def start_metrics_server() -> None:
|
||||
"""Start the Prometheus metrics HTTP server on port 9090."""
|
||||
start_http_server(9090)
|
||||
# Expose DB connection-pool stats for this deriver instance.
|
||||
register_db_pool_collector("deriver")
|
||||
register_db_query_instrumentation("deriver")
|
||||
logger.info("Prometheus metrics server started on port 9090")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -43,17 +43,20 @@ async def process_item(queue_item: models.QueueItem) -> None:
|
|||
|
||||
# Handle reconciler first - it's the only task type that doesn't require workspace_name
|
||||
if task_type == "reconciler":
|
||||
with sentry_sdk.start_transaction(name="process_reconciler_task", op="deriver"):
|
||||
try:
|
||||
validated = ReconcilerPayload(**queue_payload)
|
||||
except ValidationError as e:
|
||||
logger.error(
|
||||
"Invalid reconciler payload received: %s. Payload: %s",
|
||||
str(e),
|
||||
queue_payload,
|
||||
)
|
||||
raise ValueError(f"Invalid payload structure: {str(e)}") from e
|
||||
await process_reconciler(validated)
|
||||
# No top-level transaction here: reconciler tasks poll on a fixed
|
||||
# interval and usually find no work. Tracing is started per-batch
|
||||
# inside the reconciler only when actual work is found, so idle
|
||||
# cycles don't consume Sentry tracing/profiling quota.
|
||||
try:
|
||||
validated = ReconcilerPayload(**queue_payload)
|
||||
except ValidationError as e:
|
||||
logger.error(
|
||||
"Invalid reconciler payload received: %s. Payload: %s",
|
||||
str(e),
|
||||
queue_payload,
|
||||
)
|
||||
raise ValueError(f"Invalid payload structure: {str(e)}") from e
|
||||
await process_reconciler(validated)
|
||||
return
|
||||
|
||||
# All other task types require a workspace_name
|
||||
|
|
@ -173,7 +176,7 @@ async def process_representation_batch(
|
|||
queue_item_message_ids: Message IDs from queue items
|
||||
hit_batch_token_cap: whether the queue batcher clamped this batch to fit
|
||||
was_flush_enabled: snapshot of DERIVER.FLUSH_ENABLED at fetch time
|
||||
batch_max_tokens: DERIVER.REPRESENTATION_BATCH_MAX_TOKENS snapshot
|
||||
batch_max_tokens: DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS snapshot
|
||||
"""
|
||||
if not messages or not messages[0]:
|
||||
logger.debug("process_representation_batch received no messages")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import logging
|
||||
import time
|
||||
|
||||
from nanoid import generate as generate_nanoid
|
||||
|
||||
from src import crud
|
||||
from src.config import ConfiguredModelSettings, settings
|
||||
from src.crud.representation import RepresentationManager
|
||||
|
|
@ -56,7 +58,7 @@ async def process_representation_tasks_batch(
|
|||
queue_item_message_ids: Message IDs from queue items being processed
|
||||
hit_batch_token_cap: queue batcher clamped this batch to fit
|
||||
was_flush_enabled: DERIVER.FLUSH_ENABLED snapshot at batch time
|
||||
batch_max_tokens: DERIVER.REPRESENTATION_BATCH_MAX_TOKENS snapshot
|
||||
batch_max_tokens: DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS snapshot
|
||||
"""
|
||||
if not messages:
|
||||
return
|
||||
|
|
@ -142,12 +144,12 @@ async def process_representation_tasks_batch(
|
|||
model_config = base_model_config
|
||||
|
||||
# Single LLM call
|
||||
trace_id = generate_nanoid()
|
||||
llm_start = time.perf_counter()
|
||||
response = await honcho_llm_call(
|
||||
model_config=model_config,
|
||||
prompt=prompt,
|
||||
max_tokens=max_tokens,
|
||||
track_name="Minimal Deriver",
|
||||
response_model=PromptRepresentation,
|
||||
json_mode=True,
|
||||
max_input_tokens=settings.DERIVER.MAX_INPUT_TOKENS,
|
||||
|
|
@ -159,6 +161,9 @@ async def process_representation_tasks_batch(
|
|||
call_purpose=CallPurpose.DERIVER_REPRESENTATION.value,
|
||||
parent_category="representation",
|
||||
observed=observed,
|
||||
track_name="Minimal Deriver",
|
||||
trace_id=trace_id,
|
||||
span_id=trace_id,
|
||||
),
|
||||
)
|
||||
llm_duration = (time.perf_counter() - llm_start) * 1000
|
||||
|
|
@ -189,6 +194,7 @@ async def process_representation_tasks_batch(
|
|||
latest_message.created_at,
|
||||
)
|
||||
|
||||
agg_representation_result = crud.CreateDocumentsResult()
|
||||
successful_observer_count = 0
|
||||
if observations.is_empty() or not message_ids:
|
||||
logger.warning(
|
||||
|
|
@ -208,12 +214,26 @@ async def process_representation_tasks_batch(
|
|||
)
|
||||
|
||||
try:
|
||||
await representation_manager.save_representation(
|
||||
observations,
|
||||
message_ids,
|
||||
latest_message.session_name,
|
||||
latest_message.created_at,
|
||||
message_level_configuration,
|
||||
representation_result = (
|
||||
await representation_manager.save_representation(
|
||||
observations,
|
||||
message_ids,
|
||||
latest_message.session_name,
|
||||
latest_message.created_at,
|
||||
message_level_configuration,
|
||||
)
|
||||
)
|
||||
agg_representation_result.exact_dup_existing_count += (
|
||||
representation_result.exact_dup_existing_count
|
||||
)
|
||||
agg_representation_result.exact_dup_in_batch_count += (
|
||||
representation_result.exact_dup_in_batch_count
|
||||
)
|
||||
agg_representation_result.semantic_dup_rejected_count += (
|
||||
representation_result.semantic_dup_rejected_count
|
||||
)
|
||||
agg_representation_result.semantic_dup_replaced_count += (
|
||||
representation_result.semantic_dup_replaced_count
|
||||
)
|
||||
successful_observer_count += 1
|
||||
except Exception as e:
|
||||
|
|
@ -313,5 +333,9 @@ async def process_representation_tasks_batch(
|
|||
hit_batch_token_cap=hit_batch_token_cap,
|
||||
hit_input_token_cap=response.hit_input_token_cap,
|
||||
observer_count=successful_observer_count,
|
||||
exact_dup_existing_count=agg_representation_result.exact_dup_existing_count,
|
||||
exact_dup_in_batch_count=agg_representation_result.exact_dup_in_batch_count,
|
||||
semantic_dup_rejected_count=agg_representation_result.semantic_dup_rejected_count,
|
||||
semantic_dup_replaced_count=agg_representation_result.semantic_dup_replaced_count,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -403,6 +403,7 @@ def create_dream_record(
|
|||
delay_reason: str | None = None,
|
||||
documents_since_last_dream_at_schedule: int | None = None,
|
||||
document_threshold: int | None = None,
|
||||
rebuild: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a queue record for a dream task.
|
||||
|
|
@ -417,6 +418,7 @@ def create_dream_record(
|
|||
delay_reason: what governed when it fires
|
||||
documents_since_last_dream_at_schedule: count snapshot at schedule time
|
||||
document_threshold: DOCUMENT_THRESHOLD snapshot at schedule time
|
||||
rebuild: card_refresh only — rebuild the card without the prior card
|
||||
|
||||
Returns:
|
||||
Queue record dictionary with workspace_name and other fields
|
||||
|
|
@ -430,6 +432,7 @@ def create_dream_record(
|
|||
delay_reason=delay_reason,
|
||||
documents_since_last_dream_at_schedule=documents_since_last_dream_at_schedule,
|
||||
document_threshold=document_threshold,
|
||||
rebuild=rebuild,
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
@ -452,6 +455,7 @@ async def enqueue_dream(
|
|||
delay_reason: str | None = None,
|
||||
documents_since_last_dream_at_schedule: int | None = None,
|
||||
document_threshold: int | None = None,
|
||||
rebuild: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Enqueue a dream task for immediate processing by the deriver.
|
||||
|
|
@ -461,6 +465,8 @@ async def enqueue_dream(
|
|||
|
||||
Deduplication: If a dream with the same work_unit_key is already in-progress
|
||||
(has an ActiveQueueSession) or pending in the queue, the enqueue is skipped.
|
||||
The work unit key includes the dream type, so e.g. a card_refresh dream
|
||||
never collides with a pending omni dream for the same collection.
|
||||
|
||||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
|
|
@ -468,6 +474,7 @@ async def enqueue_dream(
|
|||
observed: Name of the observed peer
|
||||
dream_type: Type of dream to execute
|
||||
session_name: Name of the session to scope the dream to if specified
|
||||
rebuild: card_refresh only — rebuild the card without the prior card
|
||||
"""
|
||||
async with tracked_db("dream_enqueue") as db_session:
|
||||
try:
|
||||
|
|
@ -481,6 +488,7 @@ async def enqueue_dream(
|
|||
delay_reason=delay_reason,
|
||||
documents_since_last_dream_at_schedule=documents_since_last_dream_at_schedule,
|
||||
document_threshold=document_threshold,
|
||||
rebuild=rebuild,
|
||||
)
|
||||
|
||||
work_unit_key = dream_record["work_unit_key"]
|
||||
|
|
@ -495,7 +503,7 @@ async def enqueue_dream(
|
|||
is_in_progress = await db_session.scalar(in_progress_check)
|
||||
|
||||
if is_in_progress:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"Skipping dream enqueue - already in progress: %s/%s/%s (type: %s)",
|
||||
workspace_name,
|
||||
observer,
|
||||
|
|
@ -515,7 +523,7 @@ async def enqueue_dream(
|
|||
is_pending = await db_session.scalar(pending_check)
|
||||
|
||||
if is_pending:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"Dream already pending in queue: %s/%s/%s (type: %s)",
|
||||
workspace_name,
|
||||
observer,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ def _custom_instructions_section(custom_instructions: str | None) -> str:
|
|||
return c(
|
||||
f"""
|
||||
CUSTOM INSTRUCTIONS:
|
||||
These instructions apply to the target peer identified below.
|
||||
{normalized_custom_instructions}
|
||||
"""
|
||||
)
|
||||
|
|
@ -54,26 +55,32 @@ def minimal_deriver_prompt(
|
|||
custom_instructions_section = _custom_instructions_section(custom_instructions)
|
||||
return c(
|
||||
f"""
|
||||
Analyze messages from {peer_id} to extract **explicit atomic facts** about them.
|
||||
Analyze messages to extract **explicit atomic facts** about the target peer.
|
||||
|
||||
[EXPLICIT] DEFINITION: Facts about {peer_id} that can be derived directly from their messages.
|
||||
[EXPLICIT] DEFINITION: Facts about the target peer that can be derived directly from their messages.
|
||||
- Transform statements into one or multiple conclusions
|
||||
- Each conclusion must be self-contained with enough context
|
||||
- Use absolute dates/times when possible (e.g. "June 26, 2025" not "yesterday")
|
||||
|
||||
RULES:
|
||||
- Properly attribute observations to the correct subject: if it is about {peer_id}, say so. If {peer_id} is referencing someone or something else, make that clear.
|
||||
- Observations should make sense on their own. Each observation will be used in the future to better understand {peer_id}.
|
||||
- Extract ALL observations from {peer_id} messages, using others as context.
|
||||
- The target peer is the peer identified below under `Target peer:`.
|
||||
- A peer can be a human user, AI agent, bot, service, or other actor.
|
||||
- Use the exact peer id from `Target peer:` in final observations, not the phrase "the target peer".
|
||||
- Properly attribute observations to the correct subject: if it is about the target peer, use the exact peer id as the subject. If the target peer is referencing someone or something else, make that clear.
|
||||
- Observations should make sense on their own. Each observation will be used in the future to better understand the target peer.
|
||||
- Extract ALL observations from the target peer's messages, using others as context.
|
||||
- Contextualize each observation sufficiently (e.g. "Ann is nervous about the job interview at the pharmacy" not just "Ann is nervous")
|
||||
|
||||
EXAMPLES:
|
||||
- EXPLICIT: "I just had my 25th birthday last Saturday" → "{peer_id} is 25 years old", "{peer_id}'s birthday is June 21st"
|
||||
- EXPLICIT: "I took my dog for a walk in NYC" → "{peer_id} has a dog", "{peer_id} lives in NYC"
|
||||
- EXPLICIT: "{peer_id} attended college" + general knowledge → "{peer_id} completed high school or equivalent"
|
||||
EXAMPLES (using `alice` as the target peer id):
|
||||
- EXPLICIT: "I just had my 25th birthday last Saturday" → "alice is 25 years old", "alice's birthday is June 21st"
|
||||
- EXPLICIT: "I took my dog for a walk in NYC" → "alice has a dog", "alice lives in NYC"
|
||||
- EXPLICIT: "alice attended college" + general knowledge → "alice completed high school or equivalent"
|
||||
|
||||
{custom_instructions_section}
|
||||
|
||||
Target peer:
|
||||
{peer_id}
|
||||
|
||||
Messages to analyze:
|
||||
<messages>
|
||||
{messages}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import asyncio
|
||||
import contextlib
|
||||
import random
|
||||
import signal
|
||||
import time
|
||||
from asyncio import Task
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
|
|
@ -11,6 +14,7 @@ import sentry_sdk
|
|||
from dotenv import load_dotenv
|
||||
from nanoid import generate as generate_nanoid
|
||||
from sentry_sdk.integrations.asyncio import AsyncioIntegration
|
||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
from sqlalchemy import and_, delete, or_, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.engine import CursorResult
|
||||
|
|
@ -125,6 +129,22 @@ class QueueManager:
|
|||
self.worker_ownership: dict[str, WorkerOwnership] = {}
|
||||
self.queue_empty_flag: asyncio.Event = asyncio.Event()
|
||||
|
||||
# Current adaptive polling interval; grows while idle/erroring and
|
||||
# resets to the base interval as soon as work is claimed.
|
||||
self._current_poll_interval: float = (
|
||||
settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS
|
||||
)
|
||||
|
||||
# Monotonic timestamp of the last stale-work-unit cleanup ATTEMPT.
|
||||
# None -> the first poll always runs cleanup (recovers rows left stale
|
||||
# by a crashed predecessor immediately).
|
||||
self._last_stale_cleanup_attempt: float | None = None
|
||||
# Jittered gate width (seconds) sampled ONCE per attempt, so the deadline
|
||||
# for the next run is fixed when the timestamp is set rather than
|
||||
# re-rolled on every poll (which would make the effective spacing a
|
||||
# random walk and untestable at non-zero jitter ratios).
|
||||
self._stale_cleanup_gate_seconds: float = 0.0
|
||||
|
||||
# Initialize from settings
|
||||
self.workers: int = settings.DERIVER.WORKERS
|
||||
self.semaphore: asyncio.Semaphore = asyncio.Semaphore(self.workers)
|
||||
|
|
@ -147,7 +167,9 @@ class QueueManager:
|
|||
|
||||
# Initialize Sentry if enabled, using settings
|
||||
if settings.SENTRY.ENABLED:
|
||||
initialize_sentry(integrations=[AsyncioIntegration()])
|
||||
initialize_sentry(
|
||||
integrations=[AsyncioIntegration(), SqlalchemyIntegration()]
|
||||
)
|
||||
|
||||
def add_task(self, task: asyncio.Task[None]) -> None:
|
||||
"""Track a new task"""
|
||||
|
|
@ -196,6 +218,7 @@ class QueueManager:
|
|||
# Run the polling loop directly in this task
|
||||
logger.debug("Starting polling loop directly")
|
||||
try:
|
||||
await self._sleep_startup_jitter()
|
||||
await self.polling_loop()
|
||||
finally:
|
||||
await self.cleanup()
|
||||
|
|
@ -246,6 +269,35 @@ class QueueManager:
|
|||
# Polling and Scheduling #
|
||||
##########################
|
||||
|
||||
async def _maybe_cleanup_stale_work_units(self) -> None:
|
||||
"""Run stale-work-unit cleanup at most once per (jittered) interval.
|
||||
|
||||
Staleness is a minutes-timescale condition (STALE_SESSION_TIMEOUT_MINUTES),
|
||||
but the polling loop fires on a seconds timescale on every deriver
|
||||
instance — running cleanup unconditionally per poll multiplies into
|
||||
unnecessary write transactions. Gate it locally:
|
||||
concurrent cleaners on other instances remain safe via FOR UPDATE SKIP
|
||||
LOCKED, so no cross-instance coordination is required, and the jittered
|
||||
gate (sampled once per attempt) keeps instances from re-synchronizing
|
||||
their cleanup runs. The gate tracks the last ATTEMPT (set before
|
||||
running), so a failing cleanup waits a full interval instead of retrying
|
||||
every poll against a DB that is already struggling. An interval of 0
|
||||
preserves run-every-poll behavior.
|
||||
"""
|
||||
interval = settings.DERIVER.STALE_WORK_UNIT_CLEANUP_INTERVAL_SECONDS
|
||||
if (
|
||||
interval > 0.0
|
||||
and self._last_stale_cleanup_attempt is not None
|
||||
and time.monotonic() - self._last_stale_cleanup_attempt
|
||||
< self._stale_cleanup_gate_seconds
|
||||
):
|
||||
return
|
||||
# Record the attempt and fix the next deadline before running, so the
|
||||
# gate width is stable for this cycle and a failing cleanup still waits.
|
||||
self._last_stale_cleanup_attempt = time.monotonic()
|
||||
self._stale_cleanup_gate_seconds = self._jitter(interval)
|
||||
await self.cleanup_stale_work_units()
|
||||
|
||||
async def cleanup_stale_work_units(self) -> None:
|
||||
"""Clean up stale work units"""
|
||||
async with tracked_db("cleanup_stale_work_units") as db:
|
||||
|
|
@ -278,15 +330,19 @@ class QueueManager:
|
|||
async def get_and_claim_work_units(self) -> dict[str, str]:
|
||||
"""
|
||||
Get available work units that aren't being processed.
|
||||
For representation tasks, only returns work units with accumulated tokens
|
||||
>= REPRESENTATION_BATCH_MAX_TOKENS (forced batching), unless FLUSH_ENABLED is True.
|
||||
For representation tasks, only returns work units whose accumulated
|
||||
tokens reach REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS or whose
|
||||
oldest pending item exceeds REPRESENTATION_BATCH_MAX_AGE_SECONDS,
|
||||
unless FLUSH_ENABLED is True.
|
||||
Returns a dict mapping work_unit_key to aqs_id.
|
||||
"""
|
||||
limit: int = max(0, self.workers - self.get_total_owned_work_units())
|
||||
if limit == 0:
|
||||
return {}
|
||||
|
||||
batch_max_tokens = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS
|
||||
work_unit_target_tokens = (
|
||||
settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS
|
||||
)
|
||||
|
||||
async with tracked_db("get_available_work_units") as db:
|
||||
representation_prefix = "representation:"
|
||||
|
|
@ -294,6 +350,7 @@ class QueueManager:
|
|||
select(
|
||||
models.QueueItem.work_unit_key,
|
||||
func.sum(models.Message.token_count).label("total_tokens"),
|
||||
func.min(models.QueueItem.created_at).label("oldest_created_at"),
|
||||
)
|
||||
.join(
|
||||
models.Message,
|
||||
|
|
@ -306,15 +363,21 @@ class QueueManager:
|
|||
)
|
||||
|
||||
work_units_subq = (
|
||||
select(models.QueueItem.work_unit_key)
|
||||
select(
|
||||
models.QueueItem.work_unit_key,
|
||||
func.min(models.QueueItem.created_at).label("oldest_created_at"),
|
||||
)
|
||||
.where(~models.QueueItem.processed)
|
||||
.group_by(models.QueueItem.work_unit_key)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
query = (
|
||||
select(work_units_subq.c.work_unit_key)
|
||||
.limit(limit)
|
||||
select(
|
||||
work_units_subq.c.work_unit_key,
|
||||
token_stats_subq.c.total_tokens,
|
||||
token_stats_subq.c.oldest_created_at,
|
||||
)
|
||||
.outerjoin(
|
||||
token_stats_subq,
|
||||
work_units_subq.c.work_unit_key == token_stats_subq.c.work_unit_key,
|
||||
|
|
@ -327,22 +390,53 @@ class QueueManager:
|
|||
)
|
||||
.exists()
|
||||
)
|
||||
.order_by(
|
||||
work_units_subq.c.oldest_created_at.asc(),
|
||||
work_units_subq.c.work_unit_key.asc(),
|
||||
)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
# Apply batch threshold filter (skip if FLUSH_ENABLED is True)
|
||||
if not settings.DERIVER.FLUSH_ENABLED and batch_max_tokens > 0:
|
||||
if not settings.DERIVER.FLUSH_ENABLED and work_unit_target_tokens > 0:
|
||||
max_age_seconds = settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS
|
||||
threshold_clause = (
|
||||
func.coalesce(token_stats_subq.c.total_tokens, 0)
|
||||
>= work_unit_target_tokens
|
||||
)
|
||||
if max_age_seconds > 0:
|
||||
threshold_clause = or_(
|
||||
threshold_clause,
|
||||
token_stats_subq.c.oldest_created_at
|
||||
<= func.now() - timedelta(seconds=max_age_seconds),
|
||||
)
|
||||
query = query.where(
|
||||
or_(
|
||||
~work_units_subq.c.work_unit_key.startswith(
|
||||
representation_prefix
|
||||
),
|
||||
func.coalesce(token_stats_subq.c.total_tokens, 0)
|
||||
>= batch_max_tokens,
|
||||
threshold_clause,
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
available_units = result.scalars().all()
|
||||
available_rows = result.all()
|
||||
available_units: list[str] = []
|
||||
for work_unit_key, total_tokens, oldest_created_at in available_rows:
|
||||
available_units.append(work_unit_key)
|
||||
if (
|
||||
not settings.DERIVER.FLUSH_ENABLED
|
||||
and settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS > 0
|
||||
and work_unit_key.startswith(representation_prefix)
|
||||
and int(total_tokens or 0) < work_unit_target_tokens
|
||||
):
|
||||
logger.info(
|
||||
"age-flushing work unit %s (tokens=%s < %s, oldest=%s)",
|
||||
work_unit_key,
|
||||
total_tokens or 0,
|
||||
work_unit_target_tokens,
|
||||
oldest_created_at,
|
||||
)
|
||||
if not available_units:
|
||||
await db.commit()
|
||||
return {}
|
||||
|
|
@ -378,27 +472,77 @@ class QueueManager:
|
|||
)
|
||||
return claimed_mapping
|
||||
|
||||
def _reset_poll_interval(self) -> None:
|
||||
"""Snap the polling interval back to the base after finding work."""
|
||||
self._current_poll_interval = settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS
|
||||
|
||||
def _jitter(self, seconds: float) -> float:
|
||||
"""Scatter a sleep by +/- POLLING_JITTER_RATIO to avoid lockstep polling.
|
||||
|
||||
Returns a uniform-random value in [(1-ratio)*seconds, (1+ratio)*seconds].
|
||||
Only the returned sleep is scattered; the underlying backoff schedule is
|
||||
left unchanged. A ratio of 0.0 returns ``seconds`` unchanged.
|
||||
"""
|
||||
ratio = settings.DERIVER.POLLING_JITTER_RATIO
|
||||
if ratio <= 0.0:
|
||||
return seconds
|
||||
# Scheduling jitter, not security/crypto — stdlib random is appropriate.
|
||||
return seconds * random.uniform(1.0 - ratio, 1.0 + ratio) # nosec B311
|
||||
|
||||
async def _sleep_startup_jitter(self) -> None:
|
||||
"""Sleep a random delay before the first poll so instances that start
|
||||
together don't poll in lockstep. Interruptible by shutdown so a signal
|
||||
during the delay exits promptly. No-op when the window is 0.0.
|
||||
"""
|
||||
window = settings.DERIVER.POLLING_STARTUP_JITTER_SECONDS
|
||||
if window <= 0.0:
|
||||
return
|
||||
# Scheduling jitter, not security/crypto — stdlib random is appropriate.
|
||||
delay = random.uniform(0.0, window) # nosec B311
|
||||
logger.debug(f"Startup poll jitter: sleeping {delay:.1f}s before first poll")
|
||||
# Timeout (slept the full delay without a shutdown) is the normal path;
|
||||
# an early return means shutdown fired and polling_loop will exit at once.
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(self.shutdown_event.wait(), timeout=delay)
|
||||
|
||||
def _advance_poll_interval(self) -> float:
|
||||
"""Return the current idle/backoff sleep, then grow it toward the cap."""
|
||||
interval = self._current_poll_interval
|
||||
if settings.DERIVER.POLLING_BACKOFF_ENABLED:
|
||||
self._current_poll_interval = min(
|
||||
self._current_poll_interval
|
||||
* settings.DERIVER.POLLING_BACKOFF_MULTIPLIER,
|
||||
settings.DERIVER.POLLING_SLEEP_MAX_INTERVAL_SECONDS,
|
||||
)
|
||||
return self._jitter(interval)
|
||||
|
||||
async def polling_loop(self) -> None:
|
||||
"""Main polling loop to find and process new work units"""
|
||||
logger.debug("Starting polling loop")
|
||||
try:
|
||||
while not self.shutdown_event.is_set():
|
||||
if self.queue_empty_flag.is_set():
|
||||
# logger.debug("Queue empty flag set, waiting")
|
||||
await asyncio.sleep(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS)
|
||||
# The empty-poll branch below already slept this cycle's
|
||||
# interval; just clear the flag and re-query (no second
|
||||
# sleep — that would double the effective idle interval).
|
||||
self.queue_empty_flag.clear()
|
||||
continue
|
||||
|
||||
# Check if we have capacity before querying
|
||||
# Check if we have capacity before querying. There is work to do
|
||||
# (workers are busy), so keep the base interval for fast pickup
|
||||
# when capacity frees rather than backing off.
|
||||
if self.semaphore.locked():
|
||||
# logger.debug("All workers busy, waiting")
|
||||
await asyncio.sleep(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS)
|
||||
await asyncio.sleep(
|
||||
self._jitter(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS)
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await self.cleanup_stale_work_units()
|
||||
await self._maybe_cleanup_stale_work_units()
|
||||
claimed_work_units = await self.get_and_claim_work_units()
|
||||
if claimed_work_units:
|
||||
self._reset_poll_interval()
|
||||
for work_unit_key, aqs_id in claimed_work_units.items():
|
||||
# Create a new task for processing this work unit
|
||||
if not self.shutdown_event.is_set():
|
||||
|
|
@ -414,15 +558,14 @@ class QueueManager:
|
|||
self.add_task(task)
|
||||
else:
|
||||
self.queue_empty_flag.set()
|
||||
await asyncio.sleep(
|
||||
settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS
|
||||
)
|
||||
await asyncio.sleep(self._advance_poll_interval())
|
||||
except Exception as e:
|
||||
logger.exception("Error in polling loop")
|
||||
if settings.SENTRY.ENABLED:
|
||||
sentry_sdk.capture_exception(e)
|
||||
# Note: rollback is handled by tracked_db dependency
|
||||
await asyncio.sleep(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS)
|
||||
# Note: rollback is handled by tracked_db dependency.
|
||||
# Back off so a down/saturated DB isn't hammered every cycle.
|
||||
await asyncio.sleep(self._advance_poll_interval())
|
||||
finally:
|
||||
logger.info("Polling loop stopped")
|
||||
|
||||
|
|
@ -675,7 +818,7 @@ class QueueManager:
|
|||
f"{task_type} tasks are not supported for get_queue_item_batch"
|
||||
)
|
||||
|
||||
batch_max_tokens = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS
|
||||
batch_max_tokens = settings.DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS
|
||||
was_flush_enabled = settings.DERIVER.FLUSH_ENABLED
|
||||
parsed_key = parse_work_unit_key(work_unit_key)
|
||||
messages_context: list[models.Message] = []
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ using the DialecticAgent.
|
|||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src import crud, schemas
|
||||
from src.config import ReasoningLevel
|
||||
from src.dependencies import tracked_db
|
||||
|
|
@ -24,6 +26,8 @@ async def agentic_chat(
|
|||
observer: str,
|
||||
observed: str,
|
||||
reasoning_level: ReasoningLevel = "low",
|
||||
session_allowlist: list[str] | None = None,
|
||||
response_model: type[BaseModel] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Answer a query about a peer using the agentic dialectic.
|
||||
|
|
@ -35,12 +39,15 @@ async def agentic_chat(
|
|||
observer: The peer making the query
|
||||
observed: The peer being queried about
|
||||
reasoning_level: Level of reasoning to apply
|
||||
session_allowlist: Optional session allowlist restricting all recall
|
||||
response_model: Optional Pydantic model the answer must conform to.
|
||||
When set, the returned string is JSON matching the model's schema.
|
||||
|
||||
Returns:
|
||||
The synthesized answer string
|
||||
"""
|
||||
# Short-lived DB session for validation + config
|
||||
async with tracked_db("dialectic.preflight") as db:
|
||||
async with tracked_db("dialectic.preflight", read_only=True) as db:
|
||||
await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observer))
|
||||
if observer != observed:
|
||||
await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observed))
|
||||
|
|
@ -50,6 +57,9 @@ async def agentic_chat(
|
|||
session = await crud.get_session(
|
||||
db, workspace_name=workspace_name, session_name=session_name
|
||||
)
|
||||
# Read the opaque Session.id while the instance is still bound; the ORM
|
||||
# object detaches once this read-only session closes below.
|
||||
session_id = session.id if session else None
|
||||
workspace = await crud.get_workspace(db, workspace_name=workspace_name)
|
||||
configuration = get_configuration(None, session, workspace)
|
||||
|
||||
|
|
@ -68,14 +78,16 @@ async def agentic_chat(
|
|||
agent = DialecticAgent(
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
session_id=session_id,
|
||||
observer=observer,
|
||||
observed=observed,
|
||||
observer_peer_card=observer_peer_card,
|
||||
observed_peer_card=observed_peer_card,
|
||||
reasoning_level=reasoning_level,
|
||||
session_allowlist=session_allowlist,
|
||||
)
|
||||
|
||||
return await agent.answer(query)
|
||||
return await agent.answer(query, response_model=response_model)
|
||||
|
||||
|
||||
async def agentic_chat_stream(
|
||||
|
|
@ -85,6 +97,8 @@ async def agentic_chat_stream(
|
|||
observer: str,
|
||||
observed: str,
|
||||
reasoning_level: ReasoningLevel = "low",
|
||||
session_allowlist: list[str] | None = None,
|
||||
response_model: type[BaseModel] | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""
|
||||
Stream an answer to a query about a peer using the agentic dialectic.
|
||||
|
|
@ -96,12 +110,16 @@ async def agentic_chat_stream(
|
|||
observer: The peer making the query
|
||||
observed: The peer being queried about
|
||||
reasoning_level: Level of reasoning to apply
|
||||
session_allowlist: Optional session allowlist restricting all recall
|
||||
response_model: Optional Pydantic model the answer must conform to.
|
||||
When set, the streamed text accumulates to JSON matching the
|
||||
model's schema.
|
||||
|
||||
Yields:
|
||||
Chunks of the response text as they are generated
|
||||
"""
|
||||
# Short-lived DB session for validation + config
|
||||
async with tracked_db("dialectic.preflight") as db:
|
||||
async with tracked_db("dialectic.preflight", read_only=True) as db:
|
||||
await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observer))
|
||||
if observer != observed:
|
||||
await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observed))
|
||||
|
|
@ -111,6 +129,9 @@ async def agentic_chat_stream(
|
|||
session = await crud.get_session(
|
||||
db, workspace_name=workspace_name, session_name=session_name
|
||||
)
|
||||
# Read the opaque Session.id while the instance is still bound; the ORM
|
||||
# object detaches once this read-only session closes below.
|
||||
session_id = session.id if session else None
|
||||
workspace = await crud.get_workspace(db, workspace_name=workspace_name)
|
||||
configuration = get_configuration(None, session, workspace)
|
||||
|
||||
|
|
@ -129,12 +150,14 @@ async def agentic_chat_stream(
|
|||
agent = DialecticAgent(
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
session_id=session_id,
|
||||
observer=observer,
|
||||
observed=observed,
|
||||
observer_peer_card=observer_peer_card,
|
||||
observed_peer_card=observed_peer_card,
|
||||
reasoning_level=reasoning_level,
|
||||
session_allowlist=session_allowlist,
|
||||
)
|
||||
|
||||
async for chunk in agent.answer_stream(query):
|
||||
async for chunk in agent.answer_stream(query, response_model=response_model):
|
||||
yield chunk
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from collections.abc import AsyncIterator, Callable
|
|||
from typing import Any, cast
|
||||
|
||||
from nanoid import generate as generate_nanoid
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src import crud
|
||||
from src.config import ConfiguredModelSettings, ReasoningLevel, settings
|
||||
|
|
@ -68,6 +69,8 @@ class DialecticAgent:
|
|||
observed_peer_card: list[str] | None = None,
|
||||
metric_key: str | None = None,
|
||||
reasoning_level: ReasoningLevel = "low",
|
||||
session_id: str | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the dialectic agent.
|
||||
|
|
@ -81,9 +84,15 @@ class DialecticAgent:
|
|||
observed_peer_card: Biographical information about the observed peer
|
||||
metric_key: Optional key for logging metrics (if provided, agent won't log separately)
|
||||
reasoning_level: Level of reasoning to apply
|
||||
session_id: ID used for grouping traces (not session_name)
|
||||
session_allowlist: Optional session allowlist restricting all recall
|
||||
(conclusions and messages) to these sessions; empty list
|
||||
fails closed
|
||||
"""
|
||||
self.workspace_name: str = workspace_name
|
||||
self.session_name: str | None = session_name
|
||||
self.session_allowlist: list[str] | None = session_allowlist
|
||||
self.session_id: str | None = session_id
|
||||
self.observer: str = observer
|
||||
self.observed: str = observed
|
||||
self.observer_peer_card: list[str] | None = observer_peer_card
|
||||
|
|
@ -104,6 +113,24 @@ class DialecticAgent:
|
|||
self._prefetched_conclusion_count: int = 0
|
||||
self._run_id: str = generate_nanoid() # Always generate for event correlation
|
||||
|
||||
def _select_tools(self) -> list[dict[str, Any]]:
|
||||
"""Pick the toolset for this query.
|
||||
|
||||
Minimal reasoning uses a reduced set to reduce cost. Under a session
|
||||
allowlist `get_reasoning_chain` is dropped entirely rather than left in
|
||||
to fail at call time: chains traverse provenance across sessions, so it
|
||||
can't be scoped, and offering it costs both the schema in context and a
|
||||
wasted turn when the model tries it.
|
||||
"""
|
||||
tools = (
|
||||
DIALECTIC_TOOLS_MINIMAL
|
||||
if self.reasoning_level == "minimal"
|
||||
else DIALECTIC_TOOLS
|
||||
)
|
||||
if self.session_allowlist is not None:
|
||||
tools = [t for t in tools if t.get("name") != "get_reasoning_chain"]
|
||||
return tools
|
||||
|
||||
async def _initialize_session_history(self) -> None:
|
||||
"""Fetch and inject session history into the system prompt if configured."""
|
||||
if self._session_history_initialized:
|
||||
|
|
@ -121,7 +148,7 @@ class DialecticAgent:
|
|||
token_limit=max_tokens,
|
||||
reverse=False, # chronological order
|
||||
)
|
||||
async with tracked_db("dialectic.session_history") as db:
|
||||
async with tracked_db("dialectic.session_history", read_only=True) as db:
|
||||
result = await db.execute(stmt)
|
||||
messages = result.scalars().all()
|
||||
|
||||
|
|
@ -179,6 +206,7 @@ class DialecticAgent:
|
|||
workspace_name=self.workspace_name,
|
||||
run_id=self._run_id,
|
||||
parent_category="dialectic",
|
||||
session_id=self.session_id,
|
||||
):
|
||||
query_embedding = await embedding_client.embed(query)
|
||||
|
||||
|
|
@ -192,6 +220,7 @@ class DialecticAgent:
|
|||
limit=prefetch_limit,
|
||||
levels=["explicit"],
|
||||
embedding=query_embedding,
|
||||
session_allowlist=self.session_allowlist,
|
||||
)
|
||||
|
||||
derived_repr = await search_memory(
|
||||
|
|
@ -202,6 +231,7 @@ class DialecticAgent:
|
|||
limit=prefetch_limit,
|
||||
levels=["deductive", "inductive", "contradiction"],
|
||||
embedding=query_embedding,
|
||||
session_allowlist=self.session_allowlist,
|
||||
)
|
||||
|
||||
if explicit_repr.is_empty() and derived_repr.is_empty():
|
||||
|
|
@ -291,6 +321,7 @@ class DialecticAgent:
|
|||
] = await create_tool_executor(
|
||||
workspace_name=self.workspace_name,
|
||||
session_name=self.session_name,
|
||||
session_allowlist=self.session_allowlist,
|
||||
observer=self.observer,
|
||||
observed=self.observed,
|
||||
history_token_limit=settings.DIALECTIC.HISTORY_TOKEN_LIMIT,
|
||||
|
|
@ -301,13 +332,14 @@ class DialecticAgent:
|
|||
|
||||
return tool_executor, task_name, run_id, start_time
|
||||
|
||||
def _telemetry_context(self) -> LLMTelemetryContext:
|
||||
def _telemetry_context(self, track_name: str | None = None) -> LLMTelemetryContext:
|
||||
"""Build the LLMTelemetryContext shared by answer() and answer_stream().
|
||||
|
||||
Carries the instance's `_run_id` (always set in __init__) + workspace +
|
||||
peer identifiers so LLMCallCompletedEvent and 's
|
||||
AgentIterationEvent can attribute every per-iteration LLM call back to
|
||||
this dialectic invocation.
|
||||
this dialectic invocation. `track_name` names the Langfuse trace/step
|
||||
(e.g. "Dialectic Agent" vs "Dialectic Agent Stream").
|
||||
"""
|
||||
return LLMTelemetryContext(
|
||||
workspace_name=self.workspace_name,
|
||||
|
|
@ -315,7 +347,11 @@ class DialecticAgent:
|
|||
parent_category="dialectic",
|
||||
agent_type="dialectic",
|
||||
run_id=self._run_id,
|
||||
trace_id=self._run_id,
|
||||
span_id=self._run_id,
|
||||
session_id=self.session_id,
|
||||
peer_name=self.observed,
|
||||
track_name=track_name,
|
||||
)
|
||||
|
||||
def _log_response_metrics(
|
||||
|
|
@ -404,7 +440,9 @@ class DialecticAgent:
|
|||
)
|
||||
)
|
||||
|
||||
async def answer(self, query: str) -> str:
|
||||
async def answer(
|
||||
self, query: str, response_model: type[BaseModel] | None = None
|
||||
) -> str:
|
||||
"""
|
||||
Answer a query about the peer using agentic tool calling.
|
||||
|
||||
|
|
@ -415,6 +453,8 @@ class DialecticAgent:
|
|||
|
||||
Args:
|
||||
query: The question to answer about the peer
|
||||
response_model: Optional Pydantic model the final synthesis must
|
||||
conform to. When set, the returned string is JSON.
|
||||
|
||||
Returns:
|
||||
The synthesized answer string
|
||||
|
|
@ -424,12 +464,7 @@ class DialecticAgent:
|
|||
# Get level-specific settings
|
||||
level_settings = settings.DIALECTIC.LEVELS[self.reasoning_level]
|
||||
|
||||
# Use minimal tools for minimal reasoning to reduce cost
|
||||
tools = (
|
||||
DIALECTIC_TOOLS_MINIMAL
|
||||
if self.reasoning_level == "minimal"
|
||||
else DIALECTIC_TOOLS
|
||||
)
|
||||
tools = self._select_tools()
|
||||
# Use level-specific max_output_tokens if set, otherwise global default
|
||||
max_tokens = (
|
||||
level_settings.MAX_OUTPUT_TOKENS
|
||||
|
|
@ -437,26 +472,38 @@ class DialecticAgent:
|
|||
else settings.DIALECTIC.MAX_OUTPUT_TOKENS
|
||||
)
|
||||
|
||||
response: HonchoLLMCallResponse[str] = await honcho_llm_call(
|
||||
model_config=_get_dialectic_level_model_config(self.reasoning_level),
|
||||
prompt="", # Ignored since we pass messages
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
tool_choice=level_settings.TOOL_CHOICE,
|
||||
tool_executor=tool_executor,
|
||||
max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS,
|
||||
messages=self.messages,
|
||||
track_name="Dialectic Agent",
|
||||
max_input_tokens=settings.DIALECTIC.MAX_INPUT_TOKENS,
|
||||
trace_name="dialectic_chat",
|
||||
telemetry=self._telemetry_context(),
|
||||
# cast: `type[BaseModel] | None` matches neither the parsed nor the
|
||||
# plain-text overload statically, so pyright resolves the stream
|
||||
# overload — but without stream=True the call is non-streaming.
|
||||
response = cast( # pyright: ignore[reportInvalidCast]
|
||||
HonchoLLMCallResponse[Any],
|
||||
await honcho_llm_call(
|
||||
model_config=_get_dialectic_level_model_config(self.reasoning_level),
|
||||
prompt="", # Ignored since we pass messages
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
tool_choice=level_settings.TOOL_CHOICE,
|
||||
tool_executor=tool_executor,
|
||||
max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS,
|
||||
messages=self.messages,
|
||||
max_input_tokens=settings.DIALECTIC.MAX_INPUT_TOKENS,
|
||||
trace_name="dialectic_chat",
|
||||
telemetry=self._telemetry_context(track_name="Dialectic Agent"),
|
||||
response_model=response_model,
|
||||
),
|
||||
)
|
||||
|
||||
# With response_model, the backend parses content into a model
|
||||
# instance; the API contract is a JSON string.
|
||||
content = response.content
|
||||
if isinstance(content, BaseModel):
|
||||
content = content.model_dump_json(by_alias=True)
|
||||
|
||||
self._log_response_metrics(
|
||||
task_name=task_name,
|
||||
run_id=run_id,
|
||||
start_time=start_time,
|
||||
response_content=response.content,
|
||||
response_content=content,
|
||||
input_tokens=response.input_tokens,
|
||||
output_tokens=response.output_tokens,
|
||||
cache_read_input_tokens=response.cache_read_input_tokens,
|
||||
|
|
@ -467,9 +514,11 @@ class DialecticAgent:
|
|||
hit_input_token_cap=response.hit_input_token_cap,
|
||||
)
|
||||
|
||||
return response.content
|
||||
return content
|
||||
|
||||
async def answer_stream(self, query: str) -> AsyncIterator[str]:
|
||||
async def answer_stream(
|
||||
self, query: str, response_model: type[BaseModel] | None = None
|
||||
) -> AsyncIterator[str]:
|
||||
"""
|
||||
Answer a query about the peer using agentic tool calling, streaming the response.
|
||||
|
||||
|
|
@ -480,6 +529,9 @@ class DialecticAgent:
|
|||
|
||||
Args:
|
||||
query: The question to answer about the peer
|
||||
response_model: Optional Pydantic model the final synthesis must
|
||||
conform to. When set, the streamed text accumulates to JSON
|
||||
(chunks are raw text; no parsing happens on the stream path).
|
||||
|
||||
Yields:
|
||||
Chunks of the response text as they are generated
|
||||
|
|
@ -489,12 +541,7 @@ class DialecticAgent:
|
|||
# Get level-specific settings
|
||||
level_settings = settings.DIALECTIC.LEVELS[self.reasoning_level]
|
||||
|
||||
# Use minimal tools for minimal reasoning to reduce cost
|
||||
tools = (
|
||||
DIALECTIC_TOOLS_MINIMAL
|
||||
if self.reasoning_level == "minimal"
|
||||
else DIALECTIC_TOOLS
|
||||
)
|
||||
tools = self._select_tools()
|
||||
# Use level-specific max_output_tokens if set, otherwise global default
|
||||
max_tokens = (
|
||||
level_settings.MAX_OUTPUT_TOKENS
|
||||
|
|
@ -515,10 +562,10 @@ class DialecticAgent:
|
|||
tool_executor=tool_executor,
|
||||
max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS,
|
||||
messages=self.messages,
|
||||
track_name="Dialectic Agent Stream",
|
||||
max_input_tokens=settings.DIALECTIC.MAX_INPUT_TOKENS,
|
||||
trace_name="dialectic_chat",
|
||||
telemetry=self._telemetry_context(),
|
||||
telemetry=self._telemetry_context(track_name="Dialectic Agent Stream"),
|
||||
response_model=response_model,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ class DreamScheduler:
|
|||
configuration = get_configuration(None, session, workspace)
|
||||
|
||||
if not configuration.dream.enabled:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
f"Dreams disabled for {workspace_name}/{session_name}, skipping dream"
|
||||
)
|
||||
return
|
||||
|
|
@ -319,7 +319,7 @@ async def check_and_schedule_dream(
|
|||
).total_seconds() / 3600
|
||||
|
||||
if hours_since_last_dream < settings.DREAM.MIN_HOURS_BETWEEN_DREAMS:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
f"Skipping dream for {collection.observer}/{collection.observed}: only {hours_since_last_dream:.1f} hours "
|
||||
+ f"since last dream (minimum: {settings.DREAM.MIN_HOURS_BETWEEN_DREAMS})"
|
||||
)
|
||||
|
|
@ -359,7 +359,7 @@ async def check_and_schedule_dream(
|
|||
)
|
||||
)
|
||||
if pending_exists:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"Skipping dream schedule for %s/%s: pending dream already in queue",
|
||||
collection.observer,
|
||||
collection.observed,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,11 @@ from sqlalchemy import func, select
|
|||
from src import crud, models
|
||||
from src.config import settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.dreamer.specialists import SPECIALISTS, SpecialistResult
|
||||
from src.dreamer.specialists import (
|
||||
SPECIALISTS,
|
||||
CardRefreshSpecialist,
|
||||
SpecialistResult,
|
||||
)
|
||||
from src.dreamer.surprisal import SurprisalScore # type: ignore
|
||||
from src.exceptions import SurprisalError
|
||||
from src.schemas import DreamType
|
||||
|
|
@ -307,6 +311,151 @@ async def run_dream(
|
|||
)
|
||||
|
||||
|
||||
async def run_card_refresh_dream(
|
||||
workspace_name: str,
|
||||
observer: str,
|
||||
observed: str,
|
||||
session_name: str | None = None,
|
||||
*,
|
||||
rebuild: bool = False,
|
||||
dream_type: str | None = None,
|
||||
trigger_reason: str | None = None,
|
||||
delay_reason: str | None = None,
|
||||
) -> DreamResult | None:
|
||||
"""
|
||||
Run a lightweight card-only refresh dream.
|
||||
|
||||
Runs a single CardRefreshSpecialist restricted to peer-card tools
|
||||
(get_recent_observations, search_memory, update_peer_card) with a low
|
||||
tool-iteration cap. It never creates or deletes observations.
|
||||
|
||||
Args:
|
||||
workspace_name: Workspace identifier
|
||||
observer: Observer peer name
|
||||
observed: Observed peer name
|
||||
session_name: Session identifier if specified
|
||||
rebuild: When True the existing card is NOT injected into the prompt
|
||||
and the specialist rebuilds it solely from observations present in
|
||||
the collection (used after removals).
|
||||
"""
|
||||
if not settings.DREAM.ENABLED:
|
||||
return None
|
||||
|
||||
run_id = generate_nanoid()
|
||||
task_name = f"dream_orchestrator_{run_id}"
|
||||
start_time = time.perf_counter()
|
||||
|
||||
logger.info(
|
||||
f"[{run_id}] Starting card-refresh dream for {workspace_name}/{observer}/{observed} (rebuild={rebuild})"
|
||||
)
|
||||
|
||||
# Short-lived DB session for config resolution
|
||||
async with tracked_db("dream.config") as db:
|
||||
if session_name is not None:
|
||||
session = await crud.get_session(
|
||||
db, workspace_name=workspace_name, session_name=session_name
|
||||
)
|
||||
else:
|
||||
session = None
|
||||
|
||||
workspace = await crud.get_workspace(db, workspace_name=workspace_name)
|
||||
configuration = get_configuration(None, session, workspace)
|
||||
if not configuration.dream.enabled:
|
||||
logger.info(
|
||||
f"[{run_id}] Dreams disabled for {workspace_name}/{session_name}, skipping card refresh"
|
||||
)
|
||||
return None
|
||||
if not configuration.peer_card.create:
|
||||
logger.info(
|
||||
f"[{run_id}] Peer card creation disabled for {workspace_name}, skipping card refresh"
|
||||
)
|
||||
return None
|
||||
|
||||
specialist_success = False
|
||||
specialist_result: SpecialistResult | None = None
|
||||
duration_ms = 0.0
|
||||
try:
|
||||
specialist = CardRefreshSpecialist(rebuild=rebuild)
|
||||
try:
|
||||
specialist_result = await specialist.run(
|
||||
workspace_name=workspace_name,
|
||||
observer=observer,
|
||||
observed=observed,
|
||||
session_name=session_name,
|
||||
configuration=configuration,
|
||||
parent_run_id=run_id,
|
||||
)
|
||||
logger.info(
|
||||
f"[{run_id}] Card refresh completed: {specialist_result.content[:200]}..."
|
||||
)
|
||||
accumulate_metric(
|
||||
task_name, "card_refresh_result", specialist_result.content, "blob"
|
||||
)
|
||||
specialist_success = specialist_result.success
|
||||
except Exception as e:
|
||||
# Exception (not BaseException) — CancelledError must propagate so
|
||||
# the worker can shut down; the finally still emits the run event.
|
||||
logger.error(
|
||||
f"[{run_id}] Card refresh specialist failed: {e}", exc_info=True
|
||||
)
|
||||
accumulate_metric(task_name, "card_refresh_error", str(e), "blob")
|
||||
|
||||
duration_ms = (time.perf_counter() - start_time) * 1000
|
||||
accumulate_metric(task_name, "total_duration", duration_ms, "ms")
|
||||
logger.info(f"[{run_id}] Card-refresh dream completed in {duration_ms:.0f}ms")
|
||||
log_performance_metrics("dream_orchestrator", run_id)
|
||||
finally:
|
||||
# Emit DreamRunEvent unconditionally so analytics see a parent for the
|
||||
# specialist event, mirroring run_dream. Card refresh is a
|
||||
# deduction-family run, so its outcome rides on deduction_success.
|
||||
if duration_ms == 0.0:
|
||||
duration_ms = (time.perf_counter() - start_time) * 1000
|
||||
try:
|
||||
emit(
|
||||
DreamRunEvent(
|
||||
run_id=run_id,
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
observer=observer,
|
||||
observed=observed,
|
||||
specialists_run=["card_refresh"],
|
||||
deduction_success=specialist_success,
|
||||
induction_success=False,
|
||||
surprisal_enabled=False,
|
||||
surprisal_conclusion_count=0,
|
||||
total_iterations=(
|
||||
specialist_result.iterations if specialist_result else 0
|
||||
),
|
||||
total_input_tokens=(
|
||||
specialist_result.input_tokens if specialist_result else 0
|
||||
),
|
||||
total_output_tokens=(
|
||||
specialist_result.output_tokens if specialist_result else 0
|
||||
),
|
||||
total_duration_ms=duration_ms,
|
||||
dream_type=dream_type,
|
||||
enabled_types_count=len(settings.DREAM.ENABLED_TYPES),
|
||||
trigger_reason=trigger_reason,
|
||||
delay_reason=delay_reason,
|
||||
)
|
||||
)
|
||||
except Exception: # pragma: no cover - telemetry must not raise
|
||||
logger.debug("Failed to emit DreamRunEvent", exc_info=True)
|
||||
|
||||
return DreamResult(
|
||||
run_id=run_id,
|
||||
specialists_run=["card_refresh"],
|
||||
deduction_success=specialist_success,
|
||||
induction_success=False,
|
||||
surprisal_enabled=False,
|
||||
surprisal_conclusion_count=0,
|
||||
total_iterations=specialist_result.iterations if specialist_result else 0,
|
||||
total_duration_ms=duration_ms,
|
||||
input_tokens=specialist_result.input_tokens if specialist_result else 0,
|
||||
output_tokens=specialist_result.output_tokens if specialist_result else 0,
|
||||
)
|
||||
|
||||
|
||||
def _create_queries_from_surprisal(
|
||||
high_surprisal_obs: list[SurprisalScore],
|
||||
) -> list[str]:
|
||||
|
|
@ -401,6 +550,28 @@ DREAM: {payload.dream_type} documents for {workspace_name}/{payload.observer}/{p
|
|||
update_data={"dream": dream_meta},
|
||||
)
|
||||
|
||||
case DreamType.CARD_REFRESH:
|
||||
# Card-only refresh: never touches observations and never
|
||||
# advances the omni dream guard pair (last_dream_at /
|
||||
# last_dream_document_count) — a card refresh must not delay
|
||||
# or satisfy consolidation scheduling.
|
||||
result = await run_card_refresh_dream(
|
||||
workspace_name=workspace_name,
|
||||
observer=payload.observer,
|
||||
observed=payload.observed,
|
||||
session_name=payload.session_name,
|
||||
rebuild=payload.rebuild,
|
||||
dream_type=payload.dream_type.value,
|
||||
trigger_reason=payload.trigger_reason,
|
||||
delay_reason=payload.delay_reason,
|
||||
)
|
||||
if result is not None:
|
||||
logger.info(
|
||||
f"Card-refresh dream completed: run_id={result.run_id}, "
|
||||
+ f"iterations={result.total_iterations}, "
|
||||
+ f"duration={result.total_duration_ms:.0f}ms"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing dream task {payload.dream_type} for {payload.observer}/{payload.observed}: {str(e)}",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from src.telemetry.events import DreamSpecialistEvent, emit
|
|||
from src.telemetry.logging import accumulate_metric, log_performance_metrics
|
||||
from src.telemetry.prometheus.metrics import TokenTypes
|
||||
from src.utils.agent_tools import (
|
||||
CARD_REFRESH_SPECIALIST_TOOLS,
|
||||
DEDUCTION_SPECIALIST_TOOLS,
|
||||
INDUCTION_SPECIALIST_TOOLS,
|
||||
create_tool_executor,
|
||||
|
|
@ -70,6 +71,64 @@ class SpecialistResult:
|
|||
# Tool names to exclude when peer card creation is disabled
|
||||
PEER_CARD_TOOL_NAMES = {"update_peer_card"}
|
||||
|
||||
# Shared PEER CARD system-prompt section (identity-store taxonomy + rules).
|
||||
# Used verbatim by DeductionSpecialist and CardRefreshSpecialist.
|
||||
PEER_CARD_SYSTEM_SECTION = """
|
||||
|
||||
## PEER CARD (REQUIRED)
|
||||
|
||||
The peer card is the target observee's identity store: stable identity markers that distinguish this entity from others and persist across interactions. Behavior, tendencies, transient state, and episodic facts belong in observations, not on the peer card.
|
||||
|
||||
A peer can be anything with identity that changes over time — a human, an agent, a codebase, a team, an organization. Do not assume the target observee is human. Do not require any field; empty is the correct output when evidence is absent.
|
||||
|
||||
### Allowed entry kinds
|
||||
|
||||
Each entry must start with one of these four prefixes (exact case, followed by a space):
|
||||
|
||||
- `IDENTITY: ...` — canonical name, kind, aliases, IDs
|
||||
- `IDENTITY: Name: Alice`
|
||||
- `IDENTITY: Kind: Python monorepo`
|
||||
- `IDENTITY: Version: 4.2`
|
||||
- `IDENTITY: Aliases: alice@example.com`
|
||||
- `ATTRIBUTE: ...` — stable durable property of the entity (including explicitly stated standing preferences)
|
||||
- `ATTRIBUTE: Location: NYC`
|
||||
- `ATTRIBUTE: Language: Python`
|
||||
- `ATTRIBUTE: Prefers tea`
|
||||
- `ATTRIBUTE: Charter: ship Honcho infrastructure`
|
||||
- `RELATIONSHIP: ...` — durable link to another entity
|
||||
- `RELATIONSHIP: Spouse: Bob`
|
||||
- `RELATIONSHIP: Maintainer: vineeth`
|
||||
- `RELATIONSHIP: Members: vineeth, rajat`
|
||||
- `INSTRUCTION: ...` — standing rule of engagement that the target observee has explicitly stated (do/don't for the observer). Only when explicit; never inferred from behavior.
|
||||
- `INSTRUCTION: Call me Vee`
|
||||
- `INSTRUCTION: Never push to main without review`
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Stable.** If the value plausibly changes within six months absent a deliberate announcement, it does not belong on the card. Prefer leaving the card empty over filling it with volatile content.
|
||||
2. **Subject is the target observee.** Every entry must be a fact about the target observee, not about another participant in the session. Never write facts about co-occurring peers into the card, no matter how frequently they appear in the messages.
|
||||
3. **Evidence-grounded.** Only write what the target observee has explicitly stated, or what another participant has explicitly stated about the target observee with the target observee's assent. No "general knowledge" inferences (`"co-founder"` does not imply an age; mentioning a colleague does not imply a family relationship).
|
||||
4. **Type-agnostic.** The target observee may not be human. Do not require name/age/location/family/occupation fields.
|
||||
5. **No behavioral content.** TRAITs, behavioral tendencies, patterns, and inferred preferences belong in observations, not on the peer card. Do not write `TRAIT:` entries or behavioral `PREFERENCE:` entries — they will be rejected.
|
||||
6. **No evidence bundles.** Each entry is one concise fact. No `e.g.` clauses, no parenthetical example lists, no semicolon-separated value dumps.
|
||||
|
||||
### Migrating an existing peer card
|
||||
|
||||
The CURRENT PEER CARD shown in the user message may contain entries from an older format that do not start with an allowed prefix (e.g. `Name: Alice`, `Lives in NYC`, `TRAIT: Analytical`, `PREFERENCE: Detailed explanations`). When you call `update_peer_card`, you are responsible for re-emitting the entries you want to keep — entries you omit are dropped, and entries without an allowed prefix are silently rejected.
|
||||
|
||||
For each legacy entry:
|
||||
|
||||
- If it is still a valid identity marker, re-emit it under the correct prefix and keep the original content where reasonable. Examples:
|
||||
- `Name: Alice` → `IDENTITY: Name: Alice`
|
||||
- `Lives in NYC` → `ATTRIBUTE: Location: NYC`
|
||||
- `Works at Google` → `ATTRIBUTE: Employer: Google`
|
||||
- `INSTRUCTION: Call me Vee` → keep as is (already correctly prefixed)
|
||||
- Drop entries that violate the rules above: behavioral `TRAIT:` lines, inferred behavioral `PREFERENCE:` lines, one-off events, transient state, evidence bundles. Do not re-prefix them — they are not identity markers.
|
||||
|
||||
When in doubt about a specific legacy entry, prefer migrating it (so valid info isn't lost) over dropping it. Splitting one dense legacy entry into multiple correctly-prefixed entries is fine and encouraged (e.g. a semicolon-separated `Tech Stack:` dump can become several `ATTRIBUTE:` lines, one per durable tool/platform).
|
||||
|
||||
Call `update_peer_card` with the complete deduplicated list when there is a durable identity update to record, or when the existing card needs migration. Entries that do not start with one of the four allowed prefixes will be rejected. Keep concise (max 40 entries)."""
|
||||
|
||||
|
||||
class BaseSpecialist(ABC):
|
||||
"""Base class for agentic specialists."""
|
||||
|
|
@ -78,6 +137,10 @@ class BaseSpecialist(ABC):
|
|||
# Whether this specialist is allowed to write to the peer card. Defaults to True;
|
||||
# specialists that should never touch the card (e.g., induction) override to False.
|
||||
can_update_peer_card: bool = True
|
||||
# Whether the current peer card is fetched and injected into the user prompt.
|
||||
# Card-refresh runs in rebuild mode set this to False so the card is
|
||||
# reconstructed solely from observations present in the collection.
|
||||
inject_peer_card: bool = True
|
||||
# Subclasses can override to customize the peer card update instruction
|
||||
peer_card_update_instruction: str = (
|
||||
"Only update this with durable identity markers via `update_peer_card`."
|
||||
|
|
@ -111,12 +174,21 @@ class BaseSpecialist(ABC):
|
|||
@abstractmethod
|
||||
def build_user_prompt(
|
||||
self,
|
||||
observed: str,
|
||||
hints: list[str] | None,
|
||||
peer_card: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Build the user prompt with optional exploration hints and current peer card."""
|
||||
...
|
||||
|
||||
def _build_target_observee_context(self, observed: str) -> str:
|
||||
return f"""Target observee:
|
||||
{observed}
|
||||
|
||||
The target observee is the peer identified above. When created observations need to name this subject, use the exact observee id above, not the phrase "the target observee".
|
||||
|
||||
"""
|
||||
|
||||
def _build_peer_card_context(self, peer_card: list[str] | None) -> str:
|
||||
"""Build the peer card context section for user prompts."""
|
||||
if not peer_card:
|
||||
|
|
@ -160,6 +232,10 @@ If you update it, send the full deduplicated list and remove stale entries.
|
|||
SpecialistResult with metrics and content
|
||||
"""
|
||||
run_id = parent_run_id or generate_nanoid()
|
||||
# Specialists sharing the orchestrator's run_id (one dream trace) each get a
|
||||
# distinct span_id so their CloudEvents trace resource ids don't collide;
|
||||
# trace_id stays run_id so Langfuse still groups them (keyed by agent_type).
|
||||
span_id = generate_nanoid() if parent_run_id is not None else run_id
|
||||
task_name = f"dreamer_{self.name}_{run_id}"
|
||||
start_time = time.perf_counter()
|
||||
|
||||
|
|
@ -205,9 +281,11 @@ If you update it, send the full deduplicated list and remove stale entries.
|
|||
configuration is None or configuration.peer_card.create
|
||||
)
|
||||
|
||||
# Fetch current peer card to inject into prompt (saves a tool call)
|
||||
# Fetch current peer card to inject into prompt (saves a tool call).
|
||||
# Skipped when inject_peer_card is False (card-refresh rebuild
|
||||
# mode): the card must be reconstructed from observations only.
|
||||
current_peer_card: list[str] | None = None
|
||||
if peer_card_enabled:
|
||||
if peer_card_enabled and self.inject_peer_card:
|
||||
current_peer_card = await crud.get_peer_card(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
|
|
@ -226,7 +304,11 @@ If you update it, send the full deduplicated list and remove stale entries.
|
|||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": self.build_user_prompt(hints, current_peer_card),
|
||||
"content": self.build_user_prompt(
|
||||
observed=observed,
|
||||
hints=hints,
|
||||
peer_card=current_peer_card,
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -273,15 +355,20 @@ If you update it, send the full deduplicated list and remove stale entries.
|
|||
tool_executor=tool_executor,
|
||||
max_tool_iterations=self.get_max_iterations(),
|
||||
messages=messages,
|
||||
track_name=f"Dreamer/{self.name}",
|
||||
telemetry=LLMTelemetryContext(
|
||||
workspace_name=workspace_name,
|
||||
call_purpose=call_purpose_slug,
|
||||
parent_category="dream",
|
||||
agent_type=self.name,
|
||||
run_id=run_id,
|
||||
# Root span per specialist run (distinct span_id, see above).
|
||||
# parent_span_id stays None for now; wiring specialists as
|
||||
# children of a dream-level trace is forking (out of scope).
|
||||
trace_id=run_id,
|
||||
span_id=span_id,
|
||||
observer=observer,
|
||||
observed=observed,
|
||||
track_name=f"Dreamer/{self.name}",
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -465,65 +552,12 @@ class DeductionSpecialist(BaseSpecialist):
|
|||
def build_system_prompt(
|
||||
self, observed: str, *, peer_card_enabled: bool = True
|
||||
) -> str:
|
||||
_ = observed
|
||||
peer_card_section = ""
|
||||
if peer_card_enabled:
|
||||
peer_card_section = f"""
|
||||
peer_card_section = PEER_CARD_SYSTEM_SECTION
|
||||
|
||||
## PEER CARD (REQUIRED)
|
||||
|
||||
The peer card is {observed}'s identity store: stable identity markers that distinguish this entity from others and persist across interactions. Behavior, tendencies, transient state, and episodic facts belong in observations, not on the peer card.
|
||||
|
||||
A peer can be anything with identity that changes over time — a human, an agent, a codebase, a team, an organization. Do not assume {observed} is human. Do not require any field; empty is the correct output when evidence is absent.
|
||||
|
||||
### Allowed entry kinds
|
||||
|
||||
Each entry must start with one of these four prefixes (exact case, followed by a space):
|
||||
|
||||
- `IDENTITY: ...` — canonical name, kind, aliases, IDs
|
||||
- `IDENTITY: Name: Alice`
|
||||
- `IDENTITY: Kind: Python monorepo`
|
||||
- `IDENTITY: Version: 4.2`
|
||||
- `IDENTITY: Aliases: alice@example.com`
|
||||
- `ATTRIBUTE: ...` — stable durable property of the entity (including explicitly stated standing preferences)
|
||||
- `ATTRIBUTE: Location: NYC`
|
||||
- `ATTRIBUTE: Language: Python`
|
||||
- `ATTRIBUTE: Prefers tea`
|
||||
- `ATTRIBUTE: Charter: ship Honcho infrastructure`
|
||||
- `RELATIONSHIP: ...` — durable link to another entity
|
||||
- `RELATIONSHIP: Spouse: Bob`
|
||||
- `RELATIONSHIP: Maintainer: vineeth`
|
||||
- `RELATIONSHIP: Members: vineeth, rajat`
|
||||
- `INSTRUCTION: ...` — standing rule of engagement that {observed} has explicitly stated (do/don't for the observer). Only when explicit; never inferred from behavior.
|
||||
- `INSTRUCTION: Call me Vee`
|
||||
- `INSTRUCTION: Never push to main without review`
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Stable.** If the value plausibly changes within six months absent a deliberate announcement, it does not belong on the card. Prefer leaving the card empty over filling it with volatile content.
|
||||
2. **Subject is {observed}.** Every entry must be a fact about {observed}, not about another participant in the session. Never write facts about co-occurring peers into the card, no matter how frequently they appear in the messages.
|
||||
3. **Evidence-grounded.** Only write what {observed} has explicitly stated, or what another participant has explicitly stated about {observed} with {observed}'s assent. No "general knowledge" inferences (`"co-founder"` does not imply an age; mentioning a colleague does not imply a family relationship).
|
||||
4. **Type-agnostic.** {observed} may not be human. Do not require name/age/location/family/occupation fields.
|
||||
5. **No behavioral content.** TRAITs, behavioral tendencies, patterns, and inferred preferences belong in observations, not on the peer card. Do not write `TRAIT:` entries or behavioral `PREFERENCE:` entries — they will be rejected.
|
||||
6. **No evidence bundles.** Each entry is one concise fact. No `e.g.` clauses, no parenthetical example lists, no semicolon-separated value dumps.
|
||||
|
||||
### Migrating an existing peer card
|
||||
|
||||
The CURRENT PEER CARD shown in the user message may contain entries from an older format that do not start with an allowed prefix (e.g. `Name: Alice`, `Lives in NYC`, `TRAIT: Analytical`, `PREFERENCE: Detailed explanations`). When you call `update_peer_card`, you are responsible for re-emitting the entries you want to keep — entries you omit are dropped, and entries without an allowed prefix are silently rejected.
|
||||
|
||||
For each legacy entry:
|
||||
|
||||
- If it is still a valid identity marker, re-emit it under the correct prefix and keep the original content where reasonable. Examples:
|
||||
- `Name: Alice` → `IDENTITY: Name: Alice`
|
||||
- `Lives in NYC` → `ATTRIBUTE: Location: NYC`
|
||||
- `Works at Google` → `ATTRIBUTE: Employer: Google`
|
||||
- `INSTRUCTION: Call me Vee` → keep as is (already correctly prefixed)
|
||||
- Drop entries that violate the rules above: behavioral `TRAIT:` lines, inferred behavioral `PREFERENCE:` lines, one-off events, transient state, evidence bundles. Do not re-prefix them — they are not identity markers.
|
||||
|
||||
When in doubt about a specific legacy entry, prefer migrating it (so valid info isn't lost) over dropping it. Splitting one dense legacy entry into multiple correctly-prefixed entries is fine and encouraged (e.g. a semicolon-separated `Tech Stack:` dump can become several `ATTRIBUTE:` lines, one per durable tool/platform).
|
||||
|
||||
Call `update_peer_card` with the complete deduplicated list when there is a durable identity update to record, or when the existing card needs migration. Entries that do not start with one of the four allowed prefixes will be rejected. Keep concise (max 40 entries)."""
|
||||
|
||||
return f"""You are a deductive reasoning agent analyzing observations about {observed}.
|
||||
return f"""You are a deductive reasoning agent analyzing observations about the target observee.
|
||||
|
||||
## YOUR JOB
|
||||
|
||||
|
|
@ -579,18 +613,21 @@ Use `create_observations_deductive`.
|
|||
3. Always include source_ids linking to the observations you're synthesizing
|
||||
4. Empty or missing source_ids will be rejected
|
||||
5. Delete outdated observations - don't leave duplicates
|
||||
6. Quality over quantity - fewer good deductions beat many weak ones"""
|
||||
6. Quality over quantity - fewer good deductions beat many weak ones
|
||||
7. When you are finished, do not output a summary of what you did - output only the token DONE"""
|
||||
|
||||
def build_user_prompt(
|
||||
self,
|
||||
observed: str,
|
||||
hints: list[str] | None,
|
||||
peer_card: list[str] | None = None,
|
||||
) -> str:
|
||||
target_observee_context = self._build_target_observee_context(observed)
|
||||
peer_card_context = self._build_peer_card_context(peer_card)
|
||||
|
||||
if hints:
|
||||
hints_str = "\n".join(f"- {q}" for q in hints[:5])
|
||||
return f"""{peer_card_context}Start by exploring recent observations and messages. These topics may be worth investigating:
|
||||
return f"""{target_observee_context}{peer_card_context}Start by exploring recent observations and messages. These topics may be worth investigating:
|
||||
|
||||
{hints_str}
|
||||
|
||||
|
|
@ -598,7 +635,7 @@ But follow the evidence - if you find something more interesting, pursue that in
|
|||
|
||||
Begin with `get_recent_observations` to see what's there."""
|
||||
|
||||
return f"""{peer_card_context}Explore the observation space and create deductive observations.
|
||||
return f"""{target_observee_context}{peer_card_context}Explore the observation space and create deductive observations.
|
||||
|
||||
Start with `get_recent_observations` to see what's been learned recently, then investigate whatever seems most promising.
|
||||
|
||||
|
|
@ -647,8 +684,9 @@ class InductionSpecialist(BaseSpecialist):
|
|||
def build_system_prompt(
|
||||
self, observed: str, *, peer_card_enabled: bool = True
|
||||
) -> str:
|
||||
_ = observed
|
||||
_ = peer_card_enabled
|
||||
return f"""You are an inductive reasoning agent identifying patterns about {observed}.
|
||||
return """You are an inductive reasoning agent identifying patterns about the target observee.
|
||||
|
||||
## YOUR JOB
|
||||
|
||||
|
|
@ -707,20 +745,23 @@ Use `create_observations_inductive`.
|
|||
3. Confidence based on evidence count: 2=low, 3-4=medium, 5+=high
|
||||
4. Look for HOW things change over time, not just static facts
|
||||
5. Include source_ids - always link back to evidence
|
||||
6. Empty or missing source_ids will be rejected"""
|
||||
6. Empty or missing source_ids will be rejected
|
||||
7. When you are finished, do not output a summary of what you did - output only the token DONE"""
|
||||
|
||||
def build_user_prompt(
|
||||
self,
|
||||
observed: str,
|
||||
hints: list[str] | None,
|
||||
peer_card: list[str] | None = None,
|
||||
) -> str:
|
||||
target_observee_context = self._build_target_observee_context(observed)
|
||||
# Induction does not consume peer card context — it produces inductive
|
||||
# observations, not identity-marker updates.
|
||||
_ = peer_card
|
||||
|
||||
if hints:
|
||||
hints_str = "\n".join(f"- {q}" for q in hints[:5])
|
||||
return f"""Explore and find patterns. These areas may be worth investigating:
|
||||
return f"""{target_observee_context}Explore and find patterns. These areas may be worth investigating:
|
||||
|
||||
{hints_str}
|
||||
|
||||
|
|
@ -728,13 +769,120 @@ But follow the evidence - if you find patterns elsewhere, pursue those.
|
|||
|
||||
Start with `get_recent_observations`."""
|
||||
|
||||
return """Explore the observation space and identify patterns.
|
||||
return f"""{target_observee_context}Explore the observation space and identify patterns.
|
||||
|
||||
Remember: patterns need 2+ sources. Look for tendencies, preferences, and behavioral regularities.
|
||||
|
||||
Go."""
|
||||
|
||||
|
||||
class CardRefreshSpecialist(BaseSpecialist):
|
||||
"""
|
||||
Card-only maintenance specialist for the ``card_refresh`` dream type.
|
||||
|
||||
Restricted to peer-card work: it may discover observations
|
||||
(get_recent_observations, search_memory) and rewrite the peer card
|
||||
(update_peer_card). It has NO observation-mutating tools — a card refresh
|
||||
must never create or delete observations.
|
||||
|
||||
Two modes:
|
||||
- refresh (default): the current card is injected into the prompt and the
|
||||
specialist folds in new identity markers.
|
||||
- rebuild: the current card is NOT injected; the specialist reconstructs
|
||||
the card solely from observations present in the collection. Used after
|
||||
removals, where the old card may contain facts whose support was deleted.
|
||||
|
||||
Not a singleton — instantiated per run because ``rebuild`` is per-dream
|
||||
state.
|
||||
"""
|
||||
|
||||
name: str = "card_refresh"
|
||||
peer_card_update_instruction: str = "Update this with `update_peer_card`. See the PEER CARD section in the system prompt for the allowed entry kinds and rules."
|
||||
|
||||
# Low iteration ceiling for this lightweight, single-purpose run.
|
||||
MAX_ITERATIONS_CEILING: int = 6
|
||||
|
||||
def __init__(self, *, rebuild: bool = False) -> None:
|
||||
self.rebuild: bool = rebuild
|
||||
# In rebuild mode the existing card is withheld from the prompt.
|
||||
self.inject_peer_card: bool = not rebuild
|
||||
|
||||
def get_tools(self, *, peer_card_enabled: bool = True) -> list[dict[str, Any]]:
|
||||
if peer_card_enabled:
|
||||
return CARD_REFRESH_SPECIALIST_TOOLS
|
||||
# Defensive: a card refresh without card write access is a no-op, and
|
||||
# the orchestrator skips the run entirely when peer cards are disabled.
|
||||
return [
|
||||
t
|
||||
for t in CARD_REFRESH_SPECIALIST_TOOLS
|
||||
if t["name"] not in PEER_CARD_TOOL_NAMES
|
||||
]
|
||||
|
||||
def get_model_config(self) -> ConfiguredModelSettings:
|
||||
# Card refresh is a deduction-family task; reuse its model config.
|
||||
return _require_specialist_model_config(
|
||||
settings.DREAM.DEDUCTION_MODEL_CONFIG,
|
||||
specialist_name="DREAM CARD_REFRESH",
|
||||
)
|
||||
|
||||
def get_max_tokens(self) -> int:
|
||||
return 8192
|
||||
|
||||
def get_max_iterations(self) -> int:
|
||||
return min(self.MAX_ITERATIONS_CEILING, settings.DREAM.MAX_TOOL_ITERATIONS)
|
||||
|
||||
def build_system_prompt(
|
||||
self, observed: str, *, peer_card_enabled: bool = True
|
||||
) -> str:
|
||||
_ = observed
|
||||
_ = peer_card_enabled
|
||||
rebuild_section = ""
|
||||
if self.rebuild:
|
||||
rebuild_section = """
|
||||
|
||||
## REBUILD MODE
|
||||
|
||||
The existing peer card is deliberately NOT shown to you: it may contain entries whose supporting observations have since been removed. Build the card solely from the observations you find in the collection right now. Do not carry over or guess at prior card content — if an identity marker is not supported by a current observation, it does not go on the card."""
|
||||
|
||||
return f"""You are a peer-card maintenance agent for the target observee.
|
||||
|
||||
## YOUR JOB
|
||||
|
||||
Refresh the peer card and nothing else. You cannot create or delete observations — you have no tools for that. Your only write operation is `update_peer_card`.
|
||||
|
||||
## PROCESS
|
||||
|
||||
1. Survey the observation space: start with `get_recent_observations`, then use `search_memory` for targeted follow-ups (names, roles, locations, standing instructions).
|
||||
2. Extract stable identity markers supported by the observations you found.
|
||||
3. Call `update_peer_card` once with the complete deduplicated list.
|
||||
|
||||
Keep it short — a handful of tool calls at most.{rebuild_section}
|
||||
{PEER_CARD_SYSTEM_SECTION}"""
|
||||
|
||||
def build_user_prompt(
|
||||
self,
|
||||
observed: str,
|
||||
hints: list[str] | None,
|
||||
peer_card: list[str] | None = None,
|
||||
) -> str:
|
||||
_ = hints
|
||||
target_observee_context = self._build_target_observee_context(observed)
|
||||
peer_card_context = self._build_peer_card_context(peer_card)
|
||||
|
||||
if self.rebuild:
|
||||
return f"""{target_observee_context}Rebuild the peer card from scratch.
|
||||
|
||||
The previous card is not shown and must not be assumed: reconstruct the card solely from observations currently in the collection. Start with `get_recent_observations`, verify with `search_memory` where needed, then call `update_peer_card` with the complete list.
|
||||
|
||||
Go."""
|
||||
|
||||
return f"""{target_observee_context}{peer_card_context}Refresh the peer card.
|
||||
|
||||
Review recent observations with `get_recent_observations` (and `search_memory` for targeted checks), then call `update_peer_card` with the complete deduplicated list if there is anything to add, correct, or migrate. If the card is already accurate and complete, finish without updating it.
|
||||
|
||||
Go."""
|
||||
|
||||
|
||||
# Singleton instances
|
||||
SPECIALISTS: dict[str, BaseSpecialist] = {
|
||||
"deduction": DeductionSpecialist(),
|
||||
|
|
|
|||
|
|
@ -29,6 +29,15 @@ def create_tree(tree_type: str, **kwargs: Any) -> SurprisalTree:
|
|||
Raises:
|
||||
ValueError: If tree_type is not recognized
|
||||
"""
|
||||
# `surprisal.py` calls this factory with a uniform `k=settings.DREAM.SURPRISAL.TREE_K` kwarg for every tree type,
|
||||
# but `k` is only meaningful for the kNN-based trees (kdtree, balltree, graph).
|
||||
# The other 4 use different tunables and raise TypeError if `k` is passed.
|
||||
# Drop it here so the factory accepts a uniform kwargs dict.
|
||||
|
||||
trees_without_k = {"rptree", "covertree", "lsh", "prototype"}
|
||||
if tree_type in trees_without_k:
|
||||
kwargs.pop("k", None)
|
||||
|
||||
if tree_type == "rptree":
|
||||
return RPTree(**kwargs)
|
||||
elif tree_type == "kdtree":
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from typing import Any, Literal, NamedTuple, TypeVar
|
|||
import tiktoken
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
from nanoid import generate as generate_nanoid
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from .config import EmbeddingModelConfig, resolve_embedding_model_config, settings
|
||||
|
|
@ -88,6 +89,7 @@ def _publish_embedding_event(
|
|||
get_embedding_call_purpose,
|
||||
get_embedding_parent_category,
|
||||
get_embedding_run_id,
|
||||
get_embedding_session_id,
|
||||
get_embedding_workspace_name,
|
||||
)
|
||||
|
||||
|
|
@ -121,6 +123,30 @@ def _publish_embedding_event(
|
|||
run_id=get_embedding_run_id(),
|
||||
)
|
||||
)
|
||||
|
||||
# Trace stream (ground-truth) — gated on payload tracing. Each embedding
|
||||
# gets its own span nested under the driving agent run (parent_span_id =
|
||||
# run_id), so multiple embeddings in one run don't share a span id.
|
||||
if settings.TELEMETRY.TRACE_PAYLOADS_ENABLED:
|
||||
from src.telemetry.events import EmbeddingCallTracedEvent, emit_trace
|
||||
|
||||
run_id = get_embedding_run_id()
|
||||
span_id = generate_nanoid()
|
||||
emit_trace(
|
||||
EmbeddingCallTracedEvent(
|
||||
trace_id=run_id or span_id,
|
||||
span_id=span_id,
|
||||
parent_span_id=run_id,
|
||||
session_id=get_embedding_session_id(),
|
||||
call_purpose=purpose_slug,
|
||||
parent_category=get_embedding_parent_category(),
|
||||
provider=provider,
|
||||
model=model,
|
||||
provider_input_tokens=input_tokens_estimate,
|
||||
provider_output_tokens=0,
|
||||
input_count=input_count,
|
||||
)
|
||||
)
|
||||
except Exception: # pragma: no cover - telemetry must not raise
|
||||
logger.debug("Failed to emit EmbeddingCallCompletedEvent", exc_info=True)
|
||||
|
||||
|
|
@ -250,76 +276,61 @@ class _EmbeddingClient:
|
|||
|
||||
async def simple_batch_embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Simple batch embedding for a list of text strings.
|
||||
Batch-embed a list of text strings. Each input must already fit within
|
||||
`max_embedding_tokens`; this method does not sub-chunk oversized inputs.
|
||||
|
||||
Internally goes through the same token-aware batching pipeline as
|
||||
`batch_embed()` so the per-request token cap is respected.
|
||||
|
||||
Args:
|
||||
texts: List of text strings to embed
|
||||
|
||||
Returns:
|
||||
List of embedding vectors corresponding to input texts
|
||||
List of embedding vectors, one per input text (in order)
|
||||
|
||||
Raises:
|
||||
ValueError: If any text exceeds token limits
|
||||
"""
|
||||
embeddings: list[list[float]] = []
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
for i in range(0, len(texts), self.max_batch_size):
|
||||
batch = texts[i : i + self.max_batch_size]
|
||||
|
||||
async def _embed_batch(batch: list[str] = batch) -> list[list[float]]:
|
||||
"""One provider call for one batch. Lifted into a closure so
|
||||
_emit_embedding_call can time + emit + propagate errors."""
|
||||
batch_embeddings: list[list[float]] = []
|
||||
if isinstance(self.client, genai.Client):
|
||||
# Type cast needed due to genai type signature complexity
|
||||
response = await self.client.aio.models.embed_content(
|
||||
model=self.model,
|
||||
contents=batch, # pyright: ignore[reportArgumentType]
|
||||
config={"output_dimensionality": self.vector_dimensions},
|
||||
)
|
||||
if response.embeddings:
|
||||
for emb in response.embeddings:
|
||||
if emb.values:
|
||||
batch_embeddings.append(
|
||||
self._validate_embedding_dimensions(emb.values)
|
||||
)
|
||||
else: # openai
|
||||
openai_kwargs: dict[str, Any] = {
|
||||
"input": batch,
|
||||
"model": self.model,
|
||||
}
|
||||
if self.send_dimensions:
|
||||
openai_kwargs["dimensions"] = self.vector_dimensions
|
||||
response = await self.client.embeddings.create(**openai_kwargs)
|
||||
batch_embeddings.extend(
|
||||
[
|
||||
self._validate_embedding_dimensions(data.embedding)
|
||||
for data in response.data
|
||||
]
|
||||
)
|
||||
return batch_embeddings
|
||||
|
||||
try:
|
||||
# Pre-compute the tiktoken estimate ONCE for telemetry; the
|
||||
# batch contents don't change between attempts.
|
||||
tokens_estimate = sum(len(self.encoding.encode(t)) for t in batch)
|
||||
batch_embeddings = await _emit_embedding_call(
|
||||
provider=self.transport,
|
||||
model=self.model,
|
||||
texts=batch,
|
||||
input_tokens_estimate=tokens_estimate,
|
||||
fn=_embed_batch,
|
||||
# Validate per-input token limit and collect token counts for batching
|
||||
token_counts: list[int] = []
|
||||
for idx, text in enumerate(texts):
|
||||
tokens = len(self.encoding.encode(text))
|
||||
if tokens > self.max_embedding_tokens:
|
||||
raise ValueError(
|
||||
f"Text at index {idx} exceeds maximum token limit of {self.max_embedding_tokens} tokens (got {tokens} tokens)"
|
||||
)
|
||||
embeddings.extend(batch_embeddings)
|
||||
except Exception as e:
|
||||
# Check if it's a token limit error and re-raise as ValueError for consistency
|
||||
if "token" in str(e).lower():
|
||||
raise ValueError(
|
||||
f"Text content exceeds maximum token limit of {self.max_embedding_tokens}."
|
||||
) from e
|
||||
raise
|
||||
token_counts.append(tokens)
|
||||
|
||||
return embeddings
|
||||
# Use positional indices as text_ids so we can reassemble in input order.
|
||||
text_chunks: dict[str, list[tuple[str, int]]] = {
|
||||
str(i): [(text, token_counts[i])] for i, text in enumerate(texts)
|
||||
}
|
||||
|
||||
batches = self._create_batches(text_chunks)
|
||||
batch_results = await asyncio.gather(
|
||||
*[self._process_batch(batch) for batch in batches],
|
||||
)
|
||||
|
||||
combined: dict[str, list[list[float]]] = self._accumulate_embeddings(
|
||||
batch_results
|
||||
)
|
||||
return [combined[str(i)][0] for i in range(len(texts))]
|
||||
|
||||
def prepare_chunks(self, id_resource_dict: dict[str, str]) -> dict[str, list[str]]:
|
||||
"""
|
||||
Public helper: tokenize and chunk texts using the same rules as
|
||||
`batch_embed()`. Returns ordered chunk texts per input id.
|
||||
|
||||
Intended for callers that want to persist embeddable chunks
|
||||
before later embedding them off the request path.
|
||||
"""
|
||||
return {
|
||||
text_id: [chunk_text for chunk_text, _ in chunks]
|
||||
for text_id, chunks in self._prepare_chunks(id_resource_dict).items()
|
||||
}
|
||||
|
||||
async def batch_embed(
|
||||
self, id_resource_dict: dict[str, str]
|
||||
|
|
@ -623,9 +634,13 @@ class EmbeddingClient:
|
|||
return await self._get_client().embed(query)
|
||||
|
||||
async def simple_batch_embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Simple batch embedding for a list of text strings."""
|
||||
"""Batch embed a list of text strings (each must fit token limit)."""
|
||||
return await self._get_client().simple_batch_embed(texts)
|
||||
|
||||
def prepare_chunks(self, id_resource_dict: dict[str, str]) -> dict[str, list[str]]:
|
||||
"""Chunk texts using the same rules as `batch_embed` (no network)."""
|
||||
return self._get_client().prepare_chunks(id_resource_dict)
|
||||
|
||||
async def batch_embed(
|
||||
self, id_resource_dict: dict[str, str]
|
||||
) -> dict[str, list[list[float]]]:
|
||||
|
|
|
|||
103
src/llm/api.py
103
src/llm/api.py
|
|
@ -21,7 +21,6 @@ from tenacity import retry, stop_after_attempt, wait_exponential
|
|||
|
||||
from src.config import ConfiguredModelSettings, ModelConfig
|
||||
from src.exceptions import ValidationException
|
||||
from src.telemetry.logging import conditional_observe
|
||||
from src.telemetry.reasoning_traces import log_reasoning_trace
|
||||
|
||||
from .executor import honcho_llm_call_inner
|
||||
|
|
@ -31,7 +30,7 @@ from .runtime import (
|
|||
effective_temperature,
|
||||
plan_attempt,
|
||||
resolve_runtime_model_config,
|
||||
update_current_langfuse_observation,
|
||||
start_langfuse_agent_run,
|
||||
)
|
||||
from .tool_loop import execute_tool_loop
|
||||
from .types import (
|
||||
|
|
@ -54,7 +53,6 @@ async def honcho_llm_call(
|
|||
model_config: ModelConfig | ConfiguredModelSettings,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
track_name: str | None = None,
|
||||
response_model: type[M],
|
||||
json_mode: bool = False,
|
||||
temperature: float | None = None,
|
||||
|
|
@ -84,7 +82,6 @@ async def honcho_llm_call(
|
|||
model_config: ModelConfig | ConfiguredModelSettings,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
track_name: str | None = None,
|
||||
response_model: None = None,
|
||||
json_mode: bool = False,
|
||||
temperature: float | None = None,
|
||||
|
|
@ -114,7 +111,6 @@ async def honcho_llm_call(
|
|||
model_config: ModelConfig | ConfiguredModelSettings,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
track_name: str | None = None,
|
||||
response_model: type[BaseModel] | None = None,
|
||||
json_mode: bool = False,
|
||||
temperature: float | None = None,
|
||||
|
|
@ -138,13 +134,11 @@ async def honcho_llm_call(
|
|||
) -> AsyncIterator[HonchoLLMCallStreamChunk] | StreamingResponseWithMetadata: ...
|
||||
|
||||
|
||||
@conditional_observe(name="LLM Call")
|
||||
async def honcho_llm_call(
|
||||
*,
|
||||
model_config: ModelConfig | ConfiguredModelSettings,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
track_name: str | None = None,
|
||||
response_model: type[BaseModel] | None = None,
|
||||
json_mode: bool = False,
|
||||
temperature: float | None = None,
|
||||
|
|
@ -206,11 +200,6 @@ async def honcho_llm_call(
|
|||
call_thinking_budget_tokens=thinking_budget_tokens,
|
||||
call_reasoning_effort=reasoning_effort,
|
||||
)
|
||||
update_current_langfuse_observation(
|
||||
plan.provider,
|
||||
plan.model,
|
||||
name=track_name,
|
||||
)
|
||||
return plan
|
||||
|
||||
async def _call_with_provider_selection() -> (
|
||||
|
|
@ -267,8 +256,9 @@ async def honcho_llm_call(
|
|||
|
||||
decorated = _call_with_provider_selection
|
||||
|
||||
if track_name:
|
||||
decorated = ai_track(track_name)(decorated)
|
||||
sentry_track_name = telemetry.track_name if telemetry is not None else None
|
||||
if sentry_track_name:
|
||||
decorated = ai_track(sentry_track_name)(decorated)
|
||||
|
||||
def before_retry_callback(retry_state: Any) -> None:
|
||||
"""Update attempt counter before each retry + log transient failures.
|
||||
|
|
@ -397,8 +387,8 @@ async def honcho_llm_call(
|
|||
)
|
||||
|
||||
wrapped = _toolless_call
|
||||
if track_name:
|
||||
wrapped = ai_track(track_name)(wrapped)
|
||||
if sentry_track_name:
|
||||
wrapped = ai_track(sentry_track_name)(wrapped)
|
||||
if enable_retry:
|
||||
wrapped = retry(
|
||||
stop=stop_after_attempt(retry_attempts),
|
||||
|
|
@ -406,7 +396,9 @@ async def honcho_llm_call(
|
|||
before_sleep=before_retry_callback,
|
||||
)(wrapped)
|
||||
result: (
|
||||
HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk]
|
||||
HonchoLLMCallResponse[Any]
|
||||
| AsyncIterator[HonchoLLMCallStreamChunk]
|
||||
| StreamingResponseWithMetadata
|
||||
) = await wrapped()
|
||||
else:
|
||||
result = await decorated()
|
||||
|
|
@ -429,30 +421,59 @@ async def honcho_llm_call(
|
|||
)
|
||||
return result
|
||||
|
||||
# execute_tool_loop raises ValidationException on out-of-range
|
||||
# max_tool_iterations; fail-fast is cheaper than silent clamping here.
|
||||
result = await execute_tool_loop(
|
||||
prompt=prompt,
|
||||
max_tokens=max_tokens,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
tool_executor=tool_executor,
|
||||
max_tool_iterations=max_tool_iterations,
|
||||
response_model=response_model,
|
||||
json_mode=json_mode,
|
||||
temperature=temperature,
|
||||
stop_seqs=stop_seqs,
|
||||
verbosity=verbosity,
|
||||
enable_retry=enable_retry,
|
||||
retry_attempts=retry_attempts,
|
||||
max_input_tokens=max_input_tokens,
|
||||
get_attempt_plan=_get_attempt_plan,
|
||||
before_retry_callback=before_retry_callback,
|
||||
stream_final=stream_final_only,
|
||||
iteration_callback=iteration_callback,
|
||||
telemetry=telemetry,
|
||||
)
|
||||
# One run-level Langfuse trace wraps the whole run; step/LLM/tool spans
|
||||
# nest under it (the run handle keeps `start_as_current_observation` open
|
||||
# via ExitStack, so the run span stays current OTel-wise even though we
|
||||
# never use a `with` block here). The handle is passed into
|
||||
# `execute_tool_loop` so streaming results own it from construction and
|
||||
# close the span after drain — that's how the streamed text shows up as
|
||||
# the trace's output instead of blank. Non-streaming results: we end in
|
||||
# the `finally`.
|
||||
run_label = (telemetry.track_name if telemetry else None) or "Agent"
|
||||
run_handle = start_langfuse_agent_run(run_label, telemetry)
|
||||
if run_handle is not None:
|
||||
# Mirror execute_tool_loop's prompt-only handling: when messages is
|
||||
# omitted it seeds the conversation with a single user message built
|
||||
# from prompt. Record that same effective input so the run span isn't
|
||||
# blank for prompt-only calls.
|
||||
run_handle.update(
|
||||
input=messages if messages else [{"role": "user", "content": prompt}]
|
||||
)
|
||||
try:
|
||||
# execute_tool_loop raises ValidationException on out-of-range
|
||||
# max_tool_iterations; fail-fast is cheaper than silent clamping here.
|
||||
result = await execute_tool_loop(
|
||||
prompt=prompt,
|
||||
max_tokens=max_tokens,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
tool_executor=tool_executor,
|
||||
max_tool_iterations=max_tool_iterations,
|
||||
response_model=response_model,
|
||||
json_mode=json_mode,
|
||||
temperature=temperature,
|
||||
stop_seqs=stop_seqs,
|
||||
verbosity=verbosity,
|
||||
enable_retry=enable_retry,
|
||||
retry_attempts=retry_attempts,
|
||||
max_input_tokens=max_input_tokens,
|
||||
get_attempt_plan=_get_attempt_plan,
|
||||
before_retry_callback=before_retry_callback,
|
||||
stream_final=stream_final_only,
|
||||
iteration_callback=iteration_callback,
|
||||
telemetry=telemetry,
|
||||
langfuse_run_handle=run_handle,
|
||||
)
|
||||
except BaseException:
|
||||
if run_handle is not None:
|
||||
run_handle.end()
|
||||
raise
|
||||
# Streaming wrapper owns the handle and closes it after drain;
|
||||
# non-streaming paths (always a HonchoLLMCallResponse here) close it now
|
||||
# with the final content as output.
|
||||
if run_handle is not None and isinstance(result, HonchoLLMCallResponse):
|
||||
run_handle.end(output=result.content)
|
||||
if trace_name and isinstance(result, HonchoLLMCallResponse):
|
||||
log_reasoning_trace(
|
||||
task_type=trace_name,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ class ToolCallResult:
|
|||
id: str
|
||||
name: str
|
||||
input: dict[str, Any]
|
||||
thought_signature: str | None = None
|
||||
# Gemini returns this as raw bytes; other providers omit it.
|
||||
thought_signature: str | bytes | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ from anthropic.types import TextBlock, ThinkingBlock, ToolUseBlock
|
|||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from src.llm.backend import CompletionResult, StreamChunk, ToolCallResult
|
||||
from src.llm.structured_output import repair_response_model_json
|
||||
from src.llm.request_builder import apply_sdk_passthroughs
|
||||
from src.llm.structured_output import repair_response_model_json, schema_instruction
|
||||
|
||||
|
||||
class AnthropicBackend:
|
||||
|
|
@ -69,27 +70,32 @@ class AnthropicBackend:
|
|||
for key in ("top_p", "top_k"):
|
||||
if key in extra_params:
|
||||
params[key] = extra_params[key]
|
||||
# Operator escape hatch: forward Anthropic SDK passthrough kwargs
|
||||
# from ModelConfig.provider_params. Shallow merge with operator-wins.
|
||||
apply_sdk_passthroughs(params, extra_params)
|
||||
|
||||
# The '{' prefill forces a JSON-first response, which suppresses
|
||||
# tool_use blocks — skip it when tools are available and rely on the
|
||||
# conditional instruction + repair fallback instead.
|
||||
use_json_prefill = (
|
||||
bool(response_format or self._json_mode(extra_params))
|
||||
and not thinking_budget_tokens
|
||||
and not tools
|
||||
and self._supports_assistant_prefill(model)
|
||||
)
|
||||
if use_json_prefill and params["messages"]:
|
||||
if response_format and isinstance(response_format, type):
|
||||
schema_json = json.dumps(response_format.model_json_schema(), indent=2)
|
||||
self._append_text_to_last_message(
|
||||
params["messages"],
|
||||
f"\n\nRespond with valid JSON matching this schema:\n{schema_json}",
|
||||
schema_instruction(response_format, tools_present=False),
|
||||
)
|
||||
params["messages"].append({"role": "assistant", "content": "{"})
|
||||
elif (
|
||||
response_format and isinstance(response_format, type) and params["messages"]
|
||||
):
|
||||
schema_json = json.dumps(response_format.model_json_schema(), indent=2)
|
||||
self._append_text_to_last_message(
|
||||
params["messages"],
|
||||
f"\n\nRespond with valid JSON matching this schema:\n{schema_json}",
|
||||
schema_instruction(response_format, tools_present=bool(tools)),
|
||||
)
|
||||
|
||||
response = await self._client.messages.create(**params)
|
||||
|
|
@ -148,26 +154,30 @@ class AnthropicBackend:
|
|||
for key in ("top_p", "top_k"):
|
||||
if key in extra_params:
|
||||
params[key] = extra_params[key]
|
||||
# Operator escape hatch: forward Anthropic SDK passthrough kwargs
|
||||
# from ModelConfig.provider_params. Shallow merge with operator-wins.
|
||||
apply_sdk_passthroughs(params, extra_params)
|
||||
# See complete(): no '{' prefill when tools are available, so
|
||||
# tool_use blocks stay reachable on the streamed path too.
|
||||
use_json_prefill = (
|
||||
bool(response_format or is_json_mode)
|
||||
and not thinking_budget_tokens
|
||||
and not tools
|
||||
and self._supports_assistant_prefill(model)
|
||||
)
|
||||
if use_json_prefill and params["messages"]:
|
||||
if response_format and isinstance(response_format, type):
|
||||
schema_json = json.dumps(response_format.model_json_schema(), indent=2)
|
||||
self._append_text_to_last_message(
|
||||
params["messages"],
|
||||
f"\n\nRespond with valid JSON matching this schema:\n{schema_json}",
|
||||
schema_instruction(response_format, tools_present=False),
|
||||
)
|
||||
params["messages"].append({"role": "assistant", "content": "{"})
|
||||
elif (
|
||||
response_format and isinstance(response_format, type) and params["messages"]
|
||||
):
|
||||
schema_json = json.dumps(response_format.model_json_schema(), indent=2)
|
||||
self._append_text_to_last_message(
|
||||
params["messages"],
|
||||
f"\n\nRespond with valid JSON matching this schema:\n{schema_json}",
|
||||
schema_instruction(response_format, tools_present=bool(tools)),
|
||||
)
|
||||
if thinking_budget_tokens:
|
||||
params["thinking"] = {
|
||||
|
|
@ -244,7 +254,8 @@ class AnthropicBackend:
|
|||
)
|
||||
|
||||
content: Any = text_content
|
||||
if response_format is not None:
|
||||
# Tool-call turns carry no consumable content
|
||||
if response_format is not None and not tool_calls:
|
||||
raw_content = f"{{{text_content}" if prefilled_json else text_content
|
||||
try:
|
||||
if prefilled_json:
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ from src.llm.caching import (
|
|||
build_cache_key,
|
||||
gemini_cache_store,
|
||||
)
|
||||
from src.llm.structured_output import repair_response_model_json
|
||||
from src.llm.request_builder import coerce_passthrough_mapping
|
||||
from src.llm.structured_output import repair_response_model_json, schema_instruction
|
||||
|
||||
GEMINI_BLOCKED_FINISH_REASONS = {
|
||||
"SAFETY",
|
||||
|
|
@ -30,6 +31,29 @@ class GeminiBackend:
|
|||
def __init__(self, client: Any) -> None:
|
||||
self._client: Any = client
|
||||
|
||||
@staticmethod
|
||||
def _append_schema_instruction(
|
||||
contents: list[dict[str, Any]] | str,
|
||||
response_format: type[BaseModel],
|
||||
) -> list[dict[str, Any]] | str:
|
||||
"""Append the schema instruction to the final turn.
|
||||
|
||||
Used when tools accompany a response_format: native response_schema +
|
||||
function calling is a Gemini 3 preview feature and earlier models
|
||||
reject the pairing, so instruct the model and rely on parse + repair.
|
||||
Returns a new list — _convert_messages shallow-copies parts-style
|
||||
messages, so in-place appends would leak into the caller's history
|
||||
and accumulate across tool-loop iterations.
|
||||
"""
|
||||
instruction = schema_instruction(response_format, tools_present=True)
|
||||
if isinstance(contents, str):
|
||||
return contents + instruction
|
||||
if not contents:
|
||||
return contents
|
||||
last = contents[-1]
|
||||
parts: list[Any] = [*(last.get("parts") or []), {"text": instruction}]
|
||||
return [*contents[:-1], {**last, "parts": parts}]
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -60,6 +84,10 @@ class GeminiBackend:
|
|||
)
|
||||
if system_instruction:
|
||||
config["system_instruction"] = system_instruction
|
||||
if tools and isinstance(response_format, type):
|
||||
# The final turn is never part of the cached prefix, so this is
|
||||
# safe to do before cache attachment.
|
||||
contents = self._append_schema_instruction(contents, response_format)
|
||||
|
||||
cache_policy = (
|
||||
extra_params.get("cache_policy")
|
||||
|
|
@ -129,6 +157,10 @@ class GeminiBackend:
|
|||
)
|
||||
if system_instruction:
|
||||
config["system_instruction"] = system_instruction
|
||||
if tools and isinstance(response_format, type):
|
||||
# The final turn is never part of the cached prefix, so this is
|
||||
# safe to do before cache attachment.
|
||||
contents = self._append_schema_instruction(contents, response_format)
|
||||
|
||||
cache_policy = (
|
||||
extra_params.get("cache_policy")
|
||||
|
|
@ -227,7 +259,11 @@ class GeminiBackend:
|
|||
config["tools"] = self._convert_tools(tools)
|
||||
if tool_choice:
|
||||
config["tool_config"] = self._convert_tool_choice(tool_choice)
|
||||
if response_format is not None:
|
||||
# Native structured output combined with function calling is a
|
||||
# Gemini 3 preview feature; earlier models reject the pairing. With
|
||||
# tools present, callers inject a schema instruction instead (see
|
||||
# _append_schema_instruction) and rely on parse + repair downstream.
|
||||
if response_format is not None and not tools:
|
||||
config["response_mime_type"] = "application/json"
|
||||
config["response_schema"] = response_format
|
||||
elif extra_params and extra_params.get("json_mode") and not tools:
|
||||
|
|
@ -246,6 +282,26 @@ class GeminiBackend:
|
|||
for key in ("top_p", "top_k", "frequency_penalty", "presence_penalty", "seed"):
|
||||
if extra_params and key in extra_params:
|
||||
config[key] = extra_params[key]
|
||||
# Operator escape hatch: forward provider_params into the google-genai
|
||||
# config dict. The Gemini SDK doesn't expose extra_body/extra_headers
|
||||
# as kwargs (unlike OpenAI/Anthropic) — body-shaped fields live on
|
||||
# GenerateContentConfig and headers live under config.http_options.
|
||||
# extra_query has no SDK-level equivalent and is ignored. Shallow
|
||||
# merge with operator-wins. Operators are responsible for not setting
|
||||
# unknown fields that google-genai's validation will reject.
|
||||
if extra_params:
|
||||
operator_extra_body = extra_params.get("extra_body")
|
||||
if operator_extra_body:
|
||||
config.update(
|
||||
coerce_passthrough_mapping("extra_body", operator_extra_body)
|
||||
)
|
||||
operator_extra_headers = extra_params.get("extra_headers")
|
||||
if operator_extra_headers:
|
||||
http_options = config.setdefault("http_options", {})
|
||||
existing_headers = http_options.setdefault("headers", {})
|
||||
existing_headers.update(
|
||||
coerce_passthrough_mapping("extra_headers", operator_extra_headers)
|
||||
)
|
||||
return config
|
||||
|
||||
def _normalize_response(
|
||||
|
|
@ -314,7 +370,10 @@ class GeminiBackend:
|
|||
)
|
||||
|
||||
content: Any = "\n".join(text_parts) if text_parts else ""
|
||||
if response_format is not None:
|
||||
# Tool-call turns carry no consumable content — the tool loop ignores
|
||||
# it — and parsing their (empty) text would raise through the repair
|
||||
# fallback, failing the iteration.
|
||||
if response_format is not None and not tool_calls:
|
||||
parsed_response = getattr(response, "parsed", None)
|
||||
if isinstance(parsed_response, response_format):
|
||||
content = parsed_response
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
import weakref
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any, cast
|
||||
|
||||
|
|
@ -10,7 +11,10 @@ from pydantic import BaseModel, ValidationError
|
|||
|
||||
from src.exceptions import ValidationException
|
||||
from src.llm.backend import CompletionResult, StreamChunk, ToolCallResult
|
||||
from src.llm.request_builder import apply_sdk_passthroughs
|
||||
from src.llm.structured_output import (
|
||||
StructuredOutputError,
|
||||
empty_structured_output,
|
||||
repair_response_model_json,
|
||||
validate_structured_output,
|
||||
)
|
||||
|
|
@ -18,6 +22,37 @@ from src.llm.structured_output import (
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# The point of this being a WeakKeyDictionary, as opposed to a regular dict, is that it
|
||||
# does not hold a reference to the keyed BaseModel so that when a dynamically created
|
||||
# type is no longer referenced it becomes eligible for garbage collection. This avoids a
|
||||
# memory leak.
|
||||
_json_object_instruction_cache: weakref.WeakKeyDictionary[type[BaseModel], str] = (
|
||||
weakref.WeakKeyDictionary()
|
||||
)
|
||||
|
||||
|
||||
def _json_object_instruction(response_format: type[BaseModel]) -> str:
|
||||
"""Schema-injection instruction for json_object mode.
|
||||
|
||||
The JSON schema is static per response_format class, so cache the serialized
|
||||
instruction — the deriver would otherwise re-walk the schema + re-serialize
|
||||
it every call.
|
||||
"""
|
||||
cached = _json_object_instruction_cache.get(response_format)
|
||||
if cached is not None:
|
||||
return cached
|
||||
# Some OpenAI-compatible providers enforce this JSON-object precondition with
|
||||
# a case-sensitive substring check, so include lowercase "json" explicitly.
|
||||
instruction = (
|
||||
"You must respond with a single JSON object (json) that conforms "
|
||||
"exactly to the following JSON schema. Do not include any text, "
|
||||
"markdown, or code fences outside the JSON object.\n\nJSON schema:\n"
|
||||
f"{json.dumps(response_format.model_json_schema())}"
|
||||
)
|
||||
_json_object_instruction_cache[response_format] = instruction
|
||||
return instruction
|
||||
|
||||
|
||||
def _uses_max_completion_tokens(model: str) -> bool:
|
||||
"""OpenAI reasoning models (gpt-5 family + o-series) require
|
||||
``max_completion_tokens`` instead of the classic ``max_tokens`` parameter.
|
||||
|
|
@ -142,10 +177,40 @@ class OpenAIBackend:
|
|||
)
|
||||
|
||||
if isinstance(response_format, type):
|
||||
if self._structured_output_mode(extra_params) == "json_object":
|
||||
self._apply_json_object_mode(params, response_format)
|
||||
response = await self._client.chat.completions.create(**params)
|
||||
# A loose provider that returns nothing shouldn't crash the call.
|
||||
content = self._parse_or_repair_structured_content(
|
||||
response, response_format, model, empty_on_missing=True
|
||||
)
|
||||
return self._normalize_response(response, content_override=content)
|
||||
if tools:
|
||||
# parse() refuses non-strict function tools, and our agent tool
|
||||
# schemas are deliberately non-strict (see _convert_tools), so
|
||||
# tool-loop iterations use create() with an explicit json_schema
|
||||
# response_format — same server-side schema enforcement, no
|
||||
# strict-tools requirement — mirroring the streaming path.
|
||||
params["response_format"] = self._json_schema_response_format(
|
||||
response_format
|
||||
)
|
||||
response = await self._client.chat.completions.create(**params)
|
||||
# Tool-call turns carry no consumable content — the tool loop
|
||||
# ignores it — and parsing their empty text would raise.
|
||||
if getattr(response.choices[0].message, "tool_calls", None):
|
||||
return self._normalize_response(response)
|
||||
content = self._parse_or_repair_structured_content(
|
||||
response, response_format, model, empty_on_missing=False
|
||||
)
|
||||
return self._normalize_response(response, content_override=content)
|
||||
params["response_format"] = response_format
|
||||
try:
|
||||
response = await self._client.chat.completions.parse(**params)
|
||||
except LengthFinishReasonError as exc:
|
||||
# Truncated output: repair the partial content directly. repair
|
||||
# handles empty/unrepairable JSON with its own model-aware fallback
|
||||
# (PromptRepresentation -> empty, others -> raise), which differs
|
||||
# from the parse-fallback terminal below, so it stays a direct call.
|
||||
truncated = exc.completion
|
||||
raw_content = truncated.choices[0].message.content or ""
|
||||
content = repair_response_model_json(
|
||||
|
|
@ -157,41 +222,42 @@ class OpenAIBackend:
|
|||
truncated,
|
||||
content_override=content,
|
||||
)
|
||||
except (BadRequestError, json.JSONDecodeError, ValidationError):
|
||||
fallback_response = await self._create_structured_response(
|
||||
params=params,
|
||||
response_format=response_format,
|
||||
)
|
||||
content = self._parse_or_repair_structured_content(
|
||||
fallback_response,
|
||||
response_format,
|
||||
except BadRequestError:
|
||||
# A 400 means the provider rejected the request shape — most
|
||||
# often it doesn't support OpenAI Structured Outputs (json_schema).
|
||||
# Retrying or re-requesting won't help (it rejects the same shape
|
||||
# again, the latency trap of #797), so return empty rather than
|
||||
# erroring existing flows. The warning is the signal to set
|
||||
# structured_output_mode=json_object. There is no response body to
|
||||
# account for, so token usage is legitimately zero here.
|
||||
logger.warning(
|
||||
"Structured output via json_schema rejected by model %s; "
|
||||
+ "set structured_output_mode=json_object if the provider does "
|
||||
+ "not support OpenAI Structured Outputs.",
|
||||
model,
|
||||
)
|
||||
return self._normalize_response(
|
||||
fallback_response,
|
||||
content_override=content,
|
||||
)
|
||||
# empty_structured_output() validates {} against the model, which
|
||||
# itself raises if the model has required fields. Fall back to
|
||||
# empty string content rather than letting that escape the handler.
|
||||
try:
|
||||
fallback_content: Any = empty_structured_output(response_format)
|
||||
except ValidationError:
|
||||
fallback_content = ""
|
||||
return CompletionResult(content=fallback_content)
|
||||
parsed = response.choices[0].message.parsed
|
||||
raw_content = response.choices[0].message.content or ""
|
||||
if parsed is None and raw_content:
|
||||
content = repair_response_model_json(
|
||||
raw_content,
|
||||
response_format,
|
||||
model,
|
||||
if parsed is not None:
|
||||
return self._normalize_response(
|
||||
response,
|
||||
content_override=validate_structured_output(
|
||||
parsed, response_format
|
||||
),
|
||||
)
|
||||
return self._normalize_response(response, content_override=content)
|
||||
if parsed is None:
|
||||
refusal = getattr(response.choices[0].message, "refusal", None)
|
||||
if refusal:
|
||||
return self._normalize_response(
|
||||
response,
|
||||
content_override=refusal,
|
||||
)
|
||||
raise ValidationException("No parsed content in structured response")
|
||||
return self._normalize_response(
|
||||
response,
|
||||
content_override=validate_structured_output(parsed, response_format),
|
||||
# parse() returned no model: repair raw content, surface a refusal,
|
||||
# or raise so the retry/fallback chain engages on a junk response.
|
||||
content = self._parse_or_repair_structured_content(
|
||||
response, response_format, model, empty_on_missing=False
|
||||
)
|
||||
return self._normalize_response(response, content_override=content)
|
||||
if response_format is not None:
|
||||
params["response_format"] = response_format
|
||||
|
||||
|
|
@ -232,15 +298,16 @@ class OpenAIBackend:
|
|||
params["stream"] = True
|
||||
params["stream_options"] = {"include_usage": True}
|
||||
if isinstance(response_format, type):
|
||||
# parse() supports BaseModel types but streaming create() does not —
|
||||
# convert to a json_schema dict so the streaming path works.
|
||||
params["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": response_format.__name__,
|
||||
"schema": response_format.model_json_schema(),
|
||||
},
|
||||
}
|
||||
if self._structured_output_mode(extra_params) == "json_object":
|
||||
# Inject the schema into the prompt for providers without
|
||||
# json_schema support; repair happens downstream.
|
||||
self._apply_json_object_mode(params, response_format)
|
||||
else:
|
||||
# Streaming create() can't take a BaseModel like parse() does;
|
||||
# convert to a json_schema dict.
|
||||
params["response_format"] = self._json_schema_response_format(
|
||||
response_format
|
||||
)
|
||||
elif response_format is not None:
|
||||
params["response_format"] = response_format
|
||||
elif extra_params and extra_params.get("json_mode"):
|
||||
|
|
@ -300,8 +367,10 @@ class OpenAIBackend:
|
|||
# Token-budget style thinking is not part of the native OpenAI API, but
|
||||
# OpenAI-compatible proxies (OpenRouter, etc.) accept a `reasoning` object
|
||||
# on the request body. Pass through via extra_body so it reaches those
|
||||
# backends; operators on providers that need a different shape (vLLM,
|
||||
# Fireworks, ...) can override via ModelConfig.provider_params.
|
||||
# backends. Operators on providers that need a different shape (e.g.
|
||||
# Anthropic-via-Vertex behind litellm wants `thinking`, not `reasoning`)
|
||||
# supply that shape via ModelConfig.provider_params.extra_body and unset
|
||||
# thinking_budget_tokens themselves — Honcho does not try to translate.
|
||||
if thinking_budget_tokens is not None and thinking_budget_tokens > 0:
|
||||
params.setdefault("extra_body", {}).setdefault("reasoning", {})[
|
||||
"max_tokens"
|
||||
|
|
@ -311,8 +380,9 @@ class OpenAIBackend:
|
|||
params["stop"] = stop
|
||||
if tools:
|
||||
params["tools"] = self._convert_tools(tools)
|
||||
if tool_choice is not None:
|
||||
params["tool_choice"] = tool_choice
|
||||
converted_tool_choice = self._convert_tool_choice(tool_choice)
|
||||
if converted_tool_choice is not None:
|
||||
params["tool_choice"] = converted_tool_choice
|
||||
if extra_params:
|
||||
for key in (
|
||||
"top_p",
|
||||
|
|
@ -322,6 +392,11 @@ class OpenAIBackend:
|
|||
):
|
||||
if key in extra_params:
|
||||
params[key] = extra_params[key]
|
||||
# Operator escape hatch: forward OpenAI SDK passthrough kwargs from
|
||||
# ModelConfig.provider_params. Shallow merge with operator-wins —
|
||||
# if the operator supplies `extra_body.reasoning`, it replaces any
|
||||
# value Honcho auto-injected above.
|
||||
apply_sdk_passthroughs(params, extra_params)
|
||||
return params
|
||||
|
||||
def _normalize_response(
|
||||
|
|
@ -374,37 +449,130 @@ class OpenAIBackend:
|
|||
raw_response=response,
|
||||
)
|
||||
|
||||
async def _create_structured_response(
|
||||
self,
|
||||
*,
|
||||
params: dict[str, Any],
|
||||
@staticmethod
|
||||
def _json_schema_response_format(
|
||||
response_format: type[BaseModel],
|
||||
) -> Any:
|
||||
structured_params = dict(params)
|
||||
structured_params["response_format"] = {
|
||||
) -> dict[str, Any]:
|
||||
"""Build the response_format param for create() calls that can't use
|
||||
parse(): streaming, and requests carrying non-strict function tools."""
|
||||
return {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": response_format.__name__,
|
||||
"schema": response_format.model_json_schema(),
|
||||
},
|
||||
}
|
||||
return await self._client.chat.completions.create(**structured_params)
|
||||
|
||||
@staticmethod
|
||||
def _structured_output_mode(extra_params: dict[str, Any] | None) -> str | None:
|
||||
# Threaded in via extra_params (see build_config_extra_params).
|
||||
if not extra_params:
|
||||
return None
|
||||
return extra_params.get("structured_output_mode")
|
||||
|
||||
def _apply_json_object_mode(
|
||||
self,
|
||||
params: dict[str, Any],
|
||||
response_format: type[BaseModel],
|
||||
) -> None:
|
||||
"""Configure params for json_object mode in place (shared by complete/stream).
|
||||
|
||||
Injects the schema into the prompt and requests loose JSON, so providers
|
||||
without OpenAI Structured Outputs (json_schema) support still return JSON.
|
||||
"""
|
||||
params["messages"] = self._with_json_schema_instructions(
|
||||
params["messages"], response_format
|
||||
)
|
||||
params["response_format"] = {"type": "json_object"}
|
||||
|
||||
@staticmethod
|
||||
def _with_json_schema_instructions(
|
||||
messages: list[dict[str, Any]],
|
||||
response_format: type[BaseModel],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Add JSON-schema instructions to a copy of messages for json_object mode.
|
||||
|
||||
The Anthropic backend has its own schema-into-prompt injection
|
||||
(``_append_text_to_last_message``); the two are intentionally kept
|
||||
separate since the providers want different placement and wording.
|
||||
"""
|
||||
instruction = _json_object_instruction(response_format)
|
||||
new_messages = [dict(message) for message in messages]
|
||||
first = new_messages[0] if new_messages else None
|
||||
# Only merge into a leading system message when its content is a plain
|
||||
# string; non-string content (e.g. a list of content parts) would be
|
||||
# corrupted by f-string coercion, so prepend a fresh system message.
|
||||
if (
|
||||
first
|
||||
and first.get("role") == "system"
|
||||
and isinstance(first.get("content"), str)
|
||||
):
|
||||
first["content"] = f"{first['content']}\n\n{instruction}".strip()
|
||||
else:
|
||||
new_messages.insert(0, {"role": "system", "content": instruction})
|
||||
return new_messages
|
||||
|
||||
@staticmethod
|
||||
def _parse_or_repair_structured_content(
|
||||
response: Any,
|
||||
response_format: type[BaseModel],
|
||||
model: str,
|
||||
*,
|
||||
empty_on_missing: bool,
|
||||
) -> BaseModel | str:
|
||||
raw_content = response.choices[0].message.content or ""
|
||||
"""Validate (or repair) the raw structured content of a response.
|
||||
|
||||
Shared by the json_object path and the json_schema parse() fallbacks
|
||||
(truncation, parsed=None). On a contentless response with no refusal,
|
||||
``empty_on_missing`` selects the terminal behavior: json_object returns a
|
||||
graceful empty so a loose provider can't crash the call, while json_schema
|
||||
raises so the retry/fallback chain engages on a junk response.
|
||||
"""
|
||||
message = response.choices[0].message
|
||||
raw_content = message.content or ""
|
||||
if raw_content:
|
||||
return repair_response_model_json(raw_content, response_format, model)
|
||||
refusal = getattr(response.choices[0].message, "refusal", None)
|
||||
# Fast path: clean JSON validates directly. Only fall back to the
|
||||
# repair pipeline when validation fails — repair is comparatively
|
||||
# expensive and silently degrades malformed input to an empty model.
|
||||
try:
|
||||
return validate_structured_output(raw_content, response_format)
|
||||
except (StructuredOutputError, ValidationError):
|
||||
return repair_response_model_json(raw_content, response_format, model)
|
||||
refusal = getattr(message, "refusal", None)
|
||||
if refusal:
|
||||
return refusal
|
||||
raise ValidationException(
|
||||
"No raw content available for structured output repair"
|
||||
)
|
||||
if not empty_on_missing:
|
||||
raise ValidationException("No parsed content in structured response")
|
||||
# empty_structured_output() validates {} against the model, which itself
|
||||
# raises if the model has required fields. Fall back to empty string
|
||||
# content rather than letting that escape the handler.
|
||||
try:
|
||||
return empty_structured_output(response_format)
|
||||
except ValidationError:
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _convert_tool_choice(
|
||||
tool_choice: str | dict[str, Any] | None,
|
||||
) -> str | dict[str, Any] | None:
|
||||
# Translate Honcho's canonical tool_choice vocabulary to OpenAI's. This
|
||||
# mirrors the Anthropic/Gemini backends so a single TOOL_CHOICE value
|
||||
# works regardless of which provider a fallback chain lands on. Notably
|
||||
# OpenAI has no "any" — it spells the same intent "required".
|
||||
if tool_choice is None:
|
||||
return None
|
||||
if isinstance(tool_choice, dict):
|
||||
if "name" in tool_choice:
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {"name": tool_choice["name"]},
|
||||
}
|
||||
return tool_choice
|
||||
if tool_choice in {"any", "required"}:
|
||||
return "required"
|
||||
if tool_choice in {"auto", "none"}:
|
||||
return tool_choice
|
||||
return {"type": "function", "function": {"name": tool_choice}}
|
||||
|
||||
@staticmethod
|
||||
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,403 @@
|
|||
"""Structures for data captured from LLM calls via telemetry.
|
||||
|
||||
All capture is best-effort: `dispatch_captured_call` swallows exporter exceptions
|
||||
so telemetry can never break the LLM call path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol, cast, runtime_checkable
|
||||
|
||||
from src.config import settings
|
||||
|
||||
from .backend import CompletionResult as BackendCompletionResult
|
||||
from .backend import ToolCallResult
|
||||
from .types import LLMTelemetryContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel roles for non-message content stored in the shared content store so
|
||||
# the same hash+dedup machinery covers them. They never collide with real
|
||||
# conversation roles ("user"/"assistant"/"system"/"tool").
|
||||
ROLE_OUTPUT = "assistant"
|
||||
ROLE_TOOL_SCHEMA = "__tool_schema__"
|
||||
ROLE_THINKING = "__thinking__"
|
||||
|
||||
|
||||
def canonical_json(obj: Any) -> str:
|
||||
"""Deterministic JSON encoding used for every content hash."""
|
||||
return json.dumps(
|
||||
obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str
|
||||
)
|
||||
|
||||
|
||||
def compute_content_hash(
|
||||
role: str,
|
||||
content: Any,
|
||||
tool_call_id: str | None,
|
||||
tool_calls: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""Content hash covering the FULL message identity, not just the text.
|
||||
|
||||
Includes `tool_calls` so two assistant turns with identical (often empty)
|
||||
content but different tool calls don't collide in the dedup store.
|
||||
"""
|
||||
digest = hashlib.sha256(
|
||||
canonical_json(
|
||||
{
|
||||
"role": role,
|
||||
"content": content,
|
||||
"tool_call_id": tool_call_id,
|
||||
"tool_calls": tool_calls or [],
|
||||
}
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
return f"sha256:{digest}"
|
||||
|
||||
|
||||
def clip_for_trace(content: Any) -> tuple[Any, bool]:
|
||||
"""Clip a content value to `TELEMETRY.TRACE_MAX_BYTES`, returning (content, truncated).
|
||||
|
||||
Only oversized string content is clipped (with a marker); non-string
|
||||
structured content is left intact. Returns the input unchanged when it
|
||||
fits or when the cap is non-positive.
|
||||
"""
|
||||
max_bytes = settings.TELEMETRY.TRACE_MAX_BYTES
|
||||
if max_bytes <= 0 or not isinstance(content, str):
|
||||
return content, False
|
||||
encoded = content.encode("utf-8")
|
||||
if len(encoded) <= max_bytes:
|
||||
return content, False
|
||||
marker = "…[truncated]"
|
||||
keep = max(0, max_bytes - len(marker.encode("utf-8")))
|
||||
clipped = encoded[:keep].decode("utf-8", errors="ignore") + marker
|
||||
return clipped, True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CapturedMessage:
|
||||
"""One input message, normalized to a provider-agnostic shape.
|
||||
|
||||
`content` is the message text; `tool_calls` holds any tool calls in a
|
||||
unified `{id, name, input}` shape regardless of provider. `content_hash`
|
||||
covers all identity fields so the ref and the shipped `trace.content` agree.
|
||||
"""
|
||||
|
||||
role: str
|
||||
content: Any
|
||||
tool_call_id: str | None
|
||||
content_hash: str
|
||||
truncated: bool = False
|
||||
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CapturedLLMCall:
|
||||
"""Everything one LLM call needs to be reconstructed, captured once."""
|
||||
|
||||
# Correlation (span tree)
|
||||
trace_id: str | None
|
||||
span_id: str | None
|
||||
parent_span_id: str | None
|
||||
iteration: int | None
|
||||
step_seq: int
|
||||
attempt: int
|
||||
was_fallback: bool
|
||||
run_id: str | None
|
||||
# Path identity
|
||||
workspace_name: str | None
|
||||
call_purpose: str | None
|
||||
parent_category: str | None
|
||||
agent_type: str | None
|
||||
# unique session ID for grouping traces
|
||||
session_id: str | None
|
||||
observer: str | None
|
||||
observed: str | None
|
||||
peer_name: str | None
|
||||
track_name: str | None
|
||||
transport: str
|
||||
provider_label: str | None
|
||||
model: str
|
||||
# Context window
|
||||
input_messages: list[CapturedMessage]
|
||||
tool_schemas: list[dict[str, Any]]
|
||||
tool_choice: Any
|
||||
# Output (replay-grade)
|
||||
output_content: Any
|
||||
output_tool_calls: list[dict[str, Any]]
|
||||
thinking_content: str | None
|
||||
thinking_blocks: list[dict[str, Any]]
|
||||
reasoning_details: list[dict[str, Any]]
|
||||
finish_reason: str | None
|
||||
# Accounting copy (so the trace stream stands alone)
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cache_read_tokens: int
|
||||
cache_creation_tokens: int
|
||||
was_stream: bool
|
||||
# True when any input message was clipped to TRACE_MAX_BYTES.
|
||||
input_truncated: bool = False
|
||||
|
||||
|
||||
def _normalize_message(
|
||||
message: dict[str, Any], transport: str | None
|
||||
) -> tuple[Any, str | None, list[dict[str, Any]]]:
|
||||
"""Normalize a provider-native message to (content, tool_call_id, tool_calls).
|
||||
|
||||
Providers stash tool calls and results outside `content` (openai's
|
||||
`tool_calls`, gemini's `parts`), so a naive `content` read loses them. This
|
||||
lifts them into a unified shape: `content` becomes text, `tool_calls` is a
|
||||
list of `{id, name, input}`, and tool results surface as `content` keyed by
|
||||
`tool_call_id`.
|
||||
"""
|
||||
content: Any = message.get("content")
|
||||
tool_call_id: str | None = message.get("tool_call_id")
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
|
||||
if transport == "openai":
|
||||
for tc in cast("list[dict[str, Any]]", message.get("tool_calls") or []):
|
||||
fn = cast("dict[str, Any]", tc.get("function") or {})
|
||||
args = fn.get("arguments")
|
||||
if isinstance(args, str):
|
||||
with contextlib.suppress(json.JSONDecodeError):
|
||||
args = json.loads(args)
|
||||
tool_calls.append(
|
||||
{"id": tc.get("id"), "name": fn.get("name"), "input": args}
|
||||
)
|
||||
|
||||
elif transport == "gemini":
|
||||
parts = message.get("parts")
|
||||
if isinstance(parts, list):
|
||||
texts: list[str] = []
|
||||
results: list[Any] = []
|
||||
for raw_part in cast("list[Any]", parts):
|
||||
if not isinstance(raw_part, dict):
|
||||
continue
|
||||
part = cast("dict[str, Any]", raw_part)
|
||||
text = part.get("text")
|
||||
if isinstance(text, str):
|
||||
texts.append(text)
|
||||
elif "function_call" in part:
|
||||
fc = cast("dict[str, Any]", part["function_call"] or {})
|
||||
tool_calls.append(
|
||||
{"id": None, "name": fc.get("name"), "input": fc.get("args")}
|
||||
)
|
||||
elif "function_response" in part:
|
||||
fr = cast("dict[str, Any]", part["function_response"] or {})
|
||||
resp = fr.get("response")
|
||||
if isinstance(resp, dict):
|
||||
results.append(cast("dict[str, Any]", resp).get("result"))
|
||||
else:
|
||||
results.append(resp)
|
||||
if tool_call_id is None:
|
||||
tool_call_id = fr.get("name")
|
||||
content = "\n".join(texts) if texts else (results[0] if results else None)
|
||||
|
||||
elif transport == "anthropic" and isinstance(content, list):
|
||||
texts = []
|
||||
for raw_block in cast("list[Any]", content):
|
||||
if not isinstance(raw_block, dict):
|
||||
continue
|
||||
block = cast("dict[str, Any]", raw_block)
|
||||
btype = block.get("type")
|
||||
text = block.get("text")
|
||||
if btype == "text" and isinstance(text, str):
|
||||
texts.append(text)
|
||||
elif btype == "tool_use":
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": block.get("id"),
|
||||
"name": block.get("name"),
|
||||
"input": block.get("input"),
|
||||
}
|
||||
)
|
||||
elif btype == "tool_result":
|
||||
if tool_call_id is None:
|
||||
tool_call_id = block.get("tool_use_id")
|
||||
inner = block.get("content")
|
||||
texts.append(inner if isinstance(inner, str) else canonical_json(inner))
|
||||
content = "\n".join(texts) if texts else None
|
||||
|
||||
return content, tool_call_id, tool_calls
|
||||
|
||||
|
||||
def build_captured_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
memo: dict[int, CapturedMessage] | None,
|
||||
transport: str | None = None,
|
||||
) -> tuple[list[CapturedMessage], bool]:
|
||||
"""Create a list of CapturedMessage from LLM response messages.
|
||||
|
||||
Conversation is append-only. Uses hashed message content to deduplicate
|
||||
across turns. Messages are normalized per provider, then content is
|
||||
truncated and hashed.
|
||||
"""
|
||||
captured: list[CapturedMessage] = []
|
||||
any_truncated = False
|
||||
for message in messages:
|
||||
key = id(message)
|
||||
cached = memo.get(key) if memo is not None else None
|
||||
if cached is not None:
|
||||
captured.append(cached)
|
||||
any_truncated = any_truncated or cached.truncated
|
||||
continue
|
||||
role = str(message.get("role", ""))
|
||||
raw_content, tool_call_id, tool_calls = _normalize_message(message, transport)
|
||||
content, truncated = clip_for_trace(raw_content)
|
||||
any_truncated = any_truncated or truncated
|
||||
captured_message = CapturedMessage(
|
||||
role=role,
|
||||
content=content,
|
||||
tool_call_id=tool_call_id,
|
||||
content_hash=compute_content_hash(role, content, tool_call_id, tool_calls),
|
||||
truncated=truncated,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
if memo is not None:
|
||||
memo[key] = captured_message
|
||||
captured.append(captured_message)
|
||||
return captured, any_truncated
|
||||
|
||||
|
||||
def build_captured_call(
|
||||
*,
|
||||
telemetry: LLMTelemetryContext | None,
|
||||
transport: str,
|
||||
provider_label: str | None,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
tool_choice: Any,
|
||||
result: BackendCompletionResult | None,
|
||||
attempt: int,
|
||||
was_fallback: bool,
|
||||
was_stream: bool,
|
||||
finish_reason: str | None,
|
||||
) -> CapturedLLMCall:
|
||||
"""Assemble a `CapturedLLMCall` from telemetry + the provider result."""
|
||||
memo = telemetry.hash_memo if telemetry is not None else None
|
||||
captured_messages, input_truncated = build_captured_messages(
|
||||
messages, memo, transport
|
||||
)
|
||||
|
||||
output_tool_calls = [
|
||||
_tool_call_to_dict(tc) for tc in (result.tool_calls if result else [])
|
||||
]
|
||||
|
||||
return CapturedLLMCall(
|
||||
trace_id=telemetry.trace_id if telemetry else None,
|
||||
span_id=telemetry.span_id if telemetry else None,
|
||||
parent_span_id=telemetry.exported_parent_span_id() if telemetry else None,
|
||||
iteration=telemetry.iteration if telemetry else None,
|
||||
step_seq=telemetry.step_seq if telemetry else 0,
|
||||
attempt=attempt,
|
||||
was_fallback=was_fallback,
|
||||
run_id=telemetry.run_id if telemetry else None,
|
||||
workspace_name=telemetry.workspace_name if telemetry else None,
|
||||
call_purpose=telemetry.call_purpose if telemetry else None,
|
||||
parent_category=telemetry.parent_category if telemetry else None,
|
||||
agent_type=telemetry.agent_type if telemetry else None,
|
||||
session_id=telemetry.session_id if telemetry else None,
|
||||
observer=telemetry.observer if telemetry else None,
|
||||
observed=telemetry.observed if telemetry else None,
|
||||
peer_name=telemetry.peer_name if telemetry else None,
|
||||
track_name=telemetry.track_name if telemetry else None,
|
||||
transport=transport,
|
||||
provider_label=provider_label,
|
||||
model=model,
|
||||
input_messages=captured_messages,
|
||||
tool_schemas=list(tools) if tools else [],
|
||||
tool_choice=tool_choice,
|
||||
output_content=result.content if result else None,
|
||||
output_tool_calls=output_tool_calls,
|
||||
thinking_content=result.thinking_content if result else None,
|
||||
thinking_blocks=result.thinking_blocks if result else [],
|
||||
reasoning_details=result.reasoning_details if result else [],
|
||||
finish_reason=finish_reason,
|
||||
input_tokens=result.input_tokens if result else 0,
|
||||
output_tokens=result.output_tokens if result else 0,
|
||||
cache_read_tokens=result.cache_read_input_tokens if result else 0,
|
||||
cache_creation_tokens=result.cache_creation_input_tokens if result else 0,
|
||||
was_stream=was_stream,
|
||||
input_truncated=input_truncated,
|
||||
)
|
||||
|
||||
|
||||
def _tool_call_to_dict(tool_call: ToolCallResult) -> dict[str, Any]:
|
||||
"""Normalize a ToolCallResult to a JSON-safe dict for the trace stream.
|
||||
|
||||
`thought_signature` arrives as raw bytes from Gemini; base64-encode it so
|
||||
CloudEvents JSON serialization can't choke on non-UTF8 bytes (which would
|
||||
silently drop the whole event via the best-effort emit path).
|
||||
"""
|
||||
out: dict[str, Any] = {
|
||||
"id": tool_call.id,
|
||||
"name": tool_call.name,
|
||||
"input": tool_call.input,
|
||||
}
|
||||
sig = tool_call.thought_signature
|
||||
if sig is not None:
|
||||
out["thought_signature"] = (
|
||||
base64.b64encode(sig).decode("ascii") if isinstance(sig, bytes) else sig
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LLMCallExporter(Protocol):
|
||||
"""A sink that consumes a `CapturedLLMCall`"""
|
||||
|
||||
def export(self, call: CapturedLLMCall) -> None: ...
|
||||
|
||||
|
||||
_EXPORTERS: list[LLMCallExporter] = []
|
||||
|
||||
|
||||
def register_exporter(exporter: LLMCallExporter) -> None:
|
||||
"""Register an exporter (idempotent on identity). Called at startup."""
|
||||
if exporter not in _EXPORTERS:
|
||||
_EXPORTERS.append(exporter)
|
||||
|
||||
|
||||
def clear_exporters() -> None:
|
||||
"""Drop all exporters — used on shutdown and in tests."""
|
||||
_EXPORTERS.clear()
|
||||
|
||||
|
||||
def has_exporters() -> bool:
|
||||
"""True when at least one exporter is registered."""
|
||||
return bool(_EXPORTERS)
|
||||
|
||||
|
||||
def dispatch_captured_call(call: CapturedLLMCall) -> None:
|
||||
"""Fan a captured call out to every exporter."""
|
||||
for exporter in _EXPORTERS:
|
||||
try:
|
||||
exporter.export(call)
|
||||
except Exception: # pragma: no cover - best-effort telemetry
|
||||
logger.debug("LLM call exporter failed", exc_info=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ROLE_OUTPUT",
|
||||
"ROLE_THINKING",
|
||||
"ROLE_TOOL_SCHEMA",
|
||||
"CapturedLLMCall",
|
||||
"CapturedMessage",
|
||||
"LLMCallExporter",
|
||||
"build_captured_call",
|
||||
"build_captured_messages",
|
||||
"canonical_json",
|
||||
"clear_exporters",
|
||||
"clip_for_trace",
|
||||
"compute_content_hash",
|
||||
"dispatch_captured_call",
|
||||
"has_exporters",
|
||||
"register_exporter",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue