Compare commits

..
Author SHA1 Message Date
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 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 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
118 changed files with 9801 additions and 958 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}`.
+31 -1
View File
@@ -1,5 +1,30 @@
# Changelog
## 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 +72,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 +90,11 @@ Phase 0/1 of the QQQ Assistant: read-only Q&A and navigation. Requires QQQ 4.1 w
- the AI tool kernel (`qqq-backend-core` `actions.ai`, QRun-IO/qqq#1078)
- the developer-panel contract (QRun-IO/qqq#1076).
### Changed (0.1.0-RC.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`**.
+8 -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,11 +48,12 @@ 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.
@@ -67,6 +70,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 +107,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}`.
+102 -9
View File
@@ -4,10 +4,28 @@ An in-app AI assistant for any QQQ application. The user asks a question in a ch
Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`.
## Modules
| Artifact | What it is |
|---|---|
| `com.kingsrook.qbits:qbit-assistant` | The QBit: conversation tables, turn engine, `/qqq/v1/assistant` routes (JSON + Server-Sent Events), budgets, kill switch, the provider SPI, and a scripted provider for tests and demos. |
| `com.kingsrook.qbits:qbit-assistant-provider-api` | Provider SPI, connection settings, messages, usage and pricing types; only Jackson annotations at runtime. |
| `com.kingsrook.qbits:qbit-assistant` | The QBit: conversation tables, turn engine, `/qqq/v1/assistant` routes (JSON + Server-Sent Events), budgets, kill switch, the provider registry, and a scripted provider for tests and demos. |
| `com.kingsrook.qbits:qbit-assistant-provider-anthropic` | The Claude provider (`com.anthropic:anthropic-java`). Add it to use Claude. The core QBit has no LLM SDK dependency. |
| `com.kingsrook.qbits:qbit-assistant-provider-openai` | OpenAI-compatible chat and embedding providers. |
| `com.kingsrook.qbits:qbit-assistant-provider-bedrock` | Claude through AWS Bedrock. |
| `com.kingsrook.qbits:qbit-assistant-provider-vertex` | Claude and Gemini through Google Vertex AI. |
| `com.kingsrook.qbits:qbit-assistant-rag-pgvector` | JDBC vector store; retains the runtime dependency for `VectorStore` and `ChunkVector`. |
### Implementing a provider outside qbit-assistant
Depend on `com.kingsrook.qbits:qbit-assistant-provider-api` at the same version as
QBit Assistant, and implement `AssistantModelProvider` and optionally
`AssistantProviderType`. No assistant runtime dependency is needed. Existing
`com.kingsrook.qbits.assistant.provider.*` imports and
`com.kingsrook.qbits.assistant.engine.ModelPricing` stay the same.
`AssistantProviderType.validate(ProviderSettings)` receives unresolved settings
(including `apiKeyEnvName`), without a database entity or resolved credentials.
Hosts register providers through `AssistantQBitConfig` in `qbit-assistant`.
Data tools use the QQQ UI API (`/qqq/v1`) with the signed-in user's session. The UI API enforces permissions and record locks; assistant data-access policy and field exposure can only narrow access. Only record-free navigation and help use the core AI tool kernel. Every tool result reaches the model wrapped as untrusted data.
@@ -188,6 +206,8 @@ The nav lists the home and the main tables: **Providers**, **Conversations**, **
| `providerTestTimeoutSeconds` | 30 | How long Test connection waits for the model. |
| `homeMaxRows` | 20000 | Rows per table the app home reads for its period; it says when totals are partial. |
| `tablePathPrefix` | `/app` | The front of the links the app home makes to tables and records. |
| `emailExternalApproved` | `false` | Stands in for the EXTERNAL approval rule (not built yet): scheduled-task result email is sent only when `true`. |
| `emailRecipientResolver` | owner id if it is an address | Hook: task owner user id to email address. |
At runtime, an admin can override the kill switch, the main model and effort, and the budgets in the single **`assistant_policy`** row.
@@ -446,12 +466,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 +507,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 +517,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 +588,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 +639,20 @@ 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
ClientChannelsMetaData.ofOrWithNew(qInstance)
.getDeniedPatterns().get("assistant").remove("audit*");
```
This removes only the default audit pattern; other channel denials and the
signed-in user's audit permissions still apply. Keep explicit denials for audit
surfaces the host does not intend to expose. The default remains unchanged.
+50
View File
@@ -1,5 +1,55 @@
# Upgrading qbit-assistant
## 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 client-channel enforcement
arrives with the client-channel RC; the header and tool audit are already sent.
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
+101 -5
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 (the default `qqq.snapshot.version`) contains #1128, so no local build is needed.
## 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.
@@ -354,10 +366,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.
@@ -469,6 +479,92 @@ 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>`. The only uiapi change is
additive `LoopbackUiApiGateway.invokeWithClient`; custom gateways currently
fail closed for scheduled requests because their channel contract is unknown.
Tool-call rows also record `clientChannel`. Core channel deny enforcement
remains pending the client-channel RC; existing AI exposure/data-access
restrictions continue to apply on RC.5.
- 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.
- This base has create/update/delete tools, but no W1 conversation mode/policy
switch. Scheduled runs explicitly narrow the existing tool policy: write tools
require both request and matching approval, a READ_WRITE table and the policy's
allowed-tools list. All scheduled writes require panel confirmation, including
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 |
|---|---|---|
+50
View File
@@ -524,3 +524,53 @@ 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);
+12 -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,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.6</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;
@@ -110,6 +110,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 +145,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 +219,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 +1504,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)
*******************************************************************************/
@@ -1855,4 +1923,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,17 +21,21 @@ 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.AssistantInstanceMetaData;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
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;
@@ -174,7 +178,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,6 +190,11 @@ 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());
@@ -226,19 +235,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;
}
@@ -36,15 +36,19 @@ 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 +61,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;
@@ -114,7 +120,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 +485,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,17 @@ 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
{
UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withCredentials(credentials).withInvokedBy("assistant-explain"));
if(!response.isSuccess())
{
throw (new QException("GET /metaData answered HTTP " + response.status() + (response.errorMessage() == null ? "" : ": " + response.errorMessage())));
@@ -55,6 +55,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 +72,7 @@ public class AssistantApi
private final AssistantStore store;
private final QSession session;
private final String userId;
private UiApiCredentials credentials;
@@ -93,6 +95,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.
*******************************************************************************/
@@ -373,7 +387,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 +612,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);
@@ -722,7 +736,7 @@ public class AssistantApi
{
requireAccess();
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))
{
if(!access.uiVisible() && access.mode() == DataAccessMode.NONE)
{
@@ -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;
@@ -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;
/*******************************************************************************
@@ -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,16 +27,20 @@ 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.persistence.AssistantStore;
@@ -51,10 +55,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 +72,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 +133,9 @@ public class TurnEngine implements Runnable
private final StringBuilder textBuffer = new StringBuilder();
private long lastTextFlush = System.currentTimeMillis();
private AssistantScheduledTask scheduledTask;
private PolicyService.ToolPolicy toolPolicy;
private Instant startedAt;
private Instant deadline;
private int toolCallCount = 0;
@@ -140,6 +149,7 @@ public class TurnEngine implements Runnable
private ProviderService.Active budgetOrigin;
private volatile boolean finished = false;
private UiApiCredentials credentials;
@@ -168,6 +178,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 +208,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 ? policy.tools() : 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 +260,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);
Integer maxPerRequest = active.row() == null ? null : active.row().getMaxOutputTokensPerRequest();
if(maxPerRequest != null && (request.getMaxTokens() == null || request.getMaxTokens() > maxPerRequest))
{
@@ -242,7 +272,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 +289,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 +574,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 +588,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 +793,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 +885,7 @@ public class TurnEngine implements Runnable
.withUserId(userId)
.withToolCallId(use.getToolUseId())
.withToolName(use.getToolName())
.withClientChannel(scheduledTask == null ? null : "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())
@@ -899,7 +932,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 +1106,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);
}
@@ -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,13 +32,15 @@ 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;
@@ -68,8 +72,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 +113,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 +189,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")
@@ -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)
{
@@ -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;
@@ -535,4 +541,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);
}
}
@@ -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;
@@ -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);
}
}
@@ -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,22 @@ 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.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 +72,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;
/*******************************************************************************
@@ -773,7 +780,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 +788,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 +941,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);
@@ -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,173 @@
/*
* 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.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.engine.BudgetService;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.model.AssistantUsage;
import com.kingsrook.qqq.backend.core.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
{
Set<String> allowed = runtime.getContextBuilder().toolNames(policy).stream()
.filter(name -> !Set.of("create_record", "update_record", "delete_record").contains(name) || approved(task))
.collect(Collectors.toSet());
if(allowed.isEmpty())
{
allowed = Set.of("__no_tools__");
}
return (new PolicyService.ToolPolicy(true, allowed, policy.maxRowsPerCall(), policy.redactPii()));
}
/*******************************************************************************
** 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));
}
}
}
@@ -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;
@@ -187,7 +187,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)
{
@@ -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,34 @@ 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.
*******************************************************************************/
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).
@@ -658,7 +713,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;
}
@@ -1255,7 +1310,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);
}
}
@@ -541,7 +541,7 @@ public class BulkLoadTools
private void run(Call call, AiToolResult result, UiApiToolbox.Context context, String tableName, Map<String, Object> values) throws QException
{
BulkLoadRunner.RunHandle handle = runner.start(new BulkLoadRunner.Request(context.sessionUUID(), context.userTimezone(), context.gateway(), tableName,
String.valueOf(values.get("uploadRef")), ValueUtils.getValueAsInteger(values.get("profileId"))));
String.valueOf(values.get("uploadRef")), ValueUtils.getValueAsInteger(values.get("profileId")), context.credentials(), context.turnId()));
call.operation = handle.operation();
call.httpStatus = handle.httpStatus();
boolean ok = !"FAILED".equals(handle.status()) && !"NOT_AVAILABLE".equals(handle.status());
@@ -863,7 +863,7 @@ public class BulkLoadTools
{
return (null);
}
uiApiCall.withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()).withInvokedBy("assistant")
context.prepare(uiApiCall).withInvokedBy("assistant")
.withHeader(ROUTE_HEADER, hipaaRoute ? "hipaa" : "standard");
UiApiResult response = context.gateway().invoke(uiApiCall);
httpStatus = response.status();
@@ -70,6 +70,8 @@ public class JobsLaunchBulkLoadRunner implements BulkLoadRunner
.withPathParam("processName", processName)
.withJsonBody(body)
.withSessionUUID(request.sessionUUID())
.withCredentials(request.credentials())
.withClientDetail(request.turnId())
.withUserTimezone(request.userTimezone())
.withInvokedBy("assistant"));
@@ -0,0 +1,170 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.uiapi;
import java.util.Locale;
import java.util.function.Supplier;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.HeaderedUiApiInvoker;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiInvoker;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiResponse;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import com.kingsrook.qqq.openapi.model.HttpMethod;
/*******************************************************************************
** The assistant's gateway over core's {@link UiApiInvoker} (QRun-IO/qqq#1128,
** docs/UI-API-EXTENSIONS.md section 5): only spec'd /qqq/v1 operations (core
** and registered extensions), a loopback request through the identical
** Javalin pipeline, carrying the caller's own credentials exactly as they
** came in (session cookies, Authorization, API key). There is no service
** session and no way to elevate.
*******************************************************************************/
public class CoreUiApiGateway implements UiApiGateway
{
public static final String OWN_PREFIX = "/assistant";
public static final String CLIENT_CHANNEL = "assistant";
private final Supplier<String> baseUrlSupplier;
private final QInstance qInstance;
private volatile UiApiInvoker invoker;
/*******************************************************************************
** @param baseUrlSupplier this server's own root URL (e.g. http://127.0.0.1:8000)
** @param qInstance the instance (its spec extensions are invocable too)
*******************************************************************************/
public CoreUiApiGateway(Supplier<String> baseUrlSupplier, QInstance qInstance)
{
this.baseUrlSupplier = baseUrlSupplier;
this.qInstance = qInstance;
}
/*******************************************************************************
** The invoker, over a v1 middleware version that knows the instance's
** extensions (built on first use: the instance is complete by then).
*******************************************************************************/
UiApiInvoker invoker()
{
UiApiInvoker current = invoker;
if(current == null)
{
MiddlewareVersionV1 version = new MiddlewareVersionV1();
version.setQInstance(qInstance);
////////////////////////////////////////////////////////////////////////////
// the same as UiApiInvoker.forServer(server, "assistant"): this server's //
// loopback address, its first version, the client channel "assistant" //
// (a call adds only the detail after the slash: the turn id) //
////////////////////////////////////////////////////////////////////////////
current = new HeaderedUiApiInvoker(baseUrlSupplier, version).withClientChannel(CLIENT_CHANNEL);
invoker = current;
}
return (current);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean isInvocable(String method, String pathTemplate)
{
///////////////////////////////////////////////////////////////////
// never the assistant's own operations (no recursion, no turns //
// started by a turn) //
///////////////////////////////////////////////////////////////////
if(pathTemplate == null || pathTemplate.equals(OWN_PREFIX) || pathTemplate.startsWith(OWN_PREFIX + "/"))
{
return (false);
}
try
{
return (invoker().findOperation(HttpMethod.valueOf(method.toUpperCase(Locale.ROOT)), pathTemplate).isPresent());
}
catch(IllegalArgumentException e)
{
return (false);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call) throws QException
{
if(call == null || call.getMethod() == null || call.getPathTemplate() == null)
{
throw (new QException("A UI API call needs a method and a path template"));
}
if(!isInvocable(call.getMethod(), call.getPathTemplate()))
{
throw (new QException("Not a spec'd UI API operation: " + call.operation()));
}
UiApiCredentials credentials = call.getCredentials() != null ? call.getCredentials()
: (call.getSessionUUID() == null ? null : UiApiCredentials.ofSessionUUID(call.getSessionUUID()));
if(credentials == null || !credentials.isPresent())
{
throw (new QException("A session is required to invoke " + call.operation()));
}
HeaderedUiApiInvoker.HeaderedRequest request = new HeaderedUiApiInvoker.HeaderedRequest();
request.withMethod(HttpMethod.valueOf(call.getMethod().toUpperCase(Locale.ROOT)))
.withPath(call.getPathTemplate())
.withCredentials(credentials)
.withUserTimezone(call.getUserTimezone())
.withInvokedBy(call.getInvokedBy())
.withTimeout(call.getTimeout())
.withClientDetail(call.getClientDetail());
call.getHeaders().forEach(request::withHeader);
call.getPathParams().forEach(request::withPathParam);
call.getQueryParams().forEach(request::withQueryParam);
call.getFormParams().forEach(request::withFormParam);
if(call.getJsonBody() instanceof String json)
{
////////////////////////////////////////////////////////////////
// a pre-serialized body (e.g. a QQueryFilter as QQQ writes //
// it): parse it, the invoker serializes the object //
////////////////////////////////////////////////////////////////
try
{
request.withJsonBody(JsonUtils.toObject(json, Object.class));
}
catch(Exception e)
{
throw (new QException("The body of " + call.operation() + " is not valid JSON", e));
}
}
else if(call.getJsonBody() != null)
{
request.withJsonBody(call.getJsonBody());
}
UiApiResponse response = invoker().invoke(request);
return (new UiApiResult(response.getStatus(), response.getBody()));
}
}
@@ -1,257 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.uiapi;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.StringJoiner;
import java.util.UUID;
import java.util.function.Supplier;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.AbstractEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.CompleteOperation;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
/*******************************************************************************
** Stand-in for core's `UiApiInvoker` (QRun-IO/qqq#1128; REPLACE with an
** adapter over it once a QQQ release has it). Same contract:
**
** - only spec'd `/qqq/v1` operations (from {@link MiddlewareVersionV1}'s
** endpoint specs) can be called; anything else is refused before a request
** is made, so raw routes (including the assistant's own) are unreachable;
** - a loopback HTTP request to this server, carrying the user's own session
** cookie: the identical Javalin pipeline (session setup, permission checks,
** record locks, validation, audit, error mapping) the UI goes through. There
** is no service session and no way to elevate;
** - X-QQQ-Invoked-By marks the request for the access log.
**
** Why loopback rather than calling spec executors directly: buildInput, the
** version's preExecute, filters and error mapping hang off the Javalin
** pipeline; calling executors would skip some of them and drift over time.
*******************************************************************************/
public class LoopbackUiApiGateway implements UiApiGateway
{
public static final String BASE_PATH = "/qqq/v1";
public static final String INVOKED_BY_HEADER = "X-QQQ-Invoked-By";
public static final String TIMEZONE_HEADER = "X-QQQ-UserTimezone";
/** Headers a call cannot set itself (lower case). */
private static final Set<String> RESERVED_HEADERS = Set.of("cookie", "authorization", "x-api-key", "content-type", "accept", INVOKED_BY_HEADER.toLowerCase(), TIMEZONE_HEADER.toLowerCase());
private static final Set<String> SPEC_OPERATIONS = specOperations();
private final Supplier<String> baseUrlSupplier;
private final boolean mockAuthentication;
private final HttpClient httpClient;
/*******************************************************************************
** @param baseUrlSupplier the server's own root URL, e.g. http://127.0.0.1:8000
** @param qInstance the instance (MOCK authentication reads the sessionId
** cookie rather than sessionUUID, so both are sent there)
*******************************************************************************/
public LoopbackUiApiGateway(Supplier<String> baseUrlSupplier, QInstance qInstance)
{
this.baseUrlSupplier = baseUrlSupplier;
this.mockAuthentication = qInstance != null && qInstance.getAuthentication() != null && QAuthenticationType.MOCK.equals(qInstance.getAuthentication().getType());
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).version(HttpClient.Version.HTTP_1_1).build();
}
/*******************************************************************************
** "METHOD /path/template" of every v1 endpoint spec.
*******************************************************************************/
static Set<String> specOperations()
{
Set<String> operations = new LinkedHashSet<>();
for(AbstractEndpointSpec<?, ?, ?> spec : new MiddlewareVersionV1().getEndpointSpecs())
{
CompleteOperation operation = spec.defineCompleteOperation();
operations.add(operation.getHttpMethod().name() + " " + operation.getPath());
}
return (Collections.unmodifiableSet(operations));
}
/*******************************************************************************
** The spec'd operations ("METHOD template").
*******************************************************************************/
public static Set<String> getSpecOperations()
{
return (SPEC_OPERATIONS);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean isInvocable(String method, String pathTemplate)
{
return (SPEC_OPERATIONS.contains(method + " " + pathTemplate));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call) throws QException
{
if(call == null || !StringUtils.hasContent(call.getMethod()) || !StringUtils.hasContent(call.getPathTemplate()))
{
throw (new QException("A UI API call needs a method and a path template"));
}
if(!isInvocable(call.getMethod(), call.getPathTemplate()))
{
throw (new QException("Not a spec'd UI API operation: " + call.operation()));
}
if(!StringUtils.hasContent(call.getSessionUUID()))
{
throw (new QException("A session is required to invoke " + call.operation()));
}
HttpRequest request = buildRequest(call);
try
{
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return (new UiApiResult(response.statusCode(), response.body()));
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw (new QException("Interrupted invoking " + call.operation(), e));
}
catch(IOException e)
{
throw (new QException("Error invoking " + call.operation() + ": " + e.getMessage(), e));
}
}
/*******************************************************************************
** The HTTP request: path parameters filled and encoded, query string, JSON
** body, the session cookie and the marker headers.
*******************************************************************************/
HttpRequest buildRequest(UiApiCall call) throws QException
{
String path = call.getPathTemplate();
for(Map.Entry<String, String> entry : call.getPathParams().entrySet())
{
if(entry.getValue() == null)
{
throw (new QException("Missing path parameter " + entry.getKey() + " for " + call.operation()));
}
path = path.replace("{" + entry.getKey() + "}", encode(entry.getValue()));
}
if(path.contains("{"))
{
throw (new QException("Missing path parameter(s) for " + call.operation() + ": " + path));
}
StringJoiner query = new StringJoiner("&", "?", "").setEmptyValue("");
call.getQueryParams().forEach((name, value) -> query.add(encode(name) + "=" + encode(value)));
String base = baseUrlSupplier.get();
if(!StringUtils.hasContent(base))
{
throw (new QException("The UI API base URL is not known yet"));
}
URI uri = URI.create(base.replaceAll("/+$", "") + BASE_PATH + path + query);
HttpRequest.Builder builder = HttpRequest.newBuilder(uri).timeout(call.getTimeout() == null ? Duration.ofSeconds(60) : call.getTimeout());
String cookie = "sessionUUID=" + call.getSessionUUID();
if(mockAuthentication)
{
cookie += "; sessionId=" + call.getSessionUUID();
}
builder.header("Cookie", cookie);
builder.header("Accept", "application/json");
builder.header(INVOKED_BY_HEADER, StringUtils.hasContent(call.getInvokedBy()) ? call.getInvokedBy() : "assistant");
if(StringUtils.hasContent(call.getUserTimezone()))
{
builder.header(TIMEZONE_HEADER, call.getUserTimezone());
}
call.getHeaders().forEach((name, value) ->
{
if(!RESERVED_HEADERS.contains(name.toLowerCase()))
{
builder.header(name, value);
}
});
if(!call.getFormParams().isEmpty())
{
////////////////////////////////////////////////////////////////
// multipart/form-data text fields (the /processes endpoints) //
////////////////////////////////////////////////////////////////
String boundary = "qqq-assistant-" + UUID.randomUUID();
StringBuilder body = new StringBuilder();
String lineEnd = "\r\n";
call.getFormParams().forEach((name, value) -> body.append("--").append(boundary).append(lineEnd)
.append("Content-Disposition: form-data; name=\"").append(name.replace("\"", "")).append("\"").append(lineEnd)
.append("Content-Type: text/plain; charset=UTF-8").append(lineEnd).append(lineEnd)
.append(value).append(lineEnd));
body.append("--").append(boundary).append("--").append(lineEnd);
builder.header("Content-Type", "multipart/form-data; boundary=" + boundary);
builder.method(call.getMethod(), HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8));
}
else if(call.getJsonBody() != null)
{
builder.header("Content-Type", "application/json");
builder.method(call.getMethod(), HttpRequest.BodyPublishers.ofString(call.getJsonBody() instanceof String s ? s : JsonUtils.toJson(call.getJsonBody()), StandardCharsets.UTF_8));
}
else
{
builder.method(call.getMethod(), HttpRequest.BodyPublishers.noBody());
}
return (builder.build());
}
/*******************************************************************************
**
*******************************************************************************/
private static String encode(String value)
{
return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"));
}
}
@@ -20,6 +20,7 @@ package com.kingsrook.qbits.assistant.uiapi;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
@@ -37,7 +38,9 @@ public class UiApiCall
private final Map<String, String> headers = new LinkedHashMap<>();
private Object jsonBody;
private String sessionUUID;
private UiApiCredentials credentials;
private String userTimezone;
private String clientDetail;
private String invokedBy = "assistant";
private Duration timeout = Duration.ofSeconds(60);
@@ -215,6 +218,29 @@ public class UiApiCall
/*******************************************************************************
** The caller's credentials exactly as their request carried them (session
** cookies, Authorization, API key); preferred over the session UUID alone.
** Never logged (their toString is redacted).
*******************************************************************************/
public UiApiCall withCredentials(UiApiCredentials credentials)
{
this.credentials = credentials;
return (this);
}
/*******************************************************************************
** Getter for credentials
*******************************************************************************/
public UiApiCredentials getCredentials()
{
return (credentials);
}
/*******************************************************************************
** Getter for sessionUUID
*******************************************************************************/
@@ -246,6 +272,29 @@ public class UiApiCall
/*******************************************************************************
** Fluent setter for clientDetail: the detail after the slash of the client
** channel the invoker labels the call with (assistant/turnId; a scheduled
** run uses scheduled/taskId). Ignored when blank.
*******************************************************************************/
public UiApiCall withClientDetail(String clientDetail)
{
this.clientDetail = clientDetail;
return (this);
}
/*******************************************************************************
** Getter for clientDetail
*******************************************************************************/
public String getClientDetail()
{
return (clientDetail);
}
/*******************************************************************************
** Fluent setter for invokedBy (sent as X-QQQ-Invoked-By)
*******************************************************************************/
@@ -25,9 +25,8 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
** user's own session, so every tool call passes the same authentication,
** permission, record-lock, validation and audit path as the UI.
**
** {@link LoopbackUiApiGateway} is a stand-in until core's `UiApiInvoker`
** (QRun-IO/qqq#1128) is released. Then an adapter over the invoker replaces
** it, and nothing else changes.
** {@link CoreUiApiGateway} implements it over core's `UiApiInvoker`
** (QRun-IO/qqq#1128); a host may inject another (tests).
*******************************************************************************/
public interface UiApiGateway
{
@@ -0,0 +1,126 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qqq.middleware.javalin.specs.invoke;
import java.net.http.HttpRequest;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.middleware.javalin.specs.AbstractMiddlewareVersion;
/*******************************************************************************
** Core's {@link UiApiInvoker} plus extra request headers (the assistant's
** X-QQQ-AI-Route). Core's {@link UiApiRequest} carries none, and the hook that
** builds the HTTP request is package-private, so this subclass lives in core's
** package to override it. Everything else (the operation allow-list, the
** credentials, the client channel, the depth limit) is core's own.
**
** STAND-IN(core=QRun-IO/qqq#1190, removeBy=RC.8): delete this class and use
** UiApiRequest.withHeader once the qbit builds on a QQQ RC that has it.
*******************************************************************************/
public class HeaderedUiApiInvoker extends UiApiInvoker
{
/** Headers a request cannot set itself (lower case): core sets these. */
private static final Set<String> RESERVED_HEADERS = Set.of("cookie", "authorization", "x-api-key", "content-type", "accept",
INVOKED_BY_HEADER.toLowerCase(Locale.ROOT), TIMEZONE_HEADER.toLowerCase(Locale.ROOT), CLIENT_CHANNEL_HEADER.toLowerCase(Locale.ROOT));
/*******************************************************************************
** @param baseUrlSupplier the server's own root URL
** @param middlewareVersion the version whose operations can be invoked
*******************************************************************************/
public HeaderedUiApiInvoker(Supplier<String> baseUrlSupplier, AbstractMiddlewareVersion middlewareVersion)
{
super(baseUrlSupplier, middlewareVersion);
}
/*******************************************************************************
** Core's request, then the extra headers of a {@link HeaderedRequest}
** (never the reserved ones).
*******************************************************************************/
@Override
HttpRequest buildHttpRequest(UiApiOperation operation, UiApiRequest request) throws QException
{
HttpRequest built = super.buildHttpRequest(operation, request);
HttpRequest.Builder builder = HttpRequest.newBuilder(built, (name, value) -> true);
////////////////////////////////////////////////////////////////////////////
// RC.6 strips slashes from client detail. Preserve nested attribution //
// while keeping the channel chosen by core, never by extra headers. //
////////////////////////////////////////////////////////////////////////////
if(request.getClientDetail() != null && request.getClientDetail().contains("/"))
{
String channel = built.headers().firstValue(CLIENT_CHANNEL_HEADER).orElseThrow().split("/", 2)[0];
String detail = request.getClientDetail().replaceAll("[^A-Za-z0-9._:/-]", "");
builder.setHeader(CLIENT_CHANNEL_HEADER, channel + "/" + detail);
}
if(!(request instanceof HeaderedRequest headered))
{
return (builder.build());
}
headered.getHeaders().forEach((name, value) ->
{
if(!RESERVED_HEADERS.contains(name.toLowerCase(Locale.ROOT)))
{
builder.header(name, value);
}
});
return (builder.build());
}
/*******************************************************************************
** A UI API request that also carries extra headers.
*******************************************************************************/
public static class HeaderedRequest extends UiApiRequest
{
private final Map<String, String> headers = new LinkedHashMap<>();
/*******************************************************************************
** Add a header (ignored when the name or value is null).
*******************************************************************************/
public HeaderedRequest withHeader(String name, String value)
{
if(name != null && value != null)
{
headers.put(name, value);
}
return (this);
}
/*******************************************************************************
** Getter for headers
*******************************************************************************/
public Map<String, String> getHeaders()
{
return (headers);
}
}
}
File diff suppressed because it is too large. Load diff
@@ -24,7 +24,8 @@ import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
import com.kingsrook.qbits.assistant.model.AssistantSkill;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.LoopbackUiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.CoreUiApiGateway;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
@@ -176,9 +177,10 @@ class ArchitectureTest
{
assertThat(ContextBuilder.KERNEL_TOOLS).containsExactlyInAnyOrder("navigate", "search_help");
assertThat(UiApiToolbox.OPERATIONS.keySet()).doesNotContainAnyElementsOf(ContextBuilder.KERNEL_TOOLS);
CoreUiApiGateway gateway = new CoreUiApiGateway(() -> "http://127.0.0.1:1", new QInstance());
for(String[] operation : UiApiToolbox.OPERATIONS.values())
{
assertThat(LoopbackUiApiGateway.getSpecOperations()).as("spec'd: " + operation[0] + " " + operation[1]).contains(operation[0] + " " + operation[1]);
assertThat(gateway.isInvocable(operation[0], operation[1])).as("spec'd: " + operation[0] + " " + operation[1]).isTrue();
}
}
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more