Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 ceeb134c90 refactor(email): use qbit-email's public api package; no G1 allow-list
Moves the optional email integration from qbit-email internals to
com.kingsrook.qbits.email.api (qbit-email 0.1.0-RC.2) with identical
behaviour, and moves to qbit-build-parent 2.1.1, whose default G1
allow-lists include qbit-email's API (QRun-IO/qbit-bom#12), so the
per-repo allow-lists are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:20:42 -05:00
james.maes a15d66ecc7 Merge pull request #40 from QRun-IO/feature/x1-y1-channel-wall
feat: the hard wall between the assistant and its configuration, and audit attribution (X1/Y1)
2026-10-14 09:02:36 -05:00
james.maes 886ee06cc4 Merge remote-tracking branch 'origin/develop' into feature/record-history-opt-in
# Conflicts:
#	CHANGELOG.md
2026-10-14 08:58:56 -05:00
james.maesandClaude Opus 5.5 1020c3b324 feat: withRecordHistory(true) host opt-in for get_record_history
Default off (owner: hard wall; kof22 does not opt in). When on, calls
core's ClientChannelsMetaData.withAssistantAuditReads() (QRun-IO/qqq#1193:
audits readable, never writable, worm_audit_* still denied) through a
STAND-IN(core=QRun-IO/qqq#1193, removeBy=RC.8) lookup, falling back on
RC.6 to removing exactly the audit* pattern. Other patterns are never
touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:53:10 -05:00
james.maes 73b58d7391 Merge pull request #39 from QRun-IO/feature/email-qbit
feat(email): scheduled-result email adapter for qbit-email
2026-10-14 08:27:25 -05:00
james.maes 64268e63d0 Merge remote-tracking branch 'origin/develop' into feature/x1-y1-channel-wall
# Conflicts:
#	CHANGELOG.md
#	UPGRADE.md
2026-10-14 08:19:55 -05:00
james.maesandClaude Opus 5.5 7081debe5f Merge develop into feature/email-qbit
Keeps develop's changes and re-applies the optional qbit-email 0.1.0-RC.1
delivery on top (adapted to develop's refactors; guardrail allow-lists
for the optional integration where the build parent enforces them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:19:05 -05:00
james.maesandClaude Opus 5.5 b0b80ead3d feat: the hard wall and audit attribution (X1/Y1)
X1:
- Every assistant_* table, process, report and widget (and every object
  the qbit produces) is denied to client channel assistant: the
  assistant_* pattern in ClientChannelsMetaData plus
  QPermissionRules.withDeniedClients on each object.
- Startup fails if any of them is not denied.
- Every UI API call carries assistant/<turnId> (scheduled:
  assistant/scheduled/<taskId>); confirm and explain-access carry the
  owning turn.
- Tests: assistant.admin through the assistant is denied (403, absent
  from list_tables and /metaData) on every configuration object, while the
  same admin through next-ui is allowed; the startup check fails on one
  undenied object; core's security-surface patterns are present.
Y1:
- assistant_tool_call.audit_href: an /app link to the written record's
  audit rows filtered by clientChannel, built with the shared filter-link
  builder.
- Tests: an assistant write audits the human actor with
  clientChannel assistant/<turnId>; the same write from the UI audits
  next-ui. Hosts enable the audit clientChannel field (documented).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:17:35 -05:00
james.maesandClaude Opus 5.5 c40eebd3cd build: qbit-email 0.1.0-RC.1 from Maven Central
Moves from 0.1.0-SNAPSHOT to the first release candidate. Test schemas
gain the RC's email_provider_sender_address table and the message
from_address_id column where they copy the qbit's DDL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:58:48 -05:00
james.maes ff84e35fb3 Merge pull request #19 from QRun-IO/feature/spec-endpoints-core-invoker
feat!: spec'd /qqq/v1 assistant endpoints; tools on core's UiApiInvoker (QQQ RC.6)
2026-10-14 07:57:21 -05:00
james.maes a7a4648dd1 Merge pull request #37 from QRun-IO/feature/provider-api-module
refactor: qbit-assistant-provider-api module for the provider SPI and pricing (#30)
2026-10-14 07:35:11 -05:00
james.maes 467c2199dc Merge remote-tracking branch 'origin/develop' into feature/provider-api-module
# Conflicts:
#	CHANGELOG.md
#	UPGRADE.md
2026-10-14 07:31:36 -05:00
james.maes 61e6f809f1 Merge remote-tracking branch 'origin/develop' into feature/provider-api-module 2026-10-14 07:07:52 -05:00
james.maesandClaude Opus 5.5 45593d2e79 feat(email): scheduled-result email adapter for qbit-email
Phase (d) of QRun-IO/qbit-email docs/DESIGN.md section 11. qbit-email is an
optional dependency (0.1.0-SNAPSHOT until its first RC) used through one
adapter class behind a classpath and instance guard; without it the
existing behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:12:37 -05:00
james.maesandClaude Opus 5.5 de215f8ff9 refactor: qbit-assistant-provider-api module for the provider SPI and pricing (#30)
Architecture review QRun-IO/qqq#1150 F5: qbit-marketing compiled against
assistant internals.

- New artifact qbit-assistant-provider-api with the provider SPI and
  value types (same package names) and engine.ModelPricing; production
  dependency jackson-annotations only. An ArchUnit test keeps it free of
  assistant internals.
- AssistantProviderType's settings validation takes ProviderSettings
  instead of the assistant_provider entity (documented in UPGRADE.md).
- qbit-assistant and the provider modules depend on the API artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:49:45 -05:00
73 changed files with 2414 additions and 113 deletions

No files matched your search

+6 -2
View File
@@ -29,6 +29,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
## Modules
- `qbit-assistant-provider-api`: standalone provider SPI/value types and `engine.ModelPricing` (original package names); only Jackson annotations in production. Provider validation takes unresolved `ProviderSettings`, not an entity.
- `qbit-assistant` (`com.kingsrook.qbits.assistant`):
- `AssistantQBitProducer`, `AssistantQBitConfig` (with `AssistantModelRouting` and `AssistantBudgets`), `AssistantRuntime`, `AssistantSecurity`.
- `metadata/`: tables, owner key type, the Assistant app (home + main tables in the nav), the retention process, the settings customizer (key-reference checks, masked key status), and the `assistant.admin` custom permission checker.
@@ -37,7 +39,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- `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.
- `provider/`: runtime-backed `RegistryModelProvider`, plus `fake/ScriptedModelProvider` for tests, evals and demos. The SPI lives in `qbit-assistant-provider-api`.
- `developer/`: the record Developer-screen panel, on the core developer-panel contract.
- `processes/`: `AssistantRetentionStep` and `TestConnectionProcess` (record action on `assistant_provider`).
- `engine/ProviderService` (active provider from `assistant_provider` rows, key references, prices), `UsageRecorder` (one `assistant_usage` row per model call), `ConnectionTest`.
@@ -46,6 +48,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- `qbit-assistant-provider-bedrock`: Claude through the Anthropic Bedrock backend, AWS default credentials and SigV4; IRSA/web-identity hosts supply STS.
- `qbit-assistant-provider-vertex`: Claude through the Anthropic Vertex backend and Gemini through OpenAI-compatible Chat Completions, with refreshed Google ADC tokens.
- `qbit-assistant-rag-pgvector`: JDBC vector storage; depends on the runtime RAG interfaces (`VectorStore`, `ChunkVector`).
## Invariants (do not break)
- **Sessions:**
@@ -93,4 +97,4 @@ QQQ style:
- `develop` publishes SNAPSHOTs.
- `release/*` publishes RCs; the orb bumps `<revision>` in the root POM.
- `v*` tags publish releases.
- All five modules and the parent POM publish. The flatten plugin resolves `${revision}`.
- All seven modules and the parent POM publish. The flatten plugin resolves `${revision}`.
+28
View File
@@ -1,5 +1,33 @@
# Changelog
## Unreleased - X1/Y1
- Add default-off `withRecordHistory(true)` host opt-in: remove only `audit*` on RC.6, or invoke core #1193 audit-read/write-deny support when available. Preserve user permissions and all other channel denials; kof22 does not opt in.
- Register explicit assistant-channel denials on every assistant table, process,
report and widget, preserving core security patterns; fail runtime startup
when an assistant object is exposed.
- Bump the assistant UI API extension to 1.1.0 for optional owned-turn attribution
on explain-access, and regenerate its OpenAPI contract.
- Preserve turn attribution in knowledge discovery, RAG reads and explain-access
calls; confirm actions retain the original turn and scheduled runs retain the task.
- Build audit links with the presentation filter-link builder, filtering by
record, table and channel, including deletes; widen audit link storage to TEXT.
Document the host audit migration and the QBit column widening.
- Verify the admin denial matrix through the real invoker and Next UI, startup
failures, and confirmed-write human/channel attribution with decoded audit links.
## Unreleased - Email
### Changed
- Switch scheduled-result email delivery to qbit-email 0.1.0-RC.2's published `com.kingsrook.qbits.email.api` package, preserving templates, categories, idempotency keys, linked records and status mapping.
- Add optional qbit-email 0.1.0-RC.1 delivery of a scheduled task's result (`AssistantEmail.sendScheduledResult`): template `assistant_scheduled_result`, link-only secure message for results above PUBLIC using core's `DataClassification`, owner-recipient hook, opt-in and `emailExternalApproved` gates. Scheduled-task wiring remains a separate integration step.
## Unreleased - Provider API extraction (#30)
- Extract provider SPI/value types and `engine.ModelPricing` into `qbit-assistant-provider-api`, preserving package names. Its only production dependency is Jackson annotations.
- Move Anthropic, OpenAI, Bedrock and Vertex main dependencies to the API; retain runtime fixtures in test scope. Pgvector still needs runtime RAG interfaces.
- Provider-specific validation now takes unresolved `ProviderSettings` instead of `AssistantProvider`; add an ArchUnit boundary and standalone coverage gates.
## Unreleased - P-g
- Add admin Scheduled tasks, owner-locked run history, Quartz cron/timezones,
+6 -3
View File
@@ -29,6 +29,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
## Modules
- `qbit-assistant-provider-api`: standalone provider SPI/value types and `engine.ModelPricing` (original package names); only Jackson annotations in production. Provider validation takes unresolved `ProviderSettings`, not an entity.
- `qbit-assistant` (`com.kingsrook.qbits.assistant`):
- `AssistantQBitProducer`, `AssistantQBitConfig` (with `AssistantModelRouting` and `AssistantBudgets`), `AssistantRuntime`, `AssistantSecurity`.
- `metadata/`: tables, owner key type, the Assistant app (home + main tables in the nav), the retention process, the settings customizer (key-reference checks, masked key status), and the `assistant.admin` custom permission checker.
@@ -37,7 +39,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- `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.
- `provider/`: runtime-backed `RegistryModelProvider`, plus `fake/ScriptedModelProvider` for tests, evals and demos. The SPI lives in `qbit-assistant-provider-api`.
- `developer/`: the record Developer-screen panel, on the core developer-panel contract.
- `processes/`: `AssistantRetentionStep` and `TestConnectionProcess` (record action on `assistant_provider`).
- `engine/ProviderService` (active provider from `assistant_provider` rows, key references, prices), `UsageRecorder` (one `assistant_usage` row per model call), `ConnectionTest`.
@@ -46,7 +48,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- `qbit-assistant-provider-bedrock`: Claude through the Anthropic Bedrock backend, AWS default credentials and SigV4; IRSA/web-identity hosts supply STS.
- `qbit-assistant-provider-vertex`: Claude through the Anthropic Vertex backend and Gemini through OpenAI-compatible Chat Completions, with refreshed Google ADC tokens.
- `qbit-assistant-rag-pgvector`: optional host-supplied JDBC vector store, isolated by instance schema; no QQQ RDBMS internals.
- `qbit-assistant-rag-pgvector`: optional host-supplied JDBC vector store, isolated by instance schema; depends on runtime RAG interfaces (`VectorStore`, `ChunkVector`), with no QQQ RDBMS internals.
## Invariants (do not break)
@@ -55,6 +57,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- 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.
- **Channel wall:** every `assistant_*` object (table, process, report and widget) is denied to the `assistant` channel; startup fails otherwise. Requests carry `assistant/<turnId>` or `assistant/scheduled/<taskId>`; administration uses `next-ui`.
- **Data access:**
- The vector index stores no record content: only source/table/key/field/chunk, content hash and embedding. Retrieval re-reads every hit through the UI API as the asking user, then applies the existing get_record exposure/masking path.
- Only `persistence/` calls QueryAction, InsertAction and the other data actions (ArchUnit).
@@ -105,4 +108,4 @@ QQQ style:
- `develop` publishes SNAPSHOTs.
- `release/*` publishes RCs; the orb bumps `<revision>` in the root POM.
- `v*` tags publish releases.
- All six modules and the parent POM publish. The flatten plugin resolves `${revision}`.
- All seven modules and the parent POM publish. The flatten plugin resolves `${revision}`.
+84 -6
View File
@@ -4,13 +4,62 @@ An in-app AI assistant for any QQQ application. The user asks a question in a ch
Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`.
## Modules
| Artifact | What it is |
|---|---|
| `com.kingsrook.qbits:qbit-assistant` | The QBit: conversation tables, turn engine, `/qqq/v1/assistant` routes (JSON + Server-Sent Events), budgets, kill switch, the provider SPI, and a scripted provider for tests and demos. |
| `com.kingsrook.qbits:qbit-assistant-provider-api` | Provider SPI, connection settings, messages, usage and pricing types; only Jackson annotations at runtime. |
| `com.kingsrook.qbits:qbit-assistant` | The QBit: conversation tables, turn engine, `/qqq/v1/assistant` routes (JSON + Server-Sent Events), budgets, kill switch, the provider registry, and a scripted provider for tests and demos. |
| `com.kingsrook.qbits:qbit-assistant-provider-anthropic` | The Claude provider (`com.anthropic:anthropic-java`). Add it to use Claude. The core QBit has no LLM SDK dependency. |
| `com.kingsrook.qbits:qbit-assistant-provider-openai` | OpenAI-compatible chat and embedding providers. |
| `com.kingsrook.qbits:qbit-assistant-provider-bedrock` | Claude through AWS Bedrock. |
| `com.kingsrook.qbits:qbit-assistant-provider-vertex` | Claude and Gemini through Google Vertex AI. |
| `com.kingsrook.qbits:qbit-assistant-rag-pgvector` | JDBC vector store; retains the runtime dependency for `VectorStore` and `ChunkVector`. |
### Implementing a provider outside qbit-assistant
Depend on `com.kingsrook.qbits:qbit-assistant-provider-api` at the same version as
QBit Assistant, and implement `AssistantModelProvider` and optionally
`AssistantProviderType`. No assistant runtime dependency is needed. Existing
`com.kingsrook.qbits.assistant.provider.*` imports and
`com.kingsrook.qbits.assistant.engine.ModelPricing` stay the same.
`AssistantProviderType.validate(ProviderSettings)` receives unresolved settings
(including `apiKeyEnvName`), without a database entity or resolved credentials.
Hosts register providers through `AssistantQBitConfig` in `qbit-assistant`.
Data tools use the QQQ UI API (`/qqq/v1`) with the signed-in user's session. The UI API enforces permissions and record locks; assistant data-access policy and field exposure can only narrow access. Only record-free navigation and help use the core AI tool kernel. Every tool result reaches the model wrapped as untrusted data.
## Host audit attribution (X1/Y1)
Use QQQ **4.1.0-RC.6 or later**. The QBit explicitly denies every assistant
configuration table, process, report and widget to the `assistant` channel;
runtime initialization fails if an assistant object is exposed. The same human
can still administer it through Next UI (`next-ui`). Core's security-surface
deny patterns remain in place.
Enable the audit client field in the host's core audit provider **after adding
the backing column**:
```java
new AuditsMetaDataProvider().withClientChannelField(true)
.defineAll(qInstance, backendName, null);
```
Add nullable `audit.client_channel VARCHAR(100)` using the host's migration and
column-naming conventions. For kof22 this requires a feature-named changeset
under `changesets/features/`; this QBit does not migrate the host's audit table.
The actor remains the signed-in human; `clientChannel` distinguishes
`assistant/<turnId>`, `assistant/scheduled/<taskId>` and `next-ui`.
Apply the X1/Y1 DDL in [UPGRADE.md](UPGRADE.md) to widen the existing
`assistant_tool_call.audit_href` column to TEXT for encoded filters.
Successful record writes store `assistant_tool_call.auditHref` as an `/app/audit`
filter link over `recordId`, `auditTable.name` and `clientChannel`. The human
opens this link with their UI permissions; the assistant cannot read audits by
default. Without the audit channel field, the QBit leaves `auditHref` empty.
Confirmed scheduled actions use their original turn id when the human approves.
## RAG setup
Knowledge retrieval is optional. The vector index stores **no record content**:
@@ -188,6 +237,8 @@ The nav lists the home and the main tables: **Providers**, **Conversations**, **
| `providerTestTimeoutSeconds` | 30 | How long Test connection waits for the model. |
| `homeMaxRows` | 20000 | Rows per table the app home reads for its period; it says when totals are partial. |
| `tablePathPrefix` | `/app` | The front of the links the app home makes to tables and records. |
| `emailExternalApproved` | `false` | Stands in for the EXTERNAL approval rule (not built yet): scheduled-task result email is sent only when `true`. |
| `emailRecipientResolver` | owner id if it is an address | Hook: task owner user id to email address. |
At runtime, an admin can override the kill switch, the main model and effort, and the budgets in the single **`assistant_policy`** row.
@@ -446,6 +497,24 @@ engine/tool golden turns. No cloud credentials or live endpoints are required.
Apache 2.0.
## Email through qbit-email
When the optional [qbit-email](https://github.com/QRun-IO/qbit-email) is on the classpath **and** produced in the instance, a finished scheduled-task run can be emailed to its owner. Without it nothing changes: `AssistantEmail.sendScheduledResult` returns `NOT_AVAILABLE`. The dependency is `optional`; every reference to it is in `AssistantEmailSender`.
This integration uses qbit-email **0.1.0-RC.2** through its published `com.kingsrook.qbits.email.api` package. Scheduled tasks (P-g) are available, but the runner does not yet wire email delivery. Hosts can call `AssistantEmail.sendScheduledResult(qInstance, config, new ScheduledResultNotice(...))` after a run, supplying its owner, classification, absolute run link and explicit opt-in as `emailResult`. Persisting a task-level email opt-in and wiring the runner remain a separate integration step.
Gates, in order (each returns a status and queues nothing):
1. the task opted in (`NOT_REQUESTED`);
2. qbit-email is installed (`NOT_AVAILABLE`);
3. `emailExternalApproved` is `true` (`NOT_APPROVED`). The EXTERNAL approval rule exists only in the spec, so this explicit flag gates it until the rule is built;
4. a recipient resolves (`NO_RECIPIENT`): `emailRecipientResolver` maps the owner's user id to an address; by default an owner id that already is an address is used.
What is sent, one message per recipient, idempotency key `assistant:scheduled:<run id>`, linked record the run:
- result classified PUBLIC (or none): template `assistant_scheduled_result` (NOTIFICATION, Markdown, registered on first send and never overwritten) with the task name, run time, a summary cut to 280 characters and a link to the run. The full model output is never emailed;
- result classified above PUBLIC (core's `DataClassification`): only the link, through qbit-email's `email_secure_message` pattern. No task name, summary or output.
The outbox result maps to `QUEUED`, `DUPLICATE`, `SUPPRESSED` or `REFUSED` (for example a link that is not absolute http(s)). Addresses and bodies are never logged.
## Data classification and HIPAA mode
Classify application tables and fields in core's AI metadata (`AiTableMetaData` / `AiFieldMetaData`, QQQ 4.1.0-RC.6), or in the host configuration (a configured value wins when both are set):
@@ -611,10 +680,19 @@ channel-denied, including their effective permission rules. A host that intends
to expose record history can opt in during instance configuration:
```java
ClientChannelsMetaData.ofOrWithNew(qInstance)
.getDeniedPatterns().get("assistant").remove("audit*");
new AssistantQBitConfig()
.withRecordHistory(true);
```
This removes only the default audit pattern; other channel denials and the
signed-in user's audit permissions still apply. Keep explicit denials for audit
surfaces the host does not intend to expose. The default remains unchanged.
This is an owner decision and a hard wall: record history defaults to disabled;
**kof22 does not opt in**. On QQQ RC.6, the opt-in removes exactly `audit*`
from the assistant's denied patterns (the former manual configuration step).
RC.6 does not add a separate channel write denial for audits. On later RCs that
include QRun-IO/qqq#1193, it calls `withAssistantAuditReads()`, which allows audit
reads but denies audit writes. The user's own audit permissions still apply.
Other patterns, including `worm_audit_*`, `assistant_*`, and security surfaces,
remain untouched, as do explicit object and permission-rule denials.
History visibility uses core's `isDenied` read/all-operation check. RC.6 has no
operation argument; the later write-only deny patterns must remain separate
from that check so they do not hide the read tool.
+40 -2
View File
@@ -1,5 +1,43 @@
# Upgrading qbit-assistant
## X1/Y1: channel wall and audit attribution
Requires QQQ 4.1.0-RC.6. Every assistant table, process, report and widget is
explicitly denied to channel `assistant`; startup rejects an exposed object.
Host customizations must preserve these denials. Administration uses `next-ui`.
Before enabling `AuditsMetaDataProvider.withClientChannelField(true)`, add the
nullable `audit.client_channel VARCHAR(100)` column using the host migration
conventions. kof22 needs a feature changeset under `changesets/features/` (for
example `2026-10-14-audit-client-channel.sql`); no host migration is applied here.
Existing audit rows remain null. The test fixture enables the field.
The existing `assistant_tool_call.auditHref` column now points to audit rows
filtered by record, table and client channel, including for deletes. It is empty
when the host has not enabled the audit channel field. Apply the X1/Y1 DDL in
`docs/schema-postgres.sql` to widen the existing `assistant_tool_call.audit_href`
column to TEXT: encoded filters with long table names or string record ids can
exceed the old 500-character limit. No new QBit column is required. `GET /assistant/access` accepts an optional owned `turnId` for attribution.
## Provider API extraction (#30)
Hosts that depend on `qbit-assistant` keep working: it now brings in
`qbit-assistant-provider-api` transitively. Package names are unchanged, including
`com.kingsrook.qbits.assistant.engine.ModelPricing`. No schema migration is needed.
External providers and applications using only provider/pricing types should
replace their `qbit-assistant` dependency with
`com.kingsrook.qbits:qbit-assistant-provider-api` at the same version. This removes
the assistant runtime and QQQ from that dependency path; only Jackson annotations
are required by the API. Anthropic, OpenAI, Bedrock and Vertex adapters use this
split; pgvector retains `qbit-assistant` for its runtime RAG interfaces.
Recompile custom provider types that override `validate(AssistantProvider)` and
change the signature to `validate(ProviderSettings)`. The settings carry the
configured kind, URL, API version, region, project, CA bundle name, timeouts and
`apiKeyEnvName`; the key and bearer-token supplier are not resolved for validation.
Providers using the default validator need no source change. Keep the runtime and
provider artifacts on the same version; old validator overrides must be rebuilt.
## P-g: scheduled tasks
Apply the **P-g** DDL in [docs/schema-postgres.sql](docs/schema-postgres.sql)
@@ -23,8 +61,8 @@ See [scheduled-task setup](README.md#scheduled-tasks) for kof22 provisioning.
QQQ RC.5 has no conditional UpdateInput. The runner uses a database-unique
schedule-slot insertion as its optimistic claim, then advances `nextRunAt`
before starting work. Crashes can skip a slot; they never retry it. No ArchUnit
exception or fabricated session is introduced. Core client-channel enforcement
arrives with the client-channel RC; the header and tool audit are already sent.
exception or fabricated session is introduced. Core RC.6 enforces client-channel denials; scheduled requests carry
`assistant/scheduled/<taskId>`.
Notifications use the optional Alerts QBit notification SPI with a single-user
audience. Without it, delivery logs run id/status only. All output modes retain
+8
View File
@@ -287,6 +287,14 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
- **Tests:** an assistant write produces audit rows with the human actor and `via=assistant/<turnId>`; the same write from the UI produces `via=next-ui`.
- kof22 needs a feature-named changeset (`changesets/features/...`) for the audit column.
### X1/Y1 as built
- Record history is an explicit, default-off host opt-in (`withRecordHistory(true)`); kof22 does not opt in. RC.6 removes only `audit*`; later core #1193 support allows audit reads with a write-only denial. Other walls and user audit permissions remain intact.
- RC.6's invoker fixes the `assistant` channel server-side and ignores spoofed client headers. Turn context survives knowledge discovery, RAG reads, confirm actions and owned-turn explain-access; scheduled calls retain `scheduled/<taskId>`.
- The producer enumerates assistant tables, processes, reports and widgets (including source-QBit metadata), adds explicit denied-client rules without replacing user permissions, and registers `assistant_*` idempotently. Runtime construction independently validates the wall with core's `isDenied` and fails on exposed objects.
- The fixture enables core's audit channel field. Hosts must migrate the nullable audit column before enabling it; kof22 needs its own feature changeset.
- Successful insert/update/delete tool calls link to `/app/audit` with `recordId`, `auditTable.name` and `clientChannel` criteria, using the same encoder as `present_results`. Hosts without the channel field get no audit link. Existing audit link storage widens to TEXT for long encoded filters.
- HTTP tests enumerate the configuration denial matrix for an admin, contrast Next UI access, reject broken startup metadata, and verify confirmed-write actor/channel plus the decoded audit filter.
### P. Providers, local models, HIPAA routing, budgets
1. **Providers and models as data, any number, each with an enabled toggle.**
- Kinds:
+4 -1
View File
@@ -103,7 +103,7 @@ CREATE TABLE assistant_tool_call
redactions_json TEXT,
ui_api_operation VARCHAR(255),
http_status INTEGER,
audit_href VARCHAR(500),
audit_href TEXT,
pending_action_id INTEGER,
create_date TIMESTAMP WITH TIME ZONE
);
@@ -574,3 +574,6 @@ CREATE TABLE assistant_scheduled_run (
UNIQUE (task_id, scheduled_for)
);
CREATE INDEX assistant_scheduled_run_owner ON assistant_scheduled_run(user_id, started_at);
-- X1/Y1: encoded audit filters can exceed the old 500-character record links.
ALTER TABLE assistant_tool_call ALTER COLUMN audit_href TYPE TEXT;
+9 -1
View File
@@ -28,6 +28,8 @@
<anthropic.version>2.71.0</anthropic.version>
<archunit.version>1.5.1</archunit.version>
<jackson.version>2.21.7</jackson.version>
<!-- optional email delivery; qbit-email from Maven Central -->
<qbit-email.version>0.1.0-RC.2</qbit-email.version>
<!-- live evals call the real API; run them with -Dtest.excludedGroups= -Dgroups=live -->
<test.excludedGroups>live</test.excludedGroups>
</properties>
@@ -35,7 +37,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.1.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -50,6 +52,7 @@
<modelVersion>4.0.0</modelVersion>
<modules>
<module>qbit-assistant-provider-api</module>
<module>qbit-assistant</module>
<module>qbit-assistant-provider-anthropic</module>
<module>qbit-assistant-provider-openai</module>
@@ -85,6 +88,11 @@
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-anthropic</artifactId>
@@ -32,9 +32,14 @@
<url>https://github.com/QRun-IO/qbit-assistant</url>
<dependencies>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
+116
View File
@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ 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.
-->
<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">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-parent</artifactId>
<version>${revision}</version>
</parent>
<artifactId>qbit-assistant-provider-api</artifactId>
<packaging>jar</packaging>
<name>QBit Assistant - Provider API</name>
<description>Provider SPI and pricing value types, independent of the assistant runtime</description>
<url>https://github.com/QRun-IO/qbit-assistant</url>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<redirectTestOutputToFile>true</redirectTestOutputToFile>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
<limit>
<counter>CLASS</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -20,7 +20,6 @@ package com.kingsrook.qbits.assistant.engine;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantModelRouting;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
@@ -63,9 +62,9 @@ public class ModelPricing implements Serializable
public static Map<String, ModelPricing> defaults()
{
Map<String, ModelPricing> map = new LinkedHashMap<>();
map.put(AssistantModelRouting.SONNET_5_5, of(2.0, 10.0, 0.1));
map.put(AssistantModelRouting.OPUS_5_5, of(4.0, 20.0, 0.05));
map.put(AssistantModelRouting.HAIKU_5_5, of(0.10, 0.50, 0.1));
map.put("claude-sonnet-5-5", of(2.0, 10.0, 0.1));
map.put("claude-opus-5-5", of(4.0, 20.0, 0.05));
map.put("claude-haiku-5-5", of(0.10, 0.50, 0.1));
map.put("claude-fable-5-1", of(10.0, 50.0, 0.025));
map.put("claude-fable-5", of(10.0, 50.0, 0.1));
map.put("claude-opus-5", of(5.0, 25.0, 0.1));
@@ -18,7 +18,6 @@ package com.kingsrook.qbits.assistant.provider;
import java.util.List;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
/*******************************************************************************
@@ -140,8 +139,10 @@ public interface AssistantProviderType
/*******************************************************************************
** Additional settings errors, without resolving credentials or doing I/O.
** Settings contain configured values and apiKeyEnvName; apiKey, the bearer
** token supplier and SSLContext are not resolved for validation.
*******************************************************************************/
default List<String> validate(AssistantProvider row)
default List<String> validate(ProviderSettings settings)
{
return (List.of());
}
@@ -36,6 +36,7 @@ public class ProviderSettings
private String tlsCaBundle;
@JsonIgnore
private transient SSLContext sslContext;
private String apiKeyEnvName;
private String kind;
private String apiKey;
private String baseUrl;
@@ -397,4 +398,35 @@ public class ProviderSettings
this.bearerTokenSupplier = bearerTokenSupplier;
return (this);
}
/*******************************************************************************
** The configured environment variable name, never its secret value.
*******************************************************************************/
public String getApiKeyEnvName()
{
return (this.apiKeyEnvName);
}
/*******************************************************************************
** Setter for apiKeyEnvName
*******************************************************************************/
public void setApiKeyEnvName(String apiKeyEnvName)
{
this.apiKeyEnvName = apiKeyEnvName;
}
/*******************************************************************************
** Fluent setter for apiKeyEnvName
*******************************************************************************/
public ProviderSettings withApiKeyEnvName(String apiKeyEnvName)
{
this.apiKeyEnvName = apiKeyEnvName;
return (this);
}
}
@@ -0,0 +1,75 @@
/*
* 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.provider;
import java.util.Set;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
/*******************************************************************************
** Keep external provider consumers independent of the assistant runtime and QQQ.
*******************************************************************************/
class ProviderApiArchitectureTest
{
/*******************************************************************************
** Allow only the extracted types, including their nested enums, under the
** assistant namespace. A future dependency on any runtime package must fail.
*******************************************************************************/
@Test
void dependsOnlyOnApiTypes()
{
Set<String> apiTypes = Set.of(
"com.kingsrook.qbits.assistant.provider.AssistantModelProvider",
"com.kingsrook.qbits.assistant.provider.AssistantProviderType",
"com.kingsrook.qbits.assistant.provider.CacheHint",
"com.kingsrook.qbits.assistant.provider.CompletableStreamHandle",
"com.kingsrook.qbits.assistant.provider.DiscoveredModel",
"com.kingsrook.qbits.assistant.provider.EmbeddingRequest",
"com.kingsrook.qbits.assistant.provider.EmbeddingResponse",
"com.kingsrook.qbits.assistant.provider.HealthResult",
"com.kingsrook.qbits.assistant.provider.ModelCapabilities",
"com.kingsrook.qbits.assistant.provider.ModelEventListener",
"com.kingsrook.qbits.assistant.provider.ModelMessage",
"com.kingsrook.qbits.assistant.provider.ModelPart",
"com.kingsrook.qbits.assistant.provider.ModelProviderException",
"com.kingsrook.qbits.assistant.provider.ModelRequest",
"com.kingsrook.qbits.assistant.provider.ModelResponse",
"com.kingsrook.qbits.assistant.provider.ModelToolDefinition",
"com.kingsrook.qbits.assistant.provider.ModelUsage",
"com.kingsrook.qbits.assistant.provider.ProviderSettings",
"com.kingsrook.qbits.assistant.provider.StreamHandle",
"com.kingsrook.qbits.assistant.provider.SystemBlock",
"com.kingsrook.qbits.assistant.provider.TokenCount",
"com.kingsrook.qbits.assistant.provider.TrustedCaBundle",
"com.kingsrook.qbits.assistant.engine.ModelPricing");
JavaClasses imported = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("com.kingsrook.qbits.assistant");
DescribedPredicate<JavaClass> outsideRuntime = DescribedPredicate.describe("outside assistant or an extracted API type", type ->
!type.getName().startsWith("com.kingsrook.qbits.assistant.") || apiTypes.contains(type.getName().split("\\$", 2)[0]));
classes().should().onlyDependOnClassesThat(outsideRuntime).check(imported);
noClasses().should().dependOnClassesThat().resideInAnyPackage("com.kingsrook.qqq..").check(imported);
}
}
@@ -0,0 +1,214 @@
/*
* 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.provider;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import javax.net.ssl.SSLContext;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.assistant.engine.ModelPricing;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Provider contracts work on a classpath with no assistant runtime or QQQ.
*******************************************************************************/
class ProviderApiTest
{
private final ObjectMapper mapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
/*******************************************************************************
** Preserve provider-neutral requests and tool order through JSON; signed
** provider state remains in memory only.
*******************************************************************************/
@Test
void requestRoundTripAndCopy() throws Exception
{
ModelCapabilities capabilities = new ModelCapabilities().withTools(true).withParallelToolCalls(true)
.withPromptCaching(true).withThinking(false).withMaxContextTokens(32000)
.withVision(false).withStreaming(true).withReasoningEffort(true);
ModelToolDefinition tool = new ModelToolDefinition().withName("lookup").withDescription("Look up a record")
.withInputSchema(Map.of("type", "object")).withStrict(true);
ModelMessage user = ModelMessage.user("Find it").withCacheHint(CacheHint.SHORT);
ModelMessage assistant = ModelMessage.assistant("Looking").withPart(ModelPart.toolUse("call-1", "lookup", null))
.withProviderState("private-signature");
ModelMessage result = new ModelMessage().withRole(ModelMessage.Role.USER)
.withParts(List.of(ModelPart.toolResult("call-1", "denied", true)));
ModelRequest request = new ModelRequest().withModel("example").withEffort("low").withMaxTokens(100)
.withParallelToolCalls(true).withStreaming(false).withCapabilities(capabilities)
.withSystem(List.of(new SystemBlock("rules", CacheHint.LONG), new SystemBlock().withText("user context").withCacheHint(CacheHint.SHORT)))
.withTools(List.of(tool)).withMessages(new ArrayList<>(List.of(user, assistant, result)));
String json = mapper.writeValueAsString(request);
assertThat(json).doesNotContain("private-signature", "providerState");
ModelRequest restored = mapper.readValue(json, ModelRequest.class);
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(request));
assertThat(restored.getMessages().get(1).getProviderState()).isNull();
assertThat(restored.getMessages().get(2).getParts().get(0).getIsError()).isTrue();
ModelRequest copy = request.copy();
assertThat(mapper.<JsonNode>valueToTree(copy)).isEqualTo(mapper.valueToTree(request));
copy.getMessages().clear();
assertThat(request.getMessages()).hasSize(3);
}
/*******************************************************************************
** Response helpers retain tool ordering, text, usage and safe metadata.
*******************************************************************************/
@Test
void responseAndPricingRoundTrip() throws Exception
{
ModelUsage usage = new ModelUsage().withInputTokens(2L).withOutputTokens(3L)
.withCacheReadTokens(4L).withCacheWriteTokens(5L).withCacheWrite1hTokens(1L);
ModelPart first = ModelPart.toolUse("first", "lookup", Map.of("id", 1));
ModelPart second = ModelPart.toolUse("second", "lookup", Map.of("id", 2));
ModelResponse response = new ModelResponse().withParts(List.of(ModelPart.text("a"), first, ModelPart.text("b"), second))
.withModel("example").withStopReason(ModelResponse.STOP_TOOL_USE).withRequestId("request-1")
.withUsage(usage).withProviderState("signature");
assertThat(response.getText()).isEqualTo("ab");
assertThat(response.getToolUses()).containsExactly(first, second);
String json = mapper.writeValueAsString(response);
assertThat(json).doesNotContain("signature", "providerState");
ModelResponse restored = mapper.readValue(json, ModelResponse.class);
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(response));
assertThat(restored.getProviderState()).isNull();
assertThat(response.getProviderState()).isEqualTo("signature");
ModelPricing pricing = ModelPricing.of(0.1, 0.5, 0.1);
ModelPricing restoredPricing = mapper.readValue(mapper.writeValueAsString(pricing), ModelPricing.class);
assertThat(restoredPricing.costMicros(usage)).isEqualTo(3);
assertThat(new ModelUsage().withInputTokens(null).add(usage).add(null).billableTokens()).isEqualTo(10);
TokenCount tokens = mapper.readValue(mapper.writeValueAsString(new TokenCount().withInputTokens(12L)), TokenCount.class);
assertThat(tokens.getInputTokens()).isEqualTo(12L);
}
/*******************************************************************************
** Plain validation settings require no credentials or entity classes, and
** live token suppliers/TLS contexts cannot be serialized into diagnostics.
*******************************************************************************/
@Test
void settingsRoundTrip() throws Exception
{
ProviderSettings settings = new ProviderSettings().withKind("EXAMPLE").withApiKeyEnvName("EXAMPLE_KEY")
.withApiKey("test-only-key").withBaseUrl("https://example.invalid").withApiVersion("v1")
.withRegion("region").withCloudProject("project").withTlsCaBundle("corporate")
.withConnectTimeoutSeconds(5).withRequestTimeoutSeconds(30)
.withBearerTokenSupplier(() -> "ephemeral-token").withSslContext(SSLContext.getDefault());
String json = mapper.writeValueAsString(settings);
assertThat(json).doesNotContain("ephemeral-token", "bearerTokenSupplier", "sslContext");
ProviderSettings restored = mapper.readValue(json, ProviderSettings.class);
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(settings));
assertThat(settings.getBearerTokenSupplier().get()).isEqualTo("ephemeral-token");
assertThat(settings.getSslContext()).isNotNull();
assertThat(restored.getBearerTokenSupplier()).isNull();
assertThat(restored.getSslContext()).isNull();
}
/*******************************************************************************
** Embedding batches snapshot input and enforce the transport size contract.
*******************************************************************************/
@Test
void embeddingContracts()
{
List<String> input = new ArrayList<>(List.of("first", "second"));
EmbeddingRequest request = new EmbeddingRequest("embed", input);
input.clear();
assertThat(request.input()).containsExactly("first", "second");
assertThatThrownBy(() -> request.input().add("third")).isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> new EmbeddingRequest(" ", List.of("text"))).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new EmbeddingRequest(null, List.of("text"))).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new EmbeddingRequest("embed", List.of())).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new EmbeddingRequest("embed", Collections.nCopies(65, "text"))).isInstanceOf(IllegalArgumentException.class);
assertThat(new EmbeddingRequest("embed", Collections.nCopies(64, "text")).input()).hasSize(64);
float[] vector = new float[] { 0.25F, 0.75F };
EmbeddingResponse response = new EmbeddingResponse(List.of(vector), new ModelUsage().withInputTokens(2L), "request");
assertThat(response.vectors().getFirst()).containsExactly(0.25F, 0.75F);
assertThat(response.usage().getInputTokens()).isEqualTo(2L);
assertThat(response.requestId()).isEqualTo("request");
}
/*******************************************************************************
** Cancellation is idempotent, preserves accrued usage, and wins over late
** completion. Timeout also cancels the underlying transport.
*******************************************************************************/
@Test
void cancellationAndTimeout() throws Exception
{
AtomicInteger cancellations = new AtomicInteger();
ModelUsage usage = new ModelUsage().withInputTokens(10L);
CompletableStreamHandle handle = new CompletableStreamHandle();
handle.setPartialUsage(() -> usage);
handle.setOnCancel(cancellations::incrementAndGet);
handle.cancel();
handle.cancel();
handle.complete(new ModelResponse().withStopReason(ModelResponse.STOP_END_TURN));
handle.fail(new ModelProviderException(ModelProviderException.Kind.SERVER, 500, "late failure"));
assertThat(handle.isCancelled()).isTrue();
assertThat(cancellations.get()).isEqualTo(1);
assertThat(handle.await(Duration.ofSeconds(1)).getUsage()).isSameAs(usage);
CompletableStreamHandle timeout = new CompletableStreamHandle();
timeout.setPartialUsage(() -> usage);
assertThatThrownBy(() -> timeout.await(Duration.ofMillis(1))).isInstanceOfSatisfying(ModelProviderException.class, error ->
{
assertThat(error.getKind()).isEqualTo(ModelProviderException.Kind.TIMEOUT);
assertThat(error.getPartialUsage()).isSameAs(usage);
});
assertThat(timeout.isCancelled()).isTrue();
handle.setPartialUsage(() ->
{
throw (new IllegalStateException("closed"));
});
assertThat(handle.partialUsage()).isNull();
}
/*******************************************************************************
** Error metadata is bounded and redacted independently of error classification.
*******************************************************************************/
@Test
void errorDetails()
{
ModelProviderException error = new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "Invalid schema")
.withStrictSchemaRejected(true).withProviderDetails("invalid_request", "bad key sk-abcdefghijk\ntry again", "req-123");
assertThat(error.isStrictSchemaRejected()).isTrue();
assertThat(error.getProviderErrorType()).isEqualTo("invalid_request");
assertThat(error.getProviderMessage()).isEqualTo("bad key [redacted] try again");
assertThat(error.getRequestId()).isEqualTo("req-123");
assertThat(error.isRetryable()).isFalse();
assertThat(ModelProviderException.sanitizeProviderMessage(" ", 10)).isNull();
assertThat(ModelProviderException.sanitizeProviderMessage(null, 10)).isNull();
assertThat(ModelProviderException.sanitizeProviderMessage("word ".repeat(100), 20)).hasSize(20);
assertThat(ModelProviderException.sanitizeProviderMessage("a".repeat(50), 100)).isEqualTo("[redacted]");
}
}
@@ -0,0 +1,80 @@
/*
* 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.provider;
import java.time.Duration;
import com.kingsrook.qbits.assistant.engine.ModelPricing;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Standalone contracts moved from the runtime's component tests.
*******************************************************************************/
class ProviderComponentsTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void testPricing()
{
ModelPricing sonnet = ModelPricing.defaults().get("claude-sonnet-5-5");
ModelUsage usage = new ModelUsage().withInputTokens(6000L).withOutputTokens(800L).withCacheReadTokens(12_000L).withCacheWriteTokens(1000L).withCacheWrite1hTokens(400L);
//////////////////////////////////////////////////////////////////////////////////
// 6000*2 + 800*10 + 12000*0.2 + 600*2.5 + 400*4 = 12000+8000+2400+1500+1600 //
//////////////////////////////////////////////////////////////////////////////////
assertThat(sonnet.costMicros(usage)).isEqualTo(25_500);
assertThat(sonnet.costMicros(null)).isZero();
assertThat(new ModelPricing().costMicros(usage)).isZero();
assertThat(ModelPricing.defaults().get("claude-opus-5-5").getCacheReadUsdPerMTok()).isEqualTo(0.2);
assertThat(usage.billableTokens()).isEqualTo(7800);
assertThat(new ModelUsage().add(usage).add(null).getCacheReadTokens()).isEqualTo(12_000);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testStreamHandle() throws Exception
{
CompletableStreamHandle handle = new CompletableStreamHandle();
handle.complete(new ModelResponse().withStopReason("end_turn"));
assertThat(handle.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo("end_turn");
CompletableStreamHandle failing = new CompletableStreamHandle();
failing.fail(new ModelProviderException(ModelProviderException.Kind.RATE_LIMITED, 429, "slow down"));
assertThatThrownBy(() -> failing.await(Duration.ofSeconds(1))).isInstanceOfSatisfying(ModelProviderException.class, e ->
{
assertThat(e.isRetryable()).isTrue();
assertThat(e.getHttpStatus()).isEqualTo(429);
});
CompletableStreamHandle cancelled = new CompletableStreamHandle();
cancelled.setOnCancel(() ->
{
throw (new IllegalStateException("closing"));
});
cancelled.cancel();
cancelled.cancel();
assertThat(cancelled.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo(ModelResponse.STOP_CANCELLED);
assertThat(new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "x").isRetryable()).isFalse();
}
}
@@ -0,0 +1,164 @@
/*
* 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.provider;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** A minimal external provider compiles and uses SPI defaults without the runtime.
*******************************************************************************/
class ProviderDefaultsTest
{
/*******************************************************************************
** Legacy two-argument factories and static model lists remain supported.
*******************************************************************************/
@Test
void minimalProviderDefaults() throws Exception
{
ExampleProvider provider = new ExampleProvider();
ProviderSettings settings = new ProviderSettings().withApiKey("test-key").withBaseUrl("https://example.invalid");
assertThat(provider.create(settings)).isSameAs(provider);
assertThat(provider.settings.getApiKey()).isEqualTo("test-key");
assertThat(provider.settings.getBaseUrl()).isEqualTo("https://example.invalid");
assertThat(provider.getKinds()).containsExactly("EXAMPLE_PROVIDER");
assertThat(provider.getDefaultBaseUrl("EXAMPLE_PROVIDER")).isNull();
assertThat(provider.getDefaultApiKeyEnvName("EXAMPLE_PROVIDER")).isEqualTo("EXAMPLE_KEY");
assertThat(provider.getDefaultLocation("EXAMPLE_PROVIDER")).isEqualTo("CLOUD");
assertThat(provider.requiresBaseUrl("EXAMPLE_PROVIDER")).isTrue();
assertThat(provider.validate(settings)).isEmpty();
assertThat(provider.discoverModels(settings)).containsExactly(new DiscoveredModel("example", "example", null, null));
assertThat(provider.checkHealth(settings).healthy()).isNull();
assertThat(provider.checkHealth(settings).message()).isEqualTo("Health check not supported");
assertThat(new HealthResult(false, 20L, "bad sk-abcdefghijk").message()).isEqualTo("bad [redacted]");
assertThat(provider.countTokens(new ModelRequest())).isNull();
assertThatThrownBy(() -> provider.embed(new EmbeddingRequest("example", List.of("text"))))
.isInstanceOfSatisfying(ModelProviderException.class, error -> assertThat(error.getKind()).isEqualTo(ModelProviderException.Kind.INVALID_REQUEST));
StringBuilder deltas = new StringBuilder();
ModelEventListener listener = deltas::append;
listener.onToolUseStart("call", "lookup");
assertThat(deltas).isEmpty();
listener.onTextDelta("visible answer");
assertThat(deltas).hasToString("visible answer");
}
/*******************************************************************************
** Only the original required SPI methods are implemented.
*******************************************************************************/
private static class ExampleProvider implements AssistantProviderType, AssistantModelProvider
{
private ProviderSettings settings;
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public String getType()
{
return ("example-provider");
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public String getLabel()
{
return ("Example");
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public List<String> getModels()
{
return (List.of("example"));
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public String getDefaultApiKeyEnvName()
{
return ("EXAMPLE_KEY");
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public String getDefaultBaseUrl()
{
return (null);
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public AssistantModelProvider create(String apiKey, String baseUrl)
{
settings = new ProviderSettings().withApiKey(apiKey).withBaseUrl(baseUrl);
return (this);
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public String getName()
{
return ("example");
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public ModelCapabilities getCapabilities(String modelId)
{
return (new ModelCapabilities());
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public StreamHandle stream(ModelRequest request, ModelEventListener listener)
{
throw (new UnsupportedOperationException());
}
}
}
+5
View File
@@ -41,9 +41,14 @@
<artifactId>qbit-assistant-provider-anthropic</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
@@ -24,7 +24,6 @@ import com.anthropic.backends.Backend;
import com.anthropic.bedrock.backends.BedrockBackend;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.core.Timeout;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
@@ -117,14 +116,14 @@ public class BedrockProviderType implements AssistantProviderType
** Validate cloud settings without loading credentials.
*******************************************************************************/
@Override
public List<String> validate(AssistantProvider row)
public List<String> validate(ProviderSettings settings)
{
List<String> errors = new ArrayList<>();
if(row.getRegion() == null || row.getRegion().isBlank())
if(settings.getRegion() == null || settings.getRegion().isBlank())
{
errors.add("Region is required for BEDROCK.");
}
if(row.getApiKeyEnvName() != null && !row.getApiKeyEnvName().isBlank())
if(settings.getApiKeyEnvName() != null && !settings.getApiKeyEnvName().isBlank())
{
errors.add("Leave API key variable empty: AWS Bedrock (Claude) uses the AWS default credential chain (environment, profile or server role).");
}
+5
View File
@@ -32,9 +32,14 @@
<url>https://github.com/QRun-IO/qbit-assistant</url>
<dependencies>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
+5
View File
@@ -45,9 +45,14 @@
<artifactId>qbit-assistant-provider-openai</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
@@ -19,7 +19,6 @@ package com.kingsrook.qbits.assistant.provider.vertex;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
@@ -104,18 +103,18 @@ public class VertexProviderType implements AssistantProviderType
** Validate cloud settings without loading credentials.
*******************************************************************************/
@Override
public List<String> validate(AssistantProvider row)
public List<String> validate(ProviderSettings settings)
{
List<String> errors = new ArrayList<>();
if(row.getRegion() == null || row.getRegion().isBlank())
if(settings.getRegion() == null || settings.getRegion().isBlank())
{
errors.add("Region is required for VERTEX.");
}
if(row.getApiKeyEnvName() != null && !row.getApiKeyEnvName().isBlank())
if(settings.getApiKeyEnvName() != null && !settings.getApiKeyEnvName().isBlank())
{
errors.add("Leave API key variable empty: Google Vertex AI (Claude, Gemini) uses Google Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS or workload identity).");
}
if(row.getCloudProject() == null || row.getCloudProject().isBlank())
if(settings.getCloudProject() == null || settings.getCloudProject().isBlank())
{
errors.add("GCP project id (cloudProject) is required for VERTEX.");
}
+14 -1
View File
@@ -28,10 +28,14 @@
<packaging>jar</packaging>
<name>QBit Assistant</name>
<description>In-app AI assistant QBit for QQQ: conversations, turn engine, SSE routes, budgets, provider SPI</description>
<description>In-app AI assistant QBit for QQQ: conversations, turn engine, SSE routes, budgets and provider registry</description>
<url>https://github.com/QRun-IO/qbit-assistant</url>
<dependencies>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
@@ -45,6 +49,15 @@
<optional>true</optional>
</dependency>
<!-- Optional email delivery of scheduled-task results. Every reference is in -->
<!-- the email package; without the jar nothing changes. -->
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>${qbit-email.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -90,6 +90,7 @@ public class AssistantQBitConfig implements QBitConfig
private String conversationEncryptionKeyEnvName;
private Boolean enabled = true;
private Boolean recordHistory = false;
private String backendName;
private String providerName = DEFAULT_PROVIDER_NAME;
private AssistantModelRouting models = new AssistantModelRouting();
@@ -145,11 +146,13 @@ public class AssistantQBitConfig implements QBitConfig
private Integer providerTestTimeoutSeconds = 30;
private Integer homeMaxRows = 20000;
private String tablePathPrefix = "/app";
private Boolean emailExternalApproved = false;
private transient MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
private transient Map<String, AssistantModelProvider> modelProviders = new LinkedHashMap<>();
private transient Map<String, AssistantProviderType> providerTypes = new LinkedHashMap<>();
private transient Function<String, String> secretResolver = System::getenv;
private transient Function<String, String> emailRecipientResolver;
private transient AssistantRuntime runtime;
@@ -1502,6 +1505,62 @@ public class AssistantQBitConfig implements QBitConfig
/*******************************************************************************
** Whether the host's EXTERNAL approval rule allows a scheduled task's result
** to leave the app by email. There is no approval rule in the qbit yet (the
** scheduled runner is P-g), so this explicit flag stands in; default false.
*******************************************************************************/
public Boolean getEmailExternalApproved()
{
return (this.emailExternalApproved);
}
/*******************************************************************************
** Setter for emailExternalApproved
*******************************************************************************/
public void setEmailExternalApproved(Boolean emailExternalApproved)
{
this.emailExternalApproved = emailExternalApproved;
}
/*******************************************************************************
** Fluent setter for emailExternalApproved
*******************************************************************************/
public AssistantQBitConfig withEmailExternalApproved(Boolean emailExternalApproved)
{
this.emailExternalApproved = emailExternalApproved;
return (this);
}
/*******************************************************************************
** The hook that turns a task owner's user id into an email address; null
** means the default (an owner id that already is an address).
*******************************************************************************/
@JsonIgnore
public Function<String, String> getEmailRecipientResolver()
{
return (emailRecipientResolver);
}
/*******************************************************************************
** Fluent setter for emailRecipientResolver
*******************************************************************************/
public AssistantQBitConfig withEmailRecipientResolver(Function<String, String> emailRecipientResolver)
{
this.emailRecipientResolver = emailRecipientResolver;
return (this);
}
/*******************************************************************************
** Getter for allowedApiKeyEnvNames (extra API key environment variable names a provider row may reference)
*******************************************************************************/
@@ -1700,6 +1759,28 @@ public class AssistantQBitConfig implements QBitConfig
/*******************************************************************************
** Whether the host opts into record history; false by default.
*******************************************************************************/
public Boolean getRecordHistory()
{
return (recordHistory);
}
/*******************************************************************************
** Owner decision — hard wall: record history requires explicit host opt-in.
** kof22 does not opt in. The user's own audit permissions still apply.
*******************************************************************************/
public AssistantQBitConfig withRecordHistory(boolean recordHistory)
{
this.recordHistory = recordHistory;
return (this);
}
/*******************************************************************************
** Getter for hipaaMode
*******************************************************************************/
@@ -25,6 +25,7 @@ import com.kingsrook.qbits.assistant.api.AssistantRoutes;
import com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels;
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
import com.kingsrook.qbits.assistant.home.AssistantHome;
import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall;
import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
@@ -41,6 +42,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
@@ -201,12 +203,42 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
QAppMetaData app = builder.buildAdminApp(tables);
qInstance.addApp(app);
AssistantChannelWall.register(qInstance);
if(Boolean.TRUE.equals(qBitConfig.getRecordHistory()))
{
enableRecordHistory(ClientChannelsMetaData.ofOrWithNew(qInstance));
}
postProduceActions(new MetaDataProducerMultiOutput(), qInstance);
return (new MetaDataProducerMultiOutput());
}
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1193, removeBy=RC.8): call withAssistantAuditReads() directly once the qbit builds on a QQQ RC that has it
*******************************************************************************/
private void enableRecordHistory(ClientChannelsMetaData channels)
{
try
{
channels.getClass().getMethod("withAssistantAuditReads").invoke(channels);
}
catch(NoSuchMethodException e)
{
List<String> denied = channels.getDeniedPatterns().get("assistant");
if(denied != null)
{
denied.removeIf("audit*"::equals);
}
}
catch(ReflectiveOperationException e)
{
throw (new IllegalStateException("Could not enable assistant audit reads.", e));
}
}
/*******************************************************************************
** Declare budget alerts as top-level core metadata for admin visibility.
*******************************************************************************/
@@ -32,6 +32,7 @@ import com.kingsrook.qbits.assistant.engine.ProfileService;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.engine.RateLimiter;
import com.kingsrook.qbits.assistant.engine.RunningTurn;
import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.rag.InMemoryVectorStore;
import com.kingsrook.qbits.assistant.rag.RagService;
@@ -103,6 +104,7 @@ public class AssistantRuntime
*******************************************************************************/
public AssistantRuntime(QInstance qInstance, AssistantQBitConfig config, Clock clock)
{
AssistantChannelWall.validate(qInstance);
this.qInstance = qInstance;
this.config = config;
this.clock = clock;
@@ -103,7 +103,17 @@ public class AccessExplainer
*******************************************************************************/
public List<TableAccess> explain(UiApiGateway gateway, String sessionUUID, UiApiCredentials credentials, PolicyService.ToolPolicy policy, String tableName, String prompt) throws QException
{
UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withCredentials(credentials).withInvokedBy("assistant-explain"));
return (explain(gateway, sessionUUID, credentials, policy, tableName, prompt, null));
}
/*******************************************************************************
** Preserve a known turn's attribution on the metadata request.
*******************************************************************************/
public List<TableAccess> explain(UiApiGateway gateway, String sessionUUID, UiApiCredentials credentials, PolicyService.ToolPolicy policy, String tableName, String prompt, String turnId) throws QException
{
UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withCredentials(credentials).withInvokedBy("assistant-explain").withClientDetail(turnId));
if(!response.isSuccess())
{
throw (new QException("GET /metaData answered HTTP " + response.status() + (response.errorMessage() == null ? "" : ": " + response.errorMessage())));
@@ -627,6 +627,7 @@ public class AssistantApi
store.insert(new AssistantToolCall()
.withTurnId(action.getTurnId())
.withClientChannel("assistant" + (action.getTurnId() == null ? "" : "/" + action.getTurnId()))
.withConversationId(action.getConversationId())
.withUserId(userId)
.withToolCallId(action.getToolCallId())
@@ -733,10 +734,24 @@ public class AssistantApi
** reason.
*******************************************************************************/
public Map<String, Object> access(String table, String prompt) throws QException, AssistantApiException
{
return (access(table, prompt, null));
}
/*******************************************************************************
** A supplied turn must belong to this user before using it for attribution.
*******************************************************************************/
public Map<String, Object> access(String table, String prompt, Integer turnId) throws QException, AssistantApiException
{
requireAccess();
if(turnId != null)
{
requireTurn(turnId);
}
List<Map<String, Object>> tables = new ArrayList<>();
for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), credentials, runtime.getPolicyService().current(false).tools(), table, prompt))
for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), credentials, runtime.getPolicyService().current(false).tools(), table, prompt, turnId == null ? null : String.valueOf(turnId)))
{
if(!access.uiVisible() && access.mode() == DataAccessMode.NONE)
{
@@ -61,7 +61,7 @@ import io.javalin.http.HttpStatus;
public final class AssistantSpecs
{
public static final String EXTENSION_NAME = "assistant";
public static final String EXTENSION_VERSION = "1.0.0";
public static final String EXTENSION_VERSION = "1.1.0";
public static final String PREFIX = "/assistant";
@@ -1312,7 +1312,7 @@ public final class AssistantSpecs
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of(queryParam("table", Type.STRING, "Only this table."), queryParam("prompt", Type.STRING, "Only the tables this text mentions.")));
return (List.of(queryParam("table", Type.STRING, "Only this table."), queryParam("prompt", Type.STRING, "Only the tables this text mentions."), queryParam("turnId", Type.INTEGER, "Attribute this request to an owned turn.")));
}
@@ -1329,7 +1329,7 @@ public final class AssistantSpecs
protected Object handle(AssistantApi api, JsonInput input) throws Exception
{
String table = input.getQueryParam("table");
return (api.access(StringUtils.hasContent(table) ? table : null, input.getQueryParam("prompt")));
return (api.access(StringUtils.hasContent(table) ? table : null, input.getQueryParam("prompt"), input.getQueryParamInteger("turnId")));
}
}
@@ -0,0 +1,111 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.email;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** Email delivery of a scheduled task's result, when the optional qbit-email
** is present. This class names no qbit-email type, so it loads without the
** jar; the adapter (AssistantEmailSender) is only loaded after the guard passes.
**
** Hosts can call sendScheduledResult after a run; the scheduled-task runner
** does not yet wire email delivery. The gates below apply to every call:
** the task opted in, the host approved EXTERNAL delivery
** (AssistantQBitConfig.getEmailExternalApproved, until the approval rule
** exists), and there is a recipient address.
*******************************************************************************/
public final class AssistantEmail
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AssistantEmail()
{
}
/*******************************************************************************
** Whether qbit-email is on the classpath and produced in this instance.
*******************************************************************************/
public static boolean isAvailable(QInstance qInstance)
{
try
{
Class.forName("com.kingsrook.qbits.email.api.EmailApi");
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
return (AssistantEmailSender.isInstalled(qInstance));
}
/*******************************************************************************
** Email a finished scheduled run to its owner, if the task opted in and
** every gate allows it. A missing email qbit is not an error; the status
** says what happened.
*******************************************************************************/
public static ScheduledEmailStatus sendScheduledResult(QInstance qInstance, AssistantQBitConfig config, ScheduledResultNotice notice) throws QException
{
if(notice == null || !notice.emailResult())
{
return (ScheduledEmailStatus.NOT_REQUESTED);
}
if(!isAvailable(qInstance))
{
return (ScheduledEmailStatus.NOT_AVAILABLE);
}
if(!Boolean.TRUE.equals(config.getEmailExternalApproved()))
{
return (ScheduledEmailStatus.NOT_APPROVED);
}
String recipient = recipient(config, notice.ownerUserId());
if(recipient == null)
{
return (ScheduledEmailStatus.NO_RECIPIENT);
}
return (AssistantEmailSender.send(notice, recipient));
}
/*******************************************************************************
** The owner's address: the host's resolver, else the owner id when it is
** itself an address. Null when there is none.
*******************************************************************************/
static String recipient(AssistantQBitConfig config, String ownerUserId)
{
if(ownerUserId == null || ownerUserId.isBlank())
{
return (null);
}
String address = config.getEmailRecipientResolver() != null ? config.getEmailRecipientResolver().apply(ownerUserId) : (ownerUserId.contains("@") ? ownerUserId : null);
return (address == null || address.isBlank() ? null : address.trim());
}
}
@@ -0,0 +1,156 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.email;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiRequest;
import com.kingsrook.qbits.email.api.EmailApiResult;
import com.kingsrook.qbits.email.api.EmailApiTemplate;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** The only class that touches qbit-email. Registers the scheduled-result
** template on first use (never overwriting edits) and queues one message per
** recipient. Addresses, summaries and bodies are never logged.
*******************************************************************************/
final class AssistantEmailSender
{
static final String TEMPLATE_NAME = "assistant_scheduled_result";
static final String SECURE_TEMPLATE = "email_secure_message";
static final String SENDER_QBIT = "assistant";
static final String SENDER_PROCESS = "assistantScheduledTask";
private static final int MAX_SUMMARY = 280;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AssistantEmailSender()
{
}
/*******************************************************************************
** Whether the email qbit is produced in this instance.
*******************************************************************************/
static boolean isInstalled(QInstance qInstance)
{
return (EmailApi.isInstalled(qInstance));
}
/*******************************************************************************
** Queue one message to one recipient. A result classified above PUBLIC
** sends only the link, with the secure-message pattern.
*******************************************************************************/
static ScheduledEmailStatus send(ScheduledResultNotice notice, String recipient) throws QException
{
boolean linkOnly = notice.classification() != null && notice.classification().isAtLeast(DataClassification.CONFIDENTIAL);
EmailApiRequest request = new EmailApiRequest().withTo(recipient)
.withIdempotencyKey("assistant:scheduled:" + notice.runId())
.withLinkedRecord(notice.runTable(), notice.runId())
.withSender(SENDER_QBIT, SENDER_PROCESS);
Map<String, Serializable> variables = new LinkedHashMap<>();
variables.put("link", notice.link());
if(linkOnly)
{
request.withTemplateName(SECURE_TEMPLATE);
}
else
{
ensureTemplate();
request.withTemplateName(TEMPLATE_NAME);
variables.put("taskName", notice.taskName());
variables.put("runTime", notice.runTime() == null ? null : notice.runTime().toString());
variables.put("summary", summary(notice.summary()));
}
request.withVariables(variables);
return (map(EmailApi.send(request)));
}
/*******************************************************************************
** One line, cut short; the full model output never goes in an email.
*******************************************************************************/
static String summary(String value)
{
if(value == null)
{
return (null);
}
String oneLine = value.replaceAll("\\s+", " ").trim();
return (oneLine.length() <= MAX_SUMMARY ? oneLine : oneLine.substring(0, MAX_SUMMARY - 3) + "...");
}
/*******************************************************************************
** Outbox result to the qbit's own vocabulary.
*******************************************************************************/
private static ScheduledEmailStatus map(EmailApiResult result)
{
return (switch(result.status())
{
case QUEUED -> ScheduledEmailStatus.QUEUED;
case DUPLICATE -> ScheduledEmailStatus.DUPLICATE;
case SUPPRESSED -> ScheduledEmailStatus.SUPPRESSED;
case REFUSED -> ScheduledEmailStatus.REFUSED;
});
}
/*******************************************************************************
** Register the NOTIFICATION template once; an existing template is left alone.
*******************************************************************************/
static void ensureTemplate() throws QException
{
EmailApi.ensureTemplate(new EmailApiTemplate()
.withName(TEMPLATE_NAME)
.withLabel("Assistant scheduled task result")
.withCategory(EmailApiCategory.NOTIFICATION)
.withKind("MARKDOWN")
.withOwnerQbit(SENDER_QBIT)
.withSubject("Assistant task result: {{taskName}}")
.withBody("Your scheduled assistant task **{{taskName}}** finished at {{runTime}}.\n\n{{summary}}\n\n[Open the run]({{link}}){.button}")
.withVariable("taskName", "Task name", "STRING", true, "CONFIDENTIAL")
.withVariable("runTime", "Run time", "DATE_TIME", true, "PUBLIC")
.withVariable("summary", "Short summary", "STRING", false, "CONFIDENTIAL")
.withVariable("link", "Link to the run", "URL", true, "PUBLIC")
.withSampleVariables("{\"taskName\":\"Weekly review\",\"runTime\":\"2026-10-12T09:00:00Z\",\"summary\":\"Three items need attention.\",\"link\":\"https://app.example.com/app/assistant_run/1\"}"));
}
}
@@ -0,0 +1,38 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.email;
/*******************************************************************************
** Outcome of trying to email a scheduled task's result. The first four mean
** nothing was queued and why; the rest mirror the outbox result.
*******************************************************************************/
public enum ScheduledEmailStatus
{
NOT_REQUESTED,
NOT_AVAILABLE,
NOT_APPROVED,
NO_RECIPIENT,
QUEUED,
DUPLICATE,
SUPPRESSED,
REFUSED
}
@@ -0,0 +1,46 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.email;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
/*******************************************************************************
** What the scheduled-task runner knows about one finished run, for the
** result email. The full model output is deliberately not part of it: only a
** short summary, and only when the result is classified PUBLIC.
**
** @param runTable the table holding the run record (the email's linked record)
** @param runId the run's id (also the idempotency key)
** @param taskName the scheduled task's name
** @param runTime when the run finished
** @param summary a short summary (cut to 280 characters; never the model output)
** @param classification the result's classification; null means PUBLIC
** @param ownerUserId the task owner's user id (the way the qbit identifies users)
** @param emailResult whether the task opted in to email delivery
** @param link absolute link to the run record in the app
*******************************************************************************/
public record ScheduledResultNotice(String runTable, Object runId, String taskName, Instant runTime, String summary, DataClassification classification,
String ownerUserId, boolean emailResult, String link)
{
}
@@ -106,6 +106,16 @@ public class ContextBuilder
** kernel's navigate and search_help.
*******************************************************************************/
public List<ModelToolDefinition> tools(PolicyService.ToolPolicy policy)
{
return (tools(policy, null));
}
/*******************************************************************************
** Attribute tool-discovery requests to the turn that needs them.
*******************************************************************************/
private List<ModelToolDefinition> tools(PolicyService.ToolPolicy policy, String turnId)
{
Map<String, ModelToolDefinition> byName = new TreeMap<>();
for(ModelToolDefinition definition : toolbox.definitions(policy, toolbox.anyWritableTable()))
@@ -123,7 +133,7 @@ public class ContextBuilder
.withStrict(isStrict(definition.getName(), !Boolean.FALSE.equals(definition.getStrict()))));
}
}
if(knowledgeTool != null && knowledgeTool.offered(policy, QContext.getQSession()))
if(knowledgeTool != null && knowledgeTool.offered(policy, QContext.getQSession(), turnId))
{
byName.put(KnowledgeTool.NAME, knowledgeTool.definition());
}
@@ -216,12 +226,23 @@ public class ContextBuilder
*******************************************************************************/
public ModelRequest build(QSession session, String model, String effort, List<AssistantMessage> history, String userText, Map<String, Object> pageContext, PolicyService.ToolPolicy toolPolicy,
String profileInstructions)
{
return (build(session, model, effort, history, userText, pageContext, toolPolicy, profileInstructions, null));
}
/*******************************************************************************
** Include the turn in any UI API calls needed to discover available tools.
*******************************************************************************/
public ModelRequest build(QSession session, String model, String effort, List<AssistantMessage> history, String userText, Map<String, Object> pageContext, PolicyService.ToolPolicy toolPolicy,
String profileInstructions, String turnId)
{
ModelRequest request = new ModelRequest()
.withModel(model)
.withEffort(effort)
.withMaxTokens(config.getModels().getMainMaxOutputTokens())
.withTools(tools(toolPolicy));
.withTools(tools(toolPolicy, turnId));
request.getSystem().add(new SystemBlock(SystemPrompt.staticBlock(appDigest()), CacheHint.LONG));
request.getSystem().add(new SystemBlock(userBlock(session) + (StringUtils.hasContent(profileInstructions) ? "\n" + profileInstructions : ""), CacheHint.SHORT));
@@ -258,7 +258,11 @@ public class ProviderService
return (errors);
}
errors.addAll(type.validate(row));
errors.addAll(type.validate(new ProviderSettings().withKind(kind(row))
.withApiKeyEnvName(row.getApiKeyEnvName()).withBaseUrl(row.getBaseUrl())
.withApiVersion(row.getApiVersion()).withRegion(row.getRegion()).withCloudProject(row.getCloudProject())
.withTlsCaBundle(row.getTlsCaBundle()).withConnectTimeoutSeconds(row.getConnectTimeoutSeconds())
.withRequestTimeoutSeconds(row.getRequestTimeoutSeconds())));
String envName = keyEnvName(row);
if(envName != null && !AssistantQBitConfig.ENV_NAME.matcher(envName).matches())
{
@@ -678,7 +682,7 @@ public class ProviderService
{
throw (new ProviderSetupException("No API key: the environment variable " + keyEnvName(row) + " is not set on this server."));
}
return (new ProviderSettings().withKind(kind(row)).withApiKey(key)
return (new ProviderSettings().withKind(kind(row)).withApiKeyEnvName(row.getApiKeyEnvName()).withApiKey(key)
.withBaseUrl(StringUtils.hasContent(row.getBaseUrl()) ? row.getBaseUrl().trim() : null)
.withApiVersion(row.getApiVersion()).withRegion(row.getRegion()).withCloudProject(row.getCloudProject())
.withTlsCaBundle(row.getTlsCaBundle()).withSslContext(StringUtils.hasContent(row.getTlsCaBundle()) ? config.getTrustedCaBundles().get(row.getTlsCaBundle()) : null)
@@ -262,7 +262,7 @@ public class TurnEngine implements Runnable
String appName = runtime.appNameFor(pageContext);
ProfileService.Persona persona = runtime.getProfileService().persona(session, appName, scheduledTask == null ? null : scheduledTask.getProfileId());
String profileText = ProfileService.instructions(persona, runtime.getProfileService().relevantSkills(persona.profileId(), userText, appName));
ModelRequest request = runtime.getContextBuilder().build(session, model, route.effort(), history, userText, pageContext, toolPolicy, profileText);
ModelRequest request = runtime.getContextBuilder().build(session, model, route.effort(), history, userText, pageContext, toolPolicy, profileText, turn.getId() == null ? null : String.valueOf(turn.getId()));
Integer maxPerRequest = active.row() == null ? null : active.row().getMaxOutputTokensPerRequest();
if(maxPerRequest != null && (request.getMaxTokens() == null || request.getMaxTokens() > maxPerRequest))
{
@@ -885,7 +885,7 @@ public class TurnEngine implements Runnable
.withUserId(userId)
.withToolCallId(use.getToolUseId())
.withToolName(use.getToolName())
.withClientChannel(scheduledTask == null ? null : "assistant/scheduled/" + scheduledTask.getId())
.withClientChannel(scheduledTask == null ? "assistant/" + turn.getId() : "assistant/scheduled/" + scheduledTask.getId())
.withInputJson(truncate(redactInput(PresentResultsTool.NAME.equals(use.getToolName()) ? (presentation ? result.getRender() : Map.of()) : use.getInput()), MAX_INPUT_JSON_CHARS))
.withOutcome(outcome)
.withPermissionCheck(result.getPermissionCheck())
@@ -0,0 +1,96 @@
/*
* 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.metadata;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantQBitProducer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware;
/*******************************************************************************
** Register and verify the assistant's configuration wall beneath user permissions.
*******************************************************************************/
public final class AssistantChannelWall
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AssistantChannelWall()
{
}
/*******************************************************************************
** Enumerate the instance so new assistant metadata cannot miss registration.
** Preserve host permissions and other denied channels.
*******************************************************************************/
public static void register(QInstance qInstance)
{
ClientChannelsMetaData.ofOrWithNew(qInstance).withDeniedPattern("assistant", "assistant_*");
for(MetaDataWithPermissionRules object : objects(qInstance))
{
QPermissionRules rules = object.getPermissionRules();
rules = rules == null ? qInstance.getDefaultPermissionRules().clone() : rules.clone();
Set<String> denied = new HashSet<>(rules.getDeniedClients() == null ? Set.of() : rules.getDeniedClients());
denied.add("assistant");
object.setPermissionRules(rules.withDeniedClients(denied));
}
}
/*******************************************************************************
** Fail before runtime initialization if a host customization opens the wall.
*******************************************************************************/
public static void validate(QInstance qInstance)
{
ClientChannelsMetaData channels = ClientChannelsMetaData.of(qInstance);
for(MetaDataWithPermissionRules object : objects(qInstance))
{
QPermissionRules rules = object.getPermissionRules() == null ? qInstance.getDefaultPermissionRules() : object.getPermissionRules();
if(!channels.isDenied("assistant", object.getName(), rules))
{
throw (new IllegalStateException("Assistant channel wall: " + object.getName() + " must be denied to client channel assistant before startup."));
}
}
}
/*******************************************************************************
** Tables, processes, reports and widgets, including host-added assistant objects.
*******************************************************************************/
private static List<MetaDataWithPermissionRules> objects(QInstance qInstance)
{
List<MetaDataWithPermissionRules> objects = new ArrayList<>();
objects.addAll(qInstance.getTables().values());
objects.addAll(qInstance.getProcesses().values());
objects.addAll(qInstance.getReports().values());
objects.addAll(qInstance.getWidgets().values());
return (objects.stream().filter(object -> (object.getName() != null && object.getName().startsWith("assistant"))
|| (object instanceof SourceQBitAware source && (AssistantQBitProducer.GROUP_ID + ":" + AssistantQBitProducer.ARTIFACT_ID).equals(source.getSourceQBitName()))).toList());
}
}
@@ -100,7 +100,7 @@ public class AssistantToolCall extends QRecordEntity
@QField(isEditable = false)
private Integer httpStatus;
@QField(isEditable = false, maxLength = 500)
@QField(isEditable = false)
private String auditHref;
@QField(isEditable = false, possibleValueSourceName = AssistantPendingAction.TABLE_NAME)
@@ -145,7 +145,7 @@ public class RagService
{
PolicyService.ToolPolicy policy = context.policy();
UiApiToolbox.Context internal = new UiApiToolbox.Context(context.sessionUUID(), context.userTimezone(), context.gateway(),
new PolicyService.ToolPolicy(true, Set.of(), policy == null ? 50 : policy.maxRowsPerCall(), policy == null || policy.redactPii()), null);
new PolicyService.ToolPolicy(true, Set.of(), policy == null ? 50 : policy.maxRowsPerCall(), policy == null || policy.redactPii()), null, context.credentials(), context.turnId());
return (reader.execute(internal, "knowledge-read", UiApiToolbox.GET_RECORD, Map.of("table", table, "id", pk)));
}
@@ -0,0 +1,49 @@
/*
* 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.tools;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** Encode server-assembled filters in the format consumed by the Next UI.
*******************************************************************************/
public final class FilterLinkBuilder
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private FilterLinkBuilder()
{
}
/*******************************************************************************
** Callers validate model-supplied fields before constructing their criteria.
*******************************************************************************/
public static String href(String tableName, List<Map<String, Object>> criteria)
{
return (AiLinkBuilder.objectHref(tableName) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(Map.of("criteria", criteria, "booleanOperator", "AND")), StandardCharsets.UTF_8));
}
}
@@ -71,6 +71,16 @@ public class KnowledgeTool
** A source must be enabled and readable in the UI before offering the tool.
*******************************************************************************/
public Boolean offered(PolicyService.ToolPolicy policy, QSession session)
{
return (offered(policy, session, null));
}
/*******************************************************************************
** Tool discovery is a UI API call too: retain the turn when known.
*******************************************************************************/
public Boolean offered(PolicyService.ToolPolicy policy, QSession session, String turnId)
{
if(session == null || (policy != null && !policy.allows(NAME)))
{
@@ -90,7 +100,7 @@ public class KnowledgeTool
{
return (false);
}
return (runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), policy, null, null).stream()
return (runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), null, policy, null, null, turnId).stream()
.anyMatch(t -> tables.contains(t.table()) && t.uiRead() && t.mode().allowsRead()));
}
catch(QException e)
@@ -20,7 +20,6 @@ package com.kingsrook.qbits.assistant.tools;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.MathContext;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@@ -388,7 +387,7 @@ public class PresentResultsTool
values.forEach(PresentResultsTool::scalar);
output.add(map("fieldName", fieldName, "operator", operator, "values", values));
}
return (safeHref(AiLinkBuilder.objectHref(tableName) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(map("criteria", output, "booleanOperator", "AND")), StandardCharsets.UTF_8)));
return (safeHref(FilterLinkBuilder.href(tableName, output)));
}
@@ -372,6 +372,9 @@ public class UiApiToolbox
/*******************************************************************************
** Offer history only when every audit table it queries is available to the
** assistant channel, including inherited permission rules.
** RC.6 isDenied checks the all-operation wall, with no operation argument.
** Core #1193 must keep write-only denials separate from this read check;
** do not consult write-deny patterns here when offering this read tool.
*******************************************************************************/
private boolean historyAllowed()
{
@@ -1285,10 +1288,19 @@ public class UiApiToolbox
default -> "Deleted";
};
String label = table == null ? tableName : label(table);
QTableMetaData audit = qInstance.getTable(AuditsMetaDataProvider.TABLE_NAME_AUDIT);
if(id != null && audit != null && audit.getField(AuditsMetaDataProvider.FIELD_CLIENT_CHANNEL) != null)
{
String channel = "assistant" + (context.turnId() == null ? "" : "/" + context.turnId());
outcome.auditHref = FilterLinkBuilder.href(AuditsMetaDataProvider.TABLE_NAME_AUDIT, List.of(
Map.of("fieldName", "recordId", "operator", "EQUALS", "values", List.of(id)),
Map.of("fieldName", "auditTable.name", "operator", "EQUALS", "values", List.of(tableName)),
Map.of("fieldName", AuditsMetaDataProvider.FIELD_CLIENT_CHANNEL, "operator", "EQUALS", "values", List.of(channel))));
}
if(id != null && !DELETE_RECORD.equals(toolName))
{
String href = AiLinkBuilder.recordHref(tableName, id);
outcome.auditHref = href;
outcome.result.getRecordRefs().add(new AiRecordRef().withTableName(tableName).withTableLabel(label).withId(id).withLabel(label + " " + id).withHref(href));
}
outcome.result.withOutcome(AiToolOutcome.OK).withTableName(tableName).withRowCount(1).withSummary(verb + " " + label + (id == null ? "" : " " + id))
@@ -212,7 +212,7 @@ info:
description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\
\ and data access explanations"
title: "QQQ Middleware API extension: assistant"
version: "1.0.0"
version: "1.1.0"
openapi: "3.0.3"
paths:
/qqq/v1/assistant/config:
@@ -1038,6 +1038,12 @@ paths:
required: false
schema:
type: "string"
- description: "Attribute this request to an owned turn."
in: "query"
name: "turnId"
required: false
schema:
type: "integer"
responses:
400:
content:
@@ -0,0 +1,192 @@
/*
* 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.net.URI;
import java.net.URLDecoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qbits.assistant.tools.FilterLinkBuilder;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
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 com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Confirmed writes retain the human actor and link only to their channel's audits.
*******************************************************************************/
class AssistantAuditAttributionTest extends BaseAssistantTest
{
/*******************************************************************************
** Insert, update and delete all create usable audit links, including deletions.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {"create_record", "update_record", "delete_record"})
void confirmedWriteAttributesActorAndChannel(String tool) throws Exception
{
String base = UiApiTestServer.serve(qInstance, config);
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
var store = config.getRuntime().getStore();
store.insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName("page").withMode(DataAccessMode.READ_WRITE.name()));
config.getRuntime().getDataAccessService().invalidate();
AssistantTurn turn = store.insert(new AssistantTurn().withUserId(AssistantTestFixture.ADMIN));
AssistantPendingAction action = store.insert(new AssistantPendingAction().withUserId(AssistantTestFixture.ADMIN).withTurnId(turn.getId())
.withToolCallId("write-1").withToolName(tool).withTableName("page").withRecordId("12")
.withParamsJson(JsonUtils.toJson(tool.equals("delete_record") ? Map.of() : Map.of("title", "Assistant changed this page")))
.withStatus(AssistantPendingAction.STATUS_PENDING).withExpiresAt(Instant.now().plusSeconds(600)));
Map<String, Object> result = new AssistantApi(config.getRuntime(), QContext.getQSession()).confirmAction(action.getId());
assertThat(result.get("status")).isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
String channel = "assistant/" + turn.getId();
List<QRecord> audits = records("audit", new QQueryFilter().withCriteria("clientChannel", QCriteriaOperator.EQUALS, channel));
assertThat(audits).hasSize(1);
QRecord audit = audits.getFirst();
assertHumanActor(audit);
assertThat(records("auditTable", new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, audit.getValue("auditTableId"))).getFirst().getValueString("name")).isEqualTo("page");
List<AssistantToolCall> calls = store.query(AssistantToolCall.TABLE_NAME, new QQueryFilter().withCriteria("pendingActionId", QCriteriaOperator.EQUALS, action.getId()), AssistantToolCall.class);
assertThat(calls).hasSize(1);
assertThat(calls.getFirst().getClientChannel()).isEqualTo(channel);
String href = calls.getFirst().getAuditHref();
assertThat(href).startsWith("/app/audit?filter=");
QQueryFilter filter = JsonUtils.toObject(URLDecoder.decode(URI.create(href).getRawQuery().substring("filter=".length()), StandardCharsets.UTF_8), QQueryFilter.class);
assertThat(filter.getCriteria()).hasSize(3);
assertThat(filter.getCriteria()).anySatisfy(criterion ->
{
assertThat(criterion.getFieldName()).isEqualTo("clientChannel");
assertThat(criterion.getValues()).containsExactly(channel);
});
assertThat(filter.getCriteria()).anySatisfy(criterion ->
{
assertThat(criterion.getFieldName()).isEqualTo("auditTable.name");
assertThat(criterion.getValues()).containsExactly("page");
});
assertThat(filter.getCriteria()).anySatisfy(criterion ->
{
assertThat(criterion.getFieldName()).isEqualTo("recordId");
assertThat(criterion.getValues()).extracting(String::valueOf).containsExactly(audit.getValueString("recordId"));
});
UiApiResult linked = ui(base, "POST", "/table/audit/query", JsonUtils.toJson(Map.of("filter", filter)));
assertThat(linked.status()).isEqualTo(200);
assertThat((List<?>) linked.json().get("records")).hasSize(1);
String directMethod = tool.equals("create_record") ? "POST" : tool.equals("update_record") ? "PATCH" : "DELETE";
String directPath = "/table/page" + (tool.equals("create_record") ? "" : "/11");
UiApiResult direct = ui(base, directMethod, directPath, tool.equals("delete_record") ? null : "{\"title\":\"UI changed this page\"}");
assertThat(direct.status()).isBetween(200, 299);
List<QRecord> directAudits = records("audit", new QQueryFilter().withCriteria("clientChannel", QCriteriaOperator.EQUALS, "next-ui"));
assertThat(directAudits).hasSize(1).allSatisfy(this::assertHumanActor);
}
/*******************************************************************************
** Encoded filters for string keys must survive persistence without truncation.
*******************************************************************************/
@Test
void storesLongAuditLinks() throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
String href = FilterLinkBuilder.href("audit", List.of(
Map.of("fieldName", "recordId", "operator", "EQUALS", "values", List.of("opaque/record key ".repeat(12))),
Map.of("fieldName", "auditTable.name", "operator", "EQUALS", "values", List.of("customer_external_document")),
Map.of("fieldName", "clientChannel", "operator", "EQUALS", "values", List.of("assistant/42"))));
assertThat(href.length()).isGreaterThan(500);
assertThat(qInstance.getTable(AssistantToolCall.TABLE_NAME).getField("auditHref").getMaxLength()).isNull();
AssistantToolCall call = config.getRuntime().getStore().insert(new AssistantToolCall().withUserId(AssistantTestFixture.ADMIN).withAuditHref(href));
assertThat(config.getRuntime().getStore().query(AssistantToolCall.TABLE_NAME,
new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, call.getId()), AssistantToolCall.class).getFirst().getAuditHref()).isEqualTo(href);
}
/*******************************************************************************
** Explain access carries an owned turn, or the bare channel outside a turn.
*******************************************************************************/
@Test
void explainAccessUsesOwnedTurnOrBareChannel() throws Exception
{
UiApiTestServer.serve(qInstance, config);
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
AssistantTurn turn = config.getRuntime().getStore().insert(new AssistantTurn().withUserId(AssistantTestFixture.ADMIN));
AssistantApi api = new AssistantApi(config.getRuntime(), QContext.getQSession());
api.access("page", null, turn.getId());
assertThat(UiApiTestServer.SCHEDULED_REQUESTS.getLast().get("client")).isEqualTo("assistant/" + turn.getId());
api.access("page", null);
assertThat(UiApiTestServer.SCHEDULED_REQUESTS.getLast().get("client")).isEqualTo("assistant");
AssistantTurn other = config.getRuntime().getStore().insert(new AssistantTurn().withUserId(AssistantTestFixture.EDITOR));
assertThatThrownBy(() -> api.access("page", null, other.getId())).hasMessageContaining("not found");
}
/*******************************************************************************
** Read the fixture's audit evidence independently of the assistant's gateway.
*******************************************************************************/
private List<QRecord> records(String table, QQueryFilter filter) throws Exception
{
return (new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords());
}
/*******************************************************************************
** Core's audit user is the authenticated human's display name.
*******************************************************************************/
private void assertHumanActor(QRecord audit) throws Exception
{
assertThat(records("auditUser", new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, audit.getValue("auditUserId"))).getFirst().getValueString("name"))
.isEqualTo("Name of " + AssistantTestFixture.ADMIN);
}
/*******************************************************************************
** A direct Next UI request from the same administrator.
*******************************************************************************/
private UiApiResult ui(String base, String method, String path, String body) throws Exception
{
HttpRequest request = HttpRequest.newBuilder(URI.create(base + "/qqq/v1" + path)).header("Cookie", "sessionId=" + AssistantTestFixture.ADMIN)
.header("X-QQQ-Client", "next-ui").header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)).build();
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
return (new UiApiResult(response.statusCode(), response.body()));
}
}
@@ -0,0 +1,179 @@
/*
* 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.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The real UI API enforces the channel wall even for assistant administrators.
*******************************************************************************/
class AssistantChannelWallTest extends BaseAssistantTest
{
/*******************************************************************************
** Every produced object is explicitly denied, with core security defaults intact.
*******************************************************************************/
@Test
void registersAllObjectsIdempotently()
{
QTableMetaData renamed = new QTableMetaData().withName("custom_settings");
renamed.setSourceQBitName(AssistantQBitProducer.GROUP_ID + ":" + AssistantQBitProducer.ARTIFACT_ID);
qInstance.addTable(renamed);
qInstance.addReport(new QReportMetaData().withName("assistant_future_report").withPermissionRules(new QPermissionRules().withDeniedClients(Set.of("cli"))));
AssistantChannelWall.register(qInstance);
AssistantChannelWall.register(qInstance);
List<MetaDataWithPermissionRules> objects = objects();
assertThat(objects).isNotEmpty().allSatisfy(object -> assertThat(object.getPermissionRules().getDeniedClients()).contains("assistant"));
assertThat(qInstance.getReport("assistant_future_report").getPermissionRules().getDeniedClients()).contains("cli");
assertThat(ClientChannelsMetaData.of(qInstance).getDeniedPatterns().get("assistant"))
.contains("assistant_*", "authentik_*", "audit*", "*api_key*", "*credential*", "*secret*")
.doesNotHaveDuplicates();
assertThat(renamed.getPermissionRules().getDeniedClients()).contains("assistant");
AssistantChannelWall.validate(qInstance);
}
/*******************************************************************************
** Removing both layers from one object fails runtime startup, for every kind.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {"table", "process", "report", "widget"})
void startupRejectsAnUndeniedObject(String kind)
{
qInstance.addReport(new QReportMetaData().withName("assistant_report"));
AssistantChannelWall.register(qInstance);
MetaDataWithPermissionRules object = switch(kind)
{
case "table" -> qInstance.getTables().values().stream().filter(value -> value.getName().startsWith("assistant")).findFirst().orElseThrow();
case "process" -> qInstance.getProcesses().values().stream().filter(value -> value.getName().startsWith("assistant")).findFirst().orElseThrow();
case "report" -> qInstance.getReport("assistant_report");
default -> qInstance.getWidgets().values().stream().filter(value -> value.getName().startsWith("assistant")).findFirst().orElseThrow();
};
ClientChannelsMetaData.of(qInstance).getDeniedPatterns().get("assistant").remove("assistant_*");
object.getPermissionRules().withDeniedClients(Set.of());
assertThatThrownBy(() -> new AssistantRuntime(qInstance, config)).isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Assistant channel wall").hasMessageContaining(object.getName());
}
/*******************************************************************************
** Direct invocations bypass tool exposure checks, proving the wall lives in core.
*******************************************************************************/
@Test
void adminDeniedAcrossEveryTableAndProcess() throws Exception
{
String base = UiApiTestServer.serve(qInstance, config);
UiApiResult metadata = invoke(new UiApiCall("GET", "/metaData"));
assertThat(metadata.status()).isEqualTo(200);
UiApiResult uiMetadata = ui(base, "GET", "/metaData", null);
assertThat(uiMetadata.status()).isEqualTo(200);
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
UiApiToolbox.Outcome listed = config.getRuntime().getToolbox().execute(new UiApiToolbox.Context(AssistantTestFixture.ADMIN, null,
config.getRuntime().getUiApiGateway(), null, null, null, "42"), "list", UiApiToolbox.LIST_TABLES, Map.of());
for(String table : qInstance.getTables().keySet().stream().filter(name -> name.startsWith("assistant")).toList())
{
assertThat(((Map<?, ?>) metadata.json().get("tables")).containsKey(table)).isFalse();
assertThat(((Map<?, ?>) uiMetadata.json().get("tables")).containsKey(table)).isTrue();
assertThat(listed.getResult().getModelContent()).doesNotContain("\"" + table + "\"");
for(UiApiCall call : List.of(
new UiApiCall("POST", "/table/{tableName}/query").withJsonBody(Map.of()),
new UiApiCall("GET", "/table/{tableName}/{primaryKey}").withPathParam("primaryKey", "1"),
new UiApiCall("POST", "/table/{tableName}").withJsonBody(Map.of()),
new UiApiCall("PATCH", "/table/{tableName}/{primaryKey}").withPathParam("primaryKey", "1").withJsonBody(Map.of()),
new UiApiCall("DELETE", "/table/{tableName}/{primaryKey}").withPathParam("primaryKey", "1")))
{
assertThat(invoke(call.withPathParam("tableName", table)).status()).as("%s %s", table, call.operation()).isEqualTo(403);
}
assertThat(ui(base, "POST", "/table/" + table + "/query", "{}").status()).as(table).isEqualTo(200);
}
for(String process : qInstance.getProcesses().keySet().stream().filter(name -> name.startsWith("assistant")).toList())
{
assertThat(((Map<?, ?>) metadata.json().get("processes")).containsKey(process)).isFalse();
assertThat(((Map<?, ?>) uiMetadata.json().get("processes")).containsKey(process)).isTrue();
assertThat(invoke(new UiApiCall("POST", "/processes/{processName}/init").withPathParam("processName", process).withJsonBody(Map.of())).status()).as(process).isEqualTo(403);
assertThat(ui(base, "POST", "/processes/" + process + "/init", "{}").status()).as(process).isEqualTo(200);
}
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).anySatisfy(request -> assertThat(request.get("client")).isEqualTo("assistant/42"));
}
/*******************************************************************************
** Spoofed headers cannot turn an assistant call into a Next UI request.
*******************************************************************************/
private UiApiResult invoke(UiApiCall call) throws Exception
{
return (config.getRuntime().getUiApiGateway().invoke(call.withSessionUUID(AssistantTestFixture.ADMIN).withClientDetail("42").withHeader("X-QQQ-Client", "next-ui")));
}
/*******************************************************************************
** The same administrator's direct browser request.
*******************************************************************************/
private UiApiResult ui(String base, String method, String path, String body) throws Exception
{
HttpRequest request = HttpRequest.newBuilder(URI.create(base + "/qqq/v1" + path)).header("Cookie", "sessionId=" + AssistantTestFixture.ADMIN)
.header("X-QQQ-Client", "next-ui").header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)).build();
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
return (new UiApiResult(response.statusCode(), response.body()));
}
/*******************************************************************************
** Enumerate rather than maintaining an allowlist that could miss new objects.
*******************************************************************************/
private List<MetaDataWithPermissionRules> objects()
{
List<MetaDataWithPermissionRules> objects = new ArrayList<>();
objects.addAll(qInstance.getTables().values());
objects.addAll(qInstance.getProcesses().values());
objects.addAll(qInstance.getReports().values());
objects.addAll(qInstance.getWidgets().values());
return (objects.stream().filter(object -> object.getName().startsWith("assistant")).toList());
}
}
@@ -0,0 +1,120 @@
/*
* 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.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Compatibility seam for the future core audit-read opt-in.
*******************************************************************************/
public class RecordHistoryConfigTest
{
/*******************************************************************************
** RC.6 changes exactly one pattern, preserving every other channel rule.
*******************************************************************************/
@Test
void fallbackRemovesOnlyAuditPattern() throws Exception
{
ClientChannelsMetaData channels = ClientChannelsMetaData.defaults();
channels.withDeniedPattern("assistant", "audit_private*").withDeniedPattern("next-ui", "audit*");
channels.withDeniedObject("assistant", "auditDetail");
List<String> expected = new ArrayList<>(channels.getDeniedPatterns().get("assistant"));
expected.remove("audit*");
enable(channels);
enable(channels);
assertThat(channels.getDeniedPatterns().get("assistant")).containsExactlyElementsOf(expected)
.contains("worm_audit_*", "assistant_*", "audit_private*");
assertThat(channels.getDeniedPatterns().get("next-ui")).contains("audit*");
assertThat(channels.getDeniedObjects().get("assistant")).contains("auditDetail");
}
/*******************************************************************************
** Lookup uses the future method name and does not run the fallback afterward.
*******************************************************************************/
@Test
void invokesFutureCoreMethod() throws Exception
{
FutureChannels channels = new FutureChannels();
channels.withDeniedPattern("assistant", "audit*");
enable(channels);
assertThat(channels.called).isTrue();
assertThat(channels.getDeniedPatterns().get("assistant")).contains("audit*");
}
/*******************************************************************************
** A broken future core method must fail startup rather than weaken the wall.
*******************************************************************************/
@Test
void invocationFailureDoesNotFallBack()
{
FutureChannels channels = new FutureChannels();
channels.fail = true;
channels.withDeniedPattern("assistant", "audit*");
assertThatThrownBy(() -> enable(channels)).cause().isInstanceOf(IllegalStateException.class);
assertThat(channels.getDeniedPatterns().get("assistant")).contains("audit*");
}
/*******************************************************************************
** Exercise the one private compatibility helper without widening production API.
*******************************************************************************/
private void enable(ClientChannelsMetaData channels) throws Exception
{
Method method = AssistantQBitProducer.class.getDeclaredMethod("enableRecordHistory", ClientChannelsMetaData.class);
method.setAccessible(true);
method.invoke(new AssistantQBitProducer(), channels);
}
/*******************************************************************************
** A future core API stand-in; RC.6 metadata is not final.
*******************************************************************************/
public static class FutureChannels extends ClientChannelsMetaData
{
private Boolean called = false;
private Boolean fail = false;
/*******************************************************************************
** Record reflective dispatch; deliberately leave the pattern to detect fallback.
*******************************************************************************/
public ClientChannelsMetaData withAssistantAuditReads()
{
called = true;
if(fail)
{
throw (new IllegalStateException("test failure"));
}
return (this);
}
}
}
@@ -0,0 +1,265 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.email;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailQBitProducer;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiMessage;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Scheduled-result email through qbit-email, with the email qbit produced on a
** memory-backend instance, and the behavior when it is not.
*******************************************************************************/
class AssistantEmailTest
{
private static final String RUN_TABLE = "assistant_scheduled_run";
private QInstance instance;
private AssistantQBitConfig config;
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void setup() throws Exception
{
instance = newInstance(true);
config = new AssistantQBitConfig().withEmailExternalApproved(true);
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
}
/*******************************************************************************
** A memory-backend instance, with or without the email qbit.
*******************************************************************************/
private static QInstance newInstance(boolean withEmail) throws Exception
{
MemoryRecordStore.fullReset();
QContext.clear();
QInstance qInstance = new QInstance();
qInstance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
if(withEmail)
{
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName("memory").withPublicBaseUrl("https://app.example.com")
.withSecretResolver(name -> "QQQ_EMAIL_UNSUBSCRIBE_KEY".equals(name) ? "test-unsubscribe-key" : null)).produce(qInstance);
}
if(withEmail)
{
QContext.init(qInstance, new QSession().withPermissions(List.of("email.view", "email.send", "email.manage", "email.admin", "email.hasAccess")));
new InsertAction().execute(new InsertInput("email_provider").withRecords(List.of(new QRecord().withValue("name", "relay").withValue("label", "Relay")
.withValue("kind", "SMTP").withValue("host", "127.0.0.1").withValue("port", 25).withValue("security", "NONE")
.withValue("fromAddress", "sender@example.com").withValue("enabled", true).withValue("isDefault", true))));
}
return (qInstance);
}
/*******************************************************************************
**
*******************************************************************************/
private ScheduledResultNotice notice(int runId, String summary, DataClassification classification, boolean optIn)
{
return (new ScheduledResultNotice(RUN_TABLE, runId, "Weekly review", Instant.parse("2026-10-12T09:00:00Z"), summary, classification,
"owner@example.com", optIn, "https://app.example.com/app/" + RUN_TABLE + "/" + runId));
}
/*******************************************************************************
**
*******************************************************************************/
private List<EmailApiMessage> messages() throws Exception
{
return (EmailApi.findMessagesByIdempotencyKeyPrefix("assistant:scheduled:", 100));
}
/*******************************************************************************
** Inspect the test outbox through core as the authorized email administrator.
** The public email API deliberately excludes bodies and recipient addresses.
*******************************************************************************/
private QRecord storedMessage() throws Exception
{
return (new QueryAction().execute(new QueryInput("email_message")).getRecords().getFirst());
}
/*******************************************************************************
** A PUBLIC result queues one NOTIFICATION message from the template, with
** the idempotency key and the linked run; a repeat is a duplicate.
*******************************************************************************/
@Test
void publicResultQueuesTemplateMessage() throws Exception
{
assertThat(AssistantEmail.isAvailable(instance)).isTrue();
assertThat(AssistantEmail.sendScheduledResult(instance, config, notice(7, "Three items need attention.", null, true))).isEqualTo(ScheduledEmailStatus.QUEUED);
List<EmailApiMessage> messages = messages();
assertThat(messages).hasSize(1);
EmailApiMessage metadata = messages.getFirst();
assertThat(metadata.category()).isEqualTo(EmailApiCategory.NOTIFICATION);
assertThat(metadata.idempotencyKey()).isEqualTo("assistant:scheduled:7");
assertThat(metadata.linkedTableName()).isEqualTo(RUN_TABLE);
assertThat(metadata.linkedRecordId()).isEqualTo("7");
assertThat(metadata.templateName()).isEqualTo("assistant_scheduled_result");
QRecord message = storedMessage();
assertThat(message.getValueString("toAddresses")).isEqualTo("owner@example.com");
assertThat(message.getValueString("senderQbit")).isEqualTo("assistant");
assertThat(message.getValueString("subject")).contains("Weekly review");
assertThat(message.getValueString("htmlBody")).contains("Three items need attention.").contains("https://app.example.com/app/assistant_scheduled_run/7");
QRecord template = new QueryAction().execute(new QueryInput("email_template")).getRecords().stream().filter(t -> "assistant_scheduled_result".equals(t.getValueString("name"))).findFirst().orElseThrow();
assertThat(template.getValueString("category")).isEqualTo("NOTIFICATION");
assertThat(template.getValueString("ownerQbit")).isEqualTo("assistant");
assertThat(message.getValueInteger("templateId")).isEqualTo(template.getValueInteger("id"));
assertThat(AssistantEmail.sendScheduledResult(instance, config, notice(7, "Three items need attention.", null, true))).isEqualTo(ScheduledEmailStatus.DUPLICATE);
assertThat(messages()).hasSize(1);
}
/*******************************************************************************
** Above PUBLIC, only the link goes out, with the secure-message pattern;
** the summary and task name are not in the message.
*******************************************************************************/
@Test
void classifiedResultSendsLinkOnly() throws Exception
{
assertThat(AssistantEmail.sendScheduledResult(instance, config, notice(8, "Patient Jane Doe has diabetes", DataClassification.PHI, true))).isEqualTo(ScheduledEmailStatus.QUEUED);
EmailApiMessage metadata = messages().getFirst();
assertThat(metadata.category()).isEqualTo(EmailApiCategory.SECURITY);
assertThat(metadata.idempotencyKey()).isEqualTo("assistant:scheduled:8");
assertThat(metadata.linkedRecordId()).isEqualTo("8");
assertThat(metadata.templateName()).isEqualTo("email_secure_message");
QRecord message = storedMessage();
assertThat(message.getValueString("htmlBody")).contains("https://app.example.com/app/assistant_scheduled_run/8").doesNotContain("Jane Doe").doesNotContain("Weekly review");
assertThat(message.getValueString("textBody")).doesNotContain("Jane Doe");
assertThat(message.getValueString("subject")).doesNotContain("Weekly review");
}
/*******************************************************************************
** Each gate on its own: opt-in, the external approval, a recipient.
*******************************************************************************/
@Test
void gates() throws Exception
{
assertThat(AssistantEmail.sendScheduledResult(instance, config, notice(1, "x", null, false))).isEqualTo(ScheduledEmailStatus.NOT_REQUESTED);
assertThat(AssistantEmail.sendScheduledResult(instance, config, null)).isEqualTo(ScheduledEmailStatus.NOT_REQUESTED);
assertThat(AssistantEmail.sendScheduledResult(instance, new AssistantQBitConfig(), notice(2, "x", null, true))).isEqualTo(ScheduledEmailStatus.NOT_APPROVED);
ScheduledResultNotice noAddress = new ScheduledResultNotice(RUN_TABLE, 3, "t", Instant.now(), "x", null, "user-17", true, "https://app.example.com/app/x/3");
assertThat(AssistantEmail.sendScheduledResult(instance, config, noAddress)).isEqualTo(ScheduledEmailStatus.NO_RECIPIENT);
assertThat(AssistantEmail.sendScheduledResult(instance, config, new ScheduledResultNotice(RUN_TABLE, 3, "t", Instant.now(), "x", null, null, true, "https://app.example.com/a"))).isEqualTo(ScheduledEmailStatus.NO_RECIPIENT);
assertThat(messages()).isEmpty();
config.withEmailRecipientResolver(id -> "user-17".equals(id) ? " user17@example.com " : null);
assertThat(AssistantEmail.sendScheduledResult(instance, config, noAddress)).isEqualTo(ScheduledEmailStatus.QUEUED);
assertThat(storedMessage().getValueString("toAddresses")).isEqualTo("user17@example.com");
}
/*******************************************************************************
** The outbox refusing the message (here a relative link) is reported, not thrown.
*******************************************************************************/
@Test
void refusedIsReported() throws Exception
{
ScheduledResultNotice relative = new ScheduledResultNotice(RUN_TABLE, 4, "t", Instant.now(), "x", null, "owner@example.com", true, "/app/x/4");
assertThat(AssistantEmail.sendScheduledResult(instance, config, relative)).isEqualTo(ScheduledEmailStatus.REFUSED);
assertThat(messages()).isEmpty();
}
/*******************************************************************************
** The summary is one line and cut short.
*******************************************************************************/
@Test
void summaryIsShort() throws Exception
{
assertThat(AssistantEmailSender.summary(null)).isNull();
assertThat(AssistantEmailSender.summary("a\n\n b")).isEqualTo("a b");
assertThat(AssistantEmailSender.summary("x".repeat(1000))).hasSize(280).endsWith("...");
AssistantEmail.sendScheduledResult(instance, config, notice(5, "y".repeat(2000), null, true));
assertThat(storedMessage().getValueString("htmlBody")).doesNotContain("y".repeat(281));
}
/*******************************************************************************
** Without the email qbit in the instance nothing is queued and nothing breaks.
*******************************************************************************/
@Test
void withoutEmailQbit() throws Exception
{
QInstance bare = newInstance(false);
assertThat(AssistantEmail.isAvailable(bare)).isFalse();
assertThat(AssistantEmail.isAvailable(null)).isFalse();
assertThat(AssistantEmail.sendScheduledResult(bare, config, notice(6, "x", null, true))).isEqualTo(ScheduledEmailStatus.NOT_AVAILABLE);
}
}
@@ -18,7 +18,6 @@ package com.kingsrook.qbits.assistant.engine;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
@@ -27,16 +26,12 @@ import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qbits.assistant.AssistantModelRouting;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.provider.CompletableStreamHandle;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.context.QContext;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
@@ -203,27 +198,6 @@ class EngineComponentsTest extends BaseAssistantTest
/*******************************************************************************
**
*******************************************************************************/
@Test
void testPricing()
{
ModelPricing sonnet = ModelPricing.defaults().get(AssistantModelRouting.SONNET_5_5);
ModelUsage usage = new ModelUsage().withInputTokens(6000L).withOutputTokens(800L).withCacheReadTokens(12_000L).withCacheWriteTokens(1000L).withCacheWrite1hTokens(400L);
//////////////////////////////////////////////////////////////////////////////////
// 6000*2 + 800*10 + 12000*0.2 + 600*2.5 + 400*4 = 12000+8000+2400+1500+1600 //
//////////////////////////////////////////////////////////////////////////////////
assertThat(sonnet.costMicros(usage)).isEqualTo(25_500);
assertThat(sonnet.costMicros(null)).isZero();
assertThat(new ModelPricing().costMicros(usage)).isZero();
assertThat(ModelPricing.defaults().get(AssistantModelRouting.OPUS_5_5).getCacheReadUsdPerMTok()).isEqualTo(0.2);
assertThat(usage.billableTokens()).isEqualTo(7800);
assertThat(new ModelUsage().add(usage).add(null).getCacheReadTokens()).isEqualTo(12_000);
}
/*******************************************************************************
**
*******************************************************************************/
@@ -268,37 +242,6 @@ class EngineComponentsTest extends BaseAssistantTest
/*******************************************************************************
**
*******************************************************************************/
@Test
void testStreamHandle() throws Exception
{
CompletableStreamHandle handle = new CompletableStreamHandle();
handle.complete(new ModelResponse().withStopReason("end_turn"));
assertThat(handle.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo("end_turn");
CompletableStreamHandle failing = new CompletableStreamHandle();
failing.fail(new ModelProviderException(ModelProviderException.Kind.RATE_LIMITED, 429, "slow down"));
assertThatThrownBy(() -> failing.await(Duration.ofSeconds(1))).isInstanceOfSatisfying(ModelProviderException.class, e ->
{
assertThat(e.isRetryable()).isTrue();
assertThat(e.getHttpStatus()).isEqualTo(429);
});
CompletableStreamHandle cancelled = new CompletableStreamHandle();
cancelled.setOnCancel(() ->
{
throw (new IllegalStateException("closing"));
});
cancelled.cancel();
cancelled.cancel();
assertThat(cancelled.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo(ModelResponse.STOP_CANCELLED);
assertThat(new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "x").isRetryable()).isFalse();
}
/*******************************************************************************
**
*******************************************************************************/
@@ -41,8 +41,10 @@ import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
@@ -54,6 +56,7 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInpu
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@@ -278,6 +281,25 @@ class RagTest extends BaseAssistantTest
/*******************************************************************************
** Discovery and RAG record reads preserve turn details and request credentials.
*******************************************************************************/
@Test
void attributesKnowledgeRequestsToTurn()
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.EDITOR, config));
UiApiTestServer.serve(qInstance, config);
UiApiTestServer.SCHEDULED_REQUESTS.clear();
config.getRuntime().getContextBuilder().build(QContext.getQSession(), "model", null, List.of(), "question", Map.of(), PolicyService.ToolPolicy.defaults(), null, "42");
UiApiToolbox.Context context = new UiApiToolbox.Context("invalid-session", null, config.getRuntime().getUiApiGateway(),
PolicyService.ToolPolicy.defaults(), null, UiApiCredentials.ofSessionUUID(AssistantTestFixture.EDITOR), "42");
assertThat(config.getRuntime().getRagService().read(context, "blog_post", "1").getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).hasSizeGreaterThanOrEqualTo(2).allSatisfy(request ->
assertThat(request.get("client")).isEqualTo("assistant/42"));
}
/*******************************************************************************
** UI read permission is checked again for every vector hit, with no identifiers
** or snippets leaking from denied records. Exposure is reused from get_record.
@@ -145,7 +145,7 @@ public class AssistantTestFixture
.withField(new QFieldMetaData("diagnosis", QFieldType.STRING)));
}
new AuditsMetaDataProvider().defineAll(qInstance, BACKEND, null);
new AuditsMetaDataProvider().withClientChannelField(true).defineAll(qInstance, BACKEND, null);
for(String auditTable : List.of(AuditsMetaDataProvider.TABLE_NAME_AUDIT, AuditsMetaDataProvider.TABLE_NAME_AUDIT_DETAIL, AuditsMetaDataProvider.TABLE_NAME_AUDIT_TABLE, AuditsMetaDataProvider.TABLE_NAME_AUDIT_USER))
{
qInstance.getTable(auditTable).setPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS));
@@ -66,8 +66,16 @@ class UiApiToolsSecurityTest extends BaseAssistantTest
** The default channel wall hides history, even when the user can read audits.
*******************************************************************************/
@Test
void historyHiddenByDefault()
void historyHiddenByDefault() throws Exception
{
assertThat(config.getRecordHistory()).isFalse();
UiApiTestServer.serve(qInstance, config);
for(String table : List.of("auditTable", "audit", "auditDetail"))
{
UiApiResult result = config.getRuntime().getUiApiGateway().invoke(new UiApiCall("POST", "/table/{tableName}/query")
.withPathParam("tableName", table).withJsonBody(Map.of()).withSessionUUID(AssistantTestFixture.EDITOR));
assertThat(result.status()).isEqualTo(403);
}
assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY);
assertThat(tool(AssistantTestFixture.EDITOR, null, UiApiToolbox.RECORD_HISTORY, input("table", "page", "id", "12")).getHttpStatus()).isEqualTo(403);
}
@@ -78,9 +86,10 @@ class UiApiToolsSecurityTest extends BaseAssistantTest
** A host opt-in offers history and reads it through the real UI API.
*******************************************************************************/
@Test
void historyOfferedAndReadableAfterHostOptIn()
void historyOfferedAndReadableAfterHostOptIn() throws Exception
{
ClientChannelsMetaData.ofOrWithNew(qInstance).getDeniedPatterns().get("assistant").remove("audit*");
config.getRuntime().close();
qInstance = AssistantTestFixture.build(config.withRecordHistory(true));
assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).contains(UiApiToolbox.RECORD_HISTORY);
UiApiToolbox.Outcome outcome = tool(AssistantTestFixture.EDITOR, null, UiApiToolbox.RECORD_HISTORY, input("table", "page", "id", "12", "since", "THIS_WEEK"));
assertThat(outcome.getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
@@ -95,10 +104,11 @@ class UiApiToolsSecurityTest extends BaseAssistantTest
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = { AuditsMetaDataProvider.TABLE_NAME_AUDIT_TABLE, AuditsMetaDataProvider.TABLE_NAME_AUDIT, AuditsMetaDataProvider.TABLE_NAME_AUDIT_DETAIL })
void historyRespectsEachAuditTableDenial(String name)
void historyRespectsEachAuditTableDenial(String name) throws Exception
{
config.getRuntime().close();
qInstance = AssistantTestFixture.build(config.withRecordHistory(true));
ClientChannelsMetaData channels = ClientChannelsMetaData.ofOrWithNew(qInstance);
channels.getDeniedPatterns().get("assistant").remove("audit*");
channels.withDeniedObject("assistant", name);
assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY);
channels.getDeniedObjects().get("assistant").remove(name);
@@ -321,7 +331,7 @@ class UiApiToolsSecurityTest extends BaseAssistantTest
assertThat(log).hasSize(1);
assertThat(log.get(0).getUiApiOperation()).isEqualTo("POST /table/{tableName}");
assertThat(log.get(0).getHttpStatus()).isBetween(200, 299);
assertThat(log.get(0).getAuditHref()).startsWith("/app/blog_post/");
assertThat(log.get(0).getAuditHref()).startsWith("/app/audit?filter=");
QContext.clear();
}