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.maes 467c2199dc Merge remote-tracking branch 'origin/develop' into feature/provider-api-module
# Conflicts:
#	CHANGELOG.md
#	UPGRADE.md
2026-10-14 07:31:36 -05:00
james.maes 01309ddfe9 Merge pull request #36 from QRun-IO/feature/p7-scheduled-tasks
feat: scheduled assistant tasks as a designated user through the UI API (P-g)
2026-10-14 07:18:36 -05:00
james.maes de04965cd7 Merge remote-tracking branch 'origin/develop' into feature/p7-scheduled-tasks
# Conflicts:
#	qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java
2026-10-14 07:13:09 -05:00
james.maes 61e6f809f1 Merge remote-tracking branch 'origin/develop' into feature/provider-api-module 2026-10-14 07:07:52 -05:00
james.maes 8e8a5c59cd Merge pull request #38 from QRun-IO/feature/bulk-load-tools
feat(bulk-load): assistant tools to use and create saved bulk load profiles
2026-10-14 07:05:51 -05:00
james.maesandClaude Opus 5.5 45593d2e79 feat(email): scheduled-result email adapter for qbit-email
Phase (d) of QRun-IO/qbit-email docs/DESIGN.md section 11. qbit-email is an
optional dependency (0.1.0-SNAPSHOT until its first RC) used through one
adapter class behind a classpath and instance guard; without it the
existing behaviour is unchanged.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:49:45 -05:00
james.maes 9e1bda8dae Merge remote-tracking branch 'origin/develop' into feature/p7-scheduled-tasks
# Conflicts:
#	README.md
#	qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java
#	qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantRecordScreens.java
2026-10-13 20:37:33 -05:00
james.maesandClaude Opus 5.5 907d943f8d feat: scheduled assistant tasks as a designated user through the UI API (P-g)
- assistant_scheduled_task (Scheduled tasks): prompt, run-as user,
  credential env-var reference (allow-listed, never stored), profile,
  Quartz cron + time zone, output mode, write-mode request and per-task
  admin approval (cleared when the prompt, user, credential, profile or
  request changes), per-run cost cap; assistant_scheduled_run child list.
- Runner (60 s schedule): claims a slot via a unique (task, scheduledFor)
  run key, logs in exactly like a UI client (POST /qqq/v1/manageSession
  with accessToken), checks the session's user is the run-as user, then
  runs a normal turn on a new conversation (channel assistant/scheduled,
  X-QQQ-Client assistant/scheduled/<taskId>). No system or constructed
  sessions.
- PHI routing, budgets, routing/fallback apply unchanged; output as the
  conversation and run record, a notification through core alerts, or
  both. Record actions: Approve write mode, Revoke write mode, Run now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:34:46 -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
133 changed files with 12137 additions and 986 deletions

No files matched your search

+6 -2
View File
@@ -29,6 +29,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
## Modules
- `qbit-assistant-provider-api`: standalone provider SPI/value types and `engine.ModelPricing` (original package names); only Jackson annotations in production. Provider validation takes unresolved `ProviderSettings`, not an entity.
- `qbit-assistant` (`com.kingsrook.qbits.assistant`):
- `AssistantQBitProducer`, `AssistantQBitConfig` (with `AssistantModelRouting` and `AssistantBudgets`), `AssistantRuntime`, `AssistantSecurity`.
- `metadata/`: tables, owner key type, the Assistant app (home + main tables in the nav), the retention process, the settings customizer (key-reference checks, masked key status), and the `assistant.admin` custom permission checker.
@@ -37,7 +39,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- `persistence/AssistantStore`: the **only** code that calls QQQ data actions, and only on the QBit's own tables, under the user's session.
- `engine/`: `TurnEngine` (the loop), `ContextBuilder` (cache-prefix order), `SystemPrompt`, `PolicyService` (policy row, budgets), `RateLimiter`, the `TurnEvent` / `TurnEventLog` stream, and `MessageParts`.
- `api/`: `AssistantApi` (transport-free) and `AssistantRouteProvider` (Javalin, SSE).
- `provider/`: the provider-neutral SPI, plus `fake/ScriptedModelProvider` for tests, evals and demos.
- `provider/`: runtime-backed `RegistryModelProvider`, plus `fake/ScriptedModelProvider` for tests, evals and demos. The SPI lives in `qbit-assistant-provider-api`.
- `developer/`: the record Developer-screen panel, on the core developer-panel contract.
- `processes/`: `AssistantRetentionStep` and `TestConnectionProcess` (record action on `assistant_provider`).
- `engine/ProviderService` (active provider from `assistant_provider` rows, key references, prices), `UsageRecorder` (one `assistant_usage` row per model call), `ConnectionTest`.
@@ -46,6 +48,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- `qbit-assistant-provider-bedrock`: Claude through the Anthropic Bedrock backend, AWS default credentials and SigV4; IRSA/web-identity hosts supply STS.
- `qbit-assistant-provider-vertex`: Claude through the Anthropic Vertex backend and Gemini through OpenAI-compatible Chat Completions, with refreshed Google ADC tokens.
- `qbit-assistant-rag-pgvector`: JDBC vector storage; depends on the runtime RAG interfaces (`VectorStore`, `ChunkVector`).
## Invariants (do not break)
- **Sessions:**
@@ -93,4 +97,4 @@ QQQ style:
- `develop` publishes SNAPSHOTs.
- `release/*` publishes RCs; the orb bumps `<revision>` in the root POM.
- `v*` tags publish releases.
- All five modules and the parent POM publish. The flatten plugin resolves `${revision}`.
- All seven modules and the parent POM publish. The flatten plugin resolves `${revision}`.
+65 -1
View File
@@ -1,5 +1,58 @@
# 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.
- Move Anthropic, OpenAI, Bedrock and Vertex main dependencies to the API; retain runtime fixtures in test scope. Pgvector still needs runtime RAG interfaces.
- Provider-specific validation now takes unresolved `ProviderSettings` instead of `AssistantProvider`; add an ArchUnit boundary and standalone coverage gates.
## Unreleased - P-g
- Add admin Scheduled tasks, owner-locked run history, Quartz cron/timezones,
and a 60-second QQQ scheduled process using the host's scheduler context.
- Log in through the loopback UI API with a separately allow-listed credential
environment reference; verify the designated user before starting a normal turn.
- Add Review/Result Approve write mode, Revoke write mode and Run now actions.
Approval binds the task definition; scheduled writes always await confirmation.
- Attribute scheduled UI API requests and tool logs, apply per-run STOP caps,
and send optional Alerts notifications to the designated user. HIPAA summaries
contain only `see conversation`.
- Add unique schedule-slot claims, fixed-clock fixture integration tests and
additive Postgres DDL. No API routes or provider modules change.
## Unreleased - P-f
- Add OpenAI-compatible embeddings (including Azure), explicit CHAT/EMBEDDING
@@ -47,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.
@@ -65,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`**.
+9 -3
View File
@@ -29,6 +29,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
## Modules
- `qbit-assistant-provider-api`: standalone provider SPI/value types and `engine.ModelPricing` (original package names); only Jackson annotations in production. Provider validation takes unresolved `ProviderSettings`, not an entity.
- `qbit-assistant` (`com.kingsrook.qbits.assistant`):
- `AssistantQBitProducer`, `AssistantQBitConfig` (with `AssistantModelRouting` and `AssistantBudgets`), `AssistantRuntime`, `AssistantSecurity`.
- `metadata/`: tables, owner key type, the Assistant app (home + main tables in the nav), the retention process, the settings customizer (key-reference checks, masked key status), and the `assistant.admin` custom permission checker.
@@ -37,7 +39,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- `persistence/AssistantStore`: the **only** code that calls QQQ data actions, and only on the QBit's own tables, under the user's session.
- `engine/`: `TurnEngine` (the loop), `ContextBuilder` (cache-prefix order), `SystemPrompt`, `PolicyService` (policy row, budgets), `RateLimiter`, the `TurnEvent` / `TurnEventLog` stream, and `MessageParts`.
- `api/`: `AssistantApi` (transport-free) and `AssistantRouteProvider` (Javalin, SSE).
- `provider/`: the provider-neutral SPI, plus `fake/ScriptedModelProvider` for tests, evals and demos.
- `provider/`: runtime-backed `RegistryModelProvider`, plus `fake/ScriptedModelProvider` for tests, evals and demos. The SPI lives in `qbit-assistant-provider-api`.
- `developer/`: the record Developer-screen panel, on the core developer-panel contract.
- `processes/`: `AssistantRetentionStep` and `TestConnectionProcess` (record action on `assistant_provider`).
- `engine/ProviderService` (active provider from `assistant_provider` rows, key references, prices), `UsageRecorder` (one `assistant_usage` row per model call), `ConnectionTest`.
@@ -46,14 +48,16 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- `qbit-assistant-provider-bedrock`: Claude through the Anthropic Bedrock backend, AWS default credentials and SigV4; IRSA/web-identity hosts supply STS.
- `qbit-assistant-provider-vertex`: Claude through the Anthropic Vertex backend and Gemini through OpenAI-compatible Chat Completions, with refreshed Google ADC tokens.
- `qbit-assistant-rag-pgvector`: optional host-supplied JDBC vector store, isolated by instance schema; no QQQ RDBMS internals.
- `qbit-assistant-rag-pgvector`: optional host-supplied JDBC vector store, isolated by instance schema; depends on runtime RAG interfaces (`VectorStore`, `ChunkVector`), with no QQQ RDBMS internals.
## Invariants (do not break)
- **Sessions:**
- Scheduled runs log in as the designated user through `POST /qqq/v1/manageSession`; resolve only its returned UUID, verify the user id, and never construct or elevate a session. The QQQ-supplied scheduler context touches only assistant bookkeeping.
- 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).
@@ -67,6 +71,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- Errors (DENIED, NOT_FOUND, INVALID_INPUT, ERROR) are `is_error` results.
- Unknown tools and over-limit calls also get error results.
- **Keys:** never stored in a table. `assistant_provider.apiKeyEnvName` holds only an allowed environment variable NAME; `apiKeyStatus` is computed on read (last 4 at most) and stripped on save.
- Scheduled task `credentialEnvName` is a separately allow-listed environment variable NAME. Tokens never enter rows, model context or logs. `credentialStatus` is computed and stripped on save.
- Scheduled write approval is invalidated by changes to prompt, run-as user, credential reference, profile or write request. Scheduled writes always require the designated user's panel confirmation.
- **Logging:** never log message content, prompts, tool results or keys. Structured end-of-turn log only.
- **Thinking:** thinking is never streamed or stored. The Anthropic adapter keeps the raw assistant message (with signatures) only in memory, as `providerState`, for the tool loop.
- **Links:** only `/app` or `/app/...` hrefs are emitted (`TurnEngine.isAppHref`).
@@ -102,4 +108,4 @@ QQQ style:
- `develop` publishes SNAPSHOTs.
- `release/*` publishes RCs; the orb bumps `<revision>` in the root POM.
- `v*` tags publish releases.
- All six modules and the parent POM publish. The flatten plugin resolves `${revision}`.
- All seven modules and the parent POM publish. The flatten plugin resolves `${revision}`.
+168 -11
View File
@@ -1,16 +1,89 @@
# 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`.
## Modules
| Artifact | What it is |
|---|---|
| `com.kingsrook.qbits:qbit-assistant` | The QBit: conversation tables, turn engine, `/qqq/v1/assistant` routes (JSON + Server-Sent Events), budgets, kill switch, the provider SPI, and a scripted provider for tests and demos. |
| `com.kingsrook.qbits:qbit-assistant-provider-api` | Provider SPI, connection settings, messages, usage and pricing types; only Jackson annotations at runtime. |
| `com.kingsrook.qbits:qbit-assistant` | The QBit: conversation tables, turn engine, `/qqq/v1/assistant` routes (JSON + Server-Sent Events), budgets, kill switch, the provider registry, and a scripted provider for tests and demos. |
| `com.kingsrook.qbits:qbit-assistant-provider-anthropic` | The Claude provider (`com.anthropic:anthropic-java`). Add it to use Claude. The core QBit has no LLM SDK dependency. |
| `com.kingsrook.qbits:qbit-assistant-provider-openai` | OpenAI-compatible chat and embedding providers. |
| `com.kingsrook.qbits:qbit-assistant-provider-bedrock` | Claude through AWS Bedrock. |
| `com.kingsrook.qbits:qbit-assistant-provider-vertex` | Claude and Gemini through Google Vertex AI. |
| `com.kingsrook.qbits:qbit-assistant-rag-pgvector` | JDBC vector store; retains the runtime dependency for `VectorStore` and `ChunkVector`. |
### Implementing a provider outside qbit-assistant
Depend on `com.kingsrook.qbits:qbit-assistant-provider-api` at the same version as
QBit Assistant, and implement `AssistantModelProvider` and optionally
`AssistantProviderType`. No assistant runtime dependency is needed. Existing
`com.kingsrook.qbits.assistant.provider.*` imports and
`com.kingsrook.qbits.assistant.engine.ModelPricing` stay the same.
`AssistantProviderType.validate(ProviderSettings)` receives unresolved settings
(including `apiKeyEnvName`), without a database entity or resolved credentials.
Hosts register providers through `AssistantQBitConfig` in `qbit-assistant`.
Data tools use the QQQ UI API (`/qqq/v1`) with the signed-in user's session. The UI API enforces permissions and record locks; assistant data-access policy and field exposure can only narrow access. Only record-free navigation and help use the core AI tool kernel. Every tool result reaches the model wrapped as untrusted data.
## 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**:
@@ -188,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.
@@ -303,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:
@@ -446,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)
@@ -469,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
@@ -479,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
@@ -551,6 +643,45 @@ health is probed when unknown/stale with a two-second timeout. Provider cards sh
health and down-until, failures show fallback totals, and turn records show the
route and final destination. See [UPGRADE.md](UPGRADE.md) for the additive schema.
## Scheduled tasks
Administrators configure recurring prompts in **Assistant → Scheduled tasks**.
Tasks use Quartz cron syntax (for example `0 0 8 * * ?`) and an IANA timezone
(for example `America/Chicago`; default `UTC`). Tasks start disabled.
```java
config.withUiApiBaseUrl("http://127.0.0.1:8000")
.withScheduledTasksSchedulerName("hostScheduler")
.withAllowedScheduledCredentialEnvNames(Set.of("ASSISTANT_SCHEDULED_TOKEN"));
```
Provide a long-lived access token accepted by the host's authentication provider
in that environment variable. Store only its **name** on the task and set
`runAsUserId` to the corresponding QQQ user id. The runner posts the token to
`/qqq/v1/manageSession`, reads its session cookie and verifies the returned
session's user before starting a new conversation. No system session reads user
data. Configure the loopback URL at startup; a scheduled job cannot rely on a
browser having visited first.
For kof22, provision a designated Authentik account with the intended application
permissions, obtain a token that kof22's QQQ authentication provider accepts as an
`accessToken`, and inject it through the existing secret-manager/ExternalSecret
pipeline. An Authentik management API token is suitable only if the host's auth
adapter explicitly accepts it. Rotate the token in the secret manager, never in
the task table. This feature does not provision an account or token.
**Run now**, **Approve write mode** and **Revoke write mode** are Review/Result
record actions. Write approval names the designated user and displays the exact
prompt SHA-256. Changing prompt, user, credential reference, profile or write-mode
request clears approval. Scheduled writes always wait for that user's panel
confirmation, even if normal policy disables confirmation.
Each run keeps an owner-locked audit row and a normal conversation. RECORD adds
no notification; NOTIFICATION and BOTH also notify the designated user through
the optional Alerts QBit SPI. Without Alerts, notification delivery is log-only.
HIPAA summaries are always `see conversation`. Cost caps stop at dispatch
boundaries, so the last completed call can cross the cap; no further call starts.
Apply the [P-g migration](UPGRADE.md#p-g-scheduled-tasks) before enabling jobs.
### Answer presentation
The always-available `present_results` tool turns retrieved results into metrics,
@@ -563,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.
+97
View File
@@ -1,5 +1,102 @@
# 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
`qbit-assistant-provider-api` transitively. Package names are unchanged, including
`com.kingsrook.qbits.assistant.engine.ModelPricing`. No schema migration is needed.
External providers and applications using only provider/pricing types should
replace their `qbit-assistant` dependency with
`com.kingsrook.qbits:qbit-assistant-provider-api` at the same version. This removes
the assistant runtime and QQQ from that dependency path; only Jackson annotations
are required by the API. Anthropic, OpenAI, Bedrock and Vertex adapters use this
split; pgvector retains `qbit-assistant` for its runtime RAG interfaces.
Recompile custom provider types that override `validate(AssistantProvider)` and
change the signature to `validate(ProviderSettings)`. The settings carry the
configured kind, URL, API version, region, project, CA bundle name, timeouts and
`apiKeyEnvName`; the key and bearer-token supplier are not resolved for validation.
Providers using the default validator need no source change. Keep the runtime and
provider artifacts on the same version; old validator overrides must be rebuilt.
## P-g: scheduled tasks
Apply the **P-g** DDL in [docs/schema-postgres.sql](docs/schema-postgres.sql)
before deploying. The unique `(task_id, scheduled_for)` constraint is required
for multi-replica safety; retain run rows as schedule-slot claim history.
Existing conversations default to channel `panel`. Tasks start disabled.
Set `withScheduledTasksSchedulerName(...)` to an existing QQQ scheduler and
configure `withUiApiBaseUrl(...)` with this replica's own listener URL. The runner
polls every 60 seconds. QQQ must supply a scheduler session able to manage the
assistant tables; this QBit never creates that session. The configured backend
must enforce unique keys atomically (Postgres does).
Allow only designated credential references with
`withAllowedScheduledCredentialEnvNames(Set.of("ASSISTANT_SCHEDULED_TOKEN"))`.
Provide that variable through the host's secret manager. It must hold a token
accepted by the host's `manageSession` authentication provider for `runAsUserId`.
The runner fails closed on missing/disallowed credentials or an identity mismatch.
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 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
the owner-locked audit row and conversation. HIPAA run summaries never copy the
answer; they contain only `see conversation`.
## P-f: knowledge retrieval
Apply the **P-f** assistant-table DDL in
+133 -6
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
@@ -469,6 +515,87 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
per-record pgvector replacement is transactional, while scan reconciliation is
not a snapshot of concurrently changing application data.
### P-g as built
- `assistant_scheduled_task` is admin-only, AI exposure NONE, and appears as
Scheduled tasks. The owner-locked, read-only `assistant_scheduled_run` child
list retains run status, accounting and a bounded summary. Conversations gain
read-only `scheduledTaskId` and `channel` (`panel` or `assistant/scheduled`).
Task and run timestamps use the runtime clock; cron uses Quartz and a validated
ZoneId, including DST. Enablement and schedule edits recompute `nextRunAt`.
- Credentials are references, never stored tokens. `credentialEnvName` must
match `[A-Z_][A-Z0-9_]*` and the dedicated
`allowedScheduledCredentialEnvNames` allowlist. The existing secret resolver
supplies its value. Read-only `credentialStatus` reports presence without
exposing token characters and is stripped on every save.
- A run sends JSON `{accessToken: ...}` to the same loopback base URL's
`POST /qqq/v1/manageSession`. RC.5 with HTTP-only cookies returns the UUID in
`Set-Cookie: sessionUUID`; legacy JSON `uuid` is also accepted. Only then does
the runner resolve that UUID through the host authentication module, exactly
the UUID lookup used by the UI request pipeline. No direct token authentication,
automated-user session or constructed QSession exists in assistant code.
The resolved user id must equal `runAsUserId` before any model call. MOCK in
the integration fixture maps test tokens to the existing user-prefix sessions;
that token adapter is test code only.
- kof22 provisions an Authentik designated account with normal QQQ permissions
and injects an accepted long-lived token through its secret manager and
ExternalSecret into an allow-listed environment variable. The task holds the
variable name and QQQ user id. Host authentication must accept that token type
as `accessToken`; an Authentik management API token is not automatically an
application access token. Neither account nor credential is provisioned here.
- `assistantScheduledTasksRunner` registers a 60-second QScheduleMetaData when
`scheduledTasksSchedulerName` names a host scheduler. QQQ supplies its normal
scheduler context for assistant-table bookkeeping; no ArchUnit exception is
necessary. Configure `uiApiBaseUrl` at startup. No browser visit is required.
- **Claim compatibility:** RC.5 UpdateInput cannot express a conditional
`WHERE next_run_at = expected`. AssistantStore therefore claims the expected
schedule slot using the database-enforced unique `(taskId, scheduledFor)` run
key and advances `nextRunAt` before login/dispatch. This is a unique-insert
optimistic claim rather than a conditional update. The memory fixture also
serializes claim operations on its instance. The database constraint, not
that monitor, prevents cross-replica duplicate execution. Do not remove claim
history or deploy to a backend without atomic unique keys. A crash can lose a
run; it never retries a claimed slot. A later tick advances an already-claimed
slot left behind by a crash between claim and schedule update.
- The unchanged AssistantApi starts a new normal turn under the logged-in
designated user, preserving start gates, rate limiting, P-c classification,
P-d budgets and P-e routing/fallback. An internal context supplies the task's
selected profile and additional cost cap. Cost includes chat, classifier,
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>` 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
definition before recording the actual administrator's user id and time.
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.
- 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
Alerts notification with `NotificationAudience.user(runAsUserId)` and an
`/app/...` conversation link. Reflection targets the installed Alerts QBit's
RC.5 notification SPI/sink, following WebhooksAlerts' optional-dependency
pattern. Without it, a log contains run id and status only. Notification text
contains no answer data. In HIPAA mode run summaries contain exactly
`see conversation`; conversation payloads retain the existing encryption.
The existing record URL is an admin-screen link. Non-admin owners can read
their scheduled conversations in the panel's conversation list; opening a
particular existing conversation from a notification still needs a frontend
deep-link contract (the current panel only supports open/new-prompt links).
This phase does not broaden existing conversation record-screen permissions.
- Failures close the run as FAILED or BLOCKED with sanitized errors and wait
until the next schedule. All output modes retain their audit record. Tests
exercise real manageSession and UI API requests with a fixed clock, designated
permissions and owner locks, headers, competing runners, approval invalidation,
notification modes, PHI refusal, encrypted HIPAA storage and cost STOP.
### Phases (each a green PR)
| | Scope | Depends on |
|---|---|---|
+70 -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,
@@ -524,3 +540,56 @@ CREATE TABLE assistant_rag_source (
-- );
-- CREATE INDEX IF NOT EXISTS assistant_rag_chunk_cosine
-- ON assistant_rag.assistant_rag_chunk USING hnsw (embedding vector_cosine_ops);
-- P-g: designated-user scheduled assistant tasks. Install before enabling jobs.
CREATE TABLE assistant_scheduled_task (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
prompt TEXT NOT NULL CHECK (char_length(prompt) BETWEEN 1 AND 4000),
run_as_user_id VARCHAR(200) NOT NULL,
credential_env_name VARCHAR(200) NOT NULL,
credential_status VARCHAR(200), -- computed; always NULL in stored rows
profile_id INTEGER REFERENCES assistant_profile(id),
cron_expression VARCHAR(200) NOT NULL,
timezone VARCHAR(100) NOT NULL DEFAULT 'UTC',
next_run_at TIMESTAMPTZ,
output_mode VARCHAR(20) NOT NULL DEFAULT 'RECORD' CHECK (output_mode IN ('RECORD', 'NOTIFICATION', 'BOTH')),
write_mode_requested BOOLEAN NOT NULL DEFAULT FALSE,
write_mode_approved_by VARCHAR(200),
write_mode_approved_at TIMESTAMPTZ,
write_mode_approval_hash VARCHAR(64),
max_cost_usd_per_run NUMERIC(18,6) CHECK (max_cost_usd_per_run >= 0),
last_run_at TIMESTAMPTZ,
last_run_status VARCHAR(20),
last_run_error VARCHAR(500),
create_date TIMESTAMPTZ,
modify_date TIMESTAMPTZ
);
CREATE INDEX assistant_scheduled_task_due ON assistant_scheduled_task(next_run_at) WHERE enabled;
ALTER TABLE assistant_conversation ADD COLUMN scheduled_task_id INTEGER REFERENCES assistant_scheduled_task(id);
ALTER TABLE assistant_conversation ADD COLUMN channel VARCHAR(64) NOT NULL DEFAULT 'panel';
ALTER TABLE assistant_tool_call ADD COLUMN client_channel VARCHAR(64);
CREATE TABLE assistant_scheduled_run (
id SERIAL PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES assistant_scheduled_task(id),
user_id VARCHAR(200) NOT NULL,
scheduled_for TIMESTAMPTZ NOT NULL,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
status VARCHAR(20) NOT NULL CHECK (status IN ('RUNNING', 'OK', 'FAILED', 'SKIPPED', 'BLOCKED')),
conversation_id INTEGER REFERENCES assistant_conversation(id) ON DELETE SET NULL,
turn_id INTEGER REFERENCES assistant_turn(id) ON DELETE SET NULL,
summary VARCHAR(2000),
cost_micros BIGINT,
error VARCHAR(500),
create_date TIMESTAMPTZ,
modify_date TIMESTAMPTZ,
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;
+13 -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>
@@ -50,6 +52,7 @@
<modelVersion>4.0.0</modelVersion>
<modules>
<module>qbit-assistant-provider-api</module>
<module>qbit-assistant</module>
<module>qbit-assistant-provider-anthropic</module>
<module>qbit-assistant-provider-openai</module>
@@ -85,6 +88,11 @@
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-anthropic</artifactId>
@@ -194,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 -->
@@ -206,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>
@@ -32,9 +32,14 @@
<url>https://github.com/QRun-IO/qbit-assistant</url>
<dependencies>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
@@ -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);
+116
View File
@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2026 Kingsrook, LLC
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-parent</artifactId>
<version>${revision}</version>
</parent>
<artifactId>qbit-assistant-provider-api</artifactId>
<packaging>jar</packaging>
<name>QBit Assistant - Provider API</name>
<description>Provider SPI and pricing value types, independent of the assistant runtime</description>
<url>https://github.com/QRun-IO/qbit-assistant</url>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<redirectTestOutputToFile>true</redirectTestOutputToFile>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
<limit>
<counter>CLASS</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -20,7 +20,6 @@ package com.kingsrook.qbits.assistant.engine;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantModelRouting;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
@@ -63,9 +62,9 @@ public class ModelPricing implements Serializable
public static Map<String, ModelPricing> defaults()
{
Map<String, ModelPricing> map = new LinkedHashMap<>();
map.put(AssistantModelRouting.SONNET_5_5, of(2.0, 10.0, 0.1));
map.put(AssistantModelRouting.OPUS_5_5, of(4.0, 20.0, 0.05));
map.put(AssistantModelRouting.HAIKU_5_5, of(0.10, 0.50, 0.1));
map.put("claude-sonnet-5-5", of(2.0, 10.0, 0.1));
map.put("claude-opus-5-5", of(4.0, 20.0, 0.05));
map.put("claude-haiku-5-5", of(0.10, 0.50, 0.1));
map.put("claude-fable-5-1", of(10.0, 50.0, 0.025));
map.put("claude-fable-5", of(10.0, 50.0, 0.1));
map.put("claude-opus-5", of(5.0, 25.0, 0.1));
@@ -18,7 +18,6 @@ package com.kingsrook.qbits.assistant.provider;
import java.util.List;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
/*******************************************************************************
@@ -140,8 +139,10 @@ public interface AssistantProviderType
/*******************************************************************************
** Additional settings errors, without resolving credentials or doing I/O.
** Settings contain configured values and apiKeyEnvName; apiKey, the bearer
** token supplier and SSLContext are not resolved for validation.
*******************************************************************************/
default List<String> validate(AssistantProvider row)
default List<String> validate(ProviderSettings settings)
{
return (List.of());
}
@@ -36,6 +36,7 @@ public class ProviderSettings
private String tlsCaBundle;
@JsonIgnore
private transient SSLContext sslContext;
private String apiKeyEnvName;
private String kind;
private String apiKey;
private String baseUrl;
@@ -397,4 +398,35 @@ public class ProviderSettings
this.bearerTokenSupplier = bearerTokenSupplier;
return (this);
}
/*******************************************************************************
** The configured environment variable name, never its secret value.
*******************************************************************************/
public String getApiKeyEnvName()
{
return (this.apiKeyEnvName);
}
/*******************************************************************************
** Setter for apiKeyEnvName
*******************************************************************************/
public void setApiKeyEnvName(String apiKeyEnvName)
{
this.apiKeyEnvName = apiKeyEnvName;
}
/*******************************************************************************
** Fluent setter for apiKeyEnvName
*******************************************************************************/
public ProviderSettings withApiKeyEnvName(String apiKeyEnvName)
{
this.apiKeyEnvName = apiKeyEnvName;
return (this);
}
}
@@ -0,0 +1,75 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
import java.util.Set;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
/*******************************************************************************
** Keep external provider consumers independent of the assistant runtime and QQQ.
*******************************************************************************/
class ProviderApiArchitectureTest
{
/*******************************************************************************
** Allow only the extracted types, including their nested enums, under the
** assistant namespace. A future dependency on any runtime package must fail.
*******************************************************************************/
@Test
void dependsOnlyOnApiTypes()
{
Set<String> apiTypes = Set.of(
"com.kingsrook.qbits.assistant.provider.AssistantModelProvider",
"com.kingsrook.qbits.assistant.provider.AssistantProviderType",
"com.kingsrook.qbits.assistant.provider.CacheHint",
"com.kingsrook.qbits.assistant.provider.CompletableStreamHandle",
"com.kingsrook.qbits.assistant.provider.DiscoveredModel",
"com.kingsrook.qbits.assistant.provider.EmbeddingRequest",
"com.kingsrook.qbits.assistant.provider.EmbeddingResponse",
"com.kingsrook.qbits.assistant.provider.HealthResult",
"com.kingsrook.qbits.assistant.provider.ModelCapabilities",
"com.kingsrook.qbits.assistant.provider.ModelEventListener",
"com.kingsrook.qbits.assistant.provider.ModelMessage",
"com.kingsrook.qbits.assistant.provider.ModelPart",
"com.kingsrook.qbits.assistant.provider.ModelProviderException",
"com.kingsrook.qbits.assistant.provider.ModelRequest",
"com.kingsrook.qbits.assistant.provider.ModelResponse",
"com.kingsrook.qbits.assistant.provider.ModelToolDefinition",
"com.kingsrook.qbits.assistant.provider.ModelUsage",
"com.kingsrook.qbits.assistant.provider.ProviderSettings",
"com.kingsrook.qbits.assistant.provider.StreamHandle",
"com.kingsrook.qbits.assistant.provider.SystemBlock",
"com.kingsrook.qbits.assistant.provider.TokenCount",
"com.kingsrook.qbits.assistant.provider.TrustedCaBundle",
"com.kingsrook.qbits.assistant.engine.ModelPricing");
JavaClasses imported = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("com.kingsrook.qbits.assistant");
DescribedPredicate<JavaClass> outsideRuntime = DescribedPredicate.describe("outside assistant or an extracted API type", type ->
!type.getName().startsWith("com.kingsrook.qbits.assistant.") || apiTypes.contains(type.getName().split("\\$", 2)[0]));
classes().should().onlyDependOnClassesThat(outsideRuntime).check(imported);
noClasses().should().dependOnClassesThat().resideInAnyPackage("com.kingsrook.qqq..").check(imported);
}
}
@@ -0,0 +1,214 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import javax.net.ssl.SSLContext;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.assistant.engine.ModelPricing;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Provider contracts work on a classpath with no assistant runtime or QQQ.
*******************************************************************************/
class ProviderApiTest
{
private final ObjectMapper mapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
/*******************************************************************************
** Preserve provider-neutral requests and tool order through JSON; signed
** provider state remains in memory only.
*******************************************************************************/
@Test
void requestRoundTripAndCopy() throws Exception
{
ModelCapabilities capabilities = new ModelCapabilities().withTools(true).withParallelToolCalls(true)
.withPromptCaching(true).withThinking(false).withMaxContextTokens(32000)
.withVision(false).withStreaming(true).withReasoningEffort(true);
ModelToolDefinition tool = new ModelToolDefinition().withName("lookup").withDescription("Look up a record")
.withInputSchema(Map.of("type", "object")).withStrict(true);
ModelMessage user = ModelMessage.user("Find it").withCacheHint(CacheHint.SHORT);
ModelMessage assistant = ModelMessage.assistant("Looking").withPart(ModelPart.toolUse("call-1", "lookup", null))
.withProviderState("private-signature");
ModelMessage result = new ModelMessage().withRole(ModelMessage.Role.USER)
.withParts(List.of(ModelPart.toolResult("call-1", "denied", true)));
ModelRequest request = new ModelRequest().withModel("example").withEffort("low").withMaxTokens(100)
.withParallelToolCalls(true).withStreaming(false).withCapabilities(capabilities)
.withSystem(List.of(new SystemBlock("rules", CacheHint.LONG), new SystemBlock().withText("user context").withCacheHint(CacheHint.SHORT)))
.withTools(List.of(tool)).withMessages(new ArrayList<>(List.of(user, assistant, result)));
String json = mapper.writeValueAsString(request);
assertThat(json).doesNotContain("private-signature", "providerState");
ModelRequest restored = mapper.readValue(json, ModelRequest.class);
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(request));
assertThat(restored.getMessages().get(1).getProviderState()).isNull();
assertThat(restored.getMessages().get(2).getParts().get(0).getIsError()).isTrue();
ModelRequest copy = request.copy();
assertThat(mapper.<JsonNode>valueToTree(copy)).isEqualTo(mapper.valueToTree(request));
copy.getMessages().clear();
assertThat(request.getMessages()).hasSize(3);
}
/*******************************************************************************
** Response helpers retain tool ordering, text, usage and safe metadata.
*******************************************************************************/
@Test
void responseAndPricingRoundTrip() throws Exception
{
ModelUsage usage = new ModelUsage().withInputTokens(2L).withOutputTokens(3L)
.withCacheReadTokens(4L).withCacheWriteTokens(5L).withCacheWrite1hTokens(1L);
ModelPart first = ModelPart.toolUse("first", "lookup", Map.of("id", 1));
ModelPart second = ModelPart.toolUse("second", "lookup", Map.of("id", 2));
ModelResponse response = new ModelResponse().withParts(List.of(ModelPart.text("a"), first, ModelPart.text("b"), second))
.withModel("example").withStopReason(ModelResponse.STOP_TOOL_USE).withRequestId("request-1")
.withUsage(usage).withProviderState("signature");
assertThat(response.getText()).isEqualTo("ab");
assertThat(response.getToolUses()).containsExactly(first, second);
String json = mapper.writeValueAsString(response);
assertThat(json).doesNotContain("signature", "providerState");
ModelResponse restored = mapper.readValue(json, ModelResponse.class);
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(response));
assertThat(restored.getProviderState()).isNull();
assertThat(response.getProviderState()).isEqualTo("signature");
ModelPricing pricing = ModelPricing.of(0.1, 0.5, 0.1);
ModelPricing restoredPricing = mapper.readValue(mapper.writeValueAsString(pricing), ModelPricing.class);
assertThat(restoredPricing.costMicros(usage)).isEqualTo(3);
assertThat(new ModelUsage().withInputTokens(null).add(usage).add(null).billableTokens()).isEqualTo(10);
TokenCount tokens = mapper.readValue(mapper.writeValueAsString(new TokenCount().withInputTokens(12L)), TokenCount.class);
assertThat(tokens.getInputTokens()).isEqualTo(12L);
}
/*******************************************************************************
** Plain validation settings require no credentials or entity classes, and
** live token suppliers/TLS contexts cannot be serialized into diagnostics.
*******************************************************************************/
@Test
void settingsRoundTrip() throws Exception
{
ProviderSettings settings = new ProviderSettings().withKind("EXAMPLE").withApiKeyEnvName("EXAMPLE_KEY")
.withApiKey("test-only-key").withBaseUrl("https://example.invalid").withApiVersion("v1")
.withRegion("region").withCloudProject("project").withTlsCaBundle("corporate")
.withConnectTimeoutSeconds(5).withRequestTimeoutSeconds(30)
.withBearerTokenSupplier(() -> "ephemeral-token").withSslContext(SSLContext.getDefault());
String json = mapper.writeValueAsString(settings);
assertThat(json).doesNotContain("ephemeral-token", "bearerTokenSupplier", "sslContext");
ProviderSettings restored = mapper.readValue(json, ProviderSettings.class);
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(settings));
assertThat(settings.getBearerTokenSupplier().get()).isEqualTo("ephemeral-token");
assertThat(settings.getSslContext()).isNotNull();
assertThat(restored.getBearerTokenSupplier()).isNull();
assertThat(restored.getSslContext()).isNull();
}
/*******************************************************************************
** Embedding batches snapshot input and enforce the transport size contract.
*******************************************************************************/
@Test
void embeddingContracts()
{
List<String> input = new ArrayList<>(List.of("first", "second"));
EmbeddingRequest request = new EmbeddingRequest("embed", input);
input.clear();
assertThat(request.input()).containsExactly("first", "second");
assertThatThrownBy(() -> request.input().add("third")).isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> new EmbeddingRequest(" ", List.of("text"))).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new EmbeddingRequest(null, List.of("text"))).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new EmbeddingRequest("embed", List.of())).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new EmbeddingRequest("embed", Collections.nCopies(65, "text"))).isInstanceOf(IllegalArgumentException.class);
assertThat(new EmbeddingRequest("embed", Collections.nCopies(64, "text")).input()).hasSize(64);
float[] vector = new float[] { 0.25F, 0.75F };
EmbeddingResponse response = new EmbeddingResponse(List.of(vector), new ModelUsage().withInputTokens(2L), "request");
assertThat(response.vectors().getFirst()).containsExactly(0.25F, 0.75F);
assertThat(response.usage().getInputTokens()).isEqualTo(2L);
assertThat(response.requestId()).isEqualTo("request");
}
/*******************************************************************************
** Cancellation is idempotent, preserves accrued usage, and wins over late
** completion. Timeout also cancels the underlying transport.
*******************************************************************************/
@Test
void cancellationAndTimeout() throws Exception
{
AtomicInteger cancellations = new AtomicInteger();
ModelUsage usage = new ModelUsage().withInputTokens(10L);
CompletableStreamHandle handle = new CompletableStreamHandle();
handle.setPartialUsage(() -> usage);
handle.setOnCancel(cancellations::incrementAndGet);
handle.cancel();
handle.cancel();
handle.complete(new ModelResponse().withStopReason(ModelResponse.STOP_END_TURN));
handle.fail(new ModelProviderException(ModelProviderException.Kind.SERVER, 500, "late failure"));
assertThat(handle.isCancelled()).isTrue();
assertThat(cancellations.get()).isEqualTo(1);
assertThat(handle.await(Duration.ofSeconds(1)).getUsage()).isSameAs(usage);
CompletableStreamHandle timeout = new CompletableStreamHandle();
timeout.setPartialUsage(() -> usage);
assertThatThrownBy(() -> timeout.await(Duration.ofMillis(1))).isInstanceOfSatisfying(ModelProviderException.class, error ->
{
assertThat(error.getKind()).isEqualTo(ModelProviderException.Kind.TIMEOUT);
assertThat(error.getPartialUsage()).isSameAs(usage);
});
assertThat(timeout.isCancelled()).isTrue();
handle.setPartialUsage(() ->
{
throw (new IllegalStateException("closed"));
});
assertThat(handle.partialUsage()).isNull();
}
/*******************************************************************************
** Error metadata is bounded and redacted independently of error classification.
*******************************************************************************/
@Test
void errorDetails()
{
ModelProviderException error = new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "Invalid schema")
.withStrictSchemaRejected(true).withProviderDetails("invalid_request", "bad key sk-abcdefghijk\ntry again", "req-123");
assertThat(error.isStrictSchemaRejected()).isTrue();
assertThat(error.getProviderErrorType()).isEqualTo("invalid_request");
assertThat(error.getProviderMessage()).isEqualTo("bad key [redacted] try again");
assertThat(error.getRequestId()).isEqualTo("req-123");
assertThat(error.isRetryable()).isFalse();
assertThat(ModelProviderException.sanitizeProviderMessage(" ", 10)).isNull();
assertThat(ModelProviderException.sanitizeProviderMessage(null, 10)).isNull();
assertThat(ModelProviderException.sanitizeProviderMessage("word ".repeat(100), 20)).hasSize(20);
assertThat(ModelProviderException.sanitizeProviderMessage("a".repeat(50), 100)).isEqualTo("[redacted]");
}
}
@@ -0,0 +1,80 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
import java.time.Duration;
import com.kingsrook.qbits.assistant.engine.ModelPricing;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Standalone contracts moved from the runtime's component tests.
*******************************************************************************/
class ProviderComponentsTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void testPricing()
{
ModelPricing sonnet = ModelPricing.defaults().get("claude-sonnet-5-5");
ModelUsage usage = new ModelUsage().withInputTokens(6000L).withOutputTokens(800L).withCacheReadTokens(12_000L).withCacheWriteTokens(1000L).withCacheWrite1hTokens(400L);
//////////////////////////////////////////////////////////////////////////////////
// 6000*2 + 800*10 + 12000*0.2 + 600*2.5 + 400*4 = 12000+8000+2400+1500+1600 //
//////////////////////////////////////////////////////////////////////////////////
assertThat(sonnet.costMicros(usage)).isEqualTo(25_500);
assertThat(sonnet.costMicros(null)).isZero();
assertThat(new ModelPricing().costMicros(usage)).isZero();
assertThat(ModelPricing.defaults().get("claude-opus-5-5").getCacheReadUsdPerMTok()).isEqualTo(0.2);
assertThat(usage.billableTokens()).isEqualTo(7800);
assertThat(new ModelUsage().add(usage).add(null).getCacheReadTokens()).isEqualTo(12_000);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testStreamHandle() throws Exception
{
CompletableStreamHandle handle = new CompletableStreamHandle();
handle.complete(new ModelResponse().withStopReason("end_turn"));
assertThat(handle.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo("end_turn");
CompletableStreamHandle failing = new CompletableStreamHandle();
failing.fail(new ModelProviderException(ModelProviderException.Kind.RATE_LIMITED, 429, "slow down"));
assertThatThrownBy(() -> failing.await(Duration.ofSeconds(1))).isInstanceOfSatisfying(ModelProviderException.class, e ->
{
assertThat(e.isRetryable()).isTrue();
assertThat(e.getHttpStatus()).isEqualTo(429);
});
CompletableStreamHandle cancelled = new CompletableStreamHandle();
cancelled.setOnCancel(() ->
{
throw (new IllegalStateException("closing"));
});
cancelled.cancel();
cancelled.cancel();
assertThat(cancelled.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo(ModelResponse.STOP_CANCELLED);
assertThat(new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "x").isRetryable()).isFalse();
}
}
@@ -0,0 +1,164 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** A minimal external provider compiles and uses SPI defaults without the runtime.
*******************************************************************************/
class ProviderDefaultsTest
{
/*******************************************************************************
** Legacy two-argument factories and static model lists remain supported.
*******************************************************************************/
@Test
void minimalProviderDefaults() throws Exception
{
ExampleProvider provider = new ExampleProvider();
ProviderSettings settings = new ProviderSettings().withApiKey("test-key").withBaseUrl("https://example.invalid");
assertThat(provider.create(settings)).isSameAs(provider);
assertThat(provider.settings.getApiKey()).isEqualTo("test-key");
assertThat(provider.settings.getBaseUrl()).isEqualTo("https://example.invalid");
assertThat(provider.getKinds()).containsExactly("EXAMPLE_PROVIDER");
assertThat(provider.getDefaultBaseUrl("EXAMPLE_PROVIDER")).isNull();
assertThat(provider.getDefaultApiKeyEnvName("EXAMPLE_PROVIDER")).isEqualTo("EXAMPLE_KEY");
assertThat(provider.getDefaultLocation("EXAMPLE_PROVIDER")).isEqualTo("CLOUD");
assertThat(provider.requiresBaseUrl("EXAMPLE_PROVIDER")).isTrue();
assertThat(provider.validate(settings)).isEmpty();
assertThat(provider.discoverModels(settings)).containsExactly(new DiscoveredModel("example", "example", null, null));
assertThat(provider.checkHealth(settings).healthy()).isNull();
assertThat(provider.checkHealth(settings).message()).isEqualTo("Health check not supported");
assertThat(new HealthResult(false, 20L, "bad sk-abcdefghijk").message()).isEqualTo("bad [redacted]");
assertThat(provider.countTokens(new ModelRequest())).isNull();
assertThatThrownBy(() -> provider.embed(new EmbeddingRequest("example", List.of("text"))))
.isInstanceOfSatisfying(ModelProviderException.class, error -> assertThat(error.getKind()).isEqualTo(ModelProviderException.Kind.INVALID_REQUEST));
StringBuilder deltas = new StringBuilder();
ModelEventListener listener = deltas::append;
listener.onToolUseStart("call", "lookup");
assertThat(deltas).isEmpty();
listener.onTextDelta("visible answer");
assertThat(deltas).hasToString("visible answer");
}
/*******************************************************************************
** Only the original required SPI methods are implemented.
*******************************************************************************/
private static class ExampleProvider implements AssistantProviderType, AssistantModelProvider
{
private ProviderSettings settings;
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public String getType()
{
return ("example-provider");
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public String getLabel()
{
return ("Example");
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public List<String> getModels()
{
return (List.of("example"));
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public String getDefaultApiKeyEnvName()
{
return ("EXAMPLE_KEY");
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public String getDefaultBaseUrl()
{
return (null);
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public AssistantModelProvider create(String apiKey, String baseUrl)
{
settings = new ProviderSettings().withApiKey(apiKey).withBaseUrl(baseUrl);
return (this);
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public String getName()
{
return ("example");
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public ModelCapabilities getCapabilities(String modelId)
{
return (new ModelCapabilities());
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public StreamHandle stream(ModelRequest request, ModelEventListener listener)
{
throw (new UnsupportedOperationException());
}
}
}
+5
View File
@@ -41,9 +41,14 @@
<artifactId>qbit-assistant-provider-anthropic</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
@@ -24,7 +24,6 @@ import com.anthropic.backends.Backend;
import com.anthropic.bedrock.backends.BedrockBackend;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.core.Timeout;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
@@ -117,14 +116,14 @@ public class BedrockProviderType implements AssistantProviderType
** Validate cloud settings without loading credentials.
*******************************************************************************/
@Override
public List<String> validate(AssistantProvider row)
public List<String> validate(ProviderSettings settings)
{
List<String> errors = new ArrayList<>();
if(row.getRegion() == null || row.getRegion().isBlank())
if(settings.getRegion() == null || settings.getRegion().isBlank())
{
errors.add("Region is required for BEDROCK.");
}
if(row.getApiKeyEnvName() != null && !row.getApiKeyEnvName().isBlank())
if(settings.getApiKeyEnvName() != null && !settings.getApiKeyEnvName().isBlank())
{
errors.add("Leave API key variable empty: AWS Bedrock (Claude) uses the AWS default credential chain (environment, profile or server role).");
}
+5
View File
@@ -32,9 +32,14 @@
<url>https://github.com/QRun-IO/qbit-assistant</url>
<dependencies>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
+5
View File
@@ -45,9 +45,14 @@
<artifactId>qbit-assistant-provider-openai</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
@@ -19,7 +19,6 @@ package com.kingsrook.qbits.assistant.provider.vertex;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
@@ -104,18 +103,18 @@ public class VertexProviderType implements AssistantProviderType
** Validate cloud settings without loading credentials.
*******************************************************************************/
@Override
public List<String> validate(AssistantProvider row)
public List<String> validate(ProviderSettings settings)
{
List<String> errors = new ArrayList<>();
if(row.getRegion() == null || row.getRegion().isBlank())
if(settings.getRegion() == null || settings.getRegion().isBlank())
{
errors.add("Region is required for VERTEX.");
}
if(row.getApiKeyEnvName() != null && !row.getApiKeyEnvName().isBlank())
if(settings.getApiKeyEnvName() != null && !settings.getApiKeyEnvName().isBlank())
{
errors.add("Leave API key variable empty: Google Vertex AI (Claude, Gemini) uses Google Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS or workload identity).");
}
if(row.getCloudProject() == null || row.getCloudProject().isBlank())
if(settings.getCloudProject() == null || settings.getCloudProject().isBlank())
{
errors.add("GCP project id (cloudProject) is required for VERTEX.");
}
+14 -1
View File
@@ -28,10 +28,14 @@
<packaging>jar</packaging>
<name>QBit Assistant</name>
<description>In-app AI assistant QBit for QQQ: conversations, turn engine, SSE routes, budgets, provider SPI</description>
<description>In-app AI assistant QBit for QQQ: conversations, turn engine, SSE routes, budgets and provider registry</description>
<url>https://github.com/QRun-IO/qbit-assistant</url>
<dependencies>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
@@ -45,6 +49,15 @@
<optional>true</optional>
</dependency>
<!-- Optional email delivery of scheduled-task results. Every reference is in -->
<!-- the email package; without the jar nothing changes. -->
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>${qbit-email.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -17,6 +17,7 @@
package com.kingsrook.qbits.assistant;
import javax.net.ssl.SSLContext;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -28,11 +29,9 @@ import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Pattern;
import javax.net.ssl.SSLContext;
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();
@@ -110,6 +111,9 @@ public class AssistantQBitConfig implements QBitConfig
private Integer policyCacheSeconds = 30;
private String ownerSecurityKeyType = DEFAULT_OWNER_KEY_TYPE;
private String shortcut = DEFAULT_SHORTCUT;
private Set<String> allowedScheduledCredentialEnvNames = new LinkedHashSet<>();
private String scheduledTasksSchedulerName;
private String retentionSchedulerName;
private Boolean registerRoutes = true;
private Map<String, ModelPricing> pricing = ModelPricing.defaults();
@@ -142,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;
@@ -214,6 +220,13 @@ public class AssistantQBitConfig implements QBitConfig
requirePositive(errors, "killSwitchPollMillis", killSwitchPollMillis);
requirePositive(errors, "providerTestTimeoutSeconds", providerTestTimeoutSeconds);
requirePositive(errors, "homeMaxRows", homeMaxRows);
for(String name : allowedScheduledCredentialEnvNames == null ? Set.<String>of() : allowedScheduledCredentialEnvNames)
{
if(name == null || !ENV_NAME.matcher(name).matches())
{
errors.add("allowedScheduledCredentialEnvNames contains an invalid environment variable name.");
}
}
for(String name : allowedApiKeyEnvNames == null ? Set.<String>of() : allowedApiKeyEnvNames)
{
if(name == null || !ENV_NAME.matcher(name).matches())
@@ -1492,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)
*******************************************************************************/
@@ -1690,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
*******************************************************************************/
@@ -1855,4 +1946,66 @@ public class AssistantQBitConfig implements QBitConfig
this.vectorStore = vectorStore;
return (this);
}
/*******************************************************************************
** Getter for allowedScheduledCredentialEnvNames
*******************************************************************************/
public Set<String> getAllowedScheduledCredentialEnvNames()
{
return (allowedScheduledCredentialEnvNames);
}
/*******************************************************************************
** Setter for allowedScheduledCredentialEnvNames
*******************************************************************************/
public void setAllowedScheduledCredentialEnvNames(Set<String> allowedScheduledCredentialEnvNames)
{
this.allowedScheduledCredentialEnvNames = allowedScheduledCredentialEnvNames;
}
/*******************************************************************************
** Fluent setter for allowedScheduledCredentialEnvNames
*******************************************************************************/
public AssistantQBitConfig withAllowedScheduledCredentialEnvNames(Set<String> allowedScheduledCredentialEnvNames)
{
this.allowedScheduledCredentialEnvNames = allowedScheduledCredentialEnvNames;
return (this);
}
/*******************************************************************************
** Getter for scheduledTasksSchedulerName
*******************************************************************************/
public String getScheduledTasksSchedulerName()
{
return (scheduledTasksSchedulerName);
}
/*******************************************************************************
** Setter for scheduledTasksSchedulerName
*******************************************************************************/
public void setScheduledTasksSchedulerName(String scheduledTasksSchedulerName)
{
this.scheduledTasksSchedulerName = scheduledTasksSchedulerName;
}
/*******************************************************************************
** Fluent setter for scheduledTasksSchedulerName
*******************************************************************************/
public AssistantQBitConfig withScheduledTasksSchedulerName(String scheduledTasksSchedulerName)
{
this.scheduledTasksSchedulerName = scheduledTasksSchedulerName;
return (this);
}
}
@@ -21,22 +21,29 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
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;
import com.kingsrook.qbits.assistant.processes.ScheduledTaskProcess;
import com.kingsrook.qbits.assistant.processes.TestConnectionProcess;
import com.kingsrook.qbits.assistant.processes.TestModelConnectionProcess;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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;
@@ -174,7 +181,7 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
QProcessMetaData testConnection = TestConnectionProcess.process();
testConnection.setSourceQBitName(qBitMetaData.getName());
qInstance.addProcess(testConnection);
for(QProcessMetaData process : java.util.List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process()))
for(QProcessMetaData process : List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process()))
{
process.setSourceQBitName(qBitMetaData.getName());
qInstance.addProcess(process);
@@ -186,18 +193,58 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
qInstance.addProcess(process);
}
for(QProcessMetaData process : ScheduledTaskProcess.processes(qBitConfig))
{
process.setSourceQBitName(qBitMetaData.getName());
qInstance.addProcess(process);
}
qInstance.addWidget(AssistantHome.widget());
qInstance.addWidget(AssistantAccessWidget.widget());
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.
*******************************************************************************/
@@ -226,19 +273,19 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
boolean routes = false;
if(Boolean.TRUE.equals(qBitConfig.getRegisterRoutes()) && isJavalinAvailable())
{
com.kingsrook.qbits.assistant.api.AssistantRoutes.register(qInstance);
AssistantRoutes.register(qInstance);
routes = true;
}
if(qBitConfig.isEffectivelyEnabled())
{
com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.register(qInstance);
AssistantInstanceMetaData.register(qInstance);
}
boolean developerPanel = false;
if(com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels.isContractAvailable())
if(AssistantDeveloperPanels.isContractAvailable())
{
com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels.register(qInstance);
AssistantDeveloperPanels.register(qInstance);
developerPanel = true;
}
@@ -32,19 +32,24 @@ 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;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernelSettings;
import com.kingsrook.qqq.backend.core.actions.ai.exposure.AiExposurePolicy;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
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.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -57,6 +62,8 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
*******************************************************************************/
public class AssistantRuntime
{
private final ScheduledTurn scheduledTurn = new ScheduledTurn();
private final RagService ragService;
private final KnowledgeTool knowledgeTool;
private final BulkLoadTools bulkLoadTools;
@@ -97,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;
@@ -114,7 +122,36 @@ 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());
this.uiApiGateway = 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()
{
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public boolean isInvocable(String method, String pathTemplate)
{
return (delegate.isInvocable(method, pathTemplate));
}
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call) throws QException
{
String channel = scheduledTurn.channel(call.getSessionUUID());
if(channel != null)
{
if(!(delegate instanceof CoreUiApiGateway))
{
throw (new QException("Scheduled tasks require the core UI API gateway for client attribution."));
}
call.withClientDetail(channel.substring(CoreUiApiGateway.CLIENT_CHANNEL.length() + 1));
}
return (delegate.invoke(call));
}
};
VectorStore vectors = config.getVectorStore() == null ? new InMemoryVectorStore() : config.getVectorStore();
this.ragService = new RagService(this, vectors);
this.knowledgeTool = new KnowledgeTool(this, ragService, vectors);
@@ -450,6 +487,13 @@ public class AssistantRuntime
return (knowledgeTool);
}
/*******************************************************************************
** Server-owned scheduled execution context.
*******************************************************************************/
public ScheduledTurn getScheduledTurn()
{
return (scheduledTurn);
}
/*******************************************************************************
@@ -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;
@@ -17,23 +17,22 @@
package com.kingsrook.qbits.assistant.engine;
import java.util.stream.Collectors;
import java.util.Objects;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Semaphore;
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;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.provider.CacheHint;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
@@ -43,6 +42,8 @@ import com.kingsrook.qbits.assistant.provider.ModelResponse;
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;
@@ -116,7 +117,9 @@ public class ModelRouter
public Route route(QSession session, AssistantConversation conversation, AssistantTurn turn, String question, Map<String, Object> pageContext,
PolicyService.EffectivePolicy policy, TurnClassification classification, RunningTurn running, Instant deadline, Consumer<String> notice) throws QException, InterruptedException
{
AssistantProfile profile = runtime.getProfileService().pick(session, runtime.appNameFor(pageContext));
var scheduled = runtime.getScheduledTurn().task(conversation.getId());
ScheduledTurn.checkCost(scheduled, 0L);
AssistantProfile profile = runtime.getProfileService().pick(session, runtime.appNameFor(pageContext), scheduled == null ? null : scheduled.getProfileId());
String mode = profile == null || profile.getRoutingMode() == null ? "OFF" : profile.getRoutingMode();
Boolean escalation = profile == null && Boolean.TRUE.equals(runtime.getConfig().getModels().getEscalationEnabled());
String decision = "OFF";
@@ -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));
}
}
@@ -107,7 +107,17 @@ public class ProfileService
*******************************************************************************/
public Persona persona(QSession session, String appName)
{
AssistantProfile profile = pick(session, appName);
return (persona(session, appName, null));
}
/*******************************************************************************
** An administrator-selected scheduled profile still respects role scope.
*******************************************************************************/
public Persona persona(QSession session, String appName, Integer profileId)
{
AssistantProfile profile = pick(session, appName, profileId);
if(profile == null)
{
return (new Persona(null, DEFAULT_DISPLAY_NAME, DEFAULT_ICON, null, null, List.of()));
@@ -217,6 +227,22 @@ public class ProfileService
*******************************************************************************/
public AssistantProfile pick(QSession session, String appName)
{
return (pick(session, appName, null));
}
/*******************************************************************************
** Explicit profiles supply instructions and routing, never permissions.
*******************************************************************************/
public AssistantProfile pick(QSession session, String appName, Integer profileId)
{
if(profileId != null)
{
return (load().profiles().stream().filter(p -> profileId.equals(p.getId()) && !Boolean.FALSE.equals(p.getEnabled())
&& (!StringUtils.hasContent(p.getPermissionName()) || session.hasPermission(p.getPermissionName()))).findFirst().orElse(null));
}
List<AssistantProfile> enabled = load().profiles().stream().filter(p -> !Boolean.FALSE.equals(p.getEnabled())).toList();
return (enabled.stream()
.filter(p -> appliesTo(p, session, appName))
@@ -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;
@@ -258,7 +258,11 @@ public class ProviderService
return (errors);
}
errors.addAll(type.validate(row));
errors.addAll(type.validate(new ProviderSettings().withKind(kind(row))
.withApiKeyEnvName(row.getApiKeyEnvName()).withBaseUrl(row.getBaseUrl())
.withApiVersion(row.getApiVersion()).withRegion(row.getRegion()).withCloudProject(row.getCloudProject())
.withTlsCaBundle(row.getTlsCaBundle()).withConnectTimeoutSeconds(row.getConnectTimeoutSeconds())
.withRequestTimeoutSeconds(row.getRequestTimeoutSeconds())));
String envName = keyEnvName(row);
if(envName != null && !AssistantQBitConfig.ENV_NAME.matcher(envName).matches())
{
@@ -678,7 +682,7 @@ public class ProviderService
{
throw (new ProviderSetupException("No API key: the environment variable " + keyEnvName(row) + " is not set on this server."));
}
return (new ProviderSettings().withKind(kind(row)).withApiKey(key)
return (new ProviderSettings().withKind(kind(row)).withApiKeyEnvName(row.getApiKeyEnvName()).withApiKey(key)
.withBaseUrl(StringUtils.hasContent(row.getBaseUrl()) ? row.getBaseUrl().trim() : null)
.withApiVersion(row.getApiVersion()).withRegion(row.getRegion()).withCloudProject(row.getCloudProject())
.withTlsCaBundle(row.getTlsCaBundle()).withSslContext(StringUtils.hasContent(row.getTlsCaBundle()) ? config.getTrustedCaBundles().get(row.getTlsCaBundle()) : null)
@@ -738,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;
@@ -27,18 +27,23 @@ import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
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;
@@ -51,10 +56,11 @@ import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
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;
@@ -67,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;
@@ -127,6 +134,9 @@ public class TurnEngine implements Runnable
private final StringBuilder textBuffer = new StringBuilder();
private long lastTextFlush = System.currentTimeMillis();
private AssistantScheduledTask scheduledTask;
private Instant startedAt;
private Instant deadline;
private int toolCallCount = 0;
@@ -140,6 +150,8 @@ public class TurnEngine implements Runnable
private ProviderService.Active budgetOrigin;
private volatile boolean finished = false;
private UiApiCredentials credentials;
private PolicyService.ToolPolicy toolPolicy;
@@ -168,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.
*******************************************************************************/
@@ -185,6 +210,13 @@ public class TurnEngine implements Runnable
try
{
QContext.init(runtime.getQInstance(), session);
scheduledTask = runtime.getScheduledTurn().task(conversation.getId());
if(conversation.getScheduledTaskId() != null && scheduledTask == null)
{
throw (new QException("Scheduled execution context is no longer available; start a new conversation."));
}
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);
startKillSwitchWatcher();
@@ -230,9 +262,9 @@ public class TurnEngine implements Runnable
}
String appName = runtime.appNameFor(pageContext);
ProfileService.Persona persona = runtime.getProfileService().persona(session, appName);
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, policy.tools(), 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))
{
@@ -242,7 +274,7 @@ public class TurnEngine implements Runnable
{
request.setParallelToolCalls(false);
}
Set<String> allowedTools = runtime.getContextBuilder().toolNames(policy.tools());
Set<String> allowedTools = runtime.getContextBuilder().toolNames(toolPolicy);
boolean toolLimitReached = false;
while(true)
@@ -259,6 +291,7 @@ public class TurnEngine implements Runnable
ModelResponse response = fallback.call(request, () -> active, selected -> callModelWithStrictFallback(selected, request));
stopReason = response.getStopReason();
ScheduledTurn.checkCost(runtime, conversation.getId(), turn.getId());
if(ModelResponse.STOP_CANCELLED.equals(stopReason))
{
@@ -543,6 +576,7 @@ public class TurnEngine implements Runnable
{
return (new ModelResponse().withStopReason(ModelResponse.STOP_CANCELLED));
}
ScheduledTurn.checkCost(runtime, conversation.getId(), turn.getId());
PolicyService.BudgetStatus configuredBudget = runtime.getPolicyService().checkConfiguredBudget(policy, userId, usage.billableTokens(), costMicros);
if(configuredBudget.hardExceeded())
{
@@ -556,7 +590,7 @@ public class TurnEngine implements Runnable
throw (new BudgetService.Blocked(budgetDecision.reason()));
}
ProviderService.Active selected = budgetDecision.active();
if(!java.util.Objects.equals(active.providerId(), selected.providerId()) || !java.util.Objects.equals(active.model(), selected.model()))
if(!Objects.equals(active.providerId(), selected.providerId()) || !Objects.equals(active.model(), selected.model()))
{
active = selected;
model = active.model();
@@ -761,11 +795,11 @@ public class TurnEngine implements Runnable
{
future.get(waitMillis, TimeUnit.MILLISECONDS);
}
catch(java.util.concurrent.TimeoutException e)
catch(TimeoutException e)
{
future.cancel(true);
}
catch(java.util.concurrent.ExecutionException e)
catch(ExecutionException e)
{
LOG.debug("tool future failed", hipaaSafe(e));
}
@@ -853,6 +887,7 @@ public class TurnEngine implements Runnable
.withUserId(userId)
.withToolCallId(use.getToolUseId())
.withToolName(use.getToolName())
.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())
@@ -877,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
@@ -899,7 +962,7 @@ public class TurnEngine implements Runnable
*******************************************************************************/
private UiApiToolbox.Context toolContext()
{
return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), policy.tools(), this::proposeWrite));
return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), toolPolicy, this::proposeWrite, credentials, turn.getId() == null ? null : String.valueOf(turn.getId())));
}
@@ -1073,6 +1136,10 @@ public class TurnEngine implements Runnable
finally
{
running.getLog().close();
if(scheduledTask != null)
{
runtime.getScheduledTurn().detach(conversation.getId(), session.getUuid());
}
runtime.unregister(turn.getId());
runtime.getRateLimiter().release(userId);
}
@@ -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());
}
}
@@ -17,6 +17,8 @@
package com.kingsrook.qbits.assistant.metadata;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
import com.kingsrook.qbits.assistant.home.AssistantHome;
@@ -30,19 +32,22 @@ import com.kingsrook.qbits.assistant.model.AssistantDataAudit;
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantProfile;
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.model.AssistantSkill;
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.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;
@@ -68,8 +73,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import java.util.ArrayList;
import java.util.List;
/*******************************************************************************
@@ -111,7 +114,7 @@ public class AssistantMetaDataBuilder
** calls on a conversation; model calls on a provider) or reached from the
** home. Processes are table and record actions, never nav entries.
*******************************************************************************/
public static final List<String> NAV_TABLES = List.of(AssistantRagSource.TABLE_NAME, AssistantBudget.TABLE_NAME, AssistantProvider.TABLE_NAME, AssistantProfile.TABLE_NAME, AssistantSkill.TABLE_NAME, AssistantDataAccess.TABLE_NAME, AssistantConversation.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantPolicy.TABLE_NAME);
public static final List<String> NAV_TABLES = List.of(AssistantScheduledTask.TABLE_NAME, AssistantRagSource.TABLE_NAME, AssistantBudget.TABLE_NAME, AssistantProvider.TABLE_NAME, AssistantProfile.TABLE_NAME, AssistantSkill.TABLE_NAME, AssistantDataAccess.TABLE_NAME, AssistantConversation.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantPolicy.TABLE_NAME);
private final AssistantQBitConfig config;
@@ -187,6 +190,17 @@ public class AssistantMetaDataBuilder
.withUniqueKey(new UniqueKey("name"))
.withRecordLabelFormat("%s").withRecordLabelFields("name"));
QCodeReference scheduledCustomizer = new QCodeReference(AssistantScheduledTaskCustomizer.class);
tables.add(text(base(AssistantScheduledTask.TABLE_NAME, "Scheduled tasks", "schedule", AssistantScheduledTask.class), "prompt")
.withRecordLabelFormat("%s").withRecordLabelFields("name")
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, scheduledCustomizer).withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, scheduledCustomizer)
.withCustomizer(TableCustomizers.POST_QUERY_RECORD, scheduledCustomizer));
tables.add(readOnly(owned(base(AssistantScheduledRun.TABLE_NAME, "Scheduled runs", "history", AssistantScheduledRun.class), false))
.withoutCapabilities(Capability.TABLE_DELETE)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
.withUniqueKey(new UniqueKey("taskId", "scheduledFor"))
.withRecordLabelFormat("Run #%s").withRecordLabelFields("id"));
QCodeReference ragCustomizer = new QCodeReference(AssistantRagSourceCustomizer.class);
tables.add(text(base(AssistantRagSource.TABLE_NAME, "Knowledge sources", "manage_search", AssistantRagSource.class), "filterJson")
.withRecordLabelFormat("%s").withRecordLabelFields("name")
@@ -223,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));
}
}
}
@@ -43,6 +43,8 @@ import com.kingsrook.qbits.assistant.model.AssistantProfile;
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.model.AssistantSkill;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
@@ -52,9 +54,9 @@ import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
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.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
@@ -132,6 +134,8 @@ public final class AssistantRecordScreens
}
return (List.of(
QPossibleValueSource.newForTable(AssistantScheduledTask.TABLE_NAME),
enumSource("assistant_scheduled_output", List.of(new QPossibleValue<>("RECORD", "Record"), new QPossibleValue<>("NOTIFICATION", "Notification"), new QPossibleValue<>("BOTH", "Both"))),
QPossibleValueSource.newForTable(AssistantBudget.TABLE_NAME),
QPossibleValueSource.newForTable(AssistantModel.TABLE_NAME).withName("assistant_registry_model"),
QPossibleValueSource.newForTable(AssistantModel.TABLE_NAME).withName("assistant_budget_model"),
@@ -173,6 +177,32 @@ public final class AssistantRecordScreens
{
switch(table.getName())
{
case AssistantScheduledTask.TABLE_NAME ->
{
link(table, "profileId", AssistantProfile.TABLE_NAME, "Profile");
pvs(table, "outputMode", "assistant_scheduled_output", "Output mode");
help(table, "credentialEnvName", "Name of an allowed environment variable holding this user's access token. Never enter the token here.");
help(table, "cronExpression", "Quartz cron expression, including seconds; for example 0 0 8 * * ? for 08:00 daily.");
help(table, "timezone", "IANA time zone, for example America/Chicago. Defaults to UTC; daylight saving time follows this zone.");
help(table, "writeModeRequested", "Requires administrator approval of this exact prompt and identity. Each proposed write still requires the user's confirmation.");
help(table, "maxCostUsdPerRun", "Optional USD STOP cap. Checked before each model or embedding call; a completed call may cross the cap.");
sections(table,
section("task", "Task", "schedule", Tier.T1, "id", "name", "enabled", "prompt", "profileId"),
section("identity", "Run as", "person", Tier.T2, "runAsUserId", "credentialEnvName", "credentialStatus"),
section("schedule", "Schedule and output", "event", Tier.T2, "cronExpression", "timezone", "nextRunAt", "outputMode", "maxCostUsdPerRun"),
section("approval", "Write approval", "verified_user", Tier.T2, "writeModeRequested", "writeModeApprovedBy", "writeModeApprovedAt", "writeModeApprovalHash"),
section("lastRun", "Last run", "history", Tier.T2, "lastRunAt", "lastRunStatus", "lastRunError"));
}
case AssistantScheduledRun.TABLE_NAME ->
{
link(table, "taskId", AssistantScheduledTask.TABLE_NAME, "Task");
link(table, "conversationId", AssistantConversation.TABLE_NAME, "Conversation");
link(table, "turnId", AssistantTurn.TABLE_NAME, "Turn");
help(table, "costMicros", MICROS_HELP);
sections(table,
section("run", "Run", "history", Tier.T1, "id", "taskId", "userId", "status", "scheduledFor", "startedAt", "endedAt"),
section("result", "Result", "chat", Tier.T2, "conversationId", "turnId", "summary", "costMicros", "error"));
}
case AssistantBudget.TABLE_NAME -> budget(table);
case AssistantBudgetOverride.TABLE_NAME, AssistantBudgetEvent.TABLE_NAME ->
{
@@ -213,6 +243,11 @@ public final class AssistantRecordScreens
*******************************************************************************/
public static void addConversationChildLists(QInstance qInstance)
{
QTableMetaData scheduledTask = qInstance.getTable(AssistantScheduledTask.TABLE_NAME);
if(scheduledTask != null)
{
childList(qInstance, scheduledTask, AssistantScheduledRun.TABLE_NAME, "Runs", "history", List.of("startedAt", "status", "conversationId", "costMicros", "summary"), "taskId", "scheduled");
}
QTableMetaData conversation = qInstance.getTable(AssistantConversation.TABLE_NAME);
if(conversation == null)
{
@@ -539,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"));
}
@@ -677,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.");
@@ -693,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"));
}
@@ -0,0 +1,132 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.metadata;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTasks;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
/*******************************************************************************
** Validate task edits and invalidate approvals before changed text is saved.
*******************************************************************************/
public class AssistantScheduledTaskCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
** Internal status writes bypass this customizer only inside AssistantStore.
*******************************************************************************/
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
{
Optional<Map<Serializable, QRecord>> old = oldRecordListToMap("id", oldRecordList);
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
for(QRecord record : records)
{
record.getValues().remove("credentialStatus");
if(input instanceof UpdateInput update
&& update.hasFlag(AssistantStore.ScheduledWrite.INTERNAL))
{
continue;
}
QRecord previous = old.map(rows -> rows.get(record.getValue("id"))).orElse(null);
for(String field : List.of("writeModeApprovedBy", "writeModeApprovedAt", "writeModeApprovalHash", "nextRunAt", "lastRunAt", "lastRunStatus", "lastRunError"))
{
record.getValues().remove(field);
}
QRecord merged = new QRecord();
merged.setValues(previous == null ? new LinkedHashMap<>() : new LinkedHashMap<>(previous.getValues()));
record.getValues().forEach(merged::setValue);
for(var entry : Map.<String, Serializable>of("enabled", false, "timezone", "UTC", "outputMode", "RECORD", "writeModeRequested", false).entrySet())
{
if(merged.getValue(entry.getKey()) == null)
{
merged.setValue(entry.getKey(), entry.getValue());
record.setValue(entry.getKey(), entry.getValue());
}
}
if(previous == null || ScheduledTasks.APPROVAL_FIELDS.stream().anyMatch(field -> !Objects.equals(previous.getValue(field), merged.getValue(field))))
{
record.setValue("writeModeApprovedBy", null);
record.setValue("writeModeApprovedAt", null);
record.setValue("writeModeApprovalHash", null);
}
try
{
AssistantScheduledTask task = new AssistantScheduledTask(merged);
ScheduledTasks.checkReference(runtime.getConfig(), task.getCredentialEnvName());
if(task.getPrompt() == null || task.getPrompt().isBlank() || task.getPrompt().length() > 4000)
{
throw (new QException("The prompt must contain 1 to 4000 characters."));
}
if(!List.of("RECORD", "NOTIFICATION", "BOTH").contains(task.getOutputMode()))
{
throw (new QException("Output mode must be RECORD, NOTIFICATION or BOTH."));
}
if(task.getMaxCostUsdPerRun() != null && task.getMaxCostUsdPerRun().signum() < 0)
{
throw (new QException("The per-run cost cap cannot be negative."));
}
var next = ScheduledTasks.next(task, runtime.getClock().instant());
if(previous == null || List.of("enabled", "cronExpression", "timezone").stream().anyMatch(field -> !Objects.equals(previous.getValue(field), merged.getValue(field))))
{
record.setValue("nextRunAt", Boolean.TRUE.equals(task.getEnabled()) ? next : null);
}
}
catch(QException e)
{
record.addError(new BadInputStatusMessage(e.getMessage()));
}
}
return (records);
}
/*******************************************************************************
** Computed status is never persisted.
*******************************************************************************/
@Override
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
{
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
for(QRecord record : records)
{
record.setValue("credentialStatus", ScheduledTasks.credentialStatus(runtime.getConfig(), new AssistantScheduledTask(record)));
}
return (records);
}
}
@@ -38,6 +38,12 @@ public class AssistantConversation extends QRecordEntity
@Deprecated(since = "0.1.0-RC.3")
public static final String LEGACY_TABLE_NAME = "assistantConversation";
@QField(isEditable = false)
private Integer scheduledTaskId;
@QField(isEditable = false, maxLength = 64)
private String channel = "panel";
@QField(isEditable = false, maxLength = 16)
private String maxClassification;
@@ -65,6 +71,9 @@ public class AssistantConversation extends QRecordEntity
@QField(isEditable = false)
private String contextJson;
@QField()
private Boolean writeMode;
@QField(isEditable = false)
private Long totalTokensIn;
@@ -503,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
@@ -535,4 +574,66 @@ public class AssistantConversation extends QRecordEntity
/*******************************************************************************
** Getter for scheduledTaskId
*******************************************************************************/
public Integer getScheduledTaskId()
{
return (scheduledTaskId);
}
/*******************************************************************************
** Setter for scheduledTaskId
*******************************************************************************/
public void setScheduledTaskId(Integer scheduledTaskId)
{
this.scheduledTaskId = scheduledTaskId;
}
/*******************************************************************************
** Fluent setter for scheduledTaskId
*******************************************************************************/
public AssistantConversation withScheduledTaskId(Integer scheduledTaskId)
{
this.scheduledTaskId = scheduledTaskId;
return (this);
}
/*******************************************************************************
** Getter for channel
*******************************************************************************/
public String getChannel()
{
return (channel);
}
/*******************************************************************************
** Setter for channel
*******************************************************************************/
public void setChannel(String channel)
{
this.channel = channel;
}
/*******************************************************************************
** Fluent setter for channel
*******************************************************************************/
public AssistantConversation withChannel(String channel)
{
this.channel = channel;
return (this);
}
}
@@ -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);
}
}
@@ -0,0 +1,532 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.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;
/*******************************************************************************
** Scheduled assistant bookkeeping; credentials are references only.
*******************************************************************************/
public class AssistantScheduledRun extends QRecordEntity
{
public static final String TABLE_NAME = "assistant_scheduled_run";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isRequired = true)
private Integer taskId;
@QField(isRequired = true, maxLength = 200)
private String userId;
@QField()
private Instant scheduledFor;
@QField()
private Instant startedAt;
@QField()
private Instant endedAt;
@QField(maxLength = 20)
private String status;
@QField()
private Integer conversationId;
@QField()
private Integer turnId;
@QField(maxLength = 2000)
private String summary;
@QField()
private Long costMicros;
@QField(maxLength = 500)
private String error;
@QField(isEditable = false)
private Instant createDate;
@QField(isEditable = false)
private Instant modifyDate;
/*******************************************************************************
** Empty entity for framework mapping.
*******************************************************************************/
public AssistantScheduledRun()
{
}
/*******************************************************************************
** Map a bookkeeping record.
*******************************************************************************/
public AssistantScheduledRun(QRecord record) throws QException
{
populateFromQRecord(record);
}
/*******************************************************************************
** Getter for id
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Setter for id
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Fluent setter for id
*******************************************************************************/
public AssistantScheduledRun withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Getter for taskId
*******************************************************************************/
public Integer getTaskId()
{
return (taskId);
}
/*******************************************************************************
** Setter for taskId
*******************************************************************************/
public void setTaskId(Integer taskId)
{
this.taskId = taskId;
}
/*******************************************************************************
** Fluent setter for taskId
*******************************************************************************/
public AssistantScheduledRun withTaskId(Integer taskId)
{
this.taskId = taskId;
return (this);
}
/*******************************************************************************
** Getter for userId
*******************************************************************************/
public String getUserId()
{
return (userId);
}
/*******************************************************************************
** Setter for userId
*******************************************************************************/
public void setUserId(String userId)
{
this.userId = userId;
}
/*******************************************************************************
** Fluent setter for userId
*******************************************************************************/
public AssistantScheduledRun withUserId(String userId)
{
this.userId = userId;
return (this);
}
/*******************************************************************************
** Getter for scheduledFor
*******************************************************************************/
public Instant getScheduledFor()
{
return (scheduledFor);
}
/*******************************************************************************
** Setter for scheduledFor
*******************************************************************************/
public void setScheduledFor(Instant scheduledFor)
{
this.scheduledFor = scheduledFor;
}
/*******************************************************************************
** Fluent setter for scheduledFor
*******************************************************************************/
public AssistantScheduledRun withScheduledFor(Instant scheduledFor)
{
this.scheduledFor = scheduledFor;
return (this);
}
/*******************************************************************************
** Getter for startedAt
*******************************************************************************/
public Instant getStartedAt()
{
return (startedAt);
}
/*******************************************************************************
** Setter for startedAt
*******************************************************************************/
public void setStartedAt(Instant startedAt)
{
this.startedAt = startedAt;
}
/*******************************************************************************
** Fluent setter for startedAt
*******************************************************************************/
public AssistantScheduledRun withStartedAt(Instant startedAt)
{
this.startedAt = startedAt;
return (this);
}
/*******************************************************************************
** Getter for endedAt
*******************************************************************************/
public Instant getEndedAt()
{
return (endedAt);
}
/*******************************************************************************
** Setter for endedAt
*******************************************************************************/
public void setEndedAt(Instant endedAt)
{
this.endedAt = endedAt;
}
/*******************************************************************************
** Fluent setter for endedAt
*******************************************************************************/
public AssistantScheduledRun withEndedAt(Instant endedAt)
{
this.endedAt = endedAt;
return (this);
}
/*******************************************************************************
** Getter for status
*******************************************************************************/
public String getStatus()
{
return (status);
}
/*******************************************************************************
** Setter for status
*******************************************************************************/
public void setStatus(String status)
{
this.status = status;
}
/*******************************************************************************
** Fluent setter for status
*******************************************************************************/
public AssistantScheduledRun withStatus(String status)
{
this.status = status;
return (this);
}
/*******************************************************************************
** Getter for conversationId
*******************************************************************************/
public Integer getConversationId()
{
return (conversationId);
}
/*******************************************************************************
** Setter for conversationId
*******************************************************************************/
public void setConversationId(Integer conversationId)
{
this.conversationId = conversationId;
}
/*******************************************************************************
** Fluent setter for conversationId
*******************************************************************************/
public AssistantScheduledRun withConversationId(Integer conversationId)
{
this.conversationId = conversationId;
return (this);
}
/*******************************************************************************
** Getter for turnId
*******************************************************************************/
public Integer getTurnId()
{
return (turnId);
}
/*******************************************************************************
** Setter for turnId
*******************************************************************************/
public void setTurnId(Integer turnId)
{
this.turnId = turnId;
}
/*******************************************************************************
** Fluent setter for turnId
*******************************************************************************/
public AssistantScheduledRun withTurnId(Integer turnId)
{
this.turnId = turnId;
return (this);
}
/*******************************************************************************
** Getter for summary
*******************************************************************************/
public String getSummary()
{
return (summary);
}
/*******************************************************************************
** Setter for summary
*******************************************************************************/
public void setSummary(String summary)
{
this.summary = summary;
}
/*******************************************************************************
** Fluent setter for summary
*******************************************************************************/
public AssistantScheduledRun withSummary(String summary)
{
this.summary = summary;
return (this);
}
/*******************************************************************************
** Getter for costMicros
*******************************************************************************/
public Long getCostMicros()
{
return (costMicros);
}
/*******************************************************************************
** Setter for costMicros
*******************************************************************************/
public void setCostMicros(Long costMicros)
{
this.costMicros = costMicros;
}
/*******************************************************************************
** Fluent setter for costMicros
*******************************************************************************/
public AssistantScheduledRun withCostMicros(Long costMicros)
{
this.costMicros = costMicros;
return (this);
}
/*******************************************************************************
** Getter for error
*******************************************************************************/
public String getError()
{
return (error);
}
/*******************************************************************************
** Setter for error
*******************************************************************************/
public void setError(String error)
{
this.error = error;
}
/*******************************************************************************
** Fluent setter for error
*******************************************************************************/
public AssistantScheduledRun withError(String error)
{
this.error = error;
return (this);
}
/*******************************************************************************
** Getter for createDate
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Setter for createDate
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Fluent setter for createDate
*******************************************************************************/
public AssistantScheduledRun withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Getter for modifyDate
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Setter for modifyDate
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Fluent setter for modifyDate
*******************************************************************************/
public AssistantScheduledRun withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
}
@@ -0,0 +1,805 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.model;
import java.math.BigDecimal;
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;
/*******************************************************************************
** Scheduled assistant bookkeeping; credentials are references only.
*******************************************************************************/
public class AssistantScheduledTask extends QRecordEntity
{
public static final String TABLE_NAME = "assistant_scheduled_task";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isRequired = true, maxLength = 200)
private String name;
@QField()
private Boolean enabled;
@QField(isRequired = true, maxLength = 4000)
private String prompt;
@QField(isRequired = true, maxLength = 200)
private String runAsUserId;
@QField(isRequired = true, maxLength = 200)
private String credentialEnvName;
@QField(isEditable = false, maxLength = 200)
private String credentialStatus;
@QField()
private Integer profileId;
@QField(isRequired = true, maxLength = 200)
private String cronExpression;
@QField(maxLength = 100)
private String timezone;
@QField(isEditable = false)
private Instant nextRunAt;
@QField(maxLength = 20)
private String outputMode;
@QField()
private Boolean writeModeRequested;
@QField(isEditable = false, maxLength = 200)
private String writeModeApprovedBy;
@QField(isEditable = false)
private Instant writeModeApprovedAt;
@QField(isEditable = false, maxLength = 64)
private String writeModeApprovalHash;
@QField()
private BigDecimal maxCostUsdPerRun;
@QField(isEditable = false)
private Instant lastRunAt;
@QField(isEditable = false, maxLength = 20)
private String lastRunStatus;
@QField(isEditable = false, maxLength = 500)
private String lastRunError;
@QField(isEditable = false)
private Instant createDate;
@QField(isEditable = false)
private Instant modifyDate;
/*******************************************************************************
** Empty entity for framework mapping.
*******************************************************************************/
public AssistantScheduledTask()
{
}
/*******************************************************************************
** Map a bookkeeping record.
*******************************************************************************/
public AssistantScheduledTask(QRecord record) throws QException
{
populateFromQRecord(record);
}
/*******************************************************************************
** Getter for id
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Setter for id
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Fluent setter for id
*******************************************************************************/
public AssistantScheduledTask withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Getter for name
*******************************************************************************/
public String getName()
{
return (name);
}
/*******************************************************************************
** Setter for name
*******************************************************************************/
public void setName(String name)
{
this.name = name;
}
/*******************************************************************************
** Fluent setter for name
*******************************************************************************/
public AssistantScheduledTask withName(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** Getter for enabled
*******************************************************************************/
public Boolean getEnabled()
{
return (enabled);
}
/*******************************************************************************
** Setter for enabled
*******************************************************************************/
public void setEnabled(Boolean enabled)
{
this.enabled = enabled;
}
/*******************************************************************************
** Fluent setter for enabled
*******************************************************************************/
public AssistantScheduledTask withEnabled(Boolean enabled)
{
this.enabled = enabled;
return (this);
}
/*******************************************************************************
** Getter for prompt
*******************************************************************************/
public String getPrompt()
{
return (prompt);
}
/*******************************************************************************
** Setter for prompt
*******************************************************************************/
public void setPrompt(String prompt)
{
this.prompt = prompt;
}
/*******************************************************************************
** Fluent setter for prompt
*******************************************************************************/
public AssistantScheduledTask withPrompt(String prompt)
{
this.prompt = prompt;
return (this);
}
/*******************************************************************************
** Getter for runAsUserId
*******************************************************************************/
public String getRunAsUserId()
{
return (runAsUserId);
}
/*******************************************************************************
** Setter for runAsUserId
*******************************************************************************/
public void setRunAsUserId(String runAsUserId)
{
this.runAsUserId = runAsUserId;
}
/*******************************************************************************
** Fluent setter for runAsUserId
*******************************************************************************/
public AssistantScheduledTask withRunAsUserId(String runAsUserId)
{
this.runAsUserId = runAsUserId;
return (this);
}
/*******************************************************************************
** Getter for credentialEnvName
*******************************************************************************/
public String getCredentialEnvName()
{
return (credentialEnvName);
}
/*******************************************************************************
** Setter for credentialEnvName
*******************************************************************************/
public void setCredentialEnvName(String credentialEnvName)
{
this.credentialEnvName = credentialEnvName;
}
/*******************************************************************************
** Fluent setter for credentialEnvName
*******************************************************************************/
public AssistantScheduledTask withCredentialEnvName(String credentialEnvName)
{
this.credentialEnvName = credentialEnvName;
return (this);
}
/*******************************************************************************
** Getter for credentialStatus
*******************************************************************************/
public String getCredentialStatus()
{
return (credentialStatus);
}
/*******************************************************************************
** Setter for credentialStatus
*******************************************************************************/
public void setCredentialStatus(String credentialStatus)
{
this.credentialStatus = credentialStatus;
}
/*******************************************************************************
** Fluent setter for credentialStatus
*******************************************************************************/
public AssistantScheduledTask withCredentialStatus(String credentialStatus)
{
this.credentialStatus = credentialStatus;
return (this);
}
/*******************************************************************************
** Getter for profileId
*******************************************************************************/
public Integer getProfileId()
{
return (profileId);
}
/*******************************************************************************
** Setter for profileId
*******************************************************************************/
public void setProfileId(Integer profileId)
{
this.profileId = profileId;
}
/*******************************************************************************
** Fluent setter for profileId
*******************************************************************************/
public AssistantScheduledTask withProfileId(Integer profileId)
{
this.profileId = profileId;
return (this);
}
/*******************************************************************************
** Getter for cronExpression
*******************************************************************************/
public String getCronExpression()
{
return (cronExpression);
}
/*******************************************************************************
** Setter for cronExpression
*******************************************************************************/
public void setCronExpression(String cronExpression)
{
this.cronExpression = cronExpression;
}
/*******************************************************************************
** Fluent setter for cronExpression
*******************************************************************************/
public AssistantScheduledTask withCronExpression(String cronExpression)
{
this.cronExpression = cronExpression;
return (this);
}
/*******************************************************************************
** Getter for timezone
*******************************************************************************/
public String getTimezone()
{
return (timezone);
}
/*******************************************************************************
** Setter for timezone
*******************************************************************************/
public void setTimezone(String timezone)
{
this.timezone = timezone;
}
/*******************************************************************************
** Fluent setter for timezone
*******************************************************************************/
public AssistantScheduledTask withTimezone(String timezone)
{
this.timezone = timezone;
return (this);
}
/*******************************************************************************
** Getter for nextRunAt
*******************************************************************************/
public Instant getNextRunAt()
{
return (nextRunAt);
}
/*******************************************************************************
** Setter for nextRunAt
*******************************************************************************/
public void setNextRunAt(Instant nextRunAt)
{
this.nextRunAt = nextRunAt;
}
/*******************************************************************************
** Fluent setter for nextRunAt
*******************************************************************************/
public AssistantScheduledTask withNextRunAt(Instant nextRunAt)
{
this.nextRunAt = nextRunAt;
return (this);
}
/*******************************************************************************
** Getter for outputMode
*******************************************************************************/
public String getOutputMode()
{
return (outputMode);
}
/*******************************************************************************
** Setter for outputMode
*******************************************************************************/
public void setOutputMode(String outputMode)
{
this.outputMode = outputMode;
}
/*******************************************************************************
** Fluent setter for outputMode
*******************************************************************************/
public AssistantScheduledTask withOutputMode(String outputMode)
{
this.outputMode = outputMode;
return (this);
}
/*******************************************************************************
** Getter for writeModeRequested
*******************************************************************************/
public Boolean getWriteModeRequested()
{
return (writeModeRequested);
}
/*******************************************************************************
** Setter for writeModeRequested
*******************************************************************************/
public void setWriteModeRequested(Boolean writeModeRequested)
{
this.writeModeRequested = writeModeRequested;
}
/*******************************************************************************
** Fluent setter for writeModeRequested
*******************************************************************************/
public AssistantScheduledTask withWriteModeRequested(Boolean writeModeRequested)
{
this.writeModeRequested = writeModeRequested;
return (this);
}
/*******************************************************************************
** Getter for writeModeApprovedBy
*******************************************************************************/
public String getWriteModeApprovedBy()
{
return (writeModeApprovedBy);
}
/*******************************************************************************
** Setter for writeModeApprovedBy
*******************************************************************************/
public void setWriteModeApprovedBy(String writeModeApprovedBy)
{
this.writeModeApprovedBy = writeModeApprovedBy;
}
/*******************************************************************************
** Fluent setter for writeModeApprovedBy
*******************************************************************************/
public AssistantScheduledTask withWriteModeApprovedBy(String writeModeApprovedBy)
{
this.writeModeApprovedBy = writeModeApprovedBy;
return (this);
}
/*******************************************************************************
** Getter for writeModeApprovedAt
*******************************************************************************/
public Instant getWriteModeApprovedAt()
{
return (writeModeApprovedAt);
}
/*******************************************************************************
** Setter for writeModeApprovedAt
*******************************************************************************/
public void setWriteModeApprovedAt(Instant writeModeApprovedAt)
{
this.writeModeApprovedAt = writeModeApprovedAt;
}
/*******************************************************************************
** Fluent setter for writeModeApprovedAt
*******************************************************************************/
public AssistantScheduledTask withWriteModeApprovedAt(Instant writeModeApprovedAt)
{
this.writeModeApprovedAt = writeModeApprovedAt;
return (this);
}
/*******************************************************************************
** Getter for writeModeApprovalHash
*******************************************************************************/
public String getWriteModeApprovalHash()
{
return (writeModeApprovalHash);
}
/*******************************************************************************
** Setter for writeModeApprovalHash
*******************************************************************************/
public void setWriteModeApprovalHash(String writeModeApprovalHash)
{
this.writeModeApprovalHash = writeModeApprovalHash;
}
/*******************************************************************************
** Fluent setter for writeModeApprovalHash
*******************************************************************************/
public AssistantScheduledTask withWriteModeApprovalHash(String writeModeApprovalHash)
{
this.writeModeApprovalHash = writeModeApprovalHash;
return (this);
}
/*******************************************************************************
** Getter for maxCostUsdPerRun
*******************************************************************************/
public BigDecimal getMaxCostUsdPerRun()
{
return (maxCostUsdPerRun);
}
/*******************************************************************************
** Setter for maxCostUsdPerRun
*******************************************************************************/
public void setMaxCostUsdPerRun(BigDecimal maxCostUsdPerRun)
{
this.maxCostUsdPerRun = maxCostUsdPerRun;
}
/*******************************************************************************
** Fluent setter for maxCostUsdPerRun
*******************************************************************************/
public AssistantScheduledTask withMaxCostUsdPerRun(BigDecimal maxCostUsdPerRun)
{
this.maxCostUsdPerRun = maxCostUsdPerRun;
return (this);
}
/*******************************************************************************
** Getter for lastRunAt
*******************************************************************************/
public Instant getLastRunAt()
{
return (lastRunAt);
}
/*******************************************************************************
** Setter for lastRunAt
*******************************************************************************/
public void setLastRunAt(Instant lastRunAt)
{
this.lastRunAt = lastRunAt;
}
/*******************************************************************************
** Fluent setter for lastRunAt
*******************************************************************************/
public AssistantScheduledTask withLastRunAt(Instant lastRunAt)
{
this.lastRunAt = lastRunAt;
return (this);
}
/*******************************************************************************
** Getter for lastRunStatus
*******************************************************************************/
public String getLastRunStatus()
{
return (lastRunStatus);
}
/*******************************************************************************
** Setter for lastRunStatus
*******************************************************************************/
public void setLastRunStatus(String lastRunStatus)
{
this.lastRunStatus = lastRunStatus;
}
/*******************************************************************************
** Fluent setter for lastRunStatus
*******************************************************************************/
public AssistantScheduledTask withLastRunStatus(String lastRunStatus)
{
this.lastRunStatus = lastRunStatus;
return (this);
}
/*******************************************************************************
** Getter for lastRunError
*******************************************************************************/
public String getLastRunError()
{
return (lastRunError);
}
/*******************************************************************************
** Setter for lastRunError
*******************************************************************************/
public void setLastRunError(String lastRunError)
{
this.lastRunError = lastRunError;
}
/*******************************************************************************
** Fluent setter for lastRunError
*******************************************************************************/
public AssistantScheduledTask withLastRunError(String lastRunError)
{
this.lastRunError = lastRunError;
return (this);
}
/*******************************************************************************
** Getter for createDate
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Setter for createDate
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Fluent setter for createDate
*******************************************************************************/
public AssistantScheduledTask withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Getter for modifyDate
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Setter for modifyDate
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Fluent setter for modifyDate
*******************************************************************************/
public AssistantScheduledTask withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
}
@@ -40,6 +40,9 @@ public class AssistantToolCall extends QRecordEntity
@Deprecated(since = "0.1.0-RC.3")
public static final String LEGACY_TABLE_NAME = "assistantToolCall";
@QField(isEditable = false, maxLength = 64)
private String clientChannel;
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@@ -97,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)
@@ -803,4 +806,35 @@ public class AssistantToolCall extends QRecordEntity
return (this);
}
/*******************************************************************************
** Getter for clientChannel
*******************************************************************************/
public String getClientChannel()
{
return (clientChannel);
}
/*******************************************************************************
** Setter for clientChannel
*******************************************************************************/
public void setClientChannel(String clientChannel)
{
this.clientChannel = clientChannel;
}
/*******************************************************************************
** Fluent setter for clientChannel
*******************************************************************************/
public AssistantToolCall withClientChannel(String clientChannel)
{
this.clientChannel = clientChannel;
return (this);
}
}
@@ -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);
}
}
@@ -17,8 +17,20 @@
package com.kingsrook.qbits.assistant.persistence;
import java.io.Serializable;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.Classifications;
import com.kingsrook.qbits.assistant.engine.TurnClassification;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantBudgetEvent;
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
@@ -32,18 +44,23 @@ import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
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.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;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
@@ -56,15 +73,6 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import java.io.Serializable;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/*******************************************************************************
@@ -377,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.
*******************************************************************************/
@@ -773,7 +791,7 @@ public class AssistantStore
/*******************************************************************************
** Save the turn level and never lower persisted conversation history.
*******************************************************************************/
public synchronized void saveClassification(String userId, Integer conversationId, Integer turnId, com.kingsrook.qbits.assistant.engine.TurnClassification classification) throws QException
public synchronized void saveClassification(String userId, Integer conversationId, Integer turnId, TurnClassification classification) throws QException
{
AssistantConversation current = getConversation(userId, conversationId);
classification.inherit(current);
@@ -781,8 +799,8 @@ public class AssistantStore
.withValue("classification", classification.getClassification().name()).withValue("classificationTables", classification.getTables()));
if(current != null)
{
var maximum = com.kingsrook.qbits.assistant.classification.DataClassification.max(
com.kingsrook.qbits.assistant.classification.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()));
@@ -934,4 +952,98 @@ public class AssistantStore
updateFields(AssistantBudgetUsage.TABLE_NAME, projection.withValue("id", existing.get(0).getId()));
}
}
/*******************************************************************************
** Internal bookkeeping flag; never accepted from a UI request body.
*******************************************************************************/
public enum ScheduledWrite implements ActionFlag
{
INTERNAL
}
/*******************************************************************************
** Update computed task fields through QQQ under the caller's session.
*******************************************************************************/
public void scheduledUpdate(QRecord record) throws QException
{
UpdateOutput output = new UpdateAction().execute(new UpdateInput(AssistantScheduledTask.TABLE_NAME)
.withRecord(record).withFlag(ScheduledWrite.INTERNAL));
if(CollectionUtils.nullSafeHasContents(output.getRecords().get(0).getErrors()))
{
throw (new QException("Could not update scheduled task bookkeeping."));
}
}
/*******************************************************************************
** Read a task definition, never application data.
*******************************************************************************/
public AssistantScheduledTask scheduledTask(Integer id) throws QException
{
return (first(query(AssistantScheduledTask.TABLE_NAME,
new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id), AssistantScheduledTask.class)));
}
/*******************************************************************************
** Optimistic slot claim. The database unique(taskId, scheduledFor) is the
** cross-replica arbiter; the monitor also covers the fixture memory backend.
** Advance before dispatch. A crash can skip a slot, never run it twice.
*******************************************************************************/
public ScheduledClaim claimScheduled(
AssistantScheduledTask candidate, Instant now, Boolean manual) throws QException
{
synchronized(QContext.getQInstance())
{
var task = scheduledTask(candidate.getId());
if(task == null || (!manual && (!Boolean.TRUE.equals(task.getEnabled()) || task.getNextRunAt() == null
|| task.getNextRunAt().isAfter(now) || !Objects.equals(task.getNextRunAt(), candidate.getNextRunAt()))))
{
return (null);
}
Instant slot = manual ? now : task.getNextRunAt();
Instant next = Boolean.TRUE.equals(task.getEnabled()) ? ScheduledTasks.next(task, now) : null;
var filter = new QQueryFilter().withCriteria("taskId", QCriteriaOperator.EQUALS, task.getId()).withCriteria("scheduledFor", QCriteriaOperator.EQUALS, slot);
if(!query(AssistantScheduledRun.TABLE_NAME, filter, AssistantScheduledRun.class).isEmpty())
{
if(!manual)
{
scheduledUpdate(new QRecord().withValue("id", task.getId()).withValue("nextRunAt", next));
}
return (null);
}
AssistantScheduledRun run;
try
{
run = insert(new AssistantScheduledRun().withTaskId(task.getId()).withUserId(task.getRunAsUserId())
.withScheduledFor(slot).withStartedAt(now).withStatus("RUNNING").withCostMicros(0L));
}
catch(QException e)
{
if(!query(AssistantScheduledRun.TABLE_NAME, filter, AssistantScheduledRun.class).isEmpty())
{
return (null);
}
throw (e);
}
scheduledUpdate(new QRecord().withValue("id", task.getId()).withValue("nextRunAt", next));
return (new ScheduledClaim(task, run));
}
}
/*******************************************************************************
** The claimed task snapshot and its run owner must remain consistent.
*******************************************************************************/
public record ScheduledClaim(AssistantScheduledTask task, AssistantScheduledRun run)
{
}
}
@@ -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;
@@ -0,0 +1,241 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.processes;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
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.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTaskRunner;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTasks;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
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;
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.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** Admin record actions and the scheduler's bookkeeping step. No nav processes.
*******************************************************************************/
public final class ScheduledTaskProcess
{
public static final String RUNNER = "assistantScheduledTasksRunner";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ScheduledTaskProcess()
{
}
/*******************************************************************************
** QQQ supplies the scheduler session. No session is created here.
*******************************************************************************/
public static List<QProcessMetaData> processes(AssistantQBitConfig config)
{
List<QProcessMetaData> result = new ArrayList<>();
QProcessMetaData runner = new QProcessMetaData().withName(RUNNER).withLabel("Run due assistant tasks")
.withPermissionRules(AssistantMetaDataBuilder.adminRules())
.withStep(new QBackendStepMetaData().withName("runDue").withCode(new QCodeReference(Runner.class)));
if(config.getScheduledTasksSchedulerName() != null && !config.getScheduledTasksSchedulerName().isBlank())
{
runner.withSchedule(new QScheduleMetaData().withSchedulerName(config.getScheduledTasksSchedulerName()).withRepeatSeconds(60));
}
result.add(runner);
for(String action : List.of("Approve", "Revoke", "RunNow"))
{
String label = "Approve".equals(action) ? "Approve write mode" : "Revoke".equals(action) ? "Revoke write mode" : "Run now";
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, AssistantScheduledTask.TABLE_NAME);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, AssistantScheduledTask.TABLE_NAME);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, label + " for the selected tasks.");
values.put("scheduledAction", action);
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Result.class, values);
process.setName("assistantScheduledTask" + action);
process.setLabel(label);
process.setIcon(new QIcon("schedule"));
process.setTableName(AssistantScheduledTask.TABLE_NAME);
process.setPermissionRules(AssistantMetaDataBuilder.adminRules());
result.add(process);
}
return (result);
}
/*******************************************************************************
** Actions must enforce permission even when called outside the UI executor.
*******************************************************************************/
private static AssistantRuntime admin() throws QException
{
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(AssistantQBitConfig.ADMIN_PERMISSION))
{
throw (new QException("assistant.admin is required for scheduled task actions."));
}
return (AssistantRuntime.of(QContext.getQInstance()));
}
/*******************************************************************************
** Background scheduler step uses only the context supplied by QQQ.
*******************************************************************************/
public static class Runner implements BackendStep
{
/*******************************************************************************
** Dispatch the due page once.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
output.addValue("runs", new ScheduledTaskRunner(AssistantRuntime.of(QContext.getQInstance())).runDue());
}
}
/*******************************************************************************
** Review binds the displayed prompt hash and user to the exact settings.
*******************************************************************************/
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** No login or model call on Review.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
admin();
for(QRecord record : input.getRecords())
{
AssistantScheduledTask task = new AssistantScheduledTask(record);
record.setValue("reviewedHash", ScheduledTasks.approvalHash(task));
lines.add(TestConnectionProcess.single(Status.INFO, "Run as " + task.getRunAsUserId() + "; prompt SHA-256 " + ScheduledTasks.hash(task.getPrompt())
+ ". Write mode permits policy-allowed create, update and delete proposals; writes wait for this user's panel confirmation.", task.getId()));
output.getRecords().add(record);
}
}
/*******************************************************************************
** Standard streamed ETL Review lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
return (lines);
}
}
/*******************************************************************************
** Result rereads tasks; a change since Review requires a fresh review.
*******************************************************************************/
public static class Result extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** Approval records the actual administrator's session user id.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
AssistantRuntime runtime = admin();
String action = input.getValueString("scheduledAction");
for(QRecord record : input.getRecords())
{
AssistantScheduledTask task = runtime.getStore().scheduledTask(record.getValueInteger("id"));
if(task == null || !ScheduledTasks.approvalHash(task).equals(record.getValueString("reviewedHash")))
{
throw (new QException("The task changed after Review. Review it again."));
}
String summary;
if("RunNow".equals(action))
{
var run = new ScheduledTaskRunner(runtime).run(task, true);
summary = run == null ? "Task already claimed." : "Run " + run.getId() + ": " + run.getStatus();
}
else if("Approve".equals(action) || "Revoke".equals(action))
{
Boolean approve = "Approve".equals(action);
runtime.getStore().scheduledUpdate(new QRecord().withValue("id", task.getId())
.withValue("writeModeApprovedBy", approve ? AssistantSecurity.userId(QContext.getQSession()) : null)
.withValue("writeModeApprovedAt", approve ? runtime.getClock().instant() : null)
.withValue("writeModeApprovalHash", approve ? ScheduledTasks.approvalHash(task) : null));
summary = approve ? "Write mode approved." : "Write mode revoked.";
}
else
{
throw (new QException("Unknown scheduled task action."));
}
lines.add(TestConnectionProcess.single(Status.INFO, summary, task.getId()));
output.getRecords().add(record);
}
}
/*******************************************************************************
** Standard streamed ETL Result lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
return (lines);
}
}
}
@@ -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;
@@ -35,6 +34,8 @@ import com.kingsrook.qbits.assistant.provider.EmbeddingRequest;
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;
@@ -106,6 +107,7 @@ public class EmbeddingService
private List<float[]> batch(Integer modelId, List<String> texts, DataClassification classification, String tables, Integer conversationId, Integer turnId) throws QException
{
String userId = AssistantSecurity.userId(QContext.getQSession());
ScheduledTurn.checkCost(runtime, conversationId, turnId);
ProviderService.Active active = resolve(modelId, classification, tables, conversationId, turnId);
PolicyService.EffectivePolicy policy = runtime.getPolicyService().current(true);
BudgetService.Decision budget = runtime.getBudgetService().evaluate(userId, active, classification == DataClassification.PHI, false);
@@ -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."));
@@ -0,0 +1,72 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.scheduled;
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Optional Alerts notification SPI bridge. The audience is exactly one user;
** notification bodies contain only status and a link, never answer text.
*******************************************************************************/
public class ScheduledAlerts
{
private static final QLogger LOG = QLogger.getLogger(ScheduledAlerts.class);
/*******************************************************************************
** Reflection keeps the optional notification implementation out of the ABI.
*******************************************************************************/
public void raise(AssistantScheduledRun run)
{
LOG.info("Scheduled assistant result", logPair("runId", run.getId()), logPair("status", run.getStatus()));
try
{
String prefix = "com.kingsrook.qbits.alerts.spi.";
Class<?> type = Class.forName(prefix + "NotificationSignal");
Object signal = type.getConstructor(String.class).newInstance("Scheduled assistant task finished");
type.getMethod("withSourceName", String.class).invoke(signal, "assistant");
type.getMethod("withSourceKey", String.class).invoke(signal, "assistant/scheduled/run/" + run.getId());
type.getMethod("withBody", String.class).invoke(signal, "Status: " + run.getStatus() + ". See conversation.");
Class<?> audienceType = Class.forName(prefix + "NotificationAudience");
Object audience = audienceType.getMethod("user", String.class).invoke(null, run.getUserId());
type.getMethod("withAudience", audienceType).invoke(signal, audience);
if(run.getConversationId() != null)
{
Class<?> linkType = Class.forName(prefix + "AlertLink");
Object link = linkType.getMethod("toPath", String.class, String.class).invoke(null,
"/app/assistantAdmin/assistant_conversation/" + run.getConversationId(), "See conversation");
type.getMethod("withLink", linkType).invoke(signal, link);
}
Object runtime = Class.forName("com.kingsrook.qbits.alerts.AlertsRuntime").getMethod("get").invoke(null);
Object sink = runtime.getClass().getMethod("sink").invoke(runtime);
sink.getClass().getMethod("notify", type).invoke(sink, signal);
}
catch(Exception | LinkageError e)
{
LOG.info("Scheduled assistant notification unavailable", logPair("runId", run.getId()));
}
}
}
@@ -0,0 +1,119 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.scheduled;
import java.net.HttpCookie;
import java.net.URI;
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.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
/*******************************************************************************
** Log in exactly as a UI client, then resolve the returned session UUID with
** the host's authentication module. Never authenticate directly with a token
** in process, create an automated session, or manufacture session permissions.
*******************************************************************************/
public class ScheduledLogin
{
/*******************************************************************************
** No response body or transport exception can leak an access token.
*******************************************************************************/
public QSession login(AssistantRuntime runtime, AssistantScheduledTask task) throws QException
{
String token = ScheduledTasks.credential(runtime.getConfig(), task);
String base = runtime.uiApiBaseUrl();
if(base == null || base.isBlank())
{
throw (new QException("Configure uiApiBaseUrl before scheduling assistant tasks."));
}
String uuid;
try
{
HttpRequest request = HttpRequest.newBuilder(URI.create(base.replaceAll("/+$", "") + "/qqq/v1/manageSession"))
.timeout(Duration.ofSeconds(30)).header("Content-Type", "application/json")
.header("X-QQQ-Client", "assistant/scheduled/" + task.getId())
.POST(HttpRequest.BodyPublishers.ofString(new ObjectMapper().writeValueAsString(Map.of("accessToken", token)), StandardCharsets.UTF_8)).build();
HttpResponse<String> response = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()
.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if(response.statusCode() != 200)
{
throw (new QException("Scheduled login manageSession answered HTTP " + response.statusCode() + "."));
}
uuid = response.headers().allValues("Set-Cookie").stream().flatMap(value -> HttpCookie.parse(value).stream())
.filter(cookie -> "sessionUUID".equals(cookie.getName())).map(HttpCookie::getValue).findFirst().orElse("");
if(uuid.isBlank())
{
uuid = new ObjectMapper().readTree(response.body()).path("uuid").asText();
}
if(uuid.isBlank())
{
throw (new QException("Scheduled login returned no session UUID."));
}
}
catch(QException e)
{
throw (e);
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw (new QException("Scheduled login interrupted."));
}
catch(Exception e)
{
throw (new QException("Scheduled login failed."));
}
QSession session;
try
{
var instance = runtime.getQInstance();
String key = QAuthenticationType.MOCK.equals(instance.getAuthentication().getType()) ? "sessionId" : "sessionUUID";
session = new QAuthenticationModuleDispatcher().getQModule(instance.getAuthentication()).createSession(instance, Map.of(key, uuid));
}
catch(Exception e)
{
throw (new QException("Scheduled login session could not be resolved."));
}
if(!task.getRunAsUserId().equals(AssistantSecurity.userId(session)))
{
throw (new QException("The credential belongs to another user."));
}
if(!uuid.equals(session.getUuid()))
{
throw (new QException("The authentication module did not preserve the logged-in session UUID."));
}
AssistantSecurity.applySessionKeys(session, runtime.getConfig());
return (session);
}
}
@@ -0,0 +1,219 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.scheduled;
import java.time.Instant;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.api.AssistantApiException;
import com.kingsrook.qbits.assistant.engine.MessageParts;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.engine.RunningTurn;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
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.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;
/*******************************************************************************
** Scheduler bookkeeping uses its supplied QContext. Each actual turn switches
** to the designated user's freshly authenticated UI session, restoring context
** afterwards. No user data is fetched by the scheduler or the store.
*******************************************************************************/
public class ScheduledTaskRunner
{
private final AssistantRuntime runtime;
private final ScheduledLogin login;
private final ScheduledAlerts alerts;
/*******************************************************************************
** Default production collaborators.
*******************************************************************************/
public ScheduledTaskRunner(AssistantRuntime runtime)
{
this(runtime, new ScheduledLogin(), new ScheduledAlerts());
}
/*******************************************************************************
** Notification seam for hosts and tests; authentication stays explicit.
*******************************************************************************/
public ScheduledTaskRunner(AssistantRuntime runtime, ScheduledLogin login, ScheduledAlerts alerts)
{
this.runtime = runtime;
this.login = login;
this.alerts = alerts;
}
/*******************************************************************************
** One bounded page; each slot is advanced before any authentication/model.
*******************************************************************************/
public Integer runDue() throws QException
{
Instant now = runtime.getClock().instant();
Integer count = 0;
for(AssistantScheduledTask task : runtime.getStore().query(AssistantScheduledTask.TABLE_NAME, new QQueryFilter()
.withCriteria("enabled", QCriteriaOperator.EQUALS, true).withCriteria("nextRunAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)
.withOrderBy(new QFilterOrderBy("nextRunAt", true)).withLimit(100), AssistantScheduledTask.class))
{
if(run(task, false) != null)
{
count++;
}
}
return (count);
}
/*******************************************************************************
** Claim, authenticate, start the normal API turn, and record its result.
*******************************************************************************/
public AssistantScheduledRun run(AssistantScheduledTask candidate, Boolean manual) throws QException
{
var store = runtime.getStore();
var claim = store.claimScheduled(candidate, runtime.getClock().instant(), manual);
if(claim == null)
{
return (null);
}
AssistantScheduledRun run = claim.run();
AssistantScheduledTask task = claim.task();
var original = QContext.capture();
QSession session = null;
Boolean attached = false;
try
{
session = login.login(runtime, task);
QContext.init(runtime.getQInstance(), 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)
.withValue("scheduledTaskId", task.getId()).withValue("channel", "assistant/scheduled"));
runtime.getScheduledTurn().attach(conversationId, session.getUuid(), task);
attached = true;
RunningTurn running = api.startTurn(conversationId, Map.of("text", task.getPrompt()));
run.setTurnId(running.getTurnId());
while(runtime.getRunningTurn(running.getTurnId()) != null)
{
Thread.sleep(20);
}
var turn = store.getTurn(task.getRunAsUserId(), run.getTurnId());
if(turn == null || "RUNNING".equals(turn.getStatus()))
{
throw (new QException("The scheduled turn did not persist a terminal result."));
}
run.setCostMicros(ScheduledTurn.cost(runtime, turn.getId()));
run.setStatus("COMPLETE".equals(turn.getStatus()) ? "OK" : blocked(turn.getErrorCode()) ? "BLOCKED" : "FAILED");
run.setError("OK".equals(run.getStatus()) ? null : "Turn stopped: " + (turn.getErrorCode() == null ? turn.getStatus() : turn.getErrorCode()));
String summary = MessageParts.fromJson(store.getMessage(task.getRunAsUserId(), turn.getAssistantMessageId()).getPartsJson()).text();
Boolean pending = !store.query(AssistantPendingAction.TABLE_NAME, new QQueryFilter().withCriteria("turnId", QCriteriaOperator.EQUALS, turn.getId())
.withCriteria("userId", QCriteriaOperator.EQUALS, task.getRunAsUserId()).withCriteria("status", QCriteriaOperator.EQUALS, "PENDING").withLimit(1), AssistantPendingAction.class).isEmpty();
run.setSummary(Boolean.TRUE.equals(runtime.getConfig().getHipaaMode()) ? "see conversation"
: truncate((pending ? "Waiting for your confirmation in the panel. " : "") + summary, 2000));
}
catch(AssistantApiException e)
{
run.withStatus(blocked(e.getCode()) ? "BLOCKED" : "FAILED").withError("Turn could not start: " + e.getCode());
}
catch(InterruptedException e)
{
if(run.getTurnId() != null && runtime.getRunningTurn(run.getTurnId()) != null)
{
runtime.getRunningTurn(run.getTurnId()).cancel("SCHEDULER_INTERRUPTED");
}
Thread.currentThread().interrupt();
run.withStatus("FAILED").withError("Scheduled runner interrupted.");
}
catch(QException e)
{
if(e instanceof ProviderService.ProviderSetupException && blocked(e.getMessage()))
{
run.withStatus("BLOCKED").withError("Provider blocked by PHI/HIPAA policy.");
}
else
{
run.withStatus("FAILED").withError(run.getConversationId() == null ? truncate(e.getMessage(), 500) : "Scheduled turn failed; see conversation.");
}
}
catch(RuntimeException e)
{
run.withStatus("FAILED").withError("Scheduled task failed.");
}
finally
{
if(attached && (run.getTurnId() == null || runtime.getRunningTurn(run.getTurnId()) == null))
{
runtime.getScheduledTurn().detach(run.getConversationId(), session.getUuid());
}
QContext.init(original);
}
run.setEndedAt(runtime.getClock().instant());
if(Boolean.TRUE.equals(runtime.getConfig().getHipaaMode()))
{
run.setSummary("see conversation");
}
store.update(run);
store.scheduledUpdate(new QRecord().withValue("id", task.getId()).withValue("lastRunAt", run.getStartedAt())
.withValue("lastRunStatus", run.getStatus()).withValue("lastRunError", run.getError()));
if(!"RECORD".equals(task.getOutputMode()))
{
alerts.raise(run);
}
return (run);
}
/*******************************************************************************
** Policy denials are distinct from operational failures.
*******************************************************************************/
private static Boolean blocked(String code)
{
return (code != null && (code.contains("BUDGET") || code.contains("PHI") || code.contains("HIPAA") || code.contains("KILL_SWITCH")));
}
/*******************************************************************************
** Bound stored summaries and sanitized errors.
*******************************************************************************/
private static String truncate(String value, Integer limit)
{
return (value == null || value.length() <= limit ? value : value.substring(0, limit));
}
}
@@ -0,0 +1,164 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.scheduled;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Date;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.TimeZone;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.quartz.CronExpression;
/*******************************************************************************
** Schedule and credential validation shared by saves, reviews and execution.
*******************************************************************************/
public final class ScheduledTasks
{
public static final List<String> APPROVAL_FIELDS = List.of("prompt", "runAsUserId", "credentialEnvName", "profileId", "writeModeRequested");
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ScheduledTasks()
{
}
/*******************************************************************************
** Quartz computes wall-clock schedules in a validated IANA timezone.
*******************************************************************************/
public static Instant next(AssistantScheduledTask task, Instant after) throws QException
{
try
{
CronExpression cron = new CronExpression(task.getCronExpression());
cron.setTimeZone(TimeZone.getTimeZone(ZoneId.of(task.getTimezone() == null ? "UTC" : task.getTimezone())));
Date next = cron.getNextValidTimeAfter(Date.from(after));
return (next == null ? null : next.toInstant());
}
catch(Exception e)
{
throw (new QException("Invalid Quartz cron expression or timezone."));
}
}
/*******************************************************************************
** Refuse a pasted token or an unrelated host secret without echoing it.
*******************************************************************************/
public static void checkReference(AssistantQBitConfig config, String name) throws QException
{
if(name == null || !name.matches("[A-Z_][A-Z0-9_]*"))
{
throw (new QException("The scheduled credential must be an environment variable name."));
}
if(config.getAllowedScheduledCredentialEnvNames() == null || !config.getAllowedScheduledCredentialEnvNames().contains(name))
{
throw (new QException("The scheduled credential environment variable is not allowed."));
}
}
/*******************************************************************************
** Resolve only after the reference passes the dedicated allowlist.
*******************************************************************************/
public static String credential(AssistantQBitConfig config, AssistantScheduledTask task) throws QException
{
checkReference(config, task.getCredentialEnvName());
String token = config.getSecretResolver().apply(task.getCredentialEnvName());
if(token == null || token.isBlank())
{
throw (new QException("The scheduled credential environment variable is not set."));
}
return (token);
}
/*******************************************************************************
** Presence only; no token characters are exposed in record screens.
*******************************************************************************/
public static String credentialStatus(AssistantQBitConfig config, AssistantScheduledTask task)
{
try
{
credential(config, task);
return ("Set (from " + task.getCredentialEnvName() + ")");
}
catch(QException e)
{
return (e.getMessage());
}
}
/*******************************************************************************
** Bind approval to all security-relevant settings, including null values.
*******************************************************************************/
public static String approvalHash(AssistantScheduledTask task) throws QException
{
return (hash(JsonUtils.toJson(APPROVAL_FIELDS.stream().map(name -> task.toQRecord().getValue(name)).toList())));
}
/*******************************************************************************
** SHA-256 displayed on Review and checked again on Result.
*******************************************************************************/
public static String hash(String text) throws QException
{
try
{
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))));
}
catch(Exception e)
{
throw (new QException("Could not hash the scheduled task."));
}
}
/*******************************************************************************
** An approval for an older task definition never authorizes writes.
*******************************************************************************/
public static Boolean writesApproved(AssistantScheduledTask task) throws QException
{
return (Boolean.TRUE.equals(task.getWriteModeRequested()) && task.getWriteModeApprovedAt() != null
&& task.getWriteModeApprovedBy() != null && Objects.equals(task.getWriteModeApprovalHash(), approvalHash(task)));
}
}
@@ -0,0 +1,167 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.scheduled;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
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;
/*******************************************************************************
** Server-owned per-conversation execution settings. They never come from a
** panel request or prompt. Scoped to one runtime and removed after completion.
*******************************************************************************/
public class ScheduledTurn
{
private final Map<Integer, AssistantScheduledTask> tasks = new ConcurrentHashMap<>();
private final Map<String, String> channels = new ConcurrentHashMap<>();
/*******************************************************************************
** Attach a snapshot before the unchanged API starts its normal turn.
*******************************************************************************/
public void attach(Integer conversationId, String uuid, AssistantScheduledTask task) throws QException
{
if(channels.putIfAbsent(uuid, "assistant/scheduled/" + task.getId()) != null)
{
throw (new QException("This login session already has a scheduled task in progress."));
}
tasks.put(conversationId, task);
}
/*******************************************************************************
** Remove references to the completed run's context.
*******************************************************************************/
public void detach(Integer conversationId, String uuid)
{
if(conversationId != null)
{
AssistantScheduledTask task = tasks.remove(conversationId);
if(task != null)
{
channels.remove(uuid, "assistant/scheduled/" + task.getId());
}
}
}
/*******************************************************************************
** Snapshot of task settings for this conversation.
*******************************************************************************/
public AssistantScheduledTask task(Integer conversationId)
{
return (tasks.get(conversationId));
}
/*******************************************************************************
** Client attribution also covers metadata and knowledge retrieval calls.
*******************************************************************************/
public String channel(String uuid)
{
return (uuid == null ? null : channels.get(uuid));
}
/*******************************************************************************
** Narrow the normal policy. Scheduled writes always require confirmation.
*******************************************************************************/
public static PolicyService.ToolPolicy tools(AssistantRuntime runtime, AssistantScheduledTask task, PolicyService.ToolPolicy policy) throws QException
{
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));
}
/*******************************************************************************
** Approval failure closes write mode.
*******************************************************************************/
private static Boolean approved(AssistantScheduledTask task)
{
try
{
return (ScheduledTasks.writesApproved(task));
}
catch(QException e)
{
return (false);
}
}
/*******************************************************************************
** Extra STOP cap at dispatch boundaries; a completed call can cross it.
*******************************************************************************/
public static void checkCost(AssistantScheduledTask task, Long costMicros) throws BudgetService.Blocked
{
if(task != null && task.getMaxCostUsdPerRun() != null
&& BigDecimal.valueOf(costMicros, 6).compareTo(task.getMaxCostUsdPerRun()) >= 0)
{
throw (new BudgetService.Blocked("The scheduled task reached its per-run cost cap."));
}
}
/*******************************************************************************
** Include chat, classifier, failed attempts and knowledge-query embeddings.
*******************************************************************************/
public static Long cost(AssistantRuntime runtime, Integer turnId) throws QException
{
return (runtime.getStore().query(AssistantUsage.TABLE_NAME,
new QQueryFilter().withCriteria("turnId",
QCriteriaOperator.EQUALS, turnId),
AssistantUsage.class).stream().mapToLong(row -> row.getCostMicros() == null ? 0 : row.getCostMicros()).sum());
}
/*******************************************************************************
** Check persisted accounting before each embedding and model dispatch.
*******************************************************************************/
public static void checkCost(AssistantRuntime runtime, Integer conversationId, Integer turnId) throws QException
{
AssistantScheduledTask task = conversationId == null ? null : runtime.getScheduledTurn().task(conversationId);
if(task != null)
{
checkCost(task, cost(runtime, turnId));
}
}
}
@@ -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);
}
}
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more