diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f0946..370bb12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased - Empty renders and stream context (UI API extension 1.4.0) + +- Tool result renders always carry their lists: a `records` render for a query that matched nothing now has `"rows": []` and `"columns": []` (QQQ's JSON omitted empty lists, and Next's Assistant crashed the page reading `rows.length`). Parts and events stored before the fix are completed when read. Extension 1.4.0 documents the shape as `AssistantRecordsRenderV1`. +- Streamed text arrives on the provider's own (virtual) thread; the turn's progress updates now run in the turn's QContext, so job progress no longer fails with "Could not flush job progress" (NPE on the missing instance). + ## Unreleased - UI API 1.6.0 - UI API extension assistant 1.3.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags and trace IDs. Preserve HTTP statuses and assistant-specific error codes. 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 index 5859a4d..1928164 100644 --- 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 @@ -63,7 +63,7 @@ import io.javalin.http.HttpStatus; public final class AssistantSpecs { public static final String EXTENSION_NAME = "assistant"; - public static final String EXTENSION_VERSION = "1.3.0"; + public static final String EXTENSION_VERSION = "1.4.0"; public static final String PREFIX = "/assistant"; @@ -210,6 +210,11 @@ public final class AssistantSpecs 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("AssistantRecordsRenderV1", object("A tool part's render of type records (query results). columns and rows are always present - empty when the query matched nothing (since 1.4.0; older stored parts are completed when read).", + "type", string("records"), "tableName", string(null), "tableLabel", string(null), + "columns", array(object("A column.", "name", string(null), "label", string(null)), "Always present; empty when nothing matched."), + "rows", array(object("A row.", "id", string("The primary key."), "href", string("The record's /app link."), "values", freeObject("Display values by column name.")), "Always present; empty when nothing matched."), + "totalCount", integer("All matching rows.").withNullable(true), "truncated", bool("More rows match than are shown."), "openHref", string("The Query screen link."))); 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))); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/MessageParts.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/MessageParts.java index 8be9c18..ed9b4d5 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/MessageParts.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/MessageParts.java @@ -53,6 +53,10 @@ public class MessageParts List> list = JsonUtils.toObject(json, new TypeReference>>() {}); if(list != null) { + //////////////////////////////////////////////////////////////// + // parts stored before RenderShapes may lack empty render lists // + //////////////////////////////////////////////////////////////// + list.forEach(RenderShapes::complete); result.parts.addAll(list); } } @@ -240,7 +244,7 @@ public class MessageParts *******************************************************************************/ public synchronized String toJson() { - return (JsonUtils.toJson(parts)); + return (RenderShapes.toJson(parts)); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/RenderShapes.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/RenderShapes.java new file mode 100644 index 0000000..26fc467 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/RenderShapes.java @@ -0,0 +1,106 @@ +/* + * 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.engine; + + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; + + +/******************************************************************************* + ** The shape clients rely on for tool result renders: their lists are always + ** present, empty when there is nothing (a query that matched no rows has + ** "rows": [] and "columns": []). QQQ's default JSON omits empty lists, so + ** parts and stream events are written with {@link #toJson(Object)}, and parts + ** stored before this fix are completed by {@link #complete(Map)} when read. + *******************************************************************************/ +public final class RenderShapes +{ + /** each render type's list fields */ + private static final Map> LISTS = Map.of( + "records", List.of("columns", "rows"), + "record", List.of("fields"), + "history", List.of("entries")); + + + + /******************************************************************************* + ** not instantiable + *******************************************************************************/ + private RenderShapes() + { + } + + + + /******************************************************************************* + ** JSON that keeps empty lists (nulls are still left out). + *******************************************************************************/ + public static String toJson(Object value) + { + return (JsonUtils.toJsonCustomized(value, builder -> builder.serializationInclusion(JsonInclude.Include.NON_NULL))); + } + + + + /******************************************************************************* + ** Fill in a part's render lists that are missing (in place); returns the part. + *******************************************************************************/ + @SuppressWarnings("unchecked") + public static Map complete(Map part) + { + if(part != null && part.get("render") instanceof Map render) + { + completeRender((Map) render); + } + return (part); + } + + + + /******************************************************************************* + ** Fill in a render's missing lists (in place). + *******************************************************************************/ + @SuppressWarnings("unchecked") + public static void completeRender(Map render) + { + if(render == null || !(render.get("type") instanceof String type)) + { + return; + } + for(String list : LISTS.getOrDefault(type, List.of())) + { + if(!(render.get(list) instanceof List)) + { + render.put(list, new ArrayList<>()); + } + } + if("history".equals(type)) + { + for(Object entry : (List) render.get("entries")) + { + if(entry instanceof Map map && !(map.get("details") instanceof List)) + { + ((Map) map).put("details", new ArrayList<>()); + } + } + } + } +} 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 88abbb2..b38b0d0 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 @@ -67,6 +67,7 @@ import com.kingsrook.qqq.backend.core.actions.ai.AiToolCall; import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome; import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult; import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor; +import com.kingsrook.qqq.backend.core.context.CapturedContext; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; @@ -770,13 +771,28 @@ public class TurnEngine implements Runnable { throw (new BudgetService.Blocked("The assistant has reached this provider's monthly cost cap. Ask an administrator.")); } + /////////////////////////////////////////////////////////////////////// + // providers stream on their own (virtual) threads, which have no // + // QContext: run the delta handler in the turn's context, or progress // + // writes (JobLogWriter) fail with no instance // + /////////////////////////////////////////////////////////////////////// + CapturedContext turnContext = QContext.capture(); ModelEventListener listener = new ModelEventListener() { @Override public void onTextDelta(String text) { - phase("writing", null); - bufferText(text); + if(QContext.getQInstance() != null) + { + phase("writing", null); + bufferText(text); + return; + } + QContext.withTemporaryContext(turnContext, () -> + { + phase("writing", null); + bufferText(text); + }); } }; diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEvent.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEvent.java index c655ec6..d2b9022 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEvent.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEvent.java @@ -82,6 +82,10 @@ public class TurnEvent if(json != null) { data = JsonUtils.toObject(json, new TypeReference>() {}); + ////////////////////////////////////////////////////////////////// + // events stored before RenderShapes may lack empty render lists // + ////////////////////////////////////////////////////////////////// + RenderShapes.complete(data); } } catch(Exception e) @@ -110,7 +114,7 @@ public class TurnEvent *******************************************************************************/ public String toJson() { - return (JsonUtils.toJson(data)); + return (RenderShapes.toJson(data)); } diff --git a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml index 886bedc..ca9de0e 100644 --- a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml +++ b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml @@ -161,7 +161,7 @@ components: type: "string" type: "object" AssistantErrorV1: - description: "Deprecated since 1.3.0: errors use BasicErrorResponseV1." + description: "Deprecated since 1.4.0: errors use BasicErrorResponseV1." properties: error: description: "A message for the user." @@ -216,6 +216,57 @@ components: type: "string" type: "array" type: "object" + AssistantRecordsRenderV1: + description: "A tool part's render of type records (query results). columns\ + \ and rows are always present - empty when the query matched nothing (since\ + \ 1.4.0; older stored parts are completed when read)." + properties: + type: + description: "records" + type: "string" + tableName: + type: "string" + tableLabel: + type: "string" + columns: + description: "Always present; empty when nothing matched." + items: + description: "A column." + properties: + name: + type: "string" + label: + type: "string" + type: "object" + type: "array" + rows: + description: "Always present; empty when nothing matched." + items: + description: "A row." + properties: + id: + description: "The primary key." + type: "string" + href: + description: "The record's /app link." + type: "string" + values: + additionalProperties: true + description: "Display values by column name." + type: "object" + type: "object" + type: "array" + totalCount: + description: "All matching rows." + nullable: true + type: "integer" + truncated: + description: "More rows match than are shown." + type: "boolean" + openHref: + description: "The Query screen link." + type: "string" + type: "object" AssistantStatusResponseV1: description: "An acknowledgement." properties: @@ -323,7 +374,7 @@ info: description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\ \ and data access explanations" title: "QQQ Middleware API extension: assistant" - version: "1.3.0" + version: "1.4.0" openapi: "3.0.3" paths: /qqq/v1/assistant/config: diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/DurableTurnsTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/DurableTurnsTest.java index c6246f5..6b49a61 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/DurableTurnsTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/DurableTurnsTest.java @@ -208,6 +208,42 @@ class DurableTurnsTest extends BaseAssistantTest + /******************************************************************************* + ** Streamed text arrives on the provider's own thread; the "writing" progress + ** it triggers still runs in the turn's QContext (the job tracker writes + ** through QQQ, which needs the instance). + *******************************************************************************/ + @Test + void streamedProgressRunsInTheTurnContext() throws Exception + { + List contexts = new CopyOnWriteArrayList<>(); + config.setRunTracker(new AssistantRunTracker() + { + @Override + public String start(String userId, Integer turnId, Integer conversationId, String label) + { + return ("job-" + turnId); + } + + @Override + public void progress(String trackingId, String phase, String message) + { + contexts.add(phase + ":" + (QContext.getQInstance() != null)); + } + + @Override + public void finish(String trackingId, String status, String message) + { + } + }); + provider.thenText("A streamed answer on the provider's thread"); + AssistantTurn row = completed(api.startTurn(conversation(), Map.of("text", "hello"))); + assertThat(row.getStatus()).isEqualTo("COMPLETE"); + assertThat(contexts).contains("writing:true").doesNotContain("writing:false"); + } + + + /******************************************************************************* ** A throwing post-processing collaborator cannot strand a streamed answer. *******************************************************************************/ diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/RenderShapesTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/RenderShapesTest.java new file mode 100644 index 0000000..61b902f --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/RenderShapesTest.java @@ -0,0 +1,95 @@ +/* + * 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.engine; + + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Records renders keep their (empty) lists in stored parts and stream events, + ** and parts stored without them are completed when read. + *******************************************************************************/ +class RenderShapesTest +{ + + /******************************************************************************* + ** A query that matched nothing. + *******************************************************************************/ + private static Map emptyRecordsPart() + { + Map render = new LinkedHashMap<>(); + render.put("type", "records"); + render.put("tableName", "db_perf_finding"); + render.put("tableLabel", "Findings"); + render.put("columns", new ArrayList<>()); + render.put("rows", new ArrayList<>()); + render.put("totalCount", 0); + render.put("truncated", false); + render.put("openHref", "/app/db_perf_finding"); + Map part = new LinkedHashMap<>(); + part.put("type", "tool"); + part.put("name", "query_records"); + part.put("render", render); + return (part); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void emptyListsSurviveSerialization() + { + String json = RenderShapes.toJson(List.of(emptyRecordsPart())); + assertThat(json).contains("\"columns\":[]").contains("\"rows\":[]"); + assertThat(new TurnEvent(1, "tool.result", emptyRecordsPart()).toJson()).contains("\"rows\":[]").contains("\"columns\":[]"); + } + + + + /******************************************************************************* + ** The exact shape stored on kof22 before the fix (lists omitted). + *******************************************************************************/ + @Test + void partsStoredWithoutListsAreCompletedWhenRead() + { + String stored = "[{\"type\":\"tool\",\"name\":\"query_records\",\"outcome\":\"OK\",\"render\":{\"type\":\"records\",\"tableName\":\"db_perf_finding\",\"tableLabel\":\"Findings\",\"openHref\":\"/app/db_perf_finding\",\"totalCount\":0,\"truncated\":false}}," + + "{\"type\":\"tool\",\"name\":\"history\",\"render\":{\"type\":\"history\",\"entries\":[{\"message\":\"Edited\"}]}}]"; + List> parts = MessageParts.fromJson(stored).list(); + @SuppressWarnings("unchecked") + Map records = (Map) parts.get(0).get("render"); + assertThat(records.get("rows")).isEqualTo(List.of()); + assertThat(records.get("columns")).isEqualTo(List.of()); + @SuppressWarnings("unchecked") + Map history = (Map) parts.get(1).get("render"); + @SuppressWarnings("unchecked") + Map entry = ((List>) history.get("entries")).get(0); + assertThat(entry.get("details")).isEqualTo(List.of()); + + Map event = TurnEvent.fromJson(3, "tool.result", "{\"render\":{\"type\":\"records\",\"tableLabel\":\"Findings\"}}").getData(); + @SuppressWarnings("unchecked") + Map eventRender = (Map) event.get("render"); + assertThat(eventRender.get("rows")).isEqualTo(List.of()); + } +}