Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 c95e1403c5 test: make RobustnessTest.testConcurrentUsage deterministic
CircleCI job 22 failed with turnCount 19 instead of 20. A writer thread
died with ConcurrentModificationException: QQQ's MemoryRecordStore.query
iterates a table's synchronized map without holding its lock, so one
writer's INSTANCE-row update (INSTANCE usage lock) raced another
writer's insert of a user row (a different usage lock). The exception
was swallowed by the virtual thread, so the test only saw a lost count.

The race is in the in-memory test backend, not in AssistantStore's
per-key locking. The writers now use a store whose individual backend
calls are atomic, as on a real database, so the per-key locks are still
the only thing protecting the read-modify-write under test. A start
latch releases all writers together, and an executor's futures surface
any writer exception as a test failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:13:08 -05:00
james.maes c04950efc4 Merge pull request #8 from QRun-IO/feature/ga-only-release-tags
ci: publish only GA tags (vX.Y.Z), never RC tags
2026-10-12 16:01:34 -05:00
james.maesandClaude Opus 5.5 f031f9f50a ci: publish only GA tags (vX.Y.Z), never RC tags
An RC tag matched /v.*/ and the orb republished it as the next RC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:58:47 -05:00
james.maes 61a2ba643f Merge pull request #3 from QRun-IO/feature/snake-case-table-names
feat!: snake_case assistant_ table and key names (0.1.0-RC.2)
2026-10-12 15:17:10 -05:00
james.maes a4f113ef61 Merge branch 'develop' into feature/snake-case-table-names 2026-10-12 15:15:12 -05:00
james.maes f05fe4f346 Merge pull request #4 from QRun-IO/chore/qrun-repo-standard
chore: align with the QRun qbit repository standard
2026-10-12 14:54:57 -05:00
james.maesandClaude Opus 5.5 8a3c483350 chore: align with the QRun qbit repository standard
- `.editorconfig` (Kingsrook 3-space layout)
- `.github/dependabot.yml` (Maven + Actions, grouped minor/patch, at most 3 open PRs, target `develop`)
- `mvnw` executable (100755)
- `AGENTS.md` (same content as `CLAUDE.md`, as in the other qbits)
- `LICENSE` appendix copyright holder `Kingsrook, LLC`, matching the other qbits, NOTICE and the pom developers (was `QRunIO, LLC`; please confirm)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:48:47 -05:00
james.maesandClaude Opus 5.5 bf914dc76e fix: keep the assistant.hasAccess permission
QQQ core's standard permission suffix is hasAccess
(PrivatePermissionSubType.HAS_ACCESS) and the assistant base already
follows the naming rule, so the permission stays assistant.hasAccess,
matching the sql-workbench rename. Drops LEGACY_ACCESS_PERMISSION and
updates README, CHANGELOG, UPGRADE.md and NamingTest. Table, PVS and
key-type renames are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:12:00 -05:00
james.maesandClaude Opus 5.5 e5413d8ad0 feat!: snake_case table, permission and key names (assistant_ prefix)
Applies the QRun-IO naming rule (owner, 2026-10-12): table names are
lowercase snake_case and prefixed with the qbit's name. Metadata only;
docs/schema-postgres.sql already creates assistant_* tables.

- Tables: assistantConversation ... assistantUsageDaily become
  assistant_conversation, assistant_message, assistant_turn,
  assistant_turn_event, assistant_tool_call, assistant_feedback,
  assistant_policy, assistant_usage_daily. The conversation PVS follows.
- Permission assistant.hasAccess becomes assistant.has_access
  (assistant.admin unchanged).
- Owner key type assistantUserId becomes assistant_user_id; all-access
  key assistant_user_id_all_access.
- @Deprecated LEGACY_* constants keep the RC.1 names until 0.2.0.
- UPGRADE.md lists the stored references to update (permissions,
  saved views and reports, audit_table names) with sample SQL.
- NamingTest pins the new and legacy names.

Target: 0.1.0-RC.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:04:57 -05:00
james.maes c07cfd3c6c Merge pull request #2 from QRun-IO/feature/pom-revision-comment
build: keep the literal revision tag out of the pom comment
2026-10-12 12:41:11 -05:00
james.maesandClaude Opus 5.5 abc7a4bd5a build: restore the comment wording
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:38:45 -05:00
james.maesandClaude Opus 5.5 6c08f89944 build: keep the literal revision tag out of the pom comment
qqq-orb's calculate_version.sh greps the pom for '<revision>' and took
the comment line, so the release/0.1 and develop publishes failed with
'Cannot parse version format'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:38:35 -05:00
james.maes 2bb0b459cf Merge pull request #1 from QRun-IO/feature/phase-0-1-assistant
feat: QQQ Assistant qbit phase 0/1 (read-only Q&A, SSE, Anthropic provider)
2026-10-12 12:28:10 -05:00
28 changed files with 587 additions and 73 deletions

No files matched your search

