Compare commits

...
Author SHA1 Message Date
CI 88aec4afec Bump version to 0.1.0-RC.30 [skip ci] 2026-11-11 17:22:56 +00:00
james.maes ceb31a71b3 Merge pull request 'release: merge develop into release/0.1 (the store as trusted code, a person's own budget)' (#103) from rc/develop-into-release-0.1-everyone into release/0.1
maven / build (push) Successful in 5m33s
build / maven (push) Successful in 5m33s
2026-11-11 17:17:32 +00:00
james.maesandClaude Fable 5.2 e3f68bf7ee Merge develop into release/0.1 for the next 0.1.0 RC (the store as trusted code, a person's own budget)
maven / build (push) Successful in 6m4s
build / maven (push) Successful in 6m5s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 11:10:47 -06:00
james.maes e02658de7e Merge pull request 'fix(store): the assistant works for people who do not administer it where core enforces table permissions' (#102) from feat/store-reads-as-trusted-code into develop
maven / build (push) Successful in 6m28s
build / maven (push) Successful in 6m28s
2026-11-11 17:10:42 +00:00
james.maesandClaude Fable 5.2 6cbd530719 fix(store): the assistant works for people who do not administer it where core enforces table permissions
maven / build (push) Successful in 4m54s
build / maven (push) Successful in 4m54s
On a host with QInstance.enforcePermissionsInCoreActions on, a person with
assistant.hasAccess and without assistant.admin was refused before any model
call ("Not authorized to access the assistant admin data."). The assistant
reads its settings (policy, providers, models, prices, budgets, data access)
and writes that person's messages, turns, tool calls, pending actions and
usage through tables whose permission rule is assistant.admin, with the
person's session. On a host without core enforcement nothing checked that
rule for the store, so it was never seen.

AssistantStore, the only package allowed to call QQQ data actions, now marks
every one of its calls as the assistant's own service code
(withPermissionBypass with a reason), the way qbit-collaboration and
qbit-backups do. Only the table permission check is skipped, for those calls:

 - the session stays the person's: writes are attributed to them, and the
   owner record locks keep every other person's rows out (core does not relax
   record locks for a bypass);
 - the tables' permission rules are unchanged: the tables stay closed to that
   person in the UI, the UI API and the assistant's tools;
 - who may call is decided before the store: every operation of the assistant
   API requires assistant access, and the save rules of budgets and overrides
   require assistant.admin themselves.

A person's own budget. GET /config has "budgets" (UI API extension assistant
1.8.0): the budgets scoped to that user or to a permission that user holds,
with used, limit, percent, state and when the count starts again. Budgets
shared by several people are not listed. The same lines ride beside the
person's question (<assistant_budget>), so "how much of my budget have I
used?" has an answer.

