fix(assistant): records renders keep empty lists; streamed progress keeps the turn context #59
No files matched your search
@@ -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.
|
||||
|
||||
+6
-1
@@ -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)));
|
||||
|
||||
@@ -53,6 +53,10 @@ public class MessageParts
|
||||
List<Map<String, Object>> list = JsonUtils.toObject(json, new TypeReference<List<Map<String, Object>>>() {});
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<String, List<String>> 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<String, Object> complete(Map<String, Object> part)
|
||||
{
|
||||
if(part != null && part.get("render") instanceof Map<?, ?> render)
|
||||
{
|
||||
completeRender((Map<String, Object>) render);
|
||||
}
|
||||
return (part);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fill in a render's missing lists (in place).
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
public static void completeRender(Map<String, Object> 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<Object>) render.get("entries"))
|
||||
{
|
||||
if(entry instanceof Map<?, ?> map && !(map.get("details") instanceof List))
|
||||
{
|
||||
((Map<String, Object>) map).put("details", new ArrayList<>());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -82,6 +82,10 @@ public class TurnEvent
|
||||
if(json != null)
|
||||
{
|
||||
data = JsonUtils.toObject(json, new TypeReference<Map<String, Object>>() {});
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// 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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+36
@@ -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<String> 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.
|
||||
*******************************************************************************/
|
||||
|
||||
+95
@@ -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<String, Object> emptyRecordsPart()
|
||||
{
|
||||
Map<String, Object> 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<String, Object> 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<Map<String, Object>> parts = MessageParts.fromJson(stored).list();
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> records = (Map<String, Object>) parts.get(0).get("render");
|
||||
assertThat(records.get("rows")).isEqualTo(List.of());
|
||||
assertThat(records.get("columns")).isEqualTo(List.of());
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> history = (Map<String, Object>) parts.get(1).get("render");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> entry = ((List<Map<String, Object>>) history.get("entries")).get(0);
|
||||
assertThat(entry.get("details")).isEqualTo(List.of());
|
||||
|
||||
Map<String, Object> event = TurnEvent.fromJson(3, "tool.result", "{\"render\":{\"type\":\"records\",\"tableLabel\":\"Findings\"}}").getData();
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> eventRender = (Map<String, Object>) event.get("render");
|
||||
assertThat(eventRender.get("rows")).isEqualTo(List.of());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user