Merge branch 'main' into vineeth/dev-1997
This commit is contained in:
commit
79342b57e5
|
|
@ -131,7 +131,8 @@ LLM_OPENAI_API_KEY=your-api-key-here
|
|||
# DERIVER_MAX_INPUT_TOKENS=25000
|
||||
# DERIVER_MAX_CUSTOM_INSTRUCTIONS_TOKENS=2000
|
||||
# DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100
|
||||
# DERIVER_REPRESENTATION_BATCH_MAX_TOKENS=1024
|
||||
# DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=512 # Min tokens a work unit accumulates before the deriver claims it; 0 disables the gate
|
||||
# DERIVER_REPRESENTATION_BATCH_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=
|
||||
|
|
@ -286,9 +287,11 @@ LLM_OPENAI_API_KEY=your-api-key-here
|
|||
# =============================================================================
|
||||
# CACHE_ENABLED=false
|
||||
# CACHE_URL="redis://localhost:6379/0?suppress=true"
|
||||
# CACHE_CLUSTER=false # true when CACHE_URL is a Redis Cluster (e.g. Memorystore for Redis Cluster)
|
||||
# CACHE_NAMESPACE="honcho" # Inherits from NAMESPACE if not set
|
||||
# CACHE_DEFAULT_TTL_SECONDS=300
|
||||
# CACHE_DEFAULT_LOCK_TTL_SECONDS=5
|
||||
# CACHE_LOCK_WAIT_CHECK_INTERVAL_SECONDS=0.1
|
||||
|
||||
# =============================================================================
|
||||
# CORS Settings
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -44,70 +80,67 @@ jobs:
|
|||
aws-region: us-east-1
|
||||
role-duration-seconds: 43200 # 12 hours
|
||||
|
||||
- name: Resolve secret ids from latest git tags
|
||||
id: resolve-secret
|
||||
env:
|
||||
SECRET_PREFIX: ${{ secrets.STAGING_SECRET_PREFIX }}
|
||||
# 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: |
|
||||
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
|
||||
: "${SECRET_PREFIX:?STAGING_SECRET_PREFIX secret is not set for this environment}"
|
||||
# Keep the secret-name prefix out of public CI logs.
|
||||
echo "::add-mask::${SECRET_PREFIX}"
|
||||
|
||||
# Two newest v<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)
|
||||
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
|
||||
applied=0
|
||||
while IFS= read -r -d '' entry; do
|
||||
name="${entry%%=*}"
|
||||
value="${entry#*=}"
|
||||
case "$name" in
|
||||
HONCHO_TEST_OVERRIDE_*)
|
||||
target="${name#HONCHO_TEST_OVERRIDE_}"
|
||||
{
|
||||
echo "${target}<<__HONCHO_OVERRIDE_EOF__"
|
||||
printf '%s\n' "$value"
|
||||
echo "__HONCHO_OVERRIDE_EOF__"
|
||||
} >> "$GITHUB_ENV"
|
||||
echo "Overriding ${target}"
|
||||
applied=$((applied + 1))
|
||||
;;
|
||||
esac
|
||||
done < <(env -0)
|
||||
echo "Applied ${applied} override(s)"
|
||||
|
||||
# Configure the test environment. Disables auth/Sentry/CloudEvents telemetry
|
||||
# (their endpoints aren't reachable from CI), and points REASONING_TRACES_FILE
|
||||
# at a shared path so the API + deriver record full LLM I/O for auditing — the
|
||||
# runner uploads it to S3. Written after the fetch steps so these win over the
|
||||
# values loaded from Secrets Manager (last $GITHUB_ENV write wins).
|
||||
# values loaded from Secrets Manager (last $GITHUB_ENV write wins). Stale
|
||||
# config keys loaded from the staging secret (e.g. settings that have since
|
||||
# been renamed or removed on main) must always be ignored by the app config.
|
||||
- name: Configure test environment
|
||||
run: |
|
||||
echo "AUTH_USE_AUTH=false" >> "$GITHUB_ENV"
|
||||
echo "SENTRY_ENABLED=false" >> "$GITHUB_ENV"
|
||||
echo "TELEMETRY_ENABLED=false" >> "$GITHUB_ENV"
|
||||
echo "REASONING_TRACES_FILE=unified-reasoning-traces.jsonl" >> "$GITHUB_ENV"
|
||||
{
|
||||
echo "AUTH_USE_AUTH=false"
|
||||
echo "SENTRY_ENABLED=false"
|
||||
echo "TELEMETRY_ENABLED=false"
|
||||
echo "REASONING_TRACES_FILE=unified-reasoning-traces.jsonl"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Verify Docker is available
|
||||
run: docker info
|
||||
|
|
@ -168,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
|
||||
|
|
|
|||
|
|
@ -109,7 +109,8 @@ LOG_OBSERVATIONS = false
|
|||
MAX_INPUT_TOKENS = 25000
|
||||
MAX_CUSTOM_INSTRUCTIONS_TOKENS = 2000
|
||||
WORKING_REPRESENTATION_MAX_OBSERVATIONS = 100
|
||||
REPRESENTATION_BATCH_MAX_TOKENS = 1024
|
||||
REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS = 512 # Min tokens a work unit accumulates before the deriver claims it; 0 disables the gate
|
||||
REPRESENTATION_BATCH_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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -408,7 +408,8 @@ DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000 # 30 days
|
|||
DERIVER_DEDUPLICATE=true
|
||||
DERIVER_LOG_OBSERVATIONS=false
|
||||
DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100
|
||||
DERIVER_REPRESENTATION_BATCH_MAX_TOKENS=1024
|
||||
DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=512
|
||||
DERIVER_REPRESENTATION_BATCH_TARGET_INPUT_TOKENS=1024
|
||||
DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS=1800
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ Messages are stored but no observations, summaries, or representations are being
|
|||
```bash
|
||||
DERIVER_WORKERS=4
|
||||
```
|
||||
5. **Representation Batch Max** — By default the deriver buffers representation work until a session has enough tokens for that representation, set via `DERIVER_REPRESENTATION_BATCH_MAX_TOKENS`. Sub-threshold tails become eligible after `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800 seconds), so quiet sessions eventually flush without disabling batching globally. Set the age to `0` for legacy behavior where sub-threshold tails wait indefinitely. See [token batching](/v3/documentation/core-concepts/reasoning#token-batching) for more details
|
||||
5. **Representation Batching** — By default the deriver buffers representation work until a work unit has accumulated enough tokens, set via `DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS` (`0` disables the accumulation gate). A separate setting, `DERIVER_REPRESENTATION_BATCH_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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [0.1.2] - 2026-07-20
|
||||
|
||||
### Added
|
||||
|
||||
- Device-code OAuth login for managed Honcho servers. `honcho init` now offers browser-based login (RFC 8628 device authorization grant) when the host advertises the device grant in its OAuth authorization-server metadata; tokens are persisted to `~/.honcho/config.json` and auto-refreshed (#891)
|
||||
|
||||
## [0.1.1] - 2026-06-15
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "honcho-cli"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "A terminal for Honcho — memory that reasons."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ dependencies = [
|
|||
"lancedb>=0.25.3",
|
||||
"pyarrow>=19.0.0",
|
||||
"redis>=7.0.0,<8.0.0",
|
||||
"cashews[redis]==7.4.4",
|
||||
"cashews[redis]==7.5.0",
|
||||
"scikit-learn>=1.6.0",
|
||||
"prometheus_client>=0.21.0",
|
||||
"cloudevents>=1.12.0,<2.0",
|
||||
|
|
@ -59,6 +59,9 @@ dev = [
|
|||
"pytest-xdist>=3.8.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
exclude-newer = "5 days"
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = [
|
||||
"sdks/python",
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ import logging
|
|||
import warnings
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload
|
||||
|
||||
from pydantic import ConfigDict, Field, validate_call
|
||||
from pydantic import BaseModel, ConfigDict, Field, validate_call
|
||||
|
||||
from .api_types import (
|
||||
ConclusionResponse,
|
||||
|
|
@ -71,7 +71,7 @@ if TYPE_CHECKING:
|
|||
from .conclusions import ConclusionScope
|
||||
|
||||
from .conclusions import ConclusionCreateParams
|
||||
from .peer import Peer
|
||||
from .peer import Peer, TResponseFormat, serialize_response_format
|
||||
from .session import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -577,6 +577,30 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
)
|
||||
self._peer._configuration = configuration
|
||||
|
||||
@overload
|
||||
async def chat(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
target: str | PeerBase | None = None,
|
||||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[TResponseFormat],
|
||||
) -> TResponseFormat | None: ...
|
||||
|
||||
@overload
|
||||
async def chat(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
target: str | PeerBase | None = None,
|
||||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: dict[str, Any] | None = None,
|
||||
) -> str | None: ...
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
async def chat(
|
||||
self,
|
||||
|
|
@ -586,8 +610,14 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
) -> str | None:
|
||||
"""Query the peer's representation asynchronously."""
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
) -> BaseModel | str | None:
|
||||
"""Query the peer's representation asynchronously.
|
||||
|
||||
See Peer.chat for parameter details. When response_format is a Pydantic
|
||||
model class, the answer is parsed into an instance of it; when it is a
|
||||
JSON Schema dict, the answer is a JSON string.
|
||||
"""
|
||||
await self._peer._honcho._ensure_workspace_async()
|
||||
target_id = resolve_id(target)
|
||||
resolved_session_id = resolve_id(session)
|
||||
|
|
@ -599,6 +629,9 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
body["session_id"] = resolved_session_id
|
||||
if reasoning_level:
|
||||
body["reasoning_level"] = reasoning_level
|
||||
response_format_schema = serialize_response_format(response_format)
|
||||
if response_format_schema is not None:
|
||||
body["response_format"] = response_format_schema
|
||||
|
||||
data = await self._peer._honcho._async_http_client.post(
|
||||
routes.peer_chat(self._peer.workspace_id, self._peer.id),
|
||||
|
|
@ -607,6 +640,8 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
content = data.get("content")
|
||||
if not content:
|
||||
return None
|
||||
if isinstance(response_format, type):
|
||||
return response_format.model_validate_json(content)
|
||||
return content
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
|
|
@ -618,8 +653,14 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
) -> AsyncDialecticStreamResponse:
|
||||
"""Query the peer's representation with streaming asynchronously."""
|
||||
"""Query the peer's representation with streaming asynchronously.
|
||||
|
||||
See Peer.chat_stream for parameter details. With response_format set,
|
||||
chunks stay raw text that accumulates to a JSON string; parse it after
|
||||
the stream completes.
|
||||
"""
|
||||
await self._peer._honcho._ensure_workspace_async()
|
||||
target_id = resolve_id(target)
|
||||
resolved_session_id = resolve_id(session)
|
||||
|
|
@ -631,6 +672,9 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
body["session_id"] = resolved_session_id
|
||||
if reasoning_level:
|
||||
body["reasoning_level"] = reasoning_level
|
||||
response_format_schema = serialize_response_format(response_format)
|
||||
if response_format_schema is not None:
|
||||
body["response_format"] = response_format_schema
|
||||
|
||||
async def stream_response() -> AsyncGenerator[str, None]:
|
||||
async for content in parse_sse_astream(
|
||||
|
|
|
|||
|
|
@ -503,6 +503,7 @@ class DialecticParams(BaseModel):
|
|||
query: str = Field(min_length=1, max_length=10000)
|
||||
stream: bool = False
|
||||
reasoning_level: ReasoningLevel = "low"
|
||||
response_format: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class DialecticResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -45,11 +45,15 @@ async def init_cache() -> None:
|
|||
cache.setup("mem://", pickle_type=PicklerType.SQLALCHEMY)
|
||||
return
|
||||
|
||||
# Setup cache with Redis backend
|
||||
# Setup cache with Redis backend. CACHE_CLUSTER selects the
|
||||
# cluster-aware client, which follows the MOVED redirects a Redis
|
||||
# Cluster returns for keys hashed to another shard; the standalone
|
||||
# client treats those as command errors.
|
||||
try:
|
||||
cache.setup(
|
||||
settings.CACHE.URL,
|
||||
pickle_type=PicklerType.SQLALCHEMY,
|
||||
cluster=settings.CACHE.CLUSTER,
|
||||
)
|
||||
|
||||
except Exception as setup_err:
|
||||
|
|
|
|||
|
|
@ -754,6 +754,10 @@ class EmbeddingSettings(HonchoSettings):
|
|||
# Caps concurrent message-embedding fan-out on the API request path (the
|
||||
# immediate-embed background task). The reconciler is unaffected.
|
||||
MAX_CONCURRENT_EMBEDDINGS: Annotated[int, Field(default=10, gt=0, le=100)] = 10
|
||||
# Caps in-flight immediate-embed background tasks per API process. When
|
||||
# saturated, message creation skips the fast path entirely and the
|
||||
# reconciler embeds on its next cycle. 0 disables the fast path.
|
||||
MAX_PENDING_EMBED_TASKS: Annotated[int, Field(default=50, ge=0)] = 50
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
|
@ -857,7 +861,20 @@ class DeriverSettings(HonchoSettings):
|
|||
int, Field(default=100, gt=0, le=1000)
|
||||
] = 100
|
||||
|
||||
REPRESENTATION_BATCH_MAX_TOKENS: Annotated[
|
||||
# Minimum tokens a representation work unit must accumulate (summed over
|
||||
# its own unprocessed messages) before it becomes claimable. Bypassed by
|
||||
# FLUSH_ENABLED and by REPRESENTATION_BATCH_MAX_AGE_SECONDS age-flushing.
|
||||
# 0 disables the accumulation gate entirely (equivalent to FLUSH_ENABLED
|
||||
# for claiming): work units are claimable as soon as anything is pending.
|
||||
REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS: Annotated[
|
||||
int,
|
||||
Field(default=512, ge=0, le=16_384),
|
||||
] = 512
|
||||
# Cumulative-token cap on the conversation window (queued messages plus
|
||||
# interleaved context) fed to a single deriver LLM call when draining a
|
||||
# claimed work unit. The first unprocessed message is always included,
|
||||
# even if it alone exceeds the cap.
|
||||
REPRESENTATION_BATCH_TARGET_INPUT_TOKENS: Annotated[
|
||||
int,
|
||||
Field(default=1024, ge=128, le=16_384),
|
||||
] = 1024
|
||||
|
|
@ -883,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
|
||||
|
||||
|
|
@ -1195,6 +1212,10 @@ class CacheSettings(HonchoSettings):
|
|||
|
||||
ENABLED: bool = False
|
||||
URL: str = "redis://localhost:6379/0?suppress=true"
|
||||
# URL points at a Redis Cluster (OSS cluster protocol, e.g. GCP Memorystore
|
||||
# for Redis Cluster). A standalone client cannot follow the MOVED redirects
|
||||
# such deployments return for keys hashed to another shard.
|
||||
CLUSTER: bool = False
|
||||
NAMESPACE: str | None = None
|
||||
DEFAULT_TTL_SECONDS: Annotated[int, Field(default=300, ge=1, le=86_400)] = (
|
||||
300 # how long to keep items in cache
|
||||
|
|
@ -1204,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."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -91,6 +92,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
|
||||
|
||||
|
|
@ -441,6 +443,39 @@ def _normalize_content(content: str) -> str:
|
|||
return content.strip().lower()
|
||||
|
||||
|
||||
def _dedup_key(
|
||||
content: str, level: str, session_name: str | None
|
||||
) -> tuple[str, str, str | None]:
|
||||
"""Build the exact-match dedup key for a document.
|
||||
|
||||
Dedup never crosses levels: a same-content document at a different level is
|
||||
a different kind of record (an explicit fact is not interchangeable with a
|
||||
deductive conclusion that happens to share its text).
|
||||
|
||||
For **explicit** documents dedup additionally never crosses sessions.
|
||||
Explicit documents are session-pure records of what was derived from that
|
||||
session's messages — the Scopes copy-by-session model depends on this — so
|
||||
a repeat of the same fact in a different session must produce a new
|
||||
document in that session rather than reinforce another session's row.
|
||||
Derived levels (deductive/inductive/contradiction) are consolidations and
|
||||
may still dedup across sessions.
|
||||
"""
|
||||
return (
|
||||
_normalize_content(content),
|
||||
level,
|
||||
session_name if level == "explicit" else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateDocumentsResult:
|
||||
created_documents: list[schemas.DocumentCreate] = field(default_factory=list)
|
||||
exact_dup_in_batch_count: int = 0
|
||||
exact_dup_existing_count: int = 0
|
||||
semantic_dup_rejected_count: int = 0
|
||||
semantic_dup_replaced_count: int = 0
|
||||
|
||||
|
||||
async def create_documents(
|
||||
db: AsyncSession,
|
||||
documents: list[schemas.DocumentCreate],
|
||||
|
|
@ -449,7 +484,7 @@ async def create_documents(
|
|||
observer: str,
|
||||
observed: str,
|
||||
deduplicate: bool = False,
|
||||
) -> list[schemas.DocumentCreate]:
|
||||
) -> CreateDocumentsResult:
|
||||
"""
|
||||
Create multiple documents with optional duplicate detection.
|
||||
|
||||
|
|
@ -477,9 +512,10 @@ async def create_documents(
|
|||
# exact-content dedup (independent of `deduplicate`): pre-fetch
|
||||
# existing live documents whose normalized content matches anything in this
|
||||
# batch, scoped to (workspace, observer, observed). The SQL normalization must
|
||||
# mirror _normalize_content.
|
||||
# mirror _normalize_content. Matching is further scoped per-document by
|
||||
# level (always) and session (for explicit documents) via _dedup_key.
|
||||
batch_normalized: set[str] = {_normalize_content(d.content) for d in documents}
|
||||
existing_by_normalized: dict[str, models.Document] = {}
|
||||
existing_by_key: dict[tuple[str, str, str | None], models.Document] = {}
|
||||
if batch_normalized:
|
||||
# The `normalized_content_sql.in_(...)` filter below narrows to the
|
||||
# (workspace, observer, observed) partition via the single-column indexes,
|
||||
|
|
@ -507,29 +543,54 @@ async def create_documents(
|
|||
)
|
||||
)
|
||||
for existing_doc in existing_result.scalars():
|
||||
# If multiple historical rows share normalized content, reinforcing
|
||||
# If multiple historical rows share a dedup key, reinforcing
|
||||
# one is sufficient; keep the first.
|
||||
existing_by_normalized.setdefault(
|
||||
_normalize_content(existing_doc.content), existing_doc
|
||||
existing_by_key.setdefault(
|
||||
_dedup_key(
|
||||
existing_doc.content,
|
||||
existing_doc.level,
|
||||
existing_doc.session_name,
|
||||
),
|
||||
existing_doc,
|
||||
)
|
||||
|
||||
# Tracks normalized content already accepted from this batch so exact
|
||||
# Tracks dedup keys already accepted from this batch so exact
|
||||
# duplicates within a single inference call collapse to one document.
|
||||
seen_in_batch: set[str] = set()
|
||||
seen_in_batch: set[tuple[str, str, str | None]] = set()
|
||||
|
||||
exact_dup_existing_count = 0
|
||||
exact_dup_in_batch_count = 0
|
||||
semantic_dup_rejected_count = 0
|
||||
semantic_dup_replaced_count = 0
|
||||
for doc in documents:
|
||||
try:
|
||||
normalized_content = _normalize_content(doc.content)
|
||||
# Session-purity invariant: an explicit document must always carry
|
||||
# the session it was derived from. Refuse to write session-less
|
||||
# explicit documents rather than silently minting global explicit
|
||||
# memory (the Scopes copy-by-session model depends on explicit
|
||||
# documents staying session-pure).
|
||||
if doc.level == "explicit" and doc.session_name is None:
|
||||
logger.error(
|
||||
"Refusing to create explicit document without session_name in %s/%s/%s (session-purity invariant): %r",
|
||||
workspace_name,
|
||||
observer,
|
||||
observed,
|
||||
doc.content[:80],
|
||||
)
|
||||
continue
|
||||
|
||||
dedup_key = _dedup_key(doc.content, doc.level, doc.session_name)
|
||||
|
||||
# Exact-match dedup, always on:
|
||||
# 1) collapse exact duplicates within this batch (drop silently).
|
||||
if normalized_content in seen_in_batch:
|
||||
if dedup_key in seen_in_batch:
|
||||
exact_dup_in_batch_count += 1
|
||||
continue
|
||||
seen_in_batch.add(normalized_content)
|
||||
seen_in_batch.add(dedup_key)
|
||||
|
||||
# 2) drop exact duplicates of an existing live document, recording
|
||||
# the re-derivation as reinforcement on the existing row.
|
||||
existing_match = existing_by_normalized.get(normalized_content)
|
||||
existing_match = existing_by_key.get(dedup_key)
|
||||
if existing_match is not None:
|
||||
# Reinforce the existing row. greatest(...) keeps the bump atomic
|
||||
# server-side (concurrent workers can't lose an increment) while
|
||||
|
|
@ -542,16 +603,22 @@ async def create_documents(
|
|||
doc.times_derived,
|
||||
)
|
||||
await db.flush()
|
||||
exact_dup_existing_count += 1
|
||||
continue
|
||||
|
||||
# for each document, if deduplicate is True, perform a process
|
||||
# that checks against existing documents and either rejects this document
|
||||
# as a duplicate OR deletes an existing document that is a duplicate.
|
||||
if deduplicate:
|
||||
is_duplicate = await is_rejected_duplicate(
|
||||
duplicate_result = await is_rejected_duplicate(
|
||||
db, doc, workspace_name, observer=observer, observed=observed
|
||||
)
|
||||
if is_duplicate:
|
||||
if duplicate_result is SemanticRejectionResult.REPLACED_EXISTING:
|
||||
# Existing doc was soft-deleted in favor of this one; the
|
||||
# new doc still gets inserted below.
|
||||
semantic_dup_replaced_count += 1
|
||||
elif duplicate_result is SemanticRejectionResult.REJECTED:
|
||||
semantic_dup_rejected_count += 1
|
||||
continue
|
||||
|
||||
metadata_dict = doc.metadata.model_dump(exclude_none=True)
|
||||
|
|
@ -703,7 +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(
|
||||
|
|
@ -1053,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,
|
||||
|
|
@ -1060,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.
|
||||
|
||||
|
|
@ -1080,7 +1159,20 @@ async def is_rejected_duplicate(
|
|||
If the document is a duplicate AND the existing document is superior,
|
||||
increments the existing document's ``times_derived`` to record the
|
||||
reinforcement, then returns True.
|
||||
|
||||
Merges are scoped so they never cross document levels, and never cross
|
||||
sessions for explicit-level documents (session-purity invariant: an
|
||||
explicit document records what was derived from exactly one session, so
|
||||
a near-duplicate from another session must not reinforce or replace it).
|
||||
"""
|
||||
filters: dict[str, Any] = {"level": doc.level}
|
||||
if doc.level == "explicit":
|
||||
if doc.session_name is None:
|
||||
# create_documents refuses session-less explicit documents; if one
|
||||
# reaches here anyway it has no valid merge partner.
|
||||
return SemanticRejectionResult.NOT_DUPLICATE
|
||||
filters["session_name"] = doc.session_name
|
||||
|
||||
# Step 1: Find potential duplicates using cosine similarity
|
||||
similar_docs = await query_documents(
|
||||
db=db,
|
||||
|
|
@ -1088,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]
|
||||
|
||||
|
|
@ -1121,7 +1214,9 @@ async def is_rejected_duplicate(
|
|||
# Soft-delete the existing document - reconciliation will clean up vectors and hard-delete
|
||||
existing_doc.deleted_at = datetime.datetime.now(datetime.timezone.utc)
|
||||
await db.flush()
|
||||
return False # Don't reject the new document
|
||||
return (
|
||||
SemanticRejectionResult.REPLACED_EXISTING
|
||||
) # Don't reject the new document
|
||||
|
||||
# Existing document has more information, reject the new one but record the
|
||||
# reinforcement: a semantic duplicate was derived again. greatest(...) keeps
|
||||
|
|
@ -1138,7 +1233,7 @@ async def is_rejected_duplicate(
|
|||
doc.content,
|
||||
existing_doc.content,
|
||||
)
|
||||
return True
|
||||
return SemanticRejectionResult.REJECTED
|
||||
|
||||
|
||||
async def cleanup_soft_deleted_documents(
|
||||
|
|
|
|||
|
|
@ -153,6 +153,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,
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ class RepresentationManager:
|
|||
session_name: str,
|
||||
message_created_at: datetime.datetime,
|
||||
message_level_configuration: ResolvedConfiguration,
|
||||
) -> int:
|
||||
) -> crud.CreateDocumentsResult:
|
||||
"""
|
||||
Save Representation objects to the collection as a set of documents.
|
||||
|
||||
|
|
@ -75,14 +75,15 @@ class RepresentationManager:
|
|||
message_created_at: Timestamp when the message was created
|
||||
|
||||
Returns:
|
||||
The number of *new documents saved*
|
||||
The result of document creation, including saved documents and
|
||||
deduplication counts.
|
||||
"""
|
||||
|
||||
new_documents = 0
|
||||
empty_result = crud.CreateDocumentsResult()
|
||||
|
||||
if not representation.deductive and not representation.explicit:
|
||||
logger.debug("No observations to save")
|
||||
return new_documents
|
||||
return empty_result
|
||||
|
||||
all_observations = [
|
||||
_normalized_observation(obs)
|
||||
|
|
@ -91,7 +92,7 @@ class RepresentationManager:
|
|||
]
|
||||
if not all_observations:
|
||||
logger.debug("No non-empty observations to save")
|
||||
return new_documents
|
||||
return empty_result
|
||||
|
||||
# Batch embed all observations
|
||||
batch_embed_start = time.perf_counter()
|
||||
|
|
@ -123,7 +124,7 @@ class RepresentationManager:
|
|||
# Batch create document objects
|
||||
create_document_start = time.perf_counter()
|
||||
async with tracked_db("representation_manager.save_representation") as db:
|
||||
new_documents = await self._save_representation_internal(
|
||||
new_documents_result = await self._save_representation_internal(
|
||||
db,
|
||||
all_observations,
|
||||
embeddings,
|
||||
|
|
@ -141,7 +142,7 @@ class RepresentationManager:
|
|||
"ms",
|
||||
)
|
||||
|
||||
return new_documents
|
||||
return new_documents_result
|
||||
|
||||
async def _save_representation_internal(
|
||||
self,
|
||||
|
|
@ -152,7 +153,7 @@ class RepresentationManager:
|
|||
session_name: str,
|
||||
message_created_at: datetime.datetime,
|
||||
message_level_configuration: ResolvedConfiguration,
|
||||
) -> int:
|
||||
) -> crud.CreateDocumentsResult:
|
||||
# get_or_create_collection already handles IntegrityError with rollback and a retry
|
||||
collection = await crud.get_or_create_collection(
|
||||
db,
|
||||
|
|
@ -191,7 +192,7 @@ class RepresentationManager:
|
|||
)
|
||||
|
||||
# Use bulk creation with optional duplicate detection
|
||||
accepted_documents = await crud.create_documents(
|
||||
accepted_documents_result = await crud.create_documents(
|
||||
db,
|
||||
documents_to_create,
|
||||
self.workspace_name,
|
||||
|
|
@ -206,7 +207,7 @@ class RepresentationManager:
|
|||
except Exception as e:
|
||||
logger.warning(f"Failed to check dream scheduling: {e}")
|
||||
|
||||
return len(accepted_documents)
|
||||
return accepted_documents_result
|
||||
|
||||
async def get_working_representation(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ async def process_representation_batch(
|
|||
queue_item_message_ids: Message IDs from queue items
|
||||
hit_batch_token_cap: whether the queue batcher clamped this batch to fit
|
||||
was_flush_enabled: snapshot of DERIVER.FLUSH_ENABLED at fetch time
|
||||
batch_max_tokens: DERIVER.REPRESENTATION_BATCH_MAX_TOKENS snapshot
|
||||
batch_max_tokens: DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS snapshot
|
||||
"""
|
||||
if not messages or not messages[0]:
|
||||
logger.debug("process_representation_batch received no messages")
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ async def process_representation_tasks_batch(
|
|||
queue_item_message_ids: Message IDs from queue items being processed
|
||||
hit_batch_token_cap: queue batcher clamped this batch to fit
|
||||
was_flush_enabled: DERIVER.FLUSH_ENABLED snapshot at batch time
|
||||
batch_max_tokens: DERIVER.REPRESENTATION_BATCH_MAX_TOKENS snapshot
|
||||
batch_max_tokens: DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS snapshot
|
||||
"""
|
||||
if not messages:
|
||||
return
|
||||
|
|
@ -194,6 +194,7 @@ async def process_representation_tasks_batch(
|
|||
latest_message.created_at,
|
||||
)
|
||||
|
||||
agg_representation_result = crud.CreateDocumentsResult()
|
||||
successful_observer_count = 0
|
||||
if observations.is_empty() or not message_ids:
|
||||
logger.warning(
|
||||
|
|
@ -213,12 +214,26 @@ async def process_representation_tasks_batch(
|
|||
)
|
||||
|
||||
try:
|
||||
await representation_manager.save_representation(
|
||||
observations,
|
||||
message_ids,
|
||||
latest_message.session_name,
|
||||
latest_message.created_at,
|
||||
message_level_configuration,
|
||||
representation_result = (
|
||||
await representation_manager.save_representation(
|
||||
observations,
|
||||
message_ids,
|
||||
latest_message.session_name,
|
||||
latest_message.created_at,
|
||||
message_level_configuration,
|
||||
)
|
||||
)
|
||||
agg_representation_result.exact_dup_existing_count += (
|
||||
representation_result.exact_dup_existing_count
|
||||
)
|
||||
agg_representation_result.exact_dup_in_batch_count += (
|
||||
representation_result.exact_dup_in_batch_count
|
||||
)
|
||||
agg_representation_result.semantic_dup_rejected_count += (
|
||||
representation_result.semantic_dup_rejected_count
|
||||
)
|
||||
agg_representation_result.semantic_dup_replaced_count += (
|
||||
representation_result.semantic_dup_replaced_count
|
||||
)
|
||||
successful_observer_count += 1
|
||||
except Exception as e:
|
||||
|
|
@ -318,5 +333,9 @@ async def process_representation_tasks_batch(
|
|||
hit_batch_token_cap=hit_batch_token_cap,
|
||||
hit_input_token_cap=response.hit_input_token_cap,
|
||||
observer_count=successful_observer_count,
|
||||
exact_dup_existing_count=agg_representation_result.exact_dup_existing_count,
|
||||
exact_dup_in_batch_count=agg_representation_result.exact_dup_in_batch_count,
|
||||
semantic_dup_rejected_count=agg_representation_result.semantic_dup_rejected_count,
|
||||
semantic_dup_replaced_count=agg_representation_result.semantic_dup_replaced_count,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -331,16 +331,18 @@ class QueueManager:
|
|||
"""
|
||||
Get available work units that aren't being processed.
|
||||
For representation tasks, only returns work units whose accumulated
|
||||
tokens reach REPRESENTATION_BATCH_MAX_TOKENS or whose oldest pending
|
||||
item exceeds REPRESENTATION_BATCH_MAX_AGE_SECONDS, unless
|
||||
FLUSH_ENABLED is True.
|
||||
tokens reach REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS or whose
|
||||
oldest pending item exceeds REPRESENTATION_BATCH_MAX_AGE_SECONDS,
|
||||
unless FLUSH_ENABLED is True.
|
||||
Returns a dict mapping work_unit_key to aqs_id.
|
||||
"""
|
||||
limit: int = max(0, self.workers - self.get_total_owned_work_units())
|
||||
if limit == 0:
|
||||
return {}
|
||||
|
||||
batch_max_tokens = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS
|
||||
work_unit_target_tokens = (
|
||||
settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS
|
||||
)
|
||||
|
||||
async with tracked_db("get_available_work_units") as db:
|
||||
representation_prefix = "representation:"
|
||||
|
|
@ -396,11 +398,11 @@ class QueueManager:
|
|||
)
|
||||
|
||||
# Apply batch threshold filter (skip if FLUSH_ENABLED is True)
|
||||
if not settings.DERIVER.FLUSH_ENABLED and batch_max_tokens > 0:
|
||||
if not settings.DERIVER.FLUSH_ENABLED and work_unit_target_tokens > 0:
|
||||
max_age_seconds = settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS
|
||||
threshold_clause = (
|
||||
func.coalesce(token_stats_subq.c.total_tokens, 0)
|
||||
>= batch_max_tokens
|
||||
>= work_unit_target_tokens
|
||||
)
|
||||
if max_age_seconds > 0:
|
||||
threshold_clause = or_(
|
||||
|
|
@ -426,13 +428,13 @@ class QueueManager:
|
|||
not settings.DERIVER.FLUSH_ENABLED
|
||||
and settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS > 0
|
||||
and work_unit_key.startswith(representation_prefix)
|
||||
and int(total_tokens or 0) < batch_max_tokens
|
||||
and int(total_tokens or 0) < work_unit_target_tokens
|
||||
):
|
||||
logger.info(
|
||||
"age-flushing work unit %s (tokens=%s < %s, oldest=%s)",
|
||||
work_unit_key,
|
||||
total_tokens or 0,
|
||||
batch_max_tokens,
|
||||
work_unit_target_tokens,
|
||||
oldest_created_at,
|
||||
)
|
||||
if not available_units:
|
||||
|
|
@ -816,7 +818,7 @@ class QueueManager:
|
|||
f"{task_type} tasks are not supported for get_queue_item_batch"
|
||||
)
|
||||
|
||||
batch_max_tokens = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS
|
||||
batch_max_tokens = settings.DERIVER.REPRESENTATION_BATCH_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,7 @@ async def agentic_chat(
|
|||
observer: str,
|
||||
observed: str,
|
||||
reasoning_level: ReasoningLevel = "low",
|
||||
response_model: type[BaseModel] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Answer a query about a peer using the agentic dialectic.
|
||||
|
|
@ -35,6 +38,8 @@ async def agentic_chat(
|
|||
observer: The peer making the query
|
||||
observed: The peer being queried about
|
||||
reasoning_level: Level of reasoning to apply
|
||||
response_model: Optional Pydantic model the answer must conform to.
|
||||
When set, the returned string is JSON matching the model's schema.
|
||||
|
||||
Returns:
|
||||
The synthesized answer string
|
||||
|
|
@ -79,7 +84,7 @@ async def agentic_chat(
|
|||
reasoning_level=reasoning_level,
|
||||
)
|
||||
|
||||
return await agent.answer(query)
|
||||
return await agent.answer(query, response_model=response_model)
|
||||
|
||||
|
||||
async def agentic_chat_stream(
|
||||
|
|
@ -89,6 +94,7 @@ async def agentic_chat_stream(
|
|||
observer: str,
|
||||
observed: str,
|
||||
reasoning_level: ReasoningLevel = "low",
|
||||
response_model: type[BaseModel] | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""
|
||||
Stream an answer to a query about a peer using the agentic dialectic.
|
||||
|
|
@ -100,6 +106,9 @@ async def agentic_chat_stream(
|
|||
observer: The peer making the query
|
||||
observed: The peer being queried about
|
||||
reasoning_level: Level of reasoning to apply
|
||||
response_model: Optional Pydantic model the answer must conform to.
|
||||
When set, the streamed text accumulates to JSON matching the
|
||||
model's schema.
|
||||
|
||||
Yields:
|
||||
Chunks of the response text as they are generated
|
||||
|
|
@ -144,5 +153,5 @@ async def agentic_chat_stream(
|
|||
reasoning_level=reasoning_level,
|
||||
)
|
||||
|
||||
async for chunk in agent.answer_stream(query):
|
||||
async for chunk in agent.answer_stream(query, response_model=response_model):
|
||||
yield chunk
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from collections.abc import AsyncIterator, Callable
|
|||
from typing import Any, cast
|
||||
|
||||
from nanoid import generate as generate_nanoid
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src import crud
|
||||
from src.config import ConfiguredModelSettings, ReasoningLevel, settings
|
||||
|
|
@ -413,7 +414,9 @@ class DialecticAgent:
|
|||
)
|
||||
)
|
||||
|
||||
async def answer(self, query: str) -> str:
|
||||
async def answer(
|
||||
self, query: str, response_model: type[BaseModel] | None = None
|
||||
) -> str:
|
||||
"""
|
||||
Answer a query about the peer using agentic tool calling.
|
||||
|
||||
|
|
@ -424,6 +427,8 @@ class DialecticAgent:
|
|||
|
||||
Args:
|
||||
query: The question to answer about the peer
|
||||
response_model: Optional Pydantic model the final synthesis must
|
||||
conform to. When set, the returned string is JSON.
|
||||
|
||||
Returns:
|
||||
The synthesized answer string
|
||||
|
|
@ -446,25 +451,38 @@ class DialecticAgent:
|
|||
else settings.DIALECTIC.MAX_OUTPUT_TOKENS
|
||||
)
|
||||
|
||||
response: HonchoLLMCallResponse[str] = await honcho_llm_call(
|
||||
model_config=_get_dialectic_level_model_config(self.reasoning_level),
|
||||
prompt="", # Ignored since we pass messages
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
tool_choice=level_settings.TOOL_CHOICE,
|
||||
tool_executor=tool_executor,
|
||||
max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS,
|
||||
messages=self.messages,
|
||||
max_input_tokens=settings.DIALECTIC.MAX_INPUT_TOKENS,
|
||||
trace_name="dialectic_chat",
|
||||
telemetry=self._telemetry_context(track_name="Dialectic Agent"),
|
||||
# cast: `type[BaseModel] | None` matches neither the parsed nor the
|
||||
# plain-text overload statically, so pyright resolves the stream
|
||||
# overload — but without stream=True the call is non-streaming.
|
||||
response = cast( # pyright: ignore[reportInvalidCast]
|
||||
HonchoLLMCallResponse[Any],
|
||||
await honcho_llm_call(
|
||||
model_config=_get_dialectic_level_model_config(self.reasoning_level),
|
||||
prompt="", # Ignored since we pass messages
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
tool_choice=level_settings.TOOL_CHOICE,
|
||||
tool_executor=tool_executor,
|
||||
max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS,
|
||||
messages=self.messages,
|
||||
max_input_tokens=settings.DIALECTIC.MAX_INPUT_TOKENS,
|
||||
trace_name="dialectic_chat",
|
||||
telemetry=self._telemetry_context(track_name="Dialectic Agent"),
|
||||
response_model=response_model,
|
||||
),
|
||||
)
|
||||
|
||||
# With response_model, the backend parses content into a model
|
||||
# instance; the API contract is a JSON string.
|
||||
content = response.content
|
||||
if isinstance(content, BaseModel):
|
||||
content = content.model_dump_json(by_alias=True)
|
||||
|
||||
self._log_response_metrics(
|
||||
task_name=task_name,
|
||||
run_id=run_id,
|
||||
start_time=start_time,
|
||||
response_content=response.content,
|
||||
response_content=content,
|
||||
input_tokens=response.input_tokens,
|
||||
output_tokens=response.output_tokens,
|
||||
cache_read_input_tokens=response.cache_read_input_tokens,
|
||||
|
|
@ -475,9 +493,11 @@ class DialecticAgent:
|
|||
hit_input_token_cap=response.hit_input_token_cap,
|
||||
)
|
||||
|
||||
return response.content
|
||||
return content
|
||||
|
||||
async def answer_stream(self, query: str) -> AsyncIterator[str]:
|
||||
async def answer_stream(
|
||||
self, query: str, response_model: type[BaseModel] | None = None
|
||||
) -> AsyncIterator[str]:
|
||||
"""
|
||||
Answer a query about the peer using agentic tool calling, streaming the response.
|
||||
|
||||
|
|
@ -488,6 +508,9 @@ class DialecticAgent:
|
|||
|
||||
Args:
|
||||
query: The question to answer about the peer
|
||||
response_model: Optional Pydantic model the final synthesis must
|
||||
conform to. When set, the streamed text accumulates to JSON
|
||||
(chunks are raw text; no parsing happens on the stream path).
|
||||
|
||||
Yields:
|
||||
Chunks of the response text as they are generated
|
||||
|
|
@ -526,6 +549,7 @@ class DialecticAgent:
|
|||
max_input_tokens=settings.DIALECTIC.MAX_INPUT_TOKENS,
|
||||
trace_name="dialectic_chat",
|
||||
telemetry=self._telemetry_context(track_name="Dialectic Agent Stream"),
|
||||
response_model=response_model,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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`."
|
||||
|
|
@ -218,9 +281,11 @@ If you update it, send the full deduplicated list and remove stale entries.
|
|||
configuration is None or configuration.peer_card.create
|
||||
)
|
||||
|
||||
# Fetch current peer card to inject into prompt (saves a tool call)
|
||||
# Fetch current peer card to inject into prompt (saves a tool call).
|
||||
# Skipped when inject_peer_card is False (card-refresh rebuild
|
||||
# mode): the card must be reconstructed from observations only.
|
||||
current_peer_card: list[str] | None = None
|
||||
if peer_card_enabled:
|
||||
if peer_card_enabled and self.inject_peer_card:
|
||||
current_peer_card = await crud.get_peer_card(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
|
|
@ -490,61 +555,7 @@ class DeductionSpecialist(BaseSpecialist):
|
|||
_ = observed
|
||||
peer_card_section = ""
|
||||
if peer_card_enabled:
|
||||
peer_card_section = """
|
||||
|
||||
## PEER CARD (REQUIRED)
|
||||
|
||||
The peer card is the target observee's identity store: stable identity markers that distinguish this entity from others and persist across interactions. Behavior, tendencies, transient state, and episodic facts belong in observations, not on the peer card.
|
||||
|
||||
A peer can be anything with identity that changes over time — a human, an agent, a codebase, a team, an organization. Do not assume the target observee is human. Do not require any field; empty is the correct output when evidence is absent.
|
||||
|
||||
### Allowed entry kinds
|
||||
|
||||
Each entry must start with one of these four prefixes (exact case, followed by a space):
|
||||
|
||||
- `IDENTITY: ...` — canonical name, kind, aliases, IDs
|
||||
- `IDENTITY: Name: Alice`
|
||||
- `IDENTITY: Kind: Python monorepo`
|
||||
- `IDENTITY: Version: 4.2`
|
||||
- `IDENTITY: Aliases: alice@example.com`
|
||||
- `ATTRIBUTE: ...` — stable durable property of the entity (including explicitly stated standing preferences)
|
||||
- `ATTRIBUTE: Location: NYC`
|
||||
- `ATTRIBUTE: Language: Python`
|
||||
- `ATTRIBUTE: Prefers tea`
|
||||
- `ATTRIBUTE: Charter: ship Honcho infrastructure`
|
||||
- `RELATIONSHIP: ...` — durable link to another entity
|
||||
- `RELATIONSHIP: Spouse: Bob`
|
||||
- `RELATIONSHIP: Maintainer: vineeth`
|
||||
- `RELATIONSHIP: Members: vineeth, rajat`
|
||||
- `INSTRUCTION: ...` — standing rule of engagement that the target observee has explicitly stated (do/don't for the observer). Only when explicit; never inferred from behavior.
|
||||
- `INSTRUCTION: Call me Vee`
|
||||
- `INSTRUCTION: Never push to main without review`
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Stable.** If the value plausibly changes within six months absent a deliberate announcement, it does not belong on the card. Prefer leaving the card empty over filling it with volatile content.
|
||||
2. **Subject is the target observee.** Every entry must be a fact about the target observee, not about another participant in the session. Never write facts about co-occurring peers into the card, no matter how frequently they appear in the messages.
|
||||
3. **Evidence-grounded.** Only write what the target observee has explicitly stated, or what another participant has explicitly stated about the target observee with the target observee's assent. No "general knowledge" inferences (`"co-founder"` does not imply an age; mentioning a colleague does not imply a family relationship).
|
||||
4. **Type-agnostic.** The target observee may not be human. Do not require name/age/location/family/occupation fields.
|
||||
5. **No behavioral content.** TRAITs, behavioral tendencies, patterns, and inferred preferences belong in observations, not on the peer card. Do not write `TRAIT:` entries or behavioral `PREFERENCE:` entries — they will be rejected.
|
||||
6. **No evidence bundles.** Each entry is one concise fact. No `e.g.` clauses, no parenthetical example lists, no semicolon-separated value dumps.
|
||||
|
||||
### Migrating an existing peer card
|
||||
|
||||
The CURRENT PEER CARD shown in the user message may contain entries from an older format that do not start with an allowed prefix (e.g. `Name: Alice`, `Lives in NYC`, `TRAIT: Analytical`, `PREFERENCE: Detailed explanations`). When you call `update_peer_card`, you are responsible for re-emitting the entries you want to keep — entries you omit are dropped, and entries without an allowed prefix are silently rejected.
|
||||
|
||||
For each legacy entry:
|
||||
|
||||
- If it is still a valid identity marker, re-emit it under the correct prefix and keep the original content where reasonable. Examples:
|
||||
- `Name: Alice` → `IDENTITY: Name: Alice`
|
||||
- `Lives in NYC` → `ATTRIBUTE: Location: NYC`
|
||||
- `Works at Google` → `ATTRIBUTE: Employer: Google`
|
||||
- `INSTRUCTION: Call me Vee` → keep as is (already correctly prefixed)
|
||||
- Drop entries that violate the rules above: behavioral `TRAIT:` lines, inferred behavioral `PREFERENCE:` lines, one-off events, transient state, evidence bundles. Do not re-prefix them — they are not identity markers.
|
||||
|
||||
When in doubt about a specific legacy entry, prefer migrating it (so valid info isn't lost) over dropping it. Splitting one dense legacy entry into multiple correctly-prefixed entries is fine and encouraged (e.g. a semicolon-separated `Tech Stack:` dump can become several `ATTRIBUTE:` lines, one per durable tool/platform).
|
||||
|
||||
Call `update_peer_card` with the complete deduplicated list when there is a durable identity update to record, or when the existing card needs migration. Entries that do not start with one of the four allowed prefixes will be rejected. Keep concise (max 40 entries)."""
|
||||
peer_card_section = PEER_CARD_SYSTEM_SECTION
|
||||
|
||||
return f"""You are a deductive reasoning agent analyzing observations about the target observee.
|
||||
|
||||
|
|
@ -602,7 +613,8 @@ Use `create_observations_deductive`.
|
|||
3. Always include source_ids linking to the observations you're synthesizing
|
||||
4. Empty or missing source_ids will be rejected
|
||||
5. Delete outdated observations - don't leave duplicates
|
||||
6. Quality over quantity - fewer good deductions beat many weak ones"""
|
||||
6. Quality over quantity - fewer good deductions beat many weak ones
|
||||
7. When you are finished, do not output a summary of what you did - output only the token DONE"""
|
||||
|
||||
def build_user_prompt(
|
||||
self,
|
||||
|
|
@ -733,7 +745,8 @@ Use `create_observations_inductive`.
|
|||
3. Confidence based on evidence count: 2=low, 3-4=medium, 5+=high
|
||||
4. Look for HOW things change over time, not just static facts
|
||||
5. Include source_ids - always link back to evidence
|
||||
6. Empty or missing source_ids will be rejected"""
|
||||
6. Empty or missing source_ids will be rejected
|
||||
7. When you are finished, do not output a summary of what you did - output only the token DONE"""
|
||||
|
||||
def build_user_prompt(
|
||||
self,
|
||||
|
|
@ -763,6 +776,113 @@ Remember: patterns need 2+ sources. Look for tendencies, preferences, and behavi
|
|||
Go."""
|
||||
|
||||
|
||||
class CardRefreshSpecialist(BaseSpecialist):
|
||||
"""
|
||||
Card-only maintenance specialist for the ``card_refresh`` dream type.
|
||||
|
||||
Restricted to peer-card work: it may discover observations
|
||||
(get_recent_observations, search_memory) and rewrite the peer card
|
||||
(update_peer_card). It has NO observation-mutating tools — a card refresh
|
||||
must never create or delete observations.
|
||||
|
||||
Two modes:
|
||||
- refresh (default): the current card is injected into the prompt and the
|
||||
specialist folds in new identity markers.
|
||||
- rebuild: the current card is NOT injected; the specialist reconstructs
|
||||
the card solely from observations present in the collection. Used after
|
||||
removals, where the old card may contain facts whose support was deleted.
|
||||
|
||||
Not a singleton — instantiated per run because ``rebuild`` is per-dream
|
||||
state.
|
||||
"""
|
||||
|
||||
name: str = "card_refresh"
|
||||
peer_card_update_instruction: str = "Update this with `update_peer_card`. See the PEER CARD section in the system prompt for the allowed entry kinds and rules."
|
||||
|
||||
# Low iteration ceiling for this lightweight, single-purpose run.
|
||||
MAX_ITERATIONS_CEILING: int = 6
|
||||
|
||||
def __init__(self, *, rebuild: bool = False) -> None:
|
||||
self.rebuild: bool = rebuild
|
||||
# In rebuild mode the existing card is withheld from the prompt.
|
||||
self.inject_peer_card: bool = not rebuild
|
||||
|
||||
def get_tools(self, *, peer_card_enabled: bool = True) -> list[dict[str, Any]]:
|
||||
if peer_card_enabled:
|
||||
return CARD_REFRESH_SPECIALIST_TOOLS
|
||||
# Defensive: a card refresh without card write access is a no-op, and
|
||||
# the orchestrator skips the run entirely when peer cards are disabled.
|
||||
return [
|
||||
t
|
||||
for t in CARD_REFRESH_SPECIALIST_TOOLS
|
||||
if t["name"] not in PEER_CARD_TOOL_NAMES
|
||||
]
|
||||
|
||||
def get_model_config(self) -> ConfiguredModelSettings:
|
||||
# Card refresh is a deduction-family task; reuse its model config.
|
||||
return _require_specialist_model_config(
|
||||
settings.DREAM.DEDUCTION_MODEL_CONFIG,
|
||||
specialist_name="DREAM CARD_REFRESH",
|
||||
)
|
||||
|
||||
def get_max_tokens(self) -> int:
|
||||
return 8192
|
||||
|
||||
def get_max_iterations(self) -> int:
|
||||
return min(self.MAX_ITERATIONS_CEILING, settings.DREAM.MAX_TOOL_ITERATIONS)
|
||||
|
||||
def build_system_prompt(
|
||||
self, observed: str, *, peer_card_enabled: bool = True
|
||||
) -> str:
|
||||
_ = observed
|
||||
_ = peer_card_enabled
|
||||
rebuild_section = ""
|
||||
if self.rebuild:
|
||||
rebuild_section = """
|
||||
|
||||
## REBUILD MODE
|
||||
|
||||
The existing peer card is deliberately NOT shown to you: it may contain entries whose supporting observations have since been removed. Build the card solely from the observations you find in the collection right now. Do not carry over or guess at prior card content — if an identity marker is not supported by a current observation, it does not go on the card."""
|
||||
|
||||
return f"""You are a peer-card maintenance agent for the target observee.
|
||||
|
||||
## YOUR JOB
|
||||
|
||||
Refresh the peer card and nothing else. You cannot create or delete observations — you have no tools for that. Your only write operation is `update_peer_card`.
|
||||
|
||||
## PROCESS
|
||||
|
||||
1. Survey the observation space: start with `get_recent_observations`, then use `search_memory` for targeted follow-ups (names, roles, locations, standing instructions).
|
||||
2. Extract stable identity markers supported by the observations you found.
|
||||
3. Call `update_peer_card` once with the complete deduplicated list.
|
||||
|
||||
Keep it short — a handful of tool calls at most.{rebuild_section}
|
||||
{PEER_CARD_SYSTEM_SECTION}"""
|
||||
|
||||
def build_user_prompt(
|
||||
self,
|
||||
observed: str,
|
||||
hints: list[str] | None,
|
||||
peer_card: list[str] | None = None,
|
||||
) -> str:
|
||||
_ = hints
|
||||
target_observee_context = self._build_target_observee_context(observed)
|
||||
peer_card_context = self._build_peer_card_context(peer_card)
|
||||
|
||||
if self.rebuild:
|
||||
return f"""{target_observee_context}Rebuild the peer card from scratch.
|
||||
|
||||
The previous card is not shown and must not be assumed: reconstruct the card solely from observations currently in the collection. Start with `get_recent_observations`, verify with `search_memory` where needed, then call `update_peer_card` with the complete list.
|
||||
|
||||
Go."""
|
||||
|
||||
return f"""{target_observee_context}{peer_card_context}Refresh the peer card.
|
||||
|
||||
Review recent observations with `get_recent_observations` (and `search_memory` for targeted checks), then call `update_peer_card` with the complete deduplicated list if there is anything to add, correct, or migrate. If the card is already accurate and complete, finish without updating it.
|
||||
|
||||
Go."""
|
||||
|
||||
|
||||
# Singleton instances
|
||||
SPECIALISTS: dict[str, BaseSpecialist] = {
|
||||
"deduction": DeductionSpecialist(),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from pydantic import BaseModel, ValidationError
|
|||
|
||||
from src.llm.backend import CompletionResult, StreamChunk, ToolCallResult
|
||||
from src.llm.request_builder import apply_sdk_passthroughs
|
||||
from src.llm.structured_output import repair_response_model_json
|
||||
from src.llm.structured_output import repair_response_model_json, schema_instruction
|
||||
|
||||
|
||||
class AnthropicBackend:
|
||||
|
|
@ -74,26 +74,28 @@ class AnthropicBackend:
|
|||
# from ModelConfig.provider_params. Shallow merge with operator-wins.
|
||||
apply_sdk_passthroughs(params, extra_params)
|
||||
|
||||
# The '{' prefill forces a JSON-first response, which suppresses
|
||||
# tool_use blocks — skip it when tools are available and rely on the
|
||||
# conditional instruction + repair fallback instead.
|
||||
use_json_prefill = (
|
||||
bool(response_format or self._json_mode(extra_params))
|
||||
and not thinking_budget_tokens
|
||||
and not tools
|
||||
and self._supports_assistant_prefill(model)
|
||||
)
|
||||
if use_json_prefill and params["messages"]:
|
||||
if response_format and isinstance(response_format, type):
|
||||
schema_json = json.dumps(response_format.model_json_schema(), indent=2)
|
||||
self._append_text_to_last_message(
|
||||
params["messages"],
|
||||
f"\n\nRespond with valid JSON matching this schema:\n{schema_json}",
|
||||
schema_instruction(response_format, tools_present=False),
|
||||
)
|
||||
params["messages"].append({"role": "assistant", "content": "{"})
|
||||
elif (
|
||||
response_format and isinstance(response_format, type) and params["messages"]
|
||||
):
|
||||
schema_json = json.dumps(response_format.model_json_schema(), indent=2)
|
||||
self._append_text_to_last_message(
|
||||
params["messages"],
|
||||
f"\n\nRespond with valid JSON matching this schema:\n{schema_json}",
|
||||
schema_instruction(response_format, tools_present=bool(tools)),
|
||||
)
|
||||
|
||||
response = await self._client.messages.create(**params)
|
||||
|
|
@ -155,26 +157,27 @@ class AnthropicBackend:
|
|||
# Operator escape hatch: forward Anthropic SDK passthrough kwargs
|
||||
# from ModelConfig.provider_params. Shallow merge with operator-wins.
|
||||
apply_sdk_passthroughs(params, extra_params)
|
||||
# See complete(): no '{' prefill when tools are available, so
|
||||
# tool_use blocks stay reachable on the streamed path too.
|
||||
use_json_prefill = (
|
||||
bool(response_format or is_json_mode)
|
||||
and not thinking_budget_tokens
|
||||
and not tools
|
||||
and self._supports_assistant_prefill(model)
|
||||
)
|
||||
if use_json_prefill and params["messages"]:
|
||||
if response_format and isinstance(response_format, type):
|
||||
schema_json = json.dumps(response_format.model_json_schema(), indent=2)
|
||||
self._append_text_to_last_message(
|
||||
params["messages"],
|
||||
f"\n\nRespond with valid JSON matching this schema:\n{schema_json}",
|
||||
schema_instruction(response_format, tools_present=False),
|
||||
)
|
||||
params["messages"].append({"role": "assistant", "content": "{"})
|
||||
elif (
|
||||
response_format and isinstance(response_format, type) and params["messages"]
|
||||
):
|
||||
schema_json = json.dumps(response_format.model_json_schema(), indent=2)
|
||||
self._append_text_to_last_message(
|
||||
params["messages"],
|
||||
f"\n\nRespond with valid JSON matching this schema:\n{schema_json}",
|
||||
schema_instruction(response_format, tools_present=bool(tools)),
|
||||
)
|
||||
if thinking_budget_tokens:
|
||||
params["thinking"] = {
|
||||
|
|
@ -251,7 +254,8 @@ class AnthropicBackend:
|
|||
)
|
||||
|
||||
content: Any = text_content
|
||||
if response_format is not None:
|
||||
# Tool-call turns carry no consumable content
|
||||
if response_format is not None and not tool_calls:
|
||||
raw_content = f"{{{text_content}" if prefilled_json else text_content
|
||||
try:
|
||||
if prefilled_json:
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from src.llm.caching import (
|
|||
gemini_cache_store,
|
||||
)
|
||||
from src.llm.request_builder import coerce_passthrough_mapping
|
||||
from src.llm.structured_output import repair_response_model_json
|
||||
from src.llm.structured_output import repair_response_model_json, schema_instruction
|
||||
|
||||
GEMINI_BLOCKED_FINISH_REASONS = {
|
||||
"SAFETY",
|
||||
|
|
@ -31,6 +31,29 @@ class GeminiBackend:
|
|||
def __init__(self, client: Any) -> None:
|
||||
self._client: Any = client
|
||||
|
||||
@staticmethod
|
||||
def _append_schema_instruction(
|
||||
contents: list[dict[str, Any]] | str,
|
||||
response_format: type[BaseModel],
|
||||
) -> list[dict[str, Any]] | str:
|
||||
"""Append the schema instruction to the final turn.
|
||||
|
||||
Used when tools accompany a response_format: native response_schema +
|
||||
function calling is a Gemini 3 preview feature and earlier models
|
||||
reject the pairing, so instruct the model and rely on parse + repair.
|
||||
Returns a new list — _convert_messages shallow-copies parts-style
|
||||
messages, so in-place appends would leak into the caller's history
|
||||
and accumulate across tool-loop iterations.
|
||||
"""
|
||||
instruction = schema_instruction(response_format, tools_present=True)
|
||||
if isinstance(contents, str):
|
||||
return contents + instruction
|
||||
if not contents:
|
||||
return contents
|
||||
last = contents[-1]
|
||||
parts: list[Any] = [*(last.get("parts") or []), {"text": instruction}]
|
||||
return [*contents[:-1], {**last, "parts": parts}]
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -61,6 +84,10 @@ class GeminiBackend:
|
|||
)
|
||||
if system_instruction:
|
||||
config["system_instruction"] = system_instruction
|
||||
if tools and isinstance(response_format, type):
|
||||
# The final turn is never part of the cached prefix, so this is
|
||||
# safe to do before cache attachment.
|
||||
contents = self._append_schema_instruction(contents, response_format)
|
||||
|
||||
cache_policy = (
|
||||
extra_params.get("cache_policy")
|
||||
|
|
@ -130,6 +157,10 @@ class GeminiBackend:
|
|||
)
|
||||
if system_instruction:
|
||||
config["system_instruction"] = system_instruction
|
||||
if tools and isinstance(response_format, type):
|
||||
# The final turn is never part of the cached prefix, so this is
|
||||
# safe to do before cache attachment.
|
||||
contents = self._append_schema_instruction(contents, response_format)
|
||||
|
||||
cache_policy = (
|
||||
extra_params.get("cache_policy")
|
||||
|
|
@ -228,7 +259,11 @@ class GeminiBackend:
|
|||
config["tools"] = self._convert_tools(tools)
|
||||
if tool_choice:
|
||||
config["tool_config"] = self._convert_tool_choice(tool_choice)
|
||||
if response_format is not None:
|
||||
# Native structured output combined with function calling is a
|
||||
# Gemini 3 preview feature; earlier models reject the pairing. With
|
||||
# tools present, callers inject a schema instruction instead (see
|
||||
# _append_schema_instruction) and rely on parse + repair downstream.
|
||||
if response_format is not None and not tools:
|
||||
config["response_mime_type"] = "application/json"
|
||||
config["response_schema"] = response_format
|
||||
elif extra_params and extra_params.get("json_mode") and not tools:
|
||||
|
|
@ -335,7 +370,10 @@ class GeminiBackend:
|
|||
)
|
||||
|
||||
content: Any = "\n".join(text_parts) if text_parts else ""
|
||||
if response_format is not None:
|
||||
# Tool-call turns carry no consumable content — the tool loop ignores
|
||||
# it — and parsing their (empty) text would raise through the repair
|
||||
# fallback, failing the iteration.
|
||||
if response_format is not None and not tool_calls:
|
||||
parsed_response = getattr(response, "parsed", None)
|
||||
if isinstance(parsed_response, response_format):
|
||||
content = parsed_response
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
import weakref
|
||||
from collections.abc import AsyncIterator
|
||||
from functools import cache
|
||||
from typing import Any, cast
|
||||
|
||||
from openai import BadRequestError, LengthFinishReasonError
|
||||
|
|
@ -22,21 +22,35 @@ from src.llm.structured_output import (
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@cache
|
||||
# The point of this being a WeakKeyDictionary, as opposed to a regular dict, is that it
|
||||
# does not hold a reference to the keyed BaseModel so that when a dynamically created
|
||||
# type is no longer referenced it becomes eligible for garbage collection. This avoids a
|
||||
# memory leak.
|
||||
_json_object_instruction_cache: weakref.WeakKeyDictionary[type[BaseModel], str] = (
|
||||
weakref.WeakKeyDictionary()
|
||||
)
|
||||
|
||||
|
||||
def _json_object_instruction(response_format: type[BaseModel]) -> str:
|
||||
"""Schema-injection instruction for json_object mode.
|
||||
|
||||
The JSON schema is static per response_format class, so cache the serialized
|
||||
instruction — the deriver issues one structured call per batch on the worker
|
||||
hot path and would otherwise re-walk the schema + re-serialize it every call.
|
||||
instruction — the deriver would otherwise re-walk the schema + re-serialize
|
||||
it every call.
|
||||
"""
|
||||
# "JSON" must appear in the messages to satisfy the json_object contract.
|
||||
return (
|
||||
"You must respond with a single JSON object that conforms exactly to "
|
||||
"the following JSON schema. Do not include any text, markdown, or code "
|
||||
"fences outside the JSON object.\n\nJSON schema:\n"
|
||||
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:
|
||||
|
|
@ -171,6 +185,24 @@ class OpenAIBackend:
|
|||
response, response_format, model, empty_on_missing=True
|
||||
)
|
||||
return self._normalize_response(response, content_override=content)
|
||||
if tools:
|
||||
# parse() refuses non-strict function tools, and our agent tool
|
||||
# schemas are deliberately non-strict (see _convert_tools), so
|
||||
# tool-loop iterations use create() with an explicit json_schema
|
||||
# response_format — same server-side schema enforcement, no
|
||||
# strict-tools requirement — mirroring the streaming path.
|
||||
params["response_format"] = self._json_schema_response_format(
|
||||
response_format
|
||||
)
|
||||
response = await self._client.chat.completions.create(**params)
|
||||
# Tool-call turns carry no consumable content — the tool loop
|
||||
# ignores it — and parsing their empty text would raise.
|
||||
if getattr(response.choices[0].message, "tool_calls", None):
|
||||
return self._normalize_response(response)
|
||||
content = self._parse_or_repair_structured_content(
|
||||
response, response_format, model, empty_on_missing=False
|
||||
)
|
||||
return self._normalize_response(response, content_override=content)
|
||||
params["response_format"] = response_format
|
||||
try:
|
||||
response = await self._client.chat.completions.parse(**params)
|
||||
|
|
@ -273,13 +305,9 @@ class OpenAIBackend:
|
|||
else:
|
||||
# Streaming create() can't take a BaseModel like parse() does;
|
||||
# convert to a json_schema dict.
|
||||
params["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": response_format.__name__,
|
||||
"schema": response_format.model_json_schema(),
|
||||
},
|
||||
}
|
||||
params["response_format"] = self._json_schema_response_format(
|
||||
response_format
|
||||
)
|
||||
elif response_format is not None:
|
||||
params["response_format"] = response_format
|
||||
elif extra_params and extra_params.get("json_mode"):
|
||||
|
|
@ -421,6 +449,20 @@ class OpenAIBackend:
|
|||
raw_response=response,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _json_schema_response_format(
|
||||
response_format: type[BaseModel],
|
||||
) -> dict[str, Any]:
|
||||
"""Build the response_format param for create() calls that can't use
|
||||
parse(): streaming, and requests carrying non-strict function tools."""
|
||||
return {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": response_format.__name__,
|
||||
"schema": response_format.model_json_schema(),
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _structured_output_mode(extra_params: dict[str, Any] | None) -> str | None:
|
||||
# Threaded in via extra_params (see build_config_extra_params).
|
||||
|
|
|
|||
|
|
@ -12,6 +12,22 @@ class StructuredOutputError(ValueError):
|
|||
"""Raised when structured output cannot be validated or repaired."""
|
||||
|
||||
|
||||
def schema_instruction(response_format: type[BaseModel], *, tools_present: bool) -> str:
|
||||
"""Structured-output instruction appended to the conversation for
|
||||
providers without native (or tools-compatible) schema enforcement.
|
||||
|
||||
When tools are in play the wording is conditional so the model remains
|
||||
free to emit tool calls; validation then relies on parse + repair.
|
||||
"""
|
||||
schema_json = json.dumps(response_format.model_json_schema(), indent=2)
|
||||
if tools_present:
|
||||
return (
|
||||
"\n\nIf not responding with a tool call, respond with valid JSON "
|
||||
f"matching this schema:\n{schema_json}"
|
||||
)
|
||||
return f"\n\nRespond with valid JSON matching this schema:\n{schema_json}"
|
||||
|
||||
|
||||
def repair_response_model_json(
|
||||
raw_content: str,
|
||||
response_model: type[BaseModel],
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import logging
|
|||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import BackgroundTasks
|
||||
from sqlalchemy import and_, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -36,6 +37,7 @@ from src.reconciler.sync_vectors import (
|
|||
build_message_vector_record,
|
||||
compute_chunk_positions,
|
||||
)
|
||||
from src.telemetry import prometheus_metrics
|
||||
from src.telemetry.events import EmbeddingCallPurpose
|
||||
from src.utils.types import embedding_call_purpose
|
||||
from src.vector_store import VectorRecord, VectorStore, get_external_vector_store
|
||||
|
|
@ -66,6 +68,50 @@ def reset_embed_semaphore() -> None:
|
|||
_embed_semaphore = None
|
||||
|
||||
|
||||
class EmbedTaskGate:
|
||||
"""Non-blocking admission gate for immediate-embed background tasks.
|
||||
|
||||
Bounds the number of in-flight tasks per API process at
|
||||
``EMBEDDING.MAX_PENDING_EMBED_TASKS``. When saturated, nothing is scheduled:
|
||||
the rows are already ``sync_state='pending'``, so the reconciler embeds them
|
||||
on its next cycle. The count is taken at schedule time (not task start)
|
||||
because background tasks only run after the response is sent — a request
|
||||
burst would otherwise stack up unbounded scheduled-but-not-started tasks.
|
||||
|
||||
``in_flight`` is mutated only from the event loop (request handlers and the
|
||||
tracked task), so a plain int is race-free.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.in_flight: int = 0
|
||||
|
||||
def try_schedule(
|
||||
self, background_tasks: BackgroundTasks, message_ids: list[str]
|
||||
) -> bool:
|
||||
"""Schedule ``embed_messages_now`` if the cap allows it; return whether
|
||||
the task was scheduled."""
|
||||
if self.in_flight >= settings.EMBEDDING.MAX_PENDING_EMBED_TASKS:
|
||||
if settings.METRICS.ENABLED:
|
||||
prometheus_metrics.record_embed_now_task_shed()
|
||||
return False
|
||||
self.in_flight += 1
|
||||
if settings.METRICS.ENABLED:
|
||||
prometheus_metrics.set_embed_now_tasks_in_flight(self.in_flight)
|
||||
background_tasks.add_task(self._run, message_ids)
|
||||
return True
|
||||
|
||||
async def _run(self, message_ids: list[str]) -> None:
|
||||
try:
|
||||
await embed_messages_now(message_ids)
|
||||
finally:
|
||||
self.in_flight -= 1
|
||||
if settings.METRICS.ENABLED:
|
||||
prometheus_metrics.set_embed_now_tasks_in_flight(self.in_flight)
|
||||
|
||||
|
||||
embed_task_gate = EmbedTaskGate()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ClaimedChunk:
|
||||
"""Plain snapshot of a claimed ``MessageEmbedding`` row.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from src.config import settings
|
|||
from src.dependencies import db, read_db
|
||||
from src.deriver import enqueue
|
||||
from src.exceptions import FileTooLargeError, ResourceNotFoundException
|
||||
from src.reconciler.embed_now import embed_messages_now
|
||||
from src.reconciler.embed_now import embed_task_gate
|
||||
from src.security import require_auth
|
||||
from src.telemetry import prometheus_metrics
|
||||
from src.telemetry.events import FileUploadedEvent, MessageCreatedEvent, emit
|
||||
|
|
@ -161,11 +161,17 @@ async def create_messages_for_session(
|
|||
background_tasks.add_task(enqueue, payloads)
|
||||
|
||||
# Embed immediately so messages are searchable within seconds; the
|
||||
# reconciler is the fallback for anything left pending.
|
||||
# reconciler is the fallback for anything left pending. Scheduling is
|
||||
# capped per process — when saturated, the reconciler picks them up.
|
||||
if settings.EMBED_MESSAGES and created_messages:
|
||||
background_tasks.add_task(
|
||||
embed_messages_now, [m.public_id for m in created_messages]
|
||||
scheduled = embed_task_gate.try_schedule(
|
||||
background_tasks, [m.public_id for m in created_messages]
|
||||
)
|
||||
if not scheduled:
|
||||
logger.debug(
|
||||
"Immediate-embed tasks saturated; deferring %s message(s) to reconciler",
|
||||
len(created_messages),
|
||||
)
|
||||
|
||||
return created_messages
|
||||
except ValueError as e:
|
||||
|
|
@ -240,11 +246,17 @@ async def create_messages_with_file(
|
|||
background_tasks.add_task(enqueue, payloads)
|
||||
|
||||
# Embed immediately so messages are searchable within seconds; the
|
||||
# reconciler is the fallback for anything left pending.
|
||||
# reconciler is the fallback for anything left pending. Scheduling is
|
||||
# capped per process — when saturated, the reconciler picks them up.
|
||||
if settings.EMBED_MESSAGES and created_messages:
|
||||
background_tasks.add_task(
|
||||
embed_messages_now, [m.public_id for m in created_messages]
|
||||
scheduled = embed_task_gate.try_schedule(
|
||||
background_tasks, [m.public_id for m in created_messages]
|
||||
)
|
||||
if not scheduled:
|
||||
logger.debug(
|
||||
"Immediate-embed tasks saturated; deferring %s message(s) to reconciler",
|
||||
len(created_messages),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Batch of %s messages created from file uploads and queued for processing",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from fastapi import APIRouter, Body, Depends, Path, Query, Response
|
|||
from fastapi.responses import StreamingResponse
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlalchemy import apaginate
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import crud, schemas
|
||||
|
|
@ -26,6 +27,7 @@ from src.exceptions import (
|
|||
from src.security import JWTParams, require_auth
|
||||
from src.telemetry import prometheus_metrics
|
||||
from src.telemetry.events import EmbeddingCallPurpose, GetContextEvent, emit
|
||||
from src.utils.schema_conversion import json_response_schema_to_pydantic
|
||||
from src.utils.scopes import is_scope_peer_name, validate_no_scope_peer_names
|
||||
from src.utils.search import search
|
||||
from src.utils.types import embedding_call_purpose
|
||||
|
|
@ -218,6 +220,14 @@ async def chat(
|
|||
):
|
||||
raise AuthenticationException("JWT not permissioned for this resource")
|
||||
|
||||
# Convert the caller's JSON Schema so malformed schemas fail immediately with 422
|
||||
response_model: type[BaseModel] | None = None
|
||||
if options.response_format is not None:
|
||||
try:
|
||||
response_model = json_response_schema_to_pydantic(options.response_format)
|
||||
except ValueError as e:
|
||||
raise ValidationException(f"Invalid response_format: {e}") from None
|
||||
|
||||
# Get or create the peer to ensure it exists
|
||||
async with tracked_db("peers.chat.get_or_create_peer") as peer_db:
|
||||
peers_result = await crud.get_or_create_peers(
|
||||
|
|
@ -255,6 +265,7 @@ async def chat(
|
|||
observer=peer_id,
|
||||
observed=options.target if options.target is not None else peer_id,
|
||||
reasoning_level=options.reasoning_level,
|
||||
response_model=response_model,
|
||||
)
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
|
|
@ -269,6 +280,7 @@ async def chat(
|
|||
# and it's answered from the omniscient Honcho perspective
|
||||
observed=options.target if options.target is not None else peer_id,
|
||||
reasoning_level=options.reasoning_level,
|
||||
response_model=response_model,
|
||||
)
|
||||
|
||||
# Prometheus metrics
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ async def schedule_dream(
|
|||
observed=observed,
|
||||
dream_type=dream_type,
|
||||
session_name=request.session_id,
|
||||
rebuild=request.rebuild,
|
||||
# Manual route — explicit sentinels for the DreamRunEvent
|
||||
# scheduling-context fields. Auto-schedule threads concrete
|
||||
# threshold/delay reasons (see src/dreamer/dream_scheduler.py);
|
||||
|
|
|
|||
|
|
@ -688,6 +688,16 @@ class DialecticOptions(BaseModel):
|
|||
default="low",
|
||||
description="Level of reasoning to apply: minimal, low, medium, high, or max",
|
||||
)
|
||||
response_format: dict[str, Any] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional JSON Schema (root type 'object') the response must conform"
|
||||
" to. When provided, `content` is a JSON string matching this schema."
|
||||
" Only a conservative subset of JSON Schema is supported; unsupported"
|
||||
" schemas are rejected with 422. Constraint keywords (minItems, "
|
||||
" maxLength, ...) are hints to the model, not enforced server-side."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("query", mode="after")
|
||||
@classmethod
|
||||
|
|
@ -774,6 +784,14 @@ class ScheduleDreamRequest(BaseModel):
|
|||
session_id: str | None = Field(
|
||||
None, description="Session ID to scope the dream to if specified"
|
||||
)
|
||||
rebuild: bool = Field(
|
||||
False,
|
||||
description=(
|
||||
"card_refresh dreams only: rebuild the peer card solely from "
|
||||
"observations currently in the collection, without injecting the "
|
||||
"existing card (use after removals)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ class DreamType(str, Enum):
|
|||
"""Types of dreams that can be triggered."""
|
||||
|
||||
OMNI = "omni"
|
||||
# Lightweight card-only refresh: runs a single specialist restricted to
|
||||
# peer-card tools. Used for event-driven refreshes (scope membership
|
||||
# changes, cold starts) — never creates or deletes observations.
|
||||
CARD_REFRESH = "card_refresh"
|
||||
|
||||
|
||||
class ReasoningConfiguration(BaseModel):
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class CallPurpose(str, Enum):
|
|||
DIALECTIC_ANSWER = "dialectic.answer"
|
||||
DREAM_DEDUCTION = "dream.deduction"
|
||||
DREAM_INDUCTION = "dream.induction"
|
||||
DREAM_CARD_REFRESH = "dream.card_refresh"
|
||||
SUMMARY_SHORT = "summary.short"
|
||||
SUMMARY_LONG = "summary.long"
|
||||
|
||||
|
|
|
|||
|
|
@ -100,11 +100,36 @@ class RepresentationCompletedEvent(BaseEvent):
|
|||
default=0,
|
||||
description="Estimated tokens for the system/scaffold portion of the prompt",
|
||||
)
|
||||
exact_dup_in_batch_count: int = Field(
|
||||
default=0,
|
||||
description="Number of documents produced in this representation that had the same normalized content",
|
||||
)
|
||||
exact_dup_existing_count: int = Field(
|
||||
default=0,
|
||||
description=(
|
||||
"Number of documents previously written that had a representation that had the same normalized "
|
||||
"content as a document in this representation"
|
||||
),
|
||||
)
|
||||
semantic_dup_rejected_count: int = Field(
|
||||
default=0,
|
||||
description=(
|
||||
"Number of documents in this representation rejected because their cosine-similarity was high "
|
||||
"for an existing document but were worse than the corresponding existing document"
|
||||
),
|
||||
)
|
||||
semantic_dup_replaced_count: int = Field(
|
||||
default=0,
|
||||
description=(
|
||||
"Number of documents in this representation that replaced existing documents because their "
|
||||
"cosine-similarity was high and they were better than the corresponding existing document"
|
||||
),
|
||||
)
|
||||
|
||||
# Cap configuration + hit flags ()
|
||||
batch_max_tokens: int = Field(
|
||||
default=0,
|
||||
description="settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS at fetch time",
|
||||
description="settings.DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS at fetch time",
|
||||
)
|
||||
max_input_tokens: int = Field(
|
||||
default=0, description="settings.DERIVER.MAX_INPUT_TOKENS at call time"
|
||||
|
|
|
|||
|
|
@ -87,6 +87,18 @@ messages_created_counter = NamespacedCounter(
|
|||
["namespace", "workspace_name"],
|
||||
)
|
||||
|
||||
embed_now_tasks_shed_counter = NamespacedCounter(
|
||||
"embed_now_tasks_shed",
|
||||
"Immediate-embed background tasks skipped because MAX_PENDING_EMBED_TASKS was reached",
|
||||
["namespace"],
|
||||
)
|
||||
|
||||
embed_now_tasks_in_flight_gauge = NamespacedGauge(
|
||||
"embed_now_tasks_in_flight",
|
||||
"Immediate-embed background tasks currently in flight for this process",
|
||||
["namespace"],
|
||||
)
|
||||
|
||||
dialectic_calls_counter = NamespacedCounter(
|
||||
"dialectic_calls",
|
||||
"Total dialectic calls",
|
||||
|
|
@ -204,6 +216,18 @@ class PrometheusMetrics:
|
|||
except Exception as e:
|
||||
self._handle_metric_error("record_messages_created", e)
|
||||
|
||||
def record_embed_now_task_shed(self) -> None:
|
||||
try:
|
||||
embed_now_tasks_shed_counter.labels().inc()
|
||||
except Exception as e:
|
||||
self._handle_metric_error("record_embed_now_task_shed", e)
|
||||
|
||||
def set_embed_now_tasks_in_flight(self, count: int) -> None:
|
||||
try:
|
||||
embed_now_tasks_in_flight_gauge.labels().set(count)
|
||||
except Exception as e:
|
||||
self._handle_metric_error("set_embed_now_tasks_in_flight", e)
|
||||
|
||||
def record_dialectic_call(
|
||||
self,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,12 @@ from src.telemetry.events import (
|
|||
emit,
|
||||
)
|
||||
from src.utils import summarizer
|
||||
from src.utils.formatting import format_new_turn_with_timestamp, utc_now_iso
|
||||
from src.utils.formatting import (
|
||||
format_datetime_utc,
|
||||
format_new_turn_with_timestamp,
|
||||
parse_datetime_iso,
|
||||
utc_now_iso,
|
||||
)
|
||||
from src.utils.representation import Representation
|
||||
from src.utils.types import ToolResult, embedding_call_purpose, get_current_iteration
|
||||
|
||||
|
|
@ -845,6 +850,18 @@ INDUCTION_SPECIALIST_TOOLS: list[dict[str, Any]] = [
|
|||
TOOLS["create_observations_inductive"],
|
||||
]
|
||||
|
||||
# Tools for the card-refresh specialist (card_refresh dream type).
|
||||
# Card-only maintenance: discovery plus update_peer_card. Deliberately
|
||||
# excludes every observation-mutating tool (create_observations*,
|
||||
# delete_observations) — a card refresh must never touch observations.
|
||||
CARD_REFRESH_SPECIALIST_TOOLS: list[dict[str, Any]] = [
|
||||
# Discovery tools
|
||||
TOOLS["get_recent_observations"],
|
||||
TOOLS["search_memory"],
|
||||
# Action tool
|
||||
TOOLS["update_peer_card"],
|
||||
]
|
||||
|
||||
|
||||
async def create_observations(
|
||||
observations: list[schemas.ObservationInput],
|
||||
|
|
@ -982,14 +999,16 @@ async def create_observations(
|
|||
accepted: list[schemas.DocumentCreate] = []
|
||||
if documents:
|
||||
async with tracked_db("create_observations.save") as db:
|
||||
accepted = await crud.create_documents(
|
||||
db,
|
||||
documents=documents,
|
||||
workspace_name=workspace_name,
|
||||
observer=observer,
|
||||
observed=observed,
|
||||
deduplicate=True,
|
||||
)
|
||||
accepted = (
|
||||
await crud.create_documents(
|
||||
db,
|
||||
documents=documents,
|
||||
workspace_name=workspace_name,
|
||||
observer=observer,
|
||||
observed=observed,
|
||||
deduplicate=True,
|
||||
)
|
||||
).created_documents
|
||||
logger.info(
|
||||
"Created %d observations in %s/%s/%s",
|
||||
len(accepted),
|
||||
|
|
@ -1306,6 +1325,52 @@ def _normalize_observation_id(obs_id: str) -> str:
|
|||
return obs_id.strip()
|
||||
|
||||
|
||||
async def _latest_source_timestamp(
|
||||
ctx: ToolContext,
|
||||
observations: list[schemas.ObservationInput],
|
||||
) -> str | None:
|
||||
"""Latest ``message_created_at`` across all source observations in the batch.
|
||||
|
||||
Dreamer conclusions (deductive/inductive) are derived from existing
|
||||
observations referenced by ``source_ids`` rather than from live messages.
|
||||
Their logical timestamp is the point when the conclusion became possible
|
||||
from its evidence, not when the dreamer happened to run, so we date
|
||||
``internal_metadata["message_created_at"]`` to the most recent source
|
||||
observation. The physical ``Document.created_at`` column remains the insert
|
||||
time. Returns None if no source_ids resolve to a usable timestamp (caller
|
||||
falls back to now).
|
||||
"""
|
||||
source_ids: list[str] = []
|
||||
for obs in observations:
|
||||
if obs.source_ids:
|
||||
source_ids.extend(obs.source_ids)
|
||||
if not source_ids:
|
||||
return None
|
||||
|
||||
latest: datetime | None = None
|
||||
async with tracked_db("create_observations.source_ts", read_only=True) as db:
|
||||
docs = await crud.fetch_documents_by_ids(
|
||||
db,
|
||||
workspace_name=ctx.workspace_name,
|
||||
observer=ctx.observer,
|
||||
observed=ctx.observed,
|
||||
document_ids=list(set(source_ids)),
|
||||
)
|
||||
for doc in docs:
|
||||
raw = doc.internal_metadata.get("message_created_at")
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
try:
|
||||
# always tz-aware, so the comparison below can't crash on mixed formats
|
||||
parsed = parse_datetime_iso(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
if latest is None or parsed > latest:
|
||||
latest = parsed
|
||||
|
||||
return format_datetime_utc(latest) if latest is not None else None
|
||||
|
||||
|
||||
async def _handle_create_observations_impl(
|
||||
ctx: ToolContext,
|
||||
tool_input: dict[str, Any],
|
||||
|
|
@ -1357,6 +1422,21 @@ async def _handle_create_observations_impl(
|
|||
)
|
||||
)
|
||||
continue
|
||||
# Session-purity invariant: explicit observations record what was
|
||||
# directly derived from a session's messages. Agents that are not
|
||||
# processing messages (dreamer specialists, dialectic) must not mint
|
||||
# them — consolidation output belongs at a derived level.
|
||||
if not ctx.current_messages and validated.level == "explicit":
|
||||
validation_failures.append(
|
||||
ObservationFailure(
|
||||
content_preview=validated.content[:50],
|
||||
error=(
|
||||
"Only message ingestion can create 'explicit' observations; "
|
||||
"use a derived level (deductive/inductive/contradiction)"
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
observations.append(validated)
|
||||
|
||||
if not observations:
|
||||
|
|
@ -1368,10 +1448,16 @@ async def _handle_create_observations_impl(
|
|||
# Determine message context
|
||||
if ctx.current_messages:
|
||||
message_ids = [msg.id for msg in ctx.current_messages]
|
||||
message_created_at = str(ctx.current_messages[-1].created_at)
|
||||
# same ISO-8601 Z format as the dreamer path below
|
||||
message_created_at = format_datetime_utc(ctx.current_messages[-1].created_at)
|
||||
else:
|
||||
# Dreamer path: no current messages. Backdate the conclusion to the
|
||||
# latest source observation, which is when the inference became possible.
|
||||
|
||||
message_ids = []
|
||||
message_created_at = utc_now_iso()
|
||||
message_created_at = (
|
||||
await _latest_source_timestamp(ctx, observations)
|
||||
) or utc_now_iso()
|
||||
|
||||
# Use lock to serialize database writes (prevents concurrent commit issues)
|
||||
async with ctx.db_lock:
|
||||
|
|
|
|||
|
|
@ -66,6 +66,11 @@ class DreamPayload(BasePayload):
|
|||
delay_reason: str | None = None
|
||||
documents_since_last_dream_at_schedule: int | None = None
|
||||
document_threshold: int | None = None
|
||||
# card_refresh only: when True the existing peer card is NOT injected into
|
||||
# the specialist prompt and the card is rebuilt solely from observations
|
||||
# currently in the collection (used after removals, where the old card may
|
||||
# contain facts whose support was deleted).
|
||||
rebuild: bool = False
|
||||
|
||||
|
||||
class DeletionPayload(BasePayload):
|
||||
|
|
@ -103,6 +108,7 @@ def create_dream_payload(
|
|||
delay_reason: str | None = None,
|
||||
documents_since_last_dream_at_schedule: int | None = None,
|
||||
document_threshold: int | None = None,
|
||||
rebuild: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a dream payload."""
|
||||
return DreamPayload(
|
||||
|
|
@ -114,6 +120,7 @@ def create_dream_payload(
|
|||
delay_reason=delay_reason,
|
||||
documents_since_last_dream_at_schedule=documents_since_last_dream_at_schedule,
|
||||
document_threshold=document_threshold,
|
||||
rebuild=rebuild,
|
||||
).model_dump(mode="json", exclude_none=True)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,505 @@
|
|||
"""Convert caller-supplied JSON Schema objects into dynamic Pydantic models.
|
||||
|
||||
Used by the dialectic chat endpoint's ``response_format`` option: the caller
|
||||
sends a JSON Schema dict, and the resulting model is passed as
|
||||
``response_model`` to ``honcho_llm_call()`` so providers return conforming
|
||||
JSON.
|
||||
|
||||
Only a conservative subset of JSON Schema is supported (see
|
||||
``json_response_schema_to_pydantic``). Conversion doubles as validation: any
|
||||
unsupported construct raises ``ValueError`` with the offending path, which the
|
||||
router surfaces as a 422.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, NoReturn, cast
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, create_model
|
||||
|
||||
# "$defs"/"definitions" are extracted at the root before the walk and are
|
||||
# rejected anywhere else. "$ref" nodes are resolved by _resolve_ref before
|
||||
# node validation, so they never reach this check.
|
||||
_UNSUPPORTED_KEYS = (
|
||||
"$defs",
|
||||
"definitions",
|
||||
"allOf",
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"patternProperties",
|
||||
)
|
||||
|
||||
_REF_PREFIXES = ("#/$defs/", "#/definitions/")
|
||||
|
||||
_PRIMITIVE_TYPES: dict[str, Any] = {
|
||||
"string": str,
|
||||
"number": float,
|
||||
"integer": int,
|
||||
"boolean": bool,
|
||||
"null": type(None),
|
||||
}
|
||||
|
||||
# Constraint keywords are forwarded to the model's json_schema_extra so the
|
||||
# LLM sees them, but Pydantic does not enforce them (they are hints only).
|
||||
_HINT_KEYS = (
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum",
|
||||
"multipleOf",
|
||||
"pattern",
|
||||
"format",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
"uniqueItems",
|
||||
)
|
||||
|
||||
_IDENTIFIER_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Ctx:
|
||||
"""Mutable state shared across one conversion walk."""
|
||||
|
||||
max_depth: int
|
||||
max_nodes: int
|
||||
defs: dict[str, Any] = field(default_factory=dict)
|
||||
used_names: set[str] = field(default_factory=set)
|
||||
ref_stack: list[str] = field(default_factory=list)
|
||||
node_count: int = 0
|
||||
|
||||
|
||||
def json_response_schema_to_pydantic(
|
||||
schema: dict[str, Any],
|
||||
*,
|
||||
model_name: str = "ResponseFormat",
|
||||
max_depth: int = 20,
|
||||
max_nodes: int = 500,
|
||||
) -> type[BaseModel]:
|
||||
"""Convert a JSON Schema dict (root type ``object``) into a Pydantic model.
|
||||
|
||||
Supported constructs: primitive types (``string``/``number``/``integer``/
|
||||
``boolean``/``null``), nested ``object`` with ``properties``, ``array``
|
||||
with ``items`` (missing ``items`` yields ``list[Any]``), ``enum`` of
|
||||
strings/integers/booleans/null, ``anyOf``/``oneOf`` unions (a
|
||||
``{"type": "null"}`` member yields an optional), ``type`` given as a list,
|
||||
``required``, ``default``, and ``description``. A root-level ``$schema``
|
||||
key is ignored. Boolean ``additionalProperties`` is accepted and ignored;
|
||||
extra keys in LLM output are silently dropped (``extra="ignore"``).
|
||||
|
||||
``$ref`` is supported for references of the form ``#/$defs/<name>`` or
|
||||
``#/definitions/<name>`` into root-level ``$defs``/``definitions`` (this
|
||||
is what Pydantic's ``model_json_schema()`` and Zod's ``toJSONSchema``
|
||||
emit). References are resolved by inlining; sibling keys next to ``$ref``
|
||||
overlay the referenced definition (siblings win). Recursive references
|
||||
are rejected — the error names the cycle. Unreferenced definitions are
|
||||
ignored without validation.
|
||||
|
||||
Constraint keywords (``minItems``, ``maxLength``, ``minimum``,
|
||||
``pattern``, ...) are passed through to the generated schema as hints but
|
||||
are not enforced by Pydantic.
|
||||
|
||||
Args:
|
||||
schema: The JSON Schema object. Root must resolve to type ``object``.
|
||||
model_name: Name for the generated root model class.
|
||||
max_depth: Maximum nesting depth. Guard against excessive or
|
||||
malicious schemas (e.g. pathologically deep nesting); exceeding
|
||||
it raises ``ValueError``.
|
||||
max_nodes: Maximum total nodes visited across the whole schema.
|
||||
Guard against excessive or malicious schemas (e.g. enormous
|
||||
property fan-out); exceeding it raises ``ValueError``.
|
||||
|
||||
Returns:
|
||||
A dynamically created Pydantic model class.
|
||||
|
||||
Raises:
|
||||
ValueError: If the schema is malformed or uses an unsupported
|
||||
construct (``allOf``, ``not``, ``if``/``then``/``else``,
|
||||
``patternProperties``, schema-valued ``additionalProperties``,
|
||||
boolean schemas, unknown types, a non-object root, a ``$ref``
|
||||
that is recursive, malformed, or targets an unknown definition,
|
||||
or ``$defs``/``definitions`` anywhere but the root). The message
|
||||
names the construct and its path.
|
||||
"""
|
||||
schema_obj: Any = schema
|
||||
if not isinstance(schema_obj, dict):
|
||||
raise ValueError("response_format must be a JSON Schema object")
|
||||
|
||||
root = {k: v for k, v in schema.items() if k != "$schema"}
|
||||
defs = _extract_defs(root)
|
||||
root_type = root.get("type")
|
||||
# A "$ref" root is allowed through here; the post-conversion check below
|
||||
# still enforces that it resolves to an object.
|
||||
is_object_root = root_type == "object" or (
|
||||
root_type is None and ("properties" in root or "$ref" in root)
|
||||
)
|
||||
if not is_object_root:
|
||||
raise ValueError("root schema must have type 'object'")
|
||||
|
||||
ctx = _Ctx(max_depth=max_depth, max_nodes=max_nodes, defs=defs)
|
||||
annotation = _convert_schema(root, "", model_name, ctx, depth=0)
|
||||
# An object root always converts to a model class; this is a safety net.
|
||||
if not (isinstance(annotation, type) and issubclass(annotation, BaseModel)):
|
||||
raise ValueError("root schema must have type 'object'")
|
||||
return annotation
|
||||
|
||||
|
||||
def _fail(msg: str, path: str) -> NoReturn:
|
||||
raise ValueError(f"{msg} at {path or 'root'}")
|
||||
|
||||
|
||||
def _union(members: tuple[Any, ...]) -> Any:
|
||||
"""Build ``A | B | ...`` from a dynamic tuple of annotations."""
|
||||
result: Any = members[0]
|
||||
for member in members[1:]:
|
||||
result = result | member
|
||||
return result
|
||||
|
||||
|
||||
def _convert_schema(
|
||||
raw_node: Any, path: str, name_hint: str, ctx: _Ctx, depth: int
|
||||
) -> Any:
|
||||
"""Convert one schema node into a type annotation.
|
||||
|
||||
Dispatch order matters: $ref resolution comes first (a ref node is
|
||||
replaced by its target before anything else looks at it), then enum (a
|
||||
value constraint) wins over unions, which win over "type"-based
|
||||
conversion.
|
||||
"""
|
||||
if isinstance(raw_node, dict) and "$ref" in raw_node:
|
||||
return _resolve_ref(cast(dict[str, Any], raw_node), path, ctx, depth)
|
||||
|
||||
node = _validate_node(raw_node, path, ctx, depth)
|
||||
|
||||
if "enum" in node:
|
||||
return _convert_enum(node["enum"], path)
|
||||
|
||||
if "anyOf" in node or "oneOf" in node:
|
||||
return _convert_union(node, path, name_hint, ctx, depth)
|
||||
|
||||
node_type = node.get("type")
|
||||
if isinstance(node_type, list):
|
||||
return _convert_type_list(
|
||||
node, cast(list[Any], node_type), path, name_hint, ctx, depth
|
||||
)
|
||||
|
||||
# Tolerate an omitted "type" when "properties" makes the intent clear.
|
||||
if node_type is None and "properties" in node:
|
||||
node_type = "object"
|
||||
|
||||
if node_type == "object":
|
||||
return _build_object_model(node, path, name_hint, ctx, depth)
|
||||
|
||||
if node_type == "array":
|
||||
return _convert_array(node, path, name_hint, ctx, depth)
|
||||
|
||||
if node_type in _PRIMITIVE_TYPES:
|
||||
return _PRIMITIVE_TYPES[node_type]
|
||||
|
||||
if node_type is None:
|
||||
_fail("schema has no recognizable type", path)
|
||||
_fail(f"unsupported type '{node_type}'", path)
|
||||
|
||||
|
||||
def _extract_defs(root: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Pop root-level ``$defs``/``definitions`` and merge them into one
|
||||
registry. Entries are validated lazily, when (and only when) referenced."""
|
||||
defs: dict[str, Any] = {}
|
||||
for key in ("$defs", "definitions"):
|
||||
raw = root.pop(key, None)
|
||||
if raw is None:
|
||||
continue
|
||||
if not isinstance(raw, dict):
|
||||
_fail(f"'{key}' must be an object", "")
|
||||
for name, definition in cast(dict[Any, Any], raw).items():
|
||||
if not isinstance(name, str) or not name:
|
||||
_fail(f"'{key}' definition names must be non-empty strings", "")
|
||||
if name in defs:
|
||||
_fail(
|
||||
f"definition '{name}' appears in both '$defs' and 'definitions'",
|
||||
"",
|
||||
)
|
||||
defs[name] = definition
|
||||
return defs
|
||||
|
||||
|
||||
def _resolve_ref(node: dict[str, Any], path: str, ctx: _Ctx, depth: int) -> Any:
|
||||
"""Inline a ``$ref`` node: resolve the target definition, overlay any
|
||||
sibling keys (siblings win), and convert the result in place.
|
||||
|
||||
Only root-relative refs into ``$defs``/``definitions`` are supported.
|
||||
Cycles are rejected — recursion cannot be inlined. The resolved node is
|
||||
converted at the same depth (replacement semantics); the node budget in
|
||||
``_validate_node`` still counts every expansion, so a definition that is
|
||||
referenced many times cannot blow up the walk.
|
||||
"""
|
||||
ref: Any = node["$ref"]
|
||||
if not isinstance(ref, str):
|
||||
_fail("'$ref' must be a string", path)
|
||||
name: str | None = None
|
||||
for prefix in _REF_PREFIXES:
|
||||
if ref.startswith(prefix):
|
||||
name = ref[len(prefix) :]
|
||||
break
|
||||
# "/", "~", and "%" would make the remainder a deeper or escaped JSON
|
||||
# pointer (e.g. "#/$defs/a/b", "~1" escapes, %-encoding) rather than a
|
||||
# plain definition name, so their presence means an unsupported form.
|
||||
if not name or "/" in name or "~" in name or "%" in name:
|
||||
_fail(
|
||||
f"unsupported $ref '{ref}': only '#/$defs/<name>' or "
|
||||
+ "'#/definitions/<name>' references are supported",
|
||||
path,
|
||||
)
|
||||
if name not in ctx.defs:
|
||||
_fail(f"$ref '{ref}' points to an unknown definition", path)
|
||||
# ref_stack holds the definitions currently being expanded on this branch
|
||||
# of the walk, so membership means the definition (transitively)
|
||||
# references itself. Slicing from the first occurrence yields the cycle
|
||||
# for the error message, e.g. stack [A, B] + name A -> "A -> B -> A".
|
||||
if name in ctx.ref_stack:
|
||||
cycle = " -> ".join([*ctx.ref_stack[ctx.ref_stack.index(name) :], name])
|
||||
_fail(
|
||||
f"recursive $ref is not supported (cycle: {cycle}); "
|
||||
+ "restructure the schema so definitions do not reference themselves",
|
||||
path,
|
||||
)
|
||||
target: Any = ctx.defs[name]
|
||||
siblings = {k: v for k, v in node.items() if k != "$ref"}
|
||||
resolved: Any = target
|
||||
if siblings and isinstance(target, dict):
|
||||
resolved = {**cast(dict[str, Any], target), **siblings}
|
||||
# Pop after converting (not just on success) so the stack tracks only the
|
||||
# current branch: a diamond — the same definition referenced from two
|
||||
# sibling nodes — is legitimate reuse, not a cycle.
|
||||
ctx.ref_stack.append(name)
|
||||
try:
|
||||
return _convert_schema(resolved, path, name, ctx, depth)
|
||||
finally:
|
||||
ctx.ref_stack.pop()
|
||||
|
||||
|
||||
def _validate_node(raw_node: Any, path: str, ctx: _Ctx, depth: int) -> dict[str, Any]:
|
||||
"""Enforce size budgets and node shape; reject unsupported constructs."""
|
||||
# node_count is cumulative across the whole walk; depth tracks only the
|
||||
# current branch.
|
||||
ctx.node_count += 1
|
||||
if ctx.node_count > ctx.max_nodes:
|
||||
raise ValueError(f"schema exceeds the maximum of {ctx.max_nodes} nodes")
|
||||
if depth > ctx.max_depth:
|
||||
raise ValueError(f"schema nesting exceeds the maximum depth of {ctx.max_depth}")
|
||||
if isinstance(raw_node, bool):
|
||||
# A special case of the object requirement, with its own message:
|
||||
# boolean schemas are legal JSON Schema, just deliberately unsupported.
|
||||
_fail("boolean schemas are not supported", path)
|
||||
if not isinstance(raw_node, dict):
|
||||
_fail("schema must be an object", path)
|
||||
node = cast(dict[str, Any], raw_node)
|
||||
for key in _UNSUPPORTED_KEYS:
|
||||
if key in node:
|
||||
_fail(f"unsupported construct '{key}'", path)
|
||||
if isinstance(node.get("additionalProperties"), dict):
|
||||
_fail("additionalProperties with a schema is not supported", path)
|
||||
return node
|
||||
|
||||
|
||||
def _convert_union(
|
||||
node: dict[str, Any], path: str, name_hint: str, ctx: _Ctx, depth: int
|
||||
) -> Any:
|
||||
"""Convert anyOf/oneOf, treated identically: a plain union of the member
|
||||
schemas (a {"type": "null"} member makes the union optional)."""
|
||||
union_key = "anyOf" if "anyOf" in node else "oneOf"
|
||||
members = node[union_key]
|
||||
if not isinstance(members, list) or not members:
|
||||
_fail(f"'{union_key}' must be a non-empty array", path)
|
||||
converted = tuple(
|
||||
_convert_schema(
|
||||
member,
|
||||
_child_path(path, f"{union_key}[{i}]"),
|
||||
f"{name_hint}Option{i}",
|
||||
ctx,
|
||||
depth + 1,
|
||||
)
|
||||
for i, member in enumerate(cast(list[Any], members))
|
||||
)
|
||||
return _union(converted)
|
||||
|
||||
|
||||
def _convert_type_list(
|
||||
node: dict[str, Any],
|
||||
types: list[Any],
|
||||
path: str,
|
||||
name_hint: str,
|
||||
ctx: _Ctx,
|
||||
depth: int,
|
||||
) -> Any:
|
||||
"""Convert the type: ["string", "null"] sugar — re-convert the node once
|
||||
per entry (keeping sibling keys, at the same depth since it's the same
|
||||
source node) and union the results."""
|
||||
if not types:
|
||||
_fail("'type' array must not be empty", path)
|
||||
variants = tuple(
|
||||
_convert_schema(
|
||||
{**{k: v for k, v in node.items() if k != "type"}, "type": t},
|
||||
path,
|
||||
name_hint,
|
||||
ctx,
|
||||
depth,
|
||||
)
|
||||
for t in types
|
||||
)
|
||||
return _union(variants)
|
||||
|
||||
|
||||
def _convert_array(
|
||||
node: dict[str, Any], path: str, name_hint: str, ctx: _Ctx, depth: int
|
||||
) -> Any:
|
||||
"""Convert an array schema into a list annotation."""
|
||||
items = node.get("items")
|
||||
# A missing "items" constraint means any element type is allowed.
|
||||
if items is None:
|
||||
return list[Any]
|
||||
item_annotation = _convert_schema(
|
||||
items, _child_path(path, "items"), f"{name_hint}Item", ctx, depth + 1
|
||||
)
|
||||
return list[item_annotation]
|
||||
|
||||
|
||||
def _convert_enum(raw_values: Any, path: str) -> Any:
|
||||
if not isinstance(raw_values, list) or not raw_values:
|
||||
_fail("'enum' must be a non-empty array", path)
|
||||
literal_values: list[Any] = []
|
||||
# None is not Literal-legal, so collect it separately and union NoneType
|
||||
# back in at the end (an all-null enum degenerates to NoneType).
|
||||
has_null = False
|
||||
for value in cast(list[Any], raw_values):
|
||||
if value is None:
|
||||
has_null = True
|
||||
elif isinstance(value, str | int | bool):
|
||||
literal_values.append(value)
|
||||
else:
|
||||
_fail("enum values must be strings, integers, booleans, or null", path)
|
||||
if not literal_values:
|
||||
return type(None)
|
||||
annotation: Any = Literal[tuple(literal_values)]
|
||||
return _union((annotation, type(None))) if has_null else annotation
|
||||
|
||||
|
||||
def _build_object_model(
|
||||
node: dict[str, Any], path: str, name_hint: str, ctx: _Ctx, depth: int
|
||||
) -> type[BaseModel]:
|
||||
raw_properties: Any = node.get("properties", {})
|
||||
if not isinstance(raw_properties, dict):
|
||||
_fail("'properties' must be an object", path)
|
||||
properties = cast(dict[Any, Any], raw_properties)
|
||||
raw_required: Any = node.get("required", [])
|
||||
if not isinstance(raw_required, list) or not all(
|
||||
isinstance(entry, str) for entry in cast(list[Any], raw_required)
|
||||
):
|
||||
_fail("'required' must be an array of strings", path)
|
||||
# Entries naming properties that don't exist are tolerated; they just
|
||||
# have no effect.
|
||||
required = set(cast(list[str], raw_required))
|
||||
|
||||
fields: dict[str, tuple[Any, Any]] = {}
|
||||
for prop_key, prop_schema in properties.items():
|
||||
if not isinstance(prop_key, str) or not prop_key:
|
||||
_fail("property names must be non-empty strings", path)
|
||||
annotation = _convert_schema(
|
||||
prop_schema,
|
||||
_child_path(path, f"properties.{prop_key}"),
|
||||
f"{name_hint} {prop_key}",
|
||||
ctx,
|
||||
depth + 1,
|
||||
)
|
||||
# Non-identifier keys ("my-key") get a sanitized field name, with the
|
||||
# original key preserved as the alias for validation/serialization.
|
||||
field_name = _field_name(prop_key, fields)
|
||||
alias = prop_key if field_name != prop_key else None
|
||||
fields[field_name] = _make_field(
|
||||
cast(dict[str, Any], prop_schema) if isinstance(prop_schema, dict) else {},
|
||||
annotation,
|
||||
is_required=prop_key in required,
|
||||
alias=alias,
|
||||
)
|
||||
|
||||
# create_model's overloads can't type dynamic **fields; the values are
|
||||
# (annotation, FieldInfo) tuples, which is the documented calling form.
|
||||
model: Any = create_model( # pyright: ignore[reportCallIssue, reportUnknownVariableType]
|
||||
_unique_model_name(name_hint, ctx),
|
||||
__config__=ConfigDict(extra="ignore", populate_by_name=True),
|
||||
**fields, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
return cast(type[BaseModel], model)
|
||||
|
||||
|
||||
def _make_field(
|
||||
prop_schema: dict[str, Any],
|
||||
annotation: Any,
|
||||
*,
|
||||
is_required: bool,
|
||||
alias: str | None,
|
||||
) -> tuple[Any, Any]:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if alias is not None:
|
||||
kwargs["alias"] = alias
|
||||
description = prop_schema.get("description")
|
||||
if isinstance(description, str):
|
||||
kwargs["description"] = description
|
||||
hints = {key: prop_schema[key] for key in _HINT_KEYS if key in prop_schema}
|
||||
if hints:
|
||||
kwargs["json_schema_extra"] = hints
|
||||
|
||||
# Precedence: an explicit default wins (even for required fields), then
|
||||
# required, then optional — which widens to `T | None` defaulting to None.
|
||||
if "default" in prop_schema:
|
||||
return annotation, Field(default=prop_schema["default"], **kwargs)
|
||||
if is_required:
|
||||
return annotation, Field(**kwargs)
|
||||
return annotation | None, Field(default=None, **kwargs)
|
||||
|
||||
|
||||
def _child_path(path: str, segment: str) -> str:
|
||||
return f"{path}.{segment}" if path else segment
|
||||
|
||||
|
||||
def _field_name(prop_key: str, existing: dict[str, Any]) -> str:
|
||||
"""Return a valid, unique Python field name for a JSON property key.
|
||||
|
||||
Keys that aren't valid identifiers (or would be Pydantic-private via a
|
||||
leading underscore) are sanitized; the original key is preserved as the
|
||||
field alias by the caller.
|
||||
"""
|
||||
if _IDENTIFIER_RE.match(prop_key) and prop_key not in existing:
|
||||
return prop_key
|
||||
sanitized = re.sub(r"[^A-Za-z0-9_]", "_", prop_key).lstrip("_")
|
||||
if not sanitized or sanitized[0].isdigit():
|
||||
sanitized = f"field_{sanitized}"
|
||||
candidate = sanitized
|
||||
suffix = 2
|
||||
while candidate in existing:
|
||||
candidate = f"{sanitized}_{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _unique_model_name(name_hint: str, ctx: _Ctx) -> str:
|
||||
# PascalCase the hint (e.g. "ResponseFormat address geo" -> "ResponseFormatAddressGeo").
|
||||
parts = re.split(r"[^A-Za-z0-9]+", name_hint)
|
||||
name = "".join(part[:1].upper() + part[1:] for part in parts if part)
|
||||
# Class names can't be empty or start with a digit.
|
||||
if not name or name[0].isdigit():
|
||||
name = f"Model{name}"
|
||||
# Distinct hints can sanitize to the same name; suffix _2, _3, ... to disambiguate.
|
||||
candidate = name
|
||||
suffix = 2
|
||||
while candidate in ctx.used_names:
|
||||
candidate = f"{name}_{suffix}"
|
||||
suffix += 1
|
||||
ctx.used_names.add(candidate)
|
||||
return candidate
|
||||
|
|
@ -246,7 +246,7 @@ def calculate_question_events(
|
|||
|
||||
# Calculate representation events
|
||||
# Each unique (session, observed) pair generates one representation event
|
||||
# (assuming messages fit within REPRESENTATION_BATCH_MAX_TOKENS)
|
||||
# (assuming messages fit within REPRESENTATION_BATCH_TARGET_INPUT_TOKENS)
|
||||
# When merge_sessions=True, all messages go into one session
|
||||
if merge_sessions:
|
||||
# One merged session = one representation event
|
||||
|
|
|
|||
|
|
@ -672,8 +672,15 @@ def mock_llm_call_functions(request: pytest.FixtureRequest):
|
|||
mock_short_summary.return_value = "Test short summary content"
|
||||
mock_long_summary.return_value = "Test long summary content"
|
||||
|
||||
# Mock agentic_chat to return a string (matching actual return type)
|
||||
mock_agentic_chat.return_value = "Test dialectic response"
|
||||
# Mock agentic_chat to return a string (matching actual return type).
|
||||
# With a response_model (structured output) the real function returns
|
||||
# a JSON string, so mirror that for SDK clients that parse content.
|
||||
async def _agentic_chat_response(*_args: object, **kwargs: object) -> str:
|
||||
if kwargs.get("response_model") is not None:
|
||||
return "{}"
|
||||
return "Test dialectic response"
|
||||
|
||||
mock_agentic_chat.side_effect = _agentic_chat_response
|
||||
|
||||
yield {
|
||||
"short_summary": mock_short_summary,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from nanoid import generate as generate_nanoid
|
||||
|
|
@ -6,7 +7,7 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import crud, models, schemas
|
||||
from src.crud.document import is_rejected_duplicate
|
||||
from src.crud.document import SemanticRejectionResult, is_rejected_duplicate
|
||||
from src.exceptions import ResourceNotFoundException
|
||||
|
||||
|
||||
|
|
@ -381,7 +382,7 @@ class TestDocumentCRUD:
|
|||
observed=test_peer2.name,
|
||||
)
|
||||
|
||||
assert rejected is True
|
||||
assert rejected is SemanticRejectionResult.REJECTED
|
||||
surviving = (
|
||||
await db_session.execute(
|
||||
select(models.Document).where(
|
||||
|
|
@ -445,7 +446,7 @@ class TestDocumentCRUD:
|
|||
observed=test_peer2.name,
|
||||
)
|
||||
|
||||
assert rejected is False
|
||||
assert rejected is SemanticRejectionResult.REPLACED_EXISTING
|
||||
# Count carried forward onto the replacement (3 -> 4), not reset to 1.
|
||||
assert new_doc.times_derived == 4
|
||||
live = (
|
||||
|
|
@ -509,7 +510,7 @@ class TestDocumentCRUD:
|
|||
),
|
||||
]
|
||||
|
||||
accepted = await crud.create_documents(
|
||||
result = await crud.create_documents(
|
||||
db_session,
|
||||
documents=doc_schemas,
|
||||
workspace_name=test_workspace.name,
|
||||
|
|
@ -517,8 +518,13 @@ class TestDocumentCRUD:
|
|||
observed=test_peer2.name,
|
||||
deduplicate=False,
|
||||
)
|
||||
accepted = result.created_documents
|
||||
|
||||
assert len(accepted) == 1
|
||||
assert result.exact_dup_in_batch_count == 2
|
||||
assert result.exact_dup_existing_count == 0
|
||||
assert result.semantic_dup_rejected_count == 0
|
||||
assert result.semantic_dup_replaced_count == 0
|
||||
live = (
|
||||
(
|
||||
await db_session.execute(
|
||||
|
|
@ -571,7 +577,7 @@ class TestDocumentCRUD:
|
|||
)
|
||||
|
||||
# Case/whitespace variant of the existing content -> exact match.
|
||||
accepted = await crud.create_documents(
|
||||
result = await crud.create_documents(
|
||||
db_session,
|
||||
[
|
||||
schemas.DocumentCreate(
|
||||
|
|
@ -590,8 +596,13 @@ class TestDocumentCRUD:
|
|||
observed=test_peer2.name,
|
||||
deduplicate=False,
|
||||
)
|
||||
accepted = result.created_documents
|
||||
|
||||
assert len(accepted) == 0
|
||||
assert result.exact_dup_existing_count == 1
|
||||
assert result.exact_dup_in_batch_count == 0
|
||||
assert result.semantic_dup_rejected_count == 0
|
||||
assert result.semantic_dup_replaced_count == 0
|
||||
surviving = (
|
||||
(
|
||||
await db_session.execute(
|
||||
|
|
@ -663,25 +674,27 @@ class TestDocumentCRUD:
|
|||
|
||||
# Incoming exact match claims more accumulated reinforcement (5) than
|
||||
# existing + 1 (3) -> incoming wins.
|
||||
accepted = await crud.create_documents(
|
||||
db_session,
|
||||
[
|
||||
schemas.DocumentCreate(
|
||||
content="user likes coffee ",
|
||||
embedding=[0.9] * 1536,
|
||||
session_name=test_session.name,
|
||||
times_derived=5,
|
||||
metadata=schemas.DocumentMetadata(
|
||||
message_ids=[2],
|
||||
message_created_at="2026-01-02T00:00:00Z",
|
||||
),
|
||||
)
|
||||
],
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
deduplicate=False,
|
||||
)
|
||||
accepted = (
|
||||
await crud.create_documents(
|
||||
db_session,
|
||||
[
|
||||
schemas.DocumentCreate(
|
||||
content="user likes coffee ",
|
||||
embedding=[0.9] * 1536,
|
||||
session_name=test_session.name,
|
||||
times_derived=5,
|
||||
metadata=schemas.DocumentMetadata(
|
||||
message_ids=[2],
|
||||
message_created_at="2026-01-02T00:00:00Z",
|
||||
),
|
||||
)
|
||||
],
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
deduplicate=False,
|
||||
)
|
||||
).created_documents
|
||||
assert len(accepted) == 0
|
||||
live = await _live()
|
||||
assert len(live) == 1
|
||||
|
|
@ -689,24 +702,26 @@ class TestDocumentCRUD:
|
|||
|
||||
# A normal re-derivation (times_derived defaults to 1) now bumps by one:
|
||||
# greatest(existing + 1, 1) -> existing + 1.
|
||||
accepted = await crud.create_documents(
|
||||
db_session,
|
||||
[
|
||||
schemas.DocumentCreate(
|
||||
content="USER LIKES COFFEE",
|
||||
embedding=[0.4] * 1536,
|
||||
session_name=test_session.name,
|
||||
metadata=schemas.DocumentMetadata(
|
||||
message_ids=[3],
|
||||
message_created_at="2026-01-03T00:00:00Z",
|
||||
),
|
||||
)
|
||||
],
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
deduplicate=False,
|
||||
)
|
||||
accepted = (
|
||||
await crud.create_documents(
|
||||
db_session,
|
||||
[
|
||||
schemas.DocumentCreate(
|
||||
content="USER LIKES COFFEE",
|
||||
embedding=[0.4] * 1536,
|
||||
session_name=test_session.name,
|
||||
metadata=schemas.DocumentMetadata(
|
||||
message_ids=[3],
|
||||
message_created_at="2026-01-03T00:00:00Z",
|
||||
),
|
||||
)
|
||||
],
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
deduplicate=False,
|
||||
)
|
||||
).created_documents
|
||||
assert len(accepted) == 0
|
||||
live = await _live()
|
||||
assert len(live) == 1
|
||||
|
|
@ -746,7 +761,7 @@ class TestDocumentCRUD:
|
|||
)
|
||||
|
||||
db_session.autoflush = False
|
||||
accepted = await crud.create_documents(
|
||||
result = await crud.create_documents(
|
||||
db_session,
|
||||
[
|
||||
schemas.DocumentCreate(
|
||||
|
|
@ -775,9 +790,14 @@ class TestDocumentCRUD:
|
|||
observed=test_peer2.name,
|
||||
deduplicate=True,
|
||||
)
|
||||
accepted = result.created_documents
|
||||
|
||||
assert len(accepted) == 1
|
||||
assert accepted[0].content == "User likes coffee and tea"
|
||||
assert result.exact_dup_existing_count == 1
|
||||
assert result.semantic_dup_replaced_count == 1
|
||||
assert result.exact_dup_in_batch_count == 0
|
||||
assert result.semantic_dup_rejected_count == 0
|
||||
|
||||
surviving = (
|
||||
(
|
||||
|
|
@ -797,6 +817,65 @@ class TestDocumentCRUD:
|
|||
assert surviving[0].content == "User likes coffee and tea"
|
||||
assert surviving[0].times_derived == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semantic_dedup_rejected_counts(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
"""A semantically-similar doc with less information than the existing one
|
||||
is rejected, and the rejection is counted on the result."""
|
||||
test_workspace, test_peer = sample_data
|
||||
test_peer2, test_session, _ = await self._setup_test_data(
|
||||
db_session, test_workspace, test_peer
|
||||
)
|
||||
|
||||
await crud.create_documents(
|
||||
db_session,
|
||||
[
|
||||
schemas.DocumentCreate(
|
||||
content="eri loves cats and dogs and birds and snakes",
|
||||
embedding=[0.5] * 1536,
|
||||
session_name=test_session.name,
|
||||
times_derived=1,
|
||||
metadata=schemas.DocumentMetadata(
|
||||
message_ids=[1],
|
||||
message_created_at="2026-01-01T00:00:00Z",
|
||||
),
|
||||
)
|
||||
],
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
)
|
||||
|
||||
# Fewer unique tokens -> existing wins -> new doc is rejected.
|
||||
result = await crud.create_documents(
|
||||
db_session,
|
||||
[
|
||||
schemas.DocumentCreate(
|
||||
content="eri loves cats",
|
||||
embedding=[0.5] * 1536,
|
||||
session_name=test_session.name,
|
||||
times_derived=1,
|
||||
metadata=schemas.DocumentMetadata(
|
||||
message_ids=[2],
|
||||
message_created_at="2026-01-02T00:00:00Z",
|
||||
),
|
||||
)
|
||||
],
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
deduplicate=True,
|
||||
)
|
||||
|
||||
assert len(result.created_documents) == 0
|
||||
assert result.semantic_dup_rejected_count == 1
|
||||
assert result.exact_dup_in_batch_count == 0
|
||||
assert result.exact_dup_existing_count == 0
|
||||
assert result.semantic_dup_replaced_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_document_success(
|
||||
self,
|
||||
|
|
@ -902,15 +981,17 @@ class TestDocumentCRUD:
|
|||
]
|
||||
|
||||
# Create documents
|
||||
count = await crud.create_documents(
|
||||
db_session,
|
||||
documents=doc_schemas,
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
)
|
||||
created_documents = (
|
||||
await crud.create_documents(
|
||||
db_session,
|
||||
documents=doc_schemas,
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
)
|
||||
).created_documents
|
||||
|
||||
assert len(count) == 2
|
||||
assert len(created_documents) == 2
|
||||
|
||||
# Verify documents were created
|
||||
stmt = select(models.Document).where(
|
||||
|
|
@ -924,3 +1005,332 @@ class TestDocumentCRUD:
|
|||
assert len(documents) == 2
|
||||
assert documents[0].content in ["Observation 1", "Observation 2"]
|
||||
assert documents[1].content in ["Observation 1", "Observation 2"]
|
||||
|
||||
|
||||
class TestSessionPurityInvariant:
|
||||
"""Regression tests for the explicit-document session-purity invariant.
|
||||
|
||||
Explicit documents are session-pure records of what was derived from one
|
||||
session's messages (the Scopes copy-by-session model depends on this):
|
||||
|
||||
- an explicit document must always carry a non-null session_name
|
||||
- dedup/merge (exact and semantic) must never cross document levels
|
||||
- dedup/merge must never cross sessions for explicit documents
|
||||
"""
|
||||
|
||||
async def _setup(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
test_workspace: models.Workspace,
|
||||
test_peer: models.Peer,
|
||||
) -> tuple[models.Peer, models.Session, models.Session]:
|
||||
"""Create an observed peer, two sessions, and the collection."""
|
||||
test_peer2 = models.Peer(
|
||||
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
||||
)
|
||||
db_session.add(test_peer2)
|
||||
session_a = models.Session(
|
||||
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
||||
)
|
||||
session_b = models.Session(
|
||||
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
||||
)
|
||||
db_session.add_all([session_a, session_b])
|
||||
await db_session.flush()
|
||||
|
||||
collection = models.Collection(
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
)
|
||||
db_session.add(collection)
|
||||
await db_session.flush()
|
||||
return test_peer2, session_a, session_b
|
||||
|
||||
def _doc(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
session_name: str | None,
|
||||
level: str = "explicit",
|
||||
message_id: int = 1,
|
||||
) -> schemas.DocumentCreate:
|
||||
return schemas.DocumentCreate(
|
||||
content=content,
|
||||
embedding=[0.1] * 1536,
|
||||
session_name=session_name,
|
||||
level=level, # pyright: ignore[reportArgumentType]
|
||||
metadata=schemas.DocumentMetadata(
|
||||
message_ids=[message_id],
|
||||
message_created_at="2026-01-01T00:00:00Z",
|
||||
),
|
||||
)
|
||||
|
||||
async def _live_docs(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
workspace_name: str,
|
||||
observer: str,
|
||||
observed: str,
|
||||
) -> list[models.Document]:
|
||||
return list(
|
||||
(
|
||||
await db_session.execute(
|
||||
select(models.Document).where(
|
||||
models.Document.workspace_name == workspace_name,
|
||||
models.Document.observer == observer,
|
||||
models.Document.observed == observed,
|
||||
models.Document.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_without_session_is_refused(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
"""An explicit document with session_name=None must not be written;
|
||||
derived levels remain allowed without a session (dream output)."""
|
||||
test_workspace, test_peer = sample_data
|
||||
test_peer2, _, _ = await self._setup(db_session, test_workspace, test_peer)
|
||||
|
||||
accepted = (
|
||||
await crud.create_documents(
|
||||
db_session,
|
||||
[
|
||||
self._doc("Global explicit fact", session_name=None),
|
||||
self._doc(
|
||||
"Dream-derived conclusion",
|
||||
session_name=None,
|
||||
level="deductive",
|
||||
message_id=2,
|
||||
),
|
||||
],
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
)
|
||||
).created_documents
|
||||
|
||||
assert [d.content for d in accepted] == ["Dream-derived conclusion"]
|
||||
live = await self._live_docs(
|
||||
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
||||
)
|
||||
assert len(live) == 1
|
||||
assert live[0].level == "deductive"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_dedup_never_merges_explicit_across_sessions(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
"""The same explicit fact stated in two sessions produces two
|
||||
session-pure documents; the other session's row is not reinforced."""
|
||||
test_workspace, test_peer = sample_data
|
||||
test_peer2, session_a, session_b = await self._setup(
|
||||
db_session, test_workspace, test_peer
|
||||
)
|
||||
|
||||
await crud.create_documents(
|
||||
db_session,
|
||||
[self._doc("User likes coffee", session_name=session_a.name)],
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
)
|
||||
accepted = (
|
||||
await crud.create_documents(
|
||||
db_session,
|
||||
[
|
||||
self._doc(
|
||||
"user likes coffee ", session_name=session_b.name, message_id=2
|
||||
)
|
||||
],
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
)
|
||||
).created_documents
|
||||
|
||||
assert len(accepted) == 1
|
||||
live = await self._live_docs(
|
||||
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
||||
)
|
||||
assert len(live) == 2
|
||||
assert {doc.session_name for doc in live} == {session_a.name, session_b.name}
|
||||
assert all(doc.times_derived == 1 for doc in live)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_dedup_never_merges_across_levels(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
"""An explicit fact must not be dropped/reinforced against a derived
|
||||
document that happens to share its content."""
|
||||
test_workspace, test_peer = sample_data
|
||||
test_peer2, session_a, _ = await self._setup(
|
||||
db_session, test_workspace, test_peer
|
||||
)
|
||||
|
||||
await crud.create_documents(
|
||||
db_session,
|
||||
[
|
||||
self._doc(
|
||||
"User likes coffee", session_name=session_a.name, level="deductive"
|
||||
)
|
||||
],
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
)
|
||||
accepted = (
|
||||
await crud.create_documents(
|
||||
db_session,
|
||||
[
|
||||
self._doc(
|
||||
"User likes coffee", session_name=session_a.name, message_id=2
|
||||
)
|
||||
],
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
)
|
||||
).created_documents
|
||||
|
||||
assert len(accepted) == 1
|
||||
live = await self._live_docs(
|
||||
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
||||
)
|
||||
assert len(live) == 2
|
||||
assert {doc.level for doc in live} == {"explicit", "deductive"}
|
||||
assert all(doc.times_derived == 1 for doc in live)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_dedup_still_merges_derived_levels_across_sessions(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
"""Derived levels are consolidations, not session-pure records:
|
||||
cross-session exact dedup still reinforces the existing row."""
|
||||
test_workspace, test_peer = sample_data
|
||||
test_peer2, session_a, session_b = await self._setup(
|
||||
db_session, test_workspace, test_peer
|
||||
)
|
||||
|
||||
await crud.create_documents(
|
||||
db_session,
|
||||
[
|
||||
self._doc(
|
||||
"Probably a morning person",
|
||||
session_name=session_a.name,
|
||||
level="deductive",
|
||||
)
|
||||
],
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
)
|
||||
accepted = (
|
||||
await crud.create_documents(
|
||||
db_session,
|
||||
[
|
||||
self._doc(
|
||||
"probably a morning person",
|
||||
session_name=session_b.name,
|
||||
level="deductive",
|
||||
message_id=2,
|
||||
)
|
||||
],
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
)
|
||||
).created_documents
|
||||
|
||||
assert len(accepted) == 0
|
||||
live = await self._live_docs(
|
||||
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
||||
)
|
||||
assert len(live) == 1
|
||||
assert live[0].times_derived == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semantic_dedup_scoped_to_level_and_session(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
"""is_rejected_duplicate must constrain candidate search to the same
|
||||
level, and to the same session for explicit documents."""
|
||||
test_workspace, test_peer = sample_data
|
||||
test_peer2, session_a, _ = await self._setup(
|
||||
db_session, test_workspace, test_peer
|
||||
)
|
||||
|
||||
explicit_doc = self._doc("User likes coffee", session_name=session_a.name)
|
||||
with patch(
|
||||
"src.crud.document.query_documents", new=AsyncMock(return_value=[])
|
||||
) as mock_query:
|
||||
rejected = await is_rejected_duplicate(
|
||||
db_session,
|
||||
explicit_doc,
|
||||
test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
)
|
||||
assert rejected is SemanticRejectionResult.NOT_DUPLICATE
|
||||
assert mock_query.await_args is not None
|
||||
assert mock_query.await_args.kwargs["filters"] == {
|
||||
"level": "explicit",
|
||||
"session_name": session_a.name,
|
||||
}
|
||||
|
||||
deductive_doc = self._doc(
|
||||
"User likes coffee", session_name=None, level="deductive"
|
||||
)
|
||||
with patch(
|
||||
"src.crud.document.query_documents", new=AsyncMock(return_value=[])
|
||||
) as mock_query:
|
||||
rejected = await is_rejected_duplicate(
|
||||
db_session,
|
||||
deductive_doc,
|
||||
test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
)
|
||||
assert rejected is SemanticRejectionResult.NOT_DUPLICATE
|
||||
assert mock_query.await_args is not None
|
||||
assert mock_query.await_args.kwargs["filters"] == {"level": "deductive"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semantic_dedup_refuses_sessionless_explicit(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
"""A session-less explicit document has no valid merge partner: it is
|
||||
never treated as a duplicate and no candidate search runs."""
|
||||
test_workspace, test_peer = sample_data
|
||||
test_peer2, _, _ = await self._setup(db_session, test_workspace, test_peer)
|
||||
|
||||
doc = self._doc("User likes coffee", session_name=None)
|
||||
with patch(
|
||||
"src.crud.document.query_documents", new=AsyncMock(return_value=[])
|
||||
) as mock_query:
|
||||
rejected = await is_rejected_duplicate(
|
||||
db_session,
|
||||
doc,
|
||||
test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
)
|
||||
assert rejected is SemanticRejectionResult.NOT_DUPLICATE
|
||||
mock_query.assert_not_awaited()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from nanoid import generate as generate_nanoid
|
||||
|
|
@ -8,6 +8,7 @@ from sqlalchemy import func, update
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models
|
||||
from src.crud.document import CreateDocumentsResult
|
||||
from src.crud.representation import RepresentationManager
|
||||
from src.schemas.configuration import (
|
||||
ResolvedConfiguration,
|
||||
|
|
@ -268,7 +269,9 @@ class TestRepresentationManagerSave:
|
|||
patch.object(
|
||||
manager,
|
||||
"_save_representation_internal",
|
||||
new=AsyncMock(return_value=1),
|
||||
new=AsyncMock(
|
||||
return_value=CreateDocumentsResult(created_documents=[MagicMock()])
|
||||
),
|
||||
) as mock_save,
|
||||
):
|
||||
saved = await manager.save_representation(
|
||||
|
|
@ -279,7 +282,7 @@ class TestRepresentationManagerSave:
|
|||
message_level_configuration=_resolved_config(),
|
||||
)
|
||||
|
||||
assert saved == 1
|
||||
assert len(saved.created_documents) == 1
|
||||
mock_embed.assert_awaited_once_with(["useful observation"])
|
||||
saved_observations = _saved_observations(mock_save)
|
||||
assert len(saved_observations) == 1
|
||||
|
|
@ -322,7 +325,9 @@ class TestRepresentationManagerSave:
|
|||
patch.object(
|
||||
manager,
|
||||
"_save_representation_internal",
|
||||
new=AsyncMock(return_value=1),
|
||||
new=AsyncMock(
|
||||
return_value=CreateDocumentsResult(created_documents=[MagicMock()])
|
||||
),
|
||||
) as mock_save,
|
||||
):
|
||||
saved = await manager.save_representation(
|
||||
|
|
@ -333,7 +338,7 @@ class TestRepresentationManagerSave:
|
|||
message_level_configuration=_resolved_config(),
|
||||
)
|
||||
|
||||
assert saved == 1
|
||||
assert len(saved.created_documents) == 1
|
||||
mock_embed.assert_awaited_once_with(["inferred conclusion"])
|
||||
saved_observations = _saved_observations(mock_save)
|
||||
assert len(saved_observations) == 1
|
||||
|
|
@ -384,6 +389,6 @@ class TestRepresentationManagerSave:
|
|||
message_level_configuration=_resolved_config(),
|
||||
)
|
||||
|
||||
assert saved == 0
|
||||
assert len(saved.created_documents) == 0
|
||||
mock_embed.assert_not_awaited()
|
||||
mock_save.assert_not_awaited()
|
||||
|
|
|
|||
|
|
@ -5,11 +5,15 @@ from unittest.mock import AsyncMock, Mock, patch
|
|||
|
||||
import pytest
|
||||
|
||||
from src import models
|
||||
from src import crud, models
|
||||
from src.config import settings
|
||||
from src.deriver.deriver import process_representation_tasks_batch
|
||||
from src.llm import HonchoLLMCallResponse
|
||||
from src.utils.representation import PromptRepresentation, Representation
|
||||
from src.utils.representation import (
|
||||
ExplicitObservationBase,
|
||||
PromptRepresentation,
|
||||
Representation,
|
||||
)
|
||||
from src.utils.work_unit import construct_work_unit_key, parse_work_unit_key
|
||||
|
||||
|
||||
|
|
@ -316,6 +320,78 @@ class TestDeriverProcessing:
|
|||
for record in caplog.records
|
||||
)
|
||||
|
||||
async def test_emits_dedup_counts_summed_across_observers(self) -> None:
|
||||
"""RepresentationCompletedEvent dedup counts must be the sum across all
|
||||
observer collections, not the last observer's result."""
|
||||
message = Mock(
|
||||
id=1,
|
||||
public_id="msg_dedup",
|
||||
session_name="session-1",
|
||||
workspace_name="workspace-1",
|
||||
peer_name="alice",
|
||||
content="hello",
|
||||
token_count=5,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
configuration = Mock()
|
||||
configuration.reasoning.enabled = True
|
||||
|
||||
mock_response = HonchoLLMCallResponse(
|
||||
content=PromptRepresentation(
|
||||
explicit=[ExplicitObservationBase(content="alice says hello")]
|
||||
),
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
finish_reasons=["STOP"],
|
||||
)
|
||||
|
||||
manager = Mock()
|
||||
manager.save_representation = AsyncMock(
|
||||
side_effect=[
|
||||
crud.CreateDocumentsResult(
|
||||
exact_dup_in_batch_count=1,
|
||||
exact_dup_existing_count=2,
|
||||
semantic_dup_rejected_count=3,
|
||||
semantic_dup_replaced_count=4,
|
||||
),
|
||||
crud.CreateDocumentsResult(
|
||||
exact_dup_in_batch_count=10,
|
||||
exact_dup_existing_count=20,
|
||||
semantic_dup_rejected_count=30,
|
||||
semantic_dup_replaced_count=40,
|
||||
),
|
||||
]
|
||||
)
|
||||
emitted: list[Any] = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"src.deriver.deriver.honcho_llm_call",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
),
|
||||
patch(
|
||||
"src.deriver.deriver.RepresentationManager",
|
||||
return_value=manager,
|
||||
),
|
||||
patch("src.deriver.deriver.emit", side_effect=emitted.append),
|
||||
):
|
||||
await process_representation_tasks_batch(
|
||||
messages=[message],
|
||||
message_level_configuration=configuration,
|
||||
observers=["bob", "carol"],
|
||||
observed="alice",
|
||||
queue_item_message_ids=[1],
|
||||
)
|
||||
|
||||
assert len(emitted) == 1
|
||||
event = emitted[0]
|
||||
assert event.observer_count == 2
|
||||
assert event.exact_dup_in_batch_count == 11
|
||||
assert event.exact_dup_existing_count == 22
|
||||
assert event.semantic_dup_rejected_count == 33
|
||||
assert event.semantic_dup_replaced_count == 44
|
||||
|
||||
|
||||
class TestBackwardsCompatibility:
|
||||
"""Test backwards compatibility for queue items created before the deduplication change."""
|
||||
|
|
|
|||
|
|
@ -9,12 +9,19 @@ creates committed fixture rows and asserts on the result via the provided sessio
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import BackgroundTasks
|
||||
from nanoid import generate as generate_nanoid
|
||||
from prometheus_client import REGISTRY
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||
|
||||
from src import models
|
||||
from src.reconciler.embed_now import embed_messages_now, reset_embed_semaphore
|
||||
from src.config import settings
|
||||
from src.reconciler.embed_now import (
|
||||
embed_messages_now,
|
||||
embed_task_gate,
|
||||
reset_embed_semaphore,
|
||||
)
|
||||
from src.vector_store import VectorStore
|
||||
|
||||
|
||||
|
|
@ -65,10 +72,87 @@ async def _create_message_with_pending_chunks(
|
|||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_semaphore_fixture():
|
||||
"""Rebuild the module semaphore per test so it binds to the active loop."""
|
||||
"""Rebuild the module semaphore per test so it binds to the active loop,
|
||||
and clear the admission gate's in-flight count."""
|
||||
reset_embed_semaphore()
|
||||
embed_task_gate.in_flight = 0
|
||||
yield
|
||||
reset_embed_semaphore()
|
||||
embed_task_gate.in_flight = 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestEmbedTaskGate:
|
||||
"""Admission gate for immediate-embed background tasks
|
||||
(EMBEDDING.MAX_PENDING_EMBED_TASKS)."""
|
||||
|
||||
async def test_admits_under_cap_and_releases_slot(self) -> None:
|
||||
"""Under the cap, the task is scheduled; running it embeds the given ids
|
||||
and releases the slot."""
|
||||
tasks = BackgroundTasks()
|
||||
with (
|
||||
patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 2),
|
||||
patch(
|
||||
"src.reconciler.embed_now.embed_messages_now", new=AsyncMock()
|
||||
) as mock_embed,
|
||||
):
|
||||
assert embed_task_gate.try_schedule(tasks, ["msg_1"]) is True
|
||||
assert embed_task_gate.in_flight == 1
|
||||
await tasks()
|
||||
mock_embed.assert_awaited_once_with(["msg_1"])
|
||||
assert embed_task_gate.in_flight == 0
|
||||
|
||||
async def test_rejects_at_cap_without_scheduling(self) -> None:
|
||||
"""At the cap, nothing is scheduled and False is returned."""
|
||||
tasks = BackgroundTasks()
|
||||
with patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 1):
|
||||
assert embed_task_gate.try_schedule(tasks, ["msg_1"]) is True
|
||||
assert embed_task_gate.try_schedule(tasks, ["msg_2"]) is False
|
||||
assert len(tasks.tasks) == 1
|
||||
assert embed_task_gate.in_flight == 1
|
||||
|
||||
async def test_slot_released_when_task_raises(self) -> None:
|
||||
"""A failing task still releases its slot (finally path)."""
|
||||
tasks = BackgroundTasks()
|
||||
with (
|
||||
patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 1),
|
||||
patch(
|
||||
"src.reconciler.embed_now.embed_messages_now",
|
||||
new=AsyncMock(side_effect=RuntimeError("boom")),
|
||||
),
|
||||
):
|
||||
assert embed_task_gate.try_schedule(tasks, ["msg_1"]) is True
|
||||
with pytest.raises(RuntimeError):
|
||||
await tasks()
|
||||
assert embed_task_gate.in_flight == 0
|
||||
|
||||
async def test_gauge_mirrors_in_flight_count(self) -> None:
|
||||
"""With metrics enabled, the Prometheus gauge tracks the gate's
|
||||
in-flight count through schedule and release."""
|
||||
|
||||
def gauge_value() -> float | None:
|
||||
return REGISTRY.get_sample_value(
|
||||
"embed_now_tasks_in_flight", {"namespace": "test"}
|
||||
)
|
||||
|
||||
tasks = BackgroundTasks()
|
||||
with (
|
||||
patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 2),
|
||||
patch.object(settings.METRICS, "ENABLED", True),
|
||||
patch.object(settings.METRICS, "NAMESPACE", "test"),
|
||||
patch("src.reconciler.embed_now.embed_messages_now", new=AsyncMock()),
|
||||
):
|
||||
assert embed_task_gate.try_schedule(tasks, ["msg_1"]) is True
|
||||
assert gauge_value() == 1
|
||||
await tasks()
|
||||
assert gauge_value() == 0
|
||||
|
||||
async def test_zero_cap_disables_fast_path(self) -> None:
|
||||
"""MAX_PENDING_EMBED_TASKS=0 rejects every schedule attempt."""
|
||||
tasks = BackgroundTasks()
|
||||
with patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 0):
|
||||
assert embed_task_gate.try_schedule(tasks, ["msg_1"]) is False
|
||||
assert len(tasks.tasks) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -359,7 +359,7 @@ class TestQueueProcessing:
|
|||
peer = peers[0]
|
||||
|
||||
# Create messages with token counts that exceed batch limit
|
||||
limit = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS
|
||||
limit = settings.DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS
|
||||
token_counts = [limit // 2, limit // 2, limit // 2]
|
||||
|
||||
# Create and save messages to the database first
|
||||
|
|
@ -479,7 +479,7 @@ class TestQueueProcessing:
|
|||
|
||||
session, peers = sample_session_with_peers
|
||||
peer = peers[0]
|
||||
cap = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS
|
||||
cap = settings.DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS
|
||||
|
||||
# M1 + M2 sum to exactly the cap; M3 pushes over it. After SQL,
|
||||
# messages_context = [M1, M2]; the cap is genuinely binding *on the
|
||||
|
|
@ -581,7 +581,7 @@ class TestQueueProcessing:
|
|||
session, peers = sample_session_with_peers
|
||||
peer_a = peers[0]
|
||||
peer_b = peers[1] if len(peers) > 1 else peers[0]
|
||||
cap = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS
|
||||
cap = settings.DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS
|
||||
|
||||
# Layout: 4 messages, ordered.
|
||||
# M1 (peer_a, queue, 200)
|
||||
|
|
@ -744,7 +744,9 @@ class TestQueueProcessing:
|
|||
qm = QueueManager()
|
||||
|
||||
# Mock the token limit to 2000 for this test
|
||||
with patch.object(settings.DERIVER, "REPRESENTATION_BATCH_MAX_TOKENS", 2000):
|
||||
with patch.object(
|
||||
settings.DERIVER, "REPRESENTATION_BATCH_TARGET_INPUT_TOKENS", 2000
|
||||
):
|
||||
# Test alice's work unit
|
||||
alice_work_unit_key = alice_queue_items[0].work_unit_key
|
||||
alice_aqs = models.ActiveQueueSession(work_unit_key=alice_work_unit_key)
|
||||
|
|
@ -919,7 +921,9 @@ class TestQueueProcessing:
|
|||
qm = QueueManager()
|
||||
|
||||
# Mock the token limit to 1500 for this test
|
||||
with patch.object(settings.DERIVER, "REPRESENTATION_BATCH_MAX_TOKENS", 1500):
|
||||
with patch.object(
|
||||
settings.DERIVER, "REPRESENTATION_BATCH_TARGET_INPUT_TOKENS", 1500
|
||||
):
|
||||
# Test alice's work unit
|
||||
# With per-work-unit anchoring + preceding context:
|
||||
# Alice starts at message 3, includes preceding message 2 (steve) for context
|
||||
|
|
@ -1133,7 +1137,7 @@ class TestQueueProcessing:
|
|||
peer = peers[0]
|
||||
|
||||
# Create messages where first message exceeds the batch limit
|
||||
limit = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS
|
||||
limit = settings.DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS
|
||||
token_counts = [limit + 1000, 100, 200] # First message way over limit
|
||||
|
||||
# Create and save messages to the database first
|
||||
|
|
@ -1250,7 +1254,7 @@ class TestQueueProcessing:
|
|||
peer = peers[0]
|
||||
|
||||
# Create messages that test the exact boundary
|
||||
limit = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS
|
||||
limit = settings.DERIVER.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS
|
||||
token_counts = [
|
||||
limit // 2,
|
||||
limit // 2,
|
||||
|
|
@ -1381,7 +1385,7 @@ class TestQueueProcessing:
|
|||
peer = peers[0]
|
||||
|
||||
# Create messages with tokens BELOW the threshold
|
||||
limit = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS
|
||||
limit = settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS
|
||||
token_counts = [100, 100, 100] # Total 300, way below 4096
|
||||
|
||||
messages: list[models.Message] = []
|
||||
|
|
@ -1652,7 +1656,7 @@ class TestQueueProcessing:
|
|||
session, peers = sample_session_with_peers
|
||||
peer = peers[0]
|
||||
|
||||
limit = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS
|
||||
limit = settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS
|
||||
|
||||
# Create a single message that exceeds the threshold
|
||||
message = models.Message(
|
||||
|
|
@ -1758,7 +1762,7 @@ class TestQueueProcessing:
|
|||
session, peers = sample_session_with_peers
|
||||
peer = peers[0]
|
||||
|
||||
limit = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS
|
||||
limit = settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS
|
||||
|
||||
# Create messages that sum to exactly the threshold
|
||||
token_counts = [limit // 2, limit // 2]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
"""Tests for response_model threading through the DialecticAgent."""
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.dialectic.core import DialecticAgent
|
||||
from src.llm import (
|
||||
HonchoLLMCallResponse,
|
||||
HonchoLLMCallStreamChunk,
|
||||
StreamingResponseWithMetadata,
|
||||
)
|
||||
|
||||
|
||||
class FoodPreferences(BaseModel):
|
||||
favorite: str
|
||||
confidence: float
|
||||
|
||||
|
||||
def _make_agent() -> DialecticAgent:
|
||||
return DialecticAgent(
|
||||
workspace_name="workspace",
|
||||
session_name="session",
|
||||
observer="observer",
|
||||
observed="observed",
|
||||
reasoning_level="low",
|
||||
)
|
||||
|
||||
|
||||
def _patches(mock_llm_call: AsyncMock):
|
||||
return (
|
||||
patch.object(
|
||||
DialecticAgent,
|
||||
"_prepare_query",
|
||||
new=AsyncMock(
|
||||
return_value=(AsyncMock(), "task", "run", time.perf_counter())
|
||||
),
|
||||
),
|
||||
patch.object(DialecticAgent, "_log_response_metrics"),
|
||||
patch("src.dialectic.core.honcho_llm_call", new=mock_llm_call),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_answer_passes_response_model_and_serializes() -> None:
|
||||
"""answer() threads response_model to the LLM call and serializes the
|
||||
parsed model instance back to a JSON string."""
|
||||
agent = _make_agent()
|
||||
parsed = FoodPreferences(favorite="sushi", confidence=0.9)
|
||||
mock_llm_call = AsyncMock(
|
||||
return_value=HonchoLLMCallResponse(
|
||||
content=parsed,
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
finish_reasons=["stop"],
|
||||
)
|
||||
)
|
||||
|
||||
p1, p2, p3 = _patches(mock_llm_call)
|
||||
with p1, p2, p3:
|
||||
result = await agent.answer("query", response_model=FoodPreferences)
|
||||
|
||||
kwargs = mock_llm_call.await_args.kwargs # pyright: ignore
|
||||
assert kwargs["response_model"] is FoodPreferences
|
||||
assert isinstance(result, str)
|
||||
assert FoodPreferences.model_validate_json(result) == parsed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_answer_without_response_model_returns_plain_text() -> None:
|
||||
agent = _make_agent()
|
||||
mock_llm_call = AsyncMock(
|
||||
return_value=HonchoLLMCallResponse(
|
||||
content="plain answer",
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
finish_reasons=["stop"],
|
||||
)
|
||||
)
|
||||
|
||||
p1, p2, p3 = _patches(mock_llm_call)
|
||||
with p1, p2, p3:
|
||||
result = await agent.answer("query")
|
||||
|
||||
assert result == "plain answer"
|
||||
assert mock_llm_call.await_args.kwargs["response_model"] is None # pyright: ignore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_answer_stream_passes_response_model() -> None:
|
||||
"""answer_stream() threads response_model; chunks stay raw text."""
|
||||
agent = _make_agent()
|
||||
|
||||
async def _stream():
|
||||
yield HonchoLLMCallStreamChunk(content='{"favorite":"sushi",')
|
||||
yield HonchoLLMCallStreamChunk(content='"confidence":0.9}')
|
||||
yield HonchoLLMCallStreamChunk(content="", is_done=True)
|
||||
|
||||
mock_llm_call = AsyncMock(
|
||||
return_value=StreamingResponseWithMetadata(
|
||||
_stream(),
|
||||
tool_calls_made=[],
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
cache_creation_input_tokens=0,
|
||||
cache_read_input_tokens=0,
|
||||
iterations=1,
|
||||
)
|
||||
)
|
||||
|
||||
p1, p2, p3 = _patches(mock_llm_call)
|
||||
with p1, p2, p3:
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in agent.answer_stream(
|
||||
"query", response_model=FoodPreferences
|
||||
)
|
||||
]
|
||||
|
||||
kwargs = mock_llm_call.await_args.kwargs # pyright: ignore
|
||||
assert kwargs["response_model"] is FoodPreferences
|
||||
assert kwargs["stream_final_only"] is True
|
||||
accumulated = "".join(chunks)
|
||||
assert FoodPreferences.model_validate_json(accumulated).favorite == "sushi"
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
"""Tests for the card_refresh dream type (DEV-2000, Scopes RFC prerequisite).
|
||||
|
||||
Covers:
|
||||
- queue plumbing: payload roundtrip, work-unit key isolation from omni,
|
||||
enqueue alongside a pending omni dream
|
||||
- process_dream dispatch of DreamType.CARD_REFRESH (and that it does NOT
|
||||
advance the omni dream guard pair)
|
||||
- specialist tool restriction (no observation-mutating tools)
|
||||
- the low tool-iteration cap
|
||||
- rebuild mode omitting the prior peer card from the prompt
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models
|
||||
from src.config import settings
|
||||
from src.deriver.enqueue import enqueue_dream
|
||||
from src.dreamer.orchestrator import DreamResult, process_dream
|
||||
from src.dreamer.specialists import CardRefreshSpecialist
|
||||
from src.llm import HonchoLLMCallResponse
|
||||
from src.schemas import DreamType
|
||||
from src.utils.queue_payload import DreamPayload, create_dream_payload
|
||||
from src.utils.work_unit import construct_work_unit_key, parse_work_unit_key
|
||||
|
||||
OBSERVATION_MUTATION_TOOLS = {
|
||||
"create_observations",
|
||||
"create_observations_deductive",
|
||||
"create_observations_inductive",
|
||||
"delete_observations",
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seeded_collection(
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
) -> models.Collection:
|
||||
"""Create a Collection with an empty dream metadata dict."""
|
||||
workspace, peer = sample_data
|
||||
collection = models.Collection(
|
||||
observer=peer.name,
|
||||
observed=peer.name,
|
||||
workspace_name=workspace.name,
|
||||
internal_metadata={},
|
||||
)
|
||||
db_session.add(collection)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(collection)
|
||||
return collection
|
||||
|
||||
|
||||
def _make_card_refresh_result() -> DreamResult:
|
||||
return DreamResult(
|
||||
run_id="test_run_card",
|
||||
specialists_run=["card_refresh"],
|
||||
deduction_success=True,
|
||||
induction_success=False,
|
||||
surprisal_enabled=False,
|
||||
surprisal_conclusion_count=0,
|
||||
total_iterations=2,
|
||||
total_duration_ms=42.0,
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
)
|
||||
|
||||
|
||||
class TestQueuePlumbing:
|
||||
def test_payload_roundtrip_carries_rebuild(self):
|
||||
payload_dict = create_dream_payload(
|
||||
DreamType.CARD_REFRESH,
|
||||
observer="alice",
|
||||
observed="bob",
|
||||
rebuild=True,
|
||||
)
|
||||
validated = DreamPayload(**payload_dict)
|
||||
assert validated.dream_type == DreamType.CARD_REFRESH
|
||||
assert validated.rebuild is True
|
||||
|
||||
# Default is False, including for older payloads missing the field.
|
||||
assert (
|
||||
DreamPayload(dream_type=DreamType.OMNI, observer="a", observed="b").rebuild
|
||||
is False
|
||||
)
|
||||
|
||||
def test_work_unit_key_does_not_collide_with_omni(self):
|
||||
base = {"task_type": "dream", "observer": "alice", "observed": "bob"}
|
||||
omni_key = construct_work_unit_key("ws", {**base, "dream_type": "omni"})
|
||||
card_key = construct_work_unit_key("ws", {**base, "dream_type": "card_refresh"})
|
||||
|
||||
assert omni_key != card_key
|
||||
parsed = parse_work_unit_key(card_key)
|
||||
assert parsed.task_type == "dream"
|
||||
assert parsed.dream_type == "card_refresh"
|
||||
assert parsed.observer == "alice"
|
||||
assert parsed.observed == "bob"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_alongside_pending_omni(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
seeded_collection: models.Collection,
|
||||
):
|
||||
"""A pending omni dream must not dedupe away a card_refresh enqueue —
|
||||
the work-unit keys differ by dream type."""
|
||||
await enqueue_dream(
|
||||
seeded_collection.workspace_name,
|
||||
observer=seeded_collection.observer,
|
||||
observed=seeded_collection.observed,
|
||||
dream_type=DreamType.OMNI,
|
||||
)
|
||||
await enqueue_dream(
|
||||
seeded_collection.workspace_name,
|
||||
observer=seeded_collection.observer,
|
||||
observed=seeded_collection.observed,
|
||||
dream_type=DreamType.CARD_REFRESH,
|
||||
rebuild=True,
|
||||
)
|
||||
|
||||
items = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(models.QueueItem).where(
|
||||
models.QueueItem.workspace_name
|
||||
== seeded_collection.workspace_name,
|
||||
models.QueueItem.task_type == "dream",
|
||||
models.QueueItem.processed == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(items) == 2
|
||||
dream_types = {item.payload["dream_type"] for item in items}
|
||||
assert dream_types == {"omni", "card_refresh"}
|
||||
card_item = next(
|
||||
item for item in items if item.payload["dream_type"] == "card_refresh"
|
||||
)
|
||||
assert card_item.payload["rebuild"] is True
|
||||
|
||||
|
||||
class TestProcessDreamDispatch:
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatches_card_refresh(
|
||||
self,
|
||||
seeded_collection: models.Collection,
|
||||
):
|
||||
payload = DreamPayload(
|
||||
dream_type=DreamType.CARD_REFRESH,
|
||||
observer=seeded_collection.observer,
|
||||
observed=seeded_collection.observed,
|
||||
rebuild=True,
|
||||
trigger_reason="manual",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.dreamer.orchestrator.run_card_refresh_dream",
|
||||
new=AsyncMock(return_value=_make_card_refresh_result()),
|
||||
) as mock_run:
|
||||
await process_dream(payload, seeded_collection.workspace_name)
|
||||
|
||||
assert mock_run.await_args is not None
|
||||
kwargs = mock_run.await_args.kwargs
|
||||
assert kwargs["workspace_name"] == seeded_collection.workspace_name
|
||||
assert kwargs["observer"] == seeded_collection.observer
|
||||
assert kwargs["observed"] == seeded_collection.observed
|
||||
assert kwargs["rebuild"] is True
|
||||
assert kwargs["dream_type"] == "card_refresh"
|
||||
assert kwargs["trigger_reason"] == "manual"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_card_refresh_does_not_advance_dream_guard(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
seeded_collection: models.Collection,
|
||||
):
|
||||
"""The omni guard pair (last_dream_at / last_dream_document_count)
|
||||
must not move on a card refresh — it would delay real consolidation."""
|
||||
payload = DreamPayload(
|
||||
dream_type=DreamType.CARD_REFRESH,
|
||||
observer=seeded_collection.observer,
|
||||
observed=seeded_collection.observed,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.dreamer.orchestrator.run_card_refresh_dream",
|
||||
new=AsyncMock(return_value=_make_card_refresh_result()),
|
||||
):
|
||||
await process_dream(payload, seeded_collection.workspace_name)
|
||||
|
||||
await db_session.refresh(seeded_collection)
|
||||
dream_meta: dict[str, Any] = seeded_collection.internal_metadata.get(
|
||||
"dream", {}
|
||||
)
|
||||
assert "last_dream_at" not in dream_meta
|
||||
assert "last_dream_document_count" not in dream_meta
|
||||
|
||||
|
||||
class TestCardRefreshSpecialist:
|
||||
def test_tools_exclude_observation_mutation(self):
|
||||
for rebuild in (False, True):
|
||||
specialist = CardRefreshSpecialist(rebuild=rebuild)
|
||||
tool_names = {t["name"] for t in specialist.get_tools()}
|
||||
assert tool_names == {
|
||||
"get_recent_observations",
|
||||
"search_memory",
|
||||
"update_peer_card",
|
||||
}
|
||||
assert not tool_names & OBSERVATION_MUTATION_TOOLS
|
||||
|
||||
def test_tools_without_peer_card_strip_update(self):
|
||||
specialist = CardRefreshSpecialist()
|
||||
tool_names = {t["name"] for t in specialist.get_tools(peer_card_enabled=False)}
|
||||
assert "update_peer_card" not in tool_names
|
||||
assert not tool_names & OBSERVATION_MUTATION_TOOLS
|
||||
|
||||
def test_low_iteration_cap(self, monkeypatch: pytest.MonkeyPatch):
|
||||
specialist = CardRefreshSpecialist()
|
||||
assert specialist.get_max_iterations() == min(
|
||||
6, settings.DREAM.MAX_TOOL_ITERATIONS
|
||||
)
|
||||
|
||||
monkeypatch.setattr(settings.DREAM, "MAX_TOOL_ITERATIONS", 4)
|
||||
assert specialist.get_max_iterations() == 4
|
||||
|
||||
monkeypatch.setattr(settings.DREAM, "MAX_TOOL_ITERATIONS", 30)
|
||||
assert specialist.get_max_iterations() == 6
|
||||
|
||||
def test_rebuild_flag_controls_card_injection(self):
|
||||
assert CardRefreshSpecialist(rebuild=False).inject_peer_card is True
|
||||
assert CardRefreshSpecialist(rebuild=True).inject_peer_card is False
|
||||
|
||||
def test_rebuild_prompts_instruct_observation_only_build(self):
|
||||
specialist = CardRefreshSpecialist(rebuild=True)
|
||||
system_prompt = specialist.build_system_prompt("alice")
|
||||
assert "REBUILD MODE" in system_prompt
|
||||
assert "solely from the observations" in system_prompt
|
||||
|
||||
user_prompt = specialist.build_user_prompt("alice", hints=None, peer_card=None)
|
||||
assert "Rebuild the peer card" in user_prompt
|
||||
assert "CURRENT PEER CARD" not in user_prompt
|
||||
|
||||
async def _run_specialist(
|
||||
self, specialist: CardRefreshSpecialist, stored_card: list[str]
|
||||
) -> tuple[AsyncMock, AsyncMock]:
|
||||
"""Run the specialist with a fully mocked LLM layer; returns the
|
||||
(get_peer_card, honcho_llm_call) mocks for inspection."""
|
||||
mock_response = HonchoLLMCallResponse(
|
||||
content="done",
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
finish_reasons=["stop"],
|
||||
)
|
||||
mock_get_peer_card = AsyncMock(return_value=stored_card)
|
||||
mock_llm_call = AsyncMock(return_value=mock_response)
|
||||
|
||||
with (
|
||||
patch("src.dreamer.specialists.crud.get_peer", new=AsyncMock()),
|
||||
patch(
|
||||
"src.dreamer.specialists.crud.get_peer_card",
|
||||
new=mock_get_peer_card,
|
||||
),
|
||||
patch(
|
||||
"src.dreamer.specialists.create_tool_executor",
|
||||
new=AsyncMock(return_value=AsyncMock()),
|
||||
),
|
||||
patch(
|
||||
"src.dreamer.specialists.honcho_llm_call",
|
||||
new=mock_llm_call,
|
||||
),
|
||||
):
|
||||
result = await specialist.run(
|
||||
workspace_name="workspace",
|
||||
observer="alice",
|
||||
observed="alice",
|
||||
session_name=None,
|
||||
)
|
||||
assert result.success is True
|
||||
return mock_get_peer_card, mock_llm_call
|
||||
|
||||
# Sentinel card entry that cannot collide with the prompt's own examples
|
||||
# (the shared PEER CARD section contains e.g. "IDENTITY: Name: Alice").
|
||||
STORED_CARD: list[str] = [
|
||||
"IDENTITY: Name: Zorblax-Prime",
|
||||
"ATTRIBUTE: Location: Ganymede",
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_mode_injects_existing_card(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(settings.METRICS, "ENABLED", False)
|
||||
|
||||
mock_get_peer_card, mock_llm_call = await self._run_specialist(
|
||||
CardRefreshSpecialist(rebuild=False), self.STORED_CARD
|
||||
)
|
||||
|
||||
mock_get_peer_card.assert_awaited_once()
|
||||
assert mock_llm_call.await_args is not None
|
||||
kwargs = mock_llm_call.await_args.kwargs
|
||||
user_message = kwargs["messages"][1]["content"]
|
||||
assert "IDENTITY: Name: Zorblax-Prime" in user_message
|
||||
assert "CURRENT PEER CARD" in user_message
|
||||
# Restricted tool offering and low iteration cap reach the LLM call.
|
||||
tool_names = {t["name"] for t in kwargs["tools"]}
|
||||
assert not tool_names & OBSERVATION_MUTATION_TOOLS
|
||||
assert kwargs["max_tool_iterations"] == min(
|
||||
6, settings.DREAM.MAX_TOOL_ITERATIONS
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebuild_mode_omits_existing_card(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(settings.METRICS, "ENABLED", False)
|
||||
|
||||
mock_get_peer_card, mock_llm_call = await self._run_specialist(
|
||||
CardRefreshSpecialist(rebuild=True), self.STORED_CARD
|
||||
)
|
||||
|
||||
# The stored card is never even fetched, let alone injected.
|
||||
mock_get_peer_card.assert_not_awaited()
|
||||
assert mock_llm_call.await_args is not None
|
||||
kwargs = mock_llm_call.await_args.kwargs
|
||||
for message in kwargs["messages"]:
|
||||
assert "IDENTITY: Name: Zorblax-Prime" not in message["content"]
|
||||
# No CURRENT PEER CARD block in the user prompt (the system prompt's
|
||||
# shared taxonomy section legitimately mentions the phrase).
|
||||
assert "CURRENT PEER CARD" not in kwargs["messages"][1]["content"]
|
||||
|
|
@ -93,7 +93,11 @@ async def test_live_openai_gpt5_reasoning_structured_output_and_prefix_caching(
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
require_provider_key(model_spec)
|
||||
backend, config = make_backend(model_spec, reasoning_effort="minimal")
|
||||
# Only the original gpt-5 generation accepts 'minimal'; gpt-5.1+ replaced
|
||||
# it with 'none'. 'low' is valid everywhere else, including future models.
|
||||
is_base_gpt5 = model_spec.model == "gpt-5" or model_spec.model.startswith("gpt-5-")
|
||||
reasoning_effort = "minimal" if is_base_gpt5 else "low"
|
||||
backend, config = make_backend(model_spec, reasoning_effort=reasoning_effort)
|
||||
parse_calls = wrap_async_method(
|
||||
monkeypatch,
|
||||
backend._client.chat.completions,
|
||||
|
|
@ -136,7 +140,7 @@ async def test_live_openai_gpt5_reasoning_structured_output_and_prefix_caching(
|
|||
assert second.cache_read_input_tokens > 0
|
||||
|
||||
assert parse_calls[0]["kwargs"]["response_format"] is StructuredLiveResponse
|
||||
assert parse_calls[0]["kwargs"]["reasoning_effort"] == "minimal"
|
||||
assert parse_calls[0]["kwargs"]["reasoning_effort"] == reasoning_effort
|
||||
assert "max_completion_tokens" in parse_calls[0]["kwargs"]
|
||||
assert "max_tokens" not in parse_calls[0]["kwargs"]
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
"""Live coverage for union-bearing structured output without tools.
|
||||
|
||||
The dialectic's final synthesis call carries response_format but tools=None
|
||||
(see src/llm/tool_loop.py), and the model class is created dynamically from a
|
||||
caller-supplied JSON Schema (src/utils/schema_conversion.py). That call shape
|
||||
differs from test_live_tools_structured_output.py in one important way: with
|
||||
no tools attached, the Gemini backend uses its NATIVE response_schema config
|
||||
instead of injecting a schema instruction — and Gemini's response_schema
|
||||
historically rejected anyOf. These tests drive a schema that exercises every
|
||||
union-ish construct the converter supports (anyOf with null, a type list, an
|
||||
enum, a $defs reference) through that exact call shape on all three
|
||||
providers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from src.exceptions import LLMError
|
||||
from src.llm.backend import CompletionResult
|
||||
from src.llm.request_builder import execute_completion
|
||||
from src.llm.structured_output import StructuredOutputError
|
||||
from src.utils.schema_conversion import json_response_schema_to_pydantic
|
||||
|
||||
from .conftest import make_backend, require_provider_key, wrap_async_method
|
||||
from .model_matrix import LiveModelSpec, get_live_model_specs
|
||||
|
||||
pytestmark = [pytest.mark.live_llm]
|
||||
|
||||
_USER_FACTS_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"favorite_food": {"$ref": "#/$defs/Food"},
|
||||
"sentiment": {"enum": ["loves", "likes", "neutral", "dislikes", "hates"]},
|
||||
"years_vegetarian": {"type": ["integer", "null"]},
|
||||
"salient_fact": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "One short salient fact about the user, or null",
|
||||
},
|
||||
},
|
||||
"required": ["favorite_food", "sentiment", "years_vegetarian"],
|
||||
"$defs": {
|
||||
"Food": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_PROMPT = (
|
||||
"The user said: 'I love sushi. I've been vegetarian for 3 years.' "
|
||||
"Report the user's favorite food, their sentiment toward it, how many "
|
||||
"years they have been vegetarian, and optionally one salient fact."
|
||||
)
|
||||
|
||||
|
||||
async def run_union_structured_flow(backend: Any, config: Any) -> CompletionResult:
|
||||
"""One no-tools turn that must return a schema-conforming answer.
|
||||
|
||||
A fresh model class is created per call, matching how the dialectic
|
||||
converts the caller's schema on every request. Retries mirror
|
||||
test_live_tools_structured_output.py: empty/unparseable candidates are
|
||||
absorbed by the executor's retry layer in production, but these tests
|
||||
call the backend directly.
|
||||
"""
|
||||
response_model = json_response_schema_to_pydantic(
|
||||
_USER_FACTS_SCHEMA, model_name="UserFactsReport"
|
||||
)
|
||||
|
||||
result: CompletionResult | None = None
|
||||
last_error: Exception | None = None
|
||||
for _ in range(3):
|
||||
try:
|
||||
result = await execute_completion(
|
||||
backend,
|
||||
config,
|
||||
messages=[{"role": "user", "content": _PROMPT}],
|
||||
max_tokens=4096,
|
||||
response_format=response_model,
|
||||
)
|
||||
except (ValidationError, LLMError, StructuredOutputError) as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
break
|
||||
if result is None:
|
||||
raise AssertionError(
|
||||
"union structured turn failed on all attempts"
|
||||
) from last_error
|
||||
|
||||
content = result.content
|
||||
assert isinstance(content, BaseModel), f"expected parsed model, got {content!r}"
|
||||
# The model class is dynamic, so field access is untyped by construction.
|
||||
report: Any = content
|
||||
assert "sushi" in report.favorite_food.name.lower()
|
||||
assert report.sentiment == "loves"
|
||||
assert report.years_vegetarian == 3
|
||||
assert report.salient_fact is None or isinstance(report.salient_fact, str)
|
||||
return result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.requires_anthropic
|
||||
@pytest.mark.parametrize(
|
||||
"model_spec",
|
||||
get_live_model_specs(provider="anthropic", feature="structured_output"),
|
||||
ids=lambda spec: spec.id,
|
||||
)
|
||||
async def test_live_anthropic_union_structured_output(
|
||||
model_spec: LiveModelSpec,
|
||||
) -> None:
|
||||
require_provider_key(model_spec)
|
||||
backend, config = make_backend(model_spec)
|
||||
await run_union_structured_flow(backend, config)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.requires_openai
|
||||
@pytest.mark.parametrize(
|
||||
"model_spec",
|
||||
get_live_model_specs(provider="openai", feature="structured_output"),
|
||||
ids=lambda spec: spec.id,
|
||||
)
|
||||
async def test_live_openai_union_structured_output(
|
||||
model_spec: LiveModelSpec,
|
||||
) -> None:
|
||||
require_provider_key(model_spec)
|
||||
backend, config = make_backend(model_spec)
|
||||
await run_union_structured_flow(backend, config)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.requires_gemini
|
||||
@pytest.mark.parametrize(
|
||||
"model_spec",
|
||||
get_live_model_specs(provider="gemini", feature="structured_output"),
|
||||
ids=lambda spec: spec.id,
|
||||
)
|
||||
async def test_live_gemini_union_structured_output(
|
||||
model_spec: LiveModelSpec,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
require_provider_key(model_spec)
|
||||
backend, config = make_backend(model_spec)
|
||||
generate_calls = wrap_async_method(
|
||||
monkeypatch,
|
||||
backend._client.aio.models,
|
||||
"generate_content",
|
||||
)
|
||||
|
||||
await run_union_structured_flow(backend, config)
|
||||
|
||||
# The point of this test: with no tools, Gemini must take the NATIVE
|
||||
# response_schema path (the one that historically rejected anyOf), not
|
||||
# the prompt-injection workaround used when tools are attached.
|
||||
assert generate_calls
|
||||
gen_config = generate_calls[-1]["kwargs"]["config"]
|
||||
assert "response_schema" in gen_config
|
||||
assert gen_config["response_mime_type"] == "application/json"
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
"""Live coverage for combining tool calling with structured output.
|
||||
|
||||
Each provider needs a different workaround when a request carries both
|
||||
function tools and a response_format (see src/llm/backends/):
|
||||
|
||||
- OpenAI: parse() rejects non-strict function tools with a 500, so
|
||||
tool-carrying structured requests must go through create() with an
|
||||
explicit json_schema response_format.
|
||||
- Anthropic: the '{' assistant prefill suppresses tool_use blocks, so it
|
||||
must be skipped when tools are present (conditional instruction +
|
||||
parse/repair instead).
|
||||
- Gemini: native response_schema + function calling is rejected before
|
||||
Gemini 3, so a schema instruction is injected into the final turn.
|
||||
|
||||
The flow below drives both halves of the combination against real APIs:
|
||||
a first turn that must produce a tool call (structured parsing skipped),
|
||||
and a replay turn that must produce a schema-conforming final answer
|
||||
while tools are still attached.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from src.exceptions import LLMError
|
||||
from src.llm.backend import CompletionResult
|
||||
from src.llm.history_adapters import (
|
||||
AnthropicHistoryAdapter,
|
||||
GeminiHistoryAdapter,
|
||||
HistoryAdapter,
|
||||
OpenAIHistoryAdapter,
|
||||
)
|
||||
from src.llm.request_builder import execute_completion
|
||||
from src.llm.structured_output import StructuredOutputError
|
||||
|
||||
from .conftest import (
|
||||
execute_local_tool,
|
||||
favorite_prime_tools,
|
||||
make_backend,
|
||||
require_provider_key,
|
||||
wrap_async_method,
|
||||
)
|
||||
from .model_matrix import LiveModelSpec, get_live_model_specs
|
||||
|
||||
pytestmark = [pytest.mark.live_llm]
|
||||
|
||||
_OPENAI_TOOL_SPECS = tuple(
|
||||
spec
|
||||
for spec in get_live_model_specs(provider="openai", feature="structured_output")
|
||||
if spec.family in {"gpt_4_class", "gpt_5_class"}
|
||||
)
|
||||
|
||||
|
||||
class FavoritePrimeReport(BaseModel):
|
||||
number: int
|
||||
is_prime: bool
|
||||
summary: str
|
||||
|
||||
|
||||
_INITIAL_PROMPT = (
|
||||
"Before answering, call the get_favorite_prime tool exactly once. "
|
||||
"Do not answer with plain text or JSON on this turn. "
|
||||
"After you receive the tool result, answer with JSON where number is "
|
||||
"the tool's number, is_prime says whether that number is prime, and "
|
||||
"summary is one short sentence."
|
||||
)
|
||||
|
||||
|
||||
async def run_tool_then_structured_flow(
|
||||
backend: Any,
|
||||
config: Any,
|
||||
adapter: HistoryAdapter,
|
||||
) -> tuple[CompletionResult, CompletionResult]:
|
||||
"""First turn must tool-call (parsing skipped); replay turn must return
|
||||
a schema-conforming answer with tools still attached."""
|
||||
initial_messages = [{"role": "user", "content": _INITIAL_PROMPT}]
|
||||
tools = favorite_prime_tools()
|
||||
|
||||
first = await execute_completion(
|
||||
backend,
|
||||
config,
|
||||
messages=initial_messages,
|
||||
max_tokens=4096,
|
||||
tools=tools,
|
||||
tool_choice="required",
|
||||
response_format=FavoritePrimeReport,
|
||||
)
|
||||
|
||||
assert first.tool_calls, "first turn should issue a tool call"
|
||||
assert not isinstance(
|
||||
first.content, FavoritePrimeReport
|
||||
), "tool-call turns carry no consumable content and must not be parsed"
|
||||
|
||||
tool_call = first.tool_calls[0]
|
||||
tool_result = execute_local_tool(tool_call.name, tool_call.input)
|
||||
replay_messages = initial_messages + [
|
||||
adapter.format_assistant_tool_message(first),
|
||||
*adapter.format_tool_results(
|
||||
[
|
||||
{
|
||||
"tool_id": tool_call.id,
|
||||
"tool_name": tool_call.name,
|
||||
"result": tool_result,
|
||||
}
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
# tool_choice stays "auto" on the replay turn — the production tool loop
|
||||
# (dialectic) never forces "none", and gemini-2.5-flash is prone to
|
||||
# returning empty candidates under NONE mode. Retry the turn on empty /
|
||||
# unparseable candidates (in production the executor's retry layer
|
||||
# absorbs those; this calls the backend directly) and on the rare run
|
||||
# where the model chooses to tool-call again instead of answering.
|
||||
second: CompletionResult | None = None
|
||||
last_error: Exception | None = None
|
||||
for _ in range(3):
|
||||
try:
|
||||
candidate = await execute_completion(
|
||||
backend,
|
||||
config,
|
||||
messages=replay_messages,
|
||||
max_tokens=4096,
|
||||
tools=tools,
|
||||
tool_choice="auto",
|
||||
response_format=FavoritePrimeReport,
|
||||
)
|
||||
except (ValidationError, LLMError, StructuredOutputError) as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
if candidate.tool_calls:
|
||||
last_error = AssertionError(
|
||||
"model issued another tool call instead of answering"
|
||||
)
|
||||
continue
|
||||
second = candidate
|
||||
break
|
||||
if second is None:
|
||||
raise AssertionError(
|
||||
"structured replay turn failed on all attempts"
|
||||
) from last_error
|
||||
|
||||
assert isinstance(second.content, FavoritePrimeReport)
|
||||
assert second.content.number == 13
|
||||
assert second.content.is_prime is True
|
||||
return first, second
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.requires_anthropic
|
||||
@pytest.mark.parametrize(
|
||||
"model_spec",
|
||||
get_live_model_specs(provider="anthropic", feature="structured_output"),
|
||||
ids=lambda spec: spec.id,
|
||||
)
|
||||
async def test_live_anthropic_tools_with_structured_output(
|
||||
model_spec: LiveModelSpec,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
require_provider_key(model_spec)
|
||||
backend, config = make_backend(model_spec)
|
||||
create_calls = wrap_async_method(monkeypatch, backend._client.messages, "create")
|
||||
|
||||
await run_tool_then_structured_flow(backend, config, AnthropicHistoryAdapter())
|
||||
|
||||
assert len(create_calls) >= 2
|
||||
for call in create_calls:
|
||||
messages = call["kwargs"]["messages"]
|
||||
assert messages[-1] != {
|
||||
"role": "assistant",
|
||||
"content": "{",
|
||||
}, "the '{' prefill would suppress tool_use blocks"
|
||||
assert "If not responding with a tool call" in str(
|
||||
messages[-1]
|
||||
), "schema instruction should use the conditional wording with tools"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.requires_openai
|
||||
@pytest.mark.parametrize("model_spec", _OPENAI_TOOL_SPECS, ids=lambda spec: spec.id)
|
||||
async def test_live_openai_tools_with_structured_output(
|
||||
model_spec: LiveModelSpec,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
require_provider_key(model_spec)
|
||||
# gpt-5.4 rejects function tools combined with any explicit
|
||||
# reasoning_effort other than 'none' on /v1/chat/completions, so leave
|
||||
# the parameter unset and let the server default apply.
|
||||
backend, config = make_backend(model_spec)
|
||||
parse_calls = wrap_async_method(
|
||||
monkeypatch, backend._client.chat.completions, "parse"
|
||||
)
|
||||
create_calls = wrap_async_method(
|
||||
monkeypatch, backend._client.chat.completions, "create"
|
||||
)
|
||||
|
||||
await run_tool_then_structured_flow(backend, config, OpenAIHistoryAdapter())
|
||||
|
||||
# favorite_prime_tools() is deliberately non-strict, the exact shape
|
||||
# parse() refuses with a 500.
|
||||
assert not parse_calls, "tool-carrying structured requests must avoid parse()"
|
||||
assert len(create_calls) >= 2
|
||||
for call in create_calls:
|
||||
response_format = call["kwargs"]["response_format"]
|
||||
assert response_format["type"] == "json_schema"
|
||||
assert response_format["json_schema"]["name"] == "FavoritePrimeReport"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.requires_gemini
|
||||
@pytest.mark.parametrize(
|
||||
"model_spec",
|
||||
get_live_model_specs(provider="gemini", feature="structured_output"),
|
||||
ids=lambda spec: spec.id,
|
||||
)
|
||||
async def test_live_gemini_tools_with_structured_output(
|
||||
model_spec: LiveModelSpec,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
require_provider_key(model_spec)
|
||||
# No temperature pin: gemini-2.5-flash occasionally returns an empty
|
||||
# candidate on the replay turn, and at temperature=0 the retry re-sends
|
||||
# a deterministic request — default sampling gives retries a real chance.
|
||||
backend, config = make_backend(model_spec)
|
||||
generate_calls = wrap_async_method(
|
||||
monkeypatch,
|
||||
backend._client.aio.models,
|
||||
"generate_content",
|
||||
)
|
||||
|
||||
await run_tool_then_structured_flow(backend, config, GeminiHistoryAdapter())
|
||||
|
||||
assert len(generate_calls) >= 2
|
||||
for call in generate_calls:
|
||||
gen_config = call["kwargs"]["config"]
|
||||
assert (
|
||||
"response_schema" not in gen_config
|
||||
), "native response_schema + function calling is rejected pre-Gemini 3"
|
||||
assert "response_mime_type" not in gen_config
|
||||
contents = call["kwargs"]["contents"]
|
||||
assert "matching this schema" in str(
|
||||
contents[-1]
|
||||
), "schema instruction should be injected into the final turn"
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
|
@ -245,3 +246,206 @@ async def test_anthropic_backend_ignores_thinking_effort() -> None:
|
|||
call = await_args.kwargs
|
||||
assert "thinking" not in call
|
||||
assert "reasoning_effort" not in call
|
||||
|
||||
|
||||
def _make_client(content_blocks: list[Any]) -> Mock:
|
||||
client = Mock()
|
||||
client.messages.create = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
content=content_blocks,
|
||||
usage=SimpleNamespace(
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
cache_creation_input_tokens=0,
|
||||
cache_read_input_tokens=0,
|
||||
),
|
||||
stop_reason="end_turn",
|
||||
)
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
SEARCH_TOOL = {
|
||||
"name": "search",
|
||||
"description": "Search for information",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_backend_no_prefill_when_tools_present() -> None:
|
||||
"""With tools + response_format, the '{' prefill must be skipped and the
|
||||
schema instruction must be conditional, so tool_use blocks stay reachable."""
|
||||
client = _make_client([TextBlock(type="text", text='{"answer":"ok"}')])
|
||||
|
||||
backend = AnthropicBackend(client)
|
||||
result = await backend.complete(
|
||||
# claude-3-5 supports prefill, so only the tools guard prevents it here
|
||||
model="claude-3-5-sonnet-latest",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
tools=[SEARCH_TOOL],
|
||||
response_format=StructuredResponse,
|
||||
)
|
||||
|
||||
assert isinstance(result.content, StructuredResponse)
|
||||
call = client.messages.create.await_args.kwargs
|
||||
assert call["messages"][-1]["role"] == "user" # no assistant '{' prefill
|
||||
assert (
|
||||
"If not responding with a tool call, respond with valid JSON"
|
||||
in call["messages"][0]["content"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_backend_prefill_unchanged_without_tools() -> None:
|
||||
"""Tool-less structured calls keep the prefill + unconditional wording."""
|
||||
client = _make_client([TextBlock(type="text", text='"answer":"ok"}')])
|
||||
|
||||
backend = AnthropicBackend(client)
|
||||
result = await backend.complete(
|
||||
model="claude-3-5-sonnet-latest",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
response_format=StructuredResponse,
|
||||
)
|
||||
|
||||
assert isinstance(result.content, StructuredResponse)
|
||||
call = client.messages.create.await_args.kwargs
|
||||
assert call["messages"][-1] == {"role": "assistant", "content": "{"}
|
||||
instruction = call["messages"][0]["content"]
|
||||
assert "\n\nRespond with valid JSON matching this schema:" in instruction
|
||||
assert "If not responding with a tool call" not in instruction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_backend_repairs_malformed_structured_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Structured output that fails JSON parsing falls back to
|
||||
repair_response_model_json, whose result becomes the response content."""
|
||||
# After the '{' prefill is prepended this is still invalid JSON.
|
||||
client = _make_client([TextBlock(type="text", text='"answer": not-json')])
|
||||
|
||||
repaired = StructuredResponse(answer="fixed")
|
||||
repair_calls: list[tuple[str, type[BaseModel], str]] = []
|
||||
|
||||
def _fake_repair(
|
||||
raw: str, response_format: type[BaseModel], model_name: str
|
||||
) -> StructuredResponse:
|
||||
repair_calls.append((raw, response_format, model_name))
|
||||
return repaired
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.llm.backends.anthropic.repair_response_model_json", _fake_repair
|
||||
)
|
||||
|
||||
backend = AnthropicBackend(client)
|
||||
result = await backend.complete(
|
||||
model="claude-3-5-sonnet-latest",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
response_format=StructuredResponse,
|
||||
)
|
||||
|
||||
assert result.content is repaired
|
||||
assert repair_calls == [
|
||||
('{"answer": not-json', StructuredResponse, "claude-3-5-sonnet-latest")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_backend_skips_parsing_on_tool_call_turns(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A tool-call response with response_format set must not attempt JSON
|
||||
parsing — the repair fallback raises on the empty text of tool-call
|
||||
turns, which would fail every intermediate tool iteration."""
|
||||
client = _make_client(
|
||||
[
|
||||
ToolUseBlock(
|
||||
type="tool_use",
|
||||
id="tool_1",
|
||||
name="search",
|
||||
input={"query": "honcho"},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def _fail_repair(*_args: object, **_kwargs: object) -> None:
|
||||
raise AssertionError("repair must not run for tool-call turns")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.llm.backends.anthropic.repair_response_model_json", _fail_repair
|
||||
)
|
||||
|
||||
backend = AnthropicBackend(client)
|
||||
result = await backend.complete(
|
||||
model="claude-3-5-sonnet-latest",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
tools=[SEARCH_TOOL],
|
||||
response_format=StructuredResponse,
|
||||
)
|
||||
|
||||
assert result.content == "" # raw (empty) text, not a parsed model
|
||||
assert result.tool_calls[0].name == "search"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_backend_stream_no_prefill_when_tools_present() -> None:
|
||||
"""The streaming path applies the same tools guard."""
|
||||
|
||||
class _FakeStream:
|
||||
def __init__(self) -> None:
|
||||
self._chunks: list[SimpleNamespace] = [
|
||||
SimpleNamespace(
|
||||
type="content_block_delta",
|
||||
delta=SimpleNamespace(text='{"answer":"ok"}'),
|
||||
)
|
||||
]
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: object) -> bool:
|
||||
return False
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if self._chunks:
|
||||
return self._chunks.pop(0)
|
||||
raise StopAsyncIteration
|
||||
|
||||
async def get_final_message(self):
|
||||
return SimpleNamespace(
|
||||
stop_reason="end_turn", usage=SimpleNamespace(output_tokens=5)
|
||||
)
|
||||
|
||||
client = Mock()
|
||||
client.messages.stream = Mock(return_value=_FakeStream())
|
||||
|
||||
backend = AnthropicBackend(client)
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in backend.stream(
|
||||
model="claude-3-5-sonnet-latest",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
tools=[SEARCH_TOOL],
|
||||
response_format=StructuredResponse,
|
||||
)
|
||||
]
|
||||
|
||||
assert chunks[0].content == '{"answer":"ok"}'
|
||||
call = client.messages.stream.call_args.kwargs
|
||||
assert call["messages"][-1]["role"] == "user" # no assistant '{' prefill
|
||||
assert (
|
||||
"If not responding with a tool call, respond with valid JSON"
|
||||
in call["messages"][0]["content"]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -502,3 +502,126 @@ def test_gemini_convert_tools_sanitizes_parameters_schema() -> None:
|
|||
params = converted[0]["function_declarations"][0]["parameters"]
|
||||
assert "additionalProperties" not in params
|
||||
assert "additionalProperties" not in params["properties"]["observations"]["items"]
|
||||
|
||||
|
||||
class _GeminiStructured(BaseModel):
|
||||
answer: str
|
||||
|
||||
|
||||
GEMINI_AGENT_TOOL = {
|
||||
"name": "search",
|
||||
"description": "Search for information",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _gemini_response(
|
||||
parts: list[SimpleNamespace], parsed: object = None
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
candidates=[
|
||||
SimpleNamespace(
|
||||
finish_reason=SimpleNamespace(name="STOP"),
|
||||
content=SimpleNamespace(parts=parts),
|
||||
)
|
||||
],
|
||||
usage_metadata=SimpleNamespace(
|
||||
prompt_token_count=12,
|
||||
candidates_token_count=6,
|
||||
),
|
||||
parsed=parsed,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_backend_structured_with_tools_skips_native_schema() -> None:
|
||||
"""Native response_schema + function calling is Gemini-3-preview-only, so
|
||||
with tools present the schema must be delivered as an instruction on the
|
||||
final turn instead, and the answer parsed from raw text."""
|
||||
client = Mock()
|
||||
client.aio.models.generate_content = AsyncMock(
|
||||
return_value=_gemini_response([SimpleNamespace(text='{"answer":"ok"}')])
|
||||
)
|
||||
|
||||
backend = GeminiBackend(client)
|
||||
result = await backend.complete(
|
||||
model="gemini-2.5-flash",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
tools=[GEMINI_AGENT_TOOL],
|
||||
response_format=_GeminiStructured,
|
||||
)
|
||||
|
||||
assert isinstance(result.content, _GeminiStructured)
|
||||
assert result.content.answer == "ok"
|
||||
await_args = client.aio.models.generate_content.await_args
|
||||
if await_args is None:
|
||||
raise AssertionError("Expected Gemini generate_content call")
|
||||
call = await_args.kwargs
|
||||
assert "response_schema" not in call["config"]
|
||||
assert "response_mime_type" not in call["config"]
|
||||
assert "tools" in call["config"]
|
||||
last_part_text = call["contents"][-1]["parts"][-1]["text"]
|
||||
assert "If not responding with a tool call" in last_part_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_backend_structured_tool_call_turn_not_parsed() -> None:
|
||||
"""A tool-call turn under tools + response_format must not attempt JSON
|
||||
parsing (its text is empty and the repair fallback raises on that)."""
|
||||
client = Mock()
|
||||
client.aio.models.generate_content = AsyncMock(
|
||||
return_value=_gemini_response(
|
||||
[
|
||||
SimpleNamespace(
|
||||
function_call=SimpleNamespace(
|
||||
name="search", args={"query": "honcho"}
|
||||
),
|
||||
thought_signature=None,
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
backend = GeminiBackend(client)
|
||||
result = await backend.complete(
|
||||
model="gemini-2.5-flash",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
tools=[GEMINI_AGENT_TOOL],
|
||||
response_format=_GeminiStructured,
|
||||
)
|
||||
|
||||
assert result.content == "" # raw (empty) text, not a parsed model
|
||||
assert result.tool_calls[0].name == "search"
|
||||
assert result.tool_calls[0].input == {"query": "honcho"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_backend_structured_without_tools_uses_native_schema() -> None:
|
||||
"""Tool-less structured calls keep native response_schema enforcement."""
|
||||
client = Mock()
|
||||
client.aio.models.generate_content = AsyncMock(
|
||||
return_value=_gemini_response(
|
||||
[SimpleNamespace(text='{"answer":"ok"}')],
|
||||
parsed={"answer": "ok"},
|
||||
)
|
||||
)
|
||||
|
||||
backend = GeminiBackend(client)
|
||||
result = await backend.complete(
|
||||
model="gemini-2.5-flash",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
response_format=_GeminiStructured,
|
||||
)
|
||||
|
||||
assert isinstance(result.content, _GeminiStructured)
|
||||
call = client.aio.models.generate_content.await_args.kwargs # pyright: ignore
|
||||
assert call["config"]["response_schema"] is _GeminiStructured
|
||||
assert call["config"]["response_mime_type"] == "application/json"
|
||||
# No instruction injected on the tool-less path.
|
||||
assert call["contents"][-1]["parts"][-1]["text"] == "Hello"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import gc
|
||||
import json
|
||||
import weakref
|
||||
from collections.abc import AsyncIterator
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
|
@ -10,8 +12,12 @@ from openai import BadRequestError
|
|||
from pydantic import BaseModel
|
||||
|
||||
from src.exceptions import ValidationException
|
||||
from src.llm.backends.openai import OpenAIBackend
|
||||
from src.llm.backends.openai import (
|
||||
OpenAIBackend,
|
||||
_json_object_instruction, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
from src.utils.representation import PromptRepresentation
|
||||
from src.utils.schema_conversion import json_response_schema_to_pydantic
|
||||
|
||||
|
||||
def _await_kwargs(mock_method: Any) -> dict[str, Any]:
|
||||
|
|
@ -808,6 +814,7 @@ async def test_structured_output_json_object_mode_request_shape() -> None:
|
|||
assert system_messages, "expected a system message carrying the schema"
|
||||
system_content = system_messages[0]["content"]
|
||||
assert "JSON" in system_content
|
||||
assert "json" in system_content
|
||||
assert "answer" in system_content # schema property serialized in
|
||||
assert isinstance(result.content, _StructuredResponse)
|
||||
assert result.content.answer == "ok"
|
||||
|
|
@ -837,6 +844,70 @@ async def test_structured_output_json_object_mode_repairs_markdown() -> None:
|
|||
assert isinstance(result.content, PromptRepresentation)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_structured_output_json_object_mode_with_dynamic_model() -> None:
|
||||
"""json_object mode composes with a caller-supplied schema converted at
|
||||
request time (the dialectic's response_format path): the generated class's
|
||||
schema — unions included — is injected into the prompt and the JSON body
|
||||
parses back through the class."""
|
||||
response_model = json_response_schema_to_pydantic(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": {"type": "string"},
|
||||
"years": {"type": ["integer", "null"]},
|
||||
},
|
||||
"required": ["answer", "years"],
|
||||
}
|
||||
)
|
||||
client = Mock()
|
||||
client.chat.completions.parse = AsyncMock()
|
||||
client.chat.completions.create = AsyncMock(
|
||||
return_value=_structured_create_return('{"answer": "ok", "years": 3}')
|
||||
)
|
||||
|
||||
backend = OpenAIBackend(client)
|
||||
result = await backend.complete(
|
||||
model="glm-4.6",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
response_format=response_model,
|
||||
extra_params={"structured_output_mode": "json_object"},
|
||||
)
|
||||
|
||||
assert client.chat.completions.parse.await_count == 0
|
||||
call = _await_kwargs(client.chat.completions.create)
|
||||
assert call["response_format"] == {"type": "json_object"}
|
||||
system_messages = [m for m in call["messages"] if m["role"] == "system"]
|
||||
assert system_messages, "expected a system message carrying the schema"
|
||||
# The dynamic model's schema (union field included) made it into the prompt.
|
||||
assert "years" in system_messages[0]["content"]
|
||||
assert "anyOf" in system_messages[0]["content"]
|
||||
|
||||
assert isinstance(result.content, response_model)
|
||||
# The model class is dynamic, so field access is untyped by construction.
|
||||
content: Any = result.content
|
||||
assert content.answer == "ok"
|
||||
assert content.years == 3
|
||||
|
||||
|
||||
def test_json_object_instruction_does_not_pin_dynamic_models() -> None:
|
||||
"""The instruction cache must hold its model-class keys weakly: the
|
||||
dialectic creates a fresh response_format class per request (see
|
||||
src/utils/schema_conversion.py), and a strong-keyed cache would grow by
|
||||
one pinned class per structured chat call."""
|
||||
model = json_response_schema_to_pydantic(
|
||||
{"type": "object", "properties": {"answer": {"type": "string"}}}
|
||||
)
|
||||
first = _json_object_instruction(model)
|
||||
assert _json_object_instruction(model) is first # cached while alive
|
||||
|
||||
ref = weakref.ref(model)
|
||||
del model
|
||||
gc.collect()
|
||||
assert ref() is None, "instruction cache must not keep dynamic classes alive"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_structured_output_json_object_empty_content_returns_empty() -> None:
|
||||
"""An empty body with no refusal must produce a graceful empty result, not
|
||||
|
|
@ -943,4 +1014,126 @@ async def test_stream_structured_output_json_object_mode() -> None:
|
|||
call = _await_kwargs(client.chat.completions.create)
|
||||
assert call["response_format"] == {"type": "json_object"}
|
||||
system_messages = [m for m in call["messages"] if m["role"] == "system"]
|
||||
assert system_messages and "JSON" in system_messages[0]["content"]
|
||||
assert system_messages
|
||||
assert "JSON" in system_messages[0]["content"]
|
||||
assert "json" in system_messages[0]["content"]
|
||||
|
||||
|
||||
AGENT_TOOL = {
|
||||
"name": "search",
|
||||
"description": "Search for information",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_backend_structured_with_tools_uses_create_not_parse() -> None:
|
||||
"""With tools + response_format, complete() must route through create()
|
||||
with an explicit json_schema response_format: parse() raises client-side
|
||||
on non-strict function tools, and our agent tools are deliberately
|
||||
non-strict (see _convert_tools)."""
|
||||
client = Mock()
|
||||
client.chat.completions.create = AsyncMock(
|
||||
return_value=_structured_create_return('{"answer":"ok"}')
|
||||
)
|
||||
client.chat.completions.parse = AsyncMock(
|
||||
side_effect=AssertionError("parse() must not be called with tools")
|
||||
)
|
||||
|
||||
backend = OpenAIBackend(client)
|
||||
result = await backend.complete(
|
||||
model="gpt-5.4-mini",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
tools=[AGENT_TOOL],
|
||||
response_format=_StructuredResponse,
|
||||
)
|
||||
|
||||
assert isinstance(result.content, _StructuredResponse)
|
||||
assert result.content.answer == "ok"
|
||||
client.chat.completions.parse.assert_not_awaited()
|
||||
call = _await_kwargs(client.chat.completions.create)
|
||||
assert call["response_format"]["type"] == "json_schema"
|
||||
assert (
|
||||
call["response_format"]["json_schema"]["schema"]
|
||||
== _StructuredResponse.model_json_schema()
|
||||
)
|
||||
# Tools stay non-strict; strictness was the whole reason to avoid parse().
|
||||
assert "strict" not in call["tools"][0]["function"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_backend_structured_with_tools_skips_parsing_tool_call_turn() -> (
|
||||
None
|
||||
):
|
||||
"""A tool-call turn under tools + response_format must not attempt JSON
|
||||
parsing (its content is empty and _parse_or_repair raises on that)."""
|
||||
client = Mock()
|
||||
client.chat.completions.create = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
finish_reason="tool_calls",
|
||||
message=SimpleNamespace(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
SimpleNamespace(
|
||||
id="tool_1",
|
||||
function=SimpleNamespace(
|
||||
name="search",
|
||||
arguments='{"query": "honcho"}',
|
||||
),
|
||||
)
|
||||
],
|
||||
reasoning_details=[],
|
||||
refusal=None,
|
||||
),
|
||||
)
|
||||
],
|
||||
usage=SimpleNamespace(
|
||||
prompt_tokens=10,
|
||||
completion_tokens=5,
|
||||
prompt_tokens_details=None,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
backend = OpenAIBackend(client)
|
||||
result = await backend.complete(
|
||||
model="gpt-5.4-mini",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
tools=[AGENT_TOOL],
|
||||
response_format=_StructuredResponse,
|
||||
)
|
||||
|
||||
assert result.content == "" # raw empty text, not a parsed model
|
||||
assert result.tool_calls[0].name == "search"
|
||||
assert result.tool_calls[0].input == {"query": "honcho"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_backend_structured_without_tools_still_uses_parse() -> None:
|
||||
"""Tool-less structured calls (deriver, final synthesis) keep parse()."""
|
||||
parsed = _StructuredResponse(answer="ok")
|
||||
client = Mock()
|
||||
client.chat.completions.parse = AsyncMock(
|
||||
return_value=_structured_create_return('{"answer":"ok"}', parsed=parsed)
|
||||
)
|
||||
client.chat.completions.create = AsyncMock(
|
||||
side_effect=AssertionError("create() must not be called without tools")
|
||||
)
|
||||
|
||||
backend = OpenAIBackend(client)
|
||||
result = await backend.complete(
|
||||
model="gpt-5.4-mini",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
response_format=_StructuredResponse,
|
||||
)
|
||||
|
||||
assert result.content is parsed
|
||||
client.chat.completions.create.assert_not_awaited()
|
||||
|
|
|
|||
|
|
@ -63,8 +63,9 @@ async def test_create_message_schedules_immediate_embed(
|
|||
|
||||
with (
|
||||
patch("src.config.settings.EMBED_MESSAGES", True),
|
||||
patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 50),
|
||||
patch(
|
||||
"src.routers.messages.embed_messages_now", new=AsyncMock()
|
||||
"src.reconciler.embed_now.embed_messages_now", new=AsyncMock()
|
||||
) as mock_embed_now,
|
||||
):
|
||||
response = client.post(
|
||||
|
|
@ -91,7 +92,35 @@ async def test_create_message_skips_embed_when_disabled(
|
|||
with (
|
||||
patch("src.config.settings.EMBED_MESSAGES", False),
|
||||
patch(
|
||||
"src.routers.messages.embed_messages_now", new=AsyncMock()
|
||||
"src.reconciler.embed_now.embed_messages_now", new=AsyncMock()
|
||||
) as mock_embed_now,
|
||||
):
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages",
|
||||
json={"messages": [{"content": "hello", "peer_id": test_peer.name}]},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
mock_embed_now.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_message_defers_embed_when_saturated(
|
||||
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
"""When the immediate-embed task cap is saturated, message creation still
|
||||
succeeds and no embed task runs — rows stay pending for the reconciler."""
|
||||
test_workspace, test_peer = sample_data
|
||||
test_session = models.Session(
|
||||
workspace_name=test_workspace.name, name=str(generate_nanoid())
|
||||
)
|
||||
db_session.add(test_session)
|
||||
await db_session.commit()
|
||||
|
||||
with (
|
||||
patch("src.config.settings.EMBED_MESSAGES", True),
|
||||
patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 0),
|
||||
patch(
|
||||
"src.reconciler.embed_now.embed_messages_now", new=AsyncMock()
|
||||
) as mock_embed_now,
|
||||
):
|
||||
response = client.post(
|
||||
|
|
@ -119,8 +148,9 @@ async def test_file_upload_schedules_immediate_embed(
|
|||
|
||||
with (
|
||||
patch("src.config.settings.EMBED_MESSAGES", True),
|
||||
patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 50),
|
||||
patch(
|
||||
"src.routers.messages.embed_messages_now", new=AsyncMock()
|
||||
"src.reconciler.embed_now.embed_messages_now", new=AsyncMock()
|
||||
) as mock_embed_now,
|
||||
):
|
||||
files = {"file": ("note.txt", io.BytesIO(b"hello world"), "text/plain")}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from typing import Any
|
|||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from nanoid import generate as generate_nanoid
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import crud, models
|
||||
|
|
@ -1270,3 +1271,114 @@ def test_set_peer_card(client: TestClient, sample_data: tuple[Workspace, Peer]):
|
|||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["peer_card"] == target_card
|
||||
|
||||
|
||||
FOOD_PREFS_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"preferences": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"food": {"type": "string"},
|
||||
"sentiment": {"enum": ["loves", "likes", "dislikes"]},
|
||||
},
|
||||
"required": ["food", "sentiment"],
|
||||
},
|
||||
},
|
||||
"summary": {"type": "string"},
|
||||
},
|
||||
"required": ["preferences", "summary"],
|
||||
}
|
||||
|
||||
|
||||
def test_chat_with_response_format(
|
||||
client: TestClient,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
mock_llm_call_functions: dict[str, Any],
|
||||
):
|
||||
"""A valid response_format converts to a Pydantic model and is passed to
|
||||
the dialectic as response_model."""
|
||||
test_workspace, test_peer = sample_data
|
||||
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat",
|
||||
json={
|
||||
"query": "What are this user's food preferences?",
|
||||
"stream": False,
|
||||
"response_format": FOOD_PREFS_SCHEMA,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "content" in response.json()
|
||||
|
||||
kwargs = mock_llm_call_functions["agentic_chat"].await_args.kwargs
|
||||
response_model = kwargs["response_model"]
|
||||
assert isinstance(response_model, type)
|
||||
assert issubclass(response_model, BaseModel)
|
||||
# The converted model enforces the caller's schema.
|
||||
instance = response_model.model_validate(
|
||||
{"preferences": [{"food": "sushi", "sentiment": "loves"}], "summary": "s"}
|
||||
)
|
||||
assert instance.summary == "s" # pyright: ignore
|
||||
|
||||
|
||||
def test_chat_with_response_format_streaming(
|
||||
client: TestClient,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
mock_llm_call_functions: dict[str, Any],
|
||||
):
|
||||
test_workspace, test_peer = sample_data
|
||||
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat",
|
||||
json={
|
||||
"query": "What are this user's food preferences?",
|
||||
"stream": True,
|
||||
"response_format": FOOD_PREFS_SCHEMA,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "data:" in response.text
|
||||
|
||||
kwargs = mock_llm_call_functions["agentic_chat_stream"].call_args.kwargs
|
||||
response_model = kwargs["response_model"]
|
||||
assert isinstance(response_model, type)
|
||||
assert issubclass(response_model, BaseModel)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_schema",
|
||||
[
|
||||
{"type": "string"}, # non-object root
|
||||
{"type": "object", "properties": {"a": {"$ref": "#/x"}}},
|
||||
{"type": "object", "properties": {"a": {"allOf": [{"type": "string"}]}}},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"m": {"type": "object", "additionalProperties": {"type": "string"}}
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_chat_with_invalid_response_format(
|
||||
client: TestClient,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
mock_llm_call_functions: dict[str, Any],
|
||||
bad_schema: dict[str, Any],
|
||||
):
|
||||
"""Unsupported schemas are rejected with 422 before the dialectic runs."""
|
||||
test_workspace, test_peer = sample_data
|
||||
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat",
|
||||
json={
|
||||
"query": "Hello?",
|
||||
"stream": False,
|
||||
"response_format": bad_schema,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "Invalid response_format" in response.json()["detail"]
|
||||
mock_llm_call_functions["agentic_chat"].assert_not_awaited()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from src import models
|
||||
from src.models import Peer, Workspace
|
||||
from src.schemas import DreamType
|
||||
|
||||
|
||||
def test_get_or_create_workspace(client: TestClient):
|
||||
|
|
@ -758,3 +759,52 @@ async def test_schedule_dream_invokes_enqueue_dream(
|
|||
"Loop 4: enqueue_dream no longer accepts document_count; the baseline "
|
||||
"is written atomically with last_dream_at in process_dream."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_dream_card_refresh_forwards_rebuild(
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
):
|
||||
"""POST /schedule_dream accepts dream_type=card_refresh and forwards the
|
||||
rebuild flag to enqueue_dream (manual/event-driven card refreshes bypass
|
||||
the volume gates by design)."""
|
||||
workspace, peer = sample_data
|
||||
|
||||
collection = models.Collection(
|
||||
observer=peer.name,
|
||||
observed=peer.name,
|
||||
workspace_name=workspace.name,
|
||||
internal_metadata={},
|
||||
)
|
||||
db_session.add(collection)
|
||||
await db_session.commit()
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_enqueue_dream(*args: Any, **kwargs: Any) -> None:
|
||||
captured["args"] = args
|
||||
captured["kwargs"] = kwargs
|
||||
|
||||
with (
|
||||
patch("src.routers.workspaces.settings.DREAM.ENABLED", True),
|
||||
patch(
|
||||
"src.routers.workspaces.enqueue_dream",
|
||||
new=AsyncMock(side_effect=fake_enqueue_dream),
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{workspace.name}/schedule_dream",
|
||||
json={
|
||||
"observer": peer.name,
|
||||
"observed": peer.name,
|
||||
"dream_type": "card_refresh",
|
||||
"rebuild": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 204, response.text
|
||||
assert "kwargs" in captured, "enqueue_dream was not called"
|
||||
assert captured["kwargs"]["dream_type"] == DreamType.CARD_REFRESH
|
||||
assert captured["kwargs"]["rebuild"] is True
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from collections.abc import AsyncIterator, Iterator
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from sdks.python.src.honcho.client import Honcho
|
||||
from sdks.python.src.honcho.peer import Peer
|
||||
|
|
@ -626,3 +627,166 @@ async def test_peer_representation_with_all_params(
|
|||
max_conclusions=5,
|
||||
)
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
class ChatFoodPreferences(BaseModel):
|
||||
favorite: str
|
||||
confidence: float
|
||||
|
||||
|
||||
CHAT_SCHEMA_DICT = {
|
||||
"type": "object",
|
||||
"properties": {"items": {"type": "array", "items": {"type": "string"}}},
|
||||
"required": ["items"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_peer_chat_response_format_pydantic(client_fixture: tuple[Honcho, str]):
|
||||
"""A Pydantic model class is sent as JSON Schema and the response content
|
||||
is parsed back into a model instance."""
|
||||
honcho_client, client_type = client_fixture
|
||||
content = '{"favorite": "sushi", "confidence": 0.9}'
|
||||
|
||||
if client_type == "async":
|
||||
peer = await honcho_client.aio.peer(id="test-rf-async-peer")
|
||||
|
||||
async def mock_post(*_args: object, **_kwargs: object) -> dict[str, object]:
|
||||
return {"content": content}
|
||||
|
||||
with patch.object(
|
||||
peer._honcho._async_http_client, # pyright: ignore[reportPrivateUsage]
|
||||
"post",
|
||||
side_effect=mock_post,
|
||||
) as mock:
|
||||
result = await peer.aio.chat(
|
||||
"What do I like?", response_format=ChatFoodPreferences
|
||||
)
|
||||
else:
|
||||
peer = honcho_client.peer(id="test-rf-peer")
|
||||
with patch.object(
|
||||
peer._honcho._http, # pyright: ignore[reportPrivateUsage]
|
||||
"post",
|
||||
return_value={"content": content},
|
||||
) as mock:
|
||||
result = peer.chat("What do I like?", response_format=ChatFoodPreferences)
|
||||
|
||||
body = mock.call_args.kwargs["body"]
|
||||
assert body["response_format"] == ChatFoodPreferences.model_json_schema()
|
||||
assert isinstance(result, ChatFoodPreferences)
|
||||
assert result.favorite == "sushi"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_peer_chat_response_format_dict(client_fixture: tuple[Honcho, str]):
|
||||
"""A raw JSON Schema dict is sent as-is and the response stays a string."""
|
||||
honcho_client, client_type = client_fixture
|
||||
content = '{"items": ["sushi"]}'
|
||||
|
||||
if client_type == "async":
|
||||
peer = await honcho_client.aio.peer(id="test-rf-dict-async-peer")
|
||||
|
||||
async def mock_post(*_args: object, **_kwargs: object) -> dict[str, object]:
|
||||
return {"content": content}
|
||||
|
||||
with patch.object(
|
||||
peer._honcho._async_http_client, # pyright: ignore[reportPrivateUsage]
|
||||
"post",
|
||||
side_effect=mock_post,
|
||||
) as mock:
|
||||
result = await peer.aio.chat(
|
||||
"What do I like?", response_format=CHAT_SCHEMA_DICT
|
||||
)
|
||||
else:
|
||||
peer = honcho_client.peer(id="test-rf-dict-peer")
|
||||
with patch.object(
|
||||
peer._honcho._http, # pyright: ignore[reportPrivateUsage]
|
||||
"post",
|
||||
return_value={"content": content},
|
||||
) as mock:
|
||||
result = peer.chat("What do I like?", response_format=CHAT_SCHEMA_DICT)
|
||||
|
||||
body = mock.call_args.kwargs["body"]
|
||||
assert body["response_format"] == CHAT_SCHEMA_DICT
|
||||
assert result == content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_peer_chat_response_format_empty_content(
|
||||
client_fixture: tuple[Honcho, str],
|
||||
):
|
||||
"""Empty/None content returns None even when a Pydantic class was given."""
|
||||
honcho_client, client_type = client_fixture
|
||||
|
||||
if client_type == "async":
|
||||
peer = await honcho_client.aio.peer(id="test-rf-empty-async-peer")
|
||||
|
||||
async def mock_post(*_args: object, **_kwargs: object) -> dict[str, object]:
|
||||
return {"content": None}
|
||||
|
||||
with patch.object(
|
||||
peer._honcho._async_http_client, # pyright: ignore[reportPrivateUsage]
|
||||
"post",
|
||||
side_effect=mock_post,
|
||||
):
|
||||
result = await peer.aio.chat(
|
||||
"What do I like?", response_format=ChatFoodPreferences
|
||||
)
|
||||
else:
|
||||
peer = honcho_client.peer(id="test-rf-empty-peer")
|
||||
with patch.object(
|
||||
peer._honcho._http, # pyright: ignore[reportPrivateUsage]
|
||||
"post",
|
||||
return_value={"content": None},
|
||||
):
|
||||
result = peer.chat("What do I like?", response_format=ChatFoodPreferences)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_peer_chat_stream_response_format(client_fixture: tuple[Honcho, str]):
|
||||
"""chat_stream sends the schema in the body; chunks stay raw text."""
|
||||
honcho_client, client_type = client_fixture
|
||||
|
||||
if client_type == "async":
|
||||
peer = await honcho_client.aio.peer(id="test-rf-stream-async-peer")
|
||||
|
||||
async def mock_astream(
|
||||
*_args: object, **_kwargs: object
|
||||
) -> AsyncIterator[bytes]:
|
||||
yield b'data: {"delta": {"content": "{\\"favorite\\":"}}\n'
|
||||
yield b'data: {"delta": {"content": "\\"sushi\\",\\"confidence\\":0.9}"}}\n'
|
||||
yield b'data: {"done": true}\n'
|
||||
|
||||
with patch.object(
|
||||
peer._honcho._async_http_client, # pyright: ignore[reportPrivateUsage]
|
||||
"stream",
|
||||
side_effect=mock_astream,
|
||||
) as mock:
|
||||
result = await peer.aio.chat_stream(
|
||||
"What do I like?", response_format=ChatFoodPreferences
|
||||
)
|
||||
chunks = [chunk async for chunk in result]
|
||||
else:
|
||||
peer = honcho_client.peer(id="test-rf-stream-peer")
|
||||
|
||||
def mock_stream(*_args: object, **_kwargs: object) -> Iterator[bytes]:
|
||||
yield b'data: {"delta": {"content": "{\\"favorite\\":"}}\n'
|
||||
yield b'data: {"delta": {"content": "\\"sushi\\",\\"confidence\\":0.9}"}}\n'
|
||||
yield b'data: {"done": true}\n'
|
||||
|
||||
with patch.object(
|
||||
peer._honcho._http, # pyright: ignore[reportPrivateUsage]
|
||||
"stream",
|
||||
side_effect=mock_stream,
|
||||
) as mock:
|
||||
result = peer.chat_stream(
|
||||
"What do I like?", response_format=ChatFoodPreferences
|
||||
)
|
||||
chunks = list(result)
|
||||
|
||||
body = mock.call_args.kwargs["body"]
|
||||
assert body["response_format"] == ChatFoodPreferences.model_json_schema()
|
||||
accumulated = "".join(chunks)
|
||||
assert ChatFoodPreferences.model_validate_json(accumulated).favorite == "sushi"
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ class TestLLMCallCompletedEvent:
|
|||
assert CallPurpose.DIALECTIC_ANSWER.value == "dialectic.answer"
|
||||
assert CallPurpose.DREAM_DEDUCTION.value == "dream.deduction"
|
||||
assert CallPurpose.DREAM_INDUCTION.value == "dream.induction"
|
||||
assert CallPurpose.DREAM_CARD_REFRESH.value == "dream.card_refresh"
|
||||
assert CallPurpose.SUMMARY_SHORT.value == "summary.short"
|
||||
assert CallPurpose.SUMMARY_LONG.value == "summary.long"
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
"""tests for RepresentationCompletedEvent additive fields + truncation.
|
||||
|
||||
Targets:
|
||||
- Schema stays at v2 (additive, no bump). Existing `input_tokens` semantics
|
||||
unchanged.
|
||||
- fields are defaultable (no breakage for callers that ignore them)
|
||||
and round-trip through Pydantic serialization.
|
||||
- `HonchoLLMCallResponse.hit_input_token_cap` defaults to False but can be
|
||||
|
|
@ -17,10 +15,6 @@ from src.telemetry.events.representation import RepresentationCompletedEvent
|
|||
|
||||
|
||||
class TestRepresentationV2AdditiveFields:
|
||||
def test_schema_stays_at_v2(self):
|
||||
"""is additive — schema_version must NOT bump to 3."""
|
||||
assert RepresentationCompletedEvent.schema_version() == 2
|
||||
|
||||
def test_new_fields_are_optional(self):
|
||||
"""Existing callers must keep working without supplying any new
|
||||
fields. All new fields default."""
|
||||
|
|
@ -53,6 +47,10 @@ class TestRepresentationV2AdditiveFields:
|
|||
assert event.hit_batch_token_cap is False
|
||||
assert event.hit_input_token_cap is False
|
||||
assert event.observer_count == 0
|
||||
assert event.exact_dup_in_batch_count == 0
|
||||
assert event.exact_dup_existing_count == 0
|
||||
assert event.semantic_dup_rejected_count == 0
|
||||
assert event.semantic_dup_replaced_count == 0
|
||||
|
||||
def test_input_tokens_semantics_preserved(self):
|
||||
"""The downstream metering key must remain 'queued-message tokens'.
|
||||
|
|
@ -159,10 +157,41 @@ class TestRepresentationV2AdditiveFields:
|
|||
"hit_batch_token_cap",
|
||||
"hit_input_token_cap",
|
||||
"observer_count",
|
||||
"exact_dup_in_batch_count",
|
||||
"exact_dup_existing_count",
|
||||
"semantic_dup_rejected_count",
|
||||
"semantic_dup_replaced_count",
|
||||
):
|
||||
assert field in data, f"missing field: {field}"
|
||||
assert data["hit_batch_token_cap"] is True
|
||||
|
||||
def test_dedup_count_fields_round_trip(self):
|
||||
event = RepresentationCompletedEvent(
|
||||
workspace_name="ws",
|
||||
session_name="s",
|
||||
observed="user",
|
||||
queue_items_processed=1,
|
||||
earliest_message_id="m1",
|
||||
latest_message_id="m1",
|
||||
message_count=1,
|
||||
explicit_conclusion_count=0,
|
||||
context_preparation_ms=10.0,
|
||||
llm_call_ms=100.0,
|
||||
total_duration_ms=110.0,
|
||||
input_tokens=100,
|
||||
total_input_tokens=200,
|
||||
output_tokens=50,
|
||||
exact_dup_in_batch_count=2,
|
||||
exact_dup_existing_count=3,
|
||||
semantic_dup_rejected_count=4,
|
||||
semantic_dup_replaced_count=5,
|
||||
)
|
||||
data = event.model_dump(mode="json")
|
||||
assert data["exact_dup_in_batch_count"] == 2
|
||||
assert data["exact_dup_existing_count"] == 3
|
||||
assert data["semantic_dup_rejected_count"] == 4
|
||||
assert data["semantic_dup_replaced_count"] == 5
|
||||
|
||||
|
||||
class TestHitInputTokenCapFlag:
|
||||
"""`HonchoLLMCallResponse.hit_input_token_cap` is the bridge between the
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ def _make_deriver_settings(
|
|||
*,
|
||||
MAX_INPUT_TOKENS: int = 25000,
|
||||
MAX_CUSTOM_INSTRUCTIONS_TOKENS: int = 2000,
|
||||
REPRESENTATION_BATCH_MAX_TOKENS: int = 1024,
|
||||
REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS: int = 512,
|
||||
REPRESENTATION_BATCH_TARGET_INPUT_TOKENS: int = 1024,
|
||||
REPRESENTATION_BATCH_MAX_AGE_SECONDS: int = 1800,
|
||||
) -> DeriverSettings:
|
||||
return DeriverSettings(
|
||||
|
|
@ -17,7 +18,8 @@ def _make_deriver_settings(
|
|||
),
|
||||
MAX_INPUT_TOKENS=MAX_INPUT_TOKENS,
|
||||
MAX_CUSTOM_INSTRUCTIONS_TOKENS=MAX_CUSTOM_INSTRUCTIONS_TOKENS,
|
||||
REPRESENTATION_BATCH_MAX_TOKENS=REPRESENTATION_BATCH_MAX_TOKENS,
|
||||
REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS,
|
||||
REPRESENTATION_BATCH_TARGET_INPUT_TOKENS=REPRESENTATION_BATCH_TARGET_INPUT_TOKENS,
|
||||
REPRESENTATION_BATCH_MAX_AGE_SECONDS=REPRESENTATION_BATCH_MAX_AGE_SECONDS,
|
||||
)
|
||||
|
||||
|
|
@ -50,3 +52,32 @@ def test_representation_batch_age_can_be_disabled_with_zero() -> None:
|
|||
def test_representation_batch_age_rejects_negative_values() -> None:
|
||||
with pytest.raises(ValueError, match="greater than or equal to 0"):
|
||||
_make_deriver_settings(REPRESENTATION_BATCH_MAX_AGE_SECONDS=-1)
|
||||
|
||||
|
||||
def test_representation_batch_work_unit_target_can_be_disabled_with_zero() -> None:
|
||||
settings = _make_deriver_settings(REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=0)
|
||||
|
||||
assert settings.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS == 0
|
||||
|
||||
|
||||
def test_representation_batch_work_unit_target_rejects_negative_values() -> None:
|
||||
with pytest.raises(ValueError, match="greater than or equal to 0"):
|
||||
_make_deriver_settings(REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=-1)
|
||||
|
||||
|
||||
def test_representation_batch_tokens_can_diverge() -> None:
|
||||
settings = _make_deriver_settings(
|
||||
REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS=4096,
|
||||
REPRESENTATION_BATCH_TARGET_INPUT_TOKENS=1024,
|
||||
)
|
||||
|
||||
assert settings.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS == 4096
|
||||
assert settings.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS == 1024
|
||||
|
||||
|
||||
def test_representation_batch_target_input_cannot_exceed_max_input_tokens() -> None:
|
||||
with pytest.raises(ValueError, match="cannot exceed max deriver input tokens"):
|
||||
_make_deriver_settings(
|
||||
MAX_INPUT_TOKENS=1000,
|
||||
REPRESENTATION_BATCH_TARGET_INPUT_TOKENS=2048,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -357,6 +357,7 @@ class UnifiedTestExecutor:
|
|||
session=step.session_id,
|
||||
target=step.observed_peer_id,
|
||||
reasoning_level=step.reasoning_level,
|
||||
response_format=step.response_format,
|
||||
)
|
||||
return response
|
||||
|
||||
|
|
|
|||
|
|
@ -149,6 +149,9 @@ class QueryAction(TestStep):
|
|||
# for chat - reasoning level
|
||||
reasoning_level: ReasoningLevel | None = None
|
||||
|
||||
# for chat - optional JSON Schema the response must conform to
|
||||
response_format: dict[str, Any] | None = None
|
||||
|
||||
assertions: list[
|
||||
LLMJudgeAssertion
|
||||
| ContainsAssertion
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
{
|
||||
"description": "Dialectic chat with a response_format JSON Schema while the agent must use tools (reasoning off + enumeration question forces grep/search calls). Exercises the transport-layer combination of tool calling and structured output on every provider: OpenAI must avoid parse() for non-strict tools, Anthropic must skip the '{' prefill, Gemini must fall back to a schema instruction. The final answer must be a JSON string conforming to the schema.",
|
||||
"workspace_config": {},
|
||||
"steps": [
|
||||
{
|
||||
"step_type": "create_session",
|
||||
"session_id": "structured_output_test",
|
||||
"config": {
|
||||
"reasoning": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"peer_configs": {
|
||||
"user": {
|
||||
"observe_me": true,
|
||||
"observe_others": false
|
||||
},
|
||||
"assistant": {
|
||||
"observe_me": false,
|
||||
"observe_others": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"step_type": "add_messages",
|
||||
"session_id": "structured_output_test",
|
||||
"messages": [
|
||||
{
|
||||
"peer_id": "user",
|
||||
"content": "Monday I grabbed a $5 latte at Starbucks before my standup.",
|
||||
"created_at": "2024-03-04T08:30:00"
|
||||
},
|
||||
{
|
||||
"peer_id": "assistant",
|
||||
"content": "Nice, a classic way to start the week.",
|
||||
"created_at": "2024-03-04T08:31:00"
|
||||
},
|
||||
{
|
||||
"peer_id": "user",
|
||||
"content": "Tuesday I tried a $4 cold brew from Blue Bottle, really smooth.",
|
||||
"created_at": "2024-03-05T09:15:00"
|
||||
},
|
||||
{
|
||||
"peer_id": "assistant",
|
||||
"content": "Blue Bottle makes a solid cold brew.",
|
||||
"created_at": "2024-03-05T09:16:00"
|
||||
},
|
||||
{
|
||||
"peer_id": "user",
|
||||
"content": "Wednesday was a $6 oat-milk mocha at a little place downtown.",
|
||||
"created_at": "2024-03-06T08:45:00"
|
||||
},
|
||||
{
|
||||
"peer_id": "assistant",
|
||||
"content": "Oat milk mochas are underrated.",
|
||||
"created_at": "2024-03-06T08:46:00"
|
||||
},
|
||||
{
|
||||
"peer_id": "user",
|
||||
"content": "Thursday I skipped coffee and just had tea at home.",
|
||||
"created_at": "2024-03-07T08:20:00"
|
||||
},
|
||||
{
|
||||
"peer_id": "assistant",
|
||||
"content": "A calm morning, sounds good.",
|
||||
"created_at": "2024-03-07T08:21:00"
|
||||
},
|
||||
{
|
||||
"peer_id": "user",
|
||||
"content": "Friday I splurged on a $7 pour-over at the roastery near the office.",
|
||||
"created_at": "2024-03-08T08:50:00"
|
||||
},
|
||||
{
|
||||
"peer_id": "assistant",
|
||||
"content": "Ending the week strong!",
|
||||
"created_at": "2024-03-08T08:51:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"step_type": "wait",
|
||||
"target": "queue_empty",
|
||||
"timeout": 180,
|
||||
"flush": true
|
||||
},
|
||||
{
|
||||
"step_type": "query",
|
||||
"description": "Global query + empty prefetch forces tool calls; response_format forces structured output on the same LLM calls",
|
||||
"target": "chat",
|
||||
"observer_peer_id": "assistant",
|
||||
"observed_peer_id": "user",
|
||||
"reasoning_level": "max",
|
||||
"input": "How many separate coffees did I buy this week, and exactly how much did I spend in total across all of them?",
|
||||
"response_format": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"coffee_count": {
|
||||
"type": "integer",
|
||||
"description": "How many separate coffees the user bought during the week"
|
||||
},
|
||||
"total_spent_usd": {
|
||||
"type": "number",
|
||||
"description": "Total amount in US dollars the user spent on coffee"
|
||||
},
|
||||
"purchases": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "One short entry per coffee purchase, including its price"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "One-sentence answer to the question"
|
||||
}
|
||||
},
|
||||
"required": ["coffee_count", "total_spent_usd", "purchases", "summary"]
|
||||
},
|
||||
"assertions": [
|
||||
{
|
||||
"assertion_type": "json_match",
|
||||
"key_value_pairs": {
|
||||
"coffee_count": 4,
|
||||
"total_spent_usd": 22
|
||||
}
|
||||
},
|
||||
{
|
||||
"assertion_type": "llm_judge",
|
||||
"prompt": "The result must be a JSON object whose 'purchases' array enumerates the $5 latte, $4 cold brew, $6 mocha, and $7 pour-over (wording may vary), and whose 'summary' answers that the user bought 4 coffees for $22 total.",
|
||||
"pass_if": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -248,6 +248,36 @@ class TestCreateObservations:
|
|||
assert doc.level == "deductive"
|
||||
assert doc.source_ids == ["premise1", "premise2"]
|
||||
|
||||
async def test_non_deriver_context_rejects_explicit(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
make_tool_context: Callable[..., ToolContext],
|
||||
):
|
||||
"""Session-purity invariant: agents without current_messages (dreamer
|
||||
specialists, dialectic) must not create explicit-level observations,
|
||||
even when they pass level='explicit' to the generic tool."""
|
||||
ctx = make_tool_context(current_messages=None)
|
||||
|
||||
result = await _handle_create_observations(
|
||||
ctx,
|
||||
{
|
||||
"observations": [
|
||||
{"content": "Claims to be a doctor", "level": "explicit"},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "ERROR" in result
|
||||
assert "explicit" in result
|
||||
|
||||
# Verify nothing landed in the DB
|
||||
stmt = select(models.Document).where(
|
||||
models.Document.content == "Claims to be a doctor"
|
||||
)
|
||||
doc = (await db_session.execute(stmt)).scalar_one_or_none()
|
||||
assert doc is None
|
||||
|
||||
async def test_source_ids_display_prefix_is_stripped(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
|
|
@ -318,10 +348,10 @@ class TestCreateObservations:
|
|||
observer: str,
|
||||
observed: str,
|
||||
deduplicate: bool = False,
|
||||
) -> list[Any]:
|
||||
) -> crud.CreateDocumentsResult:
|
||||
_ = (workspace_name, observer, observed, deduplicate)
|
||||
created_documents.extend(documents)
|
||||
return documents
|
||||
return crud.CreateDocumentsResult(created_documents=documents)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.utils.agent_tools.embedding_client.simple_batch_embed",
|
||||
|
|
@ -379,10 +409,10 @@ class TestCreateObservations:
|
|||
observer: str,
|
||||
observed: str,
|
||||
deduplicate: bool = False,
|
||||
) -> list[Any]:
|
||||
) -> crud.CreateDocumentsResult:
|
||||
_ = (workspace_name, observer, observed, deduplicate)
|
||||
created_documents.extend(documents)
|
||||
return documents
|
||||
return crud.CreateDocumentsResult(created_documents=documents)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.utils.agent_tools.embedding_client.simple_batch_embed",
|
||||
|
|
@ -438,10 +468,10 @@ class TestCreateObservations:
|
|||
observer: str,
|
||||
observed: str,
|
||||
deduplicate: bool = False,
|
||||
) -> list[Any]:
|
||||
) -> crud.CreateDocumentsResult:
|
||||
_ = (workspace_name, observer, observed, deduplicate)
|
||||
created_documents.extend(documents)
|
||||
return documents
|
||||
return crud.CreateDocumentsResult(created_documents=documents)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.utils.agent_tools.embedding_client.simple_batch_embed",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,897 @@
|
|||
"""Unit tests for src/utils/schema_conversion.py."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from src.utils.schema_conversion import json_response_schema_to_pydantic
|
||||
|
||||
|
||||
def _object(properties: dict[str, Any], **extra: Any) -> dict[str, Any]:
|
||||
return {"type": "object", "properties": properties, **extra}
|
||||
|
||||
|
||||
class TestPrimitives:
|
||||
def test_flat_object_with_primitives(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
"score": {"type": "number"},
|
||||
"active": {"type": "boolean"},
|
||||
},
|
||||
required=["name", "age"],
|
||||
)
|
||||
)
|
||||
instance = model.model_validate(
|
||||
{"name": "ada", "age": 36, "score": 9.5, "active": True}
|
||||
)
|
||||
assert instance.name == "ada" # pyright: ignore
|
||||
assert instance.age == 36 # pyright: ignore
|
||||
|
||||
def test_required_field_missing_fails(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object({"name": {"type": "string"}}, required=["name"])
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
model.model_validate({})
|
||||
|
||||
def test_optional_field_defaults_to_none(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object({"nickname": {"type": "string"}})
|
||||
)
|
||||
instance = model.model_validate({})
|
||||
assert instance.nickname is None # pyright: ignore
|
||||
|
||||
def test_default_value(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object({"count": {"type": "integer", "default": 3}})
|
||||
)
|
||||
assert model.model_validate({}).count == 3 # pyright: ignore
|
||||
|
||||
def test_null_type(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object({"nothing": {"type": "null"}}, required=["nothing"])
|
||||
)
|
||||
assert model.model_validate({"nothing": None}).nothing is None # pyright: ignore
|
||||
|
||||
def test_default_wins_over_required(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object({"count": {"type": "integer", "default": 3}}, required=["count"])
|
||||
)
|
||||
assert model.model_validate({}).count == 3 # pyright: ignore
|
||||
|
||||
|
||||
class TestNesting:
|
||||
def test_nested_object(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{
|
||||
"address": _object(
|
||||
{
|
||||
"city": {"type": "string"},
|
||||
"geo": _object(
|
||||
{"lat": {"type": "number"}}, required=["lat"]
|
||||
),
|
||||
},
|
||||
required=["city", "geo"],
|
||||
)
|
||||
},
|
||||
required=["address"],
|
||||
)
|
||||
)
|
||||
instance = model.model_validate(
|
||||
{"address": {"city": "oakland", "geo": {"lat": 37.8}}}
|
||||
)
|
||||
assert instance.address.geo.lat == 37.8 # pyright: ignore
|
||||
|
||||
def test_array_of_objects(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": _object(
|
||||
{"food": {"type": "string"}}, required=["food"]
|
||||
),
|
||||
}
|
||||
},
|
||||
required=["items"],
|
||||
)
|
||||
)
|
||||
instance = model.model_validate({"items": [{"food": "sushi"}]})
|
||||
assert instance.items[0].food == "sushi" # pyright: ignore
|
||||
|
||||
def test_array_without_items_accepts_anything(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object({"stuff": {"type": "array"}}, required=["stuff"])
|
||||
)
|
||||
instance = model.model_validate({"stuff": [1, "two", {"three": 3}]})
|
||||
assert len(instance.stuff) == 3 # pyright: ignore
|
||||
|
||||
def test_nested_model_name_collision(self):
|
||||
# Two sibling objects whose name hints collide must not clash.
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{
|
||||
"a": _object({"x b": _object({"v": {"type": "string"}})}),
|
||||
"a_x": _object({"b": _object({"v": {"type": "integer"}})}),
|
||||
}
|
||||
)
|
||||
)
|
||||
instance = model.model_validate(
|
||||
{"a": {"x b": {"v": "s"}}, "a_x": {"b": {"v": 1}}}
|
||||
)
|
||||
assert instance.a_x.b.v == 1 # pyright: ignore
|
||||
|
||||
|
||||
class TestEnumsAndUnions:
|
||||
def test_string_enum(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{"sentiment": {"enum": ["loves", "hates"]}},
|
||||
required=["sentiment"],
|
||||
)
|
||||
)
|
||||
assert model.model_validate({"sentiment": "loves"}).sentiment == "loves" # pyright: ignore
|
||||
with pytest.raises(ValidationError):
|
||||
model.model_validate({"sentiment": "meh"})
|
||||
|
||||
def test_int_enum_and_null_member(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object({"level": {"enum": [1, 2, None]}}, required=["level"])
|
||||
)
|
||||
assert model.model_validate({"level": None}).level is None # pyright: ignore
|
||||
assert model.model_validate({"level": 2}).level == 2 # pyright: ignore
|
||||
|
||||
def test_invalid_enum_value_type(self):
|
||||
with pytest.raises(ValueError, match="enum values"):
|
||||
json_response_schema_to_pydantic(_object({"bad": {"enum": [[1]]}}))
|
||||
|
||||
def test_anyof_with_null_is_optional(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{"maybe": {"anyOf": [{"type": "string"}, {"type": "null"}]}},
|
||||
required=["maybe"],
|
||||
)
|
||||
)
|
||||
assert model.model_validate({"maybe": None}).maybe is None # pyright: ignore
|
||||
assert model.model_validate({"maybe": "x"}).maybe == "x" # pyright: ignore
|
||||
|
||||
def test_oneof_union(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{"value": {"oneOf": [{"type": "integer"}, {"type": "string"}]}},
|
||||
required=["value"],
|
||||
)
|
||||
)
|
||||
assert model.model_validate({"value": 5}).value == 5 # pyright: ignore
|
||||
|
||||
def test_type_list_form(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object({"name": {"type": ["string", "null"]}}, required=["name"])
|
||||
)
|
||||
assert model.model_validate({"name": None}).name is None # pyright: ignore
|
||||
|
||||
def test_all_null_enum_degenerates_to_none(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object({"nothing": {"enum": [None]}}, required=["nothing"])
|
||||
)
|
||||
assert model.model_validate({"nothing": None}).nothing is None # pyright: ignore
|
||||
with pytest.raises(ValidationError):
|
||||
model.model_validate({"nothing": "x"})
|
||||
|
||||
def test_union_of_objects(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{
|
||||
"pet": {
|
||||
"anyOf": [
|
||||
_object({"meows": {"type": "boolean"}}, required=["meows"]),
|
||||
_object({"barks": {"type": "boolean"}}, required=["barks"]),
|
||||
]
|
||||
}
|
||||
},
|
||||
required=["pet"],
|
||||
)
|
||||
)
|
||||
instance = model.model_validate({"pet": {"barks": True}})
|
||||
assert instance.pet.barks is True # pyright: ignore
|
||||
|
||||
|
||||
class TestRejections:
|
||||
@pytest.mark.parametrize(
|
||||
"construct,schema",
|
||||
[
|
||||
("$defs", _object({"a": {"type": "object", "$defs": {}}})),
|
||||
("definitions", _object({"a": {"type": "object", "definitions": {}}})),
|
||||
("allOf", _object({"a": {"allOf": [{"type": "string"}]}})),
|
||||
("not", _object({"a": {"not": {"type": "string"}}})),
|
||||
("if", _object({"a": {"if": {"type": "string"}}})),
|
||||
(
|
||||
"patternProperties",
|
||||
_object({"a": {"type": "object", "patternProperties": {}}}),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_unsupported_constructs(self, construct: str, schema: dict[str, Any]):
|
||||
with pytest.raises(ValueError, match=re.escape(construct)):
|
||||
json_response_schema_to_pydantic(schema)
|
||||
|
||||
def test_error_message_includes_path(self):
|
||||
with pytest.raises(
|
||||
ValueError, match=r"unsupported \$ref '#/x'.*at properties\.address"
|
||||
):
|
||||
json_response_schema_to_pydantic(_object({"address": {"$ref": "#/x"}}))
|
||||
|
||||
def test_schema_valued_additional_properties(self):
|
||||
with pytest.raises(ValueError, match="additionalProperties"):
|
||||
json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{
|
||||
"map": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_boolean_schema(self):
|
||||
with pytest.raises(ValueError, match="boolean schemas"):
|
||||
json_response_schema_to_pydantic(_object({"anything": True}))
|
||||
|
||||
def test_unknown_type(self):
|
||||
with pytest.raises(ValueError, match="unsupported type 'date'"):
|
||||
json_response_schema_to_pydantic(_object({"when": {"type": "date"}}))
|
||||
|
||||
|
||||
class TestRefs:
|
||||
def test_ref_into_defs(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{"address": {"$ref": "#/$defs/Address"}},
|
||||
required=["address"],
|
||||
**{
|
||||
"$defs": {
|
||||
"Address": _object(
|
||||
{"city": {"type": "string"}}, required=["city"]
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
instance = model.model_validate({"address": {"city": "Berlin"}})
|
||||
assert instance.address.city == "Berlin" # pyright: ignore
|
||||
|
||||
def test_ref_into_definitions_alias(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{"item": {"$ref": "#/definitions/Item"}},
|
||||
required=["item"],
|
||||
definitions={"Item": {"type": "string"}},
|
||||
)
|
||||
)
|
||||
assert model.model_validate({"item": "x"}).item == "x" # pyright: ignore
|
||||
|
||||
def test_pydantic_nested_model_schema(self):
|
||||
"""The real-world motivation: model_json_schema() of a nested Pydantic
|
||||
model emits $defs/$ref and must convert cleanly."""
|
||||
|
||||
class Preference(BaseModel):
|
||||
food: str
|
||||
confidence: float
|
||||
|
||||
class Preferences(BaseModel):
|
||||
preferences: list[Preference]
|
||||
summary: str
|
||||
|
||||
model = json_response_schema_to_pydantic(Preferences.model_json_schema())
|
||||
instance = model.model_validate(
|
||||
{
|
||||
"preferences": [{"food": "sushi", "confidence": 0.9}],
|
||||
"summary": "likes sushi",
|
||||
}
|
||||
)
|
||||
assert instance.preferences[0].food == "sushi" # pyright: ignore
|
||||
|
||||
def test_root_ref(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
{
|
||||
"$ref": "#/$defs/Root",
|
||||
"$defs": {
|
||||
"Root": _object({"ok": {"type": "boolean"}}, required=["ok"])
|
||||
},
|
||||
}
|
||||
)
|
||||
assert model.model_validate({"ok": True}).ok is True # pyright: ignore
|
||||
|
||||
def test_ref_sibling_keys_overlay_target(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{"count": {"$ref": "#/$defs/Count", "default": 3}},
|
||||
**{"$defs": {"Count": {"type": "integer"}}},
|
||||
)
|
||||
)
|
||||
assert model.model_validate({}).count == 3 # pyright: ignore
|
||||
|
||||
def test_same_def_referenced_twice(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{
|
||||
"home": {"$ref": "#/$defs/Address"},
|
||||
"work": {"$ref": "#/$defs/Address"},
|
||||
},
|
||||
required=["home", "work"],
|
||||
**{"$defs": {"Address": _object({"city": {"type": "string"}})}},
|
||||
)
|
||||
)
|
||||
instance = model.model_validate(
|
||||
{"home": {"city": "Berlin"}, "work": {"city": "Kyiv"}}
|
||||
)
|
||||
assert instance.work.city == "Kyiv" # pyright: ignore
|
||||
|
||||
def test_chained_refs(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{"a": {"$ref": "#/$defs/A"}},
|
||||
required=["a"],
|
||||
**{"$defs": {"A": {"$ref": "#/$defs/B"}, "B": {"type": "string"}}},
|
||||
)
|
||||
)
|
||||
assert model.model_validate({"a": "x"}).a == "x" # pyright: ignore
|
||||
|
||||
def test_unreferenced_invalid_def_is_ignored(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{"name": {"type": "string"}},
|
||||
**{"$defs": {"Broken": {"allOf": [{"type": "string"}]}}},
|
||||
)
|
||||
)
|
||||
assert model.model_validate({"name": "x"}).name == "x" # pyright: ignore
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ref",
|
||||
["#", "#/x", "#/$defs/a/b", "#/properties/a", "https://x.dev/s.json#/$defs/X"],
|
||||
)
|
||||
def test_unsupported_ref_forms(self, ref: str):
|
||||
with pytest.raises(ValueError, match=r"unsupported \$ref"):
|
||||
json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{"a": {"$ref": ref}},
|
||||
**{"$defs": {"a": {"type": "string"}}},
|
||||
)
|
||||
)
|
||||
|
||||
def test_unknown_definition(self):
|
||||
with pytest.raises(ValueError, match="unknown definition"):
|
||||
json_response_schema_to_pydantic(
|
||||
_object({"a": {"$ref": "#/$defs/Missing"}}, **{"$defs": {}})
|
||||
)
|
||||
|
||||
def test_direct_recursion_rejected(self):
|
||||
with pytest.raises(ValueError, match=r"recursive \$ref.*cycle: Node -> Node"):
|
||||
json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{"tree": {"$ref": "#/$defs/Node"}},
|
||||
**{
|
||||
"$defs": {
|
||||
"Node": _object(
|
||||
{
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Node"},
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def test_mutual_recursion_rejected(self):
|
||||
with pytest.raises(ValueError, match=r"cycle: A -> B -> A"):
|
||||
json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{"a": {"$ref": "#/$defs/A"}},
|
||||
**{
|
||||
"$defs": {
|
||||
"A": _object({"b": {"$ref": "#/$defs/B"}}),
|
||||
"B": _object({"a": {"$ref": "#/$defs/A"}}),
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def test_recursive_pydantic_model_rejected(self):
|
||||
class Node(BaseModel):
|
||||
value: str
|
||||
children: list["Node"] = []
|
||||
|
||||
with pytest.raises(ValueError, match=r"recursive \$ref"):
|
||||
json_response_schema_to_pydantic(Node.model_json_schema())
|
||||
|
||||
def test_ref_expansion_counts_against_node_budget(self):
|
||||
"""A doubling ref chain (billion laughs) is stopped by max_nodes."""
|
||||
defs = {
|
||||
f"L{i}": _object(
|
||||
{
|
||||
"a": {"$ref": f"#/$defs/L{i + 1}"},
|
||||
"b": {"$ref": f"#/$defs/L{i + 1}"},
|
||||
}
|
||||
)
|
||||
for i in range(10)
|
||||
}
|
||||
defs["L10"] = {"type": "string"}
|
||||
with pytest.raises(ValueError, match="maximum of .* nodes"):
|
||||
json_response_schema_to_pydantic(
|
||||
_object({"root": {"$ref": "#/$defs/L0"}}, **{"$defs": defs})
|
||||
)
|
||||
|
||||
def test_duplicate_name_across_defs_and_definitions(self):
|
||||
with pytest.raises(ValueError, match="appears in both"):
|
||||
json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{"a": {"$ref": "#/$defs/X"}},
|
||||
**{
|
||||
"$defs": {"X": {"type": "string"}},
|
||||
"definitions": {"X": {"type": "integer"}},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def test_root_must_be_object(self):
|
||||
with pytest.raises(ValueError, match="root schema"):
|
||||
json_response_schema_to_pydantic({"type": "string"})
|
||||
|
||||
def test_root_must_be_dict(self):
|
||||
with pytest.raises(ValueError, match="JSON Schema object"):
|
||||
json_response_schema_to_pydantic(["not", "a", "schema"]) # pyright: ignore
|
||||
|
||||
def test_no_recognizable_type(self):
|
||||
with pytest.raises(ValueError, match="no recognizable type"):
|
||||
json_response_schema_to_pydantic(_object({"mystery": {}}))
|
||||
|
||||
def test_depth_limit(self):
|
||||
schema: dict[str, Any] = {"type": "string"}
|
||||
for _ in range(25):
|
||||
schema = _object({"inner": schema})
|
||||
with pytest.raises(ValueError, match="maximum depth"):
|
||||
json_response_schema_to_pydantic(schema)
|
||||
|
||||
def test_node_limit(self):
|
||||
schema = _object({f"field_{i}": {"type": "string"} for i in range(600)})
|
||||
with pytest.raises(ValueError, match="maximum of 500 nodes"):
|
||||
json_response_schema_to_pydantic(schema)
|
||||
|
||||
def test_property_schema_not_an_object(self):
|
||||
with pytest.raises(ValueError, match="schema must be an object"):
|
||||
json_response_schema_to_pydantic(_object({"a": "string"}))
|
||||
|
||||
@pytest.mark.parametrize("members", [[], "not-a-list"])
|
||||
def test_malformed_anyof(self, members: Any):
|
||||
with pytest.raises(ValueError, match="'anyOf' must be a non-empty array"):
|
||||
json_response_schema_to_pydantic(_object({"a": {"anyOf": members}}))
|
||||
|
||||
def test_empty_type_list(self):
|
||||
with pytest.raises(ValueError, match="'type' array must not be empty"):
|
||||
json_response_schema_to_pydantic(_object({"a": {"type": []}}))
|
||||
|
||||
@pytest.mark.parametrize("values", [[], "loves"])
|
||||
def test_malformed_enum(self, values: Any):
|
||||
with pytest.raises(ValueError, match="'enum' must be a non-empty array"):
|
||||
json_response_schema_to_pydantic(_object({"a": {"enum": values}}))
|
||||
|
||||
def test_properties_not_an_object(self):
|
||||
with pytest.raises(ValueError, match="'properties' must be an object"):
|
||||
json_response_schema_to_pydantic({"type": "object", "properties": []})
|
||||
|
||||
@pytest.mark.parametrize("required", ["a", [1]])
|
||||
def test_malformed_required(self, required: Any):
|
||||
with pytest.raises(ValueError, match="'required' must be an array of strings"):
|
||||
json_response_schema_to_pydantic(
|
||||
_object({"a": {"type": "string"}}, required=required)
|
||||
)
|
||||
|
||||
def test_empty_property_name(self):
|
||||
with pytest.raises(ValueError, match="property names"):
|
||||
json_response_schema_to_pydantic(_object({"": {"type": "string"}}))
|
||||
|
||||
|
||||
class TestLenientAcceptance:
|
||||
def test_additional_properties_false_ignored(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{"known": {"type": "string"}},
|
||||
required=["known"],
|
||||
additionalProperties=False,
|
||||
)
|
||||
)
|
||||
instance = model.model_validate({"known": "x", "extra": "dropped"})
|
||||
assert instance.model_dump() == {"known": "x"}
|
||||
|
||||
def test_root_dollar_schema_ignored(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {"a": {"type": "string"}},
|
||||
}
|
||||
)
|
||||
assert issubclass(model, BaseModel)
|
||||
|
||||
def test_empty_properties(self):
|
||||
model = json_response_schema_to_pydantic({"type": "object", "properties": {}})
|
||||
assert model.model_validate({}).model_dump() == {}
|
||||
|
||||
def test_missing_properties_with_object_type(self):
|
||||
model = json_response_schema_to_pydantic({"type": "object"})
|
||||
assert model.model_validate({"anything": 1}).model_dump() == {}
|
||||
|
||||
def test_missing_type_with_properties_treated_as_object(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
{"properties": {"a": {"type": "string"}}, "required": ["a"]}
|
||||
)
|
||||
assert model.model_validate({"a": "x"}).a == "x" # pyright: ignore
|
||||
|
||||
def test_required_naming_unknown_property_ignored(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object({"a": {"type": "string"}}, required=["a", "ghost"])
|
||||
)
|
||||
assert model.model_validate({"a": "x"}).a == "x" # pyright: ignore
|
||||
|
||||
|
||||
class TestFieldMetadata:
|
||||
def test_description_propagates(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object({"food": {"type": "string", "description": "A food item"}})
|
||||
)
|
||||
generated = model.model_json_schema()
|
||||
assert generated["properties"]["food"]["description"] == "A food item"
|
||||
|
||||
def test_constraint_hints_pass_through_unenforced(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 3,
|
||||
}
|
||||
},
|
||||
required=["tags"],
|
||||
)
|
||||
)
|
||||
generated = model.model_json_schema()
|
||||
assert generated["properties"]["tags"]["maxItems"] == 3
|
||||
# Not enforced: more than maxItems still validates.
|
||||
instance = model.model_validate({"tags": ["a", "b", "c", "d"]})
|
||||
assert len(instance.tags) == 4 # pyright: ignore
|
||||
|
||||
def test_non_identifier_key_alias_round_trip(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{"my-key": {"type": "string"}, "_private": {"type": "integer"}},
|
||||
required=["my-key"],
|
||||
)
|
||||
)
|
||||
instance = model.model_validate({"my-key": "v", "_private": 7})
|
||||
dumped = instance.model_dump_json(by_alias=True)
|
||||
assert '"my-key":"v"' in dumped
|
||||
assert '"_private":7' in dumped
|
||||
|
||||
def test_digit_leading_key_gets_field_prefix(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object({"123": {"type": "integer"}}, required=["123"])
|
||||
)
|
||||
instance = model.model_validate({"123": 7})
|
||||
assert instance.model_dump(by_alias=True) == {"123": 7}
|
||||
|
||||
def test_sanitized_key_collision_round_trip(self):
|
||||
# "my-key" sanitizes to "my_key", which then collides with the real
|
||||
# "my_key" property; both must survive with their original JSON keys.
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object(
|
||||
{"my-key": {"type": "string"}, "my_key": {"type": "integer"}},
|
||||
required=["my-key", "my_key"],
|
||||
)
|
||||
)
|
||||
instance = model.model_validate({"my-key": "v", "my_key": 7})
|
||||
assert instance.model_dump(by_alias=True) == {"my-key": "v", "my_key": 7}
|
||||
|
||||
def test_digit_leading_model_name(self):
|
||||
model = json_response_schema_to_pydantic(
|
||||
_object({"a": {"type": "string"}}), model_name="123"
|
||||
)
|
||||
assert model.__name__ == "Model123"
|
||||
|
||||
|
||||
class TestZodCompatibility:
|
||||
def test_zod4_tojsonschema_output_converts(self):
|
||||
# Captured shape of zod 4's z.toJSONSchema() for
|
||||
# z.object({ preferences: z.array(z.object({ food: z.string(),
|
||||
# sentiment: z.enum(["loves","hates"]) })), summary: z.string(),
|
||||
# note: z.string().optional() })
|
||||
schema = {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"preferences": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"food": {"type": "string"},
|
||||
"sentiment": {
|
||||
"type": "string",
|
||||
"enum": ["loves", "hates"],
|
||||
},
|
||||
},
|
||||
"required": ["food", "sentiment"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"summary": {"type": "string"},
|
||||
"note": {"type": "string"},
|
||||
},
|
||||
"required": ["preferences", "summary"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
model = json_response_schema_to_pydantic(schema)
|
||||
instance = model.model_validate(
|
||||
{
|
||||
"preferences": [{"food": "sushi", "sentiment": "loves"}],
|
||||
"summary": "likes sushi",
|
||||
}
|
||||
)
|
||||
assert instance.preferences[0].sentiment == "loves" # pyright: ignore
|
||||
assert instance.note is None # pyright: ignore
|
||||
|
||||
|
||||
class TestCustomGuardLimits:
|
||||
def test_custom_max_depth(self):
|
||||
schema: dict[str, Any] = {"type": "string"}
|
||||
for _ in range(5):
|
||||
schema = _object({"inner": schema})
|
||||
with pytest.raises(ValueError, match="maximum depth of 3"):
|
||||
json_response_schema_to_pydantic(schema, max_depth=3)
|
||||
|
||||
def test_custom_max_nodes(self):
|
||||
schema = _object({f"f{i}": {"type": "string"} for i in range(20)})
|
||||
with pytest.raises(ValueError, match="maximum of 10 nodes"):
|
||||
json_response_schema_to_pydantic(schema, max_nodes=10)
|
||||
|
||||
def test_depth_exactly_at_limit_allowed(self):
|
||||
# Leaf sits at depth == max_depth; only depth > max_depth must fail.
|
||||
schema: dict[str, Any] = {"type": "string"}
|
||||
for _ in range(3):
|
||||
schema = _object({"inner": schema})
|
||||
model = json_response_schema_to_pydantic(schema, max_depth=3)
|
||||
assert issubclass(model, BaseModel)
|
||||
|
||||
|
||||
# The wiki spec's own request example (dialectic-enhancements §3.A.1).
|
||||
SPEC_EXAMPLE_SCHEMA = _object(
|
||||
{
|
||||
"preferences": {
|
||||
"type": "array",
|
||||
"items": _object(
|
||||
{
|
||||
"food": {"type": "string"},
|
||||
"sentiment": {
|
||||
"type": "string",
|
||||
"enum": ["loves", "likes", "neutral", "dislikes", "hates"],
|
||||
},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
},
|
||||
required=["food", "sentiment"],
|
||||
),
|
||||
"maxItems": 3,
|
||||
},
|
||||
"summary": {"type": "string"},
|
||||
},
|
||||
required=["preferences", "summary"],
|
||||
)
|
||||
|
||||
|
||||
class TestEndToEnd:
|
||||
"""Table tests running the full pipeline the server runs: convert the
|
||||
caller's schema, validate a payload against the generated model, and
|
||||
serialize it back with model_dump_json(by_alias=True)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema,payload,expected",
|
||||
[
|
||||
pytest.param(
|
||||
SPEC_EXAMPLE_SCHEMA,
|
||||
{
|
||||
"preferences": [
|
||||
{
|
||||
"food": "dark roast coffee",
|
||||
"sentiment": "loves",
|
||||
"confidence": 0.95,
|
||||
},
|
||||
{"food": "sushi", "sentiment": "likes"},
|
||||
],
|
||||
"summary": "Coffee enthusiast.",
|
||||
},
|
||||
{
|
||||
"preferences": [
|
||||
{
|
||||
"food": "dark roast coffee",
|
||||
"sentiment": "loves",
|
||||
"confidence": 0.95,
|
||||
},
|
||||
{"food": "sushi", "sentiment": "likes", "confidence": None},
|
||||
],
|
||||
"summary": "Coffee enthusiast.",
|
||||
},
|
||||
id="spec-example",
|
||||
),
|
||||
pytest.param(
|
||||
_object(
|
||||
{
|
||||
"user": _object(
|
||||
{
|
||||
"name": {"type": "string"},
|
||||
"location": _object(
|
||||
{
|
||||
"lat": {"type": "number"},
|
||||
"lon": {"type": "number"},
|
||||
},
|
||||
required=["lat", "lon"],
|
||||
),
|
||||
},
|
||||
required=["name", "location"],
|
||||
)
|
||||
},
|
||||
required=["user"],
|
||||
),
|
||||
{"user": {"name": "ada", "location": {"lat": 37.8, "lon": -122.3}}},
|
||||
{"user": {"name": "ada", "location": {"lat": 37.8, "lon": -122.3}}},
|
||||
id="nested-three-levels",
|
||||
),
|
||||
pytest.param(
|
||||
_object(
|
||||
{"my-key": {"type": "string"}, "first name": {"type": "string"}},
|
||||
required=["my-key"],
|
||||
),
|
||||
{"my-key": "v", "first name": "Ada"},
|
||||
{"my-key": "v", "first name": "Ada"},
|
||||
id="alias-keys-round-trip",
|
||||
),
|
||||
pytest.param(
|
||||
_object(
|
||||
{
|
||||
"count": {"type": "integer", "default": 3},
|
||||
"tag": {"type": "string", "default": "none"},
|
||||
}
|
||||
),
|
||||
{},
|
||||
{"count": 3, "tag": "none"},
|
||||
id="defaults-fill-omitted-fields",
|
||||
),
|
||||
pytest.param(
|
||||
_object(
|
||||
{
|
||||
"a": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"b": {"type": ["integer", "null"]},
|
||||
},
|
||||
required=["a", "b"],
|
||||
),
|
||||
{"a": None, "b": 2},
|
||||
{"a": None, "b": 2},
|
||||
id="nullable-via-anyof-and-type-list",
|
||||
),
|
||||
pytest.param(
|
||||
_object(
|
||||
{"value": {"oneOf": [{"type": "integer"}, {"type": "string"}]}},
|
||||
required=["value"],
|
||||
),
|
||||
{"value": "five"},
|
||||
{"value": "five"},
|
||||
id="oneof-union-string-member",
|
||||
),
|
||||
pytest.param(
|
||||
_object({"level": {"enum": [1, 2, None]}}, required=["level"]),
|
||||
{"level": None},
|
||||
{"level": None},
|
||||
id="enum-with-null-member",
|
||||
),
|
||||
pytest.param(
|
||||
_object({"stuff": {"type": "array"}}, required=["stuff"]),
|
||||
{"stuff": [1, "two", {"three": 3}, None]},
|
||||
{"stuff": [1, "two", {"three": 3}, None]},
|
||||
id="array-without-items-accepts-anything",
|
||||
),
|
||||
pytest.param(
|
||||
_object(
|
||||
{
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 2,
|
||||
}
|
||||
},
|
||||
required=["tags"],
|
||||
),
|
||||
{"tags": ["a", "b", "c", "d"]},
|
||||
{"tags": ["a", "b", "c", "d"]},
|
||||
id="constraint-hints-not-enforced",
|
||||
),
|
||||
pytest.param(
|
||||
_object({"known": {"type": "string"}}, required=["known"]),
|
||||
{"known": "x", "hallucinated": "dropped"},
|
||||
{"known": "x"},
|
||||
id="extra-keys-dropped",
|
||||
),
|
||||
pytest.param(
|
||||
{"type": "object", "properties": {}},
|
||||
{},
|
||||
{},
|
||||
id="empty-object",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_construct_validate_serialize(
|
||||
self,
|
||||
schema: dict[str, Any],
|
||||
payload: dict[str, Any],
|
||||
expected: dict[str, Any],
|
||||
):
|
||||
model = json_response_schema_to_pydantic(schema)
|
||||
instance = model.model_validate(payload)
|
||||
# by_alias=True mirrors DialecticAgent.answer's serialization.
|
||||
assert json.loads(instance.model_dump_json(by_alias=True)) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema,payload",
|
||||
[
|
||||
pytest.param(
|
||||
SPEC_EXAMPLE_SCHEMA,
|
||||
{"preferences": [{"food": "sushi"}], "summary": "s"},
|
||||
id="missing-required-in-array-item",
|
||||
),
|
||||
pytest.param(
|
||||
SPEC_EXAMPLE_SCHEMA,
|
||||
{
|
||||
"preferences": [{"food": "sushi", "sentiment": "adores"}],
|
||||
"summary": "s",
|
||||
},
|
||||
id="invalid-enum-value",
|
||||
),
|
||||
pytest.param(
|
||||
SPEC_EXAMPLE_SCHEMA,
|
||||
{"preferences": [{"food": "sushi", "sentiment": "likes"}]},
|
||||
id="missing-required-top-level",
|
||||
),
|
||||
pytest.param(
|
||||
_object(
|
||||
{"user": _object({"name": {"type": "string"}}, required=["name"])},
|
||||
required=["user"],
|
||||
),
|
||||
{"user": {}},
|
||||
id="missing-required-nested",
|
||||
),
|
||||
pytest.param(
|
||||
_object({"a": {"type": "string"}}, required=["a"]),
|
||||
{"a": None},
|
||||
id="null-for-non-nullable",
|
||||
),
|
||||
pytest.param(
|
||||
_object({"n": {"type": "integer"}}, required=["n"]),
|
||||
{"n": {"nested": "dict"}},
|
||||
id="wrong-type-for-integer",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_rejects_nonconforming_payloads(
|
||||
self, schema: dict[str, Any], payload: dict[str, Any]
|
||||
):
|
||||
model = json_response_schema_to_pydantic(schema)
|
||||
with pytest.raises(ValidationError):
|
||||
model.model_validate(payload)
|
||||
12
uv.lock
12
uv.lock
|
|
@ -8,7 +8,7 @@ resolution-markers = [
|
|||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-06-27T20:44:32.746059Z"
|
||||
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
|
||||
exclude-newer-span = "P5D"
|
||||
|
||||
[manifest]
|
||||
|
|
@ -275,11 +275,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "cashews"
|
||||
version = "7.4.4"
|
||||
version = "7.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/5d/26eb556824a7ac9e24f751645961d2078b7b15be105f7fc39eda5308896f/cashews-7.4.4.tar.gz", hash = "sha256:dca761c60192bfe354abd6e9eb98d6f62c817e675df3fbe7d1bdfaa4303d1320", size = 92948, upload-time = "2025-12-06T22:31:56.187Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/73/31598b352165cd0f0b777df1eb67e33f334e29fcd7eb4f4bb48a41b9affe/cashews-7.5.0.tar.gz", hash = "sha256:3f88b8c5ced0ea4826915a1ff67055b647252dd65ef25f4813316a6341f00b37", size = 97699, upload-time = "2026-03-02T22:28:52.462Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/65/29d94c27dfa3cdb213ae62a328c6efe6cd37b888d334e90ecaa22eadafe9/cashews-7.4.4-py3-none-any.whl", hash = "sha256:d5b8fc3cb590ed388823388b972947fd5659e2a94109af107cb508a3240f5ef0", size = 79893, upload-time = "2025-12-06T22:31:53.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/14/06cca741567a2ec458fb1db9d053e72477d9da2be387c1d705cb1060b2c6/cashews-7.5.0-py3-none-any.whl", hash = "sha256:e79cb4e5cc164d8f2d2856b166d45dcc2dd8d53b95874d2c6d07dfdb1c9ac3c4", size = 82413, upload-time = "2026-03-02T22:28:50.98Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -1217,7 +1217,7 @@ dev = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.14.0" },
|
||||
{ name = "cashews", extras = ["redis"], specifier = "==7.4.4" },
|
||||
{ name = "cashews", extras = ["redis"], specifier = "==7.5.0" },
|
||||
{ name = "cloudevents", specifier = ">=1.12.0,<2.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.131.0" },
|
||||
{ name = "fastapi-pagination", specifier = ">=0.14.2" },
|
||||
|
|
@ -1302,7 +1302,7 @@ dev = [{ name = "ruff", specifier = ">=0.11.13" }]
|
|||
|
||||
[[package]]
|
||||
name = "honcho-cli"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
source = { editable = "honcho-cli" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue