Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 8dd92de98e Merge develop into feature/write-mode; preferences through core Preferences
- Resolve against develop (#19 spec'd endpoints on UiApiInvoker, P-a..P-g,
  presentation, bulk-load, X1/Y1): write-mode gating now also covers
  scheduled runs (per-task approval AND policy) and bulk-load writes.
- Drop the bespoke /assistant/preferences endpoints; the assistant's
  per-user settings are a core Preferences section "assistant"
  (/qqq/v1/preferences/assistant) with AssistantPreferencesProvider over
  assistant_user_preference, policy-validated, denied to the assistant
  channel. /assistant/config still returns the effective defaults.
- Requires QQQ RC.8 (core Preferences, QRun-IO/qqq#1162).

Closes #29.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:33:55 -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.maesandClaude Opus 5.5 f7faf0a8c0 docs: mark HeaderedUiApiInvoker STAND-IN(core=QRun-IO/qqq#1190, removeBy=RC.8)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:53:43 -05:00
james.maesandClaude Opus 5.5 8fab9a856e Merge develop (P-g scheduled tasks, provider-api) into #19
Scheduled runs go through the invoker as assistant/scheduled/<taskId>;
the designated-user manageSession login stays a plain UI-client login.
get_record_history is offered only when the host opts the audit tables
in (core's default wall denies audit* to the assistant). Reserved-header
filtering in the stand-in header invoker is tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:45:15 -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.maesandClaude Opus 5.5 6e7438a2da Merge develop into feature/spec-endpoints-core-invoker (QQQ RC.6)
- CoreUiApiGateway on UiApiInvoker for every tool (P tools, bulk-load,
  presentation), client channel assistant/<turnId>; form params mapped to
  withFormParam; LoopbackUiApiGateway deleted.
- Extra headers (X-QQQ-AI-Route) through HeaderedUiApiInvoker, a stand-in
  until core UiApiRequest has withHeader.
- Core DataClassification replaces the qbit stand-in; the default
  classification source merges core metadata with the config map.
- Bulk-load confirm dispatch kept on the spec'd confirm path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:31:39 -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 a9ff632750 feat: write mode (Read-only by default), user preferences, auto-confirm rules
Owner (2026-10-13): the assistant can go into write mode by user choice,
and stays only a UI client of the /qqq/v1 API.

- writeMode per conversation; the write tools exist only in Write mode,
  and a write tool called in Read-only mode is refused before any request.
- Policy: writeModeAllowed, writeModePermission (role),
  autoConfirmAllowed; data access autoConfirmOk per table.
- assistant_user_preference: default mode, auto-confirm of low-risk
  writes (single-record insert/update on auto-confirm-OK tables; never
  deletes); GET/PATCH /assistant/preferences as spec'd operations.
- The denial matrix covers the write tools in both modes; WriteModeTest
  covers the tool list, policy, preferences and auto-confirm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:27:08 -05:00
james.maesandClaude Opus 5.5 292277908b build: require QQQ 4.1.0-RC.6 (UI API extension point and invoker, qqq#1128)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:09:38 -05:00
james.maes 77bd8df7a2 Merge develop into feature/spec-endpoints-core-invoker 2026-10-13 16:07:23 -05:00
james.maesandClaude Opus 5.5 f6edea4b7b feat!: spec'd /qqq/v1 assistant endpoints; tools on core's UiApiInvoker
Phase (d). Needs QQQ with QRun-IO/qqq#1128 (extension point, invoker,
credentials forwarding).

- AssistantSpecs: the "assistant" UI API extension (1.0.0, established
  prefix /assistant): config, conversations, turns (text/event-stream),
  events, cancel, feedback, actions (get/confirm/reject) and access, with
  the same URLs, bodies and statuses. The raw routes are removed; the
  route provider only learns the local port.
- CoreUiApiGateway over UiApiInvoker replaces the loopback stand-in. The
  caller's credentials (UiApiCredentials.fromContext) flow from the spec
  to the tools; the assistant's own operations are never invocable.
- The OpenAPI fragment is committed and contract-tested with UiApiContract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:44:34 -05:00
86 changed files with 7663 additions and 924 deletions

No files matched your search

+46 -1
View File
@@ -1,5 +1,39 @@
# Changelog
### Changed: W1 core Preferences (F13 / #29)
- Replace the bespoke assistant preferences operations with the core `assistant`
section at `/qqq/v1/preferences/assistant`, backed by the existing visible table.
User ownership, partial updates, policy validation, read-only explanations and
explicit assistant-channel denial apply to the section.
- Keep effective defaults on `/assistant/config`; preserve Read-only/Write tools,
low-risk auto-confirm, develop's core invoker, provider routing and audit context.
- Require per-task approval and Write-mode policy for scheduled writes; gate bulk
write definitions and calls by mode. Preserve default-off audit history on core develop.
- Target QQQ 4.1.0-RC.8; use the local PREFS snapshot override until publication.
## 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
- 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.
@@ -66,7 +100,7 @@
- Add HIPAA mode, eligible-provider enforcement, AES-256-GCM conversation storage,
provider diagnostic redaction, and independent PHI conversation retention.
- Add owner-locked, admin read-only data routing audits and HIPAA status on the home.
- Keep QQQ RC.4 compatibility with a classification stand-in and core #1142 adapter seam.
- Classification levels are core's `DataClassification` (the qbit stand-in is removed); the default source reads core AI metadata merged with configuration.
All notable changes to this project are documented here. The format follows Keep a Changelog, and versions follow semantic versioning.
@@ -84,6 +118,17 @@ Phase 0/1 of the QQQ Assistant: read-only Q&A and navigation. Requires QQQ 4.1 w
- the AI tool kernel (`qqq-backend-core` `actions.ai`, QRun-IO/qqq#1078)
- the developer-panel contract (QRun-IO/qqq#1076).
### Added (0.1.0-RC.7): write mode (owner decision 2026-10-13)
- Per-conversation **Read-only** (default) or **Write** mode: `writeMode` on conversations (PATCH, create), the write tools exist only in Write mode, and a write tool called in Read-only mode is refused before any request (`mode:READ_ONLY`).
- Policy: `writeModeAllowed`, `writeModePermission` (role), `autoConfirmAllowed`. Data access: `autoConfirmOk` per table.
- `assistant_user_preference` (own rows): default mode of new conversations, auto-confirm of low-risk writes (single-record insert/update on auto-confirm-OK tables; never deletes). Core `GET`/`PATCH /qqq/v1/preferences/assistant`; `/config` reports `features.writeMode`, `features.autoConfirm` and `preferences`.
- The denial matrix covers the write tools in both modes.
### Changed (0.1.0-RC.7, needs QQQ with QRun-IO/qqq#1128)
- The assistant's endpoints are spec'd `/qqq/v1` operations (UI API extension `assistant` 1.0.0, prefix `/assistant`): same URLs, bodies and statuses, now in the served OpenAPI; turns and events are `text/event-stream`. The raw routes are gone.
- Tools call core's `UiApiInvoker` with the caller's own request credentials (`UiApiCredentials`); the loopback stand-in is removed. The assistant's own operations are never invocable by its tools.
- The OpenAPI fragment is committed (`openapi/qqq-v1/assistant.yaml`) and contract-tested.
### Changed (0.1.0-RC.6): tools go through the UI API (owner decision 2026-10-13)
- **Every data tool runs a spec'd `/qqq/v1` operation with the user's own session** (`UiApiGateway`, loopback HTTP with the `sessionUUID` cookie): the same authentication, permissions, record locks, validation, audit and errors as the UI. A user without a permission gets the identical denial through the assistant. `navigate` and `search_help` (no records) stay on the core kernel. `LoopbackUiApiGateway` is a stand-in for core's `UiApiInvoker` (QRun-IO/qqq#1128).
- Tools: `list_tables`, `describe_table`, `query_records`, `count_records`, `get_record`, `search_records`, `render_widget`, `get_record_history`, and new **`create_record`, `update_record`, `delete_record`**.
+1
View File
@@ -57,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).
+110 -10
View File
@@ -1,6 +1,6 @@
# QBit Assistant
An in-app AI assistant for any QQQ application. The user asks a question in a chat panel in the Next UI. The assistant reads the application's data **with exactly that user's permissions**, answers with numbers taken from the data, and links to the records and lists it used. This first version only reads data and opens screens. It cannot change anything.
An in-app AI assistant for any QQQ application. The user asks a question in a chat panel in the Next UI. The assistant reads the application's data **with exactly that user's permissions**, answers with numbers taken from the data, and links to the records and lists it used. Conversations start Read-only. Users can select Write mode when policy permits it; changes normally wait for their confirmation.
Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`.
@@ -29,6 +29,61 @@ 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.
## Write mode and personal preferences
The **Assistant** section on the core Preferences page controls **Start conversations
in Write mode** and **Auto-confirm low-risk changes**. It is available to users
with `assistant.hasAccess`, without requiring `assistant.admin` or permission on
the backing table. The standard visible `assistant_user_preference` table remains
available to administrators.
Clients read and partially update settings through `GET` / `PATCH
/qqq/v1/preferences/assistant`, with a PATCH body such as
`{"values":{"defaultWriteMode":true}}`. There is no user-id parameter. The former
`/qqq/v1/assistant/preferences` operations are removed. The panel reads effective
defaults from `/qqq/v1/assistant/config` under `preferences`; conversation responses
include `writeMode`. Policy can make the effective default Read-only even when
the saved preference is Write.
Write tools, including bulk profile saves and bulk loads, are absent in Read-only
mode. Write mode requires policy permission and READ_WRITE data access. Low-risk
auto-confirm is limited to single-record inserts/updates on `auto_confirm_ok`
tables, with both policy and user opt-in. Its preference is read-only with a reason
when policy disallows it. Scheduled tasks additionally require their own approval
and always wait for confirmation. The assistant client channel cannot access the
preferences section.
## Host audit attribution (X1/Y1)
Use QQQ **4.1.0-RC.8 or later** (core Preferences, qqq#1162). Until RC.8 is published, build with `-Dqqq.snapshot.version=4.1.0-PREFS-SNAPSHOT`. 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**:
@@ -206,6 +261,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.
@@ -321,7 +378,7 @@ Table names follow the QRun-IO naming rule: lowercase snake_case with the `assis
```bash
./mvnw verify # against the committed QQQ version
./mvnw verify -Dqqq.snapshot.version=4.1.0-ASSISTDEV-SNAPSHOT # against a local QQQ build with #1078 + #1076
./mvnw verify -Dqqq.snapshot.version=4.1.0-PREFS-SNAPSHOT # local core Preferences build until RC.8 is published
```
Two test groups run in every build and fail it on any error:
@@ -464,12 +521,30 @@ 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.1**. 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
On QQQ 4.1.0-RC.5, classify application tables/fields in the host configuration:
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):
```java
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
AssistantQBitConfig config = new AssistantQBitConfig()
.withDataClassification("patient", DataClassification.PHI)
@@ -487,8 +562,8 @@ The existing server-side `withSecretResolver` hook also resolves this reference.
Startup validates the key. HIPAA mode defaults off; encryption can also be enabled
with the key reference while HIPAA mode is off.
The qbit `DataClassification` enum is a stand-in for core QRun-IO/qqq#1142, with
PUBLIC < CONFIDENTIAL < PII < PHI. Unconfigured tables default to PUBLIC; a field
The levels are core's `DataClassification` (PUBLIC < CONFIDENTIAL < PII < PHI); the
qbit's former stand-in enum is gone. Unconfigured tables default to PUBLIC; a field
falls back to its table unless explicitly overridden. A field subset uses its
selected fields; unrestricted reads and record page contexts use the highest
classification across the table and its fields. Returned columns and record-label
@@ -497,10 +572,9 @@ explicitly if the host considers it safe for that subset. Cross-table results wi
tables use the highest classification anywhere. Metadata-only tools remain PUBLIC.
The assistant's own tables remain denied to tools.
Hosts can supply `withClassificationSource(ClassificationSource)` instead. When
upgrading to an RC containing core #1142, implement that interface with
`AiDataClassifications`/`AiTableMetaData`/`AiFieldMetaData` (map enum names), then
replace the stand-in once the qbit's minimum core version includes it.
The default `ClassificationSource` reads core's metadata (`AiDataClassifications`,
`AiTableMetaData`, `AiFieldMetaData`) merged with the configured map; configuration
wins when both are set. Hosts can still supply `withClassificationSource(ClassificationSource)`.
PHI is sent only to a persisted provider row explicitly marked `hipaaEligible`.
Every model dispatch rechecks eligibility, including calls after tools and strict
@@ -620,3 +694,29 @@ skills can extend the built-in presentation instructions. See the
The image-breakdown golden task also runs in the existing opt-in live provider suite:
`./mvnw verify -Dassistant.live=true -Dtest.excludedGroups= -Dgroups=live -Djacoco.skip=true -pl qbit-assistant-provider-anthropic -am`
(with `ANTHROPIC_API_KEY` set). Live calls are excluded from normal verification.
### Record history and the assistant channel
Core's default `ClientChannelsMetaData` denies `audit*` to the `assistant`
channel. Accordingly, `get_record_history` is offered only when all three audit
tables it queries (`auditTable`, `audit`, and `auditDetail`) exist and are not
channel-denied, including their effective permission rules. A host that intends
to expose record history can opt in during instance configuration:
```java
new AssistantQBitConfig()
.withRecordHistory(true);
```
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.
+49 -2
View File
@@ -1,5 +1,52 @@
# Upgrading qbit-assistant
## W1 preferences migration (F13 / #29)
Requires QQQ **4.1.0-RC.8** for core Preferences (qqq#1162). Until publication,
verify with `.\mvnw.cmd -q verify "-Dqqq.snapshot.version=4.1.0-PREFS-SNAPSHOT"`.
The root default is RC.8; do not commit the local snapshot override.
Replace `GET` / `PATCH /qqq/v1/assistant/preferences` with
`GET` / `PATCH /qqq/v1/preferences/assistant`. PATCH now wraps changed fields in
`values`, for example `{"values":{"defaultWriteMode":true}}`; GET returns the
core section metadata and `values`. Do not send a user id. Core returns 400 for
invalid or policy-read-only values and 404 for a hidden section. These removed
operations were on the unmerged W1 branch; the fragment and baseline now describe
the core integration. The panel's initial toggle continues to use effective
`preferences.defaultWriteMode` from `/qqq/v1/assistant/config`.
Existing `assistant_user_preference` rows need no data migration. Hosts installing
W1 for the first time must apply its DDL in `docs/schema-postgres.sql` (conversation
`write_mode`, policy fields, data-access `auto_confirm_ok`, and the preference
table). Keep it as a standard visible admin table. The Preferences page is scoped
by `assistant.hasAccess`, not the admin-only Assistant app. The provider explicitly
refuses the `assistant` channel because core's section channel checks only apply
to app-bound sections.
Scheduled writes now require task approval plus global/role Write-mode policy.
Bulk write tools obey the same conversation mode. Record history stays default-off
when running on core versions whose channel defaults allow audit reads.
## 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
@@ -42,8 +89,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
+57 -16
View File
@@ -143,7 +143,7 @@ Owner: *"the agent should have to go through the same API that the UIUX does. th
- A request to the server's own port runs the identical pipeline by construction. It costs one local hop, which is milliseconds next to a model call.
This matches core's design (`docs/UI-API-EXTENSIONS.md` §5, `UiApiInvoker`, QRun-IO/qqq#1128).
- `LoopbackUiApiGateway` is the stand-in until a QQQ release has the invoker. Then an adapter over `UiApiInvoker.forServer(server)` replaces it (or a host injects one with `withUiApiGateway`), and nothing else changes.
- `CoreUiApiGateway` adapts core's `UiApiInvoker` (the loopback stand-in is deleted). It labels calls with the client channel `assistant`, plus the turn id as detail (`assistant/<turnId>`). Form params go to the invoker's `withFormParam`; extra headers (the bulk-load tools' `X-QQQ-AI-Route`) go through `HeaderedUiApiInvoker`, a small subclass in core's package because core's request has no `withHeader` yet. A host can still inject its own gateway with `withUiApiGateway`.
- The base URL is `uiApiBaseUrl` if configured. Otherwise it is `127.0.0.1` plus the local port the first assistant request arrived on, so in a multi-replica deployment the call stays on the replica that holds the session's process state.
### The tools
@@ -228,6 +228,18 @@ The golden and red-team evals now run through the UI API too.
- Choosing *another* user would need that user's permissions without their session. QQQ core has no API to evaluate another user's permissions, and the assistant never builds or borrows a session. That part waits for a core API, so the report is always computed with the requester's own session.
- **Recent denials:** the home's "Recent denials" card lists the newest tool calls the UI API, data access or the policy refused, with who, the table, the operation and the HTTP status, or the assistant setting that refused it.
## Spec'd assistant endpoints and the core invoker (phase d, QQQ 4.1.0-RC.6)
- **The endpoints are spec'd `/qqq/v1` operations.** `api/specs/AssistantSpecs` registers the UI API extension `assistant` (semver 1.0.0, established prefix `/assistant`) through `MiddlewareSpecExtensionsMetaData`.
- The URLs, bodies and statuses stay the same (201 create, 202 cancel, 204 delete and feedback, and the assistant's `{"error","code"}` errors), so Next needs no change.
- They appear in the served `/qqq/v1/openapi.yaml`. The turn and events streams are declared as `text/event-stream`, with `AssistantTurnEventV1` describing the events.
- The fragment is committed as `qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml`. `AssistantSpecsContractTest` checks it against the code, and the release baseline in test resources, with core's `UiApiContract`.
- **No raw routes.** `AssistantRouteProvider` keeps only a before-handler that learns the server's local port from the first `/qqq/v1` request.
- **Tools call core's `UiApiInvoker`** (`CoreUiApiGateway`), and the loopback stand-in is removed.
- The request's credentials (`UiApiCredentials.fromContext`: session cookies, Authorization, API key, exactly as they came in) are captured by the spec that starts the turn, confirms an action or explains access. They are passed to the tools, so OAuth2, table auth and API-key sessions all work. They are never logged.
- The gateway also refuses the assistant's own operations, so a turn can never start a turn.
- QQQ 4.1.0-RC.6 contains #1128; W1 now targets RC.8 for core Preferences (see W as built).
## Owner decisions (2026-10-13), continued: write mode, the hard wall, audit attribution, providers
These were added on the same day. All recommendations below are accepted; we adjust once they can be seen working.
@@ -252,6 +264,34 @@ Owner: *"the assistant should be able to go into 'write mode' too - with a user
- The ArchUnit rule (no core actions from the tool code) and the denial matrix stay.
- The matrix is extended to every write operation, in both modes.
### W as built (F13 / #29)
- W1 is integrated with develop's spec'd operations, `CoreUiApiGateway`, client
attribution, provider routing, presentation and bulk-load tools. Conversation
`writeMode` and all policy/data-access gates remain in force.
- `UserPreferencesMetaData` registers section `assistant` (Assistant, smart_toy),
scoped to `assistant.hasAccess`. It has no app scope because the Assistant app
requires admin access. `AssistantPreferencesProvider` implements core's SPI,
loads and updates `assistant_user_preference` through `AssistantStore`, and
obtains ownership only from the request context. The visible table remains the
administrative interface. No new preference storage is introduced.
- Core serves `GET` / `PATCH /qqq/v1/preferences/assistant`; the bespoke
`/assistant/preferences` specs are removed. PATCH uses `values` and preserves
omitted settings. Policy rejects unavailable Write defaults and auto-confirm.
Fresh field metadata marks auto-confirm read-only with policy help text when
unavailable, without mutating shared metadata. Core validates types and names.
- `/assistant/config.preferences` returns effective defaults for the panel;
new conversations apply the saved default only if Write mode is available.
The provider refuses `assistant` (including scheduled details) before accessing
rows: core only applies section channel rules through an app binding.
- Bulk write tools are absent and forged calls are denied in Read-only mode.
Scheduled mode requires the task approval and policy's global/role permission;
scheduled tools always require confirmation, regardless of user auto-confirm.
- HTTP tests cover permission visibility, own-user GET/PATCH, rejected identity
injection, policy metadata/validation, defaults and channel denial. W1's denial
matrix and both record and bulk mode paths remain covered. Build against QQQ
RC.8, or `-Dqqq.snapshot.version=4.1.0-PREFS-SNAPSHOT` before publication.
### X. The hard wall between the assistant and its configuration
Owner: *"there should also be a hard wall between the assistant and the configuration for the assistant. he should never be able to see his own configuration tables etc."*
@@ -275,6 +315,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:
@@ -354,10 +402,8 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
### P-c as built
- The qbit classification enum is a stand-in for core #1142 until a released RC
includes it. Configured table/field overrides are the default; hosts can supply
a `ClassificationSource` over core metadata, mapping enum names, then replace
the stand-in when the minimum QQQ version advances.
- The qbit classification stand-in is gone: the levels are core's `actions.ai.DataClassification` (QQQ 4.1.0-RC.6, core #1142).
The default `ClassificationSource` reads core metadata (`AiDataClassifications`, `AiTableMetaData`, `AiFieldMetaData`) merged with the configured table/field map; configuration wins when both are set. Hosts can still supply their own source with `withClassificationSource`.
- Turn/page/tool classification is monotonic, persisted on the turn and retained
on the conversation. Field subsets use overrides; unknown cross-table results
conservatively use the highest configured level. Metadata tools stay PUBLIC.
@@ -405,7 +451,7 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
model and effort. Empty slots retain provider row/default/policy selection.
- The documented heuristic is a pure rule table: >600 characters, two distinct app
table names, complexity keywords, pending write context, >8 previous turns or a
previous tool limit. This branch has no conversation write-mode field.
previous tool limit. W1 additionally persists conversation write mode.
- Classifier transport is non-streaming, bounded to four output tokens/three seconds,
and contains only the fixed instruction and user question. PHI guards apply before
dispatch. Errors/unexpected replies use the heuristic. ROUTE usage enters budget
@@ -518,12 +564,8 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
failed calls and knowledge-query embeddings. STOP is checked at dispatch
boundaries; an in-flight call can exceed the cap, and no next call is sent.
- Every scheduled UI API request, including metadata and knowledge retrieval,
carries `X-QQQ-Client: assistant/scheduled/<taskId>`. The only uiapi change is
additive `LoopbackUiApiGateway.invokeWithClient`; custom gateways currently
fail closed for scheduled requests because their channel contract is unknown.
Tool-call rows also record `clientChannel`. Core channel deny enforcement
remains pending the client-channel RC; existing AI exposure/data-access
restrictions continue to apply on RC.5.
carries `X-QQQ-Client: assistant/scheduled/<taskId>` through `CoreUiApiGateway`.
Tool-call rows also record `clientChannel`; core enforces the configuration wall.
- Approve write mode, Revoke write mode and Run now are admin record actions
using standard streamed ETL Review/Result. Review shows the exact prompt's
SHA-256 and run-as user. Result rereads and compares the complete reviewed
@@ -531,10 +573,9 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
A persisted approval hash additionally binds prompt, run-as user, credential
reference, profile and requested mode. Editing any of these clears approval;
ordinary saves cannot set approval or computed bookkeeping fields.
- This base has create/update/delete tools, but no W1 conversation mode/policy
switch. Scheduled runs explicitly narrow the existing tool policy: write tools
require both request and matching approval, a READ_WRITE table and the policy's
allowed-tools list. All scheduled writes require panel confirmation, including
- Scheduled runs narrow W1's tool policy: record and bulk write tools require
both request and matching approval, the designated user's global/role Write-mode
permission, a READ_WRITE table and the policy's allowed-tools list. All scheduled writes require panel confirmation, including
when normal policy allows immediate writes. A pending proposal adds a waiting
notice to the run summary; the scheduler never confirms it.
- RECORD keeps the run and conversation; NOTIFICATION and BOTH also submit an
+20 -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
);
@@ -369,6 +369,22 @@ CREATE TABLE assistant_profile_skill
CONSTRAINT assistant_profile_skill_pair UNIQUE (profile_id, skill_id)
);
-- 0.1.0-RC.7: write mode (per conversation, user preference, policy) and auto-confirm.
ALTER TABLE assistant_conversation ADD COLUMN IF NOT EXISTS write_mode BOOLEAN;
ALTER TABLE assistant_policy ADD COLUMN IF NOT EXISTS write_mode_allowed BOOLEAN;
ALTER TABLE assistant_policy ADD COLUMN IF NOT EXISTS write_mode_permission VARCHAR(128);
ALTER TABLE assistant_policy ADD COLUMN IF NOT EXISTS auto_confirm_allowed BOOLEAN;
ALTER TABLE assistant_data_access ADD COLUMN IF NOT EXISTS auto_confirm_ok BOOLEAN;
CREATE TABLE assistant_user_preference
(
id SERIAL PRIMARY KEY,
user_id VARCHAR(255) NOT NULL UNIQUE,
default_write_mode BOOLEAN,
auto_confirm_low_risk BOOLEAN,
create_date TIMESTAMP WITH TIME ZONE,
modify_date TIMESTAMP WITH TIME ZONE
);
CREATE TABLE assistant_data_audit
(
id SERIAL PRIMARY KEY,
@@ -574,3 +590,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;
+7 -3
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.1</qbit-email.version>
<!-- live evals call the real API; run them with -Dtest.excludedGroups= -Dgroups=live -->
<test.excludedGroups>live</test.excludedGroups>
</properties>
@@ -200,8 +202,9 @@
</build>
<profiles>
<!-- The assistant needs QQQ 4.1.0-RC.5: the AI tool kernel (package -->
<!-- actions.ai, qqq#1078) and the developer panels (qqq#1076). Until -->
<!-- The assistant needs QQQ 4.1.0-RC.6: the AI tool kernel (qqq#1078), the -->
<!-- developer panels (qqq#1076) and the UI API extension point, invoker -->
<!-- and credentials (qqq#1128). Until -->
<!-- qbit-build-parent imports the 4.1 BOM, this -->
<!-- profile is active by default and imports qqq-bom-pom ahead of the -->
<!-- parent's 4.0.0 BOM, as in qbit-quick-search (ADR-0007). Override with -->
@@ -212,7 +215,8 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
<!-- requires QQQ RC.8 (core Preferences, qqq#1162); until it is published build with -Dqqq.snapshot.version=4.1.0-PREFS-SNAPSHOT -->
<qqq.snapshot.version>4.1.0-RC.8</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -24,6 +24,7 @@ import com.kingsrook.qbits.assistant.testing.GoldenTasks;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
@@ -102,6 +103,10 @@ class LiveGoldenEvalTest
@MethodSource("tasks")
void live(GoldenTasks.Task task) throws Exception
{
if("D-page-12-this-week".equals(task.id()))
{
ClientChannelsMetaData.ofOrWithNew(qInstance).getDeniedPatterns().get("assistant").remove("audit*");
}
TurnRunner.Outcome outcome = new TurnRunner(qInstance, config).run(task.userId(), task.prompt());
assertThat(outcome.errorCode()).as("error").isNull();
task.liveCheck().accept(outcome);
+9
View File
@@ -49,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>
@@ -32,7 +32,6 @@ import java.util.regex.Pattern;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qbits.assistant.classification.ClassificationSource;
import com.kingsrook.qbits.assistant.classification.ConfiguredClassificationSource;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.engine.ModelPricing;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
@@ -40,6 +39,7 @@ import com.kingsrook.qbits.assistant.provider.TrustedCaBundle;
import com.kingsrook.qbits.assistant.rag.VectorStore;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
@@ -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,9 +25,11 @@ 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;
import com.kingsrook.qbits.assistant.metadata.AssistantPreferencesProvider;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.processes.DiscoverModelsProcess;
import com.kingsrook.qbits.assistant.processes.KnowledgeIndexProcess;
@@ -41,6 +43,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 +204,47 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
QAppMetaData app = builder.buildAdminApp(tables);
qInstance.addApp(app);
AssistantPreferencesProvider.register(qInstance);
AssistantChannelWall.register(qInstance);
if(Boolean.TRUE.equals(qBitConfig.getRecordHistory()))
{
enableRecordHistory(ClientChannelsMetaData.ofOrWithNew(qInstance));
}
else
{
ClientChannelsMetaData.ofOrWithNew(qInstance).withDeniedPattern("assistant", "audit*");
}
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,15 +32,16 @@ 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;
import com.kingsrook.qbits.assistant.rag.VectorStore;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.LoopbackUiApiGateway;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qbits.assistant.uiapi.CoreUiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
@@ -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;
@@ -120,7 +122,7 @@ public class AssistantRuntime
this.accessExplainer = new AccessExplainer(qInstance, dataAccessService);
this.toolbox = new UiApiToolbox(qInstance, dataAccessService, exposurePolicy, new AiRedactor(settings.getCustomRedactionPatterns()),
config.getMaxCellChars() == null ? AiToolKernelSettings.DEFAULT_MAX_CELL_CHARS : config.getMaxCellChars());
UiApiGateway delegate = config.getUiApiGateway() != null ? config.getUiApiGateway() : new LoopbackUiApiGateway(this::uiApiBaseUrl, qInstance);
UiApiGateway delegate = config.getUiApiGateway() != null ? config.getUiApiGateway() : new CoreUiApiGateway(this::uiApiBaseUrl, qInstance);
this.uiApiGateway = new UiApiGateway()
{
/*******************************************************************************
@@ -141,11 +143,11 @@ public class AssistantRuntime
String channel = scheduledTurn.channel(call.getSessionUUID());
if(channel != null)
{
if(delegate instanceof LoopbackUiApiGateway loopback)
if(!(delegate instanceof CoreUiApiGateway))
{
return (loopback.invokeWithClient(call, channel));
throw (new QException("Scheduled tasks require the core UI API gateway for client attribution."));
}
throw (new QException("Scheduled tasks require the loopback gateway for client attribution."));
call.withClientDetail(channel.substring(CoreUiApiGateway.CLIENT_CHANNEL.length() + 1));
}
return (delegate.invoke(call));
}
@@ -31,6 +31,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
@@ -92,7 +93,27 @@ public class AccessExplainer
*******************************************************************************/
public List<TableAccess> explain(UiApiGateway gateway, String sessionUUID, PolicyService.ToolPolicy policy, String tableName, String prompt) throws QException
{
UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withInvokedBy("assistant-explain"));
return (explain(gateway, sessionUUID, null, policy, tableName, prompt));
}
/*******************************************************************************
** Explain, with the caller's request credentials when they are known.
*******************************************************************************/
public List<TableAccess> explain(UiApiGateway gateway, String sessionUUID, UiApiCredentials credentials, PolicyService.ToolPolicy policy, String tableName, String prompt) throws QException
{
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())));
@@ -132,6 +132,18 @@ public class DataAccessService
/*******************************************************************************
** Whether an admin marked the table's single-record inserts and updates as
** low-risk (auto-confirmable, when the policy and the user allow it).
*******************************************************************************/
public boolean autoConfirmOk(String tableName)
{
AssistantDataAccess row = rows().get(key(AssistantDataAccess.TYPE_TABLE, tableName));
return (row != null && Boolean.TRUE.equals(row.getAutoConfirmOk()) && forTable(tableName).mode().allowsWrite());
}
/*******************************************************************************
** Drop the cache (after a change to the table).
*******************************************************************************/
@@ -44,6 +44,7 @@ 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.model.AssistantTurnEvent;
import com.kingsrook.qbits.assistant.model.AssistantUserPreference;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
@@ -55,6 +56,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
@@ -71,6 +73,7 @@ public class AssistantApi
private final AssistantStore store;
private final QSession session;
private final String userId;
private UiApiCredentials credentials;
@@ -93,6 +96,18 @@ public class AssistantApi
/*******************************************************************************
** The credentials of the request this API serves, exactly as they came in;
** the tools forward them to the UI API. Never logged.
*******************************************************************************/
public AssistantApi withCredentials(UiApiCredentials credentials)
{
this.credentials = credentials;
return (this);
}
/*******************************************************************************
** GET /config - the feature probe. Never throws for a signed-in user.
*******************************************************************************/
@@ -126,7 +141,10 @@ public class AssistantApi
result.put("shortcut", config.getShortcut());
result.put("model", reason == null ? activeProvider().model() : config.getModels().getMainModel());
result.put("limits", Map.of("maxTurnSeconds", config.getMaxTurnSeconds(), "maxToolCallsPerTurn", config.getMaxToolCallsPerTurn()));
result.put("features", Map.of("writes", runtime.getToolbox().anyWritableTable(), "pendingActions", true));
boolean writeMode = reason == null && writeModeAvailable();
result.put("features", Map.of("writes", runtime.getToolbox().anyWritableTable(), "pendingActions", true, "writeMode", writeMode,
"autoConfirm", writeMode && runtime.getPolicyService().current(false).tools().autoConfirmAllowed()));
result.put("preferences", reason == null ? preferences() : Map.of());
ProfileService.Persona persona = runtime.getProfileService().persona(session, null);
Map<String, Object> profile = new LinkedHashMap<>();
profile.put("displayName", persona.displayName());
@@ -166,6 +184,12 @@ public class AssistantApi
String title = body.get("title") instanceof String s && StringUtils.hasContent(s) ? truncate(s.trim(), 255) : null;
Map<String, Object> context = body.get("context") instanceof Map<?, ?> m ? ContextBuilder.sanitizePageContext((Map<String, Object>) m) : null;
AssistantConversation conversation = store.createConversation(userId, title, context == null || context.isEmpty() ? null : JsonUtils.toJson(context));
boolean write = body.get("writeMode") instanceof Boolean requested ? requested : defaultWriteMode();
if(write && writeModeAvailable())
{
store.updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", conversation.getId()).withValue("writeMode", true));
conversation.setWriteMode(true);
}
return (toJson(conversation));
}
@@ -241,6 +265,19 @@ public class AssistantApi
conversation.setStatus((String) status);
}
if(body.containsKey("writeMode"))
{
if(!(body.get("writeMode") instanceof Boolean writeMode))
{
throw (new AssistantApiException(400, "writeMode must be true or false."));
}
if(writeMode && !writeModeAvailable())
{
throw (new AssistantApiException(403, "WRITE_MODE_UNAVAILABLE", "Write mode is not available to you. An administrator controls it in the assistant policy."));
}
update.setValue("writeMode", writeMode);
conversation.setWriteMode(writeMode);
}
store.updateFields(AssistantConversation.TABLE_NAME, update);
return (toJson(conversation));
}
@@ -373,7 +410,7 @@ public class AssistantApi
RunningTurn runningTurn = runtime.register(new RunningTurn(turn.getId(), userId));
TurnEngine engine = new TurnEngine(runtime, session, userId, conversation, userMessage, assistantMessage, turn, text, pageContext, policy, runningTurn,
budget.softExceeded() ? budget.message() : null);
budget.softExceeded() ? budget.message() : null).withCredentials(credentials);
runtime.getExecutor().submit(engine);
return (runningTurn);
}
@@ -598,7 +635,7 @@ public class AssistantApi
throw (new QException("The stored values of action " + id + " are not valid JSON", e));
}
PolicyService.EffectivePolicy policy = runtime.getPolicyService().current(true);
UiApiToolbox.Context context = new UiApiToolbox.Context(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), runtime.getUiApiGateway(), policy.tools(), null);
UiApiToolbox.Context context = new UiApiToolbox.Context(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), runtime.getUiApiGateway(), policy.tools(), null, credentials, action.getTurnId() == null ? null : String.valueOf(action.getTurnId()));
UiApiToolbox.Outcome outcome = BulkLoadTools.WRITE_TOOLS.contains(action.getToolName())
? runtime.getBulkLoadTools().executeConfirmed(context, action.getToolCallId(), action.getToolName(), action.getTableName(), values)
: runtime.getToolbox().executeConfirmedWrite(context, action.getToolCallId(), action.getToolName(), action.getTableName(), action.getRecordId(), values);
@@ -613,6 +650,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())
@@ -719,10 +757,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(), 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)
{
@@ -747,6 +799,43 @@ public class AssistantApi
/*******************************************************************************
** Whether this user may use Write mode at all: the policy allows it, they
** hold its permission (when one is set), and some table is READ_WRITE.
*******************************************************************************/
public boolean writeModeAvailable() throws QException
{
return (runtime.getPolicyService().current(false).tools().writeModeAvailableTo(session) && runtime.getToolbox().anyWritableTable());
}
/*******************************************************************************
** The default mode of the user's new conversations (Read-only unless they
** chose Write).
*******************************************************************************/
private boolean defaultWriteMode() throws QException
{
AssistantUserPreference preference = store.getPreference(userId);
return (preference != null && Boolean.TRUE.equals(preference.getDefaultWriteMode()));
}
/*******************************************************************************
** Effective defaults for the panel config, narrowed by current policy.
*******************************************************************************/
private Map<String, Object> preferences() throws QException
{
AssistantUserPreference preference = userId == null ? null : store.getPreference(userId);
Map<String, Object> map = new LinkedHashMap<>();
map.put("defaultWriteMode", writeModeAvailable() && preference != null && Boolean.TRUE.equals(preference.getDefaultWriteMode()));
map.put("autoConfirmLowRisk", runtime.getPolicyService().current(false).tools().autoConfirmAllowed() && preference != null && Boolean.TRUE.equals(preference.getAutoConfirmLowRisk()));
return (map);
}
/*******************************************************************************
** The configured turn time limit (replay loops are capped by it).
*******************************************************************************/
@@ -845,6 +934,7 @@ public class AssistantApi
map.put("title", conversation.getTitle());
map.put("status", conversation.getStatus());
map.put("pinned", Boolean.TRUE.equals(conversation.getPinned()));
map.put("writeMode", Boolean.TRUE.equals(conversation.getWriteMode()));
map.put("lastMessageAt", conversation.getLastMessageAt() == null ? null : conversation.getLastMessageAt().toString());
map.put("unreadCount", conversation.getUnreadCount() == null ? 0 : conversation.getUnreadCount());
map.put("createDate", conversation.getCreateDate() == null ? null : conversation.getCreateDate().toString());
@@ -17,63 +17,27 @@
package com.kingsrook.qbits.assistant.api;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.engine.RunningTurn;
import com.kingsrook.qbits.assistant.engine.TurnEvent;
import com.kingsrook.qbits.assistant.engine.TurnEventLog;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
import com.kingsrook.qqq.middleware.javalin.QJavalinUtils;
import com.kingsrook.qqq.middleware.javalin.executors.ExecutorSessionUtils;
import io.javalin.config.JavalinConfig;
import io.javalin.http.ContentType;
import io.javalin.http.Context;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Javalin routes under /qqq/v1/assistant (contract section 3).
**
** Every request gets the user's own session from the instance's
** authentication module (ExecutorSessionUtils.setupSession: session cookie,
** bearer token or API key) - never a system session. Bodies must be JSON
** (a cross-site form cannot send that without a CORS preflight).
**
** Streams (POST /conversations/{id}/turns, GET /turns/{id}/events) are
** Server-Sent Events written to the response as they happen: "id: seq",
** "event: type", "data: json", a blank line; a ping every 15 seconds;
** Cache-Control no-cache and X-Accel-Buffering no (proxies must not
** buffer; give the route a long idle timeout). The turn itself runs on its
** own thread, so a client disconnect does not stop it; the client resumes
** with /events?after=<last seq>.
**
** Request and response bodies are never logged.
** No routes any more: the assistant's endpoints are spec'd /qqq/v1
** operations ({@link com.kingsrook.qbits.assistant.api.specs.AssistantSpecs}).
** What is left is a before-handler that learns this server's local port from
** the first /qqq/v1 request, so the tools (and Explain access) can call the
** UI API on 127.0.0.1 before any assistant operation has run.
*******************************************************************************/
public class AssistantRouteProvider implements QJavalinRouteProviderInterface
{
private static final QLogger LOG = QLogger.getLogger(AssistantRouteProvider.class);
public static final String BASE_PATH = "/qqq/v1/assistant";
public static final long PING_MILLIS = 15_000;
public static final long REMOTE_POLL_MS = 1_000;
public static final String BASE_PATH = "/qqq/v1/assistant";
private QInstance qInstance;
private long pingMillis = PING_MILLIS;
@@ -88,28 +52,12 @@ public class AssistantRouteProvider implements QJavalinRouteProviderInterface
/*******************************************************************************
** Ping interval (tests use a short one).
*******************************************************************************/
public AssistantRouteProvider withPingMillis(long pingMillis)
{
this.pingMillis = pingMillis;
return (this);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void acceptJavalinConfig(JavalinConfig config)
{
///////////////////////////////////////////////////////////////////////
// learn this server's local port from the first UI request (e.g. //
// the metaData load), so tools and Explain access can loop back to //
// /qqq/v1 before any assistant route has been called //
///////////////////////////////////////////////////////////////////////
config.routes.before("/qqq/v1/*", ctx ->
{
AssistantRuntime runtime = AssistantRuntime.of(qInstance);
@@ -125,412 +73,6 @@ public class AssistantRouteProvider implements QJavalinRouteProviderInterface
}
}
});
config.routes.get(BASE_PATH + "/config", ctx -> json(ctx, false, (api, body) -> api.config()));
config.routes.get(BASE_PATH + "/conversations", ctx -> json(ctx, false, (api, body) -> api.listConversations(ctx.queryParam("status"))));
config.routes.post(BASE_PATH + "/conversations", ctx -> json(ctx, true, (api, body) ->
{
ctx.status(201);
return (api.createConversation(body));
}));
config.routes.get(BASE_PATH + "/conversations/{id}", ctx -> json(ctx, false, (api, body) -> api.getConversation(pathInt(ctx, "id"), queryInt(ctx, "before"), queryInt(ctx, "limit"))));
config.routes.patch(BASE_PATH + "/conversations/{id}", ctx -> json(ctx, true, (api, body) -> api.patchConversation(pathInt(ctx, "id"), body)));
config.routes.delete(BASE_PATH + "/conversations/{id}", ctx -> json(ctx, false, (api, body) ->
{
api.deleteConversation(pathInt(ctx, "id"));
ctx.status(204);
return (null);
}));
config.routes.post(BASE_PATH + "/conversations/{id}/turns", this::startTurn);
config.routes.get(BASE_PATH + "/turns/{turnId}/events", this::turnEvents);
config.routes.post(BASE_PATH + "/turns/{turnId}/cancel", ctx -> json(ctx, true, (api, body) ->
{
ctx.status(202);
return (api.cancelTurn(pathInt(ctx, "turnId")));
}));
config.routes.get(BASE_PATH + "/access", ctx -> json(ctx, false, (api, body) -> api.access(ctx.queryParam("table"), ctx.queryParam("prompt"))));
config.routes.get(BASE_PATH + "/actions/{id}", ctx -> json(ctx, false, (api, body) -> api.getAction(pathInt(ctx, "id"))));
config.routes.post(BASE_PATH + "/actions/{id}/confirm", ctx -> json(ctx, true, (api, body) -> api.confirmAction(pathInt(ctx, "id"))));
config.routes.post(BASE_PATH + "/actions/{id}/reject", ctx -> json(ctx, true, (api, body) -> api.rejectAction(pathInt(ctx, "id"))));
config.routes.post(BASE_PATH + "/messages/{id}/feedback", ctx -> json(ctx, true, (api, body) ->
{
api.feedback(pathInt(ctx, "id"), body);
ctx.status(204);
return (null);
}));
}
/*******************************************************************************
** Session, runtime, body, handler, JSON - or the error.
*******************************************************************************/
private void json(Context context, boolean readsBody, Handler handler)
{
try
{
AssistantApi api = api(context);
Map<String, Object> body = readsBody ? readJsonBody(context) : Map.of();
Object result = handler.handle(api, body);
if(result != null)
{
context.contentType(ContentType.APPLICATION_JSON);
context.result(JsonUtils.toJsonCustomized(result, builder -> builder.serializationInclusion(JsonInclude.Include.ALWAYS)));
}
}
catch(Exception e)
{
handleException(context, e);
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** POST /conversations/{id}/turns
*******************************************************************************/
private void startTurn(Context context)
{
RunningTurn turn;
try
{
AssistantApi api = api(context);
turn = api.startTurn(pathInt(context, "id"), readJsonBody(context));
}
catch(Exception e)
{
handleException(context, e);
QContext.clear();
return;
}
QContext.clear();
streamLive(context, turn.getLog(), 0);
}
/*******************************************************************************
** GET /turns/{turnId}/events?after=
*******************************************************************************/
private void turnEvents(Context context)
{
Integer turnId;
int after;
AssistantApi api;
AssistantApi.TurnEvents events;
try
{
api = api(context);
turnId = pathInt(context, "turnId");
Integer afterParam = queryInt(context, "after");
after = afterParam == null ? 0 : Math.max(0, afterParam);
events = api.turnEvents(turnId, after);
}
catch(Exception e)
{
handleException(context, e);
QContext.clear();
return;
}
if(events.live() != null)
{
QContext.clear();
streamLive(context, events.live().getLog(), after);
return;
}
///////////////////////////////////////////////////////////////////////
// persisted events; if the turn still runs on another replica, poll //
///////////////////////////////////////////////////////////////////////
try
{
OutputStream out = startStream(context);
int last = after;
long lastWrite = System.currentTimeMillis();
long giveUpAt = System.currentTimeMillis() + (api.maxTurnSeconds() + 60L) * 1000L;
AssistantApi.TurnEvents current = events;
while(true)
{
boolean ended = false;
for(TurnEvent event : current.persisted())
{
write(out, event.toSseFrame());
last = event.getSeq();
lastWrite = System.currentTimeMillis();
ended |= event.isTerminal();
}
if(ended || (current.finished() && !current.more()) || System.currentTimeMillis() > giveUpAt)
{
return;
}
if(!current.more())
{
Thread.sleep(REMOTE_POLL_MS);
}
if(System.currentTimeMillis() - lastWrite >= pingMillis)
{
write(out, ping());
lastWrite = System.currentTimeMillis();
}
current = api.turnEvents(turnId, last);
if(current.live() != null)
{
QContext.clear();
writeLive(out, current.live().getLog(), last);
return;
}
}
}
catch(IOException e)
{
LOG.debug("Assistant event stream closed by the client", logPair("turnId", turnId));
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
catch(Exception e)
{
LOG.warn("Assistant event replay failed", e, logPair("turnId", turnId));
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** Stream a live log from a sequence number until message.end.
*******************************************************************************/
private void streamLive(Context context, TurnEventLog log, int after)
{
try
{
writeLive(startStream(context), log, after);
}
catch(IOException e)
{
LOG.debug("Assistant stream closed by the client (the turn continues)");
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
}
/*******************************************************************************
**
*******************************************************************************/
private void writeLive(OutputStream out, TurnEventLog log, int after) throws IOException, InterruptedException
{
int last = after;
while(true)
{
List<TurnEvent> events = log.after(last, pingMillis);
if(events.isEmpty())
{
if(log.isClosed())
{
return;
}
write(out, ping());
continue;
}
for(TurnEvent event : events)
{
write(out, event.toSseFrame());
last = event.getSeq();
if(event.isTerminal())
{
return;
}
}
}
}
/*******************************************************************************
** Start an SSE response.
*******************************************************************************/
private static OutputStream startStream(Context context) throws IOException
{
context.status(200);
context.disableCompression();
context.res().setContentType("text/event-stream; charset=utf-8");
context.res().setHeader("Cache-Control", "no-cache");
context.res().setHeader("X-Accel-Buffering", "no");
context.res().setHeader("Connection", "keep-alive");
OutputStream out = context.res().getOutputStream();
out.flush();
return (out);
}
/*******************************************************************************
**
*******************************************************************************/
private static void write(OutputStream out, String frame) throws IOException
{
out.write(frame.getBytes(StandardCharsets.UTF_8));
out.flush();
}
/*******************************************************************************
**
*******************************************************************************/
private static String ping()
{
return ("event: " + TurnEvent.PING + "\ndata: {}\n\n");
}
/*******************************************************************************
** Session + API for a request. 404 when the QBit is not in the instance.
*******************************************************************************/
private AssistantApi api(Context context) throws Exception
{
AssistantRuntime runtime = AssistantRuntime.of(qInstance);
if(runtime == null)
{
throw (new AssistantApiException(404, "The assistant is not installed."));
}
////////////////////////////////////////////////////////////////////
// the tools call /qqq/v1 on this server: remember its local port //
////////////////////////////////////////////////////////////////////
try
{
runtime.learnLocalPort(context.req().getLocalPort());
}
catch(RuntimeException e)
{
LOG.debug("Could not read the local port", e);
}
QSession session = ExecutorSessionUtils.setupSession(context, qInstance);
return (new AssistantApi(runtime, session));
}
/*******************************************************************************
** Map errors: API errors with their status, others through QQQ's handler
** (401 / 403 / 404 / 500).
*******************************************************************************/
private static void handleException(Context context, Exception e)
{
if(e instanceof AssistantApiException apiException)
{
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", apiException.getMessage());
if(apiException.getCode() != null)
{
body.put("code", apiException.getCode());
}
context.status(apiException.getStatus());
context.contentType(ContentType.APPLICATION_JSON);
context.result(JsonUtils.toJson(body));
return;
}
if(!(e instanceof QException))
{
LOG.warn("Assistant request failed", e);
}
QJavalinUtils.handleException(null, context, e);
}
/*******************************************************************************
** A JSON object body (empty body = empty object).
*******************************************************************************/
static Map<String, Object> readJsonBody(Context context) throws AssistantApiException
{
String body = context.body();
if(!StringUtils.hasContent(body))
{
return (new LinkedHashMap<>());
}
String contentType = context.contentType();
if(contentType == null || !contentType.toLowerCase().startsWith("application/json"))
{
throw (new AssistantApiException(400, "Request bodies must be JSON (Content-Type: application/json)."));
}
try
{
Map<String, Object> map = JsonUtils.toObject(body, new TypeReference<Map<String, Object>>() {}, mapper -> mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS));
return (map == null ? new LinkedHashMap<>() : map);
}
catch(Exception e)
{
throw (new AssistantApiException(400, "The request body is not a JSON object."));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static Integer pathInt(Context context, String name) throws AssistantApiException
{
try
{
return (Integer.parseInt(context.pathParam(name)));
}
catch(NumberFormatException e)
{
throw (new AssistantApiException(400, name + " must be an integer."));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static Integer queryInt(Context context, String name) throws AssistantApiException
{
String value = context.queryParam(name);
if(!StringUtils.hasContent(value))
{
return (null);
}
try
{
return (Integer.parseInt(value.trim()));
}
catch(NumberFormatException e)
{
throw (new AssistantApiException(400, name + " must be an integer."));
}
}
/*******************************************************************************
** One JSON endpoint's work.
*******************************************************************************/
@FunctionalInterface
private interface Handler
{
/*******************************************************************************
**
*******************************************************************************/
Object handle(AssistantApi api, Map<String, Object> body) throws Exception;
}
}
@@ -19,7 +19,9 @@ package com.kingsrook.qbits.assistant.api;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qbits.assistant.api.specs.AssistantSpecs;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
@@ -43,6 +45,16 @@ public class AssistantRoutes
{
javalinMetaData.withAdditionalRouteProviderReference(new QCodeReference(AssistantRouteProvider.class));
}
////////////////////////////////////////////////////////////////////
// the endpoints: the spec'd "assistant" UI API extension //
////////////////////////////////////////////////////////////////////
MiddlewareSpecExtensionsMetaData extensions = MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance);
boolean hasExtension = extensions.getExtensions() != null && extensions.getExtensions().containsKey(AssistantSpecs.EXTENSION_NAME);
if(!hasExtension)
{
extensions.withExtension(AssistantSpecs.extension());
}
}
}
@@ -0,0 +1,222 @@
/*
* 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.api;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import com.kingsrook.qbits.assistant.engine.TurnEvent;
import com.kingsrook.qbits.assistant.engine.TurnEventLog;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import io.javalin.http.Context;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Writes a turn's events as server-sent events: the live log of a turn
** running here, or the persisted events (polling while the turn runs on
** another replica), with a ping every pingMillis while idle.
*******************************************************************************/
public final class TurnStreams
{
private static final QLogger LOG = QLogger.getLogger(TurnStreams.class);
public static final long PING_MILLIS = 15_000;
public static final long REMOTE_POLL_MS = 1_000;
private static volatile long pingMillis = PING_MILLIS;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private TurnStreams()
{
}
/*******************************************************************************
** The ping interval (tests use a short one).
*******************************************************************************/
public static void setPingMillis(long millis)
{
pingMillis = millis;
}
/*******************************************************************************
** Stream a live log from a sequence number until message.end.
*******************************************************************************/
public static void streamLive(Context context, TurnEventLog log, int after)
{
try
{
writeLive(startStream(context), log, after);
}
catch(IOException e)
{
LOG.debug("Assistant stream closed by the client (the turn continues)");
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
}
/*******************************************************************************
** Stream the events of a turn after a sequence number: live when it runs
** here, else the persisted ones (polling while it runs elsewhere). The
** caller's QContext (session) must be set: the replay reads the store.
*******************************************************************************/
public static void streamEvents(Context context, AssistantApi api, Integer turnId, int after, AssistantApi.TurnEvents first)
{
if(first.live() != null)
{
streamLive(context, first.live().getLog(), after);
return;
}
try
{
OutputStream out = startStream(context);
int last = after;
long lastWrite = System.currentTimeMillis();
long giveUpAt = System.currentTimeMillis() + (api.maxTurnSeconds() + 60L) * 1000L;
AssistantApi.TurnEvents current = first;
while(true)
{
boolean ended = false;
for(TurnEvent event : current.persisted())
{
write(out, event.toSseFrame());
last = event.getSeq();
lastWrite = System.currentTimeMillis();
ended |= event.isTerminal();
}
if(ended || (current.finished() && !current.more()) || System.currentTimeMillis() > giveUpAt)
{
return;
}
if(!current.more())
{
Thread.sleep(REMOTE_POLL_MS);
}
if(System.currentTimeMillis() - lastWrite >= pingMillis)
{
write(out, ping());
lastWrite = System.currentTimeMillis();
}
current = api.turnEvents(turnId, last);
if(current.live() != null)
{
writeLive(out, current.live().getLog(), last);
return;
}
}
}
catch(IOException e)
{
LOG.debug("Assistant event stream closed by the client", logPair("turnId", turnId));
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
catch(Exception e)
{
LOG.warn("Assistant event replay failed", e, logPair("turnId", turnId));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void writeLive(OutputStream out, TurnEventLog log, int after) throws IOException, InterruptedException
{
int last = after;
while(true)
{
List<TurnEvent> events = log.after(last, pingMillis);
if(events.isEmpty())
{
if(log.isClosed())
{
return;
}
write(out, ping());
continue;
}
for(TurnEvent event : events)
{
write(out, event.toSseFrame());
last = event.getSeq();
if(event.isTerminal())
{
return;
}
}
}
}
/*******************************************************************************
** Start an SSE response.
*******************************************************************************/
private static OutputStream startStream(Context context) throws IOException
{
context.status(200);
context.disableCompression();
context.res().setContentType("text/event-stream; charset=utf-8");
context.res().setHeader("Cache-Control", "no-cache");
context.res().setHeader("X-Accel-Buffering", "no");
context.res().setHeader("Connection", "keep-alive");
OutputStream out = context.res().getOutputStream();
out.flush();
return (out);
}
/*******************************************************************************
**
*******************************************************************************/
private static void write(OutputStream out, String frame) throws IOException
{
out.write(frame.getBytes(StandardCharsets.UTF_8));
out.flush();
}
/*******************************************************************************
**
*******************************************************************************/
private static String ping()
{
return ("event: " + TurnEvent.PING + "\ndata: {}\n\n");
}
}
@@ -22,11 +22,12 @@ package com.kingsrook.qbits.assistant.classification;
import java.util.Collection;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
/*******************************************************************************
** Host seam for table and field classifications; a core metadata adapter can replace the configured source.
** Host seam for table and field classifications; the default reads core metadata merged with configuration.
*******************************************************************************/
public interface ClassificationSource
{
@@ -63,7 +64,7 @@ public interface ClassificationSource
DataClassification result = DataClassification.PUBLIC;
for(String field : fields)
{
result = DataClassification.max(result, field == null || "*".equals(field) ? highest(table) : ofField(table, field));
result = Classifications.max(result, field == null || "*".equals(field) ? highest(table) : ofField(table, field));
}
return (result);
}
@@ -80,7 +81,7 @@ public interface ClassificationSource
{
if(!table.startsWith("assistant_"))
{
result = DataClassification.max(result, highest(table));
result = Classifications.max(result, highest(table));
}
}
return (result);
@@ -1,15 +1,11 @@
/*
* 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/
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
* 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,
@@ -21,24 +17,31 @@
package com.kingsrook.qbits.assistant.classification;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
/*******************************************************************************
** Stand-in for core DataClassification (QRun-IO/qqq#1142); replace when the qbit builds on an RC that has it.
** The assistant's conventions over core's {@link DataClassification}: an
** absent classification is PUBLIC, and a stored level this version does not
** know is treated as PHI (fail conservative).
*******************************************************************************/
public enum DataClassification
public final class Classifications
{
PUBLIC, CONFIDENTIAL, PII, PHI;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private Classifications()
{
}
/*******************************************************************************
** Whether this classification is at least the supplied level; null means PUBLIC.
** The level, or PUBLIC when absent.
*******************************************************************************/
public boolean isAtLeast(DataClassification other)
public static DataClassification orPublic(DataClassification classification)
{
return (ordinal() >= (other == null ? PUBLIC : other).ordinal());
return (classification == null ? DataClassification.PUBLIC : classification);
}
@@ -48,9 +51,7 @@ public enum DataClassification
*******************************************************************************/
public static DataClassification max(DataClassification left, DataClassification right)
{
DataClassification a = left == null ? PUBLIC : left;
DataClassification b = right == null ? PUBLIC : right;
return (a.isAtLeast(b) ? a : b);
return (DataClassification.max(orPublic(left), orPublic(right)));
}
@@ -62,15 +63,15 @@ public enum DataClassification
{
if(value == null)
{
return (PUBLIC);
return (DataClassification.PUBLIC);
}
try
{
return (valueOf(value));
return (DataClassification.valueOf(value));
}
catch(IllegalArgumentException e)
{
return (PHI);
return (DataClassification.PHI);
}
}
}
@@ -23,12 +23,21 @@ package com.kingsrook.qbits.assistant.classification;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Default classification source for RCs without core classification metadata.
** The default classification source: core's AI classification metadata
** (AiTableMetaData / AiFieldMetaData, via AiDataClassifications) merged with
** the classifications configured on the qbit. A configured value wins when
** both are set; a field falls back to its own core classification, then to
** its table's; anything unclassified (and the assistant's own tables) is
** PUBLIC.
*******************************************************************************/
public class ConfiguredClassificationSource implements ClassificationSource
{
@@ -71,7 +80,7 @@ public class ConfiguredClassificationSource implements ClassificationSource
@Override
public DataClassification ofTable(String table)
{
return (table == null || table.startsWith("assistant_") ? DataClassification.PUBLIC : DataClassification.max(null, tables.get(table)));
return (table == null || table.startsWith("assistant_") ? DataClassification.PUBLIC : Classifications.max(null, tables.get(table)));
}
@@ -87,11 +96,28 @@ public class ConfiguredClassificationSource implements ClassificationSource
return (DataClassification.PUBLIC);
}
DataClassification value = field == null ? null : fields.getOrDefault(table, Map.of()).get(field);
if(value == null)
{
QTableMetaData metadata = coreTable(table);
AiFieldMetaData coreField = metadata == null || metadata.getFields() == null || field == null ? null : AiFieldMetaData.of(metadata.getFields().get(field));
value = coreField == null ? null : coreField.getClassification();
}
return (value == null ? ofTable(table) : value);
}
/*******************************************************************************
** The table's core metadata, or null (no instance in context, or unknown).
*******************************************************************************/
private static QTableMetaData coreTable(String table)
{
QInstance qInstance = QContext.getQInstance();
return (qInstance == null ? null : qInstance.getTable(table));
}
/*******************************************************************************
** Resolve all field names from the current instance, also retaining configured overrides.
*******************************************************************************/
@@ -108,12 +134,12 @@ public class ConfiguredClassificationSource implements ClassificationSource
{
for(String field : metadata.getFields().keySet())
{
result = DataClassification.max(result, ofField(table, field));
result = Classifications.max(result, ofField(table, field));
}
}
for(DataClassification value : fields.getOrDefault(table, Map.of()).values())
{
result = DataClassification.max(result, value);
result = Classifications.max(result, value);
}
return (result);
}
@@ -129,11 +155,11 @@ public class ConfiguredClassificationSource implements ClassificationSource
DataClassification result = ClassificationSource.super.highestAnywhere();
for(String table : tables.keySet())
{
result = DataClassification.max(result, highest(table));
result = Classifications.max(result, highest(table));
}
for(String table : fields.keySet())
{
result = DataClassification.max(result, highest(table));
result = Classifications.max(result, highest(table));
}
return (result);
}
@@ -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.outbox.EmailService");
}
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,158 @@
/*
* 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.EmailRuntime;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.outbox.EmailService;
import com.kingsrook.qbits.email.template.EmailTemplateDefinition;
import com.kingsrook.qbits.email.template.EmailTemplates;
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 (EmailRuntime.config(qInstance) != null);
}
/*******************************************************************************
** 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);
EmailRequest request = new EmailRequest().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(EmailService.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(EmailSendResult 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
{
EmailTemplates.ensure(new EmailTemplateDefinition()
.withName(TEMPLATE_NAME)
.withLabel("Assistant scheduled task result")
.withCategory(EmailCategory.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)
{
}
@@ -21,7 +21,6 @@ import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
@@ -33,6 +32,7 @@ import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.RegistryModelProvider;
import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.rag.EmbeddingService;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -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));
@@ -27,7 +27,6 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Supplier;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
@@ -36,6 +35,7 @@ import com.kingsrook.qbits.assistant.provider.ModelMessage;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -29,7 +29,6 @@ import java.util.function.Consumer;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantProfile;
@@ -44,6 +43,7 @@ import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.provider.SystemBlock;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
@@ -22,8 +22,8 @@ package com.kingsrook.qbits.assistant.engine;
import java.util.Optional;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
/*******************************************************************************
@@ -24,6 +24,7 @@ import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import java.time.Clock;
import java.time.Instant;
@@ -81,8 +82,50 @@ public class PolicyService
** @param redactPii redact secrets and personal identifiers from tool
** results (default true)
*******************************************************************************/
public record ToolPolicy(boolean writesRequireConfirmation, Set<String> allowedTools, int maxRowsPerCall, boolean redactPii)
public record ToolPolicy(boolean writesRequireConfirmation, Set<String> allowedTools, int maxRowsPerCall, boolean redactPii,
boolean writeModeAllowed, String writeModePermission, boolean autoConfirmAllowed, boolean writeMode, boolean autoConfirm)
{
/*******************************************************************************
** Write mode on, no auto-confirm, write mode allowed for everyone (tests
** and hosts that build a policy by hand).
*******************************************************************************/
public ToolPolicy(boolean writesRequireConfirmation, Set<String> allowedTools, int maxRowsPerCall, boolean redactPii)
{
this(writesRequireConfirmation, allowedTools, maxRowsPerCall, redactPii, true, null, false, true, false);
}
/*******************************************************************************
** This policy for a conversation in Write (true) or Read-only (false) mode.
*******************************************************************************/
public ToolPolicy withWriteMode(boolean writeMode)
{
return (new ToolPolicy(writesRequireConfirmation, allowedTools, maxRowsPerCall, redactPii, writeModeAllowed, writeModePermission, autoConfirmAllowed, writeMode, autoConfirm));
}
/*******************************************************************************
** This policy with the user's auto-confirm (only effective when the policy
** allows it).
*******************************************************************************/
public ToolPolicy withAutoConfirm(boolean autoConfirm)
{
return (new ToolPolicy(writesRequireConfirmation, allowedTools, maxRowsPerCall, redactPii, writeModeAllowed, writeModePermission, autoConfirmAllowed, writeMode,
autoConfirm && autoConfirmAllowed));
}
/*******************************************************************************
** Whether this user may switch a conversation to Write mode (the policy
** allows it, and they hold the write-mode permission when one is set).
*******************************************************************************/
public boolean writeModeAvailableTo(QSession session)
{
return (writeModeAllowed && (!StringUtils.hasContent(writeModePermission) || (session != null && session.hasPermission(writeModePermission.strip()))));
}
public static final int DEFAULT_MAX_ROWS_PER_CALL = 50;
public static final int MAX_ROWS_PER_CALL_CEILING = 200;
@@ -93,7 +136,7 @@ public class PolicyService
*******************************************************************************/
public static ToolPolicy defaults()
{
return (new ToolPolicy(true, Set.of(), DEFAULT_MAX_ROWS_PER_CALL, true));
return (new ToolPolicy(true, Set.of(), DEFAULT_MAX_ROWS_PER_CALL, true, true, null, false, false, false));
}
@@ -129,7 +172,8 @@ public class PolicyService
}
}
int maxRows = row.getMaxRowsPerCall() == null || row.getMaxRowsPerCall() < 1 ? DEFAULT_MAX_ROWS_PER_CALL : Math.min(MAX_ROWS_PER_CALL_CEILING, row.getMaxRowsPerCall());
return (new ToolPolicy(!Boolean.FALSE.equals(row.getWritesRequireConfirmation()), allowed, maxRows, !Boolean.FALSE.equals(row.getRedactPii())));
return (new ToolPolicy(!Boolean.FALSE.equals(row.getWritesRequireConfirmation()), allowed, maxRows, !Boolean.FALSE.equals(row.getRedactPii()),
!Boolean.FALSE.equals(row.getWriteModeAllowed()), row.getWriteModePermission(), Boolean.TRUE.equals(row.getAutoConfirmAllowed()), false, false));
}
}
@@ -17,18 +17,6 @@
package com.kingsrook.qbits.assistant.engine;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.provider.RegistryModelProvider;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
@@ -43,6 +31,18 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.provider.RegistryModelProvider;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -742,7 +742,7 @@ public class ProviderService
/*******************************************************************************
** P-e seam: no automatic alternative is selected in P-c. Never downgrade PHI eligibility.
*******************************************************************************/
public java.util.Optional<Active> eligibleAlternative(com.kingsrook.qbits.assistant.classification.DataClassification classification, AssistantProvider blockedProvider)
public java.util.Optional<Active> eligibleAlternative(com.kingsrook.qqq.backend.core.actions.ai.DataClassification classification, AssistantProvider blockedProvider)
{
return (java.util.Optional.empty());
}
@@ -27,11 +27,12 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.classification.ClassificationSource;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.classification.Classifications;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
@@ -55,7 +56,7 @@ public class TurnClassification
public TurnClassification(ClassificationSource source, AssistantConversation conversation, Map<String, Object> pageContext)
{
this.source = source;
classification = DataClassification.stored(conversation.getMaxClassification());
classification = Classifications.stored(conversation.getMaxClassification());
if(pageContext != null && pageContext.get("tableName") instanceof String table)
{
touch(table, pageContext.get("recordId") == null ? source.ofTable(table) : source.highest(table));
@@ -210,7 +211,7 @@ public class TurnClassification
*******************************************************************************/
private void touchUnknown()
{
classification = DataClassification.max(classification, source.highestAnywhere());
classification = Classifications.max(classification, source.highestAnywhere());
for(String table : QContext.getQInstance().getTables().keySet())
{
if(!table.startsWith("assistant_") && source.highest(table) != DataClassification.PUBLIC)
@@ -229,7 +230,7 @@ public class TurnClassification
{
if(table != null && !table.startsWith("assistant_"))
{
classification = DataClassification.max(classification, value);
classification = Classifications.max(classification, value);
if(QContext.getQInstance().getTable(table) != null)
{
tables.add(table);
@@ -256,7 +257,7 @@ public class TurnClassification
{
if(conversation != null)
{
classification = DataClassification.max(classification, DataClassification.stored(conversation.getMaxClassification()));
classification = Classifications.max(classification, Classifications.stored(conversation.getMaxClassification()));
}
}
@@ -22,10 +22,10 @@ package com.kingsrook.qbits.assistant.engine;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantDataAudit;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -43,6 +43,7 @@ import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.model.AssistantUserPreference;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.CacheHint;
@@ -58,8 +59,8 @@ import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
import com.kingsrook.qbits.assistant.tools.PresentResultsTool;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolCall;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
@@ -72,6 +73,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -133,7 +135,7 @@ public class TurnEngine implements Runnable
private long lastTextFlush = System.currentTimeMillis();
private AssistantScheduledTask scheduledTask;
private PolicyService.ToolPolicy toolPolicy;
private Instant startedAt;
private Instant deadline;
@@ -148,6 +150,8 @@ public class TurnEngine implements Runnable
private ProviderService.Active budgetOrigin;
private volatile boolean finished = false;
private UiApiCredentials credentials;
private PolicyService.ToolPolicy toolPolicy;
@@ -176,6 +180,19 @@ public class TurnEngine implements Runnable
/*******************************************************************************
** The credentials of the request that started the turn, forwarded to the
** UI API by the tools (session cookies, Authorization, API key as they came
** in). Without them the session UUID is used. Never logged.
*******************************************************************************/
public TurnEngine withCredentials(UiApiCredentials credentials)
{
this.credentials = credentials;
return (this);
}
/*******************************************************************************
** Run the turn to completion. Never throws.
*******************************************************************************/
@@ -198,7 +215,7 @@ public class TurnEngine implements Runnable
{
throw (new QException("Scheduled execution context is no longer available; start a new conversation."));
}
toolPolicy = scheduledTask == null ? policy.tools() : ScheduledTurn.tools(runtime, scheduledTask, policy.tools());
toolPolicy = scheduledTask == null ? toolPolicy() : ScheduledTurn.tools(runtime, scheduledTask, policy.tools());
ScheduledTurn.checkCost(runtime, conversation.getId(), turn.getId());
classification = new TurnClassification(config.getClassificationSource(), conversation, pageContext);
store.saveClassification(userId, conversation.getId(), turn.getId(), classification);
@@ -247,7 +264,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))
{
@@ -870,7 +887,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())
@@ -895,6 +912,34 @@ public class TurnEngine implements Runnable
/*******************************************************************************
** The policy's tool settings for this turn: Write mode only when the
** conversation is in Write mode and write mode is available to this user;
** auto-confirm only when the policy allows it and the user chose it.
*******************************************************************************/
PolicyService.ToolPolicy toolPolicy()
{
if(toolPolicy == null)
{
PolicyService.ToolPolicy base = policy.tools();
boolean write = Boolean.TRUE.equals(conversation.getWriteMode()) && base.writeModeAvailableTo(session);
boolean auto = false;
try
{
AssistantUserPreference preference = store.getPreference(userId);
auto = preference != null && Boolean.TRUE.equals(preference.getAutoConfirmLowRisk());
}
catch(QException | RuntimeException e)
{
LOG.debug("Could not read the user's assistant preferences", e);
}
toolPolicy = base.withWriteMode(write).withAutoConfirm(auto);
}
return (toolPolicy);
}
/*******************************************************************************
** Where a data tool runs: the user's own session (by its UUID, for the UI
** API), the gateway, the policy's tool settings, and the pending-action
@@ -917,7 +962,7 @@ public class TurnEngine implements Runnable
*******************************************************************************/
private UiApiToolbox.Context toolContext()
{
return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), toolPolicy, this::proposeWrite));
return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), toolPolicy, this::proposeWrite, credentials, turn.getId() == null ? null : String.valueOf(turn.getId())));
}
@@ -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());
}
}
@@ -47,6 +47,7 @@ import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
import com.kingsrook.qbits.assistant.model.AssistantUsage;
import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
import com.kingsrook.qbits.assistant.model.AssistantUserPreference;
import com.kingsrook.qbits.assistant.processes.AssistantRetentionStep;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData;
@@ -236,6 +237,10 @@ public class AssistantMetaDataBuilder
.withUniqueKey(new UniqueKey("profileId", "skillId"))
.withRecordLabelFormat("%s · %s").withRecordLabelFields("profileId", "skillId"));
tables.add(owned(base(AssistantUserPreference.TABLE_NAME, "Assistant User Preference", "tune", AssistantUserPreference.class), false)
.withUniqueKey(new UniqueKey("userId"))
.withRecordLabelFormat("%s").withRecordLabelFields("userId"));
tables.add(readOnly(owned(base(AssistantPendingAction.TABLE_NAME, "Assistant Pending Action", "pending_actions", AssistantPendingAction.class), false))
.withRecordLabelFormat("%s #%s").withRecordLabelFields("toolName", "id"));
text(tables.get(tables.size() - 1), "paramsJson");
@@ -0,0 +1,178 @@
/*
* 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.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.model.AssistantUserPreference;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceChange;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceProviderInterface;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRequestContext;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceValues;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
/*******************************************************************************
** Core preferences over the standard assistant_user_preference table. Core
** supplies the authenticated user and trusted scope, retaining audit identity.
*******************************************************************************/
public class AssistantPreferencesProvider implements UserPreferenceProviderInterface
{
public static final String SECTION = "assistant";
public static final String AUTO_CONFIRM_REASON = "Auto-confirm is not allowed by the assistant policy.";
/*******************************************************************************
** The admin app is intentionally not the scope: ordinary assistant users
** must see their own preferences without assistant.admin.
*******************************************************************************/
public static void register(QInstance qInstance)
{
UserPreferencesMetaData.ofOrWithNew(qInstance).withSection(new Section()
.withName(SECTION).withLabel("Assistant").withIcon(new QIcon("smart_toy"))
.withDescription("Defaults for new conversations and confirmation of changes.")
.withPermissionName(AssistantQBitConfig.ACCESS_PERMISSION)
.withProvider(new QCodeReference(AssistantPreferencesProvider.class)));
}
/*******************************************************************************
** Core only applies channel rules to app-scoped sections. Explicitly deny
** this unbound configuration section before loading or changing any rows.
*******************************************************************************/
private AssistantRuntime runtime(UserPreferenceRequestContext context) throws QException
{
if("assistant".equals(context.getSession().getClientChannelName()))
{
throw (new QNotFoundException("Preference section not found"));
}
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
AssistantSecurity.ownerKey(context.getSession(), runtime.getConfig());
return (runtime);
}
/*******************************************************************************
** Read only the context user's row, even if they hold an admin owner key.
*******************************************************************************/
@Override
public UserPreferenceValues load(UserPreferenceRequestContext context) throws QException
{
AssistantUserPreference preference = runtime(context).getStore().getPreference(context.getUserId());
return (new UserPreferenceValues().withValues(Map.of(
"defaultWriteMode", preference != null && Boolean.TRUE.equals(preference.getDefaultWriteMode()),
"autoConfirmLowRisk", preference != null && Boolean.TRUE.equals(preference.getAutoConfirmLowRisk()))));
}
/*******************************************************************************
** Re-read policy at the write boundary; the submitted defaults grant nothing.
*******************************************************************************/
@Override
public List<String> validate(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
{
PolicyService.ToolPolicy policy = runtime(context).getPolicyService().current(true).tools();
List<String> errors = new ArrayList<>();
if(change.getValues().containsKey("autoConfirmLowRisk") && !policy.autoConfirmAllowed())
{
errors.add("Auto-confirm low-risk changes: " + AUTO_CONFIRM_REASON);
}
if(Boolean.TRUE.equals(change.getValues().get("defaultWriteMode")) && !policy.writeModeAvailableTo(context.getSession()))
{
errors.add("Start conversations in Write mode: Write mode is not available to you under the assistant policy.");
}
return (errors);
}
/*******************************************************************************
** Apply only supplied fields; all ownership comes from the context.
*******************************************************************************/
@Override
public void store(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
{
AssistantRuntime runtime = runtime(context);
AssistantUserPreference preference = runtime.getStore().getPreference(context.getUserId());
if(preference == null)
{
preference = runtime.getStore().insert(new AssistantUserPreference().withUserId(context.getUserId())
.withDefaultWriteMode(false).withAutoConfirmLowRisk(false));
}
QRecord update = new QRecord().withValue("id", preference.getId());
change.getValues().forEach(update::setValue);
runtime.getStore().updateFields(AssistantUserPreference.TABLE_NAME, update);
}
/*******************************************************************************
** Return fresh field metadata for each request, so policy editability never
** mutates the shared instance metadata or leaks between concurrent users.
*******************************************************************************/
public static class Section extends UserPreferenceSectionMetaData
{
/*******************************************************************************
** Scalar read-only metadata uses the standard field help text for its reason.
*******************************************************************************/
@Override
public List<QFieldMetaData> getFields()
{
boolean autoConfirmAllowed = false;
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
if(runtime != null && QContext.getQSession() != null)
{
try
{
autoConfirmAllowed = runtime.getPolicyService().current(false).tools().autoConfirmAllowed();
}
catch(QException e)
{
// Fail closed when policy cannot be read; provider validation also rechecks it.
}
}
QFieldMetaData autoConfirm = new QFieldMetaData("autoConfirmLowRisk", QFieldType.BOOLEAN)
.withLabel("Auto-confirm low-risk changes").withIsEditable(autoConfirmAllowed);
if(!autoConfirmAllowed)
{
autoConfirm.withHelpContent(new QHelpContent(AUTO_CONFIRM_REASON));
}
return (List.of(new QFieldMetaData("defaultWriteMode", QFieldType.BOOLEAN).withLabel("Start conversations in Write mode"), autoConfirm));
}
}
}
@@ -574,8 +574,10 @@ public final class AssistantRecordScreens
.withHelpContent(new QHelpContent("The table or process name, e.g. blog_post. Tables without a row use the default: NONE for sensitive tables, READ for the others. Processes default to NONE."));
table.getField("mode").withLabel("Assistant access").withDefaultValue(DataAccessMode.READ.name())
.withHelpContent(new QHelpContent("NONE: the assistant never sees it. READ: the read tools may use it. READ and write: the create, update and delete tools may too. The assistant always acts with the user's own permissions and record locks; this can only narrow them. A sensitive table (AI exposure NONE) stays NONE."));
table.getField("autoConfirmOk").withLabel("Auto-confirm OK")
.withHelpContent(new QHelpContent("Single-record inserts and updates here count as low-risk: users who chose auto-confirm (where the policy allows it) skip the confirmation card. Never deletes."));
sections(table,
section("access", "Data Access", "admin_panel_settings", Tier.T1, "id", "objectType", "objectName", "mode"),
section("access", "Data Access", "admin_panel_settings", Tier.T1, "id", "objectType", "objectName", "mode", "autoConfirmOk"),
section("notes", "Notes", "notes", Tier.T2, "notes"));
}
@@ -712,6 +714,9 @@ public final class AssistantRecordScreens
help(table, "killSwitch", "On: the assistant takes no new requests from anyone.");
help(table, "killSwitchReason", "Shown to users while the kill switch is on.");
help(table, "writesRequireConfirmation", "On (or empty): every create, update or delete the assistant proposes waits for the user to confirm it in the panel. Off: writes run at once, still with the user's own permissions.");
help(table, "writeModeAllowed", "On (or empty): users may switch a conversation to Write mode, where the create, update and delete tools exist. Off: every conversation stays Read-only.");
help(table, "writeModePermission", "Optional: only users holding this permission (a role) may use Write mode.");
help(table, "autoConfirmAllowed", "On: users may choose to auto-confirm low-risk writes (single-record inserts and updates on tables marked auto-confirm OK). Never deletes, bulk writes or processes. Off (or empty): every write waits for confirmation.");
help(table, "allowedTools", "Comma-separated tool names the assistant may use (e.g. list_tables, query_records, count_records). Empty allows every tool.");
help(table, "maxRowsPerCall", "Most rows one tool call returns to the model. Empty: 50; at most 200.");
help(table, "redactPii", "On (or empty): API keys, tokens, private keys, card numbers and social security numbers are redacted from tool results before the model sees them.");
@@ -728,7 +733,7 @@ public final class AssistantRecordScreens
sections(table,
section("policy", "Policy", "policy", Tier.T1, "id", "name", "killSwitch", "killSwitchReason"),
section("model", "Model", "smart_toy", Tier.T2, "mainModel", "mainEffort"),
section("tools", "Tools and data", "build", Tier.T2, "writesRequireConfirmation", "allowedTools", "maxRowsPerCall", "redactPii"),
section("tools", "Tools and data", "build", Tier.T2, "writeModeAllowed", "writeModePermission", "writesRequireConfirmation", "autoConfirmAllowed", "allowedTools", "maxRowsPerCall", "redactPii"),
section("userDaily", "Per user, per day", "person", Tier.T2, "userDailyTokensSoft", "userDailyTokensHard", "userDailyCostMicrosSoft", "userDailyCostMicrosHard"),
section("instanceMonthly", "Whole instance, per month", "domain", Tier.T2, "instanceMonthlyTokensSoft", "instanceMonthlyTokensHard", "instanceMonthlyCostMicrosSoft", "instanceMonthlyCostMicrosHard"));
}
@@ -71,6 +71,9 @@ public class AssistantConversation extends QRecordEntity
@QField(isEditable = false)
private String contextJson;
@QField()
private Boolean writeMode;
@QField(isEditable = false)
private Long totalTokensIn;
@@ -509,6 +512,36 @@ public class AssistantConversation extends QRecordEntity
}
/*******************************************************************************
** Getter for writeMode (Write mode for this conversation (default Read-only); the write tools exist only in Write mode)
*******************************************************************************/
public Boolean getWriteMode()
{
return (this.writeMode);
}
/*******************************************************************************
** Setter for writeMode
*******************************************************************************/
public void setWriteMode(Boolean writeMode)
{
this.writeMode = writeMode;
}
/*******************************************************************************
** Fluent setter for writeMode
*******************************************************************************/
public AssistantConversation withWriteMode(Boolean writeMode)
{
this.writeMode = writeMode;
return (this);
}
/*******************************************************************************
** Getter for maxClassification
@@ -49,6 +49,9 @@ public class AssistantDataAccess extends QRecordEntity
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
private String notes;
@QField()
private Boolean autoConfirmOk;
@QField(isEditable = false)
private Instant createDate;
@@ -285,4 +288,34 @@ public class AssistantDataAccess extends QRecordEntity
return (this);
}
/*******************************************************************************
** Getter for autoConfirmOk (single-record inserts and updates here may be auto-confirmed (when the policy and the user allow it))
*******************************************************************************/
public Boolean getAutoConfirmOk()
{
return (this.autoConfirmOk);
}
/*******************************************************************************
** Setter for autoConfirmOk
*******************************************************************************/
public void setAutoConfirmOk(Boolean autoConfirmOk)
{
this.autoConfirmOk = autoConfirmOk;
}
/*******************************************************************************
** Fluent setter for autoConfirmOk
*******************************************************************************/
public AssistantDataAccess withAutoConfirmOk(Boolean autoConfirmOk)
{
this.autoConfirmOk = autoConfirmOk;
return (this);
}
}
@@ -94,6 +94,15 @@ public class AssistantPolicy extends QRecordEntity
@QField()
private Boolean redactPii;
@QField()
private Boolean writeModeAllowed;
@QField(maxLength = 128)
private String writeModePermission;
@QField()
private Boolean autoConfirmAllowed;
@QField(isEditable = false)
private Instant createDate;
@@ -735,4 +744,94 @@ public class AssistantPolicy extends QRecordEntity
return (this);
}
/*******************************************************************************
** Getter for writeModeAllowed (whether any conversation may switch to Write mode (default true))
*******************************************************************************/
public Boolean getWriteModeAllowed()
{
return (this.writeModeAllowed);
}
/*******************************************************************************
** Setter for writeModeAllowed
*******************************************************************************/
public void setWriteModeAllowed(Boolean writeModeAllowed)
{
this.writeModeAllowed = writeModeAllowed;
}
/*******************************************************************************
** Fluent setter for writeModeAllowed
*******************************************************************************/
public AssistantPolicy withWriteModeAllowed(Boolean writeModeAllowed)
{
this.writeModeAllowed = writeModeAllowed;
return (this);
}
/*******************************************************************************
** Getter for writeModePermission (only holders of this permission may switch to Write mode (empty: everyone))
*******************************************************************************/
public String getWriteModePermission()
{
return (this.writeModePermission);
}
/*******************************************************************************
** Setter for writeModePermission
*******************************************************************************/
public void setWriteModePermission(String writeModePermission)
{
this.writeModePermission = writeModePermission;
}
/*******************************************************************************
** Fluent setter for writeModePermission
*******************************************************************************/
public AssistantPolicy withWriteModePermission(String writeModePermission)
{
this.writeModePermission = writeModePermission;
return (this);
}
/*******************************************************************************
** Getter for autoConfirmAllowed (whether users may auto-confirm low-risk writes (default false))
*******************************************************************************/
public Boolean getAutoConfirmAllowed()
{
return (this.autoConfirmAllowed);
}
/*******************************************************************************
** Setter for autoConfirmAllowed
*******************************************************************************/
public void setAutoConfirmAllowed(Boolean autoConfirmAllowed)
{
this.autoConfirmAllowed = autoConfirmAllowed;
}
/*******************************************************************************
** Fluent setter for autoConfirmAllowed
*******************************************************************************/
public AssistantPolicy withAutoConfirmAllowed(Boolean autoConfirmAllowed)
{
this.autoConfirmAllowed = autoConfirmAllowed;
return (this);
}
}
@@ -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)
@@ -0,0 +1,252 @@
/*
* 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.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** A user's own assistant preferences: the default mode of new conversations and auto-confirm of low-risk writes (only where the policy allows it).
*******************************************************************************/
public class AssistantUserPreference extends QRecordEntity
{
public static final String TABLE_NAME = "assistant_user_preference";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isEditable = false, maxLength = 255)
private String userId;
@QField()
private Boolean defaultWriteMode;
@QField()
private Boolean autoConfirmLowRisk;
@QField(isEditable = false)
private Instant createDate;
@QField(isEditable = false)
private Instant modifyDate;
/*******************************************************************************
** Default constructor.
*******************************************************************************/
public AssistantUserPreference()
{
}
/*******************************************************************************
** Constructor that populates from a QRecord.
*******************************************************************************/
public AssistantUserPreference(QRecord record) throws QException
{
populateFromQRecord(record);
}
/*******************************************************************************
** Getter for id
*******************************************************************************/
public Integer getId()
{
return (this.id);
}
/*******************************************************************************
** Setter for id
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Fluent setter for id
*******************************************************************************/
public AssistantUserPreference withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Getter for userId
*******************************************************************************/
public String getUserId()
{
return (this.userId);
}
/*******************************************************************************
** Setter for userId
*******************************************************************************/
public void setUserId(String userId)
{
this.userId = userId;
}
/*******************************************************************************
** Fluent setter for userId
*******************************************************************************/
public AssistantUserPreference withUserId(String userId)
{
this.userId = userId;
return (this);
}
/*******************************************************************************
** Getter for defaultWriteMode
*******************************************************************************/
public Boolean getDefaultWriteMode()
{
return (this.defaultWriteMode);
}
/*******************************************************************************
** Setter for defaultWriteMode
*******************************************************************************/
public void setDefaultWriteMode(Boolean defaultWriteMode)
{
this.defaultWriteMode = defaultWriteMode;
}
/*******************************************************************************
** Fluent setter for defaultWriteMode
*******************************************************************************/
public AssistantUserPreference withDefaultWriteMode(Boolean defaultWriteMode)
{
this.defaultWriteMode = defaultWriteMode;
return (this);
}
/*******************************************************************************
** Getter for autoConfirmLowRisk
*******************************************************************************/
public Boolean getAutoConfirmLowRisk()
{
return (this.autoConfirmLowRisk);
}
/*******************************************************************************
** Setter for autoConfirmLowRisk
*******************************************************************************/
public void setAutoConfirmLowRisk(Boolean autoConfirmLowRisk)
{
this.autoConfirmLowRisk = autoConfirmLowRisk;
}
/*******************************************************************************
** Fluent setter for autoConfirmLowRisk
*******************************************************************************/
public AssistantUserPreference withAutoConfirmLowRisk(Boolean autoConfirmLowRisk)
{
this.autoConfirmLowRisk = autoConfirmLowRisk;
return (this);
}
/*******************************************************************************
** Getter for createDate
*******************************************************************************/
public Instant getCreateDate()
{
return (this.createDate);
}
/*******************************************************************************
** Setter for createDate
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Fluent setter for createDate
*******************************************************************************/
public AssistantUserPreference withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Getter for modifyDate
*******************************************************************************/
public Instant getModifyDate()
{
return (this.modifyDate);
}
/*******************************************************************************
** Setter for modifyDate
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Fluent setter for modifyDate
*******************************************************************************/
public AssistantUserPreference withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
}
@@ -29,7 +29,7 @@ import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.classification.Classifications;
import com.kingsrook.qbits.assistant.engine.TurnClassification;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantBudgetEvent;
@@ -51,6 +51,7 @@ import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
import com.kingsrook.qbits.assistant.model.AssistantUsage;
import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
import com.kingsrook.qbits.assistant.model.AssistantUserPreference;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTasks;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
@@ -384,6 +385,16 @@ public class AssistantStore
/*******************************************************************************
** The user's own preferences, or null.
*******************************************************************************/
public AssistantUserPreference getPreference(String userId) throws QException
{
return (first(query(AssistantUserPreference.TABLE_NAME, owned(userId).withLimit(1), AssistantUserPreference.class)));
}
/*******************************************************************************
** One of the user's own pending actions, or null.
*******************************************************************************/
@@ -788,8 +799,8 @@ public class AssistantStore
.withValue("classification", classification.getClassification().name()).withValue("classificationTables", classification.getTables()));
if(current != null)
{
var maximum = DataClassification.max(
DataClassification.stored(current.getMaxClassification()), classification.getClassification());
var maximum = Classifications.max(
Classifications.stored(current.getMaxClassification()), classification.getClassification());
if(!maximum.name().equals(current.getMaxClassification()))
{
updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", conversationId).withValue("maxClassification", maximum.name()));
@@ -23,10 +23,10 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
import com.kingsrook.qbits.assistant.rag.RagService;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
@@ -25,7 +25,6 @@ import java.util.Objects;
import java.util.concurrent.Semaphore;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.engine.BudgetService;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.ProviderService;
@@ -36,6 +35,7 @@ import com.kingsrook.qbits.assistant.provider.EmbeddingResponse;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -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)));
}
@@ -223,7 +223,7 @@ public class RagService
checkKillSwitch();
filter.setSkip(skip);
UiApiResult page = runtime.getUiApiGateway().invoke(new UiApiCall("POST", "/table/{tableName}/query").withPathParam("tableName", source.getTableName())
.withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()).withJsonBody("{\"filter\":" + JsonUtils.toJson(filter) + "}"));
.withSessionUUID(context.sessionUUID()).withCredentials(context.credentials()).withUserTimezone(context.userTimezone()).withJsonBody("{\"filter\":" + JsonUtils.toJson(filter) + "}"));
if(!page.isSuccess())
{
throw (new QException("Index query answered HTTP " + page.status() + "; existing chunks were preserved."));
@@ -40,6 +40,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
@@ -118,7 +119,7 @@ public class ScheduledTaskRunner
{
session = login.login(runtime, task);
QContext.init(runtime.getQInstance(), session);
AssistantApi api = new AssistantApi(runtime, session);
AssistantApi api = new AssistantApi(runtime, session).withCredentials(UiApiCredentials.ofSessionUUID(session.getUuid()));
Integer conversationId = ((Number) api.createConversation(Map.of("title", task.getName())).get("id")).intValue();
run.setConversationId(conversationId);
store.updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", conversationId)
@@ -23,14 +23,13 @@ package com.kingsrook.qbits.assistant.scheduled;
import java.math.BigDecimal;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.engine.BudgetService;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.model.AssistantUsage;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
@@ -103,14 +102,9 @@ public class ScheduledTurn
*******************************************************************************/
public static PolicyService.ToolPolicy tools(AssistantRuntime runtime, AssistantScheduledTask task, PolicyService.ToolPolicy policy) throws QException
{
Set<String> allowed = runtime.getContextBuilder().toolNames(policy).stream()
.filter(name -> !Set.of("create_record", "update_record", "delete_record").contains(name) || approved(task))
.collect(Collectors.toSet());
if(allowed.isEmpty())
{
allowed = Set.of("__no_tools__");
}
return (new PolicyService.ToolPolicy(true, allowed, policy.maxRowsPerCall(), policy.redactPii()));
boolean write = approved(task) && policy.writeModeAvailableTo(QContext.getQSession());
return (new PolicyService.ToolPolicy(true, policy.allowedTools(), policy.maxRowsPerCall(), policy.redactPii(),
policy.writeModeAllowed(), policy.writeModePermission(), policy.autoConfirmAllowed(), write, false));
}
@@ -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));
}
}
@@ -25,7 +25,6 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.TurnEngine;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
@@ -37,6 +36,7 @@ import com.kingsrook.qbits.assistant.rag.VectorStore;
import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiDataEnvelope;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.session.QSession;
@@ -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;
@@ -187,7 +186,7 @@ public class PresentResultsTool
{
try
{
metadata = context.gateway().invoke(new UiApiCall("GET", "/metaData").withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()));
metadata = context.gateway().invoke(context.prepare(new UiApiCall("GET", "/metaData")));
}
catch(QException e)
{
@@ -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)));
}
@@ -47,6 +47,7 @@ import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiJsonSchema;
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolContext;
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolException;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
@@ -56,9 +57,11 @@ import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
@@ -150,8 +153,37 @@ public class UiApiToolbox
** Where a tool call runs: the user's session, the gateway, the policy, and
** what to do with a write that needs confirmation.
*******************************************************************************/
public record Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions)
public record Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions, UiApiCredentials credentials, String turnId)
{
/*******************************************************************************
** With the session UUID only (no request credentials captured).
*******************************************************************************/
public Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions)
{
this(sessionUUID, userTimezone, gateway, policy, pendingActions, null, null);
}
/*******************************************************************************
** With the request credentials, outside a turn.
*******************************************************************************/
public Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions, UiApiCredentials credentials)
{
this(sessionUUID, userTimezone, gateway, policy, pendingActions, credentials, null);
}
/*******************************************************************************
** A call made by this context: the caller's credentials, timezone and
** the client detail (the turn id, labelled assistant/turnId in the access
** log) filled in.
*******************************************************************************/
public UiApiCall prepare(UiApiCall call)
{
return (call.withSessionUUID(sessionUUID).withCredentials(credentials).withUserTimezone(userTimezone).withClientDetail(turnId));
}
}
@@ -328,11 +360,37 @@ public class UiApiToolbox
byName.put(DELETE_RECORD, tool(DELETE_RECORD, "Delete a record. The user confirms it in the panel before anything is deleted, unless the administrator turned confirmation off.",
AiJsonSchema.object(AiJsonSchema.properties("table", AiJsonSchema.string("Table name from list_tables"), "id", AiJsonSchema.string("The record's primary key value.")), List.of("table", "id"))));
if(!historyAllowed())
{
byName.remove(RECORD_HISTORY);
}
return (new ArrayList<>(byName.values()));
}
/*******************************************************************************
** 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()
{
for(String name : List.of(AuditsMetaDataProvider.TABLE_NAME_AUDIT_TABLE, AuditsMetaDataProvider.TABLE_NAME_AUDIT, AuditsMetaDataProvider.TABLE_NAME_AUDIT_DETAIL))
{
QTableMetaData table = qInstance.getTable(name);
if(table == null || ClientChannelsMetaData.of(qInstance).isDenied("assistant", table.getName(), PermissionsHelper.getEffectivePermissionRules(table, qInstance)))
{
return (false);
}
}
return (true);
}
/*******************************************************************************
** The definitions a policy allows; write tools only when some table allows
** writes (anyWritableTable).
@@ -342,7 +400,8 @@ public class UiApiToolbox
List<ModelToolDefinition> rs = new ArrayList<>();
for(ModelToolDefinition definition : definitions())
{
if((policy == null || policy.allows(definition.getName())) && (anyWritableTable || !WRITE_TOOLS.contains(definition.getName())))
boolean write = WRITE_TOOLS.contains(definition.getName());
if((policy == null || policy.allows(definition.getName())) && (!write || (anyWritableTable && (policy == null || policy.writeMode()))))
{
rs.add(definition);
}
@@ -390,6 +449,11 @@ public class UiApiToolbox
deny(result, "policy:tool", "The administrator has not allowed the " + toolName + " tool.");
return (outcome);
}
if(WRITE_TOOLS.contains(toolName) && context.policy() != null && !context.policy().writeMode())
{
deny(result, "mode:READ_ONLY", "This conversation is in Read-only mode: the assistant cannot change data. The user can switch it to Write mode in the panel.");
return (outcome);
}
if(!context.gateway().isInvocable(operation[0], operation[1]))
{
deny(result, "uiApi:notSpecd", "The operation " + outcome.operation + " is not a spec'd UI API operation.");
@@ -658,7 +722,7 @@ public class UiApiToolbox
if(truncated)
{
UiApiResult counted = context.gateway().invoke(new UiApiCall("POST", "/table/{tableName}/count").withPathParam("tableName", tableName).withJsonBody(filterBody(linkFilter))
.withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()));
.withSessionUUID(context.sessionUUID()).withCredentials(context.credentials()).withUserTimezone(context.userTimezone()).withClientDetail(context.turnId()));
totalCount = counted.isSuccess() ? asMap(counted.json()).get("count") : null;
}
@@ -1153,7 +1217,12 @@ public class UiApiToolbox
}
}
if(context.policy() == null || context.policy().writesRequireConfirmation())
///////////////////////////////////////////////////////////////////
// auto-confirm: only when the policy allows it, the user chose //
// it, and the admin marked the table low-risk; never deletes //
///////////////////////////////////////////////////////////////////
boolean autoConfirmed = context.policy() != null && context.policy().autoConfirm() && !DELETE_RECORD.equals(toolName) && dataAccess.autoConfirmOk(tableName);
if(!autoConfirmed && (context.policy() == null || context.policy().writesRequireConfirmation()))
{
String summary = describeWrite(toolName, table, recordId, values);
Integer actionId = context.pendingActions() == null ? null
@@ -1230,10 +1299,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))
@@ -1255,7 +1333,7 @@ public class UiApiToolbox
*******************************************************************************/
private UiApiResult call(Context context, Outcome outcome, UiApiCall call) throws QException
{
call.withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()).withInvokedBy("assistant");
context.prepare(call).withInvokedBy("assistant");
outcome.operation = call.operation();
UiApiResult response = context.gateway().invoke(call);
outcome.httpStatus = response.status();
@@ -19,6 +19,7 @@ package com.kingsrook.qbits.assistant.tools.bulkload;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
@@ -31,8 +32,15 @@ public interface BulkLoadRunner
/*******************************************************************************
** A load to start: the target table, the upload, and the saved profile.
*******************************************************************************/
record Request(String sessionUUID, String userTimezone, UiApiGateway gateway, String tableName, String uploadRef, Integer savedBulkLoadProfileId)
record Request(String sessionUUID, String userTimezone, UiApiGateway gateway, String tableName, String uploadRef, Integer savedBulkLoadProfileId, UiApiCredentials credentials, String turnId)
{
/*******************************************************************************
** Without request credentials or a turn.
*******************************************************************************/
public Request(String sessionUUID, String userTimezone, UiApiGateway gateway, String tableName, String uploadRef, Integer savedBulkLoadProfileId)
{
this(sessionUUID, userTimezone, gateway, tableName, uploadRef, savedBulkLoadProfileId, null, null);
}
}
@@ -151,7 +151,7 @@ public class BulkLoadTools
AiJsonSchema.object(AiJsonSchema.properties("uploadRef", upload, "tableName", table, "profile", profile,
"mode", AiJsonSchema.enumOf(List.of("SAMPLE", "FULL"), "SAMPLE (default) or FULL.")), List.of("uploadRef", "tableName", "profile")));
if(anyWritable)
if(anyWritable && (policy == null || policy.writeMode()))
{
Map<String, Object> share = AiJsonSchema.object(AiJsonSchema.properties(
"audienceType", AiJsonSchema.enumOf(List.of("user", "group"), "Share with a user (email) or a group."),
@@ -205,6 +205,12 @@ public class BulkLoadTools
return (UiApiToolbox.Outcome.of(result, null, null, null));
}
if(WRITE_TOOLS.contains(toolName) && context.policy() != null && !context.policy().writeMode())
{
deny(result, "mode:readOnly", "This conversation is Read-only. Switch to Write mode to make changes.");
return (UiApiToolbox.Outcome.of(result, null, null, null));
}
String tableName = in.get("tableName") instanceof String t ? t.strip() : null;
if(tableName != null)
{
@@ -541,7 +547,7 @@ public class BulkLoadTools
private void run(Call call, AiToolResult result, UiApiToolbox.Context context, String tableName, Map<String, Object> values) throws QException
{
BulkLoadRunner.RunHandle handle = runner.start(new BulkLoadRunner.Request(context.sessionUUID(), context.userTimezone(), context.gateway(), tableName,
String.valueOf(values.get("uploadRef")), ValueUtils.getValueAsInteger(values.get("profileId"))));
String.valueOf(values.get("uploadRef")), ValueUtils.getValueAsInteger(values.get("profileId")), context.credentials(), context.turnId()));
call.operation = handle.operation();
call.httpStatus = handle.httpStatus();
boolean ok = !"FAILED".equals(handle.status()) && !"NOT_AVAILABLE".equals(handle.status());
@@ -863,7 +869,7 @@ public class BulkLoadTools
{
return (null);
}
uiApiCall.withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()).withInvokedBy("assistant")
context.prepare(uiApiCall).withInvokedBy("assistant")
.withHeader(ROUTE_HEADER, hipaaRoute ? "hipaa" : "standard");
UiApiResult response = context.gateway().invoke(uiApiCall);
httpStatus = response.status();
@@ -70,6 +70,8 @@ public class JobsLaunchBulkLoadRunner implements BulkLoadRunner
.withPathParam("processName", processName)
.withJsonBody(body)
.withSessionUUID(request.sessionUUID())
.withCredentials(request.credentials())
.withClientDetail(request.turnId())
.withUserTimezone(request.userTimezone())
.withInvokedBy("assistant"));
@@ -0,0 +1,170 @@
/*
* 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.uiapi;
import java.util.Locale;
import java.util.function.Supplier;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.HeaderedUiApiInvoker;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiInvoker;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiResponse;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import com.kingsrook.qqq.openapi.model.HttpMethod;
/*******************************************************************************
** The assistant's gateway over core's {@link UiApiInvoker} (QRun-IO/qqq#1128,
** docs/UI-API-EXTENSIONS.md section 5): only spec'd /qqq/v1 operations (core
** and registered extensions), a loopback request through the identical
** Javalin pipeline, carrying the caller's own credentials exactly as they
** came in (session cookies, Authorization, API key). There is no service
** session and no way to elevate.
*******************************************************************************/
public class CoreUiApiGateway implements UiApiGateway
{
public static final String OWN_PREFIX = "/assistant";
public static final String CLIENT_CHANNEL = "assistant";
private final Supplier<String> baseUrlSupplier;
private final QInstance qInstance;
private volatile UiApiInvoker invoker;
/*******************************************************************************
** @param baseUrlSupplier this server's own root URL (e.g. http://127.0.0.1:8000)
** @param qInstance the instance (its spec extensions are invocable too)
*******************************************************************************/
public CoreUiApiGateway(Supplier<String> baseUrlSupplier, QInstance qInstance)
{
this.baseUrlSupplier = baseUrlSupplier;
this.qInstance = qInstance;
}
/*******************************************************************************
** The invoker, over a v1 middleware version that knows the instance's
** extensions (built on first use: the instance is complete by then).
*******************************************************************************/
UiApiInvoker invoker()
{
UiApiInvoker current = invoker;
if(current == null)
{
MiddlewareVersionV1 version = new MiddlewareVersionV1();
version.setQInstance(qInstance);
////////////////////////////////////////////////////////////////////////////
// the same as UiApiInvoker.forServer(server, "assistant"): this server's //
// loopback address, its first version, the client channel "assistant" //
// (a call adds only the detail after the slash: the turn id) //
////////////////////////////////////////////////////////////////////////////
current = new HeaderedUiApiInvoker(baseUrlSupplier, version).withClientChannel(CLIENT_CHANNEL);
invoker = current;
}
return (current);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean isInvocable(String method, String pathTemplate)
{
///////////////////////////////////////////////////////////////////
// never the assistant's own operations (no recursion, no turns //
// started by a turn) //
///////////////////////////////////////////////////////////////////
if(pathTemplate == null || pathTemplate.equals(OWN_PREFIX) || pathTemplate.startsWith(OWN_PREFIX + "/"))
{
return (false);
}
try
{
return (invoker().findOperation(HttpMethod.valueOf(method.toUpperCase(Locale.ROOT)), pathTemplate).isPresent());
}
catch(IllegalArgumentException e)
{
return (false);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call) throws QException
{
if(call == null || call.getMethod() == null || call.getPathTemplate() == null)
{
throw (new QException("A UI API call needs a method and a path template"));
}
if(!isInvocable(call.getMethod(), call.getPathTemplate()))
{
throw (new QException("Not a spec'd UI API operation: " + call.operation()));
}
UiApiCredentials credentials = call.getCredentials() != null ? call.getCredentials()
: (call.getSessionUUID() == null ? null : UiApiCredentials.ofSessionUUID(call.getSessionUUID()));
if(credentials == null || !credentials.isPresent())
{
throw (new QException("A session is required to invoke " + call.operation()));
}
HeaderedUiApiInvoker.HeaderedRequest request = new HeaderedUiApiInvoker.HeaderedRequest();
request.withMethod(HttpMethod.valueOf(call.getMethod().toUpperCase(Locale.ROOT)))
.withPath(call.getPathTemplate())
.withCredentials(credentials)
.withUserTimezone(call.getUserTimezone())
.withInvokedBy(call.getInvokedBy())
.withTimeout(call.getTimeout())
.withClientDetail(call.getClientDetail());
call.getHeaders().forEach(request::withHeader);
call.getPathParams().forEach(request::withPathParam);
call.getQueryParams().forEach(request::withQueryParam);
call.getFormParams().forEach(request::withFormParam);
if(call.getJsonBody() instanceof String json)
{
////////////////////////////////////////////////////////////////
// a pre-serialized body (e.g. a QQueryFilter as QQQ writes //
// it): parse it, the invoker serializes the object //
////////////////////////////////////////////////////////////////
try
{
request.withJsonBody(JsonUtils.toObject(json, Object.class));
}
catch(Exception e)
{
throw (new QException("The body of " + call.operation() + " is not valid JSON", e));
}
}
else if(call.getJsonBody() != null)
{
request.withJsonBody(call.getJsonBody());
}
UiApiResponse response = invoker().invoke(request);
return (new UiApiResult(response.getStatus(), response.getBody()));
}
}
@@ -1,286 +0,0 @@
/*
* 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.uiapi;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.StringJoiner;
import java.util.UUID;
import java.util.function.Supplier;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.AbstractEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.CompleteOperation;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
/*******************************************************************************
** Stand-in for core's `UiApiInvoker` (QRun-IO/qqq#1128; REPLACE with an
** adapter over it once a QQQ release has it). Same contract:
**
** - only spec'd `/qqq/v1` operations (from {@link MiddlewareVersionV1}'s
** endpoint specs) can be called; anything else is refused before a request
** is made, so raw routes (including the assistant's own) are unreachable;
** - a loopback HTTP request to this server, carrying the user's own session
** cookie: the identical Javalin pipeline (session setup, permission checks,
** record locks, validation, audit, error mapping) the UI goes through. There
** is no service session and no way to elevate;
** - X-QQQ-Invoked-By marks the request for the access log.
**
** Why loopback rather than calling spec executors directly: buildInput, the
** version's preExecute, filters and error mapping hang off the Javalin
** pipeline; calling executors would skip some of them and drift over time.
*******************************************************************************/
public class LoopbackUiApiGateway implements UiApiGateway
{
public static final String BASE_PATH = "/qqq/v1";
public static final String INVOKED_BY_HEADER = "X-QQQ-Invoked-By";
public static final String TIMEZONE_HEADER = "X-QQQ-UserTimezone";
/** Headers a call cannot set itself (lower case). */
private static final Set<String> RESERVED_HEADERS = Set.of("cookie", "authorization", "x-api-key", "content-type", "accept", INVOKED_BY_HEADER.toLowerCase(), TIMEZONE_HEADER.toLowerCase());
private static final Set<String> SPEC_OPERATIONS = specOperations();
private final Supplier<String> baseUrlSupplier;
private final boolean mockAuthentication;
private final HttpClient httpClient;
/*******************************************************************************
** @param baseUrlSupplier the server's own root URL, e.g. http://127.0.0.1:8000
** @param qInstance the instance (MOCK authentication reads the sessionId
** cookie rather than sessionUUID, so both are sent there)
*******************************************************************************/
public LoopbackUiApiGateway(Supplier<String> baseUrlSupplier, QInstance qInstance)
{
this.baseUrlSupplier = baseUrlSupplier;
this.mockAuthentication = qInstance != null && qInstance.getAuthentication() != null && QAuthenticationType.MOCK.equals(qInstance.getAuthentication().getType());
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).version(HttpClient.Version.HTTP_1_1).build();
}
/*******************************************************************************
** "METHOD /path/template" of every v1 endpoint spec.
*******************************************************************************/
static Set<String> specOperations()
{
Set<String> operations = new LinkedHashSet<>();
for(AbstractEndpointSpec<?, ?, ?> spec : new MiddlewareVersionV1().getEndpointSpecs())
{
CompleteOperation operation = spec.defineCompleteOperation();
operations.add(operation.getHttpMethod().name() + " " + operation.getPath());
}
return (Collections.unmodifiableSet(operations));
}
/*******************************************************************************
** The spec'd operations ("METHOD template").
*******************************************************************************/
public static Set<String> getSpecOperations()
{
return (SPEC_OPERATIONS);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean isInvocable(String method, String pathTemplate)
{
return (SPEC_OPERATIONS.contains(method + " " + pathTemplate));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call) throws QException
{
if(call == null || !StringUtils.hasContent(call.getMethod()) || !StringUtils.hasContent(call.getPathTemplate()))
{
throw (new QException("A UI API call needs a method and a path template"));
}
if(!isInvocable(call.getMethod(), call.getPathTemplate()))
{
throw (new QException("Not a spec'd UI API operation: " + call.operation()));
}
if(!StringUtils.hasContent(call.getSessionUUID()))
{
throw (new QException("A session is required to invoke " + call.operation()));
}
HttpRequest request = buildRequest(call);
try
{
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return (new UiApiResult(response.statusCode(), response.body()));
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw (new QException("Interrupted invoking " + call.operation(), e));
}
catch(IOException e)
{
throw (new QException("Error invoking " + call.operation() + ": " + e.getMessage(), e));
}
}
/*******************************************************************************
** Add server-owned client attribution for a scheduled turn. All operation,
** session, encoding and transport rules remain those of this gateway.
*******************************************************************************/
public UiApiResult invokeWithClient(UiApiCall call, String clientChannel) throws QException
{
if(call == null || !isInvocable(call.getMethod(), call.getPathTemplate()) || !StringUtils.hasContent(call.getSessionUUID()))
{
throw (new QException("A scheduled UI API call requires a spec'd operation and a session."));
}
try
{
HttpRequest request = HttpRequest.newBuilder(buildRequest(call), (name, value) -> true).setHeader("X-QQQ-Client", clientChannel).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return (new UiApiResult(response.statusCode(), response.body()));
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw (new QException("Scheduled UI API request interrupted."));
}
catch(IOException e)
{
throw (new QException("Scheduled UI API request failed."));
}
}
/*******************************************************************************
** The HTTP request: path parameters filled and encoded, query string, JSON
** body, the session cookie and the marker headers.
*******************************************************************************/
HttpRequest buildRequest(UiApiCall call) throws QException
{
String path = call.getPathTemplate();
for(Map.Entry<String, String> entry : call.getPathParams().entrySet())
{
if(entry.getValue() == null)
{
throw (new QException("Missing path parameter " + entry.getKey() + " for " + call.operation()));
}
path = path.replace("{" + entry.getKey() + "}", encode(entry.getValue()));
}
if(path.contains("{"))
{
throw (new QException("Missing path parameter(s) for " + call.operation() + ": " + path));
}
StringJoiner query = new StringJoiner("&", "?", "").setEmptyValue("");
call.getQueryParams().forEach((name, value) -> query.add(encode(name) + "=" + encode(value)));
String base = baseUrlSupplier.get();
if(!StringUtils.hasContent(base))
{
throw (new QException("The UI API base URL is not known yet"));
}
URI uri = URI.create(base.replaceAll("/+$", "") + BASE_PATH + path + query);
HttpRequest.Builder builder = HttpRequest.newBuilder(uri).timeout(call.getTimeout() == null ? Duration.ofSeconds(60) : call.getTimeout());
String cookie = "sessionUUID=" + call.getSessionUUID();
if(mockAuthentication)
{
cookie += "; sessionId=" + call.getSessionUUID();
}
builder.header("Cookie", cookie);
builder.header("Accept", "application/json");
builder.header(INVOKED_BY_HEADER, StringUtils.hasContent(call.getInvokedBy()) ? call.getInvokedBy() : "assistant");
if(StringUtils.hasContent(call.getUserTimezone()))
{
builder.header(TIMEZONE_HEADER, call.getUserTimezone());
}
call.getHeaders().forEach((name, value) ->
{
if(!RESERVED_HEADERS.contains(name.toLowerCase()))
{
builder.header(name, value);
}
});
if(!call.getFormParams().isEmpty())
{
////////////////////////////////////////////////////////////////
// multipart/form-data text fields (the /processes endpoints) //
////////////////////////////////////////////////////////////////
String boundary = "qqq-assistant-" + UUID.randomUUID();
StringBuilder body = new StringBuilder();
String lineEnd = "\r\n";
call.getFormParams().forEach((name, value) -> body.append("--").append(boundary).append(lineEnd)
.append("Content-Disposition: form-data; name=\"").append(name.replace("\"", "")).append("\"").append(lineEnd)
.append("Content-Type: text/plain; charset=UTF-8").append(lineEnd).append(lineEnd)
.append(value).append(lineEnd));
body.append("--").append(boundary).append("--").append(lineEnd);
builder.header("Content-Type", "multipart/form-data; boundary=" + boundary);
builder.method(call.getMethod(), HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8));
}
else if(call.getJsonBody() != null)
{
builder.header("Content-Type", "application/json");
builder.method(call.getMethod(), HttpRequest.BodyPublishers.ofString(call.getJsonBody() instanceof String s ? s : JsonUtils.toJson(call.getJsonBody()), StandardCharsets.UTF_8));
}
else
{
builder.method(call.getMethod(), HttpRequest.BodyPublishers.noBody());
}
return (builder.build());
}
/*******************************************************************************
**
*******************************************************************************/
private static String encode(String value)
{
return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"));
}
}
@@ -20,6 +20,7 @@ package com.kingsrook.qbits.assistant.uiapi;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
@@ -37,7 +38,9 @@ public class UiApiCall
private final Map<String, String> headers = new LinkedHashMap<>();
private Object jsonBody;
private String sessionUUID;
private UiApiCredentials credentials;
private String userTimezone;
private String clientDetail;
private String invokedBy = "assistant";
private Duration timeout = Duration.ofSeconds(60);
@@ -215,6 +218,29 @@ public class UiApiCall
/*******************************************************************************
** The caller's credentials exactly as their request carried them (session
** cookies, Authorization, API key); preferred over the session UUID alone.
** Never logged (their toString is redacted).
*******************************************************************************/
public UiApiCall withCredentials(UiApiCredentials credentials)
{
this.credentials = credentials;
return (this);
}
/*******************************************************************************
** Getter for credentials
*******************************************************************************/
public UiApiCredentials getCredentials()
{
return (credentials);
}
/*******************************************************************************
** Getter for sessionUUID
*******************************************************************************/
@@ -246,6 +272,29 @@ public class UiApiCall
/*******************************************************************************
** Fluent setter for clientDetail: the detail after the slash of the client
** channel the invoker labels the call with (assistant/turnId; a scheduled
** run uses scheduled/taskId). Ignored when blank.
*******************************************************************************/
public UiApiCall withClientDetail(String clientDetail)
{
this.clientDetail = clientDetail;
return (this);
}
/*******************************************************************************
** Getter for clientDetail
*******************************************************************************/
public String getClientDetail()
{
return (clientDetail);
}
/*******************************************************************************
** Fluent setter for invokedBy (sent as X-QQQ-Invoked-By)
*******************************************************************************/
@@ -25,9 +25,8 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
** user's own session, so every tool call passes the same authentication,
** permission, record-lock, validation and audit path as the UI.
**
** {@link LoopbackUiApiGateway} is a stand-in until core's `UiApiInvoker`
** (QRun-IO/qqq#1128) is released. Then an adapter over the invoker replaces
** it, and nothing else changes.
** {@link CoreUiApiGateway} implements it over core's `UiApiInvoker`
** (QRun-IO/qqq#1128); a host may inject another (tests).
*******************************************************************************/
public interface UiApiGateway
{
@@ -0,0 +1,126 @@
/*
* 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.qqq.middleware.javalin.specs.invoke;
import java.net.http.HttpRequest;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.middleware.javalin.specs.AbstractMiddlewareVersion;
/*******************************************************************************
** Core's {@link UiApiInvoker} plus extra request headers (the assistant's
** X-QQQ-AI-Route). Core's {@link UiApiRequest} carries none, and the hook that
** builds the HTTP request is package-private, so this subclass lives in core's
** package to override it. Everything else (the operation allow-list, the
** credentials, the client channel, the depth limit) is core's own.
**
** STAND-IN(core=QRun-IO/qqq#1190, removeBy=RC.8): delete this class and use
** UiApiRequest.withHeader once the qbit builds on a QQQ RC that has it.
*******************************************************************************/
public class HeaderedUiApiInvoker extends UiApiInvoker
{
/** Headers a request cannot set itself (lower case): core sets these. */
private static final Set<String> RESERVED_HEADERS = Set.of("cookie", "authorization", "x-api-key", "content-type", "accept",
INVOKED_BY_HEADER.toLowerCase(Locale.ROOT), TIMEZONE_HEADER.toLowerCase(Locale.ROOT), CLIENT_CHANNEL_HEADER.toLowerCase(Locale.ROOT));
/*******************************************************************************
** @param baseUrlSupplier the server's own root URL
** @param middlewareVersion the version whose operations can be invoked
*******************************************************************************/
public HeaderedUiApiInvoker(Supplier<String> baseUrlSupplier, AbstractMiddlewareVersion middlewareVersion)
{
super(baseUrlSupplier, middlewareVersion);
}
/*******************************************************************************
** Core's request, then the extra headers of a {@link HeaderedRequest}
** (never the reserved ones).
*******************************************************************************/
@Override
HttpRequest buildHttpRequest(UiApiOperation operation, UiApiRequest request) throws QException
{
HttpRequest built = super.buildHttpRequest(operation, request);
HttpRequest.Builder builder = HttpRequest.newBuilder(built, (name, value) -> true);
////////////////////////////////////////////////////////////////////////////
// RC.6 strips slashes from client detail. Preserve nested attribution //
// while keeping the channel chosen by core, never by extra headers. //
////////////////////////////////////////////////////////////////////////////
if(request.getClientDetail() != null && request.getClientDetail().contains("/"))
{
String channel = built.headers().firstValue(CLIENT_CHANNEL_HEADER).orElseThrow().split("/", 2)[0];
String detail = request.getClientDetail().replaceAll("[^A-Za-z0-9._:/-]", "");
builder.setHeader(CLIENT_CHANNEL_HEADER, channel + "/" + detail);
}
if(!(request instanceof HeaderedRequest headered))
{
return (builder.build());
}
headered.getHeaders().forEach((name, value) ->
{
if(!RESERVED_HEADERS.contains(name.toLowerCase(Locale.ROOT)))
{
builder.header(name, value);
}
});
return (builder.build());
}
/*******************************************************************************
** A UI API request that also carries extra headers.
*******************************************************************************/
public static class HeaderedRequest extends UiApiRequest
{
private final Map<String, String> headers = new LinkedHashMap<>();
/*******************************************************************************
** Add a header (ignored when the name or value is null).
*******************************************************************************/
public HeaderedRequest withHeader(String name, String value)
{
if(name != null && value != null)
{
headers.put(name, value);
}
return (this);
}
/*******************************************************************************
** Getter for headers
*******************************************************************************/
public Map<String, String> getHeaders()
{
return (headers);
}
}
}
File diff suppressed because it is too large. Load diff
@@ -24,7 +24,8 @@ import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
import com.kingsrook.qbits.assistant.model.AssistantSkill;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.LoopbackUiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.CoreUiApiGateway;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
@@ -176,9 +177,10 @@ class ArchitectureTest
{
assertThat(ContextBuilder.KERNEL_TOOLS).containsExactlyInAnyOrder("navigate", "search_help");
assertThat(UiApiToolbox.OPERATIONS.keySet()).doesNotContainAnyElementsOf(ContextBuilder.KERNEL_TOOLS);
CoreUiApiGateway gateway = new CoreUiApiGateway(() -> "http://127.0.0.1:1", new QInstance());
for(String[] operation : UiApiToolbox.OPERATIONS.values())
{
assertThat(LoopbackUiApiGateway.getSpecOperations()).as("spec'd: " + operation[0] + " " + operation[1]).contains(operation[0] + " " + operation[1]);
assertThat(gateway.isInvocable(operation[0], operation[1])).as("spec'd: " + operation[0] + " " + operation[1]).isTrue();
}
}
@@ -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,183 @@
/*
* 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.api;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.NO_ACCESS;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.OTHER_EDITOR;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The core preferences HTTP contract, user isolation, policy and channel wall.
*******************************************************************************/
class AssistantPreferencesTest extends BaseAssistantTest
{
private String base;
private final HttpClient client = HttpClient.newHttpClient();
/*******************************************************************************
** Serve the same core specs as a host application.
*******************************************************************************/
@BeforeEach
void start()
{
QContext.clear();
base = UiApiTestServer.serve(qInstance, config) + "/qqq/v1";
}
/*******************************************************************************
** Authenticate as a human user; no identity is accepted in the body.
*******************************************************************************/
private HttpResponse<String> send(String method, String path, String user, String channel, String body) throws Exception
{
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(base + path))
.header("Cookie", "sessionId=" + user).header("X-QQQ-Client", channel);
if(body != null)
{
request.header("Content-Type", "application/json");
}
return (client.send(request.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)).build(),
HttpResponse.BodyHandlers.ofString()));
}
/*******************************************************************************
** A regular assistant user sees the section, without admin table permissions.
*******************************************************************************/
@Test
void visibilityAndRemovedEndpoint() throws Exception
{
HttpResponse<String> visible = send("GET", "/preferences/assistant", EDITOR, "next-ui", null);
assertThat(visible.statusCode()).as(visible.body()).isEqualTo(200);
assertThat(new JSONObject(visible.body()).getString("name")).isEqualTo("assistant");
assertThat(send("GET", "/preferences", EDITOR, "next-ui", null).body()).contains("Start conversations in Write mode");
assertThat(send("GET", "/preferences/assistant", NO_ACCESS, "next-ui", null).statusCode()).isEqualTo(404);
assertThat(send("GET", "/preferences", NO_ACCESS, "next-ui", null).body()).doesNotContain("Start conversations in Write mode");
assertThat(send("PATCH", "/preferences/assistant", NO_ACCESS, "next-ui", "{\"values\":{\"defaultWriteMode\":true}}").statusCode()).isEqualTo(404);
assertThat(send("GET", "/assistant/preferences", EDITOR, "next-ui", null).statusCode()).isEqualTo(404);
assertThat(send("PATCH", "/assistant/preferences", EDITOR, "next-ui", "{}").statusCode()).isEqualTo(404);
}
/*******************************************************************************
** Partial patches and new conversations use only the authenticated user's row.
*******************************************************************************/
@Test
void roundTripIsolationAndDefault() throws Exception
{
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
config.getRuntime().getStore().insert(new AssistantDataAccess().withObjectType("TABLE").withObjectName("page").withMode("READ_WRITE"));
config.getRuntime().getDataAccessService().invalidate();
config.getRuntime().getStore().insert(new AssistantPolicy().withName("default").withAutoConfirmAllowed(true));
config.getRuntime().getPolicyService().invalidate();
QContext.clear();
assertThat(send("PATCH", "/preferences/assistant", OTHER_EDITOR, "next-ui", "{\"values\":{\"defaultWriteMode\":false,\"autoConfirmLowRisk\":true}}").statusCode()).isEqualTo(200);
HttpResponse<String> patched = send("PATCH", "/preferences/assistant", EDITOR, "next-ui", "{\"values\":{\"defaultWriteMode\":true}}");
assertThat(patched.statusCode()).as(patched.body()).isEqualTo(200);
JSONObject values = new JSONObject(send("GET", "/preferences/assistant", EDITOR, "next-ui", null).body()).getJSONObject("values");
assertThat(values.getBoolean("defaultWriteMode")).isTrue();
assertThat(values.getBoolean("autoConfirmLowRisk")).isFalse();
JSONObject other = new JSONObject(send("GET", "/preferences/assistant", OTHER_EDITOR, "next-ui", null).body()).getJSONObject("values");
assertThat(other.getBoolean("defaultWriteMode")).isFalse();
assertThat(other.getBoolean("autoConfirmLowRisk")).isTrue();
JSONObject conversation = new JSONObject(send("POST", "/assistant/conversations", EDITOR, "next-ui", "{}").body());
assertThat(conversation.getBoolean("writeMode")).isTrue();
JSONObject probe = new JSONObject(send("GET", "/assistant/config", EDITOR, "next-ui", null).body());
assertThat(probe.getJSONObject("preferences").getBoolean("defaultWriteMode")).isTrue();
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
AssistantPolicy policy = config.getRuntime().getStore().loadPolicy();
config.getRuntime().getStore().updateFields(AssistantPolicy.TABLE_NAME, new QRecord().withValue("id", policy.getId()).withValue("writeModeAllowed", false));
config.getRuntime().getPolicyService().invalidate();
QContext.clear();
assertThat(new JSONObject(send("GET", "/assistant/config", EDITOR, "next-ui", null).body()).getJSONObject("preferences").getBoolean("defaultWriteMode")).isFalse();
assertThat(new JSONObject(send("POST", "/assistant/conversations", EDITOR, "next-ui", "{}").body()).getBoolean("writeMode")).isFalse();
assertThat(send("PATCH", "/preferences/assistant", EDITOR, "next-ui", "{\"values\":{\"defaultWriteMode\":true}}").statusCode()).isEqualTo(400);
}
/*******************************************************************************
** The UI sees a read-only field with a reason; the server rejects a bypass.
*******************************************************************************/
@Test
void policyDisablesAutoConfirm() throws Exception
{
String body = send("GET", "/preferences/assistant", EDITOR, "next-ui", null).body();
assertThat(body).contains("Auto-confirm is not allowed by the assistant policy.");
HttpResponse<String> denied = send("PATCH", "/preferences/assistant", EDITOR, "next-ui", "{\"values\":{\"autoConfirmLowRisk\":true}}");
assertThat(denied.statusCode()).as(denied.body()).isEqualTo(400);
assertThat(denied.body()).contains("can't be changed");
assertThat(new JSONObject(send("GET", "/preferences/assistant", EDITOR, "next-ui", null).body()).getJSONObject("values").getBoolean("autoConfirmLowRisk")).isFalse();
}
/*******************************************************************************
** Core rejects identity injection and unknown fields before any provider write.
*******************************************************************************/
@Test
void invalidPatchDoesNotWrite() throws Exception
{
for(String body : new String[] { "{\"userId\":\"other\",\"values\":{\"defaultWriteMode\":true}}",
"{\"values\":{\"userId\":\"other\"}}", "{\"values\":{\"unknown\":true}}", "{\"values\":{\"defaultWriteMode\":[]}}" })
{
assertThat(send("PATCH", "/preferences/assistant", EDITOR, "next-ui", body).statusCode()).isEqualTo(400);
}
assertThat(new JSONObject(send("GET", "/preferences/assistant", EDITOR, "next-ui", null).body()).getJSONObject("values").getBoolean("defaultWriteMode")).isFalse();
}
/*******************************************************************************
** Even an administrator acting through the assistant cannot read or patch it.
*******************************************************************************/
@Test
void assistantChannelCannotUsePreferences() throws Exception
{
for(String user : new String[] { EDITOR, AssistantTestFixture.ADMIN })
{
for(String channel : new String[] { "assistant/turn-42", "assistant/scheduled/12" })
{
assertThat(send("GET", "/preferences/assistant", user, channel, null).statusCode()).isEqualTo(404);
assertThat(send("PATCH", "/preferences/assistant", user, channel, "{\"values\":{\"defaultWriteMode\":true}}").statusCode()).isEqualTo(404);
}
assertThat(send("GET", "/preferences/assistant", user, "next-ui", null).statusCode()).isEqualTo(200);
}
}
}
@@ -35,11 +35,11 @@ import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
import io.javalin.Javalin;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -57,7 +57,6 @@ import static org.assertj.core.api.Assertions.assertThat;
*******************************************************************************/
class AssistantRouteProviderTest extends BaseAssistantTest
{
private Javalin javalin;
private String base;
private HttpClient client = HttpClient.newHttpClient();
@@ -70,10 +69,8 @@ class AssistantRouteProviderTest extends BaseAssistantTest
void startServer()
{
QContext.clear();
AssistantRouteProvider routes = new AssistantRouteProvider().withPingMillis(200);
routes.setQInstance(qInstance);
javalin = Javalin.create(routes::acceptJavalinConfig).start(0);
base = "http://localhost:" + javalin.port() + AssistantRouteProvider.BASE_PATH;
TurnStreams.setPingMillis(200);
base = UiApiTestServer.serve(qInstance, config) + AssistantRouteProvider.BASE_PATH;
}
@@ -84,7 +81,7 @@ class AssistantRouteProviderTest extends BaseAssistantTest
@AfterEach
void stopServer()
{
javalin.stop();
TurnStreams.setPingMillis(TurnStreams.PING_MILLIS);
}
@@ -0,0 +1,73 @@
/*
* 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.api.specs;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The assistant's UI API extension: registered by the producer, and its
** OpenAPI fragment matches the committed one
** (src/main/resources/openapi/qqq-v1/assistant.yaml) and is compatible with
** the last release's (baseline). Regenerate with
** -Dassistant.regenerateOpenApi=true.
*******************************************************************************/
class AssistantSpecsContractTest extends BaseAssistantTest
{
static final Path COMMITTED = Path.of("src/main/resources/openapi/qqq-v1/assistant.yaml");
static final Path BASELINE = Path.of("src/test/resources/openapi/qqq-v1/assistant-baseline.yaml");
/*******************************************************************************
**
*******************************************************************************/
@Test
void testRegisteredAndCommitted() throws Exception
{
assertThat(MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance).getExtensions()).containsKey(AssistantSpecs.EXTENSION_NAME);
String generated = UiApiContract.generateExtensionYaml(new MiddlewareVersionV1(), AssistantSpecs.extension());
if(Boolean.getBoolean("assistant.regenerateOpenApi"))
{
Files.createDirectories(COMMITTED.getParent());
Files.writeString(COMMITTED, generated, StandardCharsets.UTF_8);
if(!Files.exists(BASELINE))
{
Files.createDirectories(BASELINE.getParent());
Files.writeString(BASELINE, generated, StandardCharsets.UTF_8);
}
}
assertThat(generated).contains("/qqq/v1/assistant/conversations/{id}/turns").contains("text/event-stream").contains("/qqq/v1/assistant/actions/{id}/confirm")
.contains("AssistantTurnEventV1").contains("/qqq/v1/assistant/access");
List<String> problems = UiApiContract.check(generated, Files.readString(COMMITTED, StandardCharsets.UTF_8),
Files.exists(BASELINE) ? Files.readString(BASELINE, StandardCharsets.UTF_8) : null, 1, "./mvnw test -pl qbit-assistant -Dtest=AssistantSpecsContractTest -Dassistant.regenerateOpenApi=true");
assertThat(problems).isEmpty();
}
}
@@ -32,6 +32,7 @@ import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -67,11 +68,11 @@ class ClassificationTest extends BaseAssistantTest
assertThat(source.highest(null)).isEqualTo(DataClassification.PUBLIC);
assertThat(source.highest("assistant_message")).isEqualTo(DataClassification.PUBLIC);
assertThat(source.highestAnywhere()).isEqualTo(DataClassification.PHI);
assertThat(DataClassification.max(null, null)).isEqualTo(DataClassification.PUBLIC);
assertThat(DataClassification.max(DataClassification.PHI, DataClassification.PII)).isEqualTo(DataClassification.PHI);
assertThat(Classifications.max(null, null)).isEqualTo(DataClassification.PUBLIC);
assertThat(Classifications.max(DataClassification.PHI, DataClassification.PII)).isEqualTo(DataClassification.PHI);
assertThat(DataClassification.PII.isAtLeast(null)).isTrue();
assertThat(DataClassification.PUBLIC.isAtLeast(DataClassification.PII)).isFalse();
assertThat(DataClassification.stored("future")).isEqualTo(DataClassification.PHI);
assertThat(Classifications.stored("future")).isEqualTo(DataClassification.PHI);
ClassificationSource host = new ConfiguredClassificationSource().withClassification("media", DataClassification.PII);
assertThat(config.withClassificationSource(host).getClassificationSource()).isSameAs(host);
assertThat(new ConfiguredClassificationSource().withClassification(null, null).withClassification(null, null, null).ofTable(null)).isEqualTo(DataClassification.PUBLIC);
@@ -0,0 +1,249 @@
/*
* 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.metadata.EmailTables;
import com.kingsrook.qbits.email.permissions.EmailPermissions;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
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.QQueryFilter;
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(EmailPermissions.ALL));
new InsertAction().execute(new InsertInput(EmailTables.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<QRecord> messages() throws Exception
{
return (SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter()));
}
/*******************************************************************************
** 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<QRecord> messages = messages();
assertThat(messages).hasSize(1);
QRecord message = messages.get(0);
assertThat(message.getValueString("category")).isEqualTo("NOTIFICATION");
assertThat(message.getValueString("toAddresses")).isEqualTo("owner@example.com");
assertThat(message.getValueString("idempotencyKey")).isEqualTo("assistant:scheduled:7");
assertThat(message.getValueString("linkedTableName")).isEqualTo(RUN_TABLE);
assertThat(message.getValueString("linkedRecordId")).isEqualTo("7");
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 = SystemWrites.query(EmailTables.TEMPLATE, new QQueryFilter()).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);
QRecord message = messages().get(0);
assertThat(message.getValueString("category")).isEqualTo("SECURITY");
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");
assertThat(message.getValueString("idempotencyKey")).isEqualTo("assistant:scheduled:8");
assertThat(message.getValueString("linkedRecordId")).isEqualTo("8");
}
/*******************************************************************************
** 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(messages().get(0).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(messages().get(0).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);
}
}
@@ -26,7 +26,6 @@ import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantDataAudit;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
@@ -37,6 +36,7 @@ import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -17,7 +17,6 @@
package com.kingsrook.qbits.assistant.engine;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Duration;
@@ -30,11 +29,11 @@ import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.home.AssistantHomeData;
import com.kingsrook.qbits.assistant.home.AssistantHomeView;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantProfile;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
@@ -55,6 +54,7 @@ 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.TurnRunner;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -21,6 +21,7 @@ import java.util.stream.Stream;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.GoldenTasks;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
@@ -53,6 +54,10 @@ class GoldenEvalTest extends BaseAssistantTest
@MethodSource("tasks")
void golden(GoldenTasks.Task task) throws Exception
{
if("D-page-12-this-week".equals(task.id()))
{
ClientChannelsMetaData.ofOrWithNew(qInstance).getDeniedPatterns().get("assistant").remove("audit*");
}
task.script().accept(provider);
TurnRunner.Outcome outcome = runner.run(task.userId(), task.prompt());
@@ -90,7 +90,11 @@ class RedTeamTest extends BaseAssistantTest
for(Map<String, Object> result : outcome.events(TurnEvent.TOOL_RESULT))
{
if(!"list_tables".equals(result.get("name")) && !"search_records".equals(result.get("name")))
if("get_record_history".equals(result.get("name")))
{
assertThat(result.get("outcome")).as("history is not offered under the default channel wall").isEqualTo("INVALID_INPUT");
}
else if(!"list_tables".equals(result.get("name")) && !"search_records".equals(result.get("name")))
{
assertThat(result.get("outcome")).as(String.valueOf(result.get("name"))).isIn("DENIED", "NOT_FOUND");
}
@@ -27,7 +27,6 @@ import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.engine.TurnEngine;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
@@ -46,6 +45,7 @@ import com.kingsrook.qbits.assistant.provider.StreamHandle;
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.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -22,7 +22,6 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.TurnClassification;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
@@ -42,18 +41,22 @@ 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;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
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.
@@ -38,8 +38,9 @@ import java.util.concurrent.Executors;
import java.util.stream.Stream;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
@@ -56,6 +57,7 @@ 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.uiapi.UiApiCall;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
@@ -72,7 +74,6 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.MockAuthenticationModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -348,6 +349,33 @@ class ScheduledTasksTest extends BaseAssistantTest
/*******************************************************************************
** Per-task approval never overrides global or role write policy. User
** auto-confirm preferences never bypass scheduled confirmation.
*******************************************************************************/
@Test
void approvedTaskStillRequiresWritePolicy() throws Exception
{
AssistantScheduledTask task = task();
runtime.getStore().updateFields(AssistantScheduledTask.TABLE_NAME, new QRecord().withValue("id", task.getId()).withValue("writeModeRequested", true));
task = runtime.getStore().scheduledTask(task.getId());
action(task, "Approve");
task = runtime.getStore().scheduledTask(task.getId());
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.EDITOR, config));
PolicyService.ToolPolicy allowed = PolicyService.ToolPolicy.defaults();
PolicyService.ToolPolicy blocked = PolicyService.ToolPolicy.of(new AssistantPolicy().withWriteModeAllowed(false));
PolicyService.ToolPolicy role = PolicyService.ToolPolicy.of(new AssistantPolicy().withWriteModePermission("assistant.write"));
assertThat(ScheduledTurn.tools(runtime, task, blocked).writeMode()).isFalse();
assertThat(ScheduledTurn.tools(runtime, task, role).writeMode()).isFalse();
PolicyService.ToolPolicy effective = ScheduledTurn.tools(runtime, task, allowed);
assertThat(effective.writeMode()).isTrue();
assertThat(effective.writesRequireConfirmation()).isTrue();
assertThat(effective.autoConfirm()).isFalse();
assertThat(ScheduledTurn.tools(runtime, new AssistantScheduledTask(), allowed.withWriteMode(true)).writeMode()).isFalse();
}
/*******************************************************************************
** Exercise the same Review/Result steps the record action uses.
*******************************************************************************/
@@ -137,7 +137,7 @@ public class AssistantTestFixture
.withField(new QFieldMetaData("statDate", QFieldType.DATE).withLabel("Stat Date"))
.withField(new QFieldMetaData("pageviews", QFieldType.INTEGER).withLabel("Pageviews")));
if(config.getClassificationSource().ofTable("patient") == com.kingsrook.qbits.assistant.classification.DataClassification.PHI)
if(config.getClassificationSource().ofTable("patient") == com.kingsrook.qqq.backend.core.actions.ai.DataClassification.PHI)
{
qInstance.addTable(table("patient", "Patient", "status")
.withField(new QFieldMetaData("email", QFieldType.STRING))
@@ -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));
@@ -41,9 +41,13 @@ import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import org.junit.jupiter.api.AfterEach;
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;
@@ -58,6 +62,65 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
*******************************************************************************/
class UiApiToolsSecurityTest extends BaseAssistantTest
{
/*******************************************************************************
** The default channel wall hides history, even when the user can read audits.
*******************************************************************************/
@Test
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);
}
/*******************************************************************************
** A host opt-in offers history and reads it through the real UI API.
*******************************************************************************/
@Test
void historyOfferedAndReadableAfterHostOptIn() throws Exception
{
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);
assertThat(outcome.getHttpStatus()).isEqualTo(200);
assertThat(outcome.getResult().getModelContent()).contains("title", "metaDescription", "/app/page/12");
}
/*******************************************************************************
** Each audit table's explicit or inherited channel denial still hides history.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = { AuditsMetaDataProvider.TABLE_NAME_AUDIT_TABLE, AuditsMetaDataProvider.TABLE_NAME_AUDIT, AuditsMetaDataProvider.TABLE_NAME_AUDIT_DETAIL })
void historyRespectsEachAuditTableDenial(String name) throws Exception
{
config.getRuntime().close();
qInstance = AssistantTestFixture.build(config.withRecordHistory(true));
ClientChannelsMetaData channels = ClientChannelsMetaData.ofOrWithNew(qInstance);
channels.withDeniedObject("assistant", name);
assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY);
channels.getDeniedObjects().get("assistant").remove(name);
qInstance.getTable(name).getPermissionRules().withDeniedClients(Set.of("assistant"));
assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY);
qInstance.setDefaultPermissionRules(qInstance.getTable(name).getPermissionRules());
qInstance.getTable(name).setPermissionRules(null);
assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY);
}
private final HttpClient client = HttpClient.newHttpClient();
@@ -244,7 +307,7 @@ class UiApiToolsSecurityTest extends BaseAssistantTest
void testConfirmedWriteRunsThroughTheUiApi() throws Exception
{
setAccess("blog_post", DataAccessMode.READ_WRITE);
UiApiToolbox.Outcome proposed = tool(AssistantTestFixture.ADMIN, PolicyService.ToolPolicy.defaults(), UiApiToolbox.CREATE_RECORD,
UiApiToolbox.Outcome proposed = tool(AssistantTestFixture.ADMIN, PolicyService.ToolPolicy.defaults().withWriteMode(true), UiApiToolbox.CREATE_RECORD,
input("table", "blog_post", "values", Map.of("title", "Proposed by the assistant", "status", "DRAFT")));
assertThat(proposed.getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(proposed.getHttpStatus()).as("nothing runs before confirmation").isNull();
@@ -268,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();
}
@@ -282,7 +345,7 @@ class UiApiToolsSecurityTest extends BaseAssistantTest
void testOnlyTheOwnerDecides() throws Exception
{
setAccess("blog_post", DataAccessMode.READ_WRITE);
UiApiToolbox.Outcome proposed = tool(AssistantTestFixture.EDITOR, PolicyService.ToolPolicy.defaults(), UiApiToolbox.UPDATE_RECORD,
UiApiToolbox.Outcome proposed = tool(AssistantTestFixture.EDITOR, PolicyService.ToolPolicy.defaults().withWriteMode(true), UiApiToolbox.UPDATE_RECORD,
input("table", "blog_post", "id", "1", "values", Map.of("title", "Changed")));
Integer actionId = proposed.getPendingActionId();
assertThat(actionId).isNotNull();
@@ -377,6 +440,27 @@ class UiApiToolsSecurityTest extends BaseAssistantTest
}
}
//////////////////////////////////////////////////////////////////
// Read-only mode: the same writes never reach the UI API, for //
// the analyst and for the admin who could make them in the UI //
//////////////////////////////////////////////////////////////////
PolicyService.ToolPolicy readOnly = noConfirmation().withWriteMode(false);
for(String who : List.of(AssistantTestFixture.ANALYST, AssistantTestFixture.ADMIN))
{
for(Object[] row : new Object[][] {
{UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "x"))},
{UiApiToolbox.UPDATE_RECORD, input("table", "blog_post", "id", "1", "values", Map.of("title", "x"))},
{UiApiToolbox.DELETE_RECORD, input("table", "blog_post", "id", "1")}})
{
@SuppressWarnings("unchecked")
UiApiToolbox.Outcome outcome = tool(who, readOnly, (String) row[0], (Map<String, Object>) row[1]);
assertThat(outcome.getResult().getOutcome()).as(who + " " + row[0]).isEqualTo(AiToolOutcome.DENIED);
assertThat(outcome.getHttpStatus()).as(who + " " + row[0] + ": no request").isNull();
assertThat(outcome.getResult().getPermissionCheck()).isEqualTo("mode:READ_ONLY");
}
}
assertThat(ui("PATCH", "/table/blog_post/1", AssistantTestFixture.ADMIN, "{\"title\":\"Admin via the UI\"}").statusCode()).as("the admin may write in the UI").isBetween(200, 299);
//////////////////////////////////////////////////////////////////
// process: no tool, and no process has data access by default //
//////////////////////////////////////////////////////////////////
@@ -0,0 +1,209 @@
/*
* 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.io.Serializable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Write mode (owner, 2026-10-13): Read-only by default; the write tools exist
** only in Write mode; the policy can turn write mode off or limit it to a
** role; the user's preference sets the default; auto-confirm only where the
** policy allows it, on tables marked low-risk, never for deletes.
*******************************************************************************/
class WriteModeTest extends BaseAssistantTest
{
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void clear()
{
QContext.clear();
}
/*******************************************************************************
**
*******************************************************************************/
private void save(String table, Map<String, Serializable> values) throws QException
{
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
try
{
QRecord record = new QRecord();
record.setValues(new LinkedHashMap<>(values));
QRecord saved = new InsertAction().execute(new InsertInput(table).withRecord(record)).getRecords().get(0);
assertThat(saved.getErrors()).isEmpty();
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
**
*******************************************************************************/
private AssistantApi api(String user)
{
QContext.init(qInstance, AssistantTestFixture.sessionFor(user));
return (new AssistantApi(config.getRuntime(), QContext.getQSession()));
}
/*******************************************************************************
** Read-only by default: no write tools in the request; Write mode adds
** them; the conversation remembers its mode.
*******************************************************************************/
@Test
void testModeControlsTheToolList() throws Exception
{
save(AssistantDataAccess.TABLE_NAME, Map.of("objectType", "TABLE", "objectName", "page", "mode", "READ_WRITE"));
AssistantApi admin = api(AssistantTestFixture.ADMIN);
Map<String, Object> conversation = admin.createConversation(new LinkedHashMap<>());
assertThat(conversation.get("writeMode")).isEqualTo(false);
Integer id = (Integer) conversation.get("id");
QContext.clear();
provider.thenText("ok").thenText("ok");
runner.run(AssistantTestFixture.ADMIN, id, "read only please", null);
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
assertThat(new AssistantApi(config.getRuntime(), QContext.getQSession()).patchConversation(id, Map.of("writeMode", true)).get("writeMode")).isEqualTo(true);
QContext.clear();
runner.run(AssistantTestFixture.ADMIN, id, "now write", null);
List<ModelRequest> requests = provider.getRequests();
assertThat(requests.get(0).getTools()).extracting(t -> t.getName()).doesNotContain(UiApiToolbox.CREATE_RECORD, UiApiToolbox.UPDATE_RECORD, UiApiToolbox.DELETE_RECORD);
assertThat(requests.get(1).getTools()).extracting(t -> t.getName()).contains(UiApiToolbox.CREATE_RECORD, UiApiToolbox.UPDATE_RECORD, UiApiToolbox.DELETE_RECORD);
}
/*******************************************************************************
** A write tool called in a Read-only conversation is refused before any
** request (the model cannot get around the tool list).
*******************************************************************************/
@Test
void testReadOnlyRefusesAWriteCall() throws Exception
{
save(AssistantDataAccess.TABLE_NAME, Map.of("objectType", "TABLE", "objectName", "page", "mode", "READ_WRITE"));
provider.thenToolUse(UiApiToolbox.UPDATE_RECORD, Map.of("table", "page", "id", "12", "values", Map.of("title", "Hacked"))).thenText("ok");
var outcome = runner.run(AssistantTestFixture.ADMIN, "change page 12");
assertThat(outcome.toolCalls()).extracting(AssistantToolCall::getOutcome).containsExactly("INVALID_INPUT");
}
/*******************************************************************************
** The policy: write mode off for everyone, or only for a role; then 403.
*******************************************************************************/
@Test
void testPolicyLimitsWriteMode() throws Exception
{
save(AssistantDataAccess.TABLE_NAME, Map.of("objectType", "TABLE", "objectName", "page", "mode", "READ_WRITE"));
AssistantApi editor = api(AssistantTestFixture.EDITOR);
Integer id = (Integer) editor.createConversation(new LinkedHashMap<>()).get("id");
assertThat(editor.patchConversation(id, Map.of("writeMode", true)).get("writeMode")).isEqualTo(true);
QContext.clear();
save(AssistantPolicy.TABLE_NAME, Map.of("name", "default", "writeModePermission", "assistant.write"));
config.getRuntime().getPolicyService().invalidate();
AssistantApi again = api(AssistantTestFixture.EDITOR);
assertThatThrownBy(() -> again.patchConversation(id, Map.of("writeMode", true))).hasMessageContaining("Write mode is not available");
assertThat(again.config()).extracting(c -> ((Map<?, ?>) c.get("features")).get("writeMode")).isEqualTo(false);
}
/*******************************************************************************
** No READ_WRITE table: write mode is not available at all.
*******************************************************************************/
@Test
void testNoWritableTableNoWriteMode() throws Exception
{
AssistantApi admin = api(AssistantTestFixture.ADMIN);
Integer id = (Integer) admin.createConversation(new LinkedHashMap<>()).get("id");
assertThatThrownBy(() -> admin.patchConversation(id, Map.of("writeMode", true))).hasMessageContaining("Write mode is not available");
}
/*******************************************************************************
** Preferences: the default mode applies to new conversations; auto-confirm
** is refused unless the policy allows it, and then runs a low-risk insert at
** once but never a delete.
*******************************************************************************/
@Test
void testPreferencesAndAutoConfirm() throws Exception
{
save(AssistantDataAccess.TABLE_NAME, Map.of("objectType", "TABLE", "objectName", "page", "mode", "READ_WRITE", "autoConfirmOk", true));
AssistantApi admin = api(AssistantTestFixture.ADMIN);
new UserPreferencesAction().patchSection("assistant", Map.of("values", Map.of("defaultWriteMode", true)));
assertThat(admin.createConversation(new LinkedHashMap<>()).get("writeMode")).isEqualTo(true);
assertThatThrownBy(() -> new UserPreferencesAction().patchSection("assistant", Map.of("values", Map.of("autoConfirmLowRisk", true)))).hasMessageContaining("can't be changed");
QContext.clear();
save(AssistantPolicy.TABLE_NAME, Map.of("name", "default", "autoConfirmAllowed", true));
config.getRuntime().getPolicyService().invalidate();
AssistantApi allowed = api(AssistantTestFixture.ADMIN);
assertThat(new UserPreferencesAction().patchSection("assistant", Map.of("values", Map.of("autoConfirmLowRisk", true))).getValues().getValues().get("autoConfirmLowRisk")).isEqualTo(true);
QContext.clear();
provider.thenToolUses("Saving.", List.of(
new Object[] {UiApiToolbox.CREATE_RECORD, Map.of("table", "page", "values", Map.of("title", "Auto"))},
new Object[] {UiApiToolbox.DELETE_RECORD, Map.of("table", "page", "id", "12")}))
.thenText("done");
var outcome = runner.run(AssistantTestFixture.ADMIN, "make a page and delete page 12");
AssistantToolCall create = outcome.toolCalls().stream().filter(c -> UiApiToolbox.CREATE_RECORD.equals(c.getToolName())).findFirst().orElseThrow();
AssistantToolCall delete = outcome.toolCalls().stream().filter(c -> UiApiToolbox.DELETE_RECORD.equals(c.getToolName())).findFirst().orElseThrow();
assertThat(create.getHttpStatus()).as("auto-confirmed: ran through the UI API").isBetween(200, 299);
assertThat(create.getPendingActionId()).isNull();
assertThat(delete.getHttpStatus()).as("a delete is never auto-confirmed").isNull();
assertThat(delete.getPendingActionId()).isNotNull();
}
}
@@ -129,7 +129,7 @@ class BulkLoadToolsTest extends BaseAssistantTest
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
try
{
return (config.getRuntime().getBulkLoadTools().execute(new UiApiToolbox.Context("session-1", null, server, PolicyService.ToolPolicy.defaults(),
return (config.getRuntime().getBulkLoadTools().execute(new UiApiToolbox.Context("session-1", null, server, PolicyService.ToolPolicy.defaults().withWriteMode(true),
write -> config.getRuntime().getStore().insert(new AssistantPendingAction().withUserId(AssistantTestFixture.ADMIN).withToolName(write.toolName())
.withTableName(write.tableName()).withUiApiOperation(write.operation()).withParamsJson(JsonUtils.toJson(write.values()))
.withSummary(write.summary()).withStatus(AssistantPendingAction.STATUS_PENDING).withExpiresAt(java.time.Instant.now().plusSeconds(600))).getId()),
@@ -151,7 +151,7 @@ class BulkLoadToolsTest extends BaseAssistantTest
{
List<String> names = config.getRuntime().getContextBuilder().tools().stream().map(d -> d.getName()).toList();
assertThat(names).contains(BulkLoadTools.LIST, BulkLoadTools.DESCRIBE, BulkLoadTools.ATTACH, BulkLoadTools.SUGGEST, BulkLoadTools.DRY_RUN);
assertThat(config.getRuntime().getBulkLoadTools().definitions(PolicyService.ToolPolicy.defaults(), true))
assertThat(config.getRuntime().getBulkLoadTools().definitions(PolicyService.ToolPolicy.defaults().withWriteMode(true), true))
.extracting(d -> d.getName()).containsExactlyInAnyOrderElementsOf(BulkLoadTools.NAMES);
assertThat(config.getRuntime().getBulkLoadTools().definitions(PolicyService.ToolPolicy.defaults(), false))
.extracting(d -> d.getName()).doesNotContain(BulkLoadTools.SAVE, BulkLoadTools.RUN);
@@ -159,6 +159,30 @@ class BulkLoadToolsTest extends BaseAssistantTest
/*******************************************************************************
** Read-only suppresses both bulk write tools and denies forged calls before
** a proposal or request, even for an administrator with READ_WRITE access.
*******************************************************************************/
@Test
void testReadOnlyDeniesBulkWrites()
{
PolicyService.ToolPolicy policy = PolicyService.ToolPolicy.defaults();
assertThat(config.getRuntime().getBulkLoadTools().definitions(policy, true))
.extracting(d -> d.getName()).doesNotContain(BulkLoadTools.SAVE, BulkLoadTools.RUN);
for(String name : BulkLoadTools.WRITE_TOOLS)
{
UiApiToolbox.Context context = new UiApiToolbox.Context("session-1", null, server, policy,
write -> { throw new AssertionError("Read-only must not propose a write"); });
UiApiToolbox.Outcome outcome = config.getRuntime().getBulkLoadTools().execute(context, "forged", name, Map.of("tableName", "blog_post"), false);
assertThat(outcome.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(outcome.getHttpStatus()).isNull();
assertThat(outcome.getPendingActionId()).isNull();
}
assertThat(runs).isEmpty();
}
/*******************************************************************************
**
*******************************************************************************/
@@ -292,7 +316,10 @@ class BulkLoadToolsTest extends BaseAssistantTest
})
.thenText("The file maps cleanly once Status defaults to DRAFT (2 rows, no errors). Confirm to save the profile \"CMS post export\".");
TurnRunner.Outcome first = runner.run(AssistantTestFixture.ADMIN, null, "Load the attached posts.csv into blog posts and save the mapping for next time.", attachments);
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
Integer conversationId = (Integer) new AssistantApi(config.getRuntime(), QContext.getQSession()).createConversation(Map.of("writeMode", true)).get("id");
QContext.clear();
TurnRunner.Outcome first = runner.run(AssistantTestFixture.ADMIN, conversationId, "Load the attached posts.csv into blog posts and save the mapping for next time.", attachments);
List<String> tools = first.toolCalls().stream().map(AssistantToolCall::getToolName).toList();
assertThat(tools).containsExactly(BulkLoadTools.ATTACH, BulkLoadTools.SUGGEST, BulkLoadTools.DRY_RUN, BulkLoadTools.DRY_RUN, BulkLoadTools.SAVE);
@@ -0,0 +1,82 @@
/*
* 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.qqq.middleware.javalin.specs.invoke;
import java.net.http.HttpRequest;
import java.util.List;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Extra route headers cannot override core-owned credentials or attribution.
*******************************************************************************/
class HeaderedUiApiInvokerTest
{
/*******************************************************************************
** Non-reserved headers pass through; reserved headers are ignored regardless
** of case, retaining only values supplied through core's request properties.
*******************************************************************************/
@Test
void forwardsOnlyNonReservedHeaders() throws Exception
{
HeaderedUiApiInvoker invoker = new HeaderedUiApiInvoker(() -> "http://127.0.0.1:8000", new MiddlewareVersionV1());
invoker.withClientChannel("assistant");
HeaderedUiApiInvoker.HeaderedRequest request = new HeaderedUiApiInvoker.HeaderedRequest();
request.withMethod(HttpMethod.POST).withPath("/table/{tableName}/query").withPathParam("tableName", "page")
.withCredentials(new UiApiCredentials("session-value", null, "Bearer credential-value", "key-value"))
.withJsonBody(java.util.Map.of()).withUserTimezone("America/Chicago").withInvokedBy("turn-engine").withClientDetail("scheduled/42");
for(String name : List.of("cOoKiE", "AUTHORIZATION", "X-API-Key", "Content-Type", "ACCEPT", "X-QQQ-Invoked-By", "x-qqq-usertimezone", "X-QQQ-CLIENT"))
{
request.withHeader(name, "must-not-forward");
}
request.withHeader("X-QQQ-AI-Route", "phi-route").withHeader("X-Request-Tag", "tag-value").withHeader(null, "ignored").withHeader("X-Null", null);
UiApiOperation operation = invoker.findOperation(HttpMethod.POST, request.getPath()).orElseThrow();
HttpRequest built = invoker.buildHttpRequest(operation, request);
assertThat(built.headers().allValues("Cookie")).containsExactly("sessionUUID=session-value");
assertThat(built.headers().allValues("Authorization")).containsExactly("Bearer credential-value");
assertThat(built.headers().allValues("x-api-key")).containsExactly("key-value");
assertThat(built.headers().allValues("Content-Type")).containsExactly("application/json");
assertThat(built.headers().allValues("Accept")).isEmpty();
assertThat(built.headers().allValues(UiApiInvoker.INVOKED_BY_HEADER)).containsExactly("turn-engine");
assertThat(built.headers().allValues(UiApiInvoker.TIMEZONE_HEADER)).containsExactly("America/Chicago");
assertThat(built.headers().allValues(UiApiInvoker.CLIENT_CHANNEL_HEADER)).containsExactly("assistant/scheduled/42");
assertThat(built.headers().allValues("X-QQQ-AI-Route")).containsExactly("phi-route");
assertThat(built.headers().allValues("X-Request-Tag")).containsExactly("tag-value");
assertThat(built.headers().allValues("X-Null")).isEmpty();
assertThat(built.headers().map().values()).allSatisfy(values -> assertThat(values).doesNotContain("must-not-forward"));
}
/*******************************************************************************
** Ordinary core requests retain attribution without requiring extra headers.
*******************************************************************************/
@Test
void ordinaryRequestPreservesCoreHeaders() throws Exception
{
HeaderedUiApiInvoker invoker = new HeaderedUiApiInvoker(() -> "http://127.0.0.1:8000", new MiddlewareVersionV1());
invoker.withClientChannel("assistant");
UiApiRequest request = new UiApiRequest().withMethod(HttpMethod.GET).withPath("/metaData").withClientDetail("turn-42");
HttpRequest built = invoker.buildHttpRequest(invoker.findOperation(HttpMethod.GET, request.getPath()).orElseThrow(), request);
assertThat(built.headers().allValues(UiApiInvoker.CLIENT_CHANNEL_HEADER)).containsExactly("assistant/turn-42");
assertThat(built.headers().allValues("X-QQQ-AI-Route")).isEmpty();
}
}
File diff suppressed because it is too large. Load diff