+1 -1
View File
@@ -57,7 +57,7 @@ workflows:
branches:
ignore: /.*/
tags:
only: [/v.*/]
only: [/^v\d+\.\d+\.\d+$/]
publish_hotfix_release:
jobs:
+20
View File
@@ -0,0 +1,20 @@
# QRun-IO qbit .editorconfig: the Kingsrook layout (3-space indentation, never tabs).
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 3
tab_width = 3
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[*.{cmd,bat}]
end_of_line = crlf
+25
View File
@@ -0,0 +1,25 @@
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
# major updates stay separate so each gets its own review. At most three version-update PRs per
# ecosystem are open at once. Security updates are enabled in the repository settings and are
# not limited by this schedule.
version: 2
updates:
- package-ecosystem: maven
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
maven-minor-and-patch:
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
github-actions:
patterns: ['*']
+90
View File
@@ -0,0 +1,90 @@
# AGENTS.md
This file provides guidance to Codex when working with code in this repository.
## Project Overview
**QBit Assistant** adds an in-app AI assistant to any QQQ application. It owns the chat backend:
- conversations and audit tables
- the turn engine (the agent loop)
- the `/qqq/v1/assistant` routes, with SSE
- budgets, rate limits and the kill switch
- the model-provider SPI.
The panel UI lives in `qqq-frontend-next`. Data access goes only through the **AI tool kernel in QQQ core** (`com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel`).
- Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`. The owner decisions D1 to D6 at its top are binding.
- Phase 1 is read and navigate only: no write tools and no approval cards.
## Build Commands
```bash
./mvnw verify # all modules, tests, JaCoCo gate (70% instructions / 90% classes)
./mvnw verify -Dqqq.snapshot.version=4.1.0-ASSISTDEV-SNAPSHOT # against a locally installed QQQ build with the kernel (+ developer panels)
./mvnw test -pl qbit-assistant -Dtest=RedTeamTest
ANTHROPIC_API_KEY=... ./mvnw verify -Dassistant.live=true -Dtest.excludedGroups= -Dgroups=live -Djacoco.skip=true -pl qbit-assistant-provider-anthropic -am
```
Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed default QQQ version (`qqq.snapshot.version` in the root POM) must stay resolvable from Maven Central. Use a local kernel build only through `-D`.
## Modules
- `qbit-assistant` (`com.kingsrook.qbits.assistant`):
- `AssistantQBitProducer`, `AssistantQBitConfig` (with `AssistantModelRouting` and `AssistantBudgets`), `AssistantRuntime`, `AssistantSecurity`.
- `metadata/`: tables, owner key type, the Assistant Admin app, the retention process, and the `assistant.admin` custom permission checker.
- `model/`: entities for the `assistant*` tables.
- `persistence/AssistantStore`: the **only** code that calls QQQ data actions, and only on the QBit's own tables, under the user's session.
- `engine/`: `TurnEngine` (the loop), `ContextBuilder` (cache-prefix order), `SystemPrompt`, `PolicyService` (policy row, budgets), `RateLimiter`, the `TurnEvent` / `TurnEventLog` stream, and `MessageParts`.
- `api/`: `AssistantApi` (transport-free) and `AssistantRouteProvider` (Javalin, SSE).
- `provider/`: the provider-neutral SPI, plus `fake/ScriptedModelProvider` for tests, evals and demos.
- `developer/`: the record Developer-screen panel, on the core developer-panel contract.
- `processes/AssistantRetentionStep`.
- `qbit-assistant-provider-anthropic`: `AnthropicModelProvider` and `AnthropicMapping` (anthropic-java streaming).
## Invariants (do not break)
- **Sessions:**
- Never use `QSystemUserSession` or construct a `QSession` in main code (ArchUnit).
- Every route uses `ExecutorSessionUtils.setupSession` (the user's own session).
- `AssistantSecurity` only ever adds the user's OWN id key, or the all-access key for `assistant.admin` holders.
- **Data access:**
- Only `persistence/` calls QueryAction, InsertAction and the other data actions (ArchUnit).
- Tools run only through `AiToolKernel.execute`.
- Panel API queries also filter `userId = me`, so an admin's all-access key never mixes other users' conversations into the panel.
- **Caching:** the request prefix stays byte-stable. The order is tools (kernel-sorted), then system block 1 (rules + primer + app digest, LONG/1h), then system block 2 (user digest + date, SHORT/5m), then messages. Exactly one SHORT hint goes on the newest message. Don't put timestamps or ids in the system prompt.
- **Tool results:**
- Parallel tool results go back in ONE user message, in call order.
- Errors (DENIED, NOT_FOUND, INVALID_INPUT, ERROR) are `is_error` results.
- Unknown tools and over-limit calls also get error results.
- **Logging:** never log message content, prompts, tool results or keys. Structured end-of-turn log only.
- **Thinking:** thinking is never streamed or stored. The Anthropic adapter keeps the raw assistant message (with signatures) only in memory, as `providerState`, for the tool loop.
- **Links:** only `/app` or `/app/...` hrefs are emitted (`TurnEngine.isAppHref`).
- **Kill switch:** read fresh before every model call and every tool step.
## Tests
- `testing/` holds the fixture app on the memory backend:
- blog, page, media and analytics tables, plus audits
- sentinel values in hidden and password fields
- a prompt-injection record
- MOCK auth where the user-id prefix picks the permissions.
- `testing/` also holds `TurnRunner`, `GoldenTasks` and `EvalSupport`. They are published as a test-jar for the provider's live evals.
- Tags:
- `golden` (`GoldenEvalTest`) and `redteam` (`RedTeamTest`) run in every build. Any failure fails the build.
- `live` is excluded unless you pass `-Dtest.excludedGroups= -Dgroups=live` and set the key.
## Code Style
QQQ style:
- 3-space indent, braces on the next line, `return (x);`
- flower-box javadoc on every class and method
- fluent `withX` setters
- wrapper types.
## Release
- CircleCI qqq-orb 0.6.8.
- `develop` publishes SNAPSHOTs.
- `release/*` publishes RCs; the orb bumps `<revision>` in the root POM.
- `v*` tags publish releases.
- Both modules and the parent POM publish. The flatten plugin resolves `${revision}`.
+8 -1
View File
@@ -8,6 +8,13 @@ Phase 0/1 of the QQQ Assistant: read-only Q&A and navigation. Requires QQQ 4.1 w
- the AI tool kernel (`qqq-backend-core` `actions.ai`, QRun-IO/qqq#1078)
- the developer-panel contract (QRun-IO/qqq#1076).
### Changed (0.1.0-RC.2)
- **Snake_case names** (QRun-IO naming rule, 2026-10-12). Metadata only; the physical tables were already `assistant_*`:
- tables `assistantConversation` ... `assistantUsageDaily` are now `assistant_conversation`, `assistant_message`, `assistant_turn`, `assistant_turn_event`, `assistant_tool_call`, `assistant_feedback`, `assistant_policy` and `assistant_usage_daily`; the conversation possible-value source follows the table name
- permissions are unchanged: `assistant.hasAccess` keeps QQQ's standard `hasAccess` suffix, and `assistant.admin` stays as is
- security key type `assistantUserId` is now `assistant_user_id`, with all-access key `assistant_user_id_all_access`
- the old names remain as `@Deprecated` `LEGACY_*` constants until 0.2.0. See [UPGRADE.md](UPGRADE.md) for the stored references to update (permissions, saved views and reports, `audit_table` names).
### Added
- **`qbit-assistant`**
- `AssistantQBitProducer` and `AssistantQBitConfig`:
@@ -17,7 +24,7 @@ Phase 0/1 of the QQQ Assistant: read-only Q&A and navigation. Requires QQQ 4.1 w
- budgets, rate limits
- retention of 90 / 30 / 365 days
- owner key type and pricing.
- Standard, visible tables: `assistantConversation`, `assistantMessage`, `assistantTurn`, `assistantTurnEvent`, `assistantToolCall`, `assistantFeedback`, `assistantPolicy` and `assistantUsageDaily`. User-owned tables are locked to their owner (`assistantUserId`), and admins read everything through `assistantUserIdAllAccess`.
- Standard, visible tables: `assistant_conversation`, `assistant_message`, `assistant_turn`, `assistant_turn_event`, `assistant_tool_call`, `assistant_feedback`, `assistant_policy` and `assistant_usage_daily`. User-owned tables are locked to their owner (`assistant_user_id`), and admins read everything through `assistant_user_id_all_access`.
- The Assistant Admin app, gated by `assistant.admin`.
- The `assistantRetention` process.
- `/qqq/v1/assistant` routes: config probe, conversations CRUD, turns as Server-Sent Events, replay through `/events?after=`, cancel, and feedback. The panel requires `assistant.hasAccess`. The kill switch and budgets are checked when a turn starts, and the kill switch again before every step.
+1 -1
View File
@@ -32,7 +32,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- `qbit-assistant` (`com.kingsrook.qbits.assistant`):
- `AssistantQBitProducer`, `AssistantQBitConfig` (with `AssistantModelRouting` and `AssistantBudgets`), `AssistantRuntime`, `AssistantSecurity`.
- `metadata/`: tables, owner key type, the Assistant Admin app, the retention process, and the `assistant.admin` custom permission checker.
- `model/`: entities for the `assistant*` tables.
- `model/`: entities for the `assistant_*` tables.
- `persistence/AssistantStore`: the **only** code that calls QQQ data actions, and only on the QBit's own tables, under the user's session.
- `engine/`: `TurnEngine` (the loop), `ContextBuilder` (cache-prefix order), `SystemPrompt`, `PolicyService` (policy row, budgets), `RateLimiter`, the `TurnEvent` / `TurnEventLog` stream, and `MessageParts`.
- `api/`: `AssistantApi` (transport-free) and `AssistantRouteProvider` (Javalin, SSE).
+1 -1
View File
@@ -176,7 +176,7 @@
END OF TERMS AND CONDITIONS
Copyright 2022-2025 QRunIO, LLC
Copyright 2024 Kingsrook, LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+17 -17
View File
@@ -35,7 +35,7 @@ The data access itself is done by the AI tool kernel in QQQ core (`com.kingsrook
```java
AssistantQBitConfig config = new AssistantQBitConfig()
.withBackendName("rdbms") // where the assistant* tables live
.withBackendName("rdbms") // where the assistant_* tables live
.withTableMetaDataCustomizer(snakeCaseCustomizer) // optional: backend names for your schema
.withModelProvider(AnthropicModelProvider.fromEnvironment()); // key from ANTHROPIC_API_KEY
@@ -44,7 +44,7 @@ new AssistantQBitProducer().withQBitConfig(config).produce(qInstance);
`produce` does the following:
- Registers the QBit.
- Adds the tables, the `assistantUserId` security key type, the `assistantRetention` process and the **Assistant Admin** app.
- Adds the tables, the `assistant_user_id` security key type, the `assistantRetention` process and the **Assistant Admin** app.
- Builds the runtime.
- Registers the `/qqq/v1/assistant/*` routes when Javalin is on the classpath.
- Registers the record Developer-screen panel when QQQ core has the developer-panel contract.
@@ -64,7 +64,7 @@ The provider's `toString` never contains the key. Its errors carry only the HTTP
| Setting | Default | Meaning |
|---|---|---|
| `enabled` | `true` | Per-app switch. The environment variable `QQQ_ASSISTANT_ENABLED=false` also turns it off. |
| `backendName` | (required) | Backend of the `assistant*` tables. |
| `backendName` | (required) | Backend of the `assistant_*` tables. |
| `providerName` | `anthropic` | Which registered provider to use. |
| `models` | Sonnet 5.5 at effort `medium` | `mainModel`, `mainEffort`, `mainMaxOutputTokens` (16000). The escalation (Opus 5.5, `high`) and summary (Haiku 5.5) slots are reserved for phase 2. Model ids are the exact strings `claude-sonnet-5-5`, `claude-opus-5-5` and `claude-haiku-5-5`. |
| `defaultExposure` | `VALUES` | The kernel's default exposure. Use `METADATA_ONLY` for high-sensitivity apps. |
@@ -76,14 +76,14 @@ The provider's `toString` never contains the key. Its errors carry only the HTTP
| `maxTurnsPerUserPerMinute` / `maxConcurrentTurnsPerUser` | 10 / 1 | Rate limits. |
| `conversationRetentionDays` / `toolResultRetentionDays` / `approvalRetentionDays` | 90 / 30 / 365 | Used by `assistantRetention`. |
| `retentionSchedulerName` | none | Runs `assistantRetention` daily on that scheduler. |
| `ownerSecurityKeyType` | `assistantUserId` | Key type of the owner record locks. |
| `ownerSecurityKeyType` | `assistant_user_id` | Key type of the owner record locks. |
| `pricing` | Anthropic list prices, 2026-10 | USD per million tokens per model, used for cost and budgets. |
| `allowedWidgetNames` | none | Dashboard widgets `render_widget` may render. The kernel denies every widget not on this list. This config list is the **only** way to allow a widget: QQQ widget meta-data has no supplemental hook, so there is no per-widget "assistant-safe" flag. Allow only widgets that render the same data for every user who can see them. |
| `hiddenWidgetNames` | none | Widgets that are never rendered, even when allowed. |
| `customRedactionPatterns` | none | Extra regular expressions the kernel redacts from tool results. |
| `killSwitchPollMillis` | 2000 | How often a running turn re-checks the kill switch while it waits on the model. |
At runtime, an admin can override the kill switch, the main model and effort, and the budgets in the single **`assistantPolicy`** row.
At runtime, an admin can override the kill switch, the main model and effort, and the budgets in the single **`assistant_policy`** row.
A running turn notices the kill switch in two ways:
- It re-reads the switch before every model call and tool step.
@@ -104,9 +104,9 @@ When the QBit is enabled, it registers the supplemental instance meta-data `assi
### Record locks on the assistant's own tables
Conversation data belongs to its user. Every user-owned table has a record lock on `userId` with key type `assistantUserId`:
Conversation data belongs to its user. Every user-owned table has a record lock on `userId` with key type `assistant_user_id`:
- The panel routes add the user's own id to their session, so users only ever see their own rows.
- Admins see everyone's rows in the Assistant Admin app through the key type's all-access key, `assistantUserIdAllAccess`. Grant that key to `assistant.admin` holders from your authentication customizer:
- Admins see everyone's rows in the Assistant Admin app through the key type's all-access key, `assistant_user_id_all_access`. Grant that key to `assistant.admin` holders from your authentication customizer:
```java
// in your QAuthenticationModuleCustomizerInterface.customizeSession(...)
@@ -128,7 +128,7 @@ The kernel applies these rules to tool results:
- Exposure `METADATA_ONLY` and `MASKED` fields are filtered or masked.
- Rows are capped at 50 and cells at 500 characters.
- Secrets such as keys, JWTs and card numbers are redacted.
- The `assistant*` tables are never visible to the model.
- The `assistant_*` tables are never visible to the model.
Old tool results are cleared from the context of later turns; only one-line summaries remain.
@@ -178,16 +178,16 @@ All of these are standard, visible QQQ tables in the Assistant Admin app:
| Table | Holds |
|---|---|
| `assistantConversation` | Conversations. |
| `assistantMessage` | Messages (`partsJson`). |
| `assistantTurn` | Turn accounting. |
| `assistantTurnEvent` | Stream events, for replay. |
| `assistantToolCall` | The audit of every tool call. |
| `assistantFeedback` | Feedback on answers. |
| `assistantPolicy` | The kill switch and overrides. |
| `assistantUsageDaily` | Usage rollups: per user per day, plus an instance row whose `userId` is null. |
| `assistant_conversation` | Conversations. |
| `assistant_message` | Messages (`partsJson`). |
| `assistant_turn` | Turn accounting. |
| `assistant_turn_event` | Stream events, for replay. |
| `assistant_tool_call` | The audit of every tool call. |
| `assistant_feedback` | Feedback on answers. |
| `assistant_policy` | The kill switch and overrides. |
| `assistant_usage_daily` | Usage rollups: per user per day, plus an instance row whose `userId` is null. |
Field names are camelCase. Map them to your schema with a table customizer. `docs/schema-postgres.sql` has a reference DDL.
Table names follow the QRun-IO naming rule: lowercase snake_case with the `assistant_` prefix, the same as the physical tables in `docs/schema-postgres.sql` (the reference DDL). Field names are camelCase. Map them to your columns with a table customizer. Upgrading from 0.1.0-RC.1, which used camelCase table names and the `assistantUserId` key type: see [UPGRADE.md](UPGRADE.md).
## Build
+93
View File
@@ -0,0 +1,93 @@
# Upgrading qbit-assistant
## 0.1.0-RC.1 to 0.1.0-RC.2: snake_case names
RC.2 applies the QRun-IO naming rule (owner decision, 2026-10-12): *"table names should always be lowercase and use _, and qbits should always be prefixed with their name"*. The QQQ table names, the owner security key type and the possible-value source change. Permissions do not. **This is a metadata-only change**: no table, column or index in your database changes, and no Liquibase or DDL step is needed.
### What changed
| Kind | RC.1 name | RC.2 name |
|---|---|---|
| Table | `assistantConversation` | `assistant_conversation` |
| Table | `assistantMessage` | `assistant_message` |
| Table | `assistantTurn` | `assistant_turn` |
| Table | `assistantTurnEvent` | `assistant_turn_event` |
| Table | `assistantToolCall` | `assistant_tool_call` |
| Table | `assistantFeedback` | `assistant_feedback` |
| Table | `assistantPolicy` | `assistant_policy` |
| Table | `assistantUsageDaily` | `assistant_usage_daily` |
| Possible-value source (conversations) | `assistantConversation` | `assistant_conversation` |
| Permissions | `assistant.hasAccess`, `assistant.admin` | unchanged (QQQ's standard `<base>.hasAccess` suffix; the `assistant` base already follows the rule) |
| Security key type | `assistantUserId` | `assistant_user_id` |
| All-access key | `assistantUserIdAllAccess` | `assistant_user_id_all_access` |
Unchanged: Java class names, field names (`userId`, `conversationId`, ...), the `assistantAdmin` app, the `assistantRetention` process, the `/qqq/v1/assistant` routes and the `assistant` supplemental instance meta-data.
The old names stay available as `@Deprecated` constants for one release, so migration code can refer to them: `<Entity>.LEGACY_TABLE_NAME`, `AssistantQBitConfig.LEGACY_DEFAULT_OWNER_KEY_TYPE` and `AssistantMetaDataBuilder.LEGACY_ALL_ACCESS_SUFFIX`. They will be removed in 0.2.0.
### Physical tables
`docs/schema-postgres.sql` already creates `assistant_*` tables. If your RDBMS backend mapped the old QQQ names to those tables (for example with `inferBackendName`), the new QQQ names now equal the physical names and nothing else is needed; the mapping becomes a no-op. If you created tables literally named `assistantConversation` and so on, either rename them:
```sql
ALTER TABLE "assistantConversation" RENAME TO assistant_conversation;
ALTER TABLE "assistantMessage" RENAME TO assistant_message;
ALTER TABLE "assistantTurn" RENAME TO assistant_turn;
ALTER TABLE "assistantTurnEvent" RENAME TO assistant_turn_event;
ALTER TABLE "assistantToolCall" RENAME TO assistant_tool_call;
ALTER TABLE "assistantFeedback" RENAME TO assistant_feedback;
ALTER TABLE "assistantPolicy" RENAME TO assistant_policy;
ALTER TABLE "assistantUsageDaily" RENAME TO assistant_usage_daily;
```
or keep them and set `RDBMSTableBackendDetails.tableName` to the old name in your table customizer.
### Stored references to update
QQQ stores table names, permission names and key names outside this qbit. Update them in the same deployment. The examples are for Postgres with QQQ's default snake_case backend names; adjust the table and column names to your schema.
1. **Security keys.** Permission grants (`assistant.hasAccess`, `assistant.admin`) need no change. If your authentication customizer grants the all-access key itself rather than calling `AssistantSecurity.applySessionKeys`, grant `assistant_user_id_all_access`. If you set `ownerSecurityKeyType` explicitly, the all-access key is now `<keyType>_all_access`.
2. **Saved views and saved reports** on the assistant tables:
```sql
UPDATE saved_view SET table_name = CASE table_name
WHEN 'assistantConversation' THEN 'assistant_conversation'
WHEN 'assistantMessage' THEN 'assistant_message'
WHEN 'assistantTurn' THEN 'assistant_turn'
WHEN 'assistantTurnEvent' THEN 'assistant_turn_event'
WHEN 'assistantToolCall' THEN 'assistant_tool_call'
WHEN 'assistantFeedback' THEN 'assistant_feedback'
WHEN 'assistantPolicy' THEN 'assistant_policy'
WHEN 'assistantUsageDaily' THEN 'assistant_usage_daily'
ELSE table_name END
WHERE table_name IN ('assistantConversation', 'assistantMessage', 'assistantTurn', 'assistantTurnEvent',
'assistantToolCall', 'assistantFeedback', 'assistantPolicy', 'assistantUsageDaily');
```
Run the same statement against `saved_report` (and `quick_saved_view` / `shared_saved_view` only if they store a table name in your schema).
3. **Audit table names.** QQQ audits reference tables through the `audit_table` lookup. Rename the rows so old and new audits stay under one table:
```sql
UPDATE audit_table SET name = CASE name
WHEN 'assistantConversation' THEN 'assistant_conversation'
WHEN 'assistantMessage' THEN 'assistant_message'
WHEN 'assistantTurn' THEN 'assistant_turn'
WHEN 'assistantTurnEvent' THEN 'assistant_turn_event'
WHEN 'assistantToolCall' THEN 'assistant_tool_call'
WHEN 'assistantFeedback' THEN 'assistant_feedback'
WHEN 'assistantPolicy' THEN 'assistant_policy'
WHEN 'assistantUsageDaily' THEN 'assistant_usage_daily'
ELSE name END
WHERE name IN ('assistantConversation', 'assistantMessage', 'assistantTurn', 'assistantTurnEvent',
'assistantToolCall', 'assistantFeedback', 'assistantPolicy', 'assistantUsageDaily');
```
If a new-name row was already created (the app ran RC.2 before this update), repoint `audit.audit_table_id` from the old row to the new one first, then delete the old row.
4. **Anything else that stores a table name**: frontend links or bookmarks to `/app/.../assistantConversation`, Quick Search index rows (`quick_search_index.table_name`; reindex instead of editing), ESB topics or reports you configured by table name.
### Order
Deploy RC.2 and run the statements above in the same maintenance window. Until the update runs, saved views and reports on the assistant tables do not open, and admins who get the all-access key from their own customizer only see their own rows.
Vendored Regular → Executable
View File
File mode changed.
+1 -1
View File
@@ -17,7 +17,7 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<!-- Multi-module build. The qqq-orb sets <revision> here; the modules -->
<!-- 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>
@@ -38,7 +38,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Configuration of the Assistant QBit (spec 2.3).
**
** Required: backendName (where the assistant* tables live) and at least one
** Required: backendName (where the assistant_* tables live) and at least one
** model provider (withModelProvider), named by providerName. API keys never
** go here: a provider reads its key from the environment or from a value the
** host passes in from its secret store.
@@ -50,11 +50,17 @@ public class AssistantQBitConfig implements QBitConfig
{
public static final String ACCESS_PERMISSION = "assistant.hasAccess";
public static final String ADMIN_PERMISSION = "assistant.admin";
public static final String DEFAULT_OWNER_KEY_TYPE = "assistantUserId";
public static final String DEFAULT_OWNER_KEY_TYPE = "assistant_user_id";
public static final String ENABLED_ENV_VAR = "QQQ_ASSISTANT_ENABLED";
public static final String DEFAULT_SHORTCUT = "mod+j";
public static final String DEFAULT_PROVIDER_NAME = "anthropic";
/*******************************************************************************
** The owner key type used before 0.1.0-RC.2 (camelCase). See UPGRADE.md.
*******************************************************************************/
@Deprecated(since = "0.1.0-RC.2")
public static final String LEGACY_DEFAULT_OWNER_KEY_TYPE = "assistantUserId";
private Boolean enabled = true;
private String backendName;
private String providerName = DEFAULT_PROVIDER_NAME;
@@ -24,7 +24,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Session helpers for the owner record locks on the assistant* tables.
** Session helpers for the owner record locks on the assistant_* tables.
**
** The assistant never creates sessions. These methods only add security key
** values to the user's OWN session:
@@ -59,22 +59,30 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
**
** Security:
** - Every user-owned table carries userId and a record security lock on it
** (key type ownerSecurityKeyType, default assistantUserId). The panel
** (key type ownerSecurityKeyType, default assistant_user_id). The panel
** routes put the user's own id in their session, so reads and writes of
** these tables through QQQ actions only ever touch the user's own rows.
** - The key type's all-access key (assistantUserIdAllAccess) lets admins see
** - The key type's all-access key (assistant_user_id_all_access) lets admins see
** everyone's rows; hosts grant it to users holding assistant.admin with
** AssistantSecurity.applySessionKeys in their authentication customizer.
** - Tables, app and process need assistant.admin in the generic UI / API.
** - assistantUsageDaily rows with a null userId are the INSTANCE rollup and
** - assistant_usage_daily rows with a null userId are the INSTANCE rollup and
** are readable by any session (the lock allows null).
** - The assistant's own tool kernel never exposes assistant* tables to the
** - The assistant's own tool kernel never exposes assistant_* tables to the
** model (exposure NONE by name).
*******************************************************************************/
public class AssistantMetaDataBuilder
{
public static final String APP_NAME = "assistantAdmin";
public static final String RETENTION_PROCESS_NAME = "assistantRetention";
public static final String ALL_ACCESS_SUFFIX = "_all_access";
/*******************************************************************************
** The all-access key suffix before 0.1.0-RC.2 (assistant_user_id_all_access).
** See UPGRADE.md.
*******************************************************************************/
@Deprecated(since = "0.1.0-RC.2")
public static final String LEGACY_ALL_ACCESS_SUFFIX = "AllAccess";
private final AssistantQBitConfig config;
@@ -95,7 +103,7 @@ public class AssistantMetaDataBuilder
*******************************************************************************/
public static String allAccessKeyName(String ownerKeyType)
{
return (ownerKeyType + "AllAccess");
return (ownerKeyType + ALL_ACCESS_SUFFIX);
}
@@ -30,7 +30,13 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
*******************************************************************************/
public class AssistantConversation extends QRecordEntity
{
public static final String TABLE_NAME = "assistantConversation";
public static final String TABLE_NAME = "assistant_conversation";
/*******************************************************************************
** The table name before 0.1.0-RC.2 (camelCase). See UPGRADE.md.
*******************************************************************************/
@Deprecated(since = "0.1.0-RC.2")
public static final String LEGACY_TABLE_NAME = "assistantConversation";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@@ -30,7 +30,13 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
*******************************************************************************/
public class AssistantFeedback extends QRecordEntity
{
public static final String TABLE_NAME = "assistantFeedback";
public static final String TABLE_NAME = "assistant_feedback";
/*******************************************************************************
** The table name before 0.1.0-RC.2 (camelCase). See UPGRADE.md.
*******************************************************************************/
@Deprecated(since = "0.1.0-RC.2")
public static final String LEGACY_TABLE_NAME = "assistantFeedback";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@@ -31,7 +31,13 @@ import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
*******************************************************************************/
public class AssistantMessage extends QRecordEntity
{
public static final String TABLE_NAME = "assistantMessage";
public static final String TABLE_NAME = "assistant_message";
/*******************************************************************************
** The table name before 0.1.0-RC.2 (camelCase). See UPGRADE.md.
*******************************************************************************/
@Deprecated(since = "0.1.0-RC.2")
public static final String LEGACY_TABLE_NAME = "assistantMessage";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@@ -32,7 +32,13 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
*******************************************************************************/
public class AssistantPolicy extends QRecordEntity
{
public static final String TABLE_NAME = "assistantPolicy";
public static final String TABLE_NAME = "assistant_policy";
/*******************************************************************************
** The table name before 0.1.0-RC.2 (camelCase). See UPGRADE.md.
*******************************************************************************/
@Deprecated(since = "0.1.0-RC.2")
public static final String LEGACY_TABLE_NAME = "assistantPolicy";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@@ -32,7 +32,13 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
*******************************************************************************/
public class AssistantToolCall extends QRecordEntity
{
public static final String TABLE_NAME = "assistantToolCall";
public static final String TABLE_NAME = "assistant_tool_call";
/*******************************************************************************
** The table name before 0.1.0-RC.2 (camelCase). See UPGRADE.md.
*******************************************************************************/
@Deprecated(since = "0.1.0-RC.2")
public static final String LEGACY_TABLE_NAME = "assistantToolCall";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@@ -30,7 +30,13 @@ import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
*******************************************************************************/
public class AssistantTurn extends QRecordEntity
{
public static final String TABLE_NAME = "assistantTurn";
public static final String TABLE_NAME = "assistant_turn";
/*******************************************************************************
** The table name before 0.1.0-RC.2 (camelCase). See UPGRADE.md.
*******************************************************************************/
@Deprecated(since = "0.1.0-RC.2")
public static final String LEGACY_TABLE_NAME = "assistantTurn";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@@ -30,7 +30,13 @@ import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
*******************************************************************************/
public class AssistantTurnEvent extends QRecordEntity
{
public static final String TABLE_NAME = "assistantTurnEvent";
public static final String TABLE_NAME = "assistant_turn_event";
/*******************************************************************************
** The table name before 0.1.0-RC.2 (camelCase). See UPGRADE.md.
*******************************************************************************/
@Deprecated(since = "0.1.0-RC.2")
public static final String LEGACY_TABLE_NAME = "assistantTurnEvent";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@@ -31,7 +31,13 @@ import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
*******************************************************************************/
public class AssistantUsageDaily extends QRecordEntity
{
public static final String TABLE_NAME = "assistantUsageDaily";
public static final String TABLE_NAME = "assistant_usage_daily";
/*******************************************************************************
** The table name before 0.1.0-RC.2 (camelCase). See UPGRADE.md.
*******************************************************************************/
@Deprecated(since = "0.1.0-RC.2")
public static final String LEGACY_TABLE_NAME = "assistantUsageDaily";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@@ -54,7 +54,7 @@ import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
** Reads and writes the QBit's own assistant* tables through QQQ actions,
** Reads and writes the QBit's own assistant_* tables through QQQ actions,
** under whatever session is in QContext - always the requesting user's
** session (the record locks on userId then confine every call to the user's
** own rows). Queries also filter on userId explicitly, so an admin's
@@ -44,7 +44,7 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
** - no QSystemUserSession, no QSession construction (the assistant only
** ever uses the requesting user's own session);
** - QQQ data actions only from the persistence package (the QBit's own
** assistant* tables); everything else reaches data through the
** assistant_* tables); everything else reaches data through the
** AiToolKernel.
*******************************************************************************/
class ArchitectureTest
@@ -62,7 +62,7 @@ class AssistantQBitProducerTest extends BaseAssistantTest
@Test
void testProducedMetaData()
{
List<String> owned = List.of("assistantConversation", "assistantMessage", "assistantTurn", "assistantTurnEvent", "assistantToolCall", "assistantFeedback", "assistantUsageDaily");
List<String> owned = List.of("assistant_conversation", "assistant_message", "assistant_turn", "assistant_turn_event", "assistant_tool_call", "assistant_feedback", "assistant_usage_daily");
for(String name : owned)
{
QTableMetaData table = qInstance.getTable(name);
@@ -79,7 +79,7 @@ class AssistantQBitProducerTest extends BaseAssistantTest
assertThat(qInstance.getTable(AssistantPolicy.TABLE_NAME).getRecordSecurityLocks()).isNullOrEmpty();
QSecurityKeyType keyType = qInstance.getSecurityKeyType(AssistantQBitConfig.DEFAULT_OWNER_KEY_TYPE);
assertThat(keyType.getAllAccessKeyName()).isEqualTo("assistantUserIdAllAccess");
assertThat(keyType.getAllAccessKeyName()).isEqualTo("assistant_user_id_all_access");
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getChildren()).hasSize(9);
assertThat(qInstance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME)).isNotNull();
@@ -159,12 +159,12 @@ class AssistantQBitProducerTest extends BaseAssistantTest
AssistantSecurity.applySessionKeys(editor, config);
AssistantSecurity.applySessionKeys(editor, config);
assertThat(editor.getSecurityKeyValues(AssistantQBitConfig.DEFAULT_OWNER_KEY_TYPE)).containsExactly(AssistantTestFixture.EDITOR);
assertThat(editor.getSecurityKeyValues("assistantUserIdAllAccess")).isNullOrEmpty();
assertThat(editor.getSecurityKeyValues("assistant_user_id_all_access")).isNullOrEmpty();
QSession admin = new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("boss"));
admin.withPermission(AssistantQBitConfig.ADMIN_PERMISSION);
AssistantSecurity.applySessionKeys(admin, config);
assertThat(admin.getSecurityKeyValues("assistantUserIdAllAccess")).containsExactly(true);
assertThat(admin.getSecurityKeyValues("assistant_user_id_all_access")).containsExactly(true);
AssistantSecurity.applySessionKeys(null, config);
assertThat(AssistantSecurity.userId(new QSession())).isNull();
@@ -0,0 +1,141 @@
/*
* 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;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Owner naming rule (2026-10-12): table names are lowercase snake_case and
** prefixed with the qbit's name (assistant_). Pins the new names and the
** deprecated pre-RC.2 names that UPGRADE.md tells hosts to migrate from.
*******************************************************************************/
@SuppressWarnings("deprecation")
class NamingTest extends BaseAssistantTest
{
private static final String SNAKE_WITH_PREFIX = "^assistant_[a-z0-9]+(_[a-z0-9]+)*$";
/*******************************************************************************
**
*******************************************************************************/
private static Map<String, String> tableNames()
{
Map<String, String> names = new LinkedHashMap<>();
names.put(AssistantConversation.TABLE_NAME, AssistantConversation.LEGACY_TABLE_NAME);
names.put(AssistantMessage.TABLE_NAME, AssistantMessage.LEGACY_TABLE_NAME);
names.put(AssistantTurn.TABLE_NAME, AssistantTurn.LEGACY_TABLE_NAME);
names.put(AssistantTurnEvent.TABLE_NAME, AssistantTurnEvent.LEGACY_TABLE_NAME);
names.put(AssistantToolCall.TABLE_NAME, AssistantToolCall.LEGACY_TABLE_NAME);
names.put(AssistantFeedback.TABLE_NAME, AssistantFeedback.LEGACY_TABLE_NAME);
names.put(AssistantPolicy.TABLE_NAME, AssistantPolicy.LEGACY_TABLE_NAME);
names.put(AssistantUsageDaily.TABLE_NAME, AssistantUsageDaily.LEGACY_TABLE_NAME);
return (names);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testTableNamesAreSnakeCaseWithPrefix()
{
assertThat(tableNames()).hasSize(8);
for(Map.Entry<String, String> entry : tableNames().entrySet())
{
String name = entry.getKey();
assertThat(name).matches(SNAKE_WITH_PREFIX);
QTableMetaData table = qInstance.getTable(name);
assertThat(table).as(name).isNotNull();
/////////////////////////////////////////////////////////////
// the old camelCase names are no longer registered at all //
/////////////////////////////////////////////////////////////
assertThat(qInstance.getTable(entry.getValue())).as(entry.getValue()).isNull();
}
////////////////////////////////////////////////////////////////
// every produced table follows the rule (none left behind) //
////////////////////////////////////////////////////////////////
for(QTableMetaData table : qInstance.getTables().values())
{
if(table.getName().toLowerCase().startsWith("assistant"))
{
assertThat(table.getName()).matches(SNAKE_WITH_PREFIX);
}
}
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testLegacyNames()
{
assertThat(tableNames()).containsEntry("assistant_conversation", "assistantConversation")
.containsEntry("assistant_message", "assistantMessage")
.containsEntry("assistant_turn", "assistantTurn")
.containsEntry("assistant_turn_event", "assistantTurnEvent")
.containsEntry("assistant_tool_call", "assistantToolCall")
.containsEntry("assistant_feedback", "assistantFeedback")
.containsEntry("assistant_policy", "assistantPolicy")
.containsEntry("assistant_usage_daily", "assistantUsageDaily");
///////////////////////////////////////////////////////////////////////
// permissions keep QQQ's standard <base>.hasAccess suffix (unchanged) //
///////////////////////////////////////////////////////////////////////
assertThat(AssistantQBitConfig.ACCESS_PERMISSION).isEqualTo("assistant.hasAccess");
assertThat(AssistantQBitConfig.ADMIN_PERMISSION).isEqualTo("assistant.admin");
assertThat(AssistantQBitConfig.DEFAULT_OWNER_KEY_TYPE).isEqualTo("assistant_user_id");
assertThat(AssistantQBitConfig.LEGACY_DEFAULT_OWNER_KEY_TYPE).isEqualTo("assistantUserId");
assertThat(AssistantMetaDataBuilder.allAccessKeyName(AssistantQBitConfig.DEFAULT_OWNER_KEY_TYPE)).isEqualTo("assistant_user_id_all_access");
assertThat(AssistantQBitConfig.LEGACY_DEFAULT_OWNER_KEY_TYPE + AssistantMetaDataBuilder.LEGACY_ALL_ACCESS_SUFFIX).isEqualTo("assistantUserIdAllAccess");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testPossibleValueSourceUsesTableName()
{
assertThat(qInstance.getPossibleValueSource("assistant_conversation")).isNotNull();
assertThat(qInstance.getPossibleValueSource("assistantConversation")).isNull();
assertThat(qInstance.getTable(AssistantMessage.TABLE_NAME).getField("conversationId").getPossibleValueSourceName()).isEqualTo("assistant_conversation");
}
}
@@ -26,6 +26,10 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.api.AssistantApi;
@@ -46,6 +50,7 @@ import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ADMIN;
@@ -158,31 +163,43 @@ class RobustnessTest extends BaseAssistantTest
@Test
void testConcurrentUsage() throws Exception
{
LocalDate today = LocalDate.now(ZoneOffset.UTC);
List<Thread> threads = new ArrayList<>();
for(int i = 0; i < 20; i++)
LocalDate today = LocalDate.now(ZoneOffset.UTC);
int writers = 20;
CountDownLatch ready = new CountDownLatch(writers);
CountDownLatch go = new CountDownLatch(1);
List<Future<?>> results = new ArrayList<>();
try(ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor())
{
String user = i % 2 == 0 ? EDITOR : AssistantTestFixture.OTHER_EDITOR;
threads.add(Thread.ofVirtual().start(() ->
for(int i = 0; i < writers; i++)
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config));
try
String user = i % 2 == 0 ? EDITOR : AssistantTestFixture.OTHER_EDITOR;
results.add(executor.submit(() ->
{
new AssistantStore().addUsage(user, today, new ModelUsage().withInputTokens(10L).withOutputTokens(1L), 7, true);
}
catch(QException e)
{
throw (new RuntimeException(e));
}
finally
{
QContext.clear();
}
}));
}
for(Thread thread : threads)
{
thread.join();
QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config));
try
{
ready.countDown();
go.await();
new AtomicCallsStore().addUsage(user, today, new ModelUsage().withInputTokens(10L).withOutputTokens(1L), 7, true);
}
finally
{
QContext.clear();
}
return (null);
}));
}
///////////////////////////////////////////////////////////////////
// release every writer at once, then surface any writer failure //
// (a worker exception must fail the test, not vanish) //
///////////////////////////////////////////////////////////////////
ready.await();
go.countDown();
for(Future<?> result : results)
{
result.get();
}
}
QContext.init(qInstance, AssistantTestFixture.sessionFor(ADMIN));
@@ -205,6 +222,65 @@ class RobustnessTest extends BaseAssistantTest
/*******************************************************************************
** AssistantStore whose individual backend calls are atomic, as they are on a
** real database. QQQ's in-memory backend is not safe for concurrent callers:
** MemoryRecordStore.query iterates a table's synchronized map without holding
** its lock, so inserting a different usage row (another key, so another
** AssistantStore usage lock) can throw ConcurrentModificationException. Each
** call is made atomic here; the read-modify-write across calls is still only
** protected by AssistantStore's per-key usage locks, which is what the test
** checks.
*******************************************************************************/
private static class AtomicCallsStore extends AssistantStore
{
private static final Object BACKEND_LOCK = new Object();
/*******************************************************************************
**
*******************************************************************************/
@Override
public <T extends QRecordEntity> List<T> query(String tableName, QQueryFilter filter, Class<T> entityClass) throws QException
{
synchronized(BACKEND_LOCK)
{
return (super.query(tableName, filter, entityClass));
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public <T extends QRecordEntity> T insert(T entity) throws QException
{
synchronized(BACKEND_LOCK)
{
return (super.insert(entity));
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void update(QRecordEntity entity) throws QException
{
synchronized(BACKEND_LOCK)
{
super.update(entity);
}
}
}
/*******************************************************************************
** Usage of a failed model call (partial) is still counted.
*******************************************************************************/
@@ -275,9 +275,9 @@ class RedTeamTest extends BaseAssistantTest
calls.add(new Object[] { "describe_table", map("table", "blog_post") });
calls.add(new Object[] { "search_records", map("term", "Proxmox") });
calls.add(new Object[] { "count_records", map("table", "blog_post", "filter", filter("AND", criterion("internalNotes", "EQUALS", HIDDEN_SENTINEL))) });
calls.add(new Object[] { "query_records", map("table", "assistantConversation", "filter", filter("AND")) });
calls.add(new Object[] { "query_records", map("table", "assistantToolCall", "filter", filter("AND")) });
calls.add(new Object[] { "describe_table", map("table", "assistantMessage") });
calls.add(new Object[] { "query_records", map("table", "assistant_conversation", "filter", filter("AND")) });
calls.add(new Object[] { "query_records", map("table", "assistant_tool_call", "filter", filter("AND")) });
calls.add(new Object[] { "describe_table", map("table", "assistant_message") });
calls.add(new Object[] { "get_record_history", map("table", "blog_post", "id", "1") });
provider.thenToolUses(null, calls).thenText("done");
@@ -295,7 +295,7 @@ class RedTeamTest extends BaseAssistantTest
////////////////////////////////////////////////////////////////////
for(ModelRequest request : provider.getRequests())
{
assertThat(JsonUtils.toJson(request.getSystem())).doesNotContain("assistantConversation").doesNotContain("assistantToolCall").doesNotContain("assistantMessage");
assertThat(JsonUtils.toJson(request.getSystem())).doesNotContain("assistant_conversation").doesNotContain("assistant_tool_call").doesNotContain("assistant_message");
for(ModelMessage message : request.getMessages())
{
for(ModelPart part : message.getParts())
@@ -308,9 +308,9 @@ class RedTeamTest extends BaseAssistantTest
}
}
List<ModelPart> results = provider.getRequests().get(1).getMessages().get(2).getParts();
assertThat(results.get(6).getIsError()).as("assistantConversation query").isTrue();
assertThat(results.get(7).getIsError()).as("assistantToolCall query").isTrue();
assertThat(results.get(8).getIsError()).as("assistantMessage describe").isTrue();
assertThat(results.get(6).getIsError()).as("assistant_conversation query").isTrue();
assertThat(results.get(7).getIsError()).as("assistant_tool_call query").isTrue();
assertThat(results.get(8).getIsError()).as("assistant_message describe").isTrue();
}