Compare commits

...
Author SHA1 Message Date
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 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
71 changed files with 4540 additions and 128 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}`.
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## 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
+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}`.
+58 -1
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.
@@ -551,6 +569,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,
+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
+86
View File
@@ -469,6 +469,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);
+6
View File
@@ -50,6 +50,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 +86,11 @@
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-anthropic</artifactId>
@@ -32,9 +32,14 @@
<url>https://github.com/QRun-IO/qbit-assistant</url>
<dependencies>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
+116
View File
@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2026 Kingsrook, LLC
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-parent</artifactId>
<version>${revision}</version>
</parent>
<artifactId>qbit-assistant-provider-api</artifactId>
<packaging>jar</packaging>
<name>QBit Assistant - Provider API</name>
<description>Provider SPI and pricing value types, independent of the assistant runtime</description>
<url>https://github.com/QRun-IO/qbit-assistant</url>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<redirectTestOutputToFile>true</redirectTestOutputToFile>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
<limit>
<counter>CLASS</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -20,7 +20,6 @@ package com.kingsrook.qbits.assistant.engine;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantModelRouting;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
@@ -63,9 +62,9 @@ public class ModelPricing implements Serializable
public static Map<String, ModelPricing> defaults()
{
Map<String, ModelPricing> map = new LinkedHashMap<>();
map.put(AssistantModelRouting.SONNET_5_5, of(2.0, 10.0, 0.1));
map.put(AssistantModelRouting.OPUS_5_5, of(4.0, 20.0, 0.05));
map.put(AssistantModelRouting.HAIKU_5_5, of(0.10, 0.50, 0.1));
map.put("claude-sonnet-5-5", of(2.0, 10.0, 0.1));
map.put("claude-opus-5-5", of(4.0, 20.0, 0.05));
map.put("claude-haiku-5-5", of(0.10, 0.50, 0.1));
map.put("claude-fable-5-1", of(10.0, 50.0, 0.025));
map.put("claude-fable-5", of(10.0, 50.0, 0.1));
map.put("claude-opus-5", of(5.0, 25.0, 0.1));
@@ -18,7 +18,6 @@ package com.kingsrook.qbits.assistant.provider;
import java.util.List;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
/*******************************************************************************
@@ -140,8 +139,10 @@ public interface AssistantProviderType
/*******************************************************************************
** Additional settings errors, without resolving credentials or doing I/O.
** Settings contain configured values and apiKeyEnvName; apiKey, the bearer
** token supplier and SSLContext are not resolved for validation.
*******************************************************************************/
default List<String> validate(AssistantProvider row)
default List<String> validate(ProviderSettings settings)
{
return (List.of());
}
@@ -36,6 +36,7 @@ public class ProviderSettings
private String tlsCaBundle;
@JsonIgnore
private transient SSLContext sslContext;
private String apiKeyEnvName;
private String kind;
private String apiKey;
private String baseUrl;
@@ -397,4 +398,35 @@ public class ProviderSettings
this.bearerTokenSupplier = bearerTokenSupplier;
return (this);
}
/*******************************************************************************
** The configured environment variable name, never its secret value.
*******************************************************************************/
public String getApiKeyEnvName()
{
return (this.apiKeyEnvName);
}
/*******************************************************************************
** Setter for apiKeyEnvName
*******************************************************************************/
public void setApiKeyEnvName(String apiKeyEnvName)
{
this.apiKeyEnvName = apiKeyEnvName;
}
/*******************************************************************************
** Fluent setter for apiKeyEnvName
*******************************************************************************/
public ProviderSettings withApiKeyEnvName(String apiKeyEnvName)
{
this.apiKeyEnvName = apiKeyEnvName;
return (this);
}
}
@@ -0,0 +1,75 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
import java.util.Set;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
/*******************************************************************************
** Keep external provider consumers independent of the assistant runtime and QQQ.
*******************************************************************************/
class ProviderApiArchitectureTest
{
/*******************************************************************************
** Allow only the extracted types, including their nested enums, under the
** assistant namespace. A future dependency on any runtime package must fail.
*******************************************************************************/
@Test
void dependsOnlyOnApiTypes()
{
Set<String> apiTypes = Set.of(
"com.kingsrook.qbits.assistant.provider.AssistantModelProvider",
"com.kingsrook.qbits.assistant.provider.AssistantProviderType",
"com.kingsrook.qbits.assistant.provider.CacheHint",
"com.kingsrook.qbits.assistant.provider.CompletableStreamHandle",
"com.kingsrook.qbits.assistant.provider.DiscoveredModel",
"com.kingsrook.qbits.assistant.provider.EmbeddingRequest",
"com.kingsrook.qbits.assistant.provider.EmbeddingResponse",
"com.kingsrook.qbits.assistant.provider.HealthResult",
"com.kingsrook.qbits.assistant.provider.ModelCapabilities",
"com.kingsrook.qbits.assistant.provider.ModelEventListener",
"com.kingsrook.qbits.assistant.provider.ModelMessage",
"com.kingsrook.qbits.assistant.provider.ModelPart",
"com.kingsrook.qbits.assistant.provider.ModelProviderException",
"com.kingsrook.qbits.assistant.provider.ModelRequest",
"com.kingsrook.qbits.assistant.provider.ModelResponse",
"com.kingsrook.qbits.assistant.provider.ModelToolDefinition",
"com.kingsrook.qbits.assistant.provider.ModelUsage",
"com.kingsrook.qbits.assistant.provider.ProviderSettings",
"com.kingsrook.qbits.assistant.provider.StreamHandle",
"com.kingsrook.qbits.assistant.provider.SystemBlock",
"com.kingsrook.qbits.assistant.provider.TokenCount",
"com.kingsrook.qbits.assistant.provider.TrustedCaBundle",
"com.kingsrook.qbits.assistant.engine.ModelPricing");
JavaClasses imported = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("com.kingsrook.qbits.assistant");
DescribedPredicate<JavaClass> outsideRuntime = DescribedPredicate.describe("outside assistant or an extracted API type", type ->
!type.getName().startsWith("com.kingsrook.qbits.assistant.") || apiTypes.contains(type.getName().split("\\$", 2)[0]));
classes().should().onlyDependOnClassesThat(outsideRuntime).check(imported);
noClasses().should().dependOnClassesThat().resideInAnyPackage("com.kingsrook.qqq..").check(imported);
}
}
@@ -0,0 +1,214 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import javax.net.ssl.SSLContext;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.assistant.engine.ModelPricing;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Provider contracts work on a classpath with no assistant runtime or QQQ.
*******************************************************************************/
class ProviderApiTest
{
private final ObjectMapper mapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
/*******************************************************************************
** Preserve provider-neutral requests and tool order through JSON; signed
** provider state remains in memory only.
*******************************************************************************/
@Test
void requestRoundTripAndCopy() throws Exception
{
ModelCapabilities capabilities = new ModelCapabilities().withTools(true).withParallelToolCalls(true)
.withPromptCaching(true).withThinking(false).withMaxContextTokens(32000)
.withVision(false).withStreaming(true).withReasoningEffort(true);
ModelToolDefinition tool = new ModelToolDefinition().withName("lookup").withDescription("Look up a record")
.withInputSchema(Map.of("type", "object")).withStrict(true);
ModelMessage user = ModelMessage.user("Find it").withCacheHint(CacheHint.SHORT);
ModelMessage assistant = ModelMessage.assistant("Looking").withPart(ModelPart.toolUse("call-1", "lookup", null))
.withProviderState("private-signature");
ModelMessage result = new ModelMessage().withRole(ModelMessage.Role.USER)
.withParts(List.of(ModelPart.toolResult("call-1", "denied", true)));
ModelRequest request = new ModelRequest().withModel("example").withEffort("low").withMaxTokens(100)
.withParallelToolCalls(true).withStreaming(false).withCapabilities(capabilities)
.withSystem(List.of(new SystemBlock("rules", CacheHint.LONG), new SystemBlock().withText("user context").withCacheHint(CacheHint.SHORT)))
.withTools(List.of(tool)).withMessages(new ArrayList<>(List.of(user, assistant, result)));
String json = mapper.writeValueAsString(request);
assertThat(json).doesNotContain("private-signature", "providerState");
ModelRequest restored = mapper.readValue(json, ModelRequest.class);
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(request));
assertThat(restored.getMessages().get(1).getProviderState()).isNull();
assertThat(restored.getMessages().get(2).getParts().get(0).getIsError()).isTrue();
ModelRequest copy = request.copy();
assertThat(mapper.<JsonNode>valueToTree(copy)).isEqualTo(mapper.valueToTree(request));
copy.getMessages().clear();
assertThat(request.getMessages()).hasSize(3);
}
/*******************************************************************************
** Response helpers retain tool ordering, text, usage and safe metadata.
*******************************************************************************/
@Test
void responseAndPricingRoundTrip() throws Exception
{
ModelUsage usage = new ModelUsage().withInputTokens(2L).withOutputTokens(3L)
.withCacheReadTokens(4L).withCacheWriteTokens(5L).withCacheWrite1hTokens(1L);
ModelPart first = ModelPart.toolUse("first", "lookup", Map.of("id", 1));
ModelPart second = ModelPart.toolUse("second", "lookup", Map.of("id", 2));
ModelResponse response = new ModelResponse().withParts(List.of(ModelPart.text("a"), first, ModelPart.text("b"), second))
.withModel("example").withStopReason(ModelResponse.STOP_TOOL_USE).withRequestId("request-1")
.withUsage(usage).withProviderState("signature");
assertThat(response.getText()).isEqualTo("ab");
assertThat(response.getToolUses()).containsExactly(first, second);
String json = mapper.writeValueAsString(response);
assertThat(json).doesNotContain("signature", "providerState");
ModelResponse restored = mapper.readValue(json, ModelResponse.class);
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(response));
assertThat(restored.getProviderState()).isNull();
assertThat(response.getProviderState()).isEqualTo("signature");
ModelPricing pricing = ModelPricing.of(0.1, 0.5, 0.1);
ModelPricing restoredPricing = mapper.readValue(mapper.writeValueAsString(pricing), ModelPricing.class);
assertThat(restoredPricing.costMicros(usage)).isEqualTo(3);
assertThat(new ModelUsage().withInputTokens(null).add(usage).add(null).billableTokens()).isEqualTo(10);
TokenCount tokens = mapper.readValue(mapper.writeValueAsString(new TokenCount().withInputTokens(12L)), TokenCount.class);
assertThat(tokens.getInputTokens()).isEqualTo(12L);
}
/*******************************************************************************
** Plain validation settings require no credentials or entity classes, and
** live token suppliers/TLS contexts cannot be serialized into diagnostics.
*******************************************************************************/
@Test
void settingsRoundTrip() throws Exception
{
ProviderSettings settings = new ProviderSettings().withKind("EXAMPLE").withApiKeyEnvName("EXAMPLE_KEY")
.withApiKey("test-only-key").withBaseUrl("https://example.invalid").withApiVersion("v1")
.withRegion("region").withCloudProject("project").withTlsCaBundle("corporate")
.withConnectTimeoutSeconds(5).withRequestTimeoutSeconds(30)
.withBearerTokenSupplier(() -> "ephemeral-token").withSslContext(SSLContext.getDefault());
String json = mapper.writeValueAsString(settings);
assertThat(json).doesNotContain("ephemeral-token", "bearerTokenSupplier", "sslContext");
ProviderSettings restored = mapper.readValue(json, ProviderSettings.class);
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(settings));
assertThat(settings.getBearerTokenSupplier().get()).isEqualTo("ephemeral-token");
assertThat(settings.getSslContext()).isNotNull();
assertThat(restored.getBearerTokenSupplier()).isNull();
assertThat(restored.getSslContext()).isNull();
}
/*******************************************************************************
** Embedding batches snapshot input and enforce the transport size contract.
*******************************************************************************/
@Test
void embeddingContracts()
{
List<String> input = new ArrayList<>(List.of("first", "second"));
EmbeddingRequest request = new EmbeddingRequest("embed", input);
input.clear();
assertThat(request.input()).containsExactly("first", "second");
assertThatThrownBy(() -> request.input().add("third")).isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> new EmbeddingRequest(" ", List.of("text"))).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new EmbeddingRequest(null, List.of("text"))).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new EmbeddingRequest("embed", List.of())).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new EmbeddingRequest("embed", Collections.nCopies(65, "text"))).isInstanceOf(IllegalArgumentException.class);
assertThat(new EmbeddingRequest("embed", Collections.nCopies(64, "text")).input()).hasSize(64);
float[] vector = new float[] { 0.25F, 0.75F };
EmbeddingResponse response = new EmbeddingResponse(List.of(vector), new ModelUsage().withInputTokens(2L), "request");
assertThat(response.vectors().getFirst()).containsExactly(0.25F, 0.75F);
assertThat(response.usage().getInputTokens()).isEqualTo(2L);
assertThat(response.requestId()).isEqualTo("request");
}
/*******************************************************************************
** Cancellation is idempotent, preserves accrued usage, and wins over late
** completion. Timeout also cancels the underlying transport.
*******************************************************************************/
@Test
void cancellationAndTimeout() throws Exception
{
AtomicInteger cancellations = new AtomicInteger();
ModelUsage usage = new ModelUsage().withInputTokens(10L);
CompletableStreamHandle handle = new CompletableStreamHandle();
handle.setPartialUsage(() -> usage);
handle.setOnCancel(cancellations::incrementAndGet);
handle.cancel();
handle.cancel();
handle.complete(new ModelResponse().withStopReason(ModelResponse.STOP_END_TURN));
handle.fail(new ModelProviderException(ModelProviderException.Kind.SERVER, 500, "late failure"));
assertThat(handle.isCancelled()).isTrue();
assertThat(cancellations.get()).isEqualTo(1);
assertThat(handle.await(Duration.ofSeconds(1)).getUsage()).isSameAs(usage);
CompletableStreamHandle timeout = new CompletableStreamHandle();
timeout.setPartialUsage(() -> usage);
assertThatThrownBy(() -> timeout.await(Duration.ofMillis(1))).isInstanceOfSatisfying(ModelProviderException.class, error ->
{
assertThat(error.getKind()).isEqualTo(ModelProviderException.Kind.TIMEOUT);
assertThat(error.getPartialUsage()).isSameAs(usage);
});
assertThat(timeout.isCancelled()).isTrue();
handle.setPartialUsage(() ->
{
throw (new IllegalStateException("closed"));
});
assertThat(handle.partialUsage()).isNull();
}
/*******************************************************************************
** Error metadata is bounded and redacted independently of error classification.
*******************************************************************************/
@Test
void errorDetails()
{
ModelProviderException error = new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "Invalid schema")
.withStrictSchemaRejected(true).withProviderDetails("invalid_request", "bad key sk-abcdefghijk\ntry again", "req-123");
assertThat(error.isStrictSchemaRejected()).isTrue();
assertThat(error.getProviderErrorType()).isEqualTo("invalid_request");
assertThat(error.getProviderMessage()).isEqualTo("bad key [redacted] try again");
assertThat(error.getRequestId()).isEqualTo("req-123");
assertThat(error.isRetryable()).isFalse();
assertThat(ModelProviderException.sanitizeProviderMessage(" ", 10)).isNull();
assertThat(ModelProviderException.sanitizeProviderMessage(null, 10)).isNull();
assertThat(ModelProviderException.sanitizeProviderMessage("word ".repeat(100), 20)).hasSize(20);
assertThat(ModelProviderException.sanitizeProviderMessage("a".repeat(50), 100)).isEqualTo("[redacted]");
}
}
@@ -0,0 +1,80 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
import java.time.Duration;
import com.kingsrook.qbits.assistant.engine.ModelPricing;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Standalone contracts moved from the runtime's component tests.
*******************************************************************************/
class ProviderComponentsTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void testPricing()
{
ModelPricing sonnet = ModelPricing.defaults().get("claude-sonnet-5-5");
ModelUsage usage = new ModelUsage().withInputTokens(6000L).withOutputTokens(800L).withCacheReadTokens(12_000L).withCacheWriteTokens(1000L).withCacheWrite1hTokens(400L);
//////////////////////////////////////////////////////////////////////////////////
// 6000*2 + 800*10 + 12000*0.2 + 600*2.5 + 400*4 = 12000+8000+2400+1500+1600 //
//////////////////////////////////////////////////////////////////////////////////
assertThat(sonnet.costMicros(usage)).isEqualTo(25_500);
assertThat(sonnet.costMicros(null)).isZero();
assertThat(new ModelPricing().costMicros(usage)).isZero();
assertThat(ModelPricing.defaults().get("claude-opus-5-5").getCacheReadUsdPerMTok()).isEqualTo(0.2);
assertThat(usage.billableTokens()).isEqualTo(7800);
assertThat(new ModelUsage().add(usage).add(null).getCacheReadTokens()).isEqualTo(12_000);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testStreamHandle() throws Exception
{
CompletableStreamHandle handle = new CompletableStreamHandle();
handle.complete(new ModelResponse().withStopReason("end_turn"));
assertThat(handle.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo("end_turn");
CompletableStreamHandle failing = new CompletableStreamHandle();
failing.fail(new ModelProviderException(ModelProviderException.Kind.RATE_LIMITED, 429, "slow down"));
assertThatThrownBy(() -> failing.await(Duration.ofSeconds(1))).isInstanceOfSatisfying(ModelProviderException.class, e ->
{
assertThat(e.isRetryable()).isTrue();
assertThat(e.getHttpStatus()).isEqualTo(429);
});
CompletableStreamHandle cancelled = new CompletableStreamHandle();
cancelled.setOnCancel(() ->
{
throw (new IllegalStateException("closing"));
});
cancelled.cancel();
cancelled.cancel();
assertThat(cancelled.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo(ModelResponse.STOP_CANCELLED);
assertThat(new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "x").isRetryable()).isFalse();
}
}
@@ -0,0 +1,164 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** A minimal external provider compiles and uses SPI defaults without the runtime.
*******************************************************************************/
class ProviderDefaultsTest
{
/*******************************************************************************
** Legacy two-argument factories and static model lists remain supported.
*******************************************************************************/
@Test
void minimalProviderDefaults() throws Exception
{
ExampleProvider provider = new ExampleProvider();
ProviderSettings settings = new ProviderSettings().withApiKey("test-key").withBaseUrl("https://example.invalid");
assertThat(provider.create(settings)).isSameAs(provider);
assertThat(provider.settings.getApiKey()).isEqualTo("test-key");
assertThat(provider.settings.getBaseUrl()).isEqualTo("https://example.invalid");
assertThat(provider.getKinds()).containsExactly("EXAMPLE_PROVIDER");
assertThat(provider.getDefaultBaseUrl("EXAMPLE_PROVIDER")).isNull();
assertThat(provider.getDefaultApiKeyEnvName("EXAMPLE_PROVIDER")).isEqualTo("EXAMPLE_KEY");
assertThat(provider.getDefaultLocation("EXAMPLE_PROVIDER")).isEqualTo("CLOUD");
assertThat(provider.requiresBaseUrl("EXAMPLE_PROVIDER")).isTrue();
assertThat(provider.validate(settings)).isEmpty();
assertThat(provider.discoverModels(settings)).containsExactly(new DiscoveredModel("example", "example", null, null));
assertThat(provider.checkHealth(settings).healthy()).isNull();
assertThat(provider.checkHealth(settings).message()).isEqualTo("Health check not supported");
assertThat(new HealthResult(false, 20L, "bad sk-abcdefghijk").message()).isEqualTo("bad [redacted]");
assertThat(provider.countTokens(new ModelRequest())).isNull();
assertThatThrownBy(() -> provider.embed(new EmbeddingRequest("example", List.of("text"))))
.isInstanceOfSatisfying(ModelProviderException.class, error -> assertThat(error.getKind()).isEqualTo(ModelProviderException.Kind.INVALID_REQUEST));
StringBuilder deltas = new StringBuilder();
ModelEventListener listener = deltas::append;
listener.onToolUseStart("call", "lookup");
assertThat(deltas).isEmpty();
listener.onTextDelta("visible answer");
assertThat(deltas).hasToString("visible answer");
}
/*******************************************************************************
** Only the original required SPI methods are implemented.
*******************************************************************************/
private static class ExampleProvider implements AssistantProviderType, AssistantModelProvider
{
private ProviderSettings settings;
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public String getType()
{
return ("example-provider");
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public String getLabel()
{
return ("Example");
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public List<String> getModels()
{
return (List.of("example"));
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public String getDefaultApiKeyEnvName()
{
return ("EXAMPLE_KEY");
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public String getDefaultBaseUrl()
{
return (null);
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public AssistantModelProvider create(String apiKey, String baseUrl)
{
settings = new ProviderSettings().withApiKey(apiKey).withBaseUrl(baseUrl);
return (this);
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public String getName()
{
return ("example");
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public ModelCapabilities getCapabilities(String modelId)
{
return (new ModelCapabilities());
}
/*******************************************************************************
** Minimal provider contract.
*******************************************************************************/
@Override
public StreamHandle stream(ModelRequest request, ModelEventListener listener)
{
throw (new UnsupportedOperationException());
}
}
}
+5
View File
@@ -41,9 +41,14 @@
<artifactId>qbit-assistant-provider-anthropic</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
@@ -24,7 +24,6 @@ import com.anthropic.backends.Backend;
import com.anthropic.bedrock.backends.BedrockBackend;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.core.Timeout;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
@@ -117,14 +116,14 @@ public class BedrockProviderType implements AssistantProviderType
** Validate cloud settings without loading credentials.
*******************************************************************************/
@Override
public List<String> validate(AssistantProvider row)
public List<String> validate(ProviderSettings settings)
{
List<String> errors = new ArrayList<>();
if(row.getRegion() == null || row.getRegion().isBlank())
if(settings.getRegion() == null || settings.getRegion().isBlank())
{
errors.add("Region is required for BEDROCK.");
}
if(row.getApiKeyEnvName() != null && !row.getApiKeyEnvName().isBlank())
if(settings.getApiKeyEnvName() != null && !settings.getApiKeyEnvName().isBlank())
{
errors.add("Leave API key variable empty: AWS Bedrock (Claude) uses the AWS default credential chain (environment, profile or server role).");
}
+5
View File
@@ -32,9 +32,14 @@
<url>https://github.com/QRun-IO/qbit-assistant</url>
<dependencies>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
+5
View File
@@ -45,9 +45,14 @@
<artifactId>qbit-assistant-provider-openai</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-api</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
@@ -19,7 +19,6 @@ package com.kingsrook.qbits.assistant.provider.vertex;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
@@ -104,18 +103,18 @@ public class VertexProviderType implements AssistantProviderType
** Validate cloud settings without loading credentials.
*******************************************************************************/
@Override
public List<String> validate(AssistantProvider row)
public List<String> validate(ProviderSettings settings)
{
List<String> errors = new ArrayList<>();
if(row.getRegion() == null || row.getRegion().isBlank())
if(settings.getRegion() == null || settings.getRegion().isBlank())
{
errors.add("Region is required for VERTEX.");
}
if(row.getApiKeyEnvName() != null && !row.getApiKeyEnvName().isBlank())
if(settings.getApiKeyEnvName() != null && !settings.getApiKeyEnvName().isBlank())
{
errors.add("Leave API key variable empty: Google Vertex AI (Claude, Gemini) uses Google Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS or workload identity).");
}
if(row.getCloudProject() == null || row.getCloudProject().isBlank())
if(settings.getCloudProject() == null || settings.getCloudProject().isBlank())
{
errors.add("GCP project id (cloudProject) is required for VERTEX.");
}
+5 -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>
@@ -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,7 +29,6 @@ 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;
@@ -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();
@@ -214,6 +217,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())
@@ -1855,4 +1865,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.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 LoopbackUiApiGateway(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 LoopbackUiApiGateway loopback)
{
return (loopback.invokeWithClient(call, channel));
}
throw (new QException("Scheduled tasks require the loopback gateway for client attribution."));
}
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);
}
/*******************************************************************************
@@ -17,23 +17,23 @@
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 +43,7 @@ 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.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";
@@ -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))
@@ -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)
@@ -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,6 +55,7 @@ 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;
@@ -127,6 +132,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;
@@ -185,6 +193,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 +245,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 +257,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 +274,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 +559,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 +573,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 +778,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 +870,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 +917,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));
}
@@ -1073,6 +1091,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.DataClassification;
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 = DataClassification.max(
DataClassification.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)
{
}
}
@@ -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);
}
}
}
@@ -35,6 +35,7 @@ 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.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);
@@ -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,218 @@
/*
* 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;
/*******************************************************************************
** 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);
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));
}
}
}
@@ -165,6 +165,35 @@ public class LoopbackUiApiGateway implements UiApiGateway
/*******************************************************************************
** Add server-owned client attribution for a scheduled turn. All operation,
** session, encoding and transport rules remain those of this gateway.
*******************************************************************************/
public UiApiResult invokeWithClient(UiApiCall call, String clientChannel) throws QException
{
if(call == null || !isInvocable(call.getMethod(), call.getPathTemplate()) || !StringUtils.hasContent(call.getSessionUUID()))
{
throw (new QException("A scheduled UI API call requires a spec'd operation and a session."));
}
try
{
HttpRequest request = HttpRequest.newBuilder(buildRequest(call), (name, value) -> true).setHeader("X-QQQ-Client", clientChannel).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return (new UiApiResult(response.statusCode(), response.body()));
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw (new QException("Scheduled UI API request interrupted."));
}
catch(IOException e)
{
throw (new QException("Scheduled UI API request failed."));
}
}
/*******************************************************************************
** The HTTP request: path parameters filled and encoded, query string, JSON
** body, the session cookie and the marker headers.
@@ -17,7 +17,10 @@
package com.kingsrook.qbits.assistant;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.assistant.metadata.AssistantAdminPermissionChecker;
import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
@@ -26,8 +29,8 @@ import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionCheckResult;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -44,9 +47,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import java.util.ArrayList;
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;
@@ -88,7 +90,7 @@ class AssistantQBitProducerTest extends BaseAssistantTest
// the nav: the home plus the main tables; processes are actions //
//////////////////////////////////////////////////////////////////////
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getChildren()).extracting(c -> c.getName())
.containsExactly("assistant_rag_source", "assistant_budget", "assistant_provider", "assistant_profile", "assistant_skill", "assistant_data_access", "assistant_conversation", "assistant_model_price", "assistant_policy");
.containsExactly("assistant_scheduled_task", "assistant_rag_source", "assistant_budget", "assistant_provider", "assistant_profile", "assistant_skill", "assistant_data_access", "assistant_conversation", "assistant_model_price", "assistant_policy");
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getWidgets()).containsExactly("assistantHome", "assistantAccess");
assertThat(qInstance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME).getTableName()).isEqualTo("assistant_conversation");
assertThat(qInstance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME)).isNotNull();
@@ -96,9 +98,9 @@ class AssistantQBitProducerTest extends BaseAssistantTest
assertThat(qInstance.getPossibleValueSource(AssistantConversation.TABLE_NAME)).isNotNull();
assertThat(qInstance.getQBits().values()).anyMatch(q -> "assistant".equals(q.getArtifactId()));
assertThat(com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.of(qInstance)).isNotNull();
assertThat(com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.of(qInstance).getVersion()).isEqualTo(1);
assertThat(com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.of(qInstance).getBasePath()).isEqualTo("/qqq/v1/assistant");
assertThat(AssistantInstanceMetaData.of(qInstance)).isNotNull();
assertThat(AssistantInstanceMetaData.of(qInstance).getVersion()).isEqualTo(1);
assertThat(AssistantInstanceMetaData.of(qInstance).getBasePath()).isEqualTo("/qqq/v1/assistant");
assertThat(config.getRuntime()).isNotNull();
assertThat(AssistantRuntime.of(qInstance)).isSameAs(config.getRuntime());
assertThat(AssistantQBitProducer.getVersion()).isNotBlank();
@@ -192,7 +194,7 @@ class AssistantQBitProducerTest extends BaseAssistantTest
assertThat(editor.getSecurityKeyValues(AssistantQBitConfig.DEFAULT_OWNER_KEY_TYPE)).containsExactly(AssistantTestFixture.EDITOR);
assertThat(editor.getSecurityKeyValues("assistant_user_id_all_access")).isNullOrEmpty();
QSession admin = new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("boss"));
QSession admin = new QSession().withUser(new QUser().withIdReference("boss"));
admin.withPermission(AssistantQBitConfig.ADMIN_PERMISSION);
AssistantSecurity.applySessionKeys(admin, config);
assertThat(admin.getSecurityKeyValues("assistant_user_id_all_access")).containsExactly(true);
@@ -277,7 +279,7 @@ class AssistantQBitProducerTest extends BaseAssistantTest
AssistantQBitConfig hostConfig = new AssistantQBitConfig().withBackendName("memory").withOwnerSecurityKeyType("userKey").withRetentionSchedulerName("quartz").withRegisterRoutes(false);
hostConfig.setEnabled(false);
new AssistantQBitProducer().withQBitConfig(hostConfig).produce(instance);
assertThat(com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.of(instance)).as("not advertised when disabled").isNull();
assertThat(AssistantInstanceMetaData.of(instance)).as("not advertised when disabled").isNull();
assertThat(instance.getSecurityKeyType("userKey").getAllAccessKeyName()).isEqualTo("userKeyAll");
assertThat(instance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME).getSchedule().getSchedulerName()).isEqualTo("quartz");
hostConfig.getRuntime().close();
@@ -18,7 +18,6 @@ package com.kingsrook.qbits.assistant.engine;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
@@ -27,16 +26,12 @@ import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qbits.assistant.AssistantModelRouting;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.provider.CompletableStreamHandle;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.context.QContext;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
@@ -203,27 +198,6 @@ class EngineComponentsTest extends BaseAssistantTest
/*******************************************************************************
**
*******************************************************************************/
@Test
void testPricing()
{
ModelPricing sonnet = ModelPricing.defaults().get(AssistantModelRouting.SONNET_5_5);
ModelUsage usage = new ModelUsage().withInputTokens(6000L).withOutputTokens(800L).withCacheReadTokens(12_000L).withCacheWriteTokens(1000L).withCacheWrite1hTokens(400L);
//////////////////////////////////////////////////////////////////////////////////
// 6000*2 + 800*10 + 12000*0.2 + 600*2.5 + 400*4 = 12000+8000+2400+1500+1600 //
//////////////////////////////////////////////////////////////////////////////////
assertThat(sonnet.costMicros(usage)).isEqualTo(25_500);
assertThat(sonnet.costMicros(null)).isZero();
assertThat(new ModelPricing().costMicros(usage)).isZero();
assertThat(ModelPricing.defaults().get(AssistantModelRouting.OPUS_5_5).getCacheReadUsdPerMTok()).isEqualTo(0.2);
assertThat(usage.billableTokens()).isEqualTo(7800);
assertThat(new ModelUsage().add(usage).add(null).getCacheReadTokens()).isEqualTo(12_000);
}
/*******************************************************************************
**
*******************************************************************************/
@@ -268,37 +242,6 @@ class EngineComponentsTest extends BaseAssistantTest
/*******************************************************************************
**
*******************************************************************************/
@Test
void testStreamHandle() throws Exception
{
CompletableStreamHandle handle = new CompletableStreamHandle();
handle.complete(new ModelResponse().withStopReason("end_turn"));
assertThat(handle.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo("end_turn");
CompletableStreamHandle failing = new CompletableStreamHandle();
failing.fail(new ModelProviderException(ModelProviderException.Kind.RATE_LIMITED, 429, "slow down"));
assertThatThrownBy(() -> failing.await(Duration.ofSeconds(1))).isInstanceOfSatisfying(ModelProviderException.class, e ->
{
assertThat(e.isRetryable()).isTrue();
assertThat(e.getHttpStatus()).isEqualTo(429);
});
CompletableStreamHandle cancelled = new CompletableStreamHandle();
cancelled.setOnCancel(() ->
{
throw (new IllegalStateException("closing"));
});
cancelled.cancel();
cancelled.cancel();
assertThat(cancelled.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo(ModelResponse.STOP_CANCELLED);
assertThat(new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "x").isRetryable()).isFalse();
}
/*******************************************************************************
**
*******************************************************************************/
@@ -0,0 +1,557 @@
/*
* 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.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.Executors;
import java.util.stream.Stream;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantProfile;
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.processes.ScheduledTaskProcess;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.MockAuthenticationModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Scheduled runs use real manageSession and UI API requests on the fixture.
*******************************************************************************/
class ScheduledTasksTest extends BaseAssistantTest
{
private static final Instant NOW = Instant.parse("2026-03-08T06:55:00Z");
private final Map<String, String> secrets = new HashMap<>();
private final List<AssistantScheduledRun> notifications = new ArrayList<>();
private AssistantRuntime runtime;
/*******************************************************************************
** Dedicated environment references; the test resolver contains fake tokens.
*******************************************************************************/
@Override
protected void customizeConfig(AssistantQBitConfig config)
{
config.withAllowedScheduledCredentialEnvNames(Set.of("SCHEDULED_TOKEN")).withSecretResolver(secrets::get)
.withDataClassification("patient", DataClassification.PHI);
}
/*******************************************************************************
** Fixed time for due selection, cron, budgets and persisted timestamps.
*******************************************************************************/
@BeforeEach
void setupSchedule()
{
config.getRuntime().close();
runtime = new AssistantRuntime(qInstance, config, Clock.fixed(NOW, ZoneOffset.UTC));
config.setRuntime(runtime);
qInstance.getAuthentication().setModuleCodeReference(new QCodeReference(TokenMock.class));
secrets.put("SCHEDULED_TOKEN", AssistantTestFixture.EDITOR);
admin();
}
/*******************************************************************************
** RC.5 MOCK maps sessionId but ignores accessToken. This test-only module
** maps login tokens to that same fixture identity, preserving all grants.
*******************************************************************************/
public static class TokenMock extends MockAuthenticationModule
{
/*******************************************************************************
** Login and subsequent UUID resolution use identical fixture sessions.
*******************************************************************************/
@Override
public QSession createSession(QInstance instance, Map<String, String> context) throws QAuthenticationException
{
Map<String, String> mapped = new HashMap<>(context);
if(context.get("accessToken") != null)
{
mapped.put("sessionId", context.get("accessToken"));
}
QSession session = super.createSession(instance, mapped);
AssistantSecurity.applySessionKeys(session, AssistantRuntime.of(instance).getConfig());
return (session);
}
}
/*******************************************************************************
** Bookkeeping starts in the scheduler/admin context supplied by the host.
*******************************************************************************/
private void admin()
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
}
/*******************************************************************************
** A due daily task, crossing Chicago's spring daylight-saving transition.
*******************************************************************************/
private AssistantScheduledTask task() throws Exception
{
AssistantScheduledTask task = runtime.getStore().insert(new AssistantScheduledTask().withName("Morning report").withEnabled(true)
.withPrompt("Summarize today's posts").withRunAsUserId(AssistantTestFixture.EDITOR).withCredentialEnvName("SCHEDULED_TOKEN")
.withCronExpression("0 0 8 * * ?").withTimezone("America/Chicago").withOutputMode("RECORD").withWriteModeRequested(false));
runtime.getStore().scheduledUpdate(new QRecord().withValue("id", task.getId()).withValue("nextRunAt", NOW.minusSeconds(60)));
return (runtime.getStore().scheduledTask(task.getId()));
}
/*******************************************************************************
** Capture notification delivery without changing the runner.
*******************************************************************************/
private ScheduledTaskRunner scheduledRunner()
{
return (new ScheduledTaskRunner(runtime, new ScheduledLogin(), new ScheduledAlerts()
{
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public void raise(AssistantScheduledRun run)
{
notifications.add(run);
}
}));
}
/*******************************************************************************
** One due slot, one owned conversation; next wall-clock occurrence uses DST.
*******************************************************************************/
@Test
void dueRunsOnceAndAdvancesAcrossDst() throws Exception
{
AssistantScheduledTask task = task();
provider.thenText("Today's report");
assertThat(scheduledRunner().runDue()).isEqualTo(1);
assertThat(scheduledRunner().runDue()).isZero();
var runs = runtime.getStore().query(AssistantScheduledRun.TABLE_NAME, new QQueryFilter(), AssistantScheduledRun.class);
assertThat(runs).hasSize(1);
AssistantScheduledRun run = runs.get(0);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("OK");
assertThat(run.getSummary()).isEqualTo("Today's report");
assertThat(run.getStartedAt()).isEqualTo(NOW);
var conversation = runtime.getStore().getConversation(AssistantTestFixture.EDITOR, run.getConversationId());
assertThat(conversation.getUserId()).isEqualTo(AssistantTestFixture.EDITOR);
assertThat(conversation.getChannel()).isEqualTo("assistant/scheduled");
assertThat(conversation.getScheduledTaskId()).isEqualTo(task.getId());
assertThat(runtime.getStore().scheduledTask(task.getId()).getNextRunAt()).isEqualTo(Instant.parse("2026-03-08T13:00:00Z"));
assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo(AssistantTestFixture.ADMIN);
assertThat(notifications).isEmpty();
}
/*******************************************************************************
** Every tool HTTP call carries both the designated UUID and client channel.
*******************************************************************************/
@Test
void designatedUserDenialAndClientHeaders() throws Exception
{
AssistantScheduledTask task = task();
provider.thenToolUse("query_records", input("table", "analytics_daily_stat", "filter", filter("AND"))).thenText("Access denied");
AssistantScheduledRun run = scheduledRunner().run(task, false);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("OK");
var calls = runtime.getStore().query(AssistantToolCall.TABLE_NAME, new QQueryFilter().withCriteria("turnId", QCriteriaOperator.EQUALS, run.getTurnId()), AssistantToolCall.class);
assertThat(calls).hasSize(1);
assertThat(calls.get(0).getHttpStatus()).isEqualTo(403);
assertThat(calls.get(0).getClientChannel()).isEqualTo("assistant/scheduled/" + task.getId());
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).isNotEmpty().allSatisfy(request ->
{
assertThat(request.get("client")).isEqualTo("assistant/scheduled/" + task.getId());
assertThat(request.get("session")).isEqualTo(AssistantTestFixture.EDITOR);
});
QContext.init(qInstance, AssistantTestFixture.ownerSession("editor-other", config));
assertThat(runtime.getStore().query(AssistantScheduledRun.TABLE_NAME, new QQueryFilter(), AssistantScheduledRun.class)).isEmpty();
assertThat(runtime.getStore().getConversation("editor-other", run.getConversationId())).isNull();
}
/*******************************************************************************
** Credential failures occur before a model call and never expose a token.
*******************************************************************************/
@Test
void credentialFailures() throws Exception
{
AssistantScheduledTask task = task();
secrets.clear();
assertThat(scheduledRunner().run(task, false).getError()).contains("not set");
secrets.put("SCHEDULED_TOKEN", "analyst-other");
task = task();
assertThat(scheduledRunner().run(task, false).getError()).contains("another user");
secrets.put("SCHEDULED_TOKEN", "Deny");
task = task();
assertThat(scheduledRunner().run(task, false).getError()).contains("HTTP");
task = task();
config.setAllowedScheduledCredentialEnvNames(Set.of());
AssistantScheduledRun run = scheduledRunner().run(task, false);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("FAILED");
assertThat(run.getError()).contains("not allowed");
assertThat(provider.getRequests()).isEmpty();
}
/*******************************************************************************
** Independent runner instances compete for the same durable schedule slot.
*******************************************************************************/
@Test
void racingRunnersClaimOnlyOnce() throws Exception
{
AssistantScheduledTask task = task();
provider.thenText("Once");
CyclicBarrier barrier = new CyclicBarrier(2);
try(var executor = Executors.newVirtualThreadPerTaskExecutor())
{
var work = (Callable<AssistantScheduledRun>) () ->
{
admin();
barrier.await();
return (scheduledRunner().run(task, false));
};
var one = executor.submit(work);
var two = executor.submit(work);
assertThat(Stream.of(one.get(), two.get()).filter(Objects::nonNull)).hasSize(1);
}
assertThat(provider.getRequests()).hasSize(1);
}
/*******************************************************************************
** Per-run caps stop before further dispatch, including a zero-cost allowance.
*******************************************************************************/
@Test
void costCapStopsRun() throws Exception
{
AssistantScheduledTask task = task();
runtime.getStore().updateFields(AssistantScheduledTask.TABLE_NAME, new QRecord().withValue("id", task.getId()).withValue("maxCostUsdPerRun", BigDecimal.ZERO));
AssistantScheduledRun run = scheduledRunner().run(task, false);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("BLOCKED");
assertThat(run.getError()).contains("BUDGET");
assertThat(provider.getRequests()).isEmpty();
task = task();
runtime.getStore().updateFields(AssistantScheduledTask.TABLE_NAME, new QRecord().withValue("id", task.getId()).withValue("maxCostUsdPerRun", new BigDecimal("0.000001")));
provider.thenToolUse("count_records", input("table", "page", "filter", filter("AND"))).thenText("Must not run");
assertThat(scheduledRunner().run(task, false).getStatus()).isEqualTo("BLOCKED");
assertThat(provider.getRequests()).hasSize(1);
}
/*******************************************************************************
** Approved writes are pending proposals even when normal auto-confirm is on.
*******************************************************************************/
@Test
void approvalAndPromptInvalidation() throws Exception
{
AssistantScheduledTask task = task();
secrets.put("SCHEDULED_TOKEN", AssistantTestFixture.ADMIN);
runtime.getStore().updateFields(AssistantScheduledTask.TABLE_NAME, new QRecord().withValue("id", task.getId()).withValue("runAsUserId", AssistantTestFixture.ADMIN).withValue("writeModeRequested", true));
runtime.getStore().insert(new AssistantDataAccess().withObjectType("TABLE").withObjectName("page").withMode("READ_WRITE"));
runtime.getDataAccessService().invalidate();
provider.thenText("Read only");
assertThat(scheduledRunner().run(task, false).getStatus()).isEqualTo("OK");
assertThat(provider.getRequests().get(0).getTools()).noneMatch(tool -> tool.getName().equals("create_record"));
task = runtime.getStore().scheduledTask(task.getId());
action(task, "Approve");
assertThat(ScheduledTasks.writesApproved(runtime.getStore().scheduledTask(task.getId()))).isTrue();
provider.thenToolUse("create_record", input("table", "page", "values", Map.of("title", "Scheduled proposal"))).thenText("Please confirm");
AssistantScheduledRun run = scheduledRunner().run(task, true);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("OK");
assertThat(run.getSummary()).contains("Waiting for your confirmation");
assertThat(provider.getRequests().get(1).getTools()).anyMatch(tool -> tool.getName().equals("create_record"));
assertThat(runtime.getStore().query(AssistantPendingAction.TABLE_NAME, new QQueryFilter(), AssistantPendingAction.class)).hasSize(1);
runtime.getStore().updateFields(AssistantScheduledTask.TABLE_NAME, new QRecord().withValue("id", task.getId()).withValue("prompt", "Changed prompt"));
assertThat(runtime.getStore().scheduledTask(task.getId()).getWriteModeApprovedAt()).isNull();
assertThat(runtime.getStore().scheduledTask(task.getId()).getWriteModeApprovalHash()).isNull();
action(runtime.getStore().scheduledTask(task.getId()), "Approve");
action(runtime.getStore().scheduledTask(task.getId()), "Revoke");
assertThat(runtime.getStore().scheduledTask(task.getId()).getWriteModeApprovedAt()).isNull();
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.EDITOR, config));
AssistantScheduledTask selected = task;
assertThatThrownBy(() -> action(selected, "Approve")).hasMessageContaining("assistant.admin");
}
/*******************************************************************************
** Exercise the same Review/Result steps the record action uses.
*******************************************************************************/
private void action(AssistantScheduledTask task, String action) throws Exception
{
RunBackendStepInput input = new RunBackendStepInput();
input.setRecords(List.of(task.toQRecord()));
input.addValue("scheduledAction", action);
RunBackendStepOutput reviewOutput = new RunBackendStepOutput();
ScheduledTaskProcess.Review review = new ScheduledTaskProcess.Review();
review.runOnePage(input, reviewOutput);
assertThat(review.getProcessSummary(reviewOutput, false)).isNotEmpty();
input.setRecords(reviewOutput.getRecords());
RunBackendStepOutput resultOutput = new RunBackendStepOutput();
ScheduledTaskProcess.Result result = new ScheduledTaskProcess.Result();
result.runOnePage(input, resultOutput);
assertThat(result.getProcessSummary(resultOutput, true)).isNotEmpty();
}
/*******************************************************************************
** Output modes retain minimal run history and route notifications by owner.
*******************************************************************************/
@Test
void outputModesAndModelFailure() throws Exception
{
for(String mode : List.of("RECORD", "NOTIFICATION", "BOTH"))
{
AssistantScheduledTask task = task();
runtime.getStore().updateFields(AssistantScheduledTask.TABLE_NAME, new QRecord().withValue("id", task.getId()).withValue("outputMode", mode));
provider.thenText("Summary");
assertThat(scheduledRunner().run(task, false).getStatus()).isEqualTo("OK");
}
assertThat(notifications).hasSize(2).allSatisfy(run -> assertThat(run.getUserId()).isEqualTo(AssistantTestFixture.EDITOR));
provider.thenError(ModelProviderException.Kind.OTHER, "Private provider detail");
AssistantScheduledRun failed = scheduledRunner().run(task(), false);
assertThat(failed.getStatus()).isEqualTo("FAILED");
assertThat(failed.getError()).doesNotContain("Private provider detail");
new ScheduledAlerts().raise(failed);
}
/*******************************************************************************
** Validation rejects malformed schedules and secret-looking references.
*******************************************************************************/
@Test
void validationAndManualProcess() throws Exception
{
AssistantScheduledTask task = task();
assertThatThrownBy(() -> ScheduledTasks.next(task.withTimezone("No/SuchZone"), NOW)).hasMessageContaining("timezone");
assertThatThrownBy(() -> ScheduledTasks.next(task.withTimezone("UTC").withCronExpression("invalid"), NOW)).hasMessageContaining("cron");
assertThatThrownBy(() -> ScheduledTasks.checkReference(config, "token-SECRET")).hasMessageNotContaining("SECRET");
assertThatThrownBy(() -> runtime.getStore().updateFields(AssistantScheduledTask.TABLE_NAME, new QRecord().withValue("id", task.getId()).withValue("prompt", " "))).hasMessageContaining("Prompt");
AssistantScheduledTask saved = runtime.getStore().scheduledTask(task.getId());
assertThat(saved.getCredentialStatus()).contains("Set").doesNotContain(AssistantTestFixture.EDITOR);
provider.thenText("Manual");
action(saved, "RunNow");
config.setScheduledTasksSchedulerName("hostScheduler");
assertThat(ScheduledTaskProcess.processes(config).get(0).getSchedule().getRepeatSeconds()).isEqualTo(60);
new ScheduledTaskProcess.Runner().run(new RunBackendStepInput(), new RunBackendStepOutput());
}
/*******************************************************************************
** The scheduled profile supplies persona text and normal PHI refusal applies.
*******************************************************************************/
@Test
void selectedProfileAndPhiBlock() throws Exception
{
AssistantProfile profile = runtime.getStore().insert(new AssistantProfile().withName("Scheduled").withDisplayName("Daily helper").withEnabled(true)
.withPersonality("Use a concise daily briefing style."));
AssistantScheduledTask task = task();
runtime.getStore().updateFields(AssistantScheduledTask.TABLE_NAME, new QRecord().withValue("id", task.getId()).withValue("profileId", profile.getId()));
provider.thenToolUse("get_record", input("table", "patient", "id", 1)).thenText("Must not reach a noneligible provider");
AssistantScheduledRun run = scheduledRunner().run(task, false);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("BLOCKED");
assertThat(provider.getRequests()).hasSize(1);
assertThat(provider.getRequests().get(0).getSystem().get(1).getText()).contains("Daily helper");
}
/*******************************************************************************
** HIPAA retains the answer only in encrypted conversation storage.
*******************************************************************************/
@Test
void hipaaSummaryAndEncryption() throws Exception
{
secrets.put("CONVERSATION_KEY", Base64.getEncoder().encodeToString(new byte[32]));
config.withHipaaMode(true).withConversationEncryptionKeyEnvName("CONVERSATION_KEY");
config.withProviderType(new AssistantProviderType()
{
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public String getType()
{
return ("scheduled-test");
}
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public String getLabel()
{
return ("Scheduled test");
}
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public List<String> getModels()
{
return (List.of("test-model"));
}
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public String getDefaultApiKeyEnvName()
{
return (null);
}
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public String getDefaultBaseUrl()
{
return ("http://localhost");
}
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public AssistantModelProvider create(String key, String base)
{
return (provider);
}
});
runtime.close();
runtime = new AssistantRuntime(qInstance, config, Clock.fixed(NOW, ZoneOffset.UTC));
config.setRuntime(runtime);
AssistantScheduledRun blockedRun = scheduledRunner().run(task(), false);
assertThat(blockedRun.getStatus()).isEqualTo("BLOCKED");
assertThat(blockedRun.getSummary()).isEqualTo("see conversation");
assertThat(provider.getRequests()).isEmpty();
runtime.getStore().insert(new AssistantProvider().withName("Eligible local")
.withProviderType("scheduled-test").withModel("test-model").withEnabled(true).withIsDefault(true).withLocation("LOCAL").withHipaaEligible(true));
provider.thenText("PHI answer sentinel");
AssistantScheduledRun run = scheduledRunner().run(task(), false);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("OK");
assertThat(run.getSummary()).isEqualTo("see conversation");
var records = new QueryAction().execute(
new QueryInput("assistant_message")).getRecords();
assertThat(records).isNotEmpty().allSatisfy(record -> assertThat(record.getValueString("partsJson")).startsWith("enc:v1:").doesNotContain("sentinel"));
}
/*******************************************************************************
** Instance/user budgets and the kill switch also stop scheduled starts.
*******************************************************************************/
@Test
void normalPolicyStillApplies() throws Exception
{
AssistantScheduledTask task = task();
runtime.getStore().insert(new AssistantPolicy().withName("Stop").withKillSwitch(true));
assertThat(scheduledRunner().run(task, false).getStatus()).isEqualTo("BLOCKED");
assertThat(provider.getRequests()).isEmpty();
runtime.getStore().delete("assistant_policy", new QQueryFilter());
config.getBudgets().setUserDailyCostMicrosHard(0L);
AssistantScheduledRun budgetRun = scheduledRunner().run(task(), false);
assertThat(budgetRun.getStatus()).isEqualTo("BLOCKED");
assertThat(budgetRun.getError()).contains("BUDGET");
assertThat(provider.getRequests()).isEmpty();
}
/*******************************************************************************
** Application record locks stay effective with a designated-user login.
*******************************************************************************/
@Test
void applicationRowsRemainOwnerLocked() throws Exception
{
qInstance.getTable("blog_post").withRecordSecurityLock(new RecordSecurityLock()
.withFieldName("slug").withSecurityKeyType(config.getOwnerSecurityKeyType()));
assertThat(runtime.getUiApiGateway().invoke(new UiApiCall("PATCH", "/table/{tableName}/{primaryKey}")
.withPathParam("tableName", "blog_post").withPathParam("primaryKey", "1").withSessionUUID(AssistantTestFixture.ADMIN)
.withJsonBody(Map.of("slug", AssistantTestFixture.EDITOR))).status()).isEqualTo(200);
provider.thenToolUse("query_records", input("table", "blog_post", "filter", filter("AND"))).thenText("Your one post");
AssistantScheduledRun run = scheduledRunner().run(task(), false);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("OK");
var calls = runtime.getStore().query(AssistantToolCall.TABLE_NAME, new QQueryFilter().withCriteria("turnId", QCriteriaOperator.EQUALS, run.getTurnId()), AssistantToolCall.class);
assertThat(calls).singleElement().satisfies(call -> assertThat(call.getRowCount()).isEqualTo(1));
}
}
@@ -19,6 +19,9 @@ package com.kingsrook.qbits.assistant.testing;
import java.io.IOException;
import java.net.ServerSocket;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
@@ -34,6 +37,8 @@ import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
*******************************************************************************/
public final class UiApiTestServer
{
public static final List<Map<String, String>> SCHEDULED_REQUESTS = new CopyOnWriteArrayList<>();
private static QApplicationJavalinServer current;
private static QInstance currentInstance;
private static int currentPort;
@@ -89,6 +94,7 @@ public final class UiApiTestServer
if(current == null || currentInstance != qInstance)
{
stop();
SCHEDULED_REQUESTS.clear();
try
{
currentPort = freePort();
@@ -96,7 +102,15 @@ public final class UiApiTestServer
.withPort(currentPort)
.withServeFrontendNext(false)
.withServeFrontendMaterialDashboard(false)
.withServeLegacyUnversionedMiddlewareAPI(false);
.withServeLegacyUnversionedMiddlewareAPI(false)
.withJavalinConfigCustomizer(app -> app.routes.before(context ->
{
String client = context.header("X-QQQ-Client");
if(client != null && !context.path().endsWith("manageSession"))
{
SCHEDULED_REQUESTS.add(Map.of("client", client, "session", String.valueOf(context.cookie("sessionUUID")), "path", context.path()));
}
}));
current.start();
currentInstance = qInstance;
}