CoreEnforcementTest runs on the fixture instance with core enforcement on.
Run without the store change, four of its five tests failed at the first
store read; the fifth (own budget) was written afterwards.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 11:04:09 -06:00
CI e37602b71e Bump version to 0.1.0-RC.29 [skip ci] 2026-11-11 16:21:20 +00:00
james.maes cd1cdaa1c0 Merge pull request 'release: merge develop into release/0.1 (weekly budgets, refusal sentence, limit ceiling)' (#101) from rc/develop-into-release-0.1-weekly into release/0.1
maven / build (push) Successful in 5m34s
build / maven (push) Successful in 5m34s
2026-11-11 16:15:55 +00:00
james.maesandClaude Fable 5.2 1ae7bb9cfa Merge develop into release/0.1 for the next 0.1.0 RC (weekly budgets, refusal sentence, limit ceiling)
maven / build (push) Successful in 5m16s
build / maven (push) Successful in 5m16s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 10:08:23 -06:00
james.maes f5bf3e1d04 Merge pull request 'feat(budgets): weekly budgets, a refusal that names the limit, a ceiling on what may be saved' (#100) from feat/weekly-budget into develop
maven / build (push) Successful in 6m54s
build / maven (push) Successful in 6m54s
2026-11-11 16:08:12 +00:00
CI 6d66deabc9 Bump version to 0.1.0-RC.28 [skip ci] 2026-11-11 14:03:10 +00:00
james.maes 08c8264f23 Merge pull request 'release: merge develop into release/0.1 (save_analysis, decided actions, ask before guessing)' (#99) from rc/develop-into-release-0.1-save into release/0.1
maven / build (push) Successful in 5m55s
build / maven (push) Successful in 5m55s
2026-11-11 13:57:25 +00:00
james.maesandClaude Fable 5.2 07b06f2070 Merge develop into release/0.1 for the next 0.1.0 RC (save_analysis, decided actions, ask before guessing)
maven / build (push) Successful in 5m13s
build / maven (push) Successful in 5m13s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 07:51:36 -06:00
CI 61fca48424 Bump version to 0.1.0-RC.27 [skip ci] 2026-11-11 13:51:22 +00:00
james.maes d761bd4a7d Merge pull request 'release: merge develop into release/0.1 (analyze_records)' (#97) from rc/develop-into-release-0.1-analyze into release/0.1
maven / build (push) Successful in 6m16s
build / maven (push) Successful in 6m16s
2026-11-11 13:45:08 +00:00
james.maes f1634b0911 Merge develop into release/0.1 for the next 0.1.0 RC (analyze_records)
maven / build (push) Successful in 6m27s
build / maven (push) Successful in 6m27s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
#	pom.xml
2026-11-11 07:34:30 -06:00
CI feab4caf17 Bump version to 0.1.0-RC.26 [skip ci] 2026-11-09 15:59:36 +00:00
james.maes ba21b72188 Merge pull request 'fix(engine): a stalled database can no longer hang a turn on 'Thinking'' (#94) from fix/turn-watchdog-never-hangs into release/0.1
maven / build (push) Successful in 5m5s
build / maven (push) Successful in 5m5s
2026-11-09 15:54:38 +00:00
james.maesandClaude Sonnet 5.5 0a65702dab fix(engine): a stalled database can no longer hang a turn on 'Thinking'
maven / build (push) Successful in 5m3s
build / maven (push) Successful in 5m3s
The watchdog ran the deadline logic after two database reads and heartbeat() was synchronized on the monitor that finish() needs, so one blocked database call stopped the deadline, the force-finalize and the final message.end. The deadline now runs first with no database access, housekeeping runs on a bounded abandonable worker, finish() has a guard that ends the stream with an error event, and every phase logs at INFO.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:48:47 -06:00
CI 739ca6981f Bump version to 0.1.0-RC.25 [skip ci] 2026-11-07 17:28:54 +00:00
james.maes b51dd4dad1 Merge pull request 'release: merge develop into release/0.1 (Gitea-aligned RC)' (#92) from align/develop-into-release-0.1 into release/0.1
maven / build (push) Successful in 8m11s
build / maven (push) Successful in 8m11s
2026-11-07 17:20:44 +00:00
james.maesandClaude Opus 5.5 c3a03b8d84 Merge develop into release/0.1 (Gitea-aligned RC)
maven / build (push) Canceled after 37s
build / maven (push) Canceled after 43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:19:04 -06:00
james.maes 2d25f0b769 Merge pull request 'fix(assistant): store the transcript before message.end so the panel shows the answer' (#85) from fix/transcript-stored-before-message-end into release/0.1
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 29s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 5m57s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 16s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 24m15s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 8s
2026-11-05 10:17:10 +00:00
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-maven-6 (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-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
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
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
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
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
10 changed files with 462 additions and 16 deletions

No files matched your search

+7
View File
@@ -1,5 +1,12 @@
# Changelog
## Unreleased - The assistant works for people who do not administer it on a host that enforces table permissions in core actions; a person's own budget (UI API extension 1.8.0)
- Fixed: on a host with `QInstance.enforcePermissionsInCoreActions` on, a person with `assistant.hasAccess` and without `assistant.admin` was refused before any model call ("Not authorized to access the assistant admin data."). The assistant read its settings (policy, providers, models, prices, budgets, data access) and wrote that person's messages, tool calls and usage through tables whose permission rule is `assistant.admin`, with the person's session. `AssistantStore` now marks every one of its calls as the assistant's own service code (`withPermissionBypass`), the way qbit-collaboration and qbit-backups do.
- What that does not change: the session is still the person's, so writes are attributed to them and the owner record locks keep every other person's conversations, messages, pending actions and usage out (core does not relax record locks for a bypass). The tables' permission rules are unchanged, so the tables stay closed to that person in the UI, the UI API and the assistant's tools. Every operation of the assistant API still requires assistant access first.
- A person sees their own spending against their own limit and nobody else's. `GET /config` has `budgets` (UI API extension assistant 1.8.0): the budgets scoped to that user or to a permission that user holds, with used, limit, percent, state and when the count starts again. Budgets shared by several people (INSTANCE, PROVIDER, MODEL) are not listed. The same lines ride beside the person's question (`<assistant_budget>`), so "how much of my budget have I used?" has an answer. `BudgetService.own` and `ownLine` are public.
- New `CoreEnforcementTest` (fixture instance with core enforcement on): a non-administrator's question with a tool call completes and is accounted to them; the settings tables refuse that person and the assistant's tools do not reach them; one person cannot read another's conversation, messages or model calls, through the API or the store; own budget in `/config` and beside the question; a person at their own daily limit is refused with the sentence while another person is answered. Without the store change every one of them fails at the first store read.
## Unreleased - Save and schedule an analysis with one confirmation; confirmed actions reach the conversation; ask before guessing
- New tool `save_analysis`: keeps an `analyze_records` question as a saved analysis the user owns (`data_analysis_saved`) and, when the user asked for it to be sent regularly, a daily schedule emailed to them (`data_analysis_schedule`: DAILY, a local time, an IANA time zone, the confirming user as the recipient). It only ever proposes: ONE pending-action card lists the saved analysis, what it shows, the filter, the schedule, who is emailed and the numbers as they are now. Nothing is created until the user confirms, whatever the policy says about confirming record writes. On confirmation both records are created through the tables' own insert operations with the confirming user's session, so their permissions, owner locks and validation decide.
+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.4.0-SNAPSHOT</revision>
<revision>0.1.0-RC.30</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -187,7 +187,7 @@ public class AssistantRuntime
this.knowledgeTool = new KnowledgeTool(this, ragService, vectors);
this.bulkLoadTools = new BulkLoadTools(qInstance, dataAccessService, config.getBulkLoadRunner());
this.attachmentService = new AttachmentService(config.getAttachments(), new UploadRefSource());
this.contextBuilder = new ContextBuilder(config, kernel, toolbox).withKnowledgeTool(knowledgeTool).withBulkLoadTools(bulkLoadTools);
this.contextBuilder = new ContextBuilder(config, kernel, toolbox).withKnowledgeTool(knowledgeTool).withBulkLoadTools(bulkLoadTools).withBudgets(budgetService);
if(Boolean.TRUE.equals(config.getRegisterShutdownHook()))
{
///////////////////////////////////////////////////////////////////////
@@ -36,6 +36,7 @@ import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.attachments.AttachmentService;
import com.kingsrook.qbits.assistant.attachments.UploadRefSource;
import com.kingsrook.qbits.assistant.engine.AbandonedTurns;
import com.kingsrook.qbits.assistant.engine.BudgetService;
import com.kingsrook.qbits.assistant.engine.ClientErrors;
import com.kingsrook.qbits.assistant.engine.ContextBuilder;
import com.kingsrook.qbits.assistant.engine.MessageParts;
@@ -207,11 +208,49 @@ public class AssistantApi
{
result.put("conversationsTable", AssistantConversation.TABLE_NAME);
}
if(hasAccess())
{
result.put("budgets", ownBudgets());
}
return (result);
}
/*******************************************************************************
** This person's own budgets with what they have used (BudgetService.own):
** their own spending against their own limits, never a shared budget's count
** and never another person's. Never throws.
*******************************************************************************/
private List<Map<String, Object>> ownBudgets()
{
List<Map<String, Object>> rs = new ArrayList<>();
try
{
for(BudgetService.Snapshot snapshot : runtime.getBudgetService().own(userId))
{
Map<String, Object> budget = new LinkedHashMap<>();
budget.put("name", snapshot.budget().getName());
budget.put("period", snapshot.budget().getPeriod());
budget.put("metric", snapshot.budget().getMetric());
budget.put("used", snapshot.used());
budget.put("limit", snapshot.limit());
budget.put("percent", snapshot.percent());
budget.put("state", snapshot.state());
budget.put("startsAgainAt", String.valueOf(BudgetService.periodEnd(snapshot.budget().getPeriod(), snapshot.periodStart())));
budget.put("text", BudgetService.ownLine(snapshot));
rs.add(budget);
}
}
catch(QException | RuntimeException e)
{
LOG.warn("Could not read the user's own assistant budgets", e);
}
return (rs);
}
/*******************************************************************************
** The example prompts for this user (see StarterPrompts): built from the
** tables the user may read, plus the configured ones they may see. Never
@@ -63,7 +63,7 @@ import io.javalin.http.HttpStatus;
public final class AssistantSpecs
{
public static final String EXTENSION_NAME = "assistant";
public static final String EXTENSION_VERSION = "1.7.0";
public static final String EXTENSION_VERSION = "1.8.0";
public static final String PREFIX = "/assistant";
@@ -208,7 +208,11 @@ public final class AssistantSpecs
+ "and sent as pageContext.attachments [{uploadRef, fileName}] are read for the question."),
"profile", ref("AssistantProfileV1"),
"conversationsTable", string("Since 1.6.0: the table name of the user's conversations (assistant_conversation) for the standard table UI "
+ "(manage, archive, restore and delete); present only when this session may query that table.").withNullable(true)));
+ "(manage, archive, restore and delete); present only when this session may query that table.").withNullable(true),
"budgets", array(freeObject("name, period (DAY, WEEK or MONTH, in UTC), metric (COST_USD or TOKENS), used, limit (null while an override makes it unlimited), percent, "
+ "state (OK, WARNED, DOWNGRADED, STOPPED or UNLIMITED), startsAgainAt (ISO-8601 instant) and text (the same as one sentence)."),
"Since 1.8.0: this user's own budgets with what this user has used in the current period: the ones scoped to the user or to a permission the user holds. "
+ "Budgets shared by several people are not listed. Present when the user may use the assistant; empty when the user has no budget of their own.")));
rs.put("AssistantConversationV1", object("One of the user's own conversations.",
"id", integer(null), "title", string(null).withNullable(true), "status", string("ACTIVE or ARCHIVED."), "pinned", bool(null),
"lastMessageAt", string("ISO-8601 instant.").withNullable(true), "unreadCount", integer(null), "createDate", string("ISO-8601 instant.").withNullable(true),
@@ -202,6 +202,54 @@ public class BudgetService
/*******************************************************************************
** The budgets that are this person's own: scoped to them (USER) or to a
** permission they hold (ROLE, where every holder is counted separately), with
** what that person has used in the current period. It is asked with that
** person's session, so the usage read is their own rows and nobody else's.
** Budgets shared by several people (INSTANCE, PROVIDER, MODEL) are left out:
** their count is other people's spending too.
*******************************************************************************/
public List<Snapshot> own(String userId) throws QException
{
List<Snapshot> rs = new ArrayList<>();
if(userId == null)
{
return (rs);
}
for(AssistantBudget budget : store.budgets())
{
boolean mine = switch(budget.getScopeType())
{
case "USER" -> Objects.equals(budget.getScopeValue(), userId);
case "ROLE" -> QContext.getQSession().hasPermission(budget.getScopeValue());
default -> false;
};
if(mine)
{
rs.add(snapshot(budget, userId));
}
}
return (rs);
}
/*******************************************************************************
** One of a person's own budgets as a sentence: what is used, of how much, and
** when the count starts again.
*******************************************************************************/
public static String ownLine(Snapshot snapshot)
{
AssistantBudget budget = snapshot.budget();
Instant end = periodEnd(budget.getPeriod(), snapshot.periodStart());
return (periodWord(budget.getPeriod()) + " budget '" + budget.getName() + "': " + amount(budget, snapshot.used()) + " used"
+ (snapshot.limit() == null ? ", no limit at the moment" : " of " + amount(budget, snapshot.limit()) + " (" + snapshot.percent().setScale(0, RoundingMode.DOWN) + "%)")
+ "; the count starts again on " + RESET_DAY.format(LocalDate.ofInstant(end, ZoneOffset.UTC)) + " at 00:00 UTC");
}
/*******************************************************************************
** Whether the caller and selected provider/model are within this scope.
*******************************************************************************/
@@ -27,6 +27,7 @@ import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.spi.CacheHint;
import com.kingsrook.qbits.assistant.spi.ModelMessage;
@@ -41,6 +42,8 @@ import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolDefinition;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -65,6 +68,8 @@ public class ContextBuilder
///////////////////////////////////////////////////////////////////////
public static final Set<String> KERNEL_TOOLS = Set.of("navigate", "search_help");
private static final QLogger LOG = QLogger.getLogger(ContextBuilder.class);
public static final int MAX_CONTEXT_CHARS = 512;
public static final int MAX_SELECTED_IDS = 100;
public static final int MAX_ATTACHMENTS = 5;
@@ -75,6 +80,7 @@ public class ContextBuilder
private volatile String appDigest;
private KnowledgeTool knowledgeTool;
private BulkLoadTools bulkLoadTools;
private BudgetService budgets;
@@ -296,6 +302,11 @@ public class ContextBuilder
newMessage.withPart(ModelPart.text("<page_context note=\"where the user is in the app; a hint from the browser, not verified\">"
+ JsonUtils.toJson(pageContext) + "</page_context>"));
}
String ownBudgets = ownBudgets(session);
if(ownBudgets != null)
{
newMessage.withPart(ModelPart.text(ownBudgets));
}
if(!messages.isEmpty() && messages.get(messages.size() - 1).getRole() == ModelMessage.Role.USER)
{
ModelMessage previous = messages.remove(messages.size() - 1);
@@ -345,6 +356,51 @@ public class ContextBuilder
/*******************************************************************************
** The asking person's own budgets (BudgetService.own), as a note beside their
** question, so "how much of my budget have I used?" has an answer: their own
** spending against their own limits and nobody else's. It rides with the new
** message, not the cached blocks, since it changes with every question. Null
** when the person has no budget of their own; a failure to read is logged
** and never fails the question.
*******************************************************************************/
String ownBudgets(QSession session)
{
if(budgets == null)
{
return (null);
}
try
{
List<BudgetService.Snapshot> own = budgets.own(AssistantSecurity.userId(session));
if(own.isEmpty())
{
return (null);
}
StringBuilder text = new StringBuilder("<assistant_budget note=\"the user's own assistant spending limits, from the server; use it only when they ask about their usage or limit\">");
own.forEach(snapshot -> text.append("\n").append(BudgetService.ownLine(snapshot)));
return (text.append("\n</assistant_budget>").toString());
}
catch(QException | RuntimeException e)
{
LOG.warn("Could not read the user's own assistant budgets", e);
return (null);
}
}
/*******************************************************************************
** The budget service, for the asking person's own budgets.
*******************************************************************************/
public ContextBuilder withBudgets(BudgetService budgets)
{
this.budgets = budgets;
return (this);
}
/*******************************************************************************
** Append text as a message, merging consecutive messages of one role.
*******************************************************************************/
@@ -62,6 +62,7 @@ import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
@@ -85,6 +86,22 @@ import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
** own rows). Queries also filter on userId explicitly, so an admin's
** all-access key never mixes other users' conversations into the panel.
**
** Every call is the assistant's own service code and is marked as such
** (withPermissionBypass), so a host that enforces table permissions inside
** core actions (QInstance.enforcePermissionsInCoreActions) does not ask the
** person for assistant.admin when the assistant reads its policy, providers,
** models, prices, budgets and data access settings to decide, or writes that
** person's messages, tool calls, pending actions and usage. Only the table
** permission check is skipped, and only for these calls:
** - the session stays the person's, so a write is attributed to them and the
** owner record locks still keep every other person's rows out (core does
** not relax record locks for a bypass);
** - the tables' permission rules are unchanged, so the tables stay closed to
** that person in the UI, the UI API and the assistant's tools;
** - who may call is decided before the store is reached: the assistant API
** requires assistant access for every operation, and the save rules of the
** budgets and overrides require assistant.admin themselves.
**
** This is the only package allowed to call QQQ data actions (ArchUnit).
*******************************************************************************/
public class AssistantStore
@@ -111,12 +128,26 @@ public class AssistantStore
private static final Map<String, Object> USAGE_LOCKS = new ConcurrentHashMap<>();
static final String TRUSTED_REASON = "Assistant store: the assistant's own tables, behind the assistant API's access check; owner record locks still apply.";
/////////////////////////////////////////////////////////////////////////////
// generic helpers //
/////////////////////////////////////////////////////////////////////////////
/*******************************************************************************
** Mark a store call as the assistant's own service code (see the class
** comment). The reason is what core logs when it applies the bypass.
*******************************************************************************/
private static <T extends AbstractActionInput> T trusted(T input)
{
input.withPermissionBypass(TRUSTED_REASON);
return (input);
}
/*******************************************************************************
** The store only ever touches the QBit's own tables (assistant_*): the
** user's application data is reached only through the UI API.
@@ -137,7 +168,7 @@ public class AssistantStore
public <T extends QRecordEntity> List<T> query(String tableName, QQueryFilter filter, Class<T> entityClass) throws QException
{
requireOwnTable(tableName);
List<QRecord> records = new QueryAction().execute(new QueryInput(tableName).withFilter(filter)).getRecords();
List<QRecord> records = new QueryAction().execute(trusted(new QueryInput(tableName)).withFilter(filter)).getRecords();
List<T> result = new ArrayList<>();
for(QRecord record : CollectionUtils.nonNullList(records))
{
@@ -155,7 +186,7 @@ public class AssistantStore
public <T extends QRecordEntity> T insert(T entity) throws QException
{
requireOwnTable(entity.tableName());
InsertOutput output = new InsertAction().execute(new InsertInput(entity.tableName()).withRecord(cryptRecord(entity.tableName(), entity.toQRecord(), true)));
InsertOutput output = new InsertAction().execute(trusted(new InsertInput(entity.tableName())).withRecord(cryptRecord(entity.tableName(), entity.toQRecord(), true)));
QRecord record = output.getRecords().get(0);
if(CollectionUtils.nullSafeHasContents(record.getErrors()))
{
@@ -183,7 +214,7 @@ public class AssistantStore
{
requireOwnTable(entity.tableName());
rememberTranscript(entity.tableName(), entity.toQRecord(), entity.toQRecord().getValue("id"));
UpdateOutput output = new UpdateAction().execute(new UpdateInput(entity.tableName()).withRecord(cryptRecord(entity.tableName(), entity.toQRecord(), true)));
UpdateOutput output = new UpdateAction().execute(trusted(new UpdateInput(entity.tableName())).withRecord(cryptRecord(entity.tableName(), entity.toQRecord(), true)));
QRecord record = output.getRecords().get(0);
if(CollectionUtils.nullSafeHasContents(record.getErrors()))
{
@@ -200,7 +231,7 @@ public class AssistantStore
{
requireOwnTable(tableName);
rememberTranscript(tableName, record, record.getValue("id"));
UpdateOutput output = new UpdateAction().execute(new UpdateInput(tableName).withRecord(cryptRecord(tableName, record, true)));
UpdateOutput output = new UpdateAction().execute(trusted(new UpdateInput(tableName)).withRecord(cryptRecord(tableName, record, true)));
QRecord result = output.getRecords().get(0);
if(CollectionUtils.nullSafeHasContents(result.getErrors()))
{
@@ -221,7 +252,7 @@ public class AssistantStore
public void delete(String tableName, QQueryFilter filter) throws QException
{
requireOwnTable(tableName);
new DeleteAction().execute(new DeleteInput(tableName).withQueryFilter(filter));
new DeleteAction().execute(trusted(new DeleteInput(tableName)).withQueryFilter(filter));
}
@@ -743,7 +774,7 @@ public class AssistantStore
{
records.add(new QRecord().withValue("id", call.getId()).withValue("resultJson", null));
}
new UpdateAction().execute(new UpdateInput(AssistantToolCall.TABLE_NAME).withRecords(records));
new UpdateAction().execute(trusted(new UpdateInput(AssistantToolCall.TABLE_NAME)).withRecords(records));
return (calls.size());
}
@@ -764,7 +795,7 @@ public class AssistantStore
}
List<Serializable> ids = new ArrayList<>();
events.forEach(e -> ids.add(e.getId()));
new DeleteAction().execute(new DeleteInput(AssistantTurnEvent.TABLE_NAME).withPrimaryKeys(ids));
new DeleteAction().execute(trusted(new DeleteInput(AssistantTurnEvent.TABLE_NAME)).withPrimaryKeys(ids));
return (events.size());
}
@@ -776,7 +807,7 @@ public class AssistantStore
*******************************************************************************/
public int countClientErrorsSince(Instant since) throws QException
{
Integer count = new CountAction().execute(new CountInput(AssistantClientError.TABLE_NAME)
Integer count = new CountAction().execute(trusted(new CountInput(AssistantClientError.TABLE_NAME))
.withFilter(new QQueryFilter().withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since))).getCount();
return (count == null ? 0 : count);
}
@@ -797,7 +828,7 @@ public class AssistantStore
}
List<Serializable> ids = new ArrayList<>();
errors.forEach(e -> ids.add(e.getId()));
new DeleteAction().execute(new DeleteInput(AssistantClientError.TABLE_NAME).withPrimaryKeys(ids));
new DeleteAction().execute(trusted(new DeleteInput(AssistantClientError.TABLE_NAME)).withPrimaryKeys(ids));
return (errors.size());
}
@@ -923,7 +954,7 @@ public class AssistantStore
public List<QRecord> queryRecords(String tableName, QQueryFilter filter, int limit) throws QException
{
requireOwnTable(tableName);
List<QRecord> records = new QueryAction().execute(new QueryInput(tableName).withFilter(filter.withLimit(limit))).getRecords();
List<QRecord> records = new QueryAction().execute(trusted(new QueryInput(tableName)).withFilter(filter.withLimit(limit))).getRecords();
return (records == null ? List.of() : records.stream().map(record -> cryptRecord(tableName, record, false)).toList());
}
@@ -1160,7 +1191,7 @@ public class AssistantStore
*******************************************************************************/
public void scheduledUpdate(QRecord record) throws QException
{
UpdateOutput output = new UpdateAction().execute(new UpdateInput(AssistantScheduledTask.TABLE_NAME)
UpdateOutput output = new UpdateAction().execute(trusted(new UpdateInput(AssistantScheduledTask.TABLE_NAME))
.withRecord(record).withFlag(ScheduledWrite.INTERNAL));
if(CollectionUtils.nullSafeHasContents(output.getRecords().get(0).getErrors()))
{
@@ -212,6 +212,20 @@ components:
\ only when this session may query that table."
nullable: true
type: "string"
budgets:
description: "Since 1.8.0: this user's own budgets with what this user has\
\ used in the current period: the ones scoped to the user or to a permission\
\ the user holds. Budgets shared by several people are not listed. Present\
\ when the user may use the assistant; empty when the user has no budget\
\ of their own."
items:
additionalProperties: true
description: "name, period (DAY, WEEK or MONTH, in UTC), metric (COST_USD\
\ or TOKENS), used, limit (null while an override makes it unlimited),\
\ percent, state (OK, WARNED, DOWNGRADED, STOPPED or UNLIMITED), startsAgainAt\
\ (ISO-8601 instant) and text (the same as one sentence)."
type: "object"
type: "array"
type: "object"
AssistantConversationDetailResponseV1:
description: "A conversation and a page of its messages, oldest first."
@@ -476,7 +490,7 @@ info:
description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\
\ and data access explanations"
title: "QQQ Middleware API extension: assistant"
version: "1.7.0"
version: "1.8.0"
openapi: "3.0.3"
paths:
/qqq/v1/assistant/config:
@@ -0,0 +1,247 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.api.AssistantApiException;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantBudgetUsage;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantUsage;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ADMIN;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.OTHER_EDITOR;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The assistant on a host that enforces table permissions inside core actions
** (QInstance.enforcePermissionsInCoreActions): a person who may use the
** assistant and does not administer it gets answers, within their own
** permissions and their own budget, and sees none of its configuration.
**
** The assistant's tables are of two kinds. A person's own data (conversations,
** messages, turns, tool calls, pending actions, usage) is owner-locked: the
** store reads and writes it with the person's session, so the record locks
** keep everybody else's rows out. The assistant's configuration and accounting
** (policy, providers, models, prices, budgets and their events, data access
** settings, the shared budget accounting) has no owner: the store reads it as
** trusted code, so the assistant can decide, and writes its accounting
** attributed to the person. In both cases only the table permission check is
** skipped, for the store's own calls; the tables' permission rules are
** unchanged for every other caller.
*******************************************************************************/
class CoreEnforcementTest extends BaseAssistantTest
{
/*******************************************************************************
** The fixture instance, enforcing table permissions inside core actions.
*******************************************************************************/
@Override
protected AssistantTestFixture.InstanceCustomizer instanceCustomizer()
{
return (qInstance -> qInstance.setEnforcePermissionsInCoreActions(true));
}
/*******************************************************************************
** A person with assistant access and without assistant.admin asks a question
** that needs a tool, and gets the answer. The model call is accounted to them.
*******************************************************************************/
@Test
void aPersonWhoDoesNotAdministerTheAssistantGetsAnAnswer() throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
assertThat(QContext.getQSession().hasPermission("assistant.admin")).isFalse();
assertThat(QContext.getQSession().hasPermission("assistant.hasAccess")).isTrue();
provider.thenToolUse("count_records", input("table", "blog_post", "filter", filter("AND", "status", "EQUALS", List.of("PUBLISHED"))))
.thenRespond(request -> new ModelResponse().withParts(new ArrayList<>(List.of(ModelPart.text("There are 4 published posts.")))).withStopReason("end_turn"));
TurnRunner.Outcome outcome = runner.run(EDITOR, "how many published posts?");
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
assertThat(outcome.text()).isEqualTo("There are 4 published posts.");
assertThat(outcome.toolCalls()).hasSize(1);
assertThat(outcome.toolCalls().get(0).getOutcome()).isEqualTo("OK");
QContext.init(qInstance, AssistantTestFixture.ownerSession(ADMIN, config));
List<AssistantUsage> calls = config.getRuntime().getStore().query(AssistantUsage.TABLE_NAME, new QQueryFilter(), AssistantUsage.class);
assertThat(calls).isNotEmpty().allMatch(call -> EDITOR.equals(call.getUserId()));
assertThat(config.getRuntime().getStore().query(AssistantBudgetUsage.TABLE_NAME, new QQueryFilter(), AssistantBudgetUsage.class)).hasSameSizeAs(calls);
}
/*******************************************************************************
** The configuration stays closed to that person: the tables refuse them, and
** the assistant's own tools do not know the tables.
*******************************************************************************/
@Test
void theConfigurationStaysClosedToThatPerson() throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
for(String table : List.of(AssistantPolicy.TABLE_NAME, AssistantProvider.TABLE_NAME, AssistantModel.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantBudget.TABLE_NAME,
AssistantBudgetUsage.TABLE_NAME, AssistantUsage.TABLE_NAME, AssistantMessage.TABLE_NAME))
{
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(table))).as(table).isInstanceOf(QPermissionDeniedException.class);
}
provider.thenToolUse("query_records", input("table", AssistantProvider.TABLE_NAME))
.thenToolUse("count_records", input("table", AssistantBudget.TABLE_NAME))
.thenText("I cannot see that.");
TurnRunner.Outcome outcome = runner.run(EDITOR, "show me the assistant providers and count the budgets");
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
assertThat(outcome.toolCalls()).hasSize(2).allMatch(call -> !"OK".equals(call.getOutcome()));
assertThat(outcome.toolCalls()).noneMatch(call -> String.valueOf(call.getResultJson()).contains("apiKey") || String.valueOf(call.getResultJson()).contains("limitValue"));
}
/*******************************************************************************
** One person's conversation is not another's: the API answers as if it did
** not exist, and the store, asked with the other person's session, finds no
** row (the owner record lock, which the store does not skip).
*******************************************************************************/
@Test
void aPersonsConversationIsNotAnothers() throws Exception
{
provider.thenText("Hello.");
TurnRunner.Outcome outcome = runner.run(EDITOR, "Hello");
Integer conversationId = outcome.conversationId();
QContext.init(qInstance, AssistantTestFixture.ownerSession(OTHER_EDITOR, config));
AssistantApi other = new AssistantApi(config.getRuntime(), QContext.getQSession());
assertThatThrownBy(() -> other.getConversation(conversationId, null, null)).isInstanceOf(AssistantApiException.class)
.satisfies(e -> assertThat(((AssistantApiException) e).getStatus()).isEqualTo(404));
AssistantStore store = config.getRuntime().getStore();
assertThat(store.query(AssistantConversation.TABLE_NAME, new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, conversationId), AssistantConversation.class)).isEmpty();
assertThat(store.query(AssistantMessage.TABLE_NAME, new QQueryFilter().withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId), AssistantMessage.class)).isEmpty();
assertThat(store.query(AssistantUsage.TABLE_NAME, new QQueryFilter(), AssistantUsage.class)).as("nor the other person's model calls").isEmpty();
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
assertThat(store.query(AssistantMessage.TABLE_NAME, new QQueryFilter().withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId), AssistantMessage.class)).isNotEmpty();
}
/*******************************************************************************
** A person sees their own spending against their own limit, and nobody
** else's: in GET /config for the panel, and as a note beside their question
** so the assistant can answer "how much of my budget have I used?". The
** budget everyone shares is not listed, since its count is other people's
** spending too.
*******************************************************************************/
@Test
void aPersonSeesTheirOwnSpendingAgainstTheirOwnLimit() throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(ADMIN, config));
AssistantStore store = config.getRuntime().getStore();
store.insert(new AssistantBudget().withName("Each person, a day").withScopeType("ROLE").withScopeValue("assistant.hasAccess")
.withPeriod("DAY").withMetric("COST_USD").withLimitValue(BigDecimal.valueOf(25)).withAtLimitAction("STOP").withHardCeilingPercent(100));
store.insert(new AssistantBudget().withName("Everyone, a week").withScopeType("INSTANCE").withPeriod("WEEK")
.withMetric("COST_USD").withLimitValue(BigDecimal.valueOf(500)).withAtLimitAction("STOP").withHardCeilingPercent(100));
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
store.insert(new AssistantUsage().withUserId(EDITOR).withPurpose("TURN").withStatus("OK").withCostMicros(3_200_000L).withInputTokens(1L).withOutputTokens(1L).withCreateDate(Instant.now()));
QContext.init(qInstance, AssistantTestFixture.ownerSession(OTHER_EDITOR, config));
store.insert(new AssistantUsage().withUserId(OTHER_EDITOR).withPurpose("TURN").withStatus("OK").withCostMicros(7_000_000L).withInputTokens(1L).withOutputTokens(1L).withCreateDate(Instant.now()));
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
List<Map<String, Object>> mine = budgetsOf(new AssistantApi(config.getRuntime(), QContext.getQSession()).config());
assertThat(mine).hasSize(1);
assertThat(mine.get(0)).containsEntry("name", "Each person, a day").containsEntry("period", "DAY").containsEntry("metric", "COST_USD").containsEntry("state", "OK");
assertThat((BigDecimal) mine.get(0).get("used")).isEqualByComparingTo("3.2");
assertThat((BigDecimal) mine.get(0).get("limit")).isEqualByComparingTo("25");
assertThat((String) mine.get(0).get("text")).startsWith("daily budget 'Each person, a day': $3.20 used of $25.00 (12%); the count starts again on ").endsWith(" at 00:00 UTC");
QContext.init(qInstance, AssistantTestFixture.ownerSession(OTHER_EDITOR, config));
List<Map<String, Object>> theirs = budgetsOf(new AssistantApi(config.getRuntime(), QContext.getQSession()).config());
assertThat(theirs).hasSize(1);
assertThat((BigDecimal) theirs.get(0).get("used")).as("the other person's own spending, not the sum").isEqualByComparingTo("7");
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.NO_ACCESS));
assertThat(new AssistantApi(config.getRuntime(), QContext.getQSession()).config()).as("somebody who may not use the assistant is told nothing").doesNotContainKey("budgets");
provider.thenText("You have used $3.20 of your $25.00 for today.");
TurnRunner.Outcome outcome = runner.run(EDITOR, "how much of my assistant budget have I used?");
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
String sent = provider.getRequestJson().get(provider.getRequestJson().size() - 1);
assertThat(sent).contains("<assistant_budget").contains("daily budget 'Each person, a day': $3.20 used of $25.00 (12%)")
.doesNotContain("Everyone, a week").doesNotContain("$7.00").doesNotContain("$10.20");
}
/*******************************************************************************
** The budgets block of a config answer.
*******************************************************************************/
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> budgetsOf(Map<String, Object> config)
{
return ((List<Map<String, Object>>) config.get("budgets"));
}
/*******************************************************************************
** A person's own daily limit stops that person, with the sentence, and
** nobody else.
*******************************************************************************/
@Test
void aPersonIsStoppedByTheirOwnLimitAndNobodyElseIs() throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(ADMIN, config));
AssistantStore store = config.getRuntime().getStore();
store.insert(new AssistantBudget().withName("Each person, a day").withScopeType("ROLE").withScopeValue("assistant.hasAccess")
.withPeriod("DAY").withMetric("COST_USD").withLimitValue(BigDecimal.valueOf(25)).withAtLimitAction("STOP").withHardCeilingPercent(100));
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
store.insert(new AssistantUsage().withUserId(EDITOR).withPurpose("TURN").withStatus("OK").withCostMicros(25_000_000L).withInputTokens(1L).withOutputTokens(1L).withCreateDate(Instant.now()));
assertThatThrownBy(() -> runner.run(EDITOR, "Hello")).isInstanceOf(AssistantApiException.class)
.hasMessageStartingWith("The assistant has reached the daily limit of $25.00 of the budget 'Each person, a day'. It starts again on ")
.satisfies(e -> assertThat(((AssistantApiException) e).getStatus()).isEqualTo(429));
provider.thenText("Hello.");
assertThat(runner.run(OTHER_EDITOR, "Hello").end()).containsEntry("status", "COMPLETE");
}
}