From f6edea4b7be7a3882025dd53b12d7a2445cee0f6 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 14:44:34 -0500 Subject: [PATCH 1/3] feat!: spec'd /qqq/v1 assistant endpoints; tools on core's UiApiInvoker Phase (d). Needs QQQ with QRun-IO/qqq#1128 (extension point, invoker, credentials forwarding). - AssistantSpecs: the "assistant" UI API extension (1.0.0, established prefix /assistant): config, conversations, turns (text/event-stream), events, cancel, feedback, actions (get/confirm/reject) and access, with the same URLs, bodies and statuses. The raw routes are removed; the route provider only learns the local port. - CoreUiApiGateway over UiApiInvoker replaces the loopback stand-in. The caller's credentials (UiApiCredentials.fromContext) flow from the spec to the tools; the assistant's own operations are never invocable. - The OpenAPI fragment is committed and contract-tested with UiApiContract. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + docs/DESIGN.md | 12 + .../qbits/assistant/AssistantRuntime.java | 4 +- .../assistant/access/AccessExplainer.java | 13 +- .../qbits/assistant/api/AssistantApi.java | 20 +- .../assistant/api/AssistantRouteProvider.java | 470 +----- .../qbits/assistant/api/AssistantRoutes.java | 12 + .../qbits/assistant/api/TurnStreams.java | 222 +++ .../assistant/api/specs/AssistantSpecs.java | 1336 +++++++++++++++++ .../qbits/assistant/engine/TurnEngine.java | 17 +- .../qbits/assistant/tools/UiApiToolbox.java | 14 +- .../assistant/uiapi/CoreUiApiGateway.java | 161 ++ .../assistant/uiapi/LoopbackUiApiGateway.java | 230 --- .../qbits/assistant/uiapi/UiApiCall.java | 25 + .../qbits/assistant/uiapi/UiApiGateway.java | 5 +- .../resources/openapi/qqq-v1/assistant.yaml | 1088 ++++++++++++++ .../qbits/assistant/ArchitectureTest.java | 6 +- .../api/AssistantRouteProviderTest.java | 11 +- .../api/specs/AssistantSpecsContractTest.java | 73 + .../openapi/qqq-v1/assistant-baseline.yaml | 1088 ++++++++++++++ 20 files changed, 4096 insertions(+), 716 deletions(-) create mode 100644 qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/TurnStreams.java create mode 100644 qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java create mode 100644 qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/CoreUiApiGateway.java delete mode 100644 qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/LoopbackUiApiGateway.java create mode 100644 qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml create mode 100644 qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecsContractTest.java create mode 100644 qbit-assistant/src/test/resources/openapi/qqq-v1/assistant-baseline.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa3ebf..dbbb077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ Phase 0/1 of the QQQ Assistant: read-only Q&A and navigation. Requires QQQ 4.1 w - the AI tool kernel (`qqq-backend-core` `actions.ai`, QRun-IO/qqq#1078) - the developer-panel contract (QRun-IO/qqq#1076). +### Changed (0.1.0-RC.7, needs QQQ with QRun-IO/qqq#1128) +- The assistant's endpoints are spec'd `/qqq/v1` operations (UI API extension `assistant` 1.0.0, prefix `/assistant`): same URLs, bodies and statuses, now in the served OpenAPI; turns and events are `text/event-stream`. The raw routes are gone. +- Tools call core's `UiApiInvoker` with the caller's own request credentials (`UiApiCredentials`); the loopback stand-in is removed. The assistant's own operations are never invocable by its tools. +- The OpenAPI fragment is committed (`openapi/qqq-v1/assistant.yaml`) and contract-tested. + ### Changed (0.1.0-RC.6): tools go through the UI API (owner decision 2026-10-13) - **Every data tool runs a spec'd `/qqq/v1` operation with the user's own session** (`UiApiGateway`, loopback HTTP with the `sessionUUID` cookie): the same authentication, permissions, record locks, validation, audit and errors as the UI. A user without a permission gets the identical denial through the assistant. `navigate` and `search_help` (no records) stay on the core kernel. `LoopbackUiApiGateway` is a stand-in for core's `UiApiInvoker` (QRun-IO/qqq#1128). - Tools: `list_tables`, `describe_table`, `query_records`, `count_records`, `get_record`, `search_records`, `render_widget`, `get_record_history`, and new **`create_record`, `update_record`, `delete_record`**. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 0b72878..a370c65 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -228,6 +228,18 @@ The golden and red-team evals now run through the UI API too. - Choosing *another* user would need that user's permissions without their session. QQQ core has no API to evaluate another user's permissions, and the assistant never builds or borrows a session. That part waits for a core API, so the report is always computed with the requester's own session. - **Recent denials:** the home's "Recent denials" card lists the newest tool calls the UI API, data access or the policy refused, with who, the table, the operation and the HTTP status, or the assistant setting that refused it. +## Spec'd assistant endpoints and the core invoker (phase d, needs QQQ with #1128) + +- **The endpoints are spec'd `/qqq/v1` operations.** `api/specs/AssistantSpecs` registers the UI API extension `assistant` (semver 1.0.0, established prefix `/assistant`) through `MiddlewareSpecExtensionsMetaData`. + - The URLs, bodies and statuses stay the same (201 create, 202 cancel, 204 delete and feedback, and the assistant's `{"error","code"}` errors), so Next needs no change. + - They appear in the served `/qqq/v1/openapi.yaml`. The turn and events streams are declared as `text/event-stream`, with `AssistantTurnEventV1` describing the events. + - The fragment is committed as `qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml`. `AssistantSpecsContractTest` checks it against the code, and the release baseline in test resources, with core's `UiApiContract`. +- **No raw routes.** `AssistantRouteProvider` keeps only a before-handler that learns the server's local port from the first `/qqq/v1` request. +- **Tools call core's `UiApiInvoker`** (`CoreUiApiGateway`), and the loopback stand-in is removed. + - The request's credentials (`UiApiCredentials.fromContext`: session cookies, Authorization, API key, exactly as they came in) are captured by the spec that starts the turn, confirms an action or explains access. They are passed to the tools, so OAuth2, table auth and API-key sessions all work. They are never logged. + - The gateway also refuses the assistant's own operations, so a turn can never start a turn. +- Until a QQQ release contains #1128, this branch builds with `-Dqqq.snapshot.version=`. + ## Next phases - **(c) Data access screens:** diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java index 3d9bea6..d71b5dd 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java @@ -33,7 +33,7 @@ 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.CoreUiApiGateway; 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; @@ -101,7 +101,7 @@ public class AssistantRuntime this.accessExplainer = new AccessExplainer(qInstance, dataAccessService); this.toolbox = new UiApiToolbox(qInstance, dataAccessService, exposurePolicy, new AiRedactor(settings.getCustomRedactionPatterns()), config.getMaxCellChars() == null ? AiToolKernelSettings.DEFAULT_MAX_CELL_CHARS : config.getMaxCellChars()); - this.uiApiGateway = config.getUiApiGateway() != null ? config.getUiApiGateway() : new LoopbackUiApiGateway(this::uiApiBaseUrl, qInstance); + this.uiApiGateway = config.getUiApiGateway() != null ? config.getUiApiGateway() : new CoreUiApiGateway(this::uiApiBaseUrl, qInstance); this.contextBuilder = new ContextBuilder(config, kernel, toolbox); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java index 3e522e0..5ccbd62 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java @@ -31,6 +31,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; /******************************************************************************* @@ -92,7 +93,17 @@ public class AccessExplainer *******************************************************************************/ public List explain(UiApiGateway gateway, String sessionUUID, PolicyService.ToolPolicy policy, String tableName, String prompt) throws QException { - UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withInvokedBy("assistant-explain")); + return (explain(gateway, sessionUUID, null, policy, tableName, prompt)); + } + + + + /******************************************************************************* + ** Explain, with the caller's request credentials when they are known. + *******************************************************************************/ + public List explain(UiApiGateway gateway, String sessionUUID, UiApiCredentials credentials, PolicyService.ToolPolicy policy, String tableName, String prompt) throws QException + { + UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withCredentials(credentials).withInvokedBy("assistant-explain")); if(!response.isSuccess()) { throw (new QException("GET /metaData answered HTTP " + response.status() + (response.errorMessage() == null ? "" : ": " + response.errorMessage()))); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java index 86f76fe..d240d43 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java @@ -54,6 +54,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; /******************************************************************************* @@ -70,6 +71,7 @@ public class AssistantApi private final AssistantStore store; private final QSession session; private final String userId; + private UiApiCredentials credentials; @@ -92,6 +94,18 @@ public class AssistantApi + /******************************************************************************* + ** The credentials of the request this API serves, exactly as they came in; + ** the tools forward them to the UI API. Never logged. + *******************************************************************************/ + public AssistantApi withCredentials(UiApiCredentials credentials) + { + this.credentials = credentials; + return (this); + } + + + /******************************************************************************* ** GET /config - the feature probe. Never throws for a signed-in user. *******************************************************************************/ @@ -372,7 +386,7 @@ public class AssistantApi RunningTurn runningTurn = runtime.register(new RunningTurn(turn.getId(), userId)); TurnEngine engine = new TurnEngine(runtime, session, userId, conversation, userMessage, assistantMessage, turn, text, pageContext, policy, runningTurn, - budget.softExceeded() ? budget.message() : null); + budget.softExceeded() ? budget.message() : null).withCredentials(credentials); runtime.getExecutor().submit(engine); return (runningTurn); } @@ -597,7 +611,7 @@ public class AssistantApi throw (new QException("The stored values of action " + id + " are not valid JSON", e)); } PolicyService.EffectivePolicy policy = runtime.getPolicyService().current(true); - UiApiToolbox.Context context = new UiApiToolbox.Context(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), runtime.getUiApiGateway(), policy.tools(), null); + UiApiToolbox.Context context = new UiApiToolbox.Context(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), runtime.getUiApiGateway(), policy.tools(), null, credentials); 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; @@ -719,7 +733,7 @@ public class AssistantApi { requireAccess(); List> tables = new ArrayList<>(); - for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), runtime.getPolicyService().current(false).tools(), table, prompt)) + for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), credentials, runtime.getPolicyService().current(false).tools(), table, prompt)) { if(!access.uiVisible() && access.mode() == DataAccessMode.NONE) { diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRouteProvider.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRouteProvider.java index 16ff446..0a2e15c 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRouteProvider.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRouteProvider.java @@ -17,63 +17,27 @@ package com.kingsrook.qbits.assistant.api; -import java.io.IOException; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.DeserializationFeature; import com.kingsrook.qbits.assistant.AssistantRuntime; -import com.kingsrook.qbits.assistant.engine.RunningTurn; -import com.kingsrook.qbits.assistant.engine.TurnEvent; -import com.kingsrook.qbits.assistant.engine.TurnEventLog; -import com.kingsrook.qqq.backend.core.context.QContext; -import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.session.QSession; -import com.kingsrook.qqq.backend.core.utils.JsonUtils; -import com.kingsrook.qqq.backend.core.utils.StringUtils; import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface; -import com.kingsrook.qqq.middleware.javalin.QJavalinUtils; -import com.kingsrook.qqq.middleware.javalin.executors.ExecutorSessionUtils; import io.javalin.config.JavalinConfig; -import io.javalin.http.ContentType; -import io.javalin.http.Context; -import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; /******************************************************************************* - ** Javalin routes under /qqq/v1/assistant (contract section 3). - ** - ** Every request gets the user's own session from the instance's - ** authentication module (ExecutorSessionUtils.setupSession: session cookie, - ** bearer token or API key) - never a system session. Bodies must be JSON - ** (a cross-site form cannot send that without a CORS preflight). - ** - ** Streams (POST /conversations/{id}/turns, GET /turns/{id}/events) are - ** Server-Sent Events written to the response as they happen: "id: seq", - ** "event: type", "data: json", a blank line; a ping every 15 seconds; - ** Cache-Control no-cache and X-Accel-Buffering no (proxies must not - ** buffer; give the route a long idle timeout). The turn itself runs on its - ** own thread, so a client disconnect does not stop it; the client resumes - ** with /events?after=. - ** - ** Request and response bodies are never logged. + ** No routes any more: the assistant's endpoints are spec'd /qqq/v1 + ** operations ({@link com.kingsrook.qbits.assistant.api.specs.AssistantSpecs}). + ** What is left is a before-handler that learns this server's local port from + ** the first /qqq/v1 request, so the tools (and Explain access) can call the + ** UI API on 127.0.0.1 before any assistant operation has run. *******************************************************************************/ public class AssistantRouteProvider implements QJavalinRouteProviderInterface { private static final QLogger LOG = QLogger.getLogger(AssistantRouteProvider.class); - public static final String BASE_PATH = "/qqq/v1/assistant"; - public static final long PING_MILLIS = 15_000; - public static final long REMOTE_POLL_MS = 1_000; + public static final String BASE_PATH = "/qqq/v1/assistant"; private QInstance qInstance; - private long pingMillis = PING_MILLIS; @@ -88,28 +52,12 @@ public class AssistantRouteProvider implements QJavalinRouteProviderInterface - /******************************************************************************* - ** Ping interval (tests use a short one). - *******************************************************************************/ - public AssistantRouteProvider withPingMillis(long pingMillis) - { - this.pingMillis = pingMillis; - return (this); - } - - - /******************************************************************************* ** *******************************************************************************/ @Override public void acceptJavalinConfig(JavalinConfig config) { - /////////////////////////////////////////////////////////////////////// - // learn this server's local port from the first UI request (e.g. // - // the metaData load), so tools and Explain access can loop back to // - // /qqq/v1 before any assistant route has been called // - /////////////////////////////////////////////////////////////////////// config.routes.before("/qqq/v1/*", ctx -> { AssistantRuntime runtime = AssistantRuntime.of(qInstance); @@ -125,412 +73,6 @@ public class AssistantRouteProvider implements QJavalinRouteProviderInterface } } }); - config.routes.get(BASE_PATH + "/config", ctx -> json(ctx, false, (api, body) -> api.config())); - config.routes.get(BASE_PATH + "/conversations", ctx -> json(ctx, false, (api, body) -> api.listConversations(ctx.queryParam("status")))); - config.routes.post(BASE_PATH + "/conversations", ctx -> json(ctx, true, (api, body) -> - { - ctx.status(201); - return (api.createConversation(body)); - })); - config.routes.get(BASE_PATH + "/conversations/{id}", ctx -> json(ctx, false, (api, body) -> api.getConversation(pathInt(ctx, "id"), queryInt(ctx, "before"), queryInt(ctx, "limit")))); - config.routes.patch(BASE_PATH + "/conversations/{id}", ctx -> json(ctx, true, (api, body) -> api.patchConversation(pathInt(ctx, "id"), body))); - config.routes.delete(BASE_PATH + "/conversations/{id}", ctx -> json(ctx, false, (api, body) -> - { - api.deleteConversation(pathInt(ctx, "id")); - ctx.status(204); - return (null); - })); - config.routes.post(BASE_PATH + "/conversations/{id}/turns", this::startTurn); - config.routes.get(BASE_PATH + "/turns/{turnId}/events", this::turnEvents); - config.routes.post(BASE_PATH + "/turns/{turnId}/cancel", ctx -> json(ctx, true, (api, body) -> - { - ctx.status(202); - return (api.cancelTurn(pathInt(ctx, "turnId"))); - })); - config.routes.get(BASE_PATH + "/access", ctx -> json(ctx, false, (api, body) -> api.access(ctx.queryParam("table"), ctx.queryParam("prompt")))); - config.routes.get(BASE_PATH + "/actions/{id}", ctx -> json(ctx, false, (api, body) -> api.getAction(pathInt(ctx, "id")))); - config.routes.post(BASE_PATH + "/actions/{id}/confirm", ctx -> json(ctx, true, (api, body) -> api.confirmAction(pathInt(ctx, "id")))); - config.routes.post(BASE_PATH + "/actions/{id}/reject", ctx -> json(ctx, true, (api, body) -> api.rejectAction(pathInt(ctx, "id")))); - config.routes.post(BASE_PATH + "/messages/{id}/feedback", ctx -> json(ctx, true, (api, body) -> - { - api.feedback(pathInt(ctx, "id"), body); - ctx.status(204); - return (null); - })); - } - - - - /******************************************************************************* - ** Session, runtime, body, handler, JSON - or the error. - *******************************************************************************/ - private void json(Context context, boolean readsBody, Handler handler) - { - try - { - AssistantApi api = api(context); - Map body = readsBody ? readJsonBody(context) : Map.of(); - Object result = handler.handle(api, body); - if(result != null) - { - context.contentType(ContentType.APPLICATION_JSON); - context.result(JsonUtils.toJsonCustomized(result, builder -> builder.serializationInclusion(JsonInclude.Include.ALWAYS))); - } - } - catch(Exception e) - { - handleException(context, e); - } - finally - { - QContext.clear(); - } - } - - - - /******************************************************************************* - ** POST /conversations/{id}/turns - *******************************************************************************/ - private void startTurn(Context context) - { - RunningTurn turn; - try - { - AssistantApi api = api(context); - turn = api.startTurn(pathInt(context, "id"), readJsonBody(context)); - } - catch(Exception e) - { - handleException(context, e); - QContext.clear(); - return; - } - QContext.clear(); - streamLive(context, turn.getLog(), 0); - } - - - - /******************************************************************************* - ** GET /turns/{turnId}/events?after= - *******************************************************************************/ - private void turnEvents(Context context) - { - Integer turnId; - int after; - AssistantApi api; - AssistantApi.TurnEvents events; - try - { - api = api(context); - turnId = pathInt(context, "turnId"); - Integer afterParam = queryInt(context, "after"); - after = afterParam == null ? 0 : Math.max(0, afterParam); - events = api.turnEvents(turnId, after); - } - catch(Exception e) - { - handleException(context, e); - QContext.clear(); - return; - } - - if(events.live() != null) - { - QContext.clear(); - streamLive(context, events.live().getLog(), after); - return; - } - - /////////////////////////////////////////////////////////////////////// - // persisted events; if the turn still runs on another replica, poll // - /////////////////////////////////////////////////////////////////////// - try - { - OutputStream out = startStream(context); - int last = after; - long lastWrite = System.currentTimeMillis(); - long giveUpAt = System.currentTimeMillis() + (api.maxTurnSeconds() + 60L) * 1000L; - AssistantApi.TurnEvents current = events; - while(true) - { - boolean ended = false; - for(TurnEvent event : current.persisted()) - { - write(out, event.toSseFrame()); - last = event.getSeq(); - lastWrite = System.currentTimeMillis(); - ended |= event.isTerminal(); - } - if(ended || (current.finished() && !current.more()) || System.currentTimeMillis() > giveUpAt) - { - return; - } - if(!current.more()) - { - Thread.sleep(REMOTE_POLL_MS); - } - if(System.currentTimeMillis() - lastWrite >= pingMillis) - { - write(out, ping()); - lastWrite = System.currentTimeMillis(); - } - current = api.turnEvents(turnId, last); - if(current.live() != null) - { - QContext.clear(); - writeLive(out, current.live().getLog(), last); - return; - } - } - } - catch(IOException e) - { - LOG.debug("Assistant event stream closed by the client", logPair("turnId", turnId)); - } - catch(InterruptedException e) - { - Thread.currentThread().interrupt(); - } - catch(Exception e) - { - LOG.warn("Assistant event replay failed", e, logPair("turnId", turnId)); - } - finally - { - QContext.clear(); - } - } - - - - /******************************************************************************* - ** Stream a live log from a sequence number until message.end. - *******************************************************************************/ - private void streamLive(Context context, TurnEventLog log, int after) - { - try - { - writeLive(startStream(context), log, after); - } - catch(IOException e) - { - LOG.debug("Assistant stream closed by the client (the turn continues)"); - } - catch(InterruptedException e) - { - Thread.currentThread().interrupt(); - } - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private void writeLive(OutputStream out, TurnEventLog log, int after) throws IOException, InterruptedException - { - int last = after; - while(true) - { - List events = log.after(last, pingMillis); - if(events.isEmpty()) - { - if(log.isClosed()) - { - return; - } - write(out, ping()); - continue; - } - for(TurnEvent event : events) - { - write(out, event.toSseFrame()); - last = event.getSeq(); - if(event.isTerminal()) - { - return; - } - } - } - } - - - - /******************************************************************************* - ** Start an SSE response. - *******************************************************************************/ - private static OutputStream startStream(Context context) throws IOException - { - context.status(200); - context.disableCompression(); - context.res().setContentType("text/event-stream; charset=utf-8"); - context.res().setHeader("Cache-Control", "no-cache"); - context.res().setHeader("X-Accel-Buffering", "no"); - context.res().setHeader("Connection", "keep-alive"); - OutputStream out = context.res().getOutputStream(); - out.flush(); - return (out); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private static void write(OutputStream out, String frame) throws IOException - { - out.write(frame.getBytes(StandardCharsets.UTF_8)); - out.flush(); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private static String ping() - { - return ("event: " + TurnEvent.PING + "\ndata: {}\n\n"); - } - - - - /******************************************************************************* - ** Session + API for a request. 404 when the QBit is not in the instance. - *******************************************************************************/ - private AssistantApi api(Context context) throws Exception - { - AssistantRuntime runtime = AssistantRuntime.of(qInstance); - if(runtime == null) - { - throw (new AssistantApiException(404, "The assistant is not installed.")); - } - //////////////////////////////////////////////////////////////////// - // the tools call /qqq/v1 on this server: remember its local port // - //////////////////////////////////////////////////////////////////// - try - { - runtime.learnLocalPort(context.req().getLocalPort()); - } - catch(RuntimeException e) - { - LOG.debug("Could not read the local port", e); - } - QSession session = ExecutorSessionUtils.setupSession(context, qInstance); - return (new AssistantApi(runtime, session)); - } - - - - /******************************************************************************* - ** Map errors: API errors with their status, others through QQQ's handler - ** (401 / 403 / 404 / 500). - *******************************************************************************/ - private static void handleException(Context context, Exception e) - { - if(e instanceof AssistantApiException apiException) - { - Map body = new LinkedHashMap<>(); - body.put("error", apiException.getMessage()); - if(apiException.getCode() != null) - { - body.put("code", apiException.getCode()); - } - context.status(apiException.getStatus()); - context.contentType(ContentType.APPLICATION_JSON); - context.result(JsonUtils.toJson(body)); - return; - } - if(!(e instanceof QException)) - { - LOG.warn("Assistant request failed", e); - } - QJavalinUtils.handleException(null, context, e); - } - - - - /******************************************************************************* - ** A JSON object body (empty body = empty object). - *******************************************************************************/ - static Map readJsonBody(Context context) throws AssistantApiException - { - String body = context.body(); - if(!StringUtils.hasContent(body)) - { - return (new LinkedHashMap<>()); - } - - String contentType = context.contentType(); - if(contentType == null || !contentType.toLowerCase().startsWith("application/json")) - { - throw (new AssistantApiException(400, "Request bodies must be JSON (Content-Type: application/json).")); - } - - try - { - Map map = JsonUtils.toObject(body, new TypeReference>() {}, mapper -> mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)); - return (map == null ? new LinkedHashMap<>() : map); - } - catch(Exception e) - { - throw (new AssistantApiException(400, "The request body is not a JSON object.")); - } - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private static Integer pathInt(Context context, String name) throws AssistantApiException - { - try - { - return (Integer.parseInt(context.pathParam(name))); - } - catch(NumberFormatException e) - { - throw (new AssistantApiException(400, name + " must be an integer.")); - } - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private static Integer queryInt(Context context, String name) throws AssistantApiException - { - String value = context.queryParam(name); - if(!StringUtils.hasContent(value)) - { - return (null); - } - try - { - return (Integer.parseInt(value.trim())); - } - catch(NumberFormatException e) - { - throw (new AssistantApiException(400, name + " must be an integer.")); - } - } - - - - /******************************************************************************* - ** One JSON endpoint's work. - *******************************************************************************/ - @FunctionalInterface - private interface Handler - { - /******************************************************************************* - ** - *******************************************************************************/ - Object handle(AssistantApi api, Map body) throws Exception; } } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRoutes.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRoutes.java index be76268..e5a1728 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRoutes.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRoutes.java @@ -19,7 +19,9 @@ package com.kingsrook.qbits.assistant.api; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qbits.assistant.api.specs.AssistantSpecs; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData; import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData; @@ -43,6 +45,16 @@ public class AssistantRoutes { javalinMetaData.withAdditionalRouteProviderReference(new QCodeReference(AssistantRouteProvider.class)); } + + //////////////////////////////////////////////////////////////////// + // the endpoints: the spec'd "assistant" UI API extension // + //////////////////////////////////////////////////////////////////// + MiddlewareSpecExtensionsMetaData extensions = MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance); + boolean hasExtension = extensions.getExtensions() != null && extensions.getExtensions().containsKey(AssistantSpecs.EXTENSION_NAME); + if(!hasExtension) + { + extensions.withExtension(AssistantSpecs.extension()); + } } } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/TurnStreams.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/TurnStreams.java new file mode 100644 index 0000000..9e9ccb3 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/TurnStreams.java @@ -0,0 +1,222 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant.api; + + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import com.kingsrook.qbits.assistant.engine.TurnEvent; +import com.kingsrook.qbits.assistant.engine.TurnEventLog; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import io.javalin.http.Context; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** Writes a turn's events as server-sent events: the live log of a turn + ** running here, or the persisted events (polling while the turn runs on + ** another replica), with a ping every pingMillis while idle. + *******************************************************************************/ +public final class TurnStreams +{ + private static final QLogger LOG = QLogger.getLogger(TurnStreams.class); + + public static final long PING_MILLIS = 15_000; + public static final long REMOTE_POLL_MS = 1_000; + + private static volatile long pingMillis = PING_MILLIS; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private TurnStreams() + { + } + + + + /******************************************************************************* + ** The ping interval (tests use a short one). + *******************************************************************************/ + public static void setPingMillis(long millis) + { + pingMillis = millis; + } + + + + /******************************************************************************* + ** Stream a live log from a sequence number until message.end. + *******************************************************************************/ + public static void streamLive(Context context, TurnEventLog log, int after) + { + try + { + writeLive(startStream(context), log, after); + } + catch(IOException e) + { + LOG.debug("Assistant stream closed by the client (the turn continues)"); + } + catch(InterruptedException e) + { + Thread.currentThread().interrupt(); + } + } + + + + /******************************************************************************* + ** Stream the events of a turn after a sequence number: live when it runs + ** here, else the persisted ones (polling while it runs elsewhere). The + ** caller's QContext (session) must be set: the replay reads the store. + *******************************************************************************/ + public static void streamEvents(Context context, AssistantApi api, Integer turnId, int after, AssistantApi.TurnEvents first) + { + if(first.live() != null) + { + streamLive(context, first.live().getLog(), after); + return; + } + try + { + OutputStream out = startStream(context); + int last = after; + long lastWrite = System.currentTimeMillis(); + long giveUpAt = System.currentTimeMillis() + (api.maxTurnSeconds() + 60L) * 1000L; + AssistantApi.TurnEvents current = first; + while(true) + { + boolean ended = false; + for(TurnEvent event : current.persisted()) + { + write(out, event.toSseFrame()); + last = event.getSeq(); + lastWrite = System.currentTimeMillis(); + ended |= event.isTerminal(); + } + if(ended || (current.finished() && !current.more()) || System.currentTimeMillis() > giveUpAt) + { + return; + } + if(!current.more()) + { + Thread.sleep(REMOTE_POLL_MS); + } + if(System.currentTimeMillis() - lastWrite >= pingMillis) + { + write(out, ping()); + lastWrite = System.currentTimeMillis(); + } + current = api.turnEvents(turnId, last); + if(current.live() != null) + { + writeLive(out, current.live().getLog(), last); + return; + } + } + } + catch(IOException e) + { + LOG.debug("Assistant event stream closed by the client", logPair("turnId", turnId)); + } + catch(InterruptedException e) + { + Thread.currentThread().interrupt(); + } + catch(Exception e) + { + LOG.warn("Assistant event replay failed", e, logPair("turnId", turnId)); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void writeLive(OutputStream out, TurnEventLog log, int after) throws IOException, InterruptedException + { + int last = after; + while(true) + { + List events = log.after(last, pingMillis); + if(events.isEmpty()) + { + if(log.isClosed()) + { + return; + } + write(out, ping()); + continue; + } + for(TurnEvent event : events) + { + write(out, event.toSseFrame()); + last = event.getSeq(); + if(event.isTerminal()) + { + return; + } + } + } + } + + + + /******************************************************************************* + ** Start an SSE response. + *******************************************************************************/ + private static OutputStream startStream(Context context) throws IOException + { + context.status(200); + context.disableCompression(); + context.res().setContentType("text/event-stream; charset=utf-8"); + context.res().setHeader("Cache-Control", "no-cache"); + context.res().setHeader("X-Accel-Buffering", "no"); + context.res().setHeader("Connection", "keep-alive"); + OutputStream out = context.res().getOutputStream(); + out.flush(); + return (out); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void write(OutputStream out, String frame) throws IOException + { + out.write(frame.getBytes(StandardCharsets.UTF_8)); + out.flush(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String ping() + { + return ("event: " + TurnEvent.PING + "\ndata: {}\n\n"); + } + +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java new file mode 100644 index 0000000..518e982 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java @@ -0,0 +1,1336 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant.api.specs; + + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.kingsrook.qbits.assistant.AssistantRuntime; +import com.kingsrook.qbits.assistant.api.AssistantApi; +import com.kingsrook.qbits.assistant.api.AssistantApiException; +import com.kingsrook.qbits.assistant.api.TurnStreams; +import com.kingsrook.qbits.assistant.engine.RunningTurn; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException; +import com.kingsrook.qqq.backend.core.utils.ExceptionUtils; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.middleware.javalin.QJavalinUtils; +import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation; +import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse; +import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec; +import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; +import com.kingsrook.qqq.openapi.model.HttpMethod; +import com.kingsrook.qqq.openapi.model.In; +import com.kingsrook.qqq.openapi.model.Parameter; +import com.kingsrook.qqq.openapi.model.Schema; +import com.kingsrook.qqq.openapi.model.Type; +import io.javalin.http.ContentType; +import io.javalin.http.Context; +import io.javalin.http.HttpStatus; + + +/******************************************************************************* + ** The assistant's own endpoints as spec'd /qqq/v1 operations (UI API + ** extension "assistant", established prefix /assistant; QRun-IO/qqq#1128, + ** docs/UI-API-EXTENSIONS.md). They replace the raw AssistantRouteProvider + ** routes, with the same URLs and bodies, so they appear in the served + ** /qqq/v1/openapi.yaml and a generated client covers them. + ** + ** Errors keep the assistant's shape: HTTP status plus {"error", "code"}. + ** Turns and events are text/event-stream; each event type is documented as + ** AssistantTurnEventV1. + *******************************************************************************/ +public final class AssistantSpecs +{ + public static final String EXTENSION_NAME = "assistant"; + public static final String EXTENSION_VERSION = "1.0.0"; + public static final String PREFIX = "/assistant"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AssistantSpecs() + { + } + + + + /******************************************************************************* + ** The extension to register in MiddlewareSpecExtensionsMetaData. + *******************************************************************************/ + public static MiddlewareSpecExtension extension() + { + return (new MiddlewareSpecExtension() + .withName(EXTENSION_NAME) + .withSemanticVersion(EXTENSION_VERSION) + .withPathPrefix(PREFIX) + .withDescription("QQQ Assistant: conversations, streamed turns, feedback, pending actions and data access explanations") + .withSpec(ConfigSpec.class) + .withSpec(ListConversationsSpec.class) + .withSpec(CreateConversationSpec.class) + .withSpec(GetConversationSpec.class) + .withSpec(UpdateConversationSpec.class) + .withSpec(DeleteConversationSpec.class) + .withSpec(StartTurnSpec.class) + .withSpec(TurnEventsSpec.class) + .withSpec(CancelTurnSpec.class) + .withSpec(FeedbackSpec.class) + .withSpec(GetActionSpec.class) + .withSpec(ConfirmActionSpec.class) + .withSpec(RejectActionSpec.class) + .withSpec(AccessSpec.class)); + } + + + + //////////////////////////////////////////////////////////////////////////// + // schemas // + //////////////////////////////////////////////////////////////////////////// + + /******************************************************************************* + ** + *******************************************************************************/ + static Schema object(String description, Object... namesAndSchemas) + { + Map properties = new LinkedHashMap<>(); + for(int i = 0; i + 1 < namesAndSchemas.length; i += 2) + { + properties.put((String) namesAndSchemas[i], (Schema) namesAndSchemas[i + 1]); + } + return (new Schema().withType(Type.OBJECT).withDescription(description).withProperties(properties)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Schema string(String description) + { + return (new Schema().withType(Type.STRING).withDescription(description)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Schema integer(String description) + { + return (new Schema().withType(Type.INTEGER).withDescription(description)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Schema bool(String description) + { + return (new Schema().withType(Type.BOOLEAN).withDescription(description)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Schema array(Schema items, String description) + { + return (new Schema().withType(Type.ARRAY).withItems(items).withDescription(description)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Schema ref(String component) + { + return (new Schema().withRefToSchema(component)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Schema freeObject(String description) + { + return (new Schema().withType(Type.OBJECT).withDescription(description).withAdditionalProperties(true)); + } + + + + /******************************************************************************* + ** Every component schema of the extension (each spec publishes all of them; + ** the OpenAPI model merges equal components). + *******************************************************************************/ + static Map components() + { + Map rs = new LinkedHashMap<>(); + rs.put("AssistantProfileV1", object("How the assistant presents itself (admin-configured; text only, it never changes access).", + "displayName", string("The assistant's name."), "icon", string("A Material icon name."), "greeting", string("Shown on a new conversation.").withNullable(true), + "starterPrompts", array(string(null), "Suggested first questions."))); + rs.put("AssistantConfigResponseV1", object("Whether the assistant is available to this user, and how.", + "enabled", bool("Whether the panel may be used."), + "reason", string("Why not: DISABLED, NO_PERMISSION, NOT_CONFIGURED or KILL_SWITCH.").withNullable(true), + "shortcut", string("Keyboard shortcut, e.g. mod+j."), + "model", string("The model that answers."), + "limits", freeObject("maxTurnSeconds and maxToolCallsPerTurn."), + "features", freeObject("writes: some table allows writes; pendingActions: writes wait for confirmation."), + "profile", ref("AssistantProfileV1"))); + rs.put("AssistantConversationV1", object("One of the user's own conversations.", + "id", integer(null), "title", string(null).withNullable(true), "status", string("ACTIVE or ARCHIVED."), "pinned", bool(null), + "lastMessageAt", string("ISO-8601 instant.").withNullable(true), "unreadCount", integer(null), "createDate", string("ISO-8601 instant.").withNullable(true))); + rs.put("AssistantConversationListResponseV1", object("The user's conversations, newest first.", "conversations", array(ref("AssistantConversationV1"), null))); + rs.put("AssistantMessageV1", object("A message: the user's text or the assistant's reply as parts (text, tool, record_refs, navigate, notice).", + "id", integer(null), "role", string("USER or ASSISTANT."), "status", string(null), "turnId", integer(null).withNullable(true), + "createDate", string(null).withNullable(true), "parts", array(freeObject("A message part."), null))); + rs.put("AssistantConversationDetailResponseV1", object("A conversation and a page of its messages, oldest first.", + "conversation", ref("AssistantConversationV1"), "messages", array(ref("AssistantMessageV1"), null))); + rs.put("AssistantStatusResponseV1", object("An acknowledgement.", "status", string(null))); + rs.put("AssistantActionV1", object("A write the assistant proposed and its state.", + "id", integer(null), "status", string("PENDING, RUNNING, EXECUTED, FAILED, REJECTED or EXPIRED."), "toolName", string(null), "tableName", string(null), + "recordId", string(null).withNullable(true), "summary", string(null), "httpStatus", integer("The UI API status of the write.").withNullable(true), + "resultSummary", string(null).withNullable(true), "expiresAt", string(null).withNullable(true), + "outcome", string("After a confirm: the tool outcome.").withNullable(true), "message", string("After a confirm: what happened.").withNullable(true), + "href", string("After a confirm: the written record's /app link.").withNullable(true))); + rs.put("AssistantAccessResponseV1", object("Explain access: per table, the user's UI permissions, the assistant's data access, and each tool's verdict.", + "tables", array(freeObject("table, label, dataAccess, dataAccessSource, ui {visible, read, insert, edit, delete}, operations [{tool, operation, allowed, reason}]."), null))); + rs.put("AssistantTurnEventV1", object("One server-sent event of a turn (event: , id: , data: this object). Types: message.start, text.delta, tool.start, tool.result, ui.navigate, notice, usage, message.end, error, ping.", + "seq", integer("Sequence number; resume with ?after=."), "type", string("The event type (also the SSE event name)."), + "data", freeObject("The event's fields, by type."))); + rs.put("AssistantErrorV1", object("An error.", "error", string("A message for the user."), "code", string("BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED, NO_PERMISSION...").withNullable(true))); + return (rs); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Parameter pathParam(String name, String description) + { + return (new Parameter().withName(name).withDescription(description).withRequired(true).withSchema(new Schema().withType(Type.INTEGER)).withIn(In.PATH)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Parameter queryParam(String name, Type type, String description) + { + return (new Parameter().withName(name).withDescription(description).withRequired(false).withSchema(new Schema().withType(type)).withIn(In.QUERY)); + } + + + + //////////////////////////////////////////////////////////////////////////// + // base // + //////////////////////////////////////////////////////////////////////////// + + /******************************************************************************* + ** Base of the assistant's JSON specs: the user's own AssistantApi (with the + ** request's credentials, forwarded to the UI API by the tools), errors in + ** the assistant's {"error","code"} shape with their status. + *******************************************************************************/ + public abstract static class AssistantJsonSpec extends AbstractJsonEndpointSpec + { + private static final ThreadLocal CONTEXT = new ThreadLocal<>(); + + + + /******************************************************************************* + ** The operation: method and path under /assistant. + *******************************************************************************/ + protected abstract HttpMethod method(); + + /******************************************************************************* + ** The path, e.g. /assistant/conversations/{id}. + *******************************************************************************/ + protected abstract String path(); + + /******************************************************************************* + ** A one-line summary. + *******************************************************************************/ + protected abstract String summary(); + + /******************************************************************************* + ** The work, with the user's AssistantApi. + *******************************************************************************/ + protected abstract Object handle(AssistantApi api, JsonInput input) throws Exception; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public BasicOperation defineBasicOperation() + { + return (new BasicOperation().withPath(path()).withHttpMethod(method()).withShortSummary(summary()).withLongDescription(summary())); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public Map defineComponentSchemas() + { + return (components()); + } + + + + /******************************************************************************* + ** The success status (200 unless an operation says otherwise). + *******************************************************************************/ + protected HttpStatus successStatus() + { + return (HttpStatus.OK); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public BasicResponse defineBasicSuccessResponse() + { + HttpStatus status = successStatus(); + return (status == HttpStatus.NO_CONTENT + ? new BasicResponse(ContentType.APPLICATION_JSON.getMimeType(), status, "Done (no body)", null, null) + : new BasicResponse(ContentType.APPLICATION_JSON.getMimeType(), status, "Successful Response", getResponseSchemaName(), null)); + } + + + + /******************************************************************************* + ** Keep the request (for its credentials and the server's local port). + *******************************************************************************/ + @Override + public JsonInput buildInput(Context context) throws Exception + { + CONTEXT.set(context); + return (super.buildInput(context)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected Object handle(JsonInput input) throws Exception + { + AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance()); + if(runtime == null) + { + throw (new AssistantApiException(404, "The assistant is not installed.")); + } + Context context = CONTEXT.get(); + if(context != null) + { + try + { + runtime.learnLocalPort(context.req().getLocalPort()); + } + catch(RuntimeException e) + { + //////////////////////////////////// + // not a servlet request (tests) // + //////////////////////////////////// + } + } + AssistantApi api = new AssistantApi(runtime, QContext.getQSession()).withCredentials(context == null ? null : UiApiCredentials.fromContext(context)); + return (handle(api, input)); + } + + + + /******************************************************************************* + ** JSON with nulls kept (as before). + *******************************************************************************/ + @Override + public void handleOutput(Context context, JsonOutput output) throws Exception + { + try + { + context.status(successStatus().getCode()); + if(successStatus() != HttpStatus.NO_CONTENT) + { + context.contentType(ContentType.APPLICATION_JSON); + context.result(JsonUtils.toJsonCustomized(output.getResult(), builder -> builder.serializationInclusion(JsonInclude.Include.ALWAYS))); + } + } + finally + { + CONTEXT.remove(); + } + } + + + + /******************************************************************************* + ** Assistant errors keep their status and {"error","code"}; others go + ** through QQQ's mapping (400 / 401 / 403 / 404 / 500). + *******************************************************************************/ + @Override + protected void handleException(Context context, Exception e) + { + CONTEXT.remove(); + AssistantApiException apiException = ExceptionUtils.findClassInRootChain(e, AssistantApiException.class); + if(apiException != null) + { + Map body = new LinkedHashMap<>(); + body.put("error", apiException.getMessage()); + if(apiException.getCode() != null) + { + body.put("code", apiException.getCode()); + } + context.status(apiException.getStatus()); + context.contentType(ContentType.APPLICATION_JSON); + context.result(JsonUtils.toJson(body)); + return; + } + QJavalinUtils.handleException(null, context, e); + } + + + + /******************************************************************************* + ** The error statuses every assistant operation may answer. + *******************************************************************************/ + @Override + public List defineAdditionalBasicResponses() + { + return (List.of( + new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "AssistantErrorV1"), + new BasicResponse(HttpStatus.FORBIDDEN, "No access to the assistant", "AssistantErrorV1"), + new BasicResponse(HttpStatus.NOT_FOUND, "Not found (or not the user's own)", "AssistantErrorV1"), + new BasicResponse(HttpStatus.CONFLICT, "Already finished or decided", "AssistantErrorV1"), + new BasicResponse(HttpStatus.TOO_MANY_REQUESTS, "Budget or rate limit", "AssistantErrorV1"), + new BasicResponse(HttpStatus.SERVICE_UNAVAILABLE, "Disabled, not configured, or the kill switch is on", "AssistantErrorV1"))); + } + + + + /******************************************************************************* + ** An integer path parameter, or 400. + *******************************************************************************/ + protected static Integer pathInt(JsonInput input, String name) throws QBadRequestException + { + try + { + return (Integer.parseInt(input.getPathParam(name))); + } + catch(NumberFormatException | NullPointerException e) + { + throw (new QBadRequestException(name + " must be an integer.")); + } + } + } + + + + //////////////////////////////////////////////////////////////////////////// + // the operations // + //////////////////////////////////////////////////////////////////////////// + + /******************************************************************************* + ** GET /assistant/config + *******************************************************************************/ + public static class ConfigSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.GET); + } + + + + @Override + protected String path() + { + return (PREFIX + "/config"); + } + + + + @Override + protected String summary() + { + return ("Whether the assistant is available to this user, its persona and limits"); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantConfigResponseV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.config()); + } + } + + + + /******************************************************************************* + ** GET /assistant/conversations + *******************************************************************************/ + public static class ListConversationsSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.GET); + } + + + + @Override + protected String path() + { + return (PREFIX + "/conversations"); + } + + + + @Override + protected String summary() + { + return ("The user's own conversations"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(queryParam("status", Type.STRING, "ARCHIVED for archived conversations; active ones by default."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantConversationListResponseV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.listConversations(input.getQueryParam("status"))); + } + } + + + + /******************************************************************************* + ** POST /assistant/conversations + *******************************************************************************/ + public static class CreateConversationSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.POST); + } + + + + @Override + protected String path() + { + return (PREFIX + "/conversations"); + } + + + + @Override + protected String summary() + { + return ("Start a conversation"); + } + + + + @Override + protected Schema defineRequestBodySchema() + { + return (object("Optional title and the page context it starts from.", "title", string(null), "context", freeObject("route, tableName, recordId, filter, selectedIds."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantConversationV1"); + } + + + + @Override + protected HttpStatus successStatus() + { + return (HttpStatus.CREATED); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.createConversation(input.getBody())); + } + } + + + + /******************************************************************************* + ** GET /assistant/conversations/{id} + *******************************************************************************/ + public static class GetConversationSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.GET); + } + + + + @Override + protected String path() + { + return (PREFIX + "/conversations/{id}"); + } + + + + @Override + protected String summary() + { + return ("A conversation and a page of its messages"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The conversation id."), queryParam("before", Type.INTEGER, "Only messages older than this message id."), + queryParam("limit", Type.INTEGER, "Page size (default 50, at most 200)."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantConversationDetailResponseV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.getConversation(pathInt(input, "id"), input.getQueryParamInteger("before"), input.getQueryParamInteger("limit"))); + } + } + + + + /******************************************************************************* + ** PATCH /assistant/conversations/{id} + *******************************************************************************/ + public static class UpdateConversationSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.PATCH); + } + + + + @Override + protected String path() + { + return (PREFIX + "/conversations/{id}"); + } + + + + @Override + protected String summary() + { + return ("Rename, pin or archive a conversation"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The conversation id."))); + } + + + + @Override + protected Schema defineRequestBodySchema() + { + return (object("The changed properties.", "title", string(null), "pinned", bool(null), "status", string("ACTIVE or ARCHIVED."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantConversationV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.patchConversation(pathInt(input, "id"), input.getBody())); + } + } + + + + /******************************************************************************* + ** DELETE /assistant/conversations/{id} + *******************************************************************************/ + public static class DeleteConversationSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.DELETE); + } + + + + @Override + protected String path() + { + return (PREFIX + "/conversations/{id}"); + } + + + + @Override + protected String summary() + { + return ("Delete a conversation"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The conversation id."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantStatusResponseV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + api.deleteConversation(pathInt(input, "id")); + return (null); + } + + + + @Override + protected HttpStatus successStatus() + { + return (HttpStatus.NO_CONTENT); + } + } + + + + /******************************************************************************* + ** POST /assistant/conversations/{id}/turns (text/event-stream) + *******************************************************************************/ + public static class StartTurnSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.POST); + } + + + + @Override + protected String path() + { + return (PREFIX + "/conversations/{id}/turns"); + } + + + + @Override + protected String summary() + { + return ("Ask: start a turn and stream the reply as server-sent events"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The conversation id."))); + } + + + + @Override + protected Schema defineRequestBodySchema() + { + return (object("The user's text and the page context hint.", "text", string("The user's message."), "pageContext", freeObject("Where the user is in the app."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantTurnEventV1"); + } + + + + @Override + public BasicResponse defineBasicSuccessResponse() + { + return (new BasicResponse("text/event-stream", HttpStatus.OK, "The turn's events, until message.end", "AssistantTurnEventV1", null)); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.startTurn(pathInt(input, "id"), input.getBody())); + } + + + + /******************************************************************************* + ** Stream the running turn's live log. + *******************************************************************************/ + @Override + public void handleOutput(Context context, JsonOutput output) throws Exception + { + try + { + TurnStreams.streamLive(context, ((RunningTurn) output.getResult()).getLog(), 0); + } + finally + { + AssistantJsonSpec.CONTEXT.remove(); + } + } + } + + + + /******************************************************************************* + ** GET /assistant/turns/{turnId}/events?after= (text/event-stream) + *******************************************************************************/ + public static class TurnEventsSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.GET); + } + + + + @Override + protected String path() + { + return (PREFIX + "/turns/{turnId}/events"); + } + + + + @Override + protected String summary() + { + return ("Resume a turn's events after a sequence number (replay, then the live tail)"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("turnId", "The turn id."), queryParam("after", Type.INTEGER, "The last sequence number already received (0 for all)."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantTurnEventV1"); + } + + + + @Override + public BasicResponse defineBasicSuccessResponse() + { + return (new BasicResponse("text/event-stream", HttpStatus.OK, "The turn's events after `after`, until message.end", "AssistantTurnEventV1", null)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + record Events(AssistantApi api, Integer turnId, int after, AssistantApi.TurnEvents first) + { + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + Integer turnId = pathInt(input, "turnId"); + Integer after = input.getQueryParamInteger("after"); + int from = after == null ? 0 : Math.max(0, after); + return (new Events(api, turnId, from, api.turnEvents(turnId, from))); + } + + + + /******************************************************************************* + ** Stream (the QContext session is still set: the replay reads the store). + *******************************************************************************/ + @Override + public void handleOutput(Context context, JsonOutput output) throws Exception + { + try + { + Events events = (Events) output.getResult(); + TurnStreams.streamEvents(context, events.api(), events.turnId(), events.after(), events.first()); + } + finally + { + AssistantJsonSpec.CONTEXT.remove(); + } + } + } + + + + /******************************************************************************* + ** POST /assistant/turns/{turnId}/cancel + *******************************************************************************/ + public static class CancelTurnSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.POST); + } + + + + @Override + protected String path() + { + return (PREFIX + "/turns/{turnId}/cancel"); + } + + + + @Override + protected String summary() + { + return ("Stop a running turn"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("turnId", "The turn id."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantStatusResponseV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.cancelTurn(pathInt(input, "turnId"))); + } + + + + @Override + protected HttpStatus successStatus() + { + return (HttpStatus.ACCEPTED); + } + } + + + + /******************************************************************************* + ** POST /assistant/messages/{id}/feedback + *******************************************************************************/ + public static class FeedbackSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.POST); + } + + + + @Override + protected String path() + { + return (PREFIX + "/messages/{id}/feedback"); + } + + + + @Override + protected String summary() + { + return ("Thumbs up or down on an assistant message"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The assistant message id."))); + } + + + + @Override + protected Schema defineRequestBodySchema() + { + return (object("The rating and an optional reason or comment.", "rating", integer("1 or -1."), "reason", string(null), "comment", string(null))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantStatusResponseV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + api.feedback(pathInt(input, "id"), input.getBody()); + return (null); + } + + + + @Override + protected HttpStatus successStatus() + { + return (HttpStatus.NO_CONTENT); + } + } + + + + /******************************************************************************* + ** GET /assistant/actions/{id} + *******************************************************************************/ + public static class GetActionSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.GET); + } + + + + @Override + protected String path() + { + return (PREFIX + "/actions/{id}"); + } + + + + @Override + protected String summary() + { + return ("A write the assistant proposed, and its state"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The pending action id."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantActionV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.getAction(pathInt(input, "id"))); + } + } + + + + /******************************************************************************* + ** POST /assistant/actions/{id}/confirm + *******************************************************************************/ + public static class ConfirmActionSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.POST); + } + + + + @Override + protected String path() + { + return (PREFIX + "/actions/{id}/confirm"); + } + + + + @Override + protected String summary() + { + return ("Confirm a proposed write: it runs once, through the UI API, with this request's session"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The pending action id."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantActionV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.confirmAction(pathInt(input, "id"))); + } + } + + + + /******************************************************************************* + ** POST /assistant/actions/{id}/reject + *******************************************************************************/ + public static class RejectActionSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.POST); + } + + + + @Override + protected String path() + { + return (PREFIX + "/actions/{id}/reject"); + } + + + + @Override + protected String summary() + { + return ("Cancel a proposed write; nothing changes"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The pending action id."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantActionV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.rejectAction(pathInt(input, "id"))); + } + } + + + + /******************************************************************************* + ** GET /assistant/access + *******************************************************************************/ + public static class AccessSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.GET); + } + + + + @Override + protected String path() + { + return (PREFIX + "/access"); + } + + + + @Override + protected String summary() + { + return ("Explain access: which UI API operations the assistant could call for this user, and why not"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(queryParam("table", Type.STRING, "Only this table."), queryParam("prompt", Type.STRING, "Only the tables this text mentions."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantAccessResponseV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + String table = input.getQueryParam("table"); + return (api.access(StringUtils.hasContent(table) ? table : null, input.getQueryParam("prompt"))); + } + } + +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java index 9b93059..0967698 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java @@ -64,6 +64,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; @@ -135,6 +136,7 @@ public class TurnEngine implements Runnable private long monthCostAtStart = 0; private volatile boolean finished = false; + private UiApiCredentials credentials; @@ -162,6 +164,19 @@ public class TurnEngine implements Runnable + /******************************************************************************* + ** The credentials of the request that started the turn, forwarded to the + ** UI API by the tools (session cookies, Authorization, API key as they came + ** in). Without them the session UUID is used. Never logged. + *******************************************************************************/ + public TurnEngine withCredentials(UiApiCredentials credentials) + { + this.credentials = credentials; + return (this); + } + + + /******************************************************************************* ** Run the turn to completion. Never throws. *******************************************************************************/ @@ -796,7 +811,7 @@ public class TurnEngine implements Runnable *******************************************************************************/ private UiApiToolbox.Context toolContext() { - return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), policy.tools(), this::proposeWrite)); + return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), policy.tools(), this::proposeWrite, credentials)); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java index 6e1e6aa..78d08a2 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java @@ -59,6 +59,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; /******************************************************************************* @@ -150,8 +151,15 @@ public class UiApiToolbox ** Where a tool call runs: the user's session, the gateway, the policy, and ** what to do with a write that needs confirmation. *******************************************************************************/ - public record Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions) + public record Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions, UiApiCredentials credentials) { + /******************************************************************************* + ** With the session UUID only (no request credentials captured). + *******************************************************************************/ + public Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions) + { + this(sessionUUID, userTimezone, gateway, policy, pendingActions, null); + } } @@ -643,7 +651,7 @@ public class UiApiToolbox if(truncated) { UiApiResult counted = context.gateway().invoke(new UiApiCall("POST", "/table/{tableName}/count").withPathParam("tableName", tableName).withJsonBody(filterBody(linkFilter)) - .withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone())); + .withSessionUUID(context.sessionUUID()).withCredentials(context.credentials()).withUserTimezone(context.userTimezone())); totalCount = counted.isSuccess() ? asMap(counted.json()).get("count") : null; } @@ -1240,7 +1248,7 @@ public class UiApiToolbox *******************************************************************************/ private UiApiResult call(Context context, Outcome outcome, UiApiCall call) throws QException { - call.withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()).withInvokedBy("assistant"); + call.withSessionUUID(context.sessionUUID()).withCredentials(context.credentials()).withUserTimezone(context.userTimezone()).withInvokedBy("assistant"); outcome.operation = call.operation(); UiApiResult response = context.gateway().invoke(call); outcome.httpStatus = response.status(); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/CoreUiApiGateway.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/CoreUiApiGateway.java new file mode 100644 index 0000000..8a353de --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/CoreUiApiGateway.java @@ -0,0 +1,161 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant.uiapi; + + +import java.util.Locale; +import java.util.function.Supplier; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiInvoker; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiRequest; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiResponse; +import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1; +import com.kingsrook.qqq.openapi.model.HttpMethod; + + +/******************************************************************************* + ** The assistant's gateway over core's {@link UiApiInvoker} (QRun-IO/qqq#1128, + ** docs/UI-API-EXTENSIONS.md section 5): only spec'd /qqq/v1 operations (core + ** and registered extensions), a loopback request through the identical + ** Javalin pipeline, carrying the caller's own credentials exactly as they + ** came in (session cookies, Authorization, API key). There is no service + ** session and no way to elevate. + *******************************************************************************/ +public class CoreUiApiGateway implements UiApiGateway +{ + public static final String OWN_PREFIX = "/assistant"; + + private final Supplier baseUrlSupplier; + private final QInstance qInstance; + private volatile UiApiInvoker invoker; + + + + /******************************************************************************* + ** @param baseUrlSupplier this server's own root URL (e.g. http://127.0.0.1:8000) + ** @param qInstance the instance (its spec extensions are invocable too) + *******************************************************************************/ + public CoreUiApiGateway(Supplier baseUrlSupplier, QInstance qInstance) + { + this.baseUrlSupplier = baseUrlSupplier; + this.qInstance = qInstance; + } + + + + /******************************************************************************* + ** The invoker, over a v1 middleware version that knows the instance's + ** extensions (built on first use: the instance is complete by then). + *******************************************************************************/ + UiApiInvoker invoker() + { + UiApiInvoker current = invoker; + if(current == null) + { + MiddlewareVersionV1 version = new MiddlewareVersionV1(); + version.setQInstance(qInstance); + current = new UiApiInvoker(baseUrlSupplier, version); + invoker = current; + } + return (current); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public boolean isInvocable(String method, String pathTemplate) + { + /////////////////////////////////////////////////////////////////// + // never the assistant's own operations (no recursion, no turns // + // started by a turn) // + /////////////////////////////////////////////////////////////////// + if(pathTemplate == null || pathTemplate.equals(OWN_PREFIX) || pathTemplate.startsWith(OWN_PREFIX + "/")) + { + return (false); + } + try + { + return (invoker().findOperation(HttpMethod.valueOf(method.toUpperCase(Locale.ROOT)), pathTemplate).isPresent()); + } + catch(IllegalArgumentException e) + { + return (false); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public UiApiResult invoke(UiApiCall call) throws QException + { + if(call == null || call.getMethod() == null || call.getPathTemplate() == null) + { + throw (new QException("A UI API call needs a method and a path template")); + } + if(!isInvocable(call.getMethod(), call.getPathTemplate())) + { + throw (new QException("Not a spec'd UI API operation: " + call.operation())); + } + UiApiCredentials credentials = call.getCredentials() != null ? call.getCredentials() + : (call.getSessionUUID() == null ? null : UiApiCredentials.ofSessionUUID(call.getSessionUUID())); + if(credentials == null || !credentials.isPresent()) + { + throw (new QException("A session is required to invoke " + call.operation())); + } + + UiApiRequest request = new UiApiRequest() + .withMethod(HttpMethod.valueOf(call.getMethod().toUpperCase(Locale.ROOT))) + .withPath(call.getPathTemplate()) + .withCredentials(credentials) + .withUserTimezone(call.getUserTimezone()) + .withInvokedBy(call.getInvokedBy()) + .withTimeout(call.getTimeout()); + call.getPathParams().forEach(request::withPathParam); + call.getQueryParams().forEach(request::withQueryParam); + if(call.getJsonBody() instanceof String json) + { + //////////////////////////////////////////////////////////////// + // a pre-serialized body (e.g. a QQueryFilter as QQQ writes // + // it): parse it, the invoker serializes the object // + //////////////////////////////////////////////////////////////// + try + { + request.withJsonBody(JsonUtils.toObject(json, Object.class)); + } + catch(Exception e) + { + throw (new QException("The body of " + call.operation() + " is not valid JSON", e)); + } + } + else if(call.getJsonBody() != null) + { + request.withJsonBody(call.getJsonBody()); + } + UiApiResponse response = invoker().invoke(request); + return (new UiApiResult(response.getStatus(), response.getBody())); + } + +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/LoopbackUiApiGateway.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/LoopbackUiApiGateway.java deleted file mode 100644 index a91993b..0000000 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/LoopbackUiApiGateway.java +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright 2026 Kingsrook, LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.kingsrook.qbits.assistant.uiapi; - - -import java.io.IOException; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Set; -import java.util.StringJoiner; -import java.util.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 SPEC_OPERATIONS = specOperations(); - - private final Supplier 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 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 specOperations() - { - Set 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 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 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 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")); - } - -} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiCall.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiCall.java index 5ba1e9d..efe9c07 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiCall.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiCall.java @@ -20,6 +20,7 @@ package com.kingsrook.qbits.assistant.uiapi; import java.time.Duration; import java.util.LinkedHashMap; import java.util.Map; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; /******************************************************************************* @@ -35,6 +36,7 @@ public class UiApiCall private final Map queryParams = new LinkedHashMap<>(); private Object jsonBody; private String sessionUUID; + private UiApiCredentials credentials; private String userTimezone; private String invokedBy = "assistant"; private Duration timeout = Duration.ofSeconds(60); @@ -161,6 +163,29 @@ public class UiApiCall + /******************************************************************************* + ** The caller's credentials exactly as their request carried them (session + ** cookies, Authorization, API key); preferred over the session UUID alone. + ** Never logged (their toString is redacted). + *******************************************************************************/ + public UiApiCall withCredentials(UiApiCredentials credentials) + { + this.credentials = credentials; + return (this); + } + + + + /******************************************************************************* + ** Getter for credentials + *******************************************************************************/ + public UiApiCredentials getCredentials() + { + return (credentials); + } + + + /******************************************************************************* ** Getter for sessionUUID *******************************************************************************/ diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiGateway.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiGateway.java index c2064c3..a15e57f 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiGateway.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiGateway.java @@ -25,9 +25,8 @@ import com.kingsrook.qqq.backend.core.exceptions.QException; ** user's own session, so every tool call passes the same authentication, ** permission, record-lock, validation and audit path as the UI. ** - ** {@link LoopbackUiApiGateway} is a stand-in until core's `UiApiInvoker` - ** (QRun-IO/qqq#1128) is released. Then an adapter over the invoker replaces - ** it, and nothing else changes. + ** {@link CoreUiApiGateway} implements it over core's `UiApiInvoker` + ** (QRun-IO/qqq#1128); a host may inject another (tests). *******************************************************************************/ public interface UiApiGateway { diff --git a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml new file mode 100644 index 0000000..0a6097a --- /dev/null +++ b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml @@ -0,0 +1,1088 @@ +components: + schemas: + AssistantAccessResponseV1: + description: "Explain access: per table, the user's UI permissions, the assistant's\ + \ data access, and each tool's verdict." + properties: + tables: + items: + additionalProperties: true + description: "table, label, dataAccess, dataAccessSource, ui {visible,\ + \ read, insert, edit, delete}, operations [{tool, operation, allowed,\ + \ reason}]." + type: "object" + type: "array" + type: "object" + AssistantActionV1: + description: "A write the assistant proposed and its state." + properties: + id: + type: "integer" + status: + description: "PENDING, RUNNING, EXECUTED, FAILED, REJECTED or EXPIRED." + type: "string" + toolName: + type: "string" + tableName: + type: "string" + recordId: + nullable: true + type: "string" + summary: + type: "string" + httpStatus: + description: "The UI API status of the write." + nullable: true + type: "integer" + resultSummary: + nullable: true + type: "string" + expiresAt: + nullable: true + type: "string" + outcome: + description: "After a confirm: the tool outcome." + nullable: true + type: "string" + message: + description: "After a confirm: what happened." + nullable: true + type: "string" + href: + description: "After a confirm: the written record's /app link." + nullable: true + type: "string" + type: "object" + AssistantConfigResponseV1: + description: "Whether the assistant is available to this user, and how." + properties: + enabled: + description: "Whether the panel may be used." + type: "boolean" + reason: + description: "Why not: DISABLED, NO_PERMISSION, NOT_CONFIGURED or KILL_SWITCH." + nullable: true + type: "string" + shortcut: + description: "Keyboard shortcut, e.g. mod+j." + type: "string" + model: + description: "The model that answers." + type: "string" + limits: + additionalProperties: true + description: "maxTurnSeconds and maxToolCallsPerTurn." + type: "object" + features: + additionalProperties: true + description: "writes: some table allows writes; pendingActions: writes wait\ + \ for confirmation." + type: "object" + profile: + $ref: "#/components/schemas/AssistantProfileV1" + type: "object" + AssistantConversationDetailResponseV1: + description: "A conversation and a page of its messages, oldest first." + properties: + conversation: + $ref: "#/components/schemas/AssistantConversationV1" + messages: + items: + $ref: "#/components/schemas/AssistantMessageV1" + type: "array" + type: "object" + AssistantConversationListResponseV1: + description: "The user's conversations, newest first." + properties: + conversations: + items: + $ref: "#/components/schemas/AssistantConversationV1" + type: "array" + type: "object" + AssistantConversationV1: + description: "One of the user's own conversations." + properties: + id: + type: "integer" + title: + nullable: true + type: "string" + status: + description: "ACTIVE or ARCHIVED." + type: "string" + pinned: + type: "boolean" + lastMessageAt: + description: "ISO-8601 instant." + nullable: true + type: "string" + unreadCount: + type: "integer" + createDate: + description: "ISO-8601 instant." + nullable: true + type: "string" + type: "object" + AssistantErrorV1: + description: "An error." + properties: + error: + description: "A message for the user." + type: "string" + code: + description: "BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED,\ + \ NO_PERMISSION..." + nullable: true + type: "string" + type: "object" + AssistantMessageV1: + description: "A message: the user's text or the assistant's reply as parts (text,\ + \ tool, record_refs, navigate, notice)." + properties: + id: + type: "integer" + role: + description: "USER or ASSISTANT." + type: "string" + status: + type: "string" + turnId: + nullable: true + type: "integer" + createDate: + nullable: true + type: "string" + parts: + items: + additionalProperties: true + description: "A message part." + type: "object" + type: "array" + type: "object" + AssistantProfileV1: + description: "How the assistant presents itself (admin-configured; text only,\ + \ it never changes access)." + properties: + displayName: + description: "The assistant's name." + type: "string" + icon: + description: "A Material icon name." + type: "string" + greeting: + description: "Shown on a new conversation." + nullable: true + type: "string" + starterPrompts: + description: "Suggested first questions." + items: + type: "string" + type: "array" + type: "object" + AssistantStatusResponseV1: + description: "An acknowledgement." + properties: + status: + type: "string" + type: "object" + AssistantTurnEventV1: + description: "One server-sent event of a turn (event: , id: , data:\ + \ this object). Types: message.start, text.delta, tool.start, tool.result,\ + \ ui.navigate, notice, usage, message.end, error, ping." + properties: + seq: + description: "Sequence number; resume with ?after=." + type: "integer" + type: + description: "The event type (also the SSE event name)." + type: "string" + data: + additionalProperties: true + description: "The event's fields, by type." + type: "object" + type: "object" + securitySchemes: + sessionUuidCookie: + in: "cookie" + name: "sessionUUID" + type: "apiKey" +info: + contact: + email: "contact@kingsrook.com" + description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\ + \ and data access explanations" + title: "QQQ Middleware API extension: assistant" + version: "1.0.0" +openapi: "3.0.3" +paths: + /qqq/v1/assistant/config: + get: + description: "Whether the assistant is available to this user, its persona and\ + \ limits" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConfigResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Whether the assistant is available to this user, its persona and limits" + /qqq/v1/assistant/conversations: + get: + description: "The user's own conversations" + parameters: + - description: "ARCHIVED for archived conversations; active ones by default." + in: "query" + name: "status" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationListResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "The user's own conversations" + post: + description: "Start a conversation" + requestBody: + content: + application/json: + schema: + description: "Optional title and the page context it starts from." + properties: + title: + type: "string" + context: + additionalProperties: true + description: "route, tableName, recordId, filter, selectedIds." + type: "object" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 201: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationV1" + description: "Successful Response" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Start a conversation" + /qqq/v1/assistant/conversations/{id}: + delete: + description: "Delete a conversation" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 204: + content: + application/json: + schema: + $ref: "#/components/schemas/null" + description: "Done (no body)" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Delete a conversation" + get: + description: "A conversation and a page of its messages" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + - description: "Only messages older than this message id." + in: "query" + name: "before" + required: false + schema: + type: "integer" + - description: "Page size (default 50, at most 200)." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationDetailResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "A conversation and a page of its messages" + patch: + description: "Rename, pin or archive a conversation" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + requestBody: + content: + application/json: + schema: + description: "The changed properties." + properties: + title: + type: "string" + pinned: + type: "boolean" + status: + description: "ACTIVE or ARCHIVED." + type: "string" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Rename, pin or archive a conversation" + /qqq/v1/assistant/conversations/{id}/turns: + post: + description: "Ask: start a turn and stream the reply as server-sent events" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + requestBody: + content: + application/json: + schema: + description: "The user's text and the page context hint." + properties: + text: + description: "The user's message." + type: "string" + pageContext: + additionalProperties: true + description: "Where the user is in the app." + type: "object" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + text/event-stream: + schema: + $ref: "#/components/schemas/AssistantTurnEventV1" + description: "The turn's events, until message.end" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Ask: start a turn and stream the reply as server-sent events" + /qqq/v1/assistant/turns/{turnId}/events: + get: + description: "Resume a turn's events after a sequence number (replay, then the\ + \ live tail)" + parameters: + - description: "The turn id." + in: "path" + name: "turnId" + required: true + schema: + type: "integer" + - description: "The last sequence number already received (0 for all)." + in: "query" + name: "after" + required: false + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + text/event-stream: + schema: + $ref: "#/components/schemas/AssistantTurnEventV1" + description: "The turn's events after `after`, until message.end" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Resume a turn's events after a sequence number (replay, then the live\ + \ tail)" + /qqq/v1/assistant/turns/{turnId}/cancel: + post: + description: "Stop a running turn" + parameters: + - description: "The turn id." + in: "path" + name: "turnId" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 202: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantStatusResponseV1" + description: "Successful Response" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Stop a running turn" + /qqq/v1/assistant/messages/{id}/feedback: + post: + description: "Thumbs up or down on an assistant message" + parameters: + - description: "The assistant message id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + requestBody: + content: + application/json: + schema: + description: "The rating and an optional reason or comment." + properties: + rating: + description: "1 or -1." + type: "integer" + reason: + type: "string" + comment: + type: "string" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 204: + content: + application/json: + schema: + $ref: "#/components/schemas/null" + description: "Done (no body)" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Thumbs up or down on an assistant message" + /qqq/v1/assistant/actions/{id}: + get: + description: "A write the assistant proposed, and its state" + parameters: + - description: "The pending action id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "A write the assistant proposed, and its state" + /qqq/v1/assistant/actions/{id}/confirm: + post: + description: "Confirm a proposed write: it runs once, through the UI API, with\ + \ this request's session" + parameters: + - description: "The pending action id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Confirm a proposed write: it runs once, through the UI API, with this\ + \ request's session" + /qqq/v1/assistant/actions/{id}/reject: + post: + description: "Cancel a proposed write; nothing changes" + parameters: + - description: "The pending action id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Cancel a proposed write; nothing changes" + /qqq/v1/assistant/access: + get: + description: "Explain access: which UI API operations the assistant could call\ + \ for this user, and why not" + parameters: + - description: "Only this table." + in: "query" + name: "table" + required: false + schema: + type: "string" + - description: "Only the tables this text mentions." + in: "query" + name: "prompt" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantAccessResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Explain access: which UI API operations the assistant could call for\ + \ this user, and why not" diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/ArchitectureTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/ArchitectureTest.java index e805cef..bba0177 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/ArchitectureTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/ArchitectureTest.java @@ -24,7 +24,8 @@ import com.kingsrook.qbits.assistant.model.AssistantProfileSkill; import com.kingsrook.qbits.assistant.model.AssistantSkill; import com.kingsrook.qbits.assistant.persistence.AssistantStore; import com.kingsrook.qbits.assistant.tools.UiApiToolbox; -import com.kingsrook.qbits.assistant.uiapi.LoopbackUiApiGateway; +import com.kingsrook.qbits.assistant.uiapi.CoreUiApiGateway; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel; import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction; import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; @@ -176,9 +177,10 @@ class ArchitectureTest { assertThat(ContextBuilder.KERNEL_TOOLS).containsExactlyInAnyOrder("navigate", "search_help"); assertThat(UiApiToolbox.OPERATIONS.keySet()).doesNotContainAnyElementsOf(ContextBuilder.KERNEL_TOOLS); + CoreUiApiGateway gateway = new CoreUiApiGateway(() -> "http://127.0.0.1:1", new QInstance()); for(String[] operation : UiApiToolbox.OPERATIONS.values()) { - assertThat(LoopbackUiApiGateway.getSpecOperations()).as("spec'd: " + operation[0] + " " + operation[1]).contains(operation[0] + " " + operation[1]); + assertThat(gateway.isInvocable(operation[0], operation[1])).as("spec'd: " + operation[0] + " " + operation[1]).isTrue(); } } diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/AssistantRouteProviderTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/AssistantRouteProviderTest.java index 47d9152..aece4e7 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/AssistantRouteProviderTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/AssistantRouteProviderTest.java @@ -35,11 +35,11 @@ import com.kingsrook.qbits.assistant.model.AssistantPolicy; import com.kingsrook.qbits.assistant.persistence.AssistantStore; import com.kingsrook.qbits.assistant.testing.AssistantTestFixture; import com.kingsrook.qbits.assistant.testing.BaseAssistantTest; +import com.kingsrook.qbits.assistant.testing.UiApiTestServer; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData; -import io.javalin.Javalin; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -57,7 +57,6 @@ import static org.assertj.core.api.Assertions.assertThat; *******************************************************************************/ class AssistantRouteProviderTest extends BaseAssistantTest { - private Javalin javalin; private String base; private HttpClient client = HttpClient.newHttpClient(); @@ -70,10 +69,8 @@ class AssistantRouteProviderTest extends BaseAssistantTest void startServer() { QContext.clear(); - AssistantRouteProvider routes = new AssistantRouteProvider().withPingMillis(200); - routes.setQInstance(qInstance); - javalin = Javalin.create(routes::acceptJavalinConfig).start(0); - base = "http://localhost:" + javalin.port() + AssistantRouteProvider.BASE_PATH; + TurnStreams.setPingMillis(200); + base = UiApiTestServer.serve(qInstance, config) + AssistantRouteProvider.BASE_PATH; } @@ -84,7 +81,7 @@ class AssistantRouteProviderTest extends BaseAssistantTest @AfterEach void stopServer() { - javalin.stop(); + TurnStreams.setPingMillis(TurnStreams.PING_MILLIS); } diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecsContractTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecsContractTest.java new file mode 100644 index 0000000..ba5f1ec --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecsContractTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant.api.specs; + + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import com.kingsrook.qbits.assistant.testing.BaseAssistantTest; +import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData; +import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1; +import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The assistant's UI API extension: registered by the producer, and its + ** OpenAPI fragment matches the committed one + ** (src/main/resources/openapi/qqq-v1/assistant.yaml) and is compatible with + ** the last release's (baseline). Regenerate with + ** -Dassistant.regenerateOpenApi=true. + *******************************************************************************/ +class AssistantSpecsContractTest extends BaseAssistantTest +{ + static final Path COMMITTED = Path.of("src/main/resources/openapi/qqq-v1/assistant.yaml"); + static final Path BASELINE = Path.of("src/test/resources/openapi/qqq-v1/assistant-baseline.yaml"); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testRegisteredAndCommitted() throws Exception + { + assertThat(MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance).getExtensions()).containsKey(AssistantSpecs.EXTENSION_NAME); + + String generated = UiApiContract.generateExtensionYaml(new MiddlewareVersionV1(), AssistantSpecs.extension()); + if(Boolean.getBoolean("assistant.regenerateOpenApi")) + { + Files.createDirectories(COMMITTED.getParent()); + Files.writeString(COMMITTED, generated, StandardCharsets.UTF_8); + if(!Files.exists(BASELINE)) + { + Files.createDirectories(BASELINE.getParent()); + Files.writeString(BASELINE, generated, StandardCharsets.UTF_8); + } + } + + assertThat(generated).contains("/qqq/v1/assistant/conversations/{id}/turns").contains("text/event-stream").contains("/qqq/v1/assistant/actions/{id}/confirm") + .contains("AssistantTurnEventV1").contains("/qqq/v1/assistant/access"); + List problems = UiApiContract.check(generated, Files.readString(COMMITTED, StandardCharsets.UTF_8), + Files.exists(BASELINE) ? Files.readString(BASELINE, StandardCharsets.UTF_8) : null, 1, "./mvnw test -pl qbit-assistant -Dtest=AssistantSpecsContractTest -Dassistant.regenerateOpenApi=true"); + assertThat(problems).isEmpty(); + } + +} diff --git a/qbit-assistant/src/test/resources/openapi/qqq-v1/assistant-baseline.yaml b/qbit-assistant/src/test/resources/openapi/qqq-v1/assistant-baseline.yaml new file mode 100644 index 0000000..0a6097a --- /dev/null +++ b/qbit-assistant/src/test/resources/openapi/qqq-v1/assistant-baseline.yaml @@ -0,0 +1,1088 @@ +components: + schemas: + AssistantAccessResponseV1: + description: "Explain access: per table, the user's UI permissions, the assistant's\ + \ data access, and each tool's verdict." + properties: + tables: + items: + additionalProperties: true + description: "table, label, dataAccess, dataAccessSource, ui {visible,\ + \ read, insert, edit, delete}, operations [{tool, operation, allowed,\ + \ reason}]." + type: "object" + type: "array" + type: "object" + AssistantActionV1: + description: "A write the assistant proposed and its state." + properties: + id: + type: "integer" + status: + description: "PENDING, RUNNING, EXECUTED, FAILED, REJECTED or EXPIRED." + type: "string" + toolName: + type: "string" + tableName: + type: "string" + recordId: + nullable: true + type: "string" + summary: + type: "string" + httpStatus: + description: "The UI API status of the write." + nullable: true + type: "integer" + resultSummary: + nullable: true + type: "string" + expiresAt: + nullable: true + type: "string" + outcome: + description: "After a confirm: the tool outcome." + nullable: true + type: "string" + message: + description: "After a confirm: what happened." + nullable: true + type: "string" + href: + description: "After a confirm: the written record's /app link." + nullable: true + type: "string" + type: "object" + AssistantConfigResponseV1: + description: "Whether the assistant is available to this user, and how." + properties: + enabled: + description: "Whether the panel may be used." + type: "boolean" + reason: + description: "Why not: DISABLED, NO_PERMISSION, NOT_CONFIGURED or KILL_SWITCH." + nullable: true + type: "string" + shortcut: + description: "Keyboard shortcut, e.g. mod+j." + type: "string" + model: + description: "The model that answers." + type: "string" + limits: + additionalProperties: true + description: "maxTurnSeconds and maxToolCallsPerTurn." + type: "object" + features: + additionalProperties: true + description: "writes: some table allows writes; pendingActions: writes wait\ + \ for confirmation." + type: "object" + profile: + $ref: "#/components/schemas/AssistantProfileV1" + type: "object" + AssistantConversationDetailResponseV1: + description: "A conversation and a page of its messages, oldest first." + properties: + conversation: + $ref: "#/components/schemas/AssistantConversationV1" + messages: + items: + $ref: "#/components/schemas/AssistantMessageV1" + type: "array" + type: "object" + AssistantConversationListResponseV1: + description: "The user's conversations, newest first." + properties: + conversations: + items: + $ref: "#/components/schemas/AssistantConversationV1" + type: "array" + type: "object" + AssistantConversationV1: + description: "One of the user's own conversations." + properties: + id: + type: "integer" + title: + nullable: true + type: "string" + status: + description: "ACTIVE or ARCHIVED." + type: "string" + pinned: + type: "boolean" + lastMessageAt: + description: "ISO-8601 instant." + nullable: true + type: "string" + unreadCount: + type: "integer" + createDate: + description: "ISO-8601 instant." + nullable: true + type: "string" + type: "object" + AssistantErrorV1: + description: "An error." + properties: + error: + description: "A message for the user." + type: "string" + code: + description: "BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED,\ + \ NO_PERMISSION..." + nullable: true + type: "string" + type: "object" + AssistantMessageV1: + description: "A message: the user's text or the assistant's reply as parts (text,\ + \ tool, record_refs, navigate, notice)." + properties: + id: + type: "integer" + role: + description: "USER or ASSISTANT." + type: "string" + status: + type: "string" + turnId: + nullable: true + type: "integer" + createDate: + nullable: true + type: "string" + parts: + items: + additionalProperties: true + description: "A message part." + type: "object" + type: "array" + type: "object" + AssistantProfileV1: + description: "How the assistant presents itself (admin-configured; text only,\ + \ it never changes access)." + properties: + displayName: + description: "The assistant's name." + type: "string" + icon: + description: "A Material icon name." + type: "string" + greeting: + description: "Shown on a new conversation." + nullable: true + type: "string" + starterPrompts: + description: "Suggested first questions." + items: + type: "string" + type: "array" + type: "object" + AssistantStatusResponseV1: + description: "An acknowledgement." + properties: + status: + type: "string" + type: "object" + AssistantTurnEventV1: + description: "One server-sent event of a turn (event: , id: , data:\ + \ this object). Types: message.start, text.delta, tool.start, tool.result,\ + \ ui.navigate, notice, usage, message.end, error, ping." + properties: + seq: + description: "Sequence number; resume with ?after=." + type: "integer" + type: + description: "The event type (also the SSE event name)." + type: "string" + data: + additionalProperties: true + description: "The event's fields, by type." + type: "object" + type: "object" + securitySchemes: + sessionUuidCookie: + in: "cookie" + name: "sessionUUID" + type: "apiKey" +info: + contact: + email: "contact@kingsrook.com" + description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\ + \ and data access explanations" + title: "QQQ Middleware API extension: assistant" + version: "1.0.0" +openapi: "3.0.3" +paths: + /qqq/v1/assistant/config: + get: + description: "Whether the assistant is available to this user, its persona and\ + \ limits" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConfigResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Whether the assistant is available to this user, its persona and limits" + /qqq/v1/assistant/conversations: + get: + description: "The user's own conversations" + parameters: + - description: "ARCHIVED for archived conversations; active ones by default." + in: "query" + name: "status" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationListResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "The user's own conversations" + post: + description: "Start a conversation" + requestBody: + content: + application/json: + schema: + description: "Optional title and the page context it starts from." + properties: + title: + type: "string" + context: + additionalProperties: true + description: "route, tableName, recordId, filter, selectedIds." + type: "object" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 201: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationV1" + description: "Successful Response" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Start a conversation" + /qqq/v1/assistant/conversations/{id}: + delete: + description: "Delete a conversation" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 204: + content: + application/json: + schema: + $ref: "#/components/schemas/null" + description: "Done (no body)" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Delete a conversation" + get: + description: "A conversation and a page of its messages" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + - description: "Only messages older than this message id." + in: "query" + name: "before" + required: false + schema: + type: "integer" + - description: "Page size (default 50, at most 200)." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationDetailResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "A conversation and a page of its messages" + patch: + description: "Rename, pin or archive a conversation" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + requestBody: + content: + application/json: + schema: + description: "The changed properties." + properties: + title: + type: "string" + pinned: + type: "boolean" + status: + description: "ACTIVE or ARCHIVED." + type: "string" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Rename, pin or archive a conversation" + /qqq/v1/assistant/conversations/{id}/turns: + post: + description: "Ask: start a turn and stream the reply as server-sent events" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + requestBody: + content: + application/json: + schema: + description: "The user's text and the page context hint." + properties: + text: + description: "The user's message." + type: "string" + pageContext: + additionalProperties: true + description: "Where the user is in the app." + type: "object" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + text/event-stream: + schema: + $ref: "#/components/schemas/AssistantTurnEventV1" + description: "The turn's events, until message.end" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Ask: start a turn and stream the reply as server-sent events" + /qqq/v1/assistant/turns/{turnId}/events: + get: + description: "Resume a turn's events after a sequence number (replay, then the\ + \ live tail)" + parameters: + - description: "The turn id." + in: "path" + name: "turnId" + required: true + schema: + type: "integer" + - description: "The last sequence number already received (0 for all)." + in: "query" + name: "after" + required: false + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + text/event-stream: + schema: + $ref: "#/components/schemas/AssistantTurnEventV1" + description: "The turn's events after `after`, until message.end" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Resume a turn's events after a sequence number (replay, then the live\ + \ tail)" + /qqq/v1/assistant/turns/{turnId}/cancel: + post: + description: "Stop a running turn" + parameters: + - description: "The turn id." + in: "path" + name: "turnId" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 202: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantStatusResponseV1" + description: "Successful Response" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Stop a running turn" + /qqq/v1/assistant/messages/{id}/feedback: + post: + description: "Thumbs up or down on an assistant message" + parameters: + - description: "The assistant message id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + requestBody: + content: + application/json: + schema: + description: "The rating and an optional reason or comment." + properties: + rating: + description: "1 or -1." + type: "integer" + reason: + type: "string" + comment: + type: "string" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 204: + content: + application/json: + schema: + $ref: "#/components/schemas/null" + description: "Done (no body)" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Thumbs up or down on an assistant message" + /qqq/v1/assistant/actions/{id}: + get: + description: "A write the assistant proposed, and its state" + parameters: + - description: "The pending action id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "A write the assistant proposed, and its state" + /qqq/v1/assistant/actions/{id}/confirm: + post: + description: "Confirm a proposed write: it runs once, through the UI API, with\ + \ this request's session" + parameters: + - description: "The pending action id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Confirm a proposed write: it runs once, through the UI API, with this\ + \ request's session" + /qqq/v1/assistant/actions/{id}/reject: + post: + description: "Cancel a proposed write; nothing changes" + parameters: + - description: "The pending action id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Cancel a proposed write; nothing changes" + /qqq/v1/assistant/access: + get: + description: "Explain access: which UI API operations the assistant could call\ + \ for this user, and why not" + parameters: + - description: "Only this table." + in: "query" + name: "table" + required: false + schema: + type: "string" + - description: "Only the tables this text mentions." + in: "query" + name: "prompt" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantAccessResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Explain access: which UI API operations the assistant could call for\ + \ this user, and why not" -- 2.54.0 From 292277908bb82771b328b6b8cbd7c978eadf7a55 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 16:09:38 -0500 Subject: [PATCH 2/3] build: require QQQ 4.1.0-RC.6 (UI API extension point and invoker, qqq#1128) Co-Authored-By: Claude Opus 5.5 --- pom.xml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index 6e823ea..12f039b 100644 --- a/pom.xml +++ b/pom.xml @@ -170,8 +170,9 @@ - - + + + @@ -182,7 +183,7 @@ true - 4.1.0-RC.4 + 4.1.0-RC.6 -- 2.54.0 From f7faf0a8c02611a35e771e79ec15339ac5fa3ee4 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 07:53:43 -0500 Subject: [PATCH 3/3] docs: mark HeaderedUiApiInvoker STAND-IN(core=QRun-IO/qqq#1190, removeBy=RC.8) Co-Authored-By: Claude Opus 5.5 --- .../middleware/javalin/specs/invoke/HeaderedUiApiInvoker.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qbit-assistant/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/invoke/HeaderedUiApiInvoker.java b/qbit-assistant/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/invoke/HeaderedUiApiInvoker.java index 2dc96a9..f52a076 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/invoke/HeaderedUiApiInvoker.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/invoke/HeaderedUiApiInvoker.java @@ -34,7 +34,8 @@ import com.kingsrook.qqq.middleware.javalin.specs.AbstractMiddlewareVersion; ** package to override it. Everything else (the operation allow-list, the ** credentials, the client channel, the depth limit) is core's own. ** - ** stand-in until QRun-IO/qqq adds UiApiRequest.withHeader; delete then + ** STAND-IN(core=QRun-IO/qqq#1190, removeBy=RC.8): delete this class and use + ** UiApiRequest.withHeader once the qbit builds on a QQQ RC that has it. *******************************************************************************/ public class HeaderedUiApiInvoker extends UiApiInvoker { -- 2.54.0