feat!: assistant tools go through the /qqq/v1 UI API with the user's session (phase b) #14

Merged
KofTwentyTwo merged 1 commits from feature/ui-api-tools into develop 2026-10-13 18:59:36 +00:00
32 changed files with 5106 additions and 47 deletions

No files matched your search

+11
View File
@@ -8,6 +8,17 @@ Phase 0/1 of the QQQ Assistant: read-only Q&A and navigation. Requires QQQ 4.1 w
- the AI tool kernel (`qqq-backend-core` `actions.ai`, QRun-IO/qqq#1078)
- the developer-panel contract (QRun-IO/qqq#1076).
### Changed (0.1.0-RC.6): tools go through the UI API (owner decision 2026-10-13)
- **Every data tool runs a spec'd `/qqq/v1` operation with the user's own session** (`UiApiGateway`, loopback HTTP with the `sessionUUID` cookie): the same authentication, permissions, record locks, validation, audit and errors as the UI. A user without a permission gets the identical denial through the assistant. `navigate` and `search_help` (no records) stay on the core kernel. `LoopbackUiApiGateway` is a stand-in for core's `UiApiInvoker` (QRun-IO/qqq#1128).
- Tools: `list_tables`, `describe_table`, `query_records`, `count_records`, `get_record`, `search_records`, `render_widget`, `get_record_history`, and new **`create_record`, `update_record`, `delete_record`**.
### Added (0.1.0-RC.6)
- **Data access** (`assistant_data_access`, nav table "Data access"): NONE / READ / READ_WRITE per table or process. Default: NONE for sensitive tables (AI exposure NONE; cannot be raised), READ for other tables, NONE for processes. It only narrows the UI API.
- **Pending actions** (`assistant_pending_action`): with confirmation on (the default), a write only proposes; `POST /qqq/v1/assistant/actions/{id}/confirm` runs it through the UI API with the confirming user's session, once; `.../reject`; `GET .../actions/{id}`. Child list on conversations.
- **Policy columns** on `assistant_policy`: `writesRequireConfirmation`, `allowedTools`, `maxRowsPerCall`, `redactPii`.
- **Tool-call log columns** on `assistant_tool_call`: `uiApiOperation`, `httpStatus`, `auditHref`, `pendingActionId`; tool inputs are stored with secrets redacted.
- Config: `uiApiBaseUrl`, `withUiApiGateway`, `pendingActionTtlSeconds`. `/assistant/config` reports `features.writes` (some table is READ_WRITE) and `features.pendingActions`.
### Fixed (0.1.0-RC.5)
- **Every turn failed with "The AI service could not answer this request."** Anthropic refused the request with 400 `invalid_request_error` "The compiled grammar is too large ... reduce the number of strict tools": all ten read tools were sent with `strict: true`, and the API compiles every strict schema of a request into one grammar. Tools are now **not strict by default**. The kernel already validates every tool input and answers `INVALID_INPUT`. A host can opt individual tools in with `withStrictToolNames(...)`.
- If a provider still refuses the strict schemas, the turn **retries once without strict tools** and logs a warning (`ModelProviderException.isStrictSchemaRejected`).
+5 -3
View File
@@ -11,10 +11,10 @@ This file provides guidance to Claude Code when working with code in this reposi
- budgets, rate limits and the kill switch
- the model-provider SPI.
The panel UI lives in `qqq-frontend-next`. Data access goes only through the **AI tool kernel in QQQ core** (`com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel`).
The panel UI lives in `qqq-frontend-next`. Data access goes only through the **QQQ UI API** (`/qqq/v1`, spec'd operations, the user's own session) via `uiapi/UiApiGateway` and `tools/UiApiToolbox`; only `navigate` and `search_help` (no records) use the core AI tool kernel.
- Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`. The owner decisions D1 to D6 at its top are binding.
- Phase 1 is read and navigate only: no write tools and no approval cards.
- Writes (create/update/delete) are offered only for tables an admin set to READ_WRITE in `assistant_data_access`, and by default wait for the user's confirmation (pending action card).
## Build Commands
@@ -51,7 +51,9 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- `AssistantSecurity` only ever adds the user's OWN id key, or the all-access key for `assistant.admin` holders.
- **Data access:**
- Only `persistence/` calls QueryAction, InsertAction and the other data actions (ArchUnit).
- Tools run only through `AiToolKernel.execute`.
- Data tools run only through `UiApiToolbox` → `UiApiGateway` (spec'd `/qqq/v1` operations with the user's `sessionUUID`); never core data actions for user data. Only `navigate` and `search_help` use `AiToolKernel.execute`.
- Data access (`DataAccessService`) and the policy only ever narrow what the UI API allows; a table whose AI exposure is NONE stays NONE.
- A write runs only after the owner confirms its pending action (unless the policy turns confirmation off), with the confirming request's session, once.
- Panel API queries also filter `userId = me`, so an admin's all-access key never mixes other users' conversations into the panel.
- **Caching:** the request prefix stays byte-stable. The order is tools (kernel-sorted), then system block 1 (rules + primer + app digest, LONG/1h), then system block 2 (user digest + date, SHORT/5m), then messages. Exactly one SHORT hint goes on the newest message. Don't put timestamps or ids in the system prompt.
- **Tool results:**
+11
View File
@@ -107,3 +107,14 @@ QQQ stores table names, permission names and key names outside this qbit. Update
### Order
Deploy RC.3 and run the statements above in the same maintenance window. Until the update runs, saved views and reports on the assistant tables do not open, and admins who get the all-access key from their own customizer only see their own rows.
## 0.1.0-RC.6: tools through the UI API
Database (see `docs/schema-postgres.sql`):
- new tables `assistant_data_access` and `assistant_pending_action`;
- new columns `assistant_tool_call.ui_api_operation`, `http_status`, `audit_href`, `pending_action_id`;
- new columns `assistant_policy.writes_require_confirmation`, `allowed_tools`, `max_rows_per_call`, `redact_pii`.
Runtime: the tools call this server's own `/qqq/v1` on `127.0.0.1` and the port the first assistant request arrived on. If the server is not reachable that way (a different bind address, a TLS-only listener), set `withUiApiBaseUrl(...)`. Grant `assistant.admin` holders the new tables through the qbit's existing admin permission (no new permission names).
Behavior: nothing is writable until an admin adds a READ_WRITE data access row; before that the assistant reads exactly what it did before, now through the UI API.
+107
View File
@@ -104,3 +104,110 @@ The nav is the home plus the main tables: Providers, Conversations, Model prices
- logged at WARN;
- recorded as the `assistant_usage` request id;
- shown to `assistant.admin` holders, in the panel's error and in Test connection.
## Owner decisions (2026-10-13)
Owner: *"the agent should have to go through the same API that the UIUX does. that way it is held to the same security boundaries etc"*, plus the ability to configure the assistant and verify its security and data access.
1. **Tools via the UI API.** Every tool that reads or writes data, or runs a process, report or widget, runs a spec'd `/qqq/v1` operation with the calling user's own session. That means the UI's authentication, permission checks, record locks, validation, audit and error mapping. There are no direct core data actions and no elevated sessions.
2. **Data access is configured and verifiable in the UI.**
- Each table and process has an AI data access setting: NONE, READ or READ_WRITE. Sensitive tables default to NONE.
- Admins can see what the assistant could do for a given user, and why.
3. **Writes need confirmation.** A write tool only *proposes* the change, as a pending action card. Nothing changes until the user confirms in the panel, unless the policy turns confirmation off.
4. All other open questions in this section were decided as recommended (shared rule: "all recommendations accepted").
## Tools through the UI API (0.1.0-RC.6)
### Transport: loopback HTTP with the user's session
`UiApiGateway.invoke(UiApiCall)` makes an HTTP request to this server's own `/qqq/v1`, carrying the user's `sessionUUID` cookie and `X-QQQ-Invoked-By: assistant`.
- Only operations defined by `MiddlewareVersionV1`'s endpoint specs can be called. Anything else, including the assistant's own raw routes, is refused before a request is made.
- A call without a session is also refused.
**Why loopback rather than calling the spec handlers in process.** The spec's `buildInput(Context)`, session setup, the version's `preExecute`, Javalin filters, permission checks and error mapping all hang off the Javalin request pipeline.
- Calling the executors directly would skip some of these and drift over time.
- Building a synthetic Javalin `Context` relies on internal API.
- A request to the server's own port runs the identical pipeline by construction. It costs one local hop, which is milliseconds next to a model call.
This matches core's design (`docs/UI-API-EXTENSIONS.md` §5, `UiApiInvoker`, QRun-IO/qqq#1128).
- `LoopbackUiApiGateway` is the stand-in until a QQQ release has the invoker. Then an adapter over `UiApiInvoker.forServer(server)` replaces it (or a host injects one with `withUiApiGateway`), and nothing else changes.
- The base URL is `uiApiBaseUrl` if configured. Otherwise it is `127.0.0.1` plus the local port the first assistant request arrived on, so in a multi-replica deployment the call stays on the replica that holds the session's process state.
### The tools
| tool | `/qqq/v1` operation |
|---|---|
| `list_tables` | `GET /metaData` (tables with the user's read/insert/edit/delete flags) |
| `describe_table` | `GET /metaData/table/{tableName}` |
| `query_records` | `POST /table/{tableName}/query` (plus `.../count` when there are more rows) |
| `count_records` | `POST /table/{tableName}/count` |
| `get_record` | `GET /table/{tableName}/{primaryKey}` |
| `search_records` | `POST /search` |
| `render_widget` | `POST /widget/{widgetName}` |
| `get_record_history` | `GET /table/{tableName}/{primaryKey}`, then `POST /table/{tableName}/query` on `audit_table`, `audit` and `audit_detail` |
| `create_record` / `update_record` / `delete_record` | `POST /table/{tableName}` / `PATCH /table/{tableName}/{primaryKey}` / `DELETE /table/{tableName}/{primaryKey}` |
`navigate` and `search_help` stay on the core kernel because they read no records:
- `navigate` only builds an `/app` link, and the page enforces permissions when it opens.
- `search_help` reads help text from metadata.
Running processes and reports through the assistant (`/processes/...`, `/reports/...`) is not offered yet. Process data access rows are stored so that the screens can already show and set them.
### Layers (each one can only narrow the one before)
1. **The user's own permissions and record locks**, enforced by the UI API itself. A user who cannot do something in the UI gets the identical status and message through the assistant: the tool result is `DENIED`/`NOT_FOUND`/`INVALID_INPUT` with `"<operation> answered HTTP <status>: <message>"`.
2. **Data access** (`assistant_data_access`, editable by `assistant.admin`). A row sets NONE, READ or READ_WRITE per table (`TABLE`) or process (`PROCESS`). Without a row:
- tables whose AI exposure is NONE (the `assistant_*` tables, sessions, OAuth, and any table whose metadata says so) are NONE, and a row cannot raise them;
- other tables are READ, so the assistant never writes unless an admin allows it;
- processes are NONE.
The write tools are offered only when some table is READ_WRITE.
3. **Policy** (`assistant_policy`):
- `writesRequireConfirmation` (default true);
- `allowedTools` (empty allows all);
- `maxRowsPerCall` (default 50, ceiling 200);
- `redactPii` (default true: secrets, card numbers and SSNs are redacted, with the core `AiRedactor`).
4. **AI field exposure** from table metadata:
- NONE and METADATA_ONLY values never reach the model;
- MASKED values are masked;
- only VALUES fields can be filtered or sorted on.
### Writes and confirmation
With confirmation on, a write tool validates the table's data access and the fields. It then inserts an `assistant_pending_action` row (owner-locked, `PENDING`, expiring after `pendingActionTtlSeconds`, default 15 minutes) and returns a `pending_action` render, which the panel shows as a card. The model is told the change is not done.
- `POST /qqq/v1/assistant/actions/{id}/confirm` claims the action (`RUNNING`, so a double click cannot run it twice). It then runs the stored operation through the UI API **with the confirming request's session** and records the outcome as `EXECUTED` or `FAILED`.
- `POST /qqq/v1/assistant/actions/{id}/reject` runs nothing.
- `GET /qqq/v1/assistant/actions/{id}` reads the action's state.
- Only the user who proposed an action can see or decide it.
### Tool-call log
Each `assistant_tool_call` row records:
- the user and the tool;
- the parameters, with secrets redacted;
- the UI API operation and the HTTP status the UI would have seen;
- the rows touched and the outcome;
- the pending action, if there is one;
- an in-app link to the written record, whose page shows its audit entries.
A confirmed write adds its own row. The rows are a child list on conversations, and pending actions are another one.
### Tests
`UiApiToolsSecurityTest` runs the fixture app on a real QQQ application server. For each case it makes the UI's own request (a direct `/qqq/v1` call with the user's cookie) next to the assistant's tool call:
- read denied: the same 403 and message;
- write denied without the insert permission, even with READ_WRITE and no confirmation;
- data access only narrows: NONE, READ, and sensitive tables that cannot be raised;
- a confirmed write runs once, through the UI API, and is logged;
- only the owner can decide an action;
- only spec'd operations can be called, and never without a session;
- the policy narrows the tools and caps rows.
The golden and red-team evals now run through the UI API too.
## Next phases
- **(c) Data access screens:**
- the Data Access table, in the nav;
- an "Explain access" action (for a user, and a table or a prompt, which operations the assistant could call and which it would be denied, with the reason);
- a recent-denials widget on the app home.
- **(d) Spec'd assistant endpoints:** move `/qqq/v1/assistant/...` (conversations, turns, events, feedback, cancel, config, actions) onto `AbstractEndpointSpec`s through the qbit spec extension point (`MiddlewareSpecExtension`, established prefix `/assistant`, QRun-IO/qqq#1128). The SSE turn and events streams stay raw routes until the spec layer can describe a stream.
+47
View File
@@ -98,6 +98,10 @@ CREATE TABLE assistant_tool_call
result_summary VARCHAR(500),
result_json TEXT,
redactions_json TEXT,
ui_api_operation VARCHAR(255),
http_status INTEGER,
audit_href VARCHAR(500),
pending_action_id INTEGER,
create_date TIMESTAMP WITH TIME ZONE
);
CREATE INDEX assistant_tool_call_user_id ON assistant_tool_call (user_id);
@@ -134,6 +138,10 @@ CREATE TABLE assistant_policy
instance_monthly_tokens_hard BIGINT,
instance_monthly_cost_micros_soft BIGINT,
instance_monthly_cost_micros_hard BIGINT,
writes_require_confirmation BOOLEAN,
allowed_tools VARCHAR(1000),
max_rows_per_call INTEGER,
redact_pii BOOLEAN,
create_date TIMESTAMP WITH TIME ZONE,
modify_date TIMESTAMP WITH TIME ZONE
);
@@ -239,3 +247,42 @@ VALUES
('anthropic', 'claude-opus-4-8', 5.00, 25.00, 0.50, 6.25, 10.00, 'Anthropic list price, 2026-10', NOW(), NOW()),
('anthropic', 'claude-sonnet-4-6', 3.00, 15.00, 0.30, 3.75, 6.00, 'Anthropic list price, 2026-10', NOW(), NOW()),
('anthropic', 'claude-haiku-4-5', 1.00, 5.00, 0.10, 1.25, 2.00, 'Anthropic list price, 2026-10', NOW(), NOW());
-- 0.1.0-RC.6: the assistant's data access per table / process, and writes waiting
-- for the user's confirmation. Existing databases: add the tool-call and policy
-- columns above with ALTER TABLE ... ADD COLUMN IF NOT EXISTS.
CREATE TABLE assistant_data_access
(
id SERIAL PRIMARY KEY,
object_type VARCHAR(16) NOT NULL,
object_name VARCHAR(128) NOT NULL,
mode VARCHAR(16) NOT NULL,
notes VARCHAR(500),
create_date TIMESTAMP WITH TIME ZONE,
modify_date TIMESTAMP WITH TIME ZONE,
CONSTRAINT assistant_data_access_object UNIQUE (object_type, object_name)
);
CREATE TABLE assistant_pending_action
(
id SERIAL PRIMARY KEY,
conversation_id INTEGER,
turn_id INTEGER,
user_id VARCHAR(255),
tool_call_id VARCHAR(128),
tool_name VARCHAR(64),
ui_api_operation VARCHAR(255),
table_name VARCHAR(128),
record_id VARCHAR(128),
params_json TEXT,
summary VARCHAR(500),
status VARCHAR(16),
http_status INTEGER,
result_summary VARCHAR(500),
expires_at TIMESTAMP WITH TIME ZONE,
decided_at TIMESTAMP WITH TIME ZONE,
create_date TIMESTAMP WITH TIME ZONE,
modify_date TIMESTAMP WITH TIME ZONE
);
CREATE INDEX assistant_pending_action_user_id ON assistant_pending_action (user_id);
CREATE INDEX assistant_pending_action_conversation_id ON assistant_pending_action (conversation_id);
@@ -29,6 +29,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qbits.assistant.engine.ModelPricing;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
@@ -103,6 +104,16 @@ public class AssistantQBitConfig implements QBitConfig
// The kernel validates every input anyway (INVALID_INPUT results). //
////////////////////////////////////////////////////////////////////////////
private Set<String> strictToolNames = new LinkedHashSet<>();
//////////////////////////////////////////////////////////////////////////
// UI API: the data tools call /qqq/v1 on this server with the user's //
// session. The base URL defaults to 127.0.0.1 and the port the first //
// assistant request arrived on. A gateway can be injected (core's //
// UiApiInvoker adapter, tests). //
//////////////////////////////////////////////////////////////////////////
private String uiApiBaseUrl;
private UiApiGateway uiApiGateway;
private Integer pendingActionTtlSeconds = 900;
private List<String> customRedactionPatterns = new ArrayList<>();
private Integer killSwitchPollMillis = 2000;
private Set<String> allowedApiKeyEnvNames = new LinkedHashSet<>();
@@ -1136,6 +1147,99 @@ public class AssistantQBitConfig implements QBitConfig
/*******************************************************************************
** Getter for uiApiBaseUrl (this server's root URL for loopback UI API calls)
*******************************************************************************/
public String getUiApiBaseUrl()
{
return (this.uiApiBaseUrl);
}
/*******************************************************************************
** Setter for uiApiBaseUrl
*******************************************************************************/
public void setUiApiBaseUrl(String uiApiBaseUrl)
{
this.uiApiBaseUrl = uiApiBaseUrl;
}
/*******************************************************************************
** Fluent setter for uiApiBaseUrl
*******************************************************************************/
public AssistantQBitConfig withUiApiBaseUrl(String uiApiBaseUrl)
{
this.uiApiBaseUrl = uiApiBaseUrl;
return (this);
}
/*******************************************************************************
** Getter for uiApiGateway (null: the loopback stand-in)
*******************************************************************************/
public UiApiGateway getUiApiGateway()
{
return (this.uiApiGateway);
}
/*******************************************************************************
** Setter for uiApiGateway
*******************************************************************************/
public void setUiApiGateway(UiApiGateway uiApiGateway)
{
this.uiApiGateway = uiApiGateway;
}
/*******************************************************************************
** Fluent setter for uiApiGateway
*******************************************************************************/
public AssistantQBitConfig withUiApiGateway(UiApiGateway uiApiGateway)
{
this.uiApiGateway = uiApiGateway;
return (this);
}
/*******************************************************************************
** Getter for pendingActionTtlSeconds (how long a proposed write waits for confirmation)
*******************************************************************************/
public Integer getPendingActionTtlSeconds()
{
return (this.pendingActionTtlSeconds);
}
/*******************************************************************************
** Setter for pendingActionTtlSeconds
*******************************************************************************/
public void setPendingActionTtlSeconds(Integer pendingActionTtlSeconds)
{
this.pendingActionTtlSeconds = pendingActionTtlSeconds;
}
/*******************************************************************************
** Fluent setter for pendingActionTtlSeconds
*******************************************************************************/
public AssistantQBitConfig withPendingActionTtlSeconds(Integer pendingActionTtlSeconds)
{
this.pendingActionTtlSeconds = pendingActionTtlSeconds;
return (this);
}
/*******************************************************************************
** Getter for strictToolNames: the tools sent with strict schema adherence
** (empty by default; see the field comment).
@@ -19,20 +19,27 @@ package com.kingsrook.qbits.assistant;
import java.time.Clock;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.regex.Pattern;
import com.kingsrook.qbits.assistant.access.DataAccessService;
import com.kingsrook.qbits.assistant.engine.ContextBuilder;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.engine.RateLimiter;
import com.kingsrook.qbits.assistant.engine.RunningTurn;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.LoopbackUiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
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.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
@@ -51,6 +58,10 @@ public class AssistantRuntime
private final RateLimiter rateLimiter;
private final AiToolKernel kernel;
private final ContextBuilder contextBuilder;
private final DataAccessService dataAccessService;
private final UiApiToolbox toolbox;
private final UiApiGateway uiApiGateway;
private volatile String localBaseUrl;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
private final Map<Integer, RunningTurn> runningTurns = new ConcurrentHashMap<>();
@@ -78,8 +89,69 @@ public class AssistantRuntime
this.policyService = new PolicyService(config, store, clock);
this.providerService = new ProviderService(config, store, clock);
this.rateLimiter = new RateLimiter(clock);
this.kernel = new AiToolKernel(qInstance, kernelSettings(config));
this.contextBuilder = new ContextBuilder(config, kernel);
AiToolKernelSettings settings = kernelSettings(config);
this.kernel = new AiToolKernel(qInstance, settings);
AiExposurePolicy exposurePolicy = new AiExposurePolicy(settings);
this.dataAccessService = new DataAccessService(qInstance, config, store, exposurePolicy, clock);
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);
this.contextBuilder = new ContextBuilder(config, kernel, toolbox);
}
/*******************************************************************************
** The server's own root URL for UI API loopback calls: the configured
** uiApiBaseUrl, else the address the first assistant request arrived on
** (127.0.0.1 and this server's local port).
*******************************************************************************/
public String uiApiBaseUrl()
{
return (StringUtils.hasContent(config.getUiApiBaseUrl()) ? config.getUiApiBaseUrl() : localBaseUrl);
}
/*******************************************************************************
** Remember this server's local port (from a request) for loopback calls.
*******************************************************************************/
public void learnLocalPort(int port)
{
if(port > 0 && localBaseUrl == null)
{
localBaseUrl = "http://127.0.0.1:" + port;
}
}
/*******************************************************************************
** Getter for dataAccessService
*******************************************************************************/
public DataAccessService getDataAccessService()
{
return (dataAccessService);
}
/*******************************************************************************
** Getter for toolbox (the UI API data tools)
*******************************************************************************/
public UiApiToolbox getToolbox()
{
return (toolbox);
}
/*******************************************************************************
** Getter for uiApiGateway
*******************************************************************************/
public UiApiGateway getUiApiGateway()
{
return (uiApiGateway);
}
@@ -0,0 +1,93 @@
/*
* 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.access;
/*******************************************************************************
** What the assistant may do with a table or process, on top of (never instead
** of) the user's own permissions: NONE, READ (read tools), READ_WRITE (read
** and write tools; processes: run).
*******************************************************************************/
public enum DataAccessMode
{
NONE("None"),
READ("Read"),
READ_WRITE("Read and write");
private final String label;
/*******************************************************************************
**
*******************************************************************************/
DataAccessMode(String label)
{
this.label = label;
}
/*******************************************************************************
** Getter for label
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Whether read tools may use the object.
*******************************************************************************/
public boolean allowsRead()
{
return (this != NONE);
}
/*******************************************************************************
** Whether write tools may use the object.
*******************************************************************************/
public boolean allowsWrite()
{
return (this == READ_WRITE);
}
/*******************************************************************************
** Parse a stored value (null or unknown: null).
*******************************************************************************/
public static DataAccessMode parse(String value)
{
if(value == null)
{
return (null);
}
for(DataAccessMode mode : values())
{
if(mode.name().equalsIgnoreCase(value.trim()))
{
return (mode);
}
}
return (null);
}
}
@@ -0,0 +1,185 @@
/*
* 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.access;
import java.time.Clock;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.exposure.AiExposurePolicy;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
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.tables.QTableMetaData;
/*******************************************************************************
** The assistant's data access per table and process: the admin's
** `assistant_data_access` row, else the default.
**
** Defaults:
** - tables whose AI exposure is NONE (the `assistant_*` tables, sessions,
** OAuth, and any table its metadata marks NONE): NONE;
** - other tables: READ (the assistant never writes unless an admin says so);
** - processes: NONE.
**
** The mode only narrows: every call still runs through the UI API with the
** user's own session.
*******************************************************************************/
public class DataAccessService
{
private static final QLogger LOG = QLogger.getLogger(DataAccessService.class);
private final QInstance qInstance;
private final AssistantQBitConfig config;
private final AssistantStore store;
private final AiExposurePolicy exposurePolicy;
private final Clock clock;
private volatile Map<String, AssistantDataAccess> rows;
private volatile Instant loadedAt;
/*******************************************************************************
**
*******************************************************************************/
public DataAccessService(QInstance qInstance, AssistantQBitConfig config, AssistantStore store, AiExposurePolicy exposurePolicy, Clock clock)
{
this.qInstance = qInstance;
this.config = config;
this.store = store;
this.exposurePolicy = exposurePolicy;
this.clock = clock;
}
/*******************************************************************************
** A decision and where it came from.
*******************************************************************************/
public record Decision(DataAccessMode mode, String source)
{
}
/*******************************************************************************
** The mode for a table (unknown table: NONE).
*******************************************************************************/
public Decision forTable(String tableName)
{
QTableMetaData table = tableName == null ? null : qInstance.getTable(tableName);
if(table == null)
{
return (new Decision(DataAccessMode.NONE, "unknown table"));
}
AssistantDataAccess row = rows().get(key(AssistantDataAccess.TYPE_TABLE, tableName));
DataAccessMode set = row == null ? null : DataAccessMode.parse(row.getMode());
AiExposure exposure = exposurePolicy.getTableExposure(table);
////////////////////////////////////////////////////////////////////
// a table whose AI exposure is NONE stays NONE: its metadata says //
// its values must never reach a model, whatever the row says //
////////////////////////////////////////////////////////////////////
if(exposure == AiExposure.NONE)
{
return (new Decision(DataAccessMode.NONE, set != null && set != DataAccessMode.NONE ? "sensitive table (AI exposure NONE; the setting cannot raise it)" : "sensitive table (AI exposure NONE)"));
}
if(set != null)
{
return (new Decision(set, "setting"));
}
return (new Decision(DataAccessMode.READ, "default"));
}
/*******************************************************************************
** The mode for a process (unknown process: NONE; default NONE).
*******************************************************************************/
public Decision forProcess(String processName)
{
if(processName == null || qInstance.getProcess(processName) == null)
{
return (new Decision(DataAccessMode.NONE, "unknown process"));
}
AssistantDataAccess row = rows().get(key(AssistantDataAccess.TYPE_PROCESS, processName));
DataAccessMode set = row == null ? null : DataAccessMode.parse(row.getMode());
return (set == null ? new Decision(DataAccessMode.NONE, "default") : new Decision(set, "setting"));
}
/*******************************************************************************
** Drop the cache (after a change to the table).
*******************************************************************************/
public void invalidate()
{
rows = null;
}
/*******************************************************************************
** The rows, cached for policyCacheSeconds. A read failure keeps the last
** rows; with none, every table falls back to its default.
*******************************************************************************/
private Map<String, AssistantDataAccess> rows()
{
Map<String, AssistantDataAccess> current = rows;
long ttl = config.getPolicyCacheSeconds() == null ? 30 : config.getPolicyCacheSeconds();
if(current != null && loadedAt != null && loadedAt.plusSeconds(ttl).isAfter(clock.instant()))
{
return (current);
}
try
{
Map<String, AssistantDataAccess> loaded = new HashMap<>();
for(AssistantDataAccess row : store.query(AssistantDataAccess.TABLE_NAME, new QQueryFilter().withLimit(10_000), AssistantDataAccess.class))
{
loaded.put(key(row.getObjectType(), row.getObjectName()), row);
}
rows = loaded;
loadedAt = clock.instant();
return (loaded);
}
catch(QException | RuntimeException e)
{
LOG.warn("Could not read the assistant data access settings; using the defaults", e);
return (current == null ? Map.of() : current);
}
}
/*******************************************************************************
**
*******************************************************************************/
private static String key(String type, String name)
{
return ((type == null ? AssistantDataAccess.TYPE_TABLE : type) + ":" + name);
}
}
@@ -19,9 +19,11 @@ package com.kingsrook.qbits.assistant.api;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.AssistantSecurity;
@@ -35,9 +37,15 @@ import com.kingsrook.qbits.assistant.engine.TurnEvent;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
@@ -114,7 +122,7 @@ public class AssistantApi
result.put("shortcut", config.getShortcut());
result.put("model", reason == null ? activeProvider().model() : config.getModels().getMainModel());
result.put("limits", Map.of("maxTurnSeconds", config.getMaxTurnSeconds(), "maxToolCallsPerTurn", config.getMaxToolCallsPerTurn()));
result.put("features", Map.of("writes", false));
result.put("features", Map.of("writes", runtime.getToolbox().anyWritableTable(), "pendingActions", true));
return (result);
}
@@ -533,6 +541,164 @@ public class AssistantApi
/*******************************************************************************
** GET /actions/{id}: one of the user's pending actions.
*******************************************************************************/
public Map<String, Object> getAction(Integer id) throws QException, AssistantApiException
{
requireAccess();
return (toJson(expireIfDue(requireAction(id))));
}
/*******************************************************************************
** POST /actions/{id}/confirm: run the proposed write through the UI API
** with THIS request's session (the user's own), then record the result and
** a tool-call log row. A write is never run twice.
*******************************************************************************/
public Map<String, Object> confirmAction(Integer id) throws QException, AssistantApiException
{
requireAccess();
AssistantPendingAction action = expireIfDue(requireAction(id));
if(!AssistantPendingAction.STATUS_PENDING.equals(action.getStatus()))
{
throw (new AssistantApiException(409, "This action is already " + action.getStatus().toLowerCase() + "."));
}
///////////////////////////////////////////////////////////////////////
// claim it first, so a double click cannot run the write twice //
///////////////////////////////////////////////////////////////////////
Instant now = runtime.getClock().instant();
store.updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", "RUNNING").withValue("decidedAt", now));
AssistantPendingAction claimed = store.getPendingAction(userId, id);
if(claimed == null || !"RUNNING".equals(claimed.getStatus()))
{
throw (new AssistantApiException(409, "This action is already being handled."));
}
Map<String, Object> values;
try
{
values = action.getParamsJson() == null ? Map.of() : JsonUtils.toObject(action.getParamsJson(), new TypeReference<Map<String, Object>>() {});
}
catch(Exception e)
{
throw (new QException("The stored values of action " + id + " are not valid JSON", e));
}
PolicyService.EffectivePolicy policy = runtime.getPolicyService().current(true);
UiApiToolbox.Context context = new UiApiToolbox.Context(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), runtime.getUiApiGateway(), policy.tools(), null);
UiApiToolbox.Outcome outcome = runtime.getToolbox().executeConfirmedWrite(context, action.getToolCallId(), action.getToolName(), action.getTableName(), action.getRecordId(), values);
AiToolResult result = outcome.getResult();
boolean ok = result.getOutcome() == AiToolOutcome.OK;
store.updateFields(AssistantPendingAction.TABLE_NAME, new QRecord()
.withValue("id", id)
.withValue("status", ok ? AssistantPendingAction.STATUS_EXECUTED : AssistantPendingAction.STATUS_FAILED)
.withValue("httpStatus", outcome.getHttpStatus())
.withValue("resultSummary", truncate(result.getSummary(), 500)));
store.insert(new AssistantToolCall()
.withTurnId(action.getTurnId())
.withConversationId(action.getConversationId())
.withUserId(userId)
.withToolCallId(action.getToolCallId())
.withToolName(action.getToolName())
.withInputJson(truncate(new AiRedactor(List.of()).redact(action.getParamsJson() == null ? "{}" : action.getParamsJson(), new HashSet<>()), 4000))
.withOutcome(result.getOutcome() == null ? AiToolOutcome.ERROR.name() : result.getOutcome().name())
.withPermissionCheck(result.getPermissionCheck())
.withTableName(action.getTableName())
.withRowCount(result.getRowCount())
.withDurationMs(result.getDurationMs())
.withResultSummary(truncate("Confirmed: " + result.getSummary(), 500))
.withResultJson(result.getModelContent())
.withUiApiOperation(outcome.getOperation())
.withHttpStatus(outcome.getHttpStatus())
.withAuditHref(outcome.getAuditHref())
.withPendingActionId(id));
Map<String, Object> rs = toJson(store.getPendingAction(userId, id));
rs.put("outcome", result.getOutcome() == null ? null : result.getOutcome().name());
rs.put("message", result.getModelContent());
if(outcome.getAuditHref() != null)
{
rs.put("href", outcome.getAuditHref());
}
return (rs);
}
/*******************************************************************************
** POST /actions/{id}/reject: nothing runs.
*******************************************************************************/
public Map<String, Object> rejectAction(Integer id) throws QException, AssistantApiException
{
requireAccess();
AssistantPendingAction action = expireIfDue(requireAction(id));
if(!AssistantPendingAction.STATUS_PENDING.equals(action.getStatus()))
{
throw (new AssistantApiException(409, "This action is already " + action.getStatus().toLowerCase() + "."));
}
store.updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", AssistantPendingAction.STATUS_REJECTED)
.withValue("decidedAt", runtime.getClock().instant()).withValue("resultSummary", "Cancelled by the user; nothing changed."));
return (toJson(store.getPendingAction(userId, id)));
}
/*******************************************************************************
**
*******************************************************************************/
private AssistantPendingAction requireAction(Integer id) throws QException, AssistantApiException
{
AssistantPendingAction action = id == null ? null : store.getPendingAction(userId, id);
if(action == null)
{
throw (new AssistantApiException(404, "Action not found."));
}
return (action);
}
/*******************************************************************************
** Mark a pending action past its expiry as EXPIRED.
*******************************************************************************/
private AssistantPendingAction expireIfDue(AssistantPendingAction action) throws QException
{
if(AssistantPendingAction.STATUS_PENDING.equals(action.getStatus()) && action.getExpiresAt() != null && action.getExpiresAt().isBefore(runtime.getClock().instant()))
{
store.updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", action.getId()).withValue("status", AssistantPendingAction.STATUS_EXPIRED)
.withValue("resultSummary", "Expired before it was confirmed; nothing changed."));
action.setStatus(AssistantPendingAction.STATUS_EXPIRED);
action.setResultSummary("Expired before it was confirmed; nothing changed.");
}
return (action);
}
/*******************************************************************************
**
*******************************************************************************/
static Map<String, Object> toJson(AssistantPendingAction action)
{
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", action.getId());
map.put("status", action.getStatus());
map.put("toolName", action.getToolName());
map.put("tableName", action.getTableName());
map.put("recordId", action.getRecordId());
map.put("summary", action.getSummary());
map.put("httpStatus", action.getHttpStatus());
map.put("resultSummary", action.getResultSummary());
map.put("expiresAt", action.getExpiresAt() == null ? null : action.getExpiresAt().toString());
return (map);
}
/*******************************************************************************
** The configured turn time limit (replay loops are capped by it).
*******************************************************************************/
@@ -127,6 +127,9 @@ public class AssistantRouteProvider implements QJavalinRouteProviderInterface
ctx.status(202);
return (api.cancelTurn(pathInt(ctx, "turnId")));
}));
config.routes.get(BASE_PATH + "/actions/{id}", ctx -> json(ctx, false, (api, body) -> api.getAction(pathInt(ctx, "id"))));
config.routes.post(BASE_PATH + "/actions/{id}/confirm", ctx -> json(ctx, true, (api, body) -> api.confirmAction(pathInt(ctx, "id"))));
config.routes.post(BASE_PATH + "/actions/{id}/reject", ctx -> json(ctx, true, (api, body) -> api.rejectAction(pathInt(ctx, "id"))));
config.routes.post(BASE_PATH + "/messages/{id}/feedback", ctx -> json(ctx, true, (api, body) ->
{
api.feedback(pathInt(ctx, "id"), body);
@@ -383,6 +386,17 @@ public class AssistantRouteProvider implements QJavalinRouteProviderInterface
{
throw (new AssistantApiException(404, "The assistant is not installed."));
}
////////////////////////////////////////////////////////////////////
// the tools call /qqq/v1 on this server: remember its local port //
////////////////////////////////////////////////////////////////////
try
{
runtime.learnLocalPort(context.req().getLocalPort());
}
catch(RuntimeException e)
{
LOG.debug("Could not read the local port", e);
}
QSession session = ExecutorSessionUtils.setupSession(context, qInstance);
return (new AssistantApi(runtime, session));
}
@@ -20,10 +20,12 @@ package com.kingsrook.qbits.assistant.engine;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.provider.CacheHint;
@@ -32,9 +34,9 @@ import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.provider.SystemBlock;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolDefinition;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKind;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -51,12 +53,20 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
*******************************************************************************/
public class ContextBuilder
{
public static final Set<AiToolKind> PHASE1_KINDS = Set.of(AiToolKind.READ, AiToolKind.NAVIGATION);
public static final int MAX_CONTEXT_CHARS = 512;
public static final int MAX_SELECTED_IDS = 100;
///////////////////////////////////////////////////////////////////////
// tools that read no records stay on the core kernel: navigate only //
// builds an /app link (the page enforces permission when it opens) //
// and search_help reads help text from metadata. Every tool that //
// reads or writes data is a UI API tool (UiApiToolbox). //
///////////////////////////////////////////////////////////////////////
public static final Set<String> KERNEL_TOOLS = Set.of("navigate", "search_help");
public static final int MAX_CONTEXT_CHARS = 512;
public static final int MAX_SELECTED_IDS = 100;
private final AssistantQBitConfig config;
private final AiToolKernel kernel;
private final UiApiToolbox toolbox;
private volatile String appDigest;
@@ -64,56 +74,83 @@ public class ContextBuilder
/*******************************************************************************
**
*******************************************************************************/
public ContextBuilder(AssistantQBitConfig config, AiToolKernel kernel)
public ContextBuilder(AssistantQBitConfig config, AiToolKernel kernel, UiApiToolbox toolbox)
{
this.config = config;
this.kernel = kernel;
this.toolbox = toolbox;
}
/*******************************************************************************
** The phase-1 tools (read + navigation), in the kernel's stable order.
** The tools with the default policy.
*******************************************************************************/
public List<ModelToolDefinition> tools()
{
List<ModelToolDefinition> tools = new ArrayList<>();
return (tools(PolicyService.ToolPolicy.defaults()));
}
/*******************************************************************************
** The tools a policy allows, sorted by name (a stable cache prefix): the UI
** API data tools (write tools only when some table allows writes) plus the
** kernel's navigate and search_help.
*******************************************************************************/
public List<ModelToolDefinition> tools(PolicyService.ToolPolicy policy)
{
Map<String, ModelToolDefinition> byName = new TreeMap<>();
for(ModelToolDefinition definition : toolbox.definitions(policy, toolbox.anyWritableTable()))
{
byName.put(definition.getName(), definition.withStrict(isStrict(definition.getName(), true)));
}
for(AiToolDefinition definition : kernel.getToolDefinitions())
{
if(definition.getKind() != null && PHASE1_KINDS.contains(definition.getKind()))
if(KERNEL_TOOLS.contains(definition.getName()) && (policy == null || policy.allows(definition.getName())))
{
tools.add(new ModelToolDefinition()
byName.put(definition.getName(), new ModelToolDefinition()
.withName(definition.getName())
.withDescription(definition.getDescription())
.withInputSchema(definition.getInputSchema())
.withStrict(isStrict(definition)));
.withStrict(isStrict(definition.getName(), !Boolean.FALSE.equals(definition.getStrict()))));
}
}
return (tools);
return (new ArrayList<>(byName.values()));
}
/*******************************************************************************
** Strict only when the host names the tool in strictToolNames (and the
** kernel does not opt it out): the provider compiles every strict schema of
** a request into one grammar, which it refuses when it grows too large.
** Strict only when the host names the tool in strictToolNames: the provider
** compiles every strict schema of a request into one grammar, which it
** refuses when it grows too large.
*******************************************************************************/
boolean isStrict(AiToolDefinition definition)
boolean isStrict(String toolName, boolean strictCapable)
{
return (!Boolean.FALSE.equals(definition.getStrict()) && config.getStrictToolNames() != null && config.getStrictToolNames().contains(definition.getName()));
return (strictCapable && config.getStrictToolNames() != null && config.getStrictToolNames().contains(toolName));
}
/*******************************************************************************
** Names of the phase-1 tools.
** Names of the tools a policy allows.
*******************************************************************************/
public Set<String> toolNames(PolicyService.ToolPolicy policy)
{
Set<String> names = new HashSet<>();
tools(policy).forEach(t -> names.add(t.getName()));
return (names);
}
/*******************************************************************************
** Names of the tools with the default policy.
*******************************************************************************/
public Set<String> toolNames()
{
Set<String> names = new java.util.HashSet<>();
tools().forEach(t -> names.add(t.getName()));
return (names);
return (toolNames(PolicyService.ToolPolicy.defaults()));
}
@@ -138,12 +175,22 @@ public class ContextBuilder
** Build the first request of a turn.
*******************************************************************************/
public ModelRequest build(QSession session, String model, String effort, List<AssistantMessage> history, String userText, Map<String, Object> pageContext)
{
return (build(session, model, effort, history, userText, pageContext, PolicyService.ToolPolicy.defaults()));
}
/*******************************************************************************
** The request, with the tools the policy allows.
*******************************************************************************/
public ModelRequest build(QSession session, String model, String effort, List<AssistantMessage> history, String userText, Map<String, Object> pageContext, PolicyService.ToolPolicy toolPolicy)
{
ModelRequest request = new ModelRequest()
.withModel(model)
.withEffort(effort)
.withMaxTokens(config.getModels().getMainMaxOutputTokens())
.withTools(tools());
.withTools(tools(toolPolicy));
request.getSystem().add(new SystemBlock(SystemPrompt.staticBlock(appDigest()), CacheHint.LONG));
request.getSystem().add(new SystemBlock(userBlock(session), CacheHint.SHORT));
@@ -21,6 +21,8 @@ import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.LinkedHashSet;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantBudgets;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
@@ -53,8 +55,79 @@ public class PolicyService
/*******************************************************************************
** The resolved policy values.
*******************************************************************************/
public record EffectivePolicy(boolean killSwitch, String killSwitchReason, String mainModel, String mainEffort, AssistantBudgets budgets)
public record EffectivePolicy(boolean killSwitch, String killSwitchReason, String mainModel, String mainEffort, AssistantBudgets budgets, ToolPolicy tools)
{
/*******************************************************************************
** Without tool settings (the defaults).
*******************************************************************************/
public EffectivePolicy(boolean killSwitch, String killSwitchReason, String mainModel, String mainEffort, AssistantBudgets budgets)
{
this(killSwitch, killSwitchReason, mainModel, mainEffort, budgets, ToolPolicy.defaults());
}
}
/*******************************************************************************
** The policy row's tool settings, with their defaults.
**
** @param writesRequireConfirmation every write waits for the user's
** confirmation card (default true)
** @param allowedTools tool names allowed; empty allows every tool
** @param maxRowsPerCall most rows one tool call returns to the model
** @param redactPii redact secrets and personal identifiers from tool
** results (default true)
*******************************************************************************/
public record ToolPolicy(boolean writesRequireConfirmation, Set<String> allowedTools, int maxRowsPerCall, boolean redactPii)
{
public static final int DEFAULT_MAX_ROWS_PER_CALL = 50;
public static final int MAX_ROWS_PER_CALL_CEILING = 200;
/*******************************************************************************
** The defaults.
*******************************************************************************/
public static ToolPolicy defaults()
{
return (new ToolPolicy(true, Set.of(), DEFAULT_MAX_ROWS_PER_CALL, true));
}
/*******************************************************************************
** Whether a tool is allowed by name.
*******************************************************************************/
public boolean allows(String toolName)
{
return (allowedTools == null || allowedTools.isEmpty() || allowedTools.contains(toolName));
}
/*******************************************************************************
** From a policy row (null: the defaults).
*******************************************************************************/
public static ToolPolicy of(AssistantPolicy row)
{
if(row == null)
{
return (defaults());
}
Set<String> allowed = new LinkedHashSet<>();
if(StringUtils.hasContent(row.getAllowedTools()))
{
for(String name : row.getAllowedTools().split("[,\\s]+"))
{
if(StringUtils.hasContent(name))
{
allowed.add(name.trim());
}
}
}
int maxRows = row.getMaxRowsPerCall() == null || row.getMaxRowsPerCall() < 1 ? DEFAULT_MAX_ROWS_PER_CALL : Math.min(MAX_ROWS_PER_CALL_CEILING, row.getMaxRowsPerCall());
return (new ToolPolicy(!Boolean.FALSE.equals(row.getWritesRequireConfirmation()), allowed, maxRows, !Boolean.FALSE.equals(row.getRedactPii())));
}
}
@@ -108,7 +181,7 @@ public class PolicyService
String mainModel = row != null && StringUtils.hasContent(row.getMainModel()) ? row.getMainModel() : config.getModels().getMainModel();
String mainEffort = row != null && StringUtils.hasContent(row.getMainEffort()) ? row.getMainEffort() : config.getModels().getMainEffort();
return (new EffectivePolicy(row != null && Boolean.TRUE.equals(row.getKillSwitch()), row == null ? null : row.getKillSwitchReason(), mainModel, mainEffort, budgets));
return (new EffectivePolicy(row != null && Boolean.TRUE.equals(row.getKillSwitch()), row == null ? null : row.getKillSwitchReason(), mainModel, mainEffort, budgets, ToolPolicy.of(row)));
}
@@ -23,6 +23,7 @@ import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -35,6 +36,7 @@ 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.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
@@ -49,10 +51,12 @@ 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.tools.UiApiToolbox;
import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolCall;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
@@ -89,6 +93,8 @@ public class TurnEngine implements Runnable
private static final QLogger LOG = QLogger.getLogger(TurnEngine.class);
private static final int MAX_INPUT_JSON_CHARS = 4000;
private static final AiRedactor INPUT_REDACTOR = new AiRedactor(List.of());
private static final int MAX_RESULT_JSON_CHARS = 64_000;
private static final int TEXT_FLUSH_CHARS = 48;
private static final long TEXT_FLUSH_MILLIS = 120;
@@ -206,7 +212,7 @@ public class TurnEngine implements Runnable
}
}
ModelRequest request = runtime.getContextBuilder().build(session, model, policy.mainEffort(), history, userText, pageContext);
ModelRequest request = runtime.getContextBuilder().build(session, model, policy.mainEffort(), history, userText, pageContext, policy.tools());
Integer maxPerRequest = active.row() == null ? null : active.row().getMaxOutputTokensPerRequest();
if(maxPerRequest != null && (request.getMaxTokens() == null || request.getMaxTokens() > maxPerRequest))
{
@@ -216,7 +222,7 @@ public class TurnEngine implements Runnable
{
request.setParallelToolCalls(false);
}
Set<String> allowedTools = runtime.getContextBuilder().toolNames();
Set<String> allowedTools = runtime.getContextBuilder().toolNames(policy.tools());
boolean toolLimitReached = false;
while(true)
@@ -588,6 +594,7 @@ public class TurnEngine implements Runnable
int allowed = Math.max(0, config.getMaxToolCallsPerTurn() - toolCallCount);
boolean limitReached = false;
AiToolResult[] results = new AiToolResult[toolUses.size()];
UiApiToolbox.Outcome[] outcomes = new UiApiToolbox.Outcome[toolUses.size()];
List<Future<?>> futures = new ArrayList<>();
Semaphore permits = new Semaphore(Math.max(1, config.getMaxParallelToolCalls()));
@@ -620,7 +627,29 @@ public class TurnEngine implements Runnable
{
if(!running.isCancelled())
{
results[index] = runtime.getKernel().execute(session, call);
if(ContextBuilder.KERNEL_TOOLS.contains(call.getName()))
{
///////////////////////////////////////////////////
// navigate / search_help: no records are read //
///////////////////////////////////////////////////
results[index] = runtime.getKernel().execute(session, call);
}
else
{
///////////////////////////////////////////////////////////
// data tools: a /qqq/v1 call with the user's session //
///////////////////////////////////////////////////////////
QContext.init(runtime.getQInstance(), session);
try
{
outcomes[index] = runtime.getToolbox().execute(toolContext(), call.getId(), call.getName(), call.getInput());
results[index] = outcomes[index].getResult();
}
finally
{
QContext.clear();
}
}
}
}
finally
@@ -676,7 +705,7 @@ public class TurnEngine implements Runnable
.withSummary(running.isCancelled() ? "Not run (stopped)" : "Timed out")
.withModelContent(running.isCancelled() ? "Not run: the user stopped the answer." : "The tool did not finish in time.");
}
recordToolResult(use, result);
recordToolResult(use, result, outcomes[i]);
String content = result.getModelContent() == null ? "" : result.getModelContent();
modelResults.add(ModelPart.toolResult(use.getToolUseId(), content, result.getOutcome() != null && ERROR_OUTCOMES.contains(result.getOutcome())));
@@ -690,7 +719,7 @@ public class TurnEngine implements Runnable
/*******************************************************************************
** Audit row, events and message parts of one tool result.
*******************************************************************************/
private void recordToolResult(ModelPart use, AiToolResult result)
private void recordToolResult(ModelPart use, AiToolResult result, UiApiToolbox.Outcome extras)
{
String outcome = result.getOutcome() == null ? AiToolOutcome.ERROR.name() : result.getOutcome().name();
String summary = StringUtils.hasContent(result.getSummary()) ? result.getSummary() : ToolLabels.done(use.getToolName());
@@ -733,7 +762,7 @@ public class TurnEngine implements Runnable
.withUserId(userId)
.withToolCallId(use.getToolUseId())
.withToolName(use.getToolName())
.withInputJson(truncate(JsonUtils.toJson(use.getInput()), MAX_INPUT_JSON_CHARS))
.withInputJson(truncate(redactInput(use.getInput()), MAX_INPUT_JSON_CHARS))
.withOutcome(outcome)
.withPermissionCheck(result.getPermissionCheck())
.withTableName(result.getTableName())
@@ -743,7 +772,11 @@ public class TurnEngine implements Runnable
.withDurationMs(result.getDurationMs())
.withResultSummary(summary)
.withResultJson(truncate(result.getModelContent(), MAX_RESULT_JSON_CHARS))
.withRedactionsJson(result.getRedactions() == null || result.getRedactions().isEmpty() ? null : JsonUtils.toJson(result.getRedactions())));
.withRedactionsJson(result.getRedactions() == null || result.getRedactions().isEmpty() ? null : JsonUtils.toJson(result.getRedactions()))
.withUiApiOperation(extras == null ? null : extras.getOperation())
.withHttpStatus(extras == null ? null : extras.getHttpStatus())
.withAuditHref(extras == null ? null : extras.getAuditHref())
.withPendingActionId(extras == null ? null : extras.getPendingActionId()));
}
catch(QException e)
{
@@ -753,6 +786,63 @@ public class TurnEngine implements Runnable
/*******************************************************************************
** Where a data tool runs: the user's own session (by its UUID, for the UI
** API), the gateway, the policy's tool settings, and the pending-action
** store for writes that need confirmation.
*******************************************************************************/
private UiApiToolbox.Context toolContext()
{
return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), policy.tools(), this::proposeWrite));
}
/*******************************************************************************
** The browser's timezone the session carries, if any.
*******************************************************************************/
private String userTimezone()
{
return (session.getValue(QSession.VALUE_KEY_USER_TIMEZONE));
}
/*******************************************************************************
** Record a write the user must confirm; nothing runs until they do.
*******************************************************************************/
private Integer proposeWrite(UiApiToolbox.ProposedWrite write) throws QException
{
Instant now = runtime.getClock().instant();
AssistantPendingAction action = store.insert(new AssistantPendingAction()
.withConversationId(conversation.getId())
.withTurnId(turn.getId())
.withUserId(userId)
.withToolCallId(write.toolCallId())
.withToolName(write.toolName())
.withUiApiOperation(write.operation())
.withTableName(write.tableName())
.withRecordId(write.recordId())
.withParamsJson(JsonUtils.toJson(write.values()))
.withSummary(write.summary())
.withStatus(AssistantPendingAction.STATUS_PENDING)
.withExpiresAt(now.plusSeconds(config.getPendingActionTtlSeconds() == null ? 900 : config.getPendingActionTtlSeconds())));
return (action.getId());
}
/*******************************************************************************
** The tool input as JSON with secrets redacted (for the tool-call log).
*******************************************************************************/
private String redactInput(Map<String, Object> input)
{
String json = JsonUtils.toJson(input);
return (INPUT_REDACTOR.redact(json, new HashSet<>()));
}
/*******************************************************************************
** Only same-origin application links are passed on.
*******************************************************************************/
@@ -0,0 +1,132 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.metadata;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
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.delete.DeleteInput;
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.statusmessages.BadInputStatusMessage;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** `assistant_data_access` rows: the object must exist (a table or process of
** this instance) and the mode must be NONE, READ or READ_WRITE; READ_WRITE
** on a process means "may run". After any change the runtime's cached modes
** are dropped, so the next tool call uses the new setting.
*******************************************************************************/
public class AssistantDataAccessCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
** Check each row (merged with its old values on update).
*******************************************************************************/
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
{
QInstance qInstance = QContext.getQInstance();
Optional<Map<Serializable, QRecord>> old = oldRecordListToMap("id", oldRecordList);
for(QRecord record : records)
{
QRecord merged = new QRecord();
old.map(m -> m.get(record.getValue("id"))).ifPresent(o -> merged.setValues(new LinkedHashMap<>(o.getValues())));
record.getValues().forEach(merged::setValue);
String type = merged.getValueString("objectType");
String name = merged.getValueString("objectName");
if(!StringUtils.hasContent(type))
{
record.setValue("objectType", AssistantDataAccess.TYPE_TABLE);
type = AssistantDataAccess.TYPE_TABLE;
}
if(!AssistantDataAccess.TYPE_TABLE.equals(type) && !AssistantDataAccess.TYPE_PROCESS.equals(type))
{
record.addError(new BadInputStatusMessage("Type must be TABLE or PROCESS."));
}
else if(!StringUtils.hasContent(name))
{
record.addError(new BadInputStatusMessage("Name the table or process."));
}
else if(AssistantDataAccess.TYPE_TABLE.equals(type) ? qInstance.getTable(name.trim()) == null : qInstance.getProcess(name.trim()) == null)
{
record.addError(new BadInputStatusMessage("There is no " + type.toLowerCase() + " named " + name + " in this application."));
}
else if(!name.equals(name.trim()))
{
record.setValue("objectName", name.trim());
}
if(DataAccessMode.parse(merged.getValueString("mode")) == null)
{
record.addError(new BadInputStatusMessage("Assistant access must be NONE, READ or READ_WRITE."));
}
}
return (records);
}
/*******************************************************************************
** Drop the cached modes after a save.
*******************************************************************************/
@Override
public List<QRecord> postInsertOrUpdate(AbstractActionInput input, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
{
invalidate();
return (records);
}
/*******************************************************************************
** Drop the cached modes after a delete.
*******************************************************************************/
@Override
public List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
{
invalidate();
return (records);
}
/*******************************************************************************
**
*******************************************************************************/
private static void invalidate()
{
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
if(runtime != null)
{
runtime.getDataAccessService().invalidate();
}
}
}
@@ -20,11 +20,13 @@ 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.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
import com.kingsrook.qbits.assistant.home.AssistantHome;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
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.AssistantToolCall;
@@ -97,7 +99,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(AssistantProvider.TABLE_NAME, AssistantConversation.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantPolicy.TABLE_NAME);
public static final List<String> NAV_TABLES = List.of(AssistantProvider.TABLE_NAME, AssistantDataAccess.TABLE_NAME, AssistantConversation.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantPolicy.TABLE_NAME);
private final AssistantQBitConfig config;
@@ -180,6 +182,14 @@ public class AssistantMetaDataBuilder
tables.add(readOnly(owned(base(AssistantUsage.TABLE_NAME, "Assistant Model Call", "receipt_long", AssistantUsage.class), false))
.withRecordLabelFormat("%s #%s").withRecordLabelFields("purpose", "id"));
tables.add(dataAccess(base(AssistantDataAccess.TABLE_NAME, "Assistant Data Access", "admin_panel_settings", AssistantDataAccess.class))
.withUniqueKey(new UniqueKey("objectType", "objectName"))
.withRecordLabelFormat("%s %s").withRecordLabelFields("objectType", "objectName"));
tables.add(readOnly(owned(base(AssistantPendingAction.TABLE_NAME, "Assistant Pending Action", "pending_actions", AssistantPendingAction.class), false))
.withRecordLabelFormat("%s #%s").withRecordLabelFields("toolName", "id"));
text(tables.get(tables.size() - 1), "paramsJson");
tables.forEach(AssistantRecordScreens::apply);
return (tables);
}
@@ -204,6 +214,22 @@ public class AssistantMetaDataBuilder
/*******************************************************************************
** The data access table: a change drops the runtime's cached modes.
*******************************************************************************/
private static QTableMetaData dataAccess(QTableMetaData table)
{
QCodeReference customizer = new QCodeReference(AssistantDataAccessCustomizer.class);
return (table
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, customizer)
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, customizer)
.withCustomizer(TableCustomizers.POST_INSERT_RECORD, customizer)
.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, customizer)
.withCustomizer(TableCustomizers.POST_DELETE_RECORD, customizer));
}
/*******************************************************************************
** Possible value source over conversations (for the conversationId fields).
*******************************************************************************/
@@ -23,11 +23,14 @@ import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantModelRouting;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
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.AssistantToolCall;
@@ -75,6 +78,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
public final class AssistantRecordScreens
{
public static final String EFFORT_PVS_NAME = "assistant_effort";
public static final String OBJECT_TYPE_PVS_NAME = "assistant_object_type";
public static final String DATA_ACCESS_MODE_PVS_NAME = "assistant_data_access_mode";
public static final String DETAILS_SECTION = "details";
static final String JSON = "json";
static final String MICROS_HELP = "In micro-dollars: 1,000,000 is $1.00.";
@@ -116,7 +121,24 @@ public final class AssistantRecordScreens
new QPossibleValueSource().withName(EFFORT_PVS_NAME).withType(QPossibleValueSourceType.ENUM)
.withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY).withEnumValues(efforts),
QPossibleValueSource.newForTable(AssistantTurn.TABLE_NAME),
QPossibleValueSource.newForTable(AssistantMessage.TABLE_NAME)));
QPossibleValueSource.newForTable(AssistantMessage.TABLE_NAME),
QPossibleValueSource.newForTable(AssistantPendingAction.TABLE_NAME),
enumSource(OBJECT_TYPE_PVS_NAME, List.of(new QPossibleValue<>(AssistantDataAccess.TYPE_TABLE, "Table"), new QPossibleValue<>(AssistantDataAccess.TYPE_PROCESS, "Process"))),
enumSource(DATA_ACCESS_MODE_PVS_NAME, List.of(
new QPossibleValue<>(DataAccessMode.NONE.name(), DataAccessMode.NONE.getLabel()),
new QPossibleValue<>(DataAccessMode.READ.name(), DataAccessMode.READ.getLabel()),
new QPossibleValue<>(DataAccessMode.READ_WRITE.name(), DataAccessMode.READ_WRITE.getLabel())))));
}
/*******************************************************************************
** An enum possible-value source shown by label.
*******************************************************************************/
private static QPossibleValueSource enumSource(String name, List<QPossibleValue<?>> values)
{
return (new QPossibleValueSource().withName(name).withType(QPossibleValueSourceType.ENUM)
.withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY).withEnumValues(values));
}
@@ -139,6 +161,8 @@ public final class AssistantRecordScreens
case AssistantProvider.TABLE_NAME -> provider(table);
case AssistantModelPrice.TABLE_NAME -> price(table);
case AssistantUsage.TABLE_NAME -> usage(table);
case AssistantDataAccess.TABLE_NAME -> dataAccess(table);
case AssistantPendingAction.TABLE_NAME -> pendingAction(table);
default ->
{
// not an assistant table
@@ -162,7 +186,8 @@ public final class AssistantRecordScreens
childList(qInstance, conversation, AssistantMessage.TABLE_NAME, "Messages", "chat", List.of("seq", "role", "status", "model", "createDate"));
childList(qInstance, conversation, AssistantTurn.TABLE_NAME, "Turns", "sync_alt", List.of("status", "model", "toolCallCount", "costMicros", "startedAt"));
childList(qInstance, conversation, AssistantToolCall.TABLE_NAME, "Tool Calls", "build", List.of("toolName", "outcome", "rowCount", "durationMs", "createDate"));
childList(qInstance, conversation, AssistantToolCall.TABLE_NAME, "Tool Calls", "build", List.of("toolName", "uiApiOperation", "httpStatus", "outcome", "tableName", "rowCount", "createDate"));
childList(qInstance, conversation, AssistantPendingAction.TABLE_NAME, "Pending Actions", "pending_actions", List.of("toolName", "tableName", "recordId", "status", "httpStatus", "createDate"));
childList(qInstance, conversation, AssistantFeedback.TABLE_NAME, "Feedback", "thumbs_up_down", List.of("rating", "reason", "createDate"));
childList(qInstance, conversation, AssistantUsage.TABLE_NAME, "Model Calls", "receipt_long", USAGE_LIST_FIELDS);
@@ -371,6 +396,41 @@ public final class AssistantRecordScreens
/*******************************************************************************
** Data access: what, the mode, notes.
*******************************************************************************/
private static void dataAccess(QTableMetaData table)
{
table.getField("objectType").withLabel("Type").withDefaultValue(AssistantDataAccess.TYPE_TABLE);
table.getField("objectName").withLabel("Table or process name")
.withHelpContent(new QHelpContent("The table or process name, e.g. blog_post. Tables without a row use the default: NONE for sensitive tables, READ for the others. Processes default to NONE."));
table.getField("mode").withLabel("Assistant access").withDefaultValue(DataAccessMode.READ.name())
.withHelpContent(new QHelpContent("NONE: the assistant never sees it. READ: the read tools may use it. READ and write: the create, update and delete tools may too. The assistant always acts with the user's own permissions and record locks; this can only narrow them. A sensitive table (AI exposure NONE) stays NONE."));
sections(table,
section("access", "Data Access", "admin_panel_settings", Tier.T1, "id", "objectType", "objectName", "mode"),
section("notes", "Notes", "notes", Tier.T2, "notes"));
}
/*******************************************************************************
** Pending action: the proposed write, its decision and result.
*******************************************************************************/
private static void pendingAction(QTableMetaData table)
{
json(table, "paramsJson");
link(table, "turnId", AssistantTurn.TABLE_NAME, "Turn");
table.getField("paramsJson").withLabel("Values");
table.getField("uiApiOperation").withLabel("UI API operation");
table.getField("httpStatus").withLabel("HTTP status");
sections(table,
section("action", "Pending Action", "pending_actions", Tier.T1, "id", "summary", "status", "toolName", "tableName", "recordId", "userId", "conversationId", "turnId"),
section("decision", "Decision and result", "fact_check", Tier.T2, "expiresAt", "decidedAt", "uiApiOperation", "httpStatus", "resultSummary"),
section("payload", "Values", "data_object", Tier.T3, "paramsJson", "toolCallId"));
}
/*******************************************************************************
**
*******************************************************************************/
@@ -384,10 +444,14 @@ public final class AssistantRecordScreens
table.getField("inputJson").withLabel("Input");
table.getField("resultJson").withLabel("Result");
table.getField("redactionsJson").withLabel("Redactions");
table.getField("uiApiOperation").withLabel("UI API operation");
table.getField("httpStatus").withLabel("HTTP status");
table.getField("auditHref").withLabel("Audit");
link(table, "pendingActionId", AssistantPendingAction.TABLE_NAME, "Pending action");
sections(table,
section("call", "Tool Call", "build", Tier.T1, "id", "toolName", "outcome", "conversationId", "turnId", "toolCallId", "userId"),
section("target", "Target and permission", "policy", Tier.T2, "tableName", "processName", "widgetName", "permissionCheck"),
section("result", "Result", "fact_check", Tier.T2, "resultSummary", "rowCount", "durationMs"),
section("target", "Target and permission", "policy", Tier.T2, "tableName", "processName", "widgetName", "uiApiOperation", "httpStatus", "permissionCheck"),
section("result", "Result", "fact_check", Tier.T2, "resultSummary", "rowCount", "durationMs", "pendingActionId", "auditHref"),
section("payloads", "Payloads", "data_object", Tier.T3, "inputJson", "resultJson", "redactionsJson"));
}
@@ -422,6 +486,10 @@ public final class AssistantRecordScreens
help(table, "mainModel", "The model id answers come from; empty uses the model configured for the application.");
help(table, "killSwitch", "On: the assistant takes no new requests from anyone.");
help(table, "killSwitchReason", "Shown to users while the kill switch is on.");
help(table, "writesRequireConfirmation", "On (or empty): every create, update or delete the assistant proposes waits for the user to confirm it in the panel. Off: writes run at once, still with the user's own permissions.");
help(table, "allowedTools", "Comma-separated tool names the assistant may use (e.g. list_tables, query_records, count_records). Empty allows every tool.");
help(table, "maxRowsPerCall", "Most rows one tool call returns to the model. Empty: 50; at most 200.");
help(table, "redactPii", "On (or empty): API keys, tokens, private keys, card numbers and social security numbers are redacted from tool results before the model sees them.");
for(String scope : List.of("userDaily", "instanceMonthly"))
{
String who = scope.equals("userDaily") ? "one user in a day" : "the whole instance in a month";
@@ -435,6 +503,7 @@ public final class AssistantRecordScreens
sections(table,
section("policy", "Policy", "policy", Tier.T1, "id", "name", "killSwitch", "killSwitchReason"),
section("model", "Model", "smart_toy", Tier.T2, "mainModel", "mainEffort"),
section("tools", "Tools and data", "build", Tier.T2, "writesRequireConfirmation", "allowedTools", "maxRowsPerCall", "redactPii"),
section("userDaily", "Per user, per day", "person", Tier.T2, "userDailyTokensSoft", "userDailyTokensHard", "userDailyCostMicrosSoft", "userDailyCostMicrosHard"),
section("instanceMonthly", "Whole instance, per month", "domain", Tier.T2, "instanceMonthlyTokensSoft", "instanceMonthlyTokensHard", "instanceMonthlyCostMicrosSoft", "instanceMonthlyCostMicrosHard"));
}
@@ -0,0 +1,288 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
/*******************************************************************************
** The assistant's data access for one table or process: NONE (never), READ (read tools) or READ_WRITE (read and write tools). The assistant still acts with the user's own session, so this only ever narrows what the user may do. Tables and processes without a row use the default (NONE for sensitive tables, READ for other tables, NONE for processes).
*******************************************************************************/
public class AssistantDataAccess extends QRecordEntity
{
public static final String TABLE_NAME = "assistant_data_access";
public static final String TYPE_TABLE = "TABLE";
public static final String TYPE_PROCESS = "PROCESS";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "assistant_object_type")
private String objectType;
@QField(isRequired = true, maxLength = 128)
private String objectName;
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "assistant_data_access_mode")
private String mode;
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
private String notes;
@QField(isEditable = false)
private Instant createDate;
@QField(isEditable = false)
private Instant modifyDate;
/*******************************************************************************
** Default constructor.
*******************************************************************************/
public AssistantDataAccess()
{
}
/*******************************************************************************
** Constructor that populates from a QRecord.
*******************************************************************************/
public AssistantDataAccess(QRecord record) throws QException
{
populateFromQRecord(record);
}
/*******************************************************************************
** Getter for id
*******************************************************************************/
public Integer getId()
{
return (this.id);
}
/*******************************************************************************
** Setter for id
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Fluent setter for id
*******************************************************************************/
public AssistantDataAccess withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Getter for objectType
*******************************************************************************/
public String getObjectType()
{
return (this.objectType);
}
/*******************************************************************************
** Setter for objectType
*******************************************************************************/
public void setObjectType(String objectType)
{
this.objectType = objectType;
}
/*******************************************************************************
** Fluent setter for objectType
*******************************************************************************/
public AssistantDataAccess withObjectType(String objectType)
{
this.objectType = objectType;
return (this);
}
/*******************************************************************************
** Getter for objectName
*******************************************************************************/
public String getObjectName()
{
return (this.objectName);
}
/*******************************************************************************
** Setter for objectName
*******************************************************************************/
public void setObjectName(String objectName)
{
this.objectName = objectName;
}
/*******************************************************************************
** Fluent setter for objectName
*******************************************************************************/
public AssistantDataAccess withObjectName(String objectName)
{
this.objectName = objectName;
return (this);
}
/*******************************************************************************
** Getter for mode
*******************************************************************************/
public String getMode()
{
return (this.mode);
}
/*******************************************************************************
** Setter for mode
*******************************************************************************/
public void setMode(String mode)
{
this.mode = mode;
}
/*******************************************************************************
** Fluent setter for mode
*******************************************************************************/
public AssistantDataAccess withMode(String mode)
{
this.mode = mode;
return (this);
}
/*******************************************************************************
** Getter for notes
*******************************************************************************/
public String getNotes()
{
return (this.notes);
}
/*******************************************************************************
** Setter for notes
*******************************************************************************/
public void setNotes(String notes)
{
this.notes = notes;
}
/*******************************************************************************
** Fluent setter for notes
*******************************************************************************/
public AssistantDataAccess withNotes(String notes)
{
this.notes = notes;
return (this);
}
/*******************************************************************************
** Getter for createDate
*******************************************************************************/
public Instant getCreateDate()
{
return (this.createDate);
}
/*******************************************************************************
** Setter for createDate
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Fluent setter for createDate
*******************************************************************************/
public AssistantDataAccess withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Getter for modifyDate
*******************************************************************************/
public Instant getModifyDate()
{
return (this.modifyDate);
}
/*******************************************************************************
** Setter for modifyDate
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Fluent setter for modifyDate
*******************************************************************************/
public AssistantDataAccess withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
}
@@ -0,0 +1,654 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
/*******************************************************************************
** A write the assistant proposed and the user must confirm. Confirming runs the stored UI API operation with the confirming user's own session; nothing runs before that.
*******************************************************************************/
public class AssistantPendingAction extends QRecordEntity
{
public static final String TABLE_NAME = "assistant_pending_action";
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_EXECUTED = "EXECUTED";
public static final String STATUS_FAILED = "FAILED";
public static final String STATUS_REJECTED = "REJECTED";
public static final String STATUS_EXPIRED = "EXPIRED";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isEditable = false, possibleValueSourceName = AssistantConversation.TABLE_NAME)
private Integer conversationId;
@QField(isEditable = false)
private Integer turnId;
@QField(isEditable = false, maxLength = 255)
private String userId;
@QField(isEditable = false, maxLength = 128)
private String toolCallId;
@QField(isEditable = false, maxLength = 64)
private String toolName;
@QField(isEditable = false, maxLength = 255)
private String uiApiOperation;
@QField(isEditable = false, maxLength = 128)
private String tableName;
@QField(isEditable = false, maxLength = 128)
private String recordId;
@QField(isEditable = false)
private String paramsJson;
@QField(isEditable = false, maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
private String summary;
@QField(isEditable = false, maxLength = 16)
private String status;
@QField(isEditable = false)
private Integer httpStatus;
@QField(isEditable = false, maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
private String resultSummary;
@QField(isEditable = false)
private Instant expiresAt;
@QField(isEditable = false)
private Instant decidedAt;
@QField(isEditable = false)
private Instant createDate;
@QField(isEditable = false)
private Instant modifyDate;
/*******************************************************************************
** Default constructor.
*******************************************************************************/
public AssistantPendingAction()
{
}
/*******************************************************************************
** Constructor that populates from a QRecord.
*******************************************************************************/
public AssistantPendingAction(QRecord record) throws QException
{
populateFromQRecord(record);
}
/*******************************************************************************
** Getter for id
*******************************************************************************/
public Integer getId()
{
return (this.id);
}
/*******************************************************************************
** Setter for id
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Fluent setter for id
*******************************************************************************/
public AssistantPendingAction withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Getter for conversationId
*******************************************************************************/
public Integer getConversationId()
{
return (this.conversationId);
}
/*******************************************************************************
** Setter for conversationId
*******************************************************************************/
public void setConversationId(Integer conversationId)
{
this.conversationId = conversationId;
}
/*******************************************************************************
** Fluent setter for conversationId
*******************************************************************************/
public AssistantPendingAction withConversationId(Integer conversationId)
{
this.conversationId = conversationId;
return (this);
}
/*******************************************************************************
** Getter for turnId
*******************************************************************************/
public Integer getTurnId()
{
return (this.turnId);
}
/*******************************************************************************
** Setter for turnId
*******************************************************************************/
public void setTurnId(Integer turnId)
{
this.turnId = turnId;
}
/*******************************************************************************
** Fluent setter for turnId
*******************************************************************************/
public AssistantPendingAction withTurnId(Integer turnId)
{
this.turnId = turnId;
return (this);
}
/*******************************************************************************
** Getter for userId
*******************************************************************************/
public String getUserId()
{
return (this.userId);
}
/*******************************************************************************
** Setter for userId
*******************************************************************************/
public void setUserId(String userId)
{
this.userId = userId;
}
/*******************************************************************************
** Fluent setter for userId
*******************************************************************************/
public AssistantPendingAction withUserId(String userId)
{
this.userId = userId;
return (this);
}
/*******************************************************************************
** Getter for toolCallId
*******************************************************************************/
public String getToolCallId()
{
return (this.toolCallId);
}
/*******************************************************************************
** Setter for toolCallId
*******************************************************************************/
public void setToolCallId(String toolCallId)
{
this.toolCallId = toolCallId;
}
/*******************************************************************************
** Fluent setter for toolCallId
*******************************************************************************/
public AssistantPendingAction withToolCallId(String toolCallId)
{
this.toolCallId = toolCallId;
return (this);
}
/*******************************************************************************
** Getter for toolName
*******************************************************************************/
public String getToolName()
{
return (this.toolName);
}
/*******************************************************************************
** Setter for toolName
*******************************************************************************/
public void setToolName(String toolName)
{
this.toolName = toolName;
}
/*******************************************************************************
** Fluent setter for toolName
*******************************************************************************/
public AssistantPendingAction withToolName(String toolName)
{
this.toolName = toolName;
return (this);
}
/*******************************************************************************
** Getter for uiApiOperation
*******************************************************************************/
public String getUiApiOperation()
{
return (this.uiApiOperation);
}
/*******************************************************************************
** Setter for uiApiOperation
*******************************************************************************/
public void setUiApiOperation(String uiApiOperation)
{
this.uiApiOperation = uiApiOperation;
}
/*******************************************************************************
** Fluent setter for uiApiOperation
*******************************************************************************/
public AssistantPendingAction withUiApiOperation(String uiApiOperation)
{
this.uiApiOperation = uiApiOperation;
return (this);
}
/*******************************************************************************
** Getter for tableName
*******************************************************************************/
public String getTableName()
{
return (this.tableName);
}
/*******************************************************************************
** Setter for tableName
*******************************************************************************/
public void setTableName(String tableName)
{
this.tableName = tableName;
}
/*******************************************************************************
** Fluent setter for tableName
*******************************************************************************/
public AssistantPendingAction withTableName(String tableName)
{
this.tableName = tableName;
return (this);
}
/*******************************************************************************
** Getter for recordId
*******************************************************************************/
public String getRecordId()
{
return (this.recordId);
}
/*******************************************************************************
** Setter for recordId
*******************************************************************************/
public void setRecordId(String recordId)
{
this.recordId = recordId;
}
/*******************************************************************************
** Fluent setter for recordId
*******************************************************************************/
public AssistantPendingAction withRecordId(String recordId)
{
this.recordId = recordId;
return (this);
}
/*******************************************************************************
** Getter for paramsJson
*******************************************************************************/
public String getParamsJson()
{
return (this.paramsJson);
}
/*******************************************************************************
** Setter for paramsJson
*******************************************************************************/
public void setParamsJson(String paramsJson)
{
this.paramsJson = paramsJson;
}
/*******************************************************************************
** Fluent setter for paramsJson
*******************************************************************************/
public AssistantPendingAction withParamsJson(String paramsJson)
{
this.paramsJson = paramsJson;
return (this);
}
/*******************************************************************************
** Getter for summary
*******************************************************************************/
public String getSummary()
{
return (this.summary);
}
/*******************************************************************************
** Setter for summary
*******************************************************************************/
public void setSummary(String summary)
{
this.summary = summary;
}
/*******************************************************************************
** Fluent setter for summary
*******************************************************************************/
public AssistantPendingAction withSummary(String summary)
{
this.summary = summary;
return (this);
}
/*******************************************************************************
** Getter for status
*******************************************************************************/
public String getStatus()
{
return (this.status);
}
/*******************************************************************************
** Setter for status
*******************************************************************************/
public void setStatus(String status)
{
this.status = status;
}
/*******************************************************************************
** Fluent setter for status
*******************************************************************************/
public AssistantPendingAction withStatus(String status)
{
this.status = status;
return (this);
}
/*******************************************************************************
** Getter for httpStatus
*******************************************************************************/
public Integer getHttpStatus()
{
return (this.httpStatus);
}
/*******************************************************************************
** Setter for httpStatus
*******************************************************************************/
public void setHttpStatus(Integer httpStatus)
{
this.httpStatus = httpStatus;
}
/*******************************************************************************
** Fluent setter for httpStatus
*******************************************************************************/
public AssistantPendingAction withHttpStatus(Integer httpStatus)
{
this.httpStatus = httpStatus;
return (this);
}
/*******************************************************************************
** Getter for resultSummary
*******************************************************************************/
public String getResultSummary()
{
return (this.resultSummary);
}
/*******************************************************************************
** Setter for resultSummary
*******************************************************************************/
public void setResultSummary(String resultSummary)
{
this.resultSummary = resultSummary;
}
/*******************************************************************************
** Fluent setter for resultSummary
*******************************************************************************/
public AssistantPendingAction withResultSummary(String resultSummary)
{
this.resultSummary = resultSummary;
return (this);
}
/*******************************************************************************
** Getter for expiresAt
*******************************************************************************/
public Instant getExpiresAt()
{
return (this.expiresAt);
}
/*******************************************************************************
** Setter for expiresAt
*******************************************************************************/
public void setExpiresAt(Instant expiresAt)
{
this.expiresAt = expiresAt;
}
/*******************************************************************************
** Fluent setter for expiresAt
*******************************************************************************/
public AssistantPendingAction withExpiresAt(Instant expiresAt)
{
this.expiresAt = expiresAt;
return (this);
}
/*******************************************************************************
** Getter for decidedAt
*******************************************************************************/
public Instant getDecidedAt()
{
return (this.decidedAt);
}
/*******************************************************************************
** Setter for decidedAt
*******************************************************************************/
public void setDecidedAt(Instant decidedAt)
{
this.decidedAt = decidedAt;
}
/*******************************************************************************
** Fluent setter for decidedAt
*******************************************************************************/
public AssistantPendingAction withDecidedAt(Instant decidedAt)
{
this.decidedAt = decidedAt;
return (this);
}
/*******************************************************************************
** Getter for createDate
*******************************************************************************/
public Instant getCreateDate()
{
return (this.createDate);
}
/*******************************************************************************
** Setter for createDate
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Fluent setter for createDate
*******************************************************************************/
public AssistantPendingAction withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Getter for modifyDate
*******************************************************************************/
public Instant getModifyDate()
{
return (this.modifyDate);
}
/*******************************************************************************
** Setter for modifyDate
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Fluent setter for modifyDate
*******************************************************************************/
public AssistantPendingAction withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
}
@@ -82,6 +82,18 @@ public class AssistantPolicy extends QRecordEntity
@QField()
private Long instanceMonthlyCostMicrosHard;
@QField()
private Boolean writesRequireConfirmation;
@QField(maxLength = 1000)
private String allowedTools;
@QField()
private Integer maxRowsPerCall;
@QField()
private Boolean redactPii;
@QField(isEditable = false)
private Instant createDate;
@@ -603,4 +615,124 @@ public class AssistantPolicy extends QRecordEntity
return (this);
}
/*******************************************************************************
** Getter for writesRequireConfirmation (whether every write waits for the user to confirm a card (default true))
*******************************************************************************/
public Boolean getWritesRequireConfirmation()
{
return (this.writesRequireConfirmation);
}
/*******************************************************************************
** Setter for writesRequireConfirmation
*******************************************************************************/
public void setWritesRequireConfirmation(Boolean writesRequireConfirmation)
{
this.writesRequireConfirmation = writesRequireConfirmation;
}
/*******************************************************************************
** Fluent setter for writesRequireConfirmation
*******************************************************************************/
public AssistantPolicy withWritesRequireConfirmation(Boolean writesRequireConfirmation)
{
this.writesRequireConfirmation = writesRequireConfirmation;
return (this);
}
/*******************************************************************************
** Getter for allowedTools (comma-separated tool names; empty allows all)
*******************************************************************************/
public String getAllowedTools()
{
return (this.allowedTools);
}
/*******************************************************************************
** Setter for allowedTools
*******************************************************************************/
public void setAllowedTools(String allowedTools)
{
this.allowedTools = allowedTools;
}
/*******************************************************************************
** Fluent setter for allowedTools
*******************************************************************************/
public AssistantPolicy withAllowedTools(String allowedTools)
{
this.allowedTools = allowedTools;
return (this);
}
/*******************************************************************************
** Getter for maxRowsPerCall (most rows one tool call may return to the model)
*******************************************************************************/
public Integer getMaxRowsPerCall()
{
return (this.maxRowsPerCall);
}
/*******************************************************************************
** Setter for maxRowsPerCall
*******************************************************************************/
public void setMaxRowsPerCall(Integer maxRowsPerCall)
{
this.maxRowsPerCall = maxRowsPerCall;
}
/*******************************************************************************
** Fluent setter for maxRowsPerCall
*******************************************************************************/
public AssistantPolicy withMaxRowsPerCall(Integer maxRowsPerCall)
{
this.maxRowsPerCall = maxRowsPerCall;
return (this);
}
/*******************************************************************************
** Getter for redactPii (whether secrets and personal identifiers are redacted from tool results (default true))
*******************************************************************************/
public Boolean getRedactPii()
{
return (this.redactPii);
}
/*******************************************************************************
** Setter for redactPii
*******************************************************************************/
public void setRedactPii(Boolean redactPii)
{
this.redactPii = redactPii;
}
/*******************************************************************************
** Fluent setter for redactPii
*******************************************************************************/
public AssistantPolicy withRedactPii(Boolean redactPii)
{
this.redactPii = redactPii;
return (this);
}
}
@@ -91,6 +91,18 @@ public class AssistantToolCall extends QRecordEntity
@QField(isEditable = false)
private String redactionsJson;
@QField(isEditable = false, maxLength = 255)
private String uiApiOperation;
@QField(isEditable = false)
private Integer httpStatus;
@QField(isEditable = false, maxLength = 500)
private String auditHref;
@QField(isEditable = false, possibleValueSourceName = AssistantPendingAction.TABLE_NAME)
private Integer pendingActionId;
@QField(isEditable = false)
private Instant createDate;
@@ -671,4 +683,124 @@ public class AssistantToolCall extends QRecordEntity
return (this);
}
/*******************************************************************************
** Getter for uiApiOperation (the /qqq/v1 operation the tool ran, e.g. POST /table/{tableName}/query)
*******************************************************************************/
public String getUiApiOperation()
{
return (this.uiApiOperation);
}
/*******************************************************************************
** Setter for uiApiOperation
*******************************************************************************/
public void setUiApiOperation(String uiApiOperation)
{
this.uiApiOperation = uiApiOperation;
}
/*******************************************************************************
** Fluent setter for uiApiOperation
*******************************************************************************/
public AssistantToolCall withUiApiOperation(String uiApiOperation)
{
this.uiApiOperation = uiApiOperation;
return (this);
}
/*******************************************************************************
** Getter for httpStatus (the operation's HTTP status, as the UI would have seen it)
*******************************************************************************/
public Integer getHttpStatus()
{
return (this.httpStatus);
}
/*******************************************************************************
** Setter for httpStatus
*******************************************************************************/
public void setHttpStatus(Integer httpStatus)
{
this.httpStatus = httpStatus;
}
/*******************************************************************************
** Fluent setter for httpStatus
*******************************************************************************/
public AssistantToolCall withHttpStatus(Integer httpStatus)
{
this.httpStatus = httpStatus;
return (this);
}
/*******************************************************************************
** Getter for auditHref (an in-app link to the audit entries of the records a write touched)
*******************************************************************************/
public String getAuditHref()
{
return (this.auditHref);
}
/*******************************************************************************
** Setter for auditHref
*******************************************************************************/
public void setAuditHref(String auditHref)
{
this.auditHref = auditHref;
}
/*******************************************************************************
** Fluent setter for auditHref
*******************************************************************************/
public AssistantToolCall withAuditHref(String auditHref)
{
this.auditHref = auditHref;
return (this);
}
/*******************************************************************************
** Getter for pendingActionId
*******************************************************************************/
public Integer getPendingActionId()
{
return (this.pendingActionId);
}
/*******************************************************************************
** Setter for pendingActionId
*******************************************************************************/
public void setPendingActionId(Integer pendingActionId)
{
this.pendingActionId = pendingActionId;
}
/*******************************************************************************
** Fluent setter for pendingActionId
*******************************************************************************/
public AssistantToolCall withPendingActionId(Integer pendingActionId)
{
this.pendingActionId = pendingActionId;
return (this);
}
}
@@ -29,6 +29,7 @@ import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
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.AssistantToolCall;
@@ -326,6 +327,16 @@ public class AssistantStore
/*******************************************************************************
** One of the user's own pending actions, or null.
*******************************************************************************/
public AssistantPendingAction getPendingAction(String userId, Integer id) throws QException
{
return (first(query(AssistantPendingAction.TABLE_NAME, owned(userId).withCriteria("id", QCriteriaOperator.EQUALS, id), AssistantPendingAction.class)));
}
/*******************************************************************************
** The user's turns still running (any replica).
*******************************************************************************/
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,230 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.uiapi;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.StringJoiner;
import java.util.function.Supplier;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.AbstractEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.CompleteOperation;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
/*******************************************************************************
** Stand-in for core's `UiApiInvoker` (QRun-IO/qqq#1128; REPLACE with an
** adapter over it once a QQQ release has it). Same contract:
**
** - only spec'd `/qqq/v1` operations (from {@link MiddlewareVersionV1}'s
** endpoint specs) can be called; anything else is refused before a request
** is made, so raw routes (including the assistant's own) are unreachable;
** - a loopback HTTP request to this server, carrying the user's own session
** cookie: the identical Javalin pipeline (session setup, permission checks,
** record locks, validation, audit, error mapping) the UI goes through. There
** is no service session and no way to elevate;
** - X-QQQ-Invoked-By marks the request for the access log.
**
** Why loopback rather than calling spec executors directly: buildInput, the
** version's preExecute, filters and error mapping hang off the Javalin
** pipeline; calling executors would skip some of them and drift over time.
*******************************************************************************/
public class LoopbackUiApiGateway implements UiApiGateway
{
public static final String BASE_PATH = "/qqq/v1";
public static final String INVOKED_BY_HEADER = "X-QQQ-Invoked-By";
public static final String TIMEZONE_HEADER = "X-QQQ-UserTimezone";
private static final Set<String> SPEC_OPERATIONS = specOperations();
private final Supplier<String> baseUrlSupplier;
private final boolean mockAuthentication;
private final HttpClient httpClient;
/*******************************************************************************
** @param baseUrlSupplier the server's own root URL, e.g. http://127.0.0.1:8000
** @param qInstance the instance (MOCK authentication reads the sessionId
** cookie rather than sessionUUID, so both are sent there)
*******************************************************************************/
public LoopbackUiApiGateway(Supplier<String> baseUrlSupplier, QInstance qInstance)
{
this.baseUrlSupplier = baseUrlSupplier;
this.mockAuthentication = qInstance != null && qInstance.getAuthentication() != null && QAuthenticationType.MOCK.equals(qInstance.getAuthentication().getType());
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).version(HttpClient.Version.HTTP_1_1).build();
}
/*******************************************************************************
** "METHOD /path/template" of every v1 endpoint spec.
*******************************************************************************/
static Set<String> specOperations()
{
Set<String> operations = new LinkedHashSet<>();
for(AbstractEndpointSpec<?, ?, ?> spec : new MiddlewareVersionV1().getEndpointSpecs())
{
CompleteOperation operation = spec.defineCompleteOperation();
operations.add(operation.getHttpMethod().name() + " " + operation.getPath());
}
return (Collections.unmodifiableSet(operations));
}
/*******************************************************************************
** The spec'd operations ("METHOD template").
*******************************************************************************/
public static Set<String> getSpecOperations()
{
return (SPEC_OPERATIONS);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean isInvocable(String method, String pathTemplate)
{
return (SPEC_OPERATIONS.contains(method + " " + pathTemplate));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call) throws QException
{
if(call == null || !StringUtils.hasContent(call.getMethod()) || !StringUtils.hasContent(call.getPathTemplate()))
{
throw (new QException("A UI API call needs a method and a path template"));
}
if(!isInvocable(call.getMethod(), call.getPathTemplate()))
{
throw (new QException("Not a spec'd UI API operation: " + call.operation()));
}
if(!StringUtils.hasContent(call.getSessionUUID()))
{
throw (new QException("A session is required to invoke " + call.operation()));
}
HttpRequest request = buildRequest(call);
try
{
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return (new UiApiResult(response.statusCode(), response.body()));
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw (new QException("Interrupted invoking " + call.operation(), e));
}
catch(IOException e)
{
throw (new QException("Error invoking " + call.operation() + ": " + e.getMessage(), e));
}
}
/*******************************************************************************
** The HTTP request: path parameters filled and encoded, query string, JSON
** body, the session cookie and the marker headers.
*******************************************************************************/
HttpRequest buildRequest(UiApiCall call) throws QException
{
String path = call.getPathTemplate();
for(Map.Entry<String, String> entry : call.getPathParams().entrySet())
{
if(entry.getValue() == null)
{
throw (new QException("Missing path parameter " + entry.getKey() + " for " + call.operation()));
}
path = path.replace("{" + entry.getKey() + "}", encode(entry.getValue()));
}
if(path.contains("{"))
{
throw (new QException("Missing path parameter(s) for " + call.operation() + ": " + path));
}
StringJoiner query = new StringJoiner("&", "?", "").setEmptyValue("");
call.getQueryParams().forEach((name, value) -> query.add(encode(name) + "=" + encode(value)));
String base = baseUrlSupplier.get();
if(!StringUtils.hasContent(base))
{
throw (new QException("The UI API base URL is not known yet"));
}
URI uri = URI.create(base.replaceAll("/+$", "") + BASE_PATH + path + query);
HttpRequest.Builder builder = HttpRequest.newBuilder(uri).timeout(call.getTimeout() == null ? Duration.ofSeconds(60) : call.getTimeout());
String cookie = "sessionUUID=" + call.getSessionUUID();
if(mockAuthentication)
{
cookie += "; sessionId=" + call.getSessionUUID();
}
builder.header("Cookie", cookie);
builder.header("Accept", "application/json");
builder.header(INVOKED_BY_HEADER, StringUtils.hasContent(call.getInvokedBy()) ? call.getInvokedBy() : "assistant");
if(StringUtils.hasContent(call.getUserTimezone()))
{
builder.header(TIMEZONE_HEADER, call.getUserTimezone());
}
if(call.getJsonBody() != null)
{
builder.header("Content-Type", "application/json");
builder.method(call.getMethod(), HttpRequest.BodyPublishers.ofString(call.getJsonBody() instanceof String s ? s : JsonUtils.toJson(call.getJsonBody()), StandardCharsets.UTF_8));
}
else
{
builder.method(call.getMethod(), HttpRequest.BodyPublishers.noBody());
}
return (builder.build());
}
/*******************************************************************************
**
*******************************************************************************/
private static String encode(String value)
{
return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"));
}
}
@@ -0,0 +1,235 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.uiapi;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/*******************************************************************************
** One call of a spec'd `/qqq/v1` operation, made with a user's own session.
** Mirrors core's `UiApiRequest` (QRun-IO/qqq#1128, docs/UI-API-EXTENSIONS.md
** section 5), so the stand-in gateway can be swapped for core's invoker.
*******************************************************************************/
public class UiApiCall
{
private final String method;
private final String pathTemplate;
private final Map<String, String> pathParams = new LinkedHashMap<>();
private final Map<String, String> queryParams = new LinkedHashMap<>();
private Object jsonBody;
private String sessionUUID;
private String userTimezone;
private String invokedBy = "assistant";
private Duration timeout = Duration.ofSeconds(60);
/*******************************************************************************
** A call of METHOD pathTemplate: the template as in openapi.yaml, relative
** to /qqq/v1 (e.g. "/table/{tableName}/query").
*******************************************************************************/
public UiApiCall(String method, String pathTemplate)
{
this.method = method;
this.pathTemplate = pathTemplate;
}
/*******************************************************************************
** "METHOD template": the operation's identity in logs and the allow-list.
*******************************************************************************/
public String operation()
{
return (method + " " + pathTemplate);
}
/*******************************************************************************
** Getter for method
*******************************************************************************/
public String getMethod()
{
return (method);
}
/*******************************************************************************
** Getter for pathTemplate
*******************************************************************************/
public String getPathTemplate()
{
return (pathTemplate);
}
/*******************************************************************************
** Add a path parameter (URL-encoded by the gateway).
*******************************************************************************/
public UiApiCall withPathParam(String name, Object value)
{
pathParams.put(name, value == null ? null : String.valueOf(value));
return (this);
}
/*******************************************************************************
** Getter for pathParams
*******************************************************************************/
public Map<String, String> getPathParams()
{
return (pathParams);
}
/*******************************************************************************
** Add a query parameter (ignored when null).
*******************************************************************************/
public UiApiCall withQueryParam(String name, Object value)
{
if(value != null)
{
queryParams.put(name, String.valueOf(value));
}
return (this);
}
/*******************************************************************************
** Getter for queryParams
*******************************************************************************/
public Map<String, String> getQueryParams()
{
return (queryParams);
}
/*******************************************************************************
** Fluent setter for jsonBody
*******************************************************************************/
public UiApiCall withJsonBody(Object jsonBody)
{
this.jsonBody = jsonBody;
return (this);
}
/*******************************************************************************
** Getter for jsonBody
*******************************************************************************/
public Object getJsonBody()
{
return (jsonBody);
}
/*******************************************************************************
** The user's own session UUID (required): the call is authenticated exactly
** as the user's browser request would be.
*******************************************************************************/
public UiApiCall withSessionUUID(String sessionUUID)
{
this.sessionUUID = sessionUUID;
return (this);
}
/*******************************************************************************
** Getter for sessionUUID
*******************************************************************************/
public String getSessionUUID()
{
return (sessionUUID);
}
/*******************************************************************************
** Fluent setter for userTimezone
*******************************************************************************/
public UiApiCall withUserTimezone(String userTimezone)
{
this.userTimezone = userTimezone;
return (this);
}
/*******************************************************************************
** Getter for userTimezone
*******************************************************************************/
public String getUserTimezone()
{
return (userTimezone);
}
/*******************************************************************************
** Fluent setter for invokedBy (sent as X-QQQ-Invoked-By)
*******************************************************************************/
public UiApiCall withInvokedBy(String invokedBy)
{
this.invokedBy = invokedBy;
return (this);
}
/*******************************************************************************
** Getter for invokedBy
*******************************************************************************/
public String getInvokedBy()
{
return (invokedBy);
}
/*******************************************************************************
** Fluent setter for timeout
*******************************************************************************/
public UiApiCall withTimeout(Duration timeout)
{
this.timeout = timeout;
return (this);
}
/*******************************************************************************
** Getter for timeout
*******************************************************************************/
public Duration getTimeout()
{
return (timeout);
}
}
@@ -0,0 +1,45 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.uiapi;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** How the assistant reaches data: spec'd `/qqq/v1` operations called with the
** user's own session, so every tool call passes the same authentication,
** permission, record-lock, validation and audit path as the UI.
**
** {@link LoopbackUiApiGateway} is a stand-in until core's `UiApiInvoker`
** (QRun-IO/qqq#1128) is released. Then an adapter over the invoker replaces
** it, and nothing else changes.
*******************************************************************************/
public interface UiApiGateway
{
/*******************************************************************************
** Whether METHOD template is a spec'd operation this gateway will call.
*******************************************************************************/
boolean isInvocable(String method, String pathTemplate);
/*******************************************************************************
** Run the call. Refuses (QException) an operation that is not spec'd, or a
** call without a session. Otherwise it returns the response as the UI would
** see it, error statuses included.
*******************************************************************************/
UiApiResult invoke(UiApiCall call) throws QException;
}
@@ -0,0 +1,71 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.uiapi;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** A `/qqq/v1` response exactly as the UI would see it: status and body.
*******************************************************************************/
public record UiApiResult(int status, String body)
{
/*******************************************************************************
** Whether the status is 2xx.
*******************************************************************************/
public boolean isSuccess()
{
return (status >= 200 && status < 300);
}
/*******************************************************************************
** The body as a JSON object, or null when it is not one.
*******************************************************************************/
public Map<String, Object> json()
{
if(!StringUtils.hasContent(body))
{
return (null);
}
try
{
return (JsonUtils.toObject(body, new TypeReference<Map<String, Object>>() {}));
}
catch(Exception e)
{
return (null);
}
}
/*******************************************************************************
** The API's own error message ({"error": "..."}), or null.
*******************************************************************************/
public String errorMessage()
{
Map<String, Object> json = json();
return (json != null && json.get("error") instanceof String error && StringUtils.hasContent(error) ? error : null);
}
}
@@ -85,7 +85,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_provider", "assistant_conversation", "assistant_model_price", "assistant_policy");
.containsExactly("assistant_provider", "assistant_data_access", "assistant_conversation", "assistant_model_price", "assistant_policy");
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getWidgets()).containsExactly("assistantHome");
assertThat(qInstance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME).getTableName()).isEqualTo("assistant_conversation");
assertThat(qInstance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME)).isNotNull();
@@ -109,7 +109,7 @@ class AssistantRecordScreensTest extends BaseAssistantTest
{
QTableMetaData conversation = qInstance.getTable(AssistantConversation.TABLE_NAME);
List<String> widgets = conversation.getSections().stream().map(QFieldSection::getWidgetName).filter(name -> name != null).toList();
assertThat(widgets).hasSize(5);
assertThat(widgets).hasSize(6);
for(String widget : widgets)
{
assertThat(qInstance.getWidget(widget)).as(widget).isNotNull();
@@ -119,7 +119,7 @@ class AssistantRecordScreensTest extends BaseAssistantTest
// a second pass does not add them again //
///////////////////////////////////////////////////
AssistantRecordScreens.addConversationChildLists(qInstance);
assertThat(conversation.getSections().stream().filter(section -> section.getWidgetName() != null)).hasSize(5);
assertThat(conversation.getSections().stream().filter(section -> section.getWidgetName() != null)).hasSize(6);
}
@@ -104,6 +104,10 @@ public class TurnRunner
{
this.qInstance = qInstance;
this.config = config;
if(config.getUiApiGateway() == null && config.getUiApiBaseUrl() == null)
{
UiApiTestServer.serve(qInstance, config);
}
}
@@ -0,0 +1,148 @@
/*
* 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.testing;
import java.io.IOException;
import java.net.ServerSocket;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
/*******************************************************************************
** The fixture app served by a real QQQ application server (the `/qqq/v1` UI
** API plus the assistant routes) on a free port, so the assistant's tools go
** through exactly the pipeline the UI uses. One server at a time: starting
** one for a new instance stops the previous one.
*******************************************************************************/
public final class UiApiTestServer
{
private static QApplicationJavalinServer current;
private static QInstance currentInstance;
private static int currentPort;
/*******************************************************************************
** Utility class - no instances.
*******************************************************************************/
private UiApiTestServer()
{
}
/*******************************************************************************
** The fixture application.
*******************************************************************************/
public static class FixtureApplication extends AbstractQQQApplication
{
private final QInstance qInstance;
/*******************************************************************************
**
*******************************************************************************/
public FixtureApplication(QInstance qInstance)
{
this.qInstance = qInstance;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QInstance defineQInstance() throws QException
{
return (qInstance);
}
}
/*******************************************************************************
** Serve this instance (if not already) and point the config's UI API base
** URL at it. Returns the base URL.
*******************************************************************************/
public static synchronized String serve(QInstance qInstance, AssistantQBitConfig config)
{
if(current == null || currentInstance != qInstance)
{
stop();
try
{
currentPort = freePort();
current = new QApplicationJavalinServer(new FixtureApplication(qInstance))
.withPort(currentPort)
.withServeFrontendNext(false)
.withServeFrontendMaterialDashboard(false)
.withServeLegacyUnversionedMiddlewareAPI(false);
current.start();
currentInstance = qInstance;
}
catch(Exception e)
{
throw (new IllegalStateException("Could not start the UI API test server", e));
}
}
String baseUrl = "http://127.0.0.1:" + currentPort;
config.setUiApiBaseUrl(baseUrl);
return (baseUrl);
}
/*******************************************************************************
** Stop the running server, if any.
*******************************************************************************/
public static synchronized void stop()
{
if(current != null)
{
try
{
current.stop();
}
catch(Exception e)
{
// already stopped
}
current = null;
currentInstance = null;
}
}
/*******************************************************************************
**
*******************************************************************************/
private static int freePort() throws IOException
{
try(ServerSocket socket = new ServerSocket(0))
{
return (socket.getLocalPort());
}
}
}
@@ -0,0 +1,340 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.tools;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.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.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The owner's rule (2026-10-13): the assistant goes through the same API the
** UI does, so it is held to the same security boundaries. For each case the
** UI's own request (a direct /qqq/v1 call with the user's session cookie) and
** the assistant's tool call are made side by side: the status and the error
** message must be identical, and the assistant may only ever be narrower
** (data access NONE / READ).
*******************************************************************************/
class UiApiToolsSecurityTest extends BaseAssistantTest
{
private final HttpClient client = HttpClient.newHttpClient();
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void clear()
{
QContext.clear();
}
/*******************************************************************************
** The UI's request: METHOD /qqq/v1{path} as the user's browser sends it.
*******************************************************************************/
private HttpResponse<String> ui(String method, String path, String user, String body) throws Exception
{
String base = UiApiTestServer.serve(qInstance, config);
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(base + "/qqq/v1" + path)).header("Cookie", "sessionId=" + user).timeout(Duration.ofSeconds(30));
if(body != null)
{
builder.header("Content-Type", "application/json");
}
builder.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
return (client.send(builder.build(), HttpResponse.BodyHandlers.ofString()));
}
/*******************************************************************************
** The assistant's tool call, as the turn engine makes it.
*******************************************************************************/
private UiApiToolbox.Outcome tool(String user, PolicyService.ToolPolicy policy, String toolName, Map<String, Object> in)
{
UiApiTestServer.serve(qInstance, config);
QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config));
try
{
return (config.getRuntime().getToolbox().execute(new UiApiToolbox.Context(user, null, config.getRuntime().getUiApiGateway(), policy,
write -> config.getRuntime().getStore().insert(new AssistantPendingAction().withUserId(user).withToolName(write.toolName()).withTableName(write.tableName())
.withRecordId(write.recordId()).withUiApiOperation(write.operation()).withParamsJson(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(write.values()))
.withSummary(write.summary()).withStatus(AssistantPendingAction.STATUS_PENDING)
.withExpiresAt(java.time.Instant.now().plusSeconds(600))).getId()), "call-1", toolName, in));
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
**
*******************************************************************************/
private void setAccess(String tableName, DataAccessMode mode) throws QException
{
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
try
{
config.getRuntime().getStore().insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName(tableName).withMode(mode.name()));
config.getRuntime().getDataAccessService().invalidate();
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
**
*******************************************************************************/
private static PolicyService.ToolPolicy noConfirmation()
{
return (new PolicyService.ToolPolicy(false, Set.of(), 50, true));
}
/*******************************************************************************
** No read permission: the UI gets 403; the assistant gets the same 403 and
** the same message, as a DENIED tool result.
*******************************************************************************/
@Test
void testReadDeniedIsTheSameDenial() throws Exception
{
String body = "{\"filter\":{\"criteria\":[],\"booleanOperator\":\"AND\"}}";
HttpResponse<String> ui = ui("POST", "/table/blog_post/query", AssistantTestFixture.ANALYST, body);
assertThat(ui.statusCode()).isEqualTo(403);
UiApiToolbox.Outcome outcome = tool(AssistantTestFixture.ANALYST, PolicyService.ToolPolicy.defaults(), UiApiToolbox.QUERY_RECORDS,
input("table", "blog_post", "filter", filter("AND")));
assertThat(outcome.getHttpStatus()).isEqualTo(ui.statusCode());
assertThat(outcome.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(outcome.getOperation()).isEqualTo("POST /table/{tableName}/query");
assertThat(outcome.getResult().getModelContent()).contains(new UiApiResult(ui.statusCode(), ui.body()).errorMessage());
////////////////////////////////////////////
// get and count: the same, by status //
////////////////////////////////////////////
assertThat(tool(AssistantTestFixture.ANALYST, null, UiApiToolbox.GET_RECORD, input("table", "blog_post", "id", "1")).getHttpStatus())
.isEqualTo(ui("GET", "/table/blog_post/1", AssistantTestFixture.ANALYST, null).statusCode());
assertThat(tool(AssistantTestFixture.ANALYST, null, UiApiToolbox.COUNT_RECORDS, input("table", "blog_post", "filter", filter("AND"))).getHttpStatus())
.isEqualTo(ui("POST", "/table/blog_post/count", AssistantTestFixture.ANALYST, body).statusCode());
////////////////////////////////////////////////////////////
// and the table the user may read works, with no change //
////////////////////////////////////////////////////////////
UiApiToolbox.Outcome ok = tool(AssistantTestFixture.ANALYST, null, UiApiToolbox.COUNT_RECORDS, input("table", "analytics_daily_stat", "filter", filter("AND")));
assertThat(ok.getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(ok.getHttpStatus()).isEqualTo(200);
}
/*******************************************************************************
** No insert permission: even with data access READ_WRITE and no
** confirmation, the write gets the UI's 403.
*******************************************************************************/
@Test
void testWriteDeniedIsTheSameDenial() throws Exception
{
setAccess("blog_post", DataAccessMode.READ_WRITE);
HttpResponse<String> ui = ui("POST", "/table/blog_post", AssistantTestFixture.EDITOR, "{\"title\":\"From the UI\"}");
assertThat(ui.statusCode()).isEqualTo(403);
UiApiToolbox.Outcome outcome = tool(AssistantTestFixture.EDITOR, noConfirmation(), UiApiToolbox.CREATE_RECORD,
input("table", "blog_post", "values", Map.of("title", "From the assistant")));
assertThat(outcome.getHttpStatus()).isEqualTo(403);
assertThat(outcome.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(outcome.getResult().getModelContent()).contains(new UiApiResult(ui.statusCode(), ui.body()).errorMessage());
assertThat(tool(AssistantTestFixture.EDITOR, noConfirmation(), UiApiToolbox.DELETE_RECORD, input("table", "blog_post", "id", "1")).getHttpStatus())
.isEqualTo(ui("DELETE", "/table/blog_post/1", AssistantTestFixture.EDITOR, null).statusCode());
}
/*******************************************************************************
** Data access only narrows: NONE hides a table the user can read; READ
** refuses writes the user could make; nothing raises a permission.
*******************************************************************************/
@Test
void testDataAccessOnlyNarrows() throws Exception
{
assertThat(ui("POST", "/table/page/count", AssistantTestFixture.EDITOR, "{}").statusCode()).isEqualTo(200);
setAccess("page", DataAccessMode.NONE);
UiApiToolbox.Outcome hidden = tool(AssistantTestFixture.EDITOR, null, UiApiToolbox.COUNT_RECORDS, input("table", "page", "filter", filter("AND")));
assertThat(hidden.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(hidden.getHttpStatus()).as("no request is made").isNull();
UiApiToolbox.Outcome list = tool(AssistantTestFixture.EDITOR, null, UiApiToolbox.LIST_TABLES, Map.of());
assertThat(list.getResult().getModelContent()).contains("\"blog_post\"").doesNotContain("\"page\"").doesNotContain("analytics_daily_stat").doesNotContain("assistant_");
//////////////////////////////////////////////////////////////////
// the admin may insert in the UI, but blog_post is READ (the //
// default): the assistant refuses before any request //
//////////////////////////////////////////////////////////////////
UiApiToolbox.Outcome readOnly = tool(AssistantTestFixture.ADMIN, noConfirmation(), UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "x")));
assertThat(readOnly.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(readOnly.getHttpStatus()).isNull();
assertThat(readOnly.getResult().getModelContent()).contains("READ");
///////////////////////////////////////////////////////////////////
// a sensitive table (AI exposure NONE) cannot be raised //
///////////////////////////////////////////////////////////////////
setAccess("assistant_policy", DataAccessMode.READ_WRITE);
assertThat(config.getRuntime().getDataAccessService().forTable("assistant_policy").mode()).isEqualTo(DataAccessMode.NONE);
}
/*******************************************************************************
** With confirmation on (the default) a write only proposes; confirming runs
** it through the UI API with the confirming user's session, once, and logs
** the operation, status and audit link.
*******************************************************************************/
@Test
void testConfirmedWriteRunsThroughTheUiApi() throws Exception
{
setAccess("blog_post", DataAccessMode.READ_WRITE);
UiApiToolbox.Outcome proposed = tool(AssistantTestFixture.ADMIN, PolicyService.ToolPolicy.defaults(), UiApiToolbox.CREATE_RECORD,
input("table", "blog_post", "values", Map.of("title", "Proposed by the assistant", "status", "DRAFT")));
assertThat(proposed.getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(proposed.getHttpStatus()).as("nothing runs before confirmation").isNull();
assertThat(proposed.getPendingActionId()).isNotNull();
assertThat(String.valueOf(proposed.getResult().getRender())).contains("pending_action");
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
AssistantApi api = new AssistantApi(config.getRuntime(), QContext.getQSession());
assertThat(config.getRuntime().getStore().queryRecords("blog_post", new QQueryFilter().withCriteria("title", QCriteriaOperator.EQUALS, "Proposed by the assistant"), 10)).isEmpty();
Map<String, Object> confirmed = api.confirmAction(proposed.getPendingActionId());
assertThat(confirmed.get("status")).isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
assertThat(confirmed.get("outcome")).isEqualTo("OK");
List<QRecord> created = config.getRuntime().getStore().queryRecords("blog_post", new QQueryFilter().withCriteria("title", QCriteriaOperator.EQUALS, "Proposed by the assistant"), 10);
assertThat(created).hasSize(1);
assertThatThrownBy(() -> api.confirmAction(proposed.getPendingActionId())).hasMessageContaining("already");
List<AssistantToolCall> log = config.getRuntime().getStore().query(AssistantToolCall.TABLE_NAME,
new QQueryFilter().withCriteria("pendingActionId", QCriteriaOperator.EQUALS, proposed.getPendingActionId()), AssistantToolCall.class);
assertThat(log).hasSize(1);
assertThat(log.get(0).getUiApiOperation()).isEqualTo("POST /table/{tableName}");
assertThat(log.get(0).getHttpStatus()).isBetween(200, 299);
assertThat(log.get(0).getAuditHref()).startsWith("/app/blog_post/");
QContext.clear();
}
/*******************************************************************************
** The user who did not propose an action cannot see or confirm it (the
** owner record lock), and a rejected action never runs.
*******************************************************************************/
@Test
void testOnlyTheOwnerDecides() throws Exception
{
setAccess("blog_post", DataAccessMode.READ_WRITE);
UiApiToolbox.Outcome proposed = tool(AssistantTestFixture.EDITOR, PolicyService.ToolPolicy.defaults(), UiApiToolbox.UPDATE_RECORD,
input("table", "blog_post", "id", "1", "values", Map.of("title", "Changed")));
Integer actionId = proposed.getPendingActionId();
assertThat(actionId).isNotNull();
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.OTHER_EDITOR));
AssistantApi other = new AssistantApi(config.getRuntime(), QContext.getQSession());
assertThatThrownBy(() -> other.confirmAction(actionId)).hasMessageContaining("not found");
QContext.clear();
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.EDITOR));
AssistantApi owner = new AssistantApi(config.getRuntime(), QContext.getQSession());
assertThat(owner.rejectAction(actionId).get("status")).isEqualTo(AssistantPendingAction.STATUS_REJECTED);
assertThatThrownBy(() -> owner.confirmAction(actionId)).hasMessageContaining("already");
QContext.clear();
}
/*******************************************************************************
** Only spec'd /qqq/v1 operations can be invoked (the assistant's own raw
** routes are not reachable), and never without a session.
*******************************************************************************/
@Test
void testOnlySpecdOperationsWithASession()
{
UiApiTestServer.serve(qInstance, config);
assertThatThrownBy(() -> config.getRuntime().getUiApiGateway().invoke(new UiApiCall("POST", "/assistant/conversations").withSessionUUID(AssistantTestFixture.ADMIN)))
.hasMessageContaining("Not a spec'd");
assertThatThrownBy(() -> config.getRuntime().getUiApiGateway().invoke(new UiApiCall("GET", "/metaData")))
.hasMessageContaining("session");
for(String[] operation : UiApiToolbox.OPERATIONS.values())
{
assertThat(config.getRuntime().getUiApiGateway().isInvocable(operation[0], operation[1])).as(operation[0] + " " + operation[1]).isTrue();
}
}
/*******************************************************************************
** The policy narrows the tools, and caps rows.
*******************************************************************************/
@Test
void testPolicy()
{
PolicyService.ToolPolicy onlyCount = new PolicyService.ToolPolicy(true, Set.of(UiApiToolbox.COUNT_RECORDS), 2, true);
assertThat(tool(AssistantTestFixture.EDITOR, onlyCount, UiApiToolbox.QUERY_RECORDS, input("table", "blog_post", "filter", filter("AND"))).getResult().getOutcome())
.isEqualTo(AiToolOutcome.DENIED);
PolicyService.ToolPolicy twoRows = new PolicyService.ToolPolicy(true, Set.of(), 2, true);
UiApiToolbox.Outcome capped = tool(AssistantTestFixture.EDITOR, twoRows, UiApiToolbox.QUERY_RECORDS, input("table", "blog_post", "filter", filter("AND"), "limit", 50));
assertThat(capped.getResult().getRowCount()).isEqualTo(2);
assertThat(capped.getResult().getModelContent()).contains("\"hasMore\":true").doesNotContain(AssistantTestFixture.HIDDEN_SENTINEL).doesNotContain(AssistantTestFixture.PASSWORD_SENTINEL);
}
}