Merge branch 'main' into vineeth/dev-1997

This commit is contained in:
Vineeth Voruganti 2026-07-24 11:30:32 -04:00
commit 79342b57e5
99 changed files with 7298 additions and 405 deletions

View File

@ -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

19
.github/CODEOWNERS vendored Normal file
View File

@ -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

View File

@ -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

123
.github/workflows/live-llm-tests.yml vendored Normal file
View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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

3
.gitignore vendored
View File

@ -193,3 +193,6 @@ metrics.jsonl
AGENTS.md
lancedb_data/
grafana-data/
# Claude Code addon stuff
.omc

View File

@ -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

View File

@ -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"
]

View File

@ -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
```

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -1,3 +1,3 @@
"""Honcho CLI — a terminal for Honcho."""
__version__ = "0.1.0"
__version__ = "0.1.2"

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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",
};

View File

@ -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,
},
});
}

View File

@ -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",

View File

@ -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(

View File

@ -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):

View File

@ -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(

View File

@ -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
})

View File

@ -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', () => {

View File

@ -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)

View File

@ -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 {

View File

@ -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()

6
src/cache/client.py vendored
View File

@ -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:

View File

@ -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."""

View File

@ -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",

View File

@ -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,

View File

@ -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(

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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")

View File

@ -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,
)
)

View File

@ -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"]

View File

@ -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] = []

View File

@ -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

View File

@ -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,
),
)

View File

@ -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)}",

View File

@ -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(),

View File

@ -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:

View File

@ -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

View File

@ -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).

View File

@ -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],

View File

@ -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.

View File

@ -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",

View File

@ -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

View File

@ -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);

View File

@ -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)"
),
)
# ---------------------------------------------------------------------------

View File

@ -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):

View File

@ -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"

View File

@ -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"

View File

@ -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,
*,

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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()

View File

@ -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()

View File

@ -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."""

View File

@ -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

View File

@ -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]

View File

@ -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"

View File

@ -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"]

View File

@ -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"]

View File

@ -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"

View File

@ -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"

View File

@ -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"]
)

View File

@ -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"

View File

@ -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()

View File

@ -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")}

View File

@ -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()

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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,
)

View File

@ -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

View File

@ -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

View File

@ -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
}
]
}
]
}

View File

@ -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",

View File

@ -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
View File

@ -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" },