Compare commits

..
Author SHA1 Message Date
james.maesandClaude Sonnet 5.5 c5125f9788 fix(assistant): store the transcript before message.end so the panel shows the answer
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 11s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 36s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 7m8s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 23s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 12m50s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 10s
The panel reloads the conversation the moment the stream ends. The
redacted transcript (message parts, pending actions, stored events) was
flushed only after message.end was sent, so that reload read the "[]"
placeholders and showed the newest question and answer as empty bubbles
until the panel was closed and reopened. Persist and flush first, then
emit message.end.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 19:15:44 -06:00
james.maesandCodex 3de664e0fd ci: centralize release branch verification and workflows (#75)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 12:38:46 -05:00
CircleCI 2caa634044 Bump version to 0.1.0-RC.24 [skip ci] 2026-10-16 17:08:39 +00:00
james.maesandClaude Opus 5.5 c77e5b0123 Merge develop into release/0.1 for 0.1.0-RC.24 (#62 optional unreachable providers report DEGRADED)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:59:08 -05:00
james.maes 2f848c08b7 Merge pull request #62 from QRun-IO/feature/assistant-optional-provider-health
fix(assistant): optional unreachable providers degrade health; add provider setup state
2026-10-16 11:51:19 -05:00
james.maesandClaude Opus 5.5 cfad90eaee fix(assistant): optional unreachable providers degrade health; add provider setup state
An enabled provider that is not the default and not the only route for any
configured routing (enabled profile simple/complex model, budget downgrade,
knowledge source embedding, with no fallback on another usable provider) is
optional. When it is unreachable its provider and model checks now report
DEGRADED with a message naming where requests go, instead of turning the
whole "assistant" component DOWN (central health rolls up worst-of; WARNING
severity does not cap DOWN). The default provider and sole routes stay DOWN.
Failed provider probes carry reachable=false so the circuit breaker still
refuses the provider when its central outcome is only DEGRADED.

New assistant_provider.setup_state (Active / Setting up, empty = Active):
a provider that is Setting up is never routed to (default, profile models,
fallbacks, budget downgrades, embeddings) and gets no provider or model
health checks. Additive guarded DDL in docs/schema-postgres.sql; see
UPGRADE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:43:58 -05:00
CircleCI f46735b1c0 Bump version to 0.1.0-RC.23 [skip ci] 2026-10-16 16:04:25 +00:00
github-actions[bot] 86563c64d2 Merge develop into release/0.1 for the next 0.1.0 RC 2026-10-16 15:58:52 +00:00
james.maes 237dbf98d6 Merge pull request #61 from QRun-IO/fix/assistant-health-tile-wording
fix(assistant): worded health tile instead of raw UNKNOWN
2026-10-16 10:20:16 -05:00
james.maesandClaude Opus 5.5 f14e445e9c fix(assistant): worded health tile instead of the raw UNKNOWN enum
The Assistant health tile showed the HealthStatus name (UP/DEGRADED/DOWN/UNKNOWN). It now shows Healthy / Degraded / Down, and "Not checked yet" when no
check has reported, with the check cadence in the context line. The drill to health_check is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:13:32 -05:00
james.maes 3ed622f116 Merge pull request #58 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:03:49 -05:00
CircleCI 82febe0f19 Bump version to 0.1.0-RC.22 [skip ci] 2026-10-16 02:27:26 +00:00
james.maesandClaude Opus 5.5 1ea23dfc49 Merge develop into release/0.1 for 0.1.0-RC.22 (#60 fail-soft assistant server)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:17:28 -05:00
james.maes 54f91107eb Merge pull request #60 from QRun-IO/feature/fail-soft-renders
feat(assistant): fail soft - validated renders, error parts, timeouts, turns that always end, client error reports
2026-10-15 21:10:01 -05:00
CircleCI 9d31de46a6 Bump version to 0.1.0-RC.21 [skip ci] 2026-10-16 01:13:38 +00:00
james.maesandClaude Opus 5.5 24b0b3b14a Merge develop into release/0.1 for 0.1.0-RC.21 (#59 records render always carries rows and columns)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:04:06 -05:00
CircleCI 1c33e97664 Bump version to 0.1.0-RC.20 [skip ci] 2026-10-15 21:41:12 +00:00
james.maesandClaude Opus 5.5 fcaf2ded7e Merge develop into release/0.1 for 0.1.0-RC.20 (#56 UI API 1.6.0 extension, QQQ RC.15)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:30:07 -05:00
james.maesandClaude Opus 5.5 fe2ec09a80 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:22:05 -05:00
CircleCI 2cea8ffd44 Bump version to 0.1.0-RC.19 [skip ci] 2026-10-15 21:12:25 +00:00
james.maesandClaude Opus 5.5 6b98d5216b Merge develop into release/0.1 for 0.1.0-RC.19 (#57 shutdown hook holds the runtime weakly)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:04:27 -05:00
CircleCI 6adf390bf8 Bump version to 0.1.0-RC.18 [skip ci] 2026-10-15 03:36:35 +00:00
james.maesandClaude Opus 5.5 bc92c89665 Merge develop into release/0.1 for 0.1.0-RC.18 (#45 health checks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:29:33 -05:00
CircleCI d43402e262 Bump version to 0.1.0-RC.17 [skip ci] 2026-10-15 02:40:56 +00:00
james.maesandClaude Opus 5.5 fe725ac8ff Merge develop into release/0.1 for 0.1.0-RC.17 (#54 assistant answers in the Jobs tray, QQQ RC.8)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:32:46 -05:00
CircleCI dfb05d8935 Bump version to 0.1.0-RC.16 [skip ci] 2026-10-15 02:20:24 +00:00
james.maesandClaude Opus 5.5 523fd0128a Merge develop into release/0.1 for 0.1.0-RC.16 (#53 help bundle)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:11:51 -05:00
CircleCI 47115fe359 Bump version to 0.1.0-RC.15 [skip ci] 2026-10-15 00:18:50 +00:00
james.maesandClaude Opus 5.5 f2d2b50459 Merge develop into release/0.1 for 0.1.0-RC.15 (#44 transcript redaction)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:10:35 -05:00
CircleCI 8c33db9fcf Bump version to 0.1.0-RC.14 [skip ci] 2026-10-14 23:13:46 +00:00
james.maesandClaude Opus 5.5 75a5dedf7f Merge develop into release/0.1 for 0.1.0-RC.14 (#46, #47 indexes and FKs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:07:05 -05:00
CircleCI c1cceaf4c3 Bump version to 0.1.0-RC.13 [skip ci] 2026-10-14 22:27:30 +00:00
james.maesandClaude Opus 5.5 f8a114bbb8 Merge develop into release/0.1 for 0.1.0-RC.13 (#48 seeds and Setup card, #49 durable turns, #50 seed fixes, #52 rollback)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:20:45 -05:00
CircleCI 3a7ff1c659 Bump version to 0.1.0-RC.12 [skip ci] 2026-10-14 16:03:00 +00:00
james.maesandClaude Opus 5.5 ea81166454 Merge develop into release/0.1 for 0.1.0-RC.12 (#19 #39 #40 #42 #43)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:56:16 -05:00
CircleCI cfcc044055 Bump version to 0.1.0-RC.11 [skip ci] 2026-10-14 12:57:45 +00:00
james.maesandClaude Opus 5.5 620dee488d Merge develop into release/0.1 for 0.1.0-RC.11 (#36 scheduled tasks, #37 provider-api module)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:51:34 -05:00
CircleCI e23206f1e8 Bump version to 0.1.0-RC.10 [skip ci] 2026-10-14 12:12:23 +00:00
james.maesandClaude Opus 5.5 c6bae3bcf2 Merge develop into release/0.1 for 0.1.0-RC.10 (#38 bulk-load tools)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:09:32 -05:00
CircleCI 2d8459bb24 Bump version to 0.1.0-RC.9 [skip ci] 2026-10-14 01:42:57 +00:00
james.maesandClaude Opus 5.5 ac06a96787 Merge develop into release/0.1 for 0.1.0-RC.9 (presentation #32, RAG and contingent PVs #33, demo #34, P-d/P-e)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:39:02 -05:00
CircleCI d4a1f64480 Bump version to 0.1.0-RC.8 [skip ci] 2026-10-14 00:55:57 +00:00
james.maesandClaude Opus 5.5 c28e782075 Merge develop into release/0.1 for 0.1.0-RC.8 (core alerts #31, budgets #26, routing #27)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:52:20 -05:00
CircleCI 5c76a13245 Bump version to 0.1.0-RC.7 [skip ci] 2026-10-13 23:31:30 +00:00
james.maesandClaude Opus 5.5 8d1348888d Merge develop into release/0.1 for 0.1.0-RC.7
Provider/model registry and OpenAI-compatible adapter (#22), Bedrock and
Vertex (#23), data classification, PHI routing and HIPAA mode (#24).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:28:10 -05:00
CircleCI 47cc572c36 Bump version to 0.1.0-RC.6 [skip ci] 2026-10-13 19:51:20 +00:00
james.maesandClaude Opus 5.5 8099f64bf2 Merge develop into release/0.1 for 0.1.0-RC.6
Tools via /qqq/v1 with a loopback stand-in (#14); profiles, skills,
explain access, denials, ArchUnit and the denial matrix (#15); #16; the
demo pending-action card (#18).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:49:14 -05:00
CircleCI ac6ec75deb Bump version to 0.1.0-RC.5 [skip ci] 2026-10-13 18:44:09 +00:00
james.maesandClaude Opus 5.5 1f48a370e3 Merge develop into release/0.1 for 0.1.0-RC.5
Tools are no longer all sent as strict, so Anthropic no longer rejects
every turn with "compiled grammar is too large" (#13).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:40:36 -05:00
CircleCI d0ad2aa0fa Bump version to 0.1.0-RC.4 [skip ci] 2026-10-13 15:24:59 +00:00
james.maesandClaude Opus 5.5 16c8500f59 Merge develop into release/0.1 for 0.1.0-RC.4
App home, provider settings with Test connection, per-call usage and
model prices (#12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:23:04 -05:00
CircleCI a8c5f586ee Bump version to 0.1.0-RC.3 [skip ci] 2026-10-12 21:22:35 +00:00
james.maesandClaude Opus 5.5 3dfa775b21 Merge develop into release/0.1 for 0.1.0-RC.3
Assistant Admin record screens (#6), deterministic RobustnessTest (#9)
and the GA-only tag filter (#8), on top of the snake_case rename (#3).
0.1.0-RC.2 on Central is an accidental republish of RC.1, so this
candidate is RC.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:20:10 -05:00
james.maesandClaude Opus 5.5 67f78fd660 ci: fix the GA tag filter YAML (no brackets in the flow sequence) [skip ci]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:01:54 -05:00
james.maesandClaude Opus 5.5 ff67c6f7cc Set version to 0.1.0-RC.2 and publish only GA tags [skip ci]
0.1.0-RC.2 is already on Maven Central (an RC tag build republished the previous code), so the next release-branch build must publish the following RC. RC tags no longer trigger a publish (/^v[0-9]+\.[0-9]+\.[0-9]+$/ only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:56:59 -05:00
james.maesandClaude Opus 5.5 19551ac2af Merge develop into release/0.1 for 0.1.0-RC.2
Brings the snake_case assistant_ table and key names (#3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:18:00 -05:00
james.maesandClaude Opus 5.5 7263ac627d Bump version to 0.1.0-RC.1 [skip ci]
The orb published 0.1.0-RC.1 but could not push this commit (the
CircleCI deploy key is read only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:49:33 -05:00
20 changed files with 708 additions and 89 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.7.15
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.15
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+62 -13
View File
@@ -1,25 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+15
View File
@@ -0,0 +1,15 @@
profile: qbit
orb_version: 0.7.15
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## Unreleased - Answer visible at the end of the stream
- The turn's redacted transcript (the question and answer parts, pending actions, stored events) is now written before `message.end` is sent. The panel reloads the conversation the moment the stream ends, and used to read the empty placeholders (`[]`) the transcript redactor stores until its flush, so the newest question and answer showed as empty bubbles until the panel was closed and reopened.
## Unreleased - Optional provider health
- An enabled provider that is not the default and not the only route for any configured routing (an enabled profile's simple/complex model, a budget downgrade model or a knowledge source's embedding model, with no fallback on another usable provider) is optional: when it is unreachable, its `assistant.provider.<id>` and `assistant.model.<id>` checks report DEGRADED with a message naming the provider requests use instead, so one unfinished local server no longer turns the whole `assistant` component DOWN. The default provider and sole routes still report DOWN. A failed provider probe still carries `reachable=false`, so the circuit breaker keeps refusing the provider.
- New `assistant_provider.setup_state` (Setup state: Active / Setting up, empty counts as Active). A provider that is Setting up is never routed to (default, profile models, fallbacks, budget downgrades, embeddings) and gets no provider or model health checks; Test connection and Discover models still work. Apply the Provider setup state DDL (UPGRADE.md).
## Unreleased - Fail soft (UI API extension 1.5.0)
- Every message part, tool render and presentation is validated before it is stored or streamed, and again when stored parts and events are read: all render types (records, record, count, widget, history, pending_action, bulk_load_dry_run) and presentation blocks always carry their lists. A render that cannot be repaired is replaced by an error notice ("This result could not be displayed."); unknown part and render types pass through. Problems are logged with the type and a fixed code only.
+14
View File
@@ -1,5 +1,19 @@
# Upgrading qbit-assistant
## Provider setup state (Unreleased)
Apply the **Provider setup state** block at the end of `docs/schema-postgres.sql`
**before** deploying: the provider table now reads `setup_state`, so the new jar
fails on `assistant_provider` without it. It adds a nullable `VARCHAR(16)` with
default `'ACTIVE'` and a check for `ACTIVE` / `SETTING_UP`; existing rows behave as
before. It is `IF NOT EXISTS` and safe to re-run.
To stop an unfinished provider from being routed to or health-checked, edit it in
Assistant > Assistant Provider (Provider section) and set **Setup state** to **Setting up** (SQL:
`UPDATE assistant_provider SET setup_state = 'SETTING_UP' WHERE id = <id>;`).
Enabled providers that are neither the default nor the only route for a configured
model now report DEGRADED, not DOWN, when unreachable. No configuration change.
## Fail soft (Unreleased, UI API extension assistant 1.5.0)
Apply the **Fail soft** block at the end of `docs/schema-postgres.sql` before
+6
View File
@@ -744,3 +744,9 @@ CREATE INDEX IF NOT EXISTS assistant_client_error_create_date ON assistant_clien
CREATE INDEX IF NOT EXISTS assistant_client_error_conversation ON assistant_client_error (conversation_id);
CREATE INDEX IF NOT EXISTS assistant_client_error_message ON assistant_client_error (message_id);
CREATE INDEX IF NOT EXISTS assistant_client_error_turn ON assistant_client_error (turn_id);
-- Provider setup state: SETTING_UP marks a provider an admin is still building. The
-- assistant never routes to it and its provider and model health checks do not run.
-- Existing rows (and NULL) behave as ACTIVE, exactly as before. Safe to re-run.
ALTER TABLE assistant_provider ADD COLUMN IF NOT EXISTS setup_state VARCHAR(16) DEFAULT 'ACTIVE'
CONSTRAINT ck_assistant_provider_setup_state CHECK (setup_state IN ('ACTIVE', 'SETTING_UP'));
+1 -1
View File
@@ -20,7 +20,7 @@
<!-- Multi-module build. The qqq-orb sets the revision here; the modules -->
<!-- inherit it, and the flatten plugin resolves it in the published POMs. -->
<properties>
<revision>0.1.0-SNAPSHOT</revision>
<revision>0.1.0-RC.24</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -27,6 +27,7 @@ import com.kingsrook.qbits.assistant.spi.HealthResult;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
import com.kingsrook.qqq.backend.core.health.HealthRunner;
import com.kingsrook.qqq.backend.core.health.HealthStatus;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -37,6 +38,12 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
*******************************************************************************/
public class ProviderHealth
{
/*******************************************************************************
** Health detail set to false when a provider probe failed, even when the
** outcome is only DEGRADED because the provider is optional.
*******************************************************************************/
public static final String DETAIL_REACHABLE = "reachable";
private final AssistantRuntime runtime;
@@ -65,6 +72,10 @@ public class ProviderHealth
{
throw (new ModelProviderException(ModelProviderException.Kind.AUTHENTICATION, null, "Provider is disabled."));
}
if(row.isSettingUp())
{
throw (new ModelProviderException(ModelProviderException.Kind.AUTHENTICATION, null, "Provider is not set up yet."));
}
if(isDown(row))
{
throw (new ModelProviderException(ModelProviderException.Kind.OVERLOADED, null, "Provider circuit is open."));
@@ -80,7 +91,7 @@ public class ProviderHealth
var outcome = runner == null ? null : runner.getLastOutcome(checkName);
if(checked != null && !checked.isAfter(now) && checked.plusSeconds(60).isAfter(now) && outcome != null)
{
if(outcome.getStatus() == HealthStatus.DOWN)
if(outcome.getStatus() == HealthStatus.DOWN || isUnreachable(outcome))
{
throw (new ModelProviderException(ModelProviderException.Kind.NETWORK, null, "Provider health check failed."));
}
@@ -158,4 +169,15 @@ public class ProviderHealth
.withValue("healthState", state).withValue("healthCheckedAt", runtime.getClock().instant()).withValue("downUntil", downUntil));
runtime.getProviderService().invalidate();
}
/*******************************************************************************
** A central outcome that says the provider could not be reached, whatever
** its status (an optional provider's failed probe is DEGRADED, not DOWN).
*******************************************************************************/
public static boolean isUnreachable(HealthCheckOutcome outcome)
{
return (outcome != null && outcome.getDetails() != null && Boolean.FALSE.equals(outcome.getDetails().getValues().get(DETAIL_REACHABLE)));
}
}
@@ -158,14 +158,15 @@ public class ProviderService
/*******************************************************************************
** The default enabled provider row, or null.
** The default usable (enabled and set up) provider row, else the first
** usable one, or null.
*******************************************************************************/
public AssistantProvider activeRow() throws QException
{
AssistantProvider first = null;
for(AssistantProvider row : rows())
{
if(Boolean.TRUE.equals(row.getEnabled()))
if(row.isUsable())
{
if(Boolean.TRUE.equals(row.getIsDefault()))
{
@@ -782,7 +783,7 @@ public class ProviderService
return (null);
}
AssistantProvider row = store.getProvider(model.getProviderId());
if(row == null || !Boolean.TRUE.equals(row.getEnabled()) || ((requiresHipaa || Boolean.TRUE.equals(config.getHipaaMode())) && !Boolean.TRUE.equals(row.getHipaaEligible())))
if(row == null || !row.isUsable() || ((requiresHipaa || Boolean.TRUE.equals(config.getHipaaMode())) && !Boolean.TRUE.equals(row.getHipaaEligible())))
{
return (null);
}
@@ -854,9 +855,9 @@ public class ProviderService
throw (new ProviderSetupException("Select an enabled EMBEDDING model for this knowledge source."));
}
AssistantProvider row = store.getProvider(model.getProviderId());
if(row == null || !Boolean.TRUE.equals(row.getEnabled()))
if(row == null || !row.isUsable())
{
throw (new ProviderSetupException("The embedding provider is disabled or unavailable."));
throw (new ProviderSetupException("The embedding provider is disabled, not set up yet or unavailable."));
}
if((requiresHipaa || Boolean.TRUE.equals(config.getHipaaMode())) && !Boolean.TRUE.equals(row.getHipaaEligible()))
{
@@ -1321,6 +1321,17 @@ public class TurnEngine implements Runnable
String finalErrorText = errorText;
safely(() -> emit(TurnEvent.ERROR, map("code", finalErrorCode, "message", finalErrorText)));
}
///////////////////////////////////////////////////////////////////////
// the stream ends at message.end and the panel reloads the //
// conversation at once, so the redacted transcript (message parts, //
// pending actions, events) must be stored before that event is sent //
///////////////////////////////////////////////////////////////////////
if(!alreadyTerminal)
{
safely(this::persistPendingEvents);
safely(() -> transcript.flush(store));
}
safely(() -> emit(TurnEvent.MESSAGE_END, map("messageId", assistantMessage.getId(), "status", finalStatus, "stopReason", finalStopReason)));
if(!alreadyTerminal)
{
@@ -20,16 +20,26 @@ package com.kingsrook.qbits.assistant.health;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.engine.ConnectionTest;
import com.kingsrook.qbits.assistant.engine.ProviderHealth;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantProfile;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
import com.kingsrook.qbits.assistant.spi.HealthResult;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -44,6 +54,8 @@ import com.kingsrook.qqq.backend.core.health.HealthCheckSupplierMetaData;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.health.HealthDetails;
import com.kingsrook.qqq.backend.core.health.HealthStatus;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
@@ -54,6 +66,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
public final class AssistantHealthChecks
{
public static final String CLIENT_ERRORS = "assistant.client-errors";
public static final String NOT_SET_UP = "Not set up yet; checks are skipped";
private static final QLogger LOG = QLogger.getLogger(AssistantHealthChecks.class);
/*******************************************************************************
** Utility class.
@@ -129,7 +144,8 @@ public final class AssistantHealthChecks
/*******************************************************************************
** One definition per enabled provider; no I/O to providers during resolution.
** One definition per enabled provider that is set up; no I/O to providers
** during resolution.
*******************************************************************************/
public static class Providers implements HealthCheckSupplier
{
@@ -143,10 +159,10 @@ public final class AssistantHealthChecks
List<HealthCheckMetaData> checks = new ArrayList<>();
for(AssistantProvider row : runtime.getStore().listProviders())
{
if(Boolean.TRUE.equals(row.getEnabled()))
if(row.isUsable())
{
checks.add(definition("assistant.provider." + row.getId(), "LOCAL".equals(runtime.getProviderService().location(row)) ? 60 : 300, 10000,
input -> bounded(input, () -> provider(runtime, row.getId(), input))));
input -> optional(runtime, row.getId(), bounded(input, () -> provider(runtime, row.getId(), input)), false)));
}
}
return (checks);
@@ -156,7 +172,8 @@ public final class AssistantHealthChecks
/*******************************************************************************
** One definition per enabled model, including models of disabled providers.
** One definition per enabled model, including models of disabled providers,
** but none for a provider that is still being set up.
*******************************************************************************/
public static class Models implements HealthCheckSupplier
{
@@ -168,12 +185,13 @@ public final class AssistantHealthChecks
{
AssistantRuntime runtime = AssistantRuntime.of(instance);
List<HealthCheckMetaData> checks = new ArrayList<>();
Set<Integer> settingUp = runtime.getStore().listProviders().stream().filter(AssistantProvider::isSettingUp).map(AssistantProvider::getId).collect(Collectors.toSet());
for(AssistantModel row : runtime.getStore().listModels())
{
if(Boolean.TRUE.equals(row.getEnabled()))
if(Boolean.TRUE.equals(row.getEnabled()) && !settingUp.contains(row.getProviderId()))
{
checks.add(definition("assistant.model." + row.getId(), 21600, 30000,
input -> bounded(input, () -> model(runtime, row.getId(), input))));
input -> optional(runtime, row.getProviderId(), bounded(input, () -> model(runtime, row.getId(), input)), true)));
}
}
return (checks);
@@ -242,6 +260,10 @@ public final class AssistantHealthChecks
{
return (HealthCheckOutcome.unknown("Provider disabled or unavailable"));
}
if(row.isSettingUp())
{
return (HealthCheckOutcome.up(NOT_SET_UP).withDetails(new HealthDetails().withValue("providerId", id)));
}
var type = runtime.getConfig().getProviderTypes().get(row.getProviderType());
if(type == null)
{
@@ -290,6 +312,10 @@ public final class AssistantHealthChecks
{
return (HealthCheckOutcome.unknown("Provider disabled or unavailable"));
}
if(provider.isSettingUp())
{
return (HealthCheckOutcome.up(NOT_SET_UP).withDetails(new HealthDetails().withValue("modelId", id).withValue("providerId", provider.getId())));
}
Long cap = ProviderService.capMicros(provider);
if(cap != null && runtime.getProviderService().monthToDateCostMicros(provider.getId(), true) >= cap)
{
@@ -305,4 +331,149 @@ public final class AssistantHealthChecks
.withDetails(new HealthDetails().withValue("modelId", id).withValue("providerId", provider.getId()))
.withLatencyMs(result.latencyMs() == null ? null : result.latencyMs().longValue()));
}
/*******************************************************************************
** A failed provider (or model) check is DOWN only when the provider matters:
** it is the default, or the only route for a configured profile model, budget
** downgrade or knowledge source. Any other enabled provider is optional, so its
** failure is DEGRADED with a message that says where requests go instead.
** Provider failures also carry reachable=false, so the circuit breaker still
** refuses the provider. If the routes cannot be read, the outcome stays DOWN.
*******************************************************************************/
static HealthCheckOutcome optional(AssistantRuntime runtime, Integer providerId, HealthCheckOutcome outcome, boolean model)
{
if(outcome == null || outcome.getStatus() != HealthStatus.DOWN)
{
return (outcome);
}
HealthDetails details = outcome.getDetails() == null ? new HealthDetails() : outcome.getDetails();
outcome.withDetails(details);
if(!model)
{
details.withValue(ProviderHealth.DETAIL_REACHABLE, false);
}
try
{
AssistantProvider row = providerId == null ? null : runtime.getStore().getProvider(providerId);
if(row == null || !row.isUsable())
{
return (outcome);
}
String required = requiredBecause(runtime, row);
if(required != null)
{
details.withValue("required", required);
return (outcome);
}
AssistantProvider active = runtime.getProviderService().activeRow();
String instead = active == null ? "the other providers" : "'" + active.getName() + "'";
String what = model ? "Model connection failed; provider '" + row.getName() + "' is optional" : "Provider '" + row.getName() + "' is unreachable; it is optional";
details.withValue("optional", true);
return (outcome.withStatus(HealthStatus.DEGRADED).withMessage(what + " (not the default and not the only route), so requests use " + instead));
}
catch(Exception e)
{
LOG.warn("Could not tell whether an assistant provider is optional; reporting it as down", e);
return (outcome);
}
}
/*******************************************************************************
** Why a provider is required (null when it is optional): it is the provider
** the assistant uses by default, or one of its enabled models is assigned to
** a routing profile, a budget downgrade or a knowledge source and that model
** has no fallback on another usable provider.
*******************************************************************************/
static String requiredBecause(AssistantRuntime runtime, AssistantProvider row) throws QException
{
AssistantProvider active = runtime.getProviderService().activeRow();
if(active == null || Objects.equals(active.getId(), row.getId()))
{
return ("default provider");
}
Map<Integer, AssistantModel> models = runtime.getStore().listModels().stream().collect(Collectors.toMap(AssistantModel::getId, Function.identity(), (a, b) -> a));
Map<Integer, AssistantProvider> providers = runtime.getStore().listProviders().stream().collect(Collectors.toMap(AssistantProvider::getId, Function.identity(), (a, b) -> a));
Set<Integer> routed = new HashSet<>();
for(AssistantProfile profile : runtime.getStore().query(AssistantProfile.TABLE_NAME, new QQueryFilter(), AssistantProfile.class))
{
if(!Boolean.FALSE.equals(profile.getEnabled()) && profile.getRoutingMode() != null && !"OFF".equals(profile.getRoutingMode()))
{
routed.add(profile.getSimpleModelId());
routed.add(profile.getComplexModelId());
}
}
for(AssistantBudget budget : runtime.getStore().budgets())
{
routed.add(budget.getDowngradeModelId());
}
for(Integer id : routed)
{
AssistantModel model = id == null ? null : models.get(id);
if(onProvider(model, row) && !hasFallback(runtime, model, row, models, providers))
{
return ("only route for a configured model");
}
}
for(AssistantRagSource source : runtime.getStore().query(AssistantRagSource.TABLE_NAME, new QQueryFilter(), AssistantRagSource.class))
{
if(!Boolean.FALSE.equals(source.getEnabled()) && source.getEmbeddingModelId() != null && onProvider(models.get(source.getEmbeddingModelId()), row))
{
return ("only route for a knowledge source");
}
}
return (null);
}
/*******************************************************************************
** An enabled model of this provider.
*******************************************************************************/
private static boolean onProvider(AssistantModel model, AssistantProvider row)
{
return (model != null && Boolean.TRUE.equals(model.getEnabled()) && Objects.equals(model.getProviderId(), row.getId()));
}
/*******************************************************************************
** The fallback FallbackChain would try: the explicit chain when the model has
** one, else (local providers only) another local provider of the same type.
** Only enabled models of other usable providers count.
*******************************************************************************/
private static boolean hasFallback(AssistantRuntime runtime, AssistantModel model, AssistantProvider row, Map<Integer, AssistantModel> models, Map<Integer, AssistantProvider> providers)
{
if(model.getFallbackModelId() != null)
{
Set<Integer> seen = new HashSet<>();
Integer next = model.getFallbackModelId();
while(next != null && seen.add(next))
{
AssistantModel candidate = models.get(next);
if(candidate == null)
{
return (false);
}
AssistantProvider owner = providers.get(candidate.getProviderId());
if(Boolean.TRUE.equals(candidate.getEnabled()) && owner != null && owner.isUsable() && !Objects.equals(owner.getId(), row.getId()))
{
return (true);
}
next = candidate.getFallbackModelId();
}
return (false);
}
if(!"LOCAL".equals(runtime.getProviderService().location(row)))
{
return (false);
}
return (providers.values().stream().anyMatch(other -> other.isUsable() && !Objects.equals(other.getId(), row.getId())
&& "LOCAL".equals(runtime.getProviderService().location(other)) && Objects.equals(other.getProviderType(), row.getProviderType())
&& models.values().stream().anyMatch(candidate -> Boolean.TRUE.equals(candidate.getEnabled()) && Objects.equals(candidate.getProviderId(), other.getId()))));
}
}
@@ -42,6 +42,24 @@ public class AssistantHealthWidget extends AbstractWidgetRenderer
{
public static final String NAME = "assistantHealth";
public static final String NOT_CHECKED = "Not checked yet";
/*******************************************************************************
** Human words for a health status; unknown means no check has reported yet.
*******************************************************************************/
static String wording(HealthStatus status)
{
return (switch(status)
{
case UP -> "Healthy";
case DEGRADED -> "Degraded";
case DOWN -> "Down";
case UNKNOWN -> NOT_CHECKED;
});
}
/*******************************************************************************
@@ -72,8 +90,8 @@ public class AssistantHealthWidget extends AbstractWidgetRenderer
.map(check -> runner.getLastOutcome(check.getName()))
.map(outcome -> outcome == null ? HealthStatus.UNKNOWN : outcome.getStatus()).toArray(HealthStatus[]::new));
}
return (new RenderWidgetOutput(new StatisticsData().withCount(status.name())
.withCountContext("System Health")
return (new RenderWidgetOutput(new StatisticsData().withCount(wording(status))
.withCountContext("System Health. Provider checks run every 1 to 5 minutes; model checks every 6 hours")
.withCountURL(FilterLinkBuilder.href("health_check", List.of(Map.of("fieldName", "component", "operator", "EQUALS", "values", List.of("assistant")))))));
}
}
@@ -333,6 +333,7 @@ public final class AssistantRecordScreens
settingsSource("assistant_price_model", modelValues, true, false),
settingsSource("assistant_provider_kind", kinds, false, false),
enumSource("assistant_provider_location", List.of(new QPossibleValue<>("LOCAL", "Local"), new QPossibleValue<>("CLOUD", "Cloud"))),
enumSource("assistant_provider_setup_state", List.of(new QPossibleValue<>("ACTIVE", "Active"), new QPossibleValue<>("SETTING_UP", "Setting up"))),
enumSource("assistant_routing_mode", List.of(new QPossibleValue<>("OFF", "Off"), new QPossibleValue<>("HEURISTIC", "Heuristic"), new QPossibleValue<>("CLASSIFIER", "Classifier"))),
enumSource("assistant_model_kind", List.of(new QPossibleValue<>("CHAT", "Chat"), new QPossibleValue<>("EMBEDDING", "Embedding"))),
enumSource("assistant_model_source", List.of(new QPossibleValue<>("DISCOVERED", "Discovered"), new QPossibleValue<>("MANUAL", "Manual"))),
@@ -396,6 +397,7 @@ public final class AssistantRecordScreens
pvs(table, "model", MODEL_PVS_NAME, "Model");
pvs(table, "kind", "assistant_provider_kind", "Kind");
pvs(table, "location", "assistant_provider_location", "Location");
pvs(table, "setupState", "assistant_provider_setup_state", "Setup state");
table.getField("hipaaEligible").withLabel("HIPAA-eligible");
table.getField("apiVersion").withLabel("API version");
table.getField("connectTimeoutSeconds").withLabel("Connect timeout (s)");
@@ -414,6 +416,7 @@ public final class AssistantRecordScreens
help(table, "providerType", "Which kind of provider this is. Only the types installed on this server are offered.");
help(table, "model", "The model answers come from. Empty uses the policy's (or the application's) main model.");
help(table, "enabled", "Off: the assistant never uses this provider.");
help(table, "setupState", "Setting up: the provider is still being built, so the assistant never routes to it and its health checks do not run. Use Active once it works.");
help(table, "isDefault", "The assistant uses the default enabled provider (else the first enabled one). Marking one default unmarks the others.");
help(table, "apiKeyEnvName", "The NAME of the environment variable that holds the API key (e.g. ANTHROPIC_API_KEY), never the key itself. "
+ "The key stays in the server's secret store; only allowed variable names are accepted. Empty uses the type's default variable. Bedrock and Vertex require this field to be empty and use server credentials. To replace the key, change it in the secret store.");
@@ -424,7 +427,7 @@ public final class AssistantRecordScreens
help(table, "maxConcurrentRequests", "How many model calls may run at the same time on this server. Empty: no limit.");
table.getField(ProviderService.KEY_STATUS_FIELD).withLabel("API Key").withHelpContent(new QHelpContent("Whether the key variable is set on this server, with the key's last four characters. The key itself is never shown or returned."));
sections(table,
section("provider", "Provider", "settings_input_component", Tier.T1, "id", "name", "providerType", "model", "enabled", "isDefault"),
section("provider", "Provider", "settings_input_component", Tier.T1, "id", "name", "providerType", "model", "enabled", "setupState", "isDefault"),
section("connection", "Connection", "key", Tier.T2, "apiKeyEnvName", ProviderService.KEY_STATUS_FIELD, "baseUrl", "tlsCaBundle", "cloudProject"),
section("limits", "Limits", "speed", Tier.T2, "monthlyCostCapUsd", "maxOutputTokensPerRequest", "maxConcurrentRequests"),
section("registry", "Provider settings", "settings", Tier.T2, "kind", "location", "hipaaEligible", "region", "apiVersion", "connectTimeoutSeconds", "requestTimeoutSeconds"),
@@ -137,6 +137,14 @@ public class AssistantSettingsCustomizer implements TableCustomizerInterface
{
record.setValue("location", service.location(provider));
}
if(merged.getValue("setupState") == null)
{
record.setValue("setupState", AssistantProvider.SETUP_STATE_ACTIVE);
}
else if(!List.of(AssistantProvider.SETUP_STATE_ACTIVE, AssistantProvider.SETUP_STATE_SETTING_UP).contains(merged.getValueString("setupState")))
{
record.addError(new BadInputStatusMessage("Setup state must be Active or Setting up."));
}
for(String field : List.of("hipaaEligible", "connectTimeoutSeconds", "requestTimeoutSeconds"))
{
if(merged.getValue(field) == null)
@@ -71,6 +71,12 @@ public class AssistantProvider extends QRecordEntity
public static final String TABLE_NAME = "assistant_provider";
public static final String SETUP_STATE_ACTIVE = "ACTIVE";
public static final String SETUP_STATE_SETTING_UP = "SETTING_UP";
@QField(maxLength = 16)
private String setupState;
@QField(isEditable = false, maxLength = 8)
private String healthState;
@@ -1218,4 +1224,55 @@ public class AssistantProvider extends QRecordEntity
this.downUntil = downUntil;
return (this);
}
/*******************************************************************************
** Get setupState: ACTIVE (or empty) when in use, SETTING_UP while an admin
** is still building it (never routed to, no health checks).
*******************************************************************************/
public String getSetupState()
{
return (this.setupState);
}
/*******************************************************************************
** Set setupState.
*******************************************************************************/
public void setSetupState(String setupState)
{
this.setupState = setupState;
}
/*******************************************************************************
** With setupState.
*******************************************************************************/
public AssistantProvider withSetupState(String setupState)
{
this.setupState = setupState;
return (this);
}
/*******************************************************************************
** Whether an admin marked this provider as not set up yet.
*******************************************************************************/
public boolean isSettingUp()
{
return (SETUP_STATE_SETTING_UP.equals(setupState));
}
/*******************************************************************************
** Enabled and set up: the only rows the assistant routes to or health-checks.
*******************************************************************************/
public boolean isUsable()
{
return (Boolean.TRUE.equals(enabled) && !isSettingUp());
}
}
@@ -562,6 +562,10 @@ entries:
summary: LOCAL for a server on your own network, or CLOUD for an outside service.
content: |
Local providers are probed for health before use. Empty uses the usual value for the kind: Ollama, LM Studio, vLLM and llama.cpp are local; everything else is cloud.
- key: table:assistant_provider;field:setupState
summary: Setting up while you are still building this provider; Active once it works. Empty counts as Active.
content: |
While a provider is Setting up the assistant never sends questions to it, it is not the default even if marked so, and its provider and model health checks do not run, so an unfinished server does not show the assistant as down. Test connection and Discover models still work, so you can check it before you switch it to Active.
- key: table:assistant_provider;field:region
summary: The cloud region to use; only needed for AWS Bedrock and Google Vertex AI.
content: |
@@ -1397,6 +1401,11 @@ entries:
content: |
- **Local**: runs on your network. Its models cost 0 unless priced, and it is the preferred automatic backup.
- **Cloud**: a hosted service. Costs follow the price list and the monthly cost cap.
- key: possibleValueSource:assistant_provider_setup_state;slot:help
summary: Whether a provider is in use or still being set up.
content: |
- **Active**: in use. The assistant routes to it and health-checks it.
- **Setting up**: not used yet. No routing and no health checks until you switch it to Active.
- key: possibleValueSource:assistant_routing_mode;slot:help
summary: How a profile chooses between its simple and complex model for each question.
content: |
@@ -141,6 +141,27 @@ class TurnEngineTest extends BaseAssistantTest
/*******************************************************************************
** The panel reloads the conversation as soon as message.end arrives, so the
** question and the answer must already be stored then, not after it.
*******************************************************************************/
@Test
void testMessagesAreStoredBeforeMessageEnd() throws Exception
{
provider.thenText("Four posts are published.");
TurnRunner.Outcome outcome = runner.run(EDITOR, "how many published posts?");
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
List<AssistantMessage> stored = runner.messagesAtEnd();
assertThat(stored).hasSize(2);
assertThat(stored).extracting(AssistantMessage::getPartsJson).allSatisfy(parts -> assertThat(parts).isNotEqualTo("[]"));
assertThat(stored.get(0).getPartsJson()).contains("how many published posts?");
assertThat(stored.get(1).getPartsJson()).contains("Four posts are published.");
}
/*******************************************************************************
** Parallel tool calls: all results in ONE user message, in order.
*******************************************************************************/
@@ -31,8 +31,10 @@ import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.engine.ProviderHealth;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.engine.UsageRecorder;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantProfile;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantUsage;
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
@@ -606,7 +608,8 @@ class AssistantHealthChecksTest extends BaseAssistantTest
assertThat(qInstance.getWidget(AssistantHealthWidget.NAME).getType()).isEqualTo(WidgetType.STATISTICS.getType());
var renderer = new AssistantHealthWidget();
StatisticsData data = (StatisticsData) renderer.render(new RenderWidgetInput()).getWidgetData();
assertThat(data.getCount()).isEqualTo("UNKNOWN");
assertThat(data.getCount()).isEqualTo("Not checked yet");
assertThat(data.getCountContext()).contains("Provider checks run every 1 to 5 minutes");
assertThat(data.getCountURL()).startsWith("/app/").contains("health_check", "assistant");
try(var central = cached("assistant.provider." + row.getId(), HealthCheckOutcome.up("Reachable"), Instant.now()))
{
@@ -614,14 +617,202 @@ class AssistantHealthChecksTest extends BaseAssistantTest
cache.setAccessible(true);
cache.invoke(central, AssistantHealthChecks.CLIENT_ERRORS, HealthCheckOutcome.up("0 client errors reported in the last hour"));
data = (StatisticsData) renderer.render(new RenderWidgetInput()).getWidgetData();
assertThat(data.getCount()).isEqualTo("UP");
assertThat(data.getCount()).isEqualTo("Healthy");
//////////////////////////////////////////////////////////////////
// many client errors in the last hour show on the card as well //
//////////////////////////////////////////////////////////////////
cache.invoke(central, AssistantHealthChecks.CLIENT_ERRORS, HealthCheckOutcome.degraded("25 client errors reported in the last hour"));
data = (StatisticsData) renderer.render(new RenderWidgetInput()).getWidgetData();
assertThat(data.getCount()).isEqualTo("DEGRADED");
assertThat(data.getCount()).isEqualTo("Degraded");
}
}
/*******************************************************************************
** A second enabled provider, not the default.
*******************************************************************************/
private AssistantProvider optionalRow(AssistantProvider defaultRow) throws Exception
{
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", defaultRow.getId()).withValue("isDefault", true).withValue("name", "Claude"));
AssistantProvider optional = config.getRuntime().getStore().insert(new AssistantProvider().withName("lore (local GPU)")
.withProviderType(type.getType()).withEnabled(true).withIsDefault(false));
config.getRuntime().getProviderService().invalidate();
return (optional);
}
/*******************************************************************************
** An unreachable optional provider is DEGRADED and names the default; the
** default stays DOWN, and the breaker still refuses the optional provider.
*******************************************************************************/
@Test
void optionalProviderIsDegradedDefaultIsDown() throws Exception
{
AssistantProvider defaultRow = row();
AssistantProvider lore = optionalRow(defaultRow);
healthy = false;
HealthCheckOutcome outcome = run(check("assistant.provider." + lore.getId()));
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(outcome.getMessage()).isEqualTo("Provider 'lore (local GPU)' is unreachable; it is optional (not the default and not the only route), so requests use 'Claude'");
assertThat(outcome.getDetails().getValues()).containsEntry("reachable", false).containsEntry("optional", true).containsEntry("providerId", lore.getId());
assertThat(ProviderHealth.isUnreachable(outcome)).isTrue();
HealthCheckOutcome required = run(check("assistant.provider." + defaultRow.getId()));
assertThat(required.getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(required.getDetails().getValues()).containsEntry("required", "default provider").containsEntry("reachable", false);
healthy = true;
assertThat(run(check("assistant.provider." + lore.getId())).getStatus()).isEqualTo(HealthStatus.UP);
assertThat(ProviderHealth.isUnreachable(HealthCheckOutcome.degraded("Slow"))).isFalse();
assertThat(ProviderHealth.isUnreachable(null)).isFalse();
////////////////////////////////////////////////////////////////////
// a fresh DEGRADED-but-unreachable central result opens the breaker //
////////////////////////////////////////////////////////////////////
var active = new ProviderService.Active(lore, type.getType(), "model", provider);
try(var central = cached("assistant.provider." + lore.getId(), outcome, Instant.now().minusSeconds(1)))
{
assertThatThrownBy(() -> new ProviderHealth(config.getRuntime()).beforeCall(active)).isInstanceOf(ModelProviderException.class);
assertThat(probes).isEqualTo(3);
}
}
/*******************************************************************************
** Model checks follow their provider: DEGRADED when optional.
*******************************************************************************/
@Test
void optionalProviderModelIsDegraded() throws Exception
{
AssistantProvider defaultRow = row();
AssistantProvider lore = optionalRow(defaultRow);
AssistantModel model = model(lore);
provider.thenError(ModelProviderException.Kind.NETWORK, SECRET);
HealthCheckOutcome outcome = run(check("assistant.model." + model.getId()));
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(outcome.getMessage()).startsWith("Model connection failed; provider 'lore (local GPU)' is optional").endsWith("so requests use 'Claude'");
assertThat(outcome.getDetails().getValues()).doesNotContainKey("reachable").containsEntry("modelId", model.getId());
AssistantModel defaultModel = model(defaultRow);
provider.thenError(ModelProviderException.Kind.NETWORK, SECRET);
assertThat(run(check("assistant.model." + defaultModel.getId())).getStatus()).isEqualTo(HealthStatus.DOWN);
}
/*******************************************************************************
** A provider that is the only route for routing or a budget downgrade stays
** DOWN; an explicit fallback on another provider makes it optional again.
*******************************************************************************/
@Test
void soleRouteStaysDown() throws Exception
{
AssistantProvider defaultRow = row();
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", defaultRow.getId()).withValue("location", "CLOUD"));
AssistantProvider lore = optionalRow(defaultRow);
AssistantModel loreModel = model(lore);
AssistantModel defaultModel = model(defaultRow);
healthy = false;
var check = check("assistant.provider." + lore.getId());
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.DEGRADED);
AssistantProfile profile = config.getRuntime().getStore().insert(new AssistantProfile().withName("routing").withDisplayName("Assistant").withEnabled(true)
.withRoutingMode("HEURISTIC").withSimpleModelId(loreModel.getId()).withComplexModelId(defaultModel.getId()));
HealthCheckOutcome outcome = run(check);
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(outcome.getDetails().getValues()).containsEntry("required", "only route for a configured model");
config.getRuntime().getStore().updateFields(AssistantModel.TABLE_NAME, new QRecord().withValue("id", loreModel.getId()).withValue("fallbackModelId", defaultModel.getId()));
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.DEGRADED);
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", defaultRow.getId()).withValue("setupState", AssistantProvider.SETUP_STATE_SETTING_UP));
config.getRuntime().getProviderService().invalidate();
assertThat(run(check).getStatus()).as("lore is the only usable provider left, hence the default").isEqualTo(HealthStatus.DOWN);
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", defaultRow.getId()).withValue("setupState", AssistantProvider.SETUP_STATE_ACTIVE));
config.getRuntime().getProviderService().invalidate();
config.getRuntime().getStore().updateFields(AssistantModel.TABLE_NAME, new QRecord().withValue("id", loreModel.getId()).withValue("fallbackModelId", null));
config.getRuntime().getStore().updateFields(AssistantProfile.TABLE_NAME, new QRecord().withValue("id", profile.getId()).withValue("routingMode", "OFF"));
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.DEGRADED);
config.getRuntime().getStore().insert(new AssistantBudget().withName("Downgrade").withScopeType("INSTANCE").withPeriod("MONTH").withMetric("COST_USD")
.withLimitValue(BigDecimal.ONE).withEnabled(true).withDowngradeModelId(loreModel.getId()));
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.DOWN);
}
/*******************************************************************************
** Another local provider of the same type is the automatic fallback.
*******************************************************************************/
@Test
void localPoolIsAFallback() throws Exception
{
AssistantProvider defaultRow = row();
AssistantProvider lore = optionalRow(defaultRow);
AssistantModel loreModel = model(lore);
model(defaultRow);
config.getRuntime().getStore().insert(new AssistantProfile().withName("routing").withDisplayName("Assistant").withEnabled(true)
.withRoutingMode("HEURISTIC").withSimpleModelId(loreModel.getId()).withComplexModelId(loreModel.getId()));
healthy = false;
assertThat(run(check("assistant.provider." + lore.getId())).getStatus()).isEqualTo(HealthStatus.DEGRADED);
}
/*******************************************************************************
** Setting up: no checks registered, no routing, and an already resolved check
** reports UP without probing.
*******************************************************************************/
@Test
void settingUpSkipsChecksAndRouting() throws Exception
{
AssistantProvider defaultRow = row();
AssistantProvider lore = optionalRow(defaultRow);
AssistantModel model = model(lore);
assertThat(lore.getSetupState()).isEqualTo(AssistantProvider.SETUP_STATE_ACTIVE);
var providerCheck = check("assistant.provider." + lore.getId());
var modelCheck = check("assistant.model." + model.getId());
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", lore.getId()).withValue("setupState", AssistantProvider.SETUP_STATE_SETTING_UP));
config.getRuntime().getProviderService().invalidate();
assertThat(HealthChecksMetaData.ofOrWithNew(qInstance).resolveAll(qInstance)).extracting(HealthCheckMetaData::getName)
.doesNotContain("assistant.provider." + lore.getId(), "assistant.model." + model.getId())
.contains("assistant.provider." + defaultRow.getId());
Integer before = probes;
assertThat(run(providerCheck).getMessage()).isEqualTo(AssistantHealthChecks.NOT_SET_UP);
assertThat(run(modelCheck).getStatus()).isEqualTo(HealthStatus.UP);
assertThat(probes).isEqualTo(before);
assertThat(provider.getRequests()).isEmpty();
assertThat(config.getRuntime().getProviderService().resolveModel(model.getId(), false)).isNull();
assertThatThrownBy(() -> new ProviderHealth(config.getRuntime()).beforeCall(new ProviderService.Active(lore, type.getType(), "model", provider)))
.hasMessageContaining("not set up yet");
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", lore.getId()).withValue("isDefault", true));
config.getRuntime().getProviderService().invalidate();
assertThat(config.getRuntime().getProviderService().activeRow().getId()).as("a default that is setting up is skipped").isEqualTo(defaultRow.getId());
assertThatThrownBy(() -> config.getRuntime().getStore().insert(new AssistantProvider().withName("Bad").withProviderType(type.getType()).withEnabled(true).withSetupState("BOGUS")))
.hasMessageContaining("Setup state must be Active or Setting up.");
}
/*******************************************************************************
** If routes cannot be read the failure stays DOWN; other statuses pass through.
*******************************************************************************/
@Test
void optionalPassThrough() throws Exception
{
assertThat(AssistantHealthChecks.optional(config.getRuntime(), null, null, false)).isNull();
HealthCheckOutcome up = HealthCheckOutcome.up("fine");
assertThat(AssistantHealthChecks.optional(config.getRuntime(), 1, up, false)).isSameAs(up);
assertThat(AssistantHealthChecks.optional(config.getRuntime(), null, HealthCheckOutcome.down("x"), true).getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(AssistantHealthChecks.optional(null, 1, HealthCheckOutcome.down("x"), false).getStatus()).isEqualTo(HealthStatus.DOWN);
}
}
@@ -27,6 +27,7 @@ import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.engine.RunningTurn;
import com.kingsrook.qbits.assistant.engine.TurnEvent;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -47,6 +48,8 @@ public class TurnRunner
private final QInstance qInstance;
private final AssistantQBitConfig config;
private List<AssistantMessage> messagesAtEnd = List.of();
/*******************************************************************************
@@ -169,6 +172,10 @@ public class TurnRunner
{
List<TurnEvent> batch = running.getLog().after(last, 1000);
events.addAll(batch);
if(batch.stream().anyMatch(e -> TurnEvent.MESSAGE_END.equals(e.getType())))
{
messagesAtEnd = messagesOf(userId, convId);
}
if(!batch.isEmpty())
{
last = batch.get(batch.size() - 1).getSeq();
@@ -194,6 +201,36 @@ public class TurnRunner
/*******************************************************************************
** The conversation's stored messages, read the moment the run's message.end
** was seen: what the panel finds when it reloads the conversation at the end
** of the stream.
*******************************************************************************/
public List<AssistantMessage> messagesAtEnd()
{
return (messagesAtEnd);
}
/*******************************************************************************
** The stored messages of a conversation (read as the user).
*******************************************************************************/
private List<AssistantMessage> messagesOf(String userId, Integer conversationId) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(userId, config));
try
{
return (new AssistantStore().listMessages(userId, conversationId, null, 50));
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** The audited tool calls of a turn (read as the user).
*******************************************************************************/