Compare commits

..
Author SHA1 Message Date
CircleCI d4a1f64480 Bump version to 0.1.0-RC.8 [skip ci] 2026-10-14 00:55:57 +00:00
james.maesandClaude Opus 5.5 c28e782075 Merge develop into release/0.1 for 0.1.0-RC.8 (core alerts #31, budgets #26, routing #27)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:52:20 -05:00
CircleCI 5c76a13245 Bump version to 0.1.0-RC.7 [skip ci] 2026-10-13 23:31:30 +00:00
james.maesandClaude Opus 5.5 8d1348888d Merge develop into release/0.1 for 0.1.0-RC.7
Provider/model registry and OpenAI-compatible adapter (#22), Bedrock and
Vertex (#23), data classification, PHI routing and HIPAA mode (#24).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:28:10 -05:00
CircleCI 47cc572c36 Bump version to 0.1.0-RC.6 [skip ci] 2026-10-13 19:51:20 +00:00
james.maesandClaude Opus 5.5 8099f64bf2 Merge develop into release/0.1 for 0.1.0-RC.6
Tools via /qqq/v1 with a loopback stand-in (#14); profiles, skills,
explain access, denials, ArchUnit and the denial matrix (#15); #16; the
demo pending-action card (#18).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:49:14 -05:00
CircleCI ac6ec75deb Bump version to 0.1.0-RC.5 [skip ci] 2026-10-13 18:44:09 +00:00
james.maesandClaude Opus 5.5 1f48a370e3 Merge develop into release/0.1 for 0.1.0-RC.5
Tools are no longer all sent as strict, so Anthropic no longer rejects
every turn with "compiled grammar is too large" (#13).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:40:36 -05:00
CircleCI d0ad2aa0fa Bump version to 0.1.0-RC.4 [skip ci] 2026-10-13 15:24:59 +00:00
james.maesandClaude Opus 5.5 16c8500f59 Merge develop into release/0.1 for 0.1.0-RC.4
App home, provider settings with Test connection, per-call usage and
model prices (#12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:23:04 -05:00
CircleCI a8c5f586ee Bump version to 0.1.0-RC.3 [skip ci] 2026-10-12 21:22:35 +00:00
james.maesandClaude Opus 5.5 3dfa775b21 Merge develop into release/0.1 for 0.1.0-RC.3
Assistant Admin record screens (#6), deterministic RobustnessTest (#9)
and the GA-only tag filter (#8), on top of the snake_case rename (#3).
0.1.0-RC.2 on Central is an accidental republish of RC.1, so this
candidate is RC.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:20:10 -05:00
james.maesandClaude Opus 5.5 67f78fd660 ci: fix the GA tag filter YAML (no brackets in the flow sequence) [skip ci]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:01:54 -05:00
james.maesandClaude Opus 5.5 ff67c6f7cc Set version to 0.1.0-RC.2 and publish only GA tags [skip ci]
0.1.0-RC.2 is already on Maven Central (an RC tag build republished the previous code), so the next release-branch build must publish the following RC. RC tags no longer trigger a publish (/^v[0-9]+\.[0-9]+\.[0-9]+$/ only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:56:59 -05:00
james.maesandClaude Opus 5.5 19551ac2af Merge develop into release/0.1 for 0.1.0-RC.2
Brings the snake_case assistant_ table and key names (#3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:18:00 -05:00
james.maesandClaude Opus 5.5 7263ac627d Bump version to 0.1.0-RC.1 [skip ci]
The orb published 0.1.0-RC.1 but could not push this commit (the
CircleCI deploy key is read only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:49:33 -05:00
21 changed files with 17 additions and 1644 deletions

No files matched your search

-9
View File
@@ -1,14 +1,5 @@
# Changelog
## Unreleased - Answer presentation
- Add `present_results`: server-formatted metrics, tables, computed percentages,
charts and validated in-app filter/record/action links.
- Persist and stream presentation parts with a multi-line GFM fallback and
record-data retention; add a static default presentation skill.
- Add the owner's 81 JPEG / 20 PNG golden example, shared live eval coverage,
permission/formatting/input tests and strict-schema budget coverage.
## Unreleased - P-e
- Add per-profile heuristic/classifier model routing, with budgeted ROUTE usage.
+1 -15
View File
@@ -9,7 +9,7 @@ Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`.
| `com.kingsrook.qbits:qbit-assistant` | The QBit: conversation tables, turn engine, `/qqq/v1/assistant` routes (JSON + Server-Sent Events), budgets, kill switch, the provider SPI, and a scripted provider for tests and demos. |
| `com.kingsrook.qbits:qbit-assistant-provider-anthropic` | The Claude provider (`com.anthropic:anthropic-java`). Add it to use Claude. The core QBit has no LLM SDK dependency. |
Data tools call the QQQ UI API through `UiApiToolbox` and `UiApiGateway` with the signed-in user's session. The UI enforces permissions and record security; the assistant further narrows access and caps and redacts returned values. Data tool results reach the model wrapped as untrusted data. Only record-free navigation and help use the core AI tool kernel.
The data access itself is done by the AI tool kernel in QQQ core (`com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel`). The kernel checks table permissions and exposure rules, applies record security, and caps and redacts what the model sees. Every tool result reaches the model wrapped as untrusted data.
## Requirements
@@ -203,7 +203,6 @@ Base path `/qqq/v1/assistant`. Every request is authenticated through the app's
The stream sends these events, each with a `seq`:
- `message.start`, `text.delta`
- `tool.start`, `tool.result` (with `render` and `recordRefs`)
- `presentation` (`messageId`, `part`: summary, blocks and server-built GFM markdown)
- `ui.navigate`, `notice`
- `usage`, `message.end`
- `error`
@@ -488,16 +487,3 @@ NETWORK/TIMEOUT marks a provider DOWN for 60 seconds; success marks it UP. Local
health is probed when unknown/stale with a two-second timeout. Provider cards show
health and down-until, failures show fallback totals, and turn records show the
route and final destination. See [UPGRADE.md](UPGRADE.md) for the additive schema.
### Answer presentation
The always-available `present_results` tool turns retrieved results into metrics,
tables, charts and actionable in-app links. The server validates the user's UI API
visibility, formats field values, computes shares and builds all links. It emits
and stores a `presentation` part with a complete GFM Markdown fallback. Profile
skills can extend the built-in presentation instructions. See the
[part, stream and input contract](docs/answer-presentation.md).
The image-breakdown golden task also runs in the existing opt-in live provider suite:
`./mvnw verify -Dassistant.live=true -Dtest.excludedGroups= -Dgroups=live -Djacoco.skip=true -pl qbit-assistant-provider-anthropic -am`
(with `ANTHROPIC_API_KEY` set). Live calls are excluded from normal verification.
-33
View File
@@ -446,36 +446,3 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
- an "Explain access" action (for a user, and a table or a prompt, which operations the assistant could call and which it would be denied, with the reason);
- a recent-denials widget on the app home.
- **(d) Spec'd assistant endpoints:** move `/qqq/v1/assistant/...` (conversations, turns, events, feedback, cancel, config, actions) onto `AbstractEndpointSpec`s through the qbit spec extension point (`MiddlewareSpecExtension`, established prefix `/assistant`, QRun-IO/qqq#1128). The SSE turn and events streams stay raw routes until the spec layer can describe a stream.
## Answer presentation (owner priority 2026-10-13)
`present_results` is always offered and reads no records. It takes a direct answer,
metrics, a table, an optional table-derived chart, filtered list links, record links
and next-step actions. The server validates metadata and the user's UI API table
visibility once per turn, formats values using QQQ field types/display formats,
computes percentages and builds every `/app` href. Unknown or denied items are
dropped with an explanation. The assistant must still retrieve every fact through
data tools before presenting it.
The [presentation part and SSE contract](answer-presentation.md) defines `summary`,
`blocks` and the complete server-built GFM `markdown` fallback. Each successful
call emits `presentation` with `{messageId, part}` and persists that part; the tool
response tells the model not to repeat its tables or links. Audit rows contain
only accepted presentation data. Normal record-data retention removes these parts.
The built-in Presentation skill is compact static prompt text in the cached rules
block. It asks for a one-sentence answer first, metrics for single numbers, tables
for breakdowns/comparisons, charts for useful shares/trends and actual filter links
whenever offering a list. Administrator profile skills append after it as before
and extend it; there is no replacement boolean or extra permission surface.
The owner's one-line table was a frontend parsing defect: Next's `AssistantMarkdown`
used react-markdown without `remark-gfm`, so table lines became a paragraph.
The renderer fix belongs to `qqq-frontend-next`; this worktree preserves real
newlines and provides structured blocks plus a proper multi-line GFM fallback.
The frontend change is maintained separately and is not verified by this build.
The shared golden task asks "How many images do I have by type?": five UI count
calls establish 81 JPEG, 20 PNG and zero other configured types, then a presentation
with two rows, 80.2%/19.8% shares, two pie points and working MIME filter links.
The same task is included in the opt-in `live` Anthropic eval suite.
-56
View File
@@ -1,56 +0,0 @@
# Assistant answer presentation contract (v1)
Owner priority (2026-10-13): answers must ALWAYS be easy to read and act on. Bad example the owner saw, rendered on ONE line:
"| Type | Count | |---|---| | JPEG (image/jpeg) | 81 | | PNG (image/png) | 20 | | WebP, GIF, SVG | 0 |" followed by "You can open the lists filtered by JPEG or PNG" with NO links.
Root cause found in Next: `AssistantMarkdown` uses react-markdown WITHOUT `remark-gfm`, so GFM tables are not parsed and the table's lines collapse into one paragraph (soft line breaks). Backend streaming preserves `\n` (events are JSON).
## New message part: `presentation`
An assistant message's `parts` (persisted in `assistant_message.parts_json`, streamed as an SSE event, returned by the conversation API) may contain, besides `text`, `notice`, `tool`/`render` parts etc., ONE part per `present_results` call:
```json
{
"type": "presentation",
"summary": "101 media files are images: 81 JPEG and 20 PNG.",
"markdown": "<server-built GFM fallback of the whole presentation, for clients that do not render blocks (CLI/TUI)>",
"blocks": [
{ "kind": "metrics", "items": [ { "label": "Images", "value": 101, "display": "101", "href": "/app/media?filter=..." } ] },
{ "kind": "table", "title": "Media by type",
"columns": [ { "key": "type", "label": "Type", "align": "left" },
{ "key": "count", "label": "Count", "align": "right" },
{ "key": "share", "label": "Share", "align": "right" } ],
"rows": [ { "cells": { "type": "JPEG (image/jpeg)", "count": 81, "share": 0.802 },
"display": { "type": "JPEG (image/jpeg)", "count": "81", "share": "80.2%" },
"href": "/app/media?filter=..." } ] },
{ "kind": "chart", "chartType": "pie", "title": "Share by type",
"points": [ { "label": "JPEG", "value": 81, "display": "81", "href": "/app/media?filter=..." },
{ "label": "PNG", "value": 20, "display": "20", "href": "/app/media?filter=..." } ] },
{ "kind": "filterLinks", "items": [ { "label": "Open JPEG media (81)", "tableName": "media", "href": "/app/media?filter=..." } ] },
{ "kind": "recordLinks", "items": [ { "label": "Sunset.jpg", "tableName": "media", "recordId": "12", "href": "/app/media/12" } ] },
{ "kind": "actions", "items": [ { "label": "Show the PNG files", "prompt": "Show me the PNG media files" },
{ "label": "Open media", "href": "/app/media" } ] }
]
}
```
Rules:
- `summary` is required, one sentence, the direct answer first.
- `chartType` ∈ `bar`, `pie`, `line`; ≤ 12 points; values are numbers.
- Every `href` is built SERVER-SIDE from metadata and is an `/app` path (same rules as `TurnEngine.isAppHref`). Filtered list links are `/app/<tableName>?filter=<URL-encoded JSON {"criteria":[{"fieldName":..,"operator":..,"values":[..]}],"booleanOperator":"AND"}>`, which Next's `deserializeFilter` accepts (JSON starting with `{`). The model never writes URLs.
- `display` strings are formatted server-side from field metadata (`displayFormat`, e.g. `%,d`, `$%,.2f`; percent columns as `80.2%`); clients show `display` when present, else the raw value.
- `actions` items have either `prompt` (send as the user's next message) or `href` (navigate), never both.
- Unknown `kind`s must be ignored by clients (forward compatible); clients that do not render blocks render `markdown`.
- The SSE event is `presentation` with `{ "messageId": <id>, "part": <the part above> }`; Next's reducer appends it to the assistant message's parts.
## Tool input and implementation
The input schema is [present-results.schema.json](../qbit-assistant/src/main/resources/present-results.schema.json). `table.rowKeys` supplies one raw filter value per row; `table.rowFilter` names the table, field and optional operator (EQUALS by default). Columns receive stable server keys `c0`, `c1`, etc. Input cells remain raw scalars; each row has a parallel display map. The example keys above are illustrative.
`PresentResultsTool` is instantiated per turn. It caches `GET /metaData` with that turn's session, checks table read visibility and instance fields, validates QQQ criteria operators and builds paths using RC.5 `AiLinkBuilder`. Filter links use URL-encoded JSON accepted by Next. Record ids are encoded as opaque path segments. Metadata denial or invalid item drops that item (an invalid table drops its table/chart), with a short explanation to the model; other items survive. No data query or permission elevation happens here.
Limits: summary 300 characters, display text 2,000 characters, table 20 columns/200 rows, chart 12 points, item lists 100 entries, filters 20 criteria/200 values. Chart truncation is reported to the model. Percent columns reference a raw numeric column; a zero total yields 0.0%. Numbers still must originate in earlier data tool results; presentation is formatting, not a new source of facts.
The schema uses small named, nonrecursive definitions to avoid deeply inlined structures. Each object component is at most two levels of inline containers; the resolved table/criteria structure necessarily has the nesting shown in the owner contract. Strict is off by default. Opting `present_results` into strict mode requires all properties (empty arrays/strings, empty table and -1 for unused percentage indexes), removes provider-unsupported length constraints, and retains server validation. This has zero optional parameters and one shared scalar union in the existing strict-budget test.
Presentation parts are persisted with the assistant message and replayed as `presentation` events with `{messageId, part}` (plus the standard event sequence). They are removed by record-data retention along with tool renders. Model history and text-only consumers can use the server-built Markdown. Unknown block kinds should be ignored. Plain labels must be rendered as text, never parsed as links or HTML; fallback Markdown escapes them. Tool audit input records the accepted presentation only, excluding dropped values.
+1 -1
View File
@@ -20,7 +20,7 @@
<!-- Multi-module build. The qqq-orb sets the revision here; the modules -->
<!-- inherit it, and the flatten plugin resolves it in the published POMs. -->
<properties>
<revision>0.1.0-SNAPSHOT</revision>
<revision>0.1.0-RC.8</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -108,24 +108,6 @@ class AnthropicRequestContractTest
/*******************************************************************************
** Presentation remains inside the actual serialized strict request budget.
*******************************************************************************/
@Test
void testPresentationStrictOptIn() throws Exception
{
Map<String, Object> body = requestBody(new AssistantQBitConfig().withModelProvider(new ScriptedModelProvider()).withStrictToolNames(Set.of("present_results")));
assertThat(violations(body)).isEmpty();
assertThat(strictBudgetViolations(body)).isEmpty();
assertThat(tools(body)).anySatisfy(tool ->
{
assertThat(tool.get("name")).isEqualTo("present_results");
assertThat(tool.get("strict")).isEqualTo(true);
});
}
/*******************************************************************************
** The RC.3 shape (every tool strict) is recognized as over budget, and a
** host can still opt a few tools in.
@@ -34,7 +34,6 @@ import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.provider.SystemBlock;
import com.kingsrook.qbits.assistant.tools.PresentResultsTool;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolDefinition;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
@@ -117,7 +116,6 @@ public class ContextBuilder
.withStrict(isStrict(definition.getName(), !Boolean.FALSE.equals(definition.getStrict()))));
}
}
byName.put(PresentResultsTool.NAME, PresentResultsTool.definition(isStrict(PresentResultsTool.NAME, true)));
return (new ArrayList<>(byName.values()));
}
@@ -31,7 +31,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
** assistantMessage.partsJson and returned by the API:
** {type:"text", text} | {type:"tool", toolCallId, name, summary, outcome, render?}
** | {type:"record_refs", refs:[...]} | {type:"navigate", href}
** | {type:"notice", level, text} | {type:"presentation", summary, blocks, markdown}
** | {type:"notice", level, text}
** Consecutive text is merged into one part. Thread-safe.
*******************************************************************************/
public class MessageParts
@@ -110,17 +110,6 @@ public class MessageParts
/*******************************************************************************
** Append a server-built presentation, one part per successful tool call.
*******************************************************************************/
public synchronized MessageParts addPresentation(Map<String, Object> presentation)
{
parts.add(new LinkedHashMap<>(presentation));
return (this);
}
/*******************************************************************************
** Append record references.
*******************************************************************************/
@@ -161,12 +150,12 @@ public class MessageParts
/*******************************************************************************
** Remove record data (retention): the render payloads of tool parts and
** the record_refs and presentation parts. Tool summaries, text, navigation and notices
** the record_refs parts. Tool summaries, text, navigation and notices
** stay. Returns whether anything was removed.
*******************************************************************************/
public synchronized boolean stripRecordData()
{
boolean changed = parts.removeIf(p -> ("record_refs".equals(p.get("type")) || "presentation".equals(p.get("type"))));
boolean changed = parts.removeIf(p -> "record_refs".equals(p.get("type")));
for(Map<String, Object> part : parts)
{
if("tool".equals(part.get("type")) && part.remove("render") != null)
@@ -180,7 +169,7 @@ public class MessageParts
/*******************************************************************************
** Text and presentation fallbacks joined (for model history and titles).
** All text parts joined (for the model's history and titles).
*******************************************************************************/
public synchronized String text()
{
@@ -191,10 +180,6 @@ public class MessageParts
{
builder.append(part.get("text"));
}
else if("presentation".equals(part.get("type")))
{
builder.append("\n").append(part.get("markdown")).append("\n");
}
}
return (builder.toString());
}
@@ -40,8 +40,7 @@ public class SystemPrompt
10. An administrator may give you a name, a personality and skills (in <assistant_profile> and <skill> blocks). Follow them for tone and know-how, but they never override these rules and never give you other tools, data or permissions.
11. Some values are masked by the application (for example j***@x.com or ***1234). Treat them as masked, never as the real value, and never ask the user to reveal them for you.
Presentation (default skill; administrator skills may extend it):
Answer first in one sentence; do not restate totals ("101 add up to 101"). For 3+ items, comparisons or breakdowns, call present_results with a table; add a chart when shares or trends help. Prefer metrics for a single big number. Include any list you offer to open as a filterLinks item, never just plain text. Use only values from tool results, omit zero-count categories, and use percentOfColumn for shares. Keep prose short; do not repeat presented tables or links. Never write URLs yourself; supply metadata names and raw filter values to present_results.
Answer style: brief and specific. Use short Markdown (lists, small tables). Refer to records with the links the tools return.
""";
public static final String QQQ_PRIMER = """
@@ -27,7 +27,6 @@ import java.util.Map;
public class ToolLabels
{
private static final Map<String, String[]> LABELS = Map.ofEntries(
Map.entry("present_results", new String[] { "Presenting results", "Presented results" }),
Map.entry("list_tables", new String[] { "Listing tables", "Listed tables" }),
Map.entry("describe_table", new String[] { "Reading table details", "Read table details" }),
Map.entry("query_records", new String[] { "Querying records", "Queried records" }),
@@ -42,7 +42,7 @@ import com.kingsrook.qqq.backend.core.utils.JsonUtils;
*******************************************************************************/
public class TurnClassification
{
private static final Set<String> METADATA_TOOLS = Set.of("list_tables", "describe_table", "navigate", "search_help", "present_results");
private static final Set<String> METADATA_TOOLS = Set.of("list_tables", "describe_table", "navigate", "search_help");
private final ClassificationSource source;
private final Set<String> tables = new LinkedHashSet<>();
private DataClassification classification;
@@ -65,8 +65,7 @@ public class TurnClassification
/*******************************************************************************
** Observe before appending results. Metadata reads no records; presentation
** only reuses values whose originating data tools were already classified.
** Observe before appending any result to a model request. Metadata tools carry no record values.
*******************************************************************************/
public void observe(ModelPart use, AiToolResult result)
{
@@ -36,7 +36,6 @@ import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.tools.PresentResultsTool;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolCall;
@@ -102,7 +101,6 @@ public class TurnEngine implements Runnable
private static final Set<AiToolOutcome> ERROR_OUTCOMES = Set.of(AiToolOutcome.DENIED, AiToolOutcome.NOT_FOUND, AiToolOutcome.INVALID_INPUT, AiToolOutcome.ERROR);
private final PresentResultsTool presenter;
private final AssistantRuntime runtime;
private final AssistantQBitConfig config;
private final AssistantStore store;
@@ -148,7 +146,6 @@ public class TurnEngine implements Runnable
AssistantTurn turn, String userText, Map<String, Object> pageContext, PolicyService.EffectivePolicy policy, RunningTurn running, String softBudgetNotice)
{
this.runtime = runtime;
this.presenter = new PresentResultsTool(runtime.getQInstance());
this.config = runtime.getConfig();
this.store = runtime.getStore();
this.session = session;
@@ -692,11 +689,7 @@ public class TurnEngine implements Runnable
{
if(!running.isCancelled())
{
if(PresentResultsTool.NAME.equals(call.getName()))
{
results[index] = presenter.execute(toolContext(), call.getId(), call.getInput());
}
else if(ContextBuilder.KERNEL_TOOLS.contains(call.getName()))
if(ContextBuilder.KERNEL_TOOLS.contains(call.getName()))
{
///////////////////////////////////////////////////
// navigate / search_help: no records are read //
@@ -805,8 +798,7 @@ public class TurnEngine implements Runnable
}
Map<String, Object> data = map("messageId", assistantMessage.getId(), "toolCallId", use.getToolUseId(), "name", use.getToolName(), "outcome", outcome, "summary", summary);
Boolean presentation = PresentResultsTool.NAME.equals(use.getToolName()) && AiToolOutcome.OK.equals(result.getOutcome()) && result.getRender() != null;
if(result.getRender() != null && !presentation)
if(result.getRender() != null)
{
data.put("render", result.getRender());
}
@@ -816,12 +808,7 @@ public class TurnEngine implements Runnable
}
emit(TurnEvent.TOOL_RESULT, data);
parts.addTool(use.getToolUseId(), use.getToolName(), summary, outcome, presentation ? null : result.getRender());
if(presentation)
{
parts.addPresentation(result.getRender());
emit(TurnEvent.PRESENTATION, map("messageId", assistantMessage.getId(), "part", result.getRender()));
}
parts.addTool(use.getToolUseId(), use.getToolName(), summary, outcome, result.getRender());
parts.addRecordRefs(refs);
String href = result.getNavigateHref();
@@ -839,7 +826,7 @@ public class TurnEngine implements Runnable
.withUserId(userId)
.withToolCallId(use.getToolUseId())
.withToolName(use.getToolName())
.withInputJson(truncate(redactInput(PresentResultsTool.NAME.equals(use.getToolName()) ? (presentation ? result.getRender() : Map.of()) : use.getInput()), MAX_INPUT_JSON_CHARS))
.withInputJson(truncate(redactInput(use.getInput()), MAX_INPUT_JSON_CHARS))
.withOutcome(outcome)
.withPermissionCheck(result.getPermissionCheck())
.withTableName(result.getTableName())
@@ -33,7 +33,6 @@ public class TurnEvent
public static final String TEXT_DELTA = "text.delta";
public static final String TOOL_START = "tool.start";
public static final String TOOL_RESULT = "tool.result";
public static final String PRESENTATION = "presentation";
public static final String UI_NAVIGATE = "ui.navigate";
public static final String NOTICE = "notice";
public static final String USAGE = "usage";
@@ -1,727 +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.tools;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.MathContext;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Function;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
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.ValueUtils;
/*******************************************************************************
** Builds presentations from previously retrieved values, never reading records.
** One instance belongs to one turn: UI metadata, including denials, is cached
** only for that turn and session. All output links are built by the server.
*******************************************************************************/
public class PresentResultsTool
{
public static final String NAME = "present_results";
private final QInstance qInstance;
private UiApiResult metadata;
/*******************************************************************************
** Create a turn-scoped presenter.
*******************************************************************************/
public PresentResultsTool(QInstance qInstance)
{
this.qInstance = qInstance;
}
/*******************************************************************************
** Stable schema. Strict opt-in requires empty placeholders instead of many
** optional productions, keeping the existing strict grammar budget small.
*******************************************************************************/
public static ModelToolDefinition definition(Boolean strict)
{
try(InputStream stream = PresentResultsTool.class.getResourceAsStream("/present-results.schema.json"))
{
Map<String, Object> schema = JsonUtils.toObject(new String(stream.readAllBytes(), StandardCharsets.UTF_8), new TypeReference<Map<String, Object>>() {});
if(strict)
{
requireProperties(schema);
}
return (new ModelToolDefinition().withName(NAME).withDescription("Present an answer as metrics, a table, a chart and actionable links. Use only data from tool results; never supply URLs.")
.withInputSchema(schema).withStrict(strict));
}
catch(Exception e)
{
throw (new IllegalStateException("Cannot load presentation schema", e));
}
}
/*******************************************************************************
** Eliminate optional productions for strict mode without adding unions.
*******************************************************************************/
private static void requireProperties(Object node)
{
if(node instanceof Map<?, ?>)
{
Map<String, Object> schema = object(node);
schema.remove("maxLength");
if(schema.get("properties") instanceof Map<?, ?> properties)
{
schema.put("required", new ArrayList<>(properties.keySet()));
}
schema.values().forEach(PresentResultsTool::requireProperties);
}
else if(node instanceof List<?> list)
{
list.forEach(PresentResultsTool::requireProperties);
}
}
/*******************************************************************************
** Preserve valid items when others are dropped; never echo rejected values.
*******************************************************************************/
public synchronized AiToolResult execute(UiApiToolbox.Context context, String callId, Map<String, Object> input)
{
AiToolResult result = new AiToolResult().withToolCallId(callId).withToolName(NAME);
try
{
require(input != null && input.get("summary") instanceof String, "summary required");
String summary = text(input.get("summary"));
require(!summary.isBlank() && summary.length() <= 300, "summary must contain 1 to 300 characters");
List<String> notes = new ArrayList<>();
List<Map<String, Object>> blocks = new ArrayList<>();
addItems(blocks, "metrics", input.get("metrics"), notes, item -> metric(context, item));
Map<String, Object> table = null;
if(input.get("table") != null)
{
try
{
table = table(context, object(input.get("table")));
if(!list(table.get("columns")).isEmpty())
{
blocks.add(table);
}
}
catch(IllegalArgumentException e)
{
notes.add("Dropped table: " + e.getMessage());
}
}
if(input.get("chart") != null && table != null && !list(table.get("rows")).isEmpty())
{
try
{
blocks.add(chart(object(input.get("chart")), table, notes));
}
catch(IllegalArgumentException e)
{
notes.add("Dropped chart: " + e.getMessage());
}
}
if(input.get("chart") != null && (table == null || list(table.get("rows")).isEmpty()))
{
notes.add("Dropped chart: requires a valid nonempty table.");
}
addItems(blocks, "filterLinks", input.get("filterLinks"), notes, item -> filterLink(context, item));
addItems(blocks, "recordLinks", input.get("recordLinks"), notes, item -> recordLink(context, item));
addItems(blocks, "actions", input.get("actions"), notes, item -> action(context, item));
Map<String, Object> part = map("type", "presentation", "summary", summary, "blocks", blocks, "markdown", markdown(summary, blocks));
return (result.withOutcome(AiToolOutcome.OK).withSummary("Presented results").withRender(part)
.withPermissionCheck(metadata == null ? "presentation:no table references" : "uiApi:GET /metaData:" + metadata.status())
.withModelContent("Presented to the user: " + summary + (summary.endsWith(".") ? "" : ".") + " Do not repeat the table or links in text." + (notes.isEmpty() ? "" : "\n" + String.join("\n", notes))));
}
catch(IllegalArgumentException e)
{
return (result.withOutcome(AiToolOutcome.INVALID_INPUT).withSummary("Invalid presentation").withModelContent(e.getMessage()));
}
}
/*******************************************************************************
** Resolve tables through the same metadata operation and session as the UI.
*******************************************************************************/
private QTableMetaData visibleTable(UiApiToolbox.Context context, String name)
{
QTableMetaData table = qInstance.getTable(name);
require(table != null, "unknown table " + name);
if(metadata == null)
{
try
{
metadata = context.gateway().invoke(new UiApiCall("GET", "/metaData").withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()));
}
catch(QException e)
{
metadata = new UiApiResult(503, "{}");
}
}
require(metadata.isSuccess(), "GET /metaData answered HTTP " + metadata.status());
Map<String, Object> tables = object(object(metadata.json()).get("tables"));
require(tables.containsKey(name) && Boolean.TRUE.equals(object(tables.get(name)).get("readPermission")), "table not visible through UI API: " + name);
return (table);
}
/*******************************************************************************
** A field reference must be paired with a visible table reference.
*******************************************************************************/
private QFieldMetaData field(UiApiToolbox.Context context, Map<String, Object> item)
{
String tableName = text(item.get("tableName"));
String fieldName = text(item.get("fieldName"));
require(fieldName.isEmpty() || !tableName.isEmpty(), "fieldName requires tableName");
QTableMetaData table = tableName.isEmpty() ? null : visibleTable(context, tableName);
QFieldMetaData field = fieldName.isEmpty() ? null : table.getFields().get(fieldName);
require(fieldName.isEmpty() || field != null, "unknown field " + fieldName);
return (field);
}
/*******************************************************************************
** A metric preserves its numeric value and adds the metadata display.
*******************************************************************************/
private Map<String, Object> metric(UiApiToolbox.Context context, Map<String, Object> item)
{
QFieldMetaData field = field(context, item);
number(item.get("value"));
return (map("label", label(item), "value", item.get("value"), "display", display(item.get("value"), field)));
}
/*******************************************************************************
** Build table cells and percentages from raw values, never model displays.
*******************************************************************************/
private Map<String, Object> table(UiApiToolbox.Context context, Map<String, Object> input)
{
List<?> columns = list(input.get("columns"));
List<?> rows = list(input.get("rows"));
require(columns.size() <= 20 && rows.size() <= 200, "table exceeds 20 columns or 200 rows");
require(rows.isEmpty() || !columns.isEmpty(), "rows require columns");
List<QFieldMetaData> fields = new ArrayList<>();
List<Map<String, Object>> outputColumns = new ArrayList<>();
List<BigDecimal> totals = new ArrayList<>();
for(Integer c = 0; c < columns.size(); c++)
{
Map<String, Object> column = object(columns.get(c));
fields.add(field(context, column));
BigDecimal total = BigDecimal.ZERO;
Boolean numeric = !rows.isEmpty();
for(Object row : rows)
{
require(list(row).size() == columns.size(), "row width must match columns");
Object value = list(row).get(c);
scalar(value);
if(value instanceof Number)
{
total = total.add(number(value));
}
else
{
numeric = false;
}
}
totals.add(numeric ? total : null);
outputColumns.add(map("key", "c" + c, "label", label(column), "align", numeric || percentColumn(column) >= 0 ? "right" : "left"));
}
Map<String, Object> rowFilter = input.get("rowFilter") == null ? Map.of() : object(input.get("rowFilter"));
List<?> rowKeys = input.get("rowKeys") == null ? List.of() : list(input.get("rowKeys"));
Boolean hasFilter = !text(rowFilter.get("tableName")).isEmpty();
require(!hasFilter || rowKeys.size() == rows.size(), "rowKeys must match rows");
QFieldMetaData filterField = field(context, rowFilter);
require(!hasFilter || filterField != null, "rowFilter requires a field");
List<Map<String, Object>> outputRows = new ArrayList<>();
for(Integer r = 0; r < rows.size(); r++)
{
Map<String, Object> cells = new LinkedHashMap<>();
Map<String, Object> displays = new LinkedHashMap<>();
for(Integer c = 0; c < columns.size(); c++)
{
Integer source = percentColumn(object(columns.get(c)));
Object value = list(rows.get(r)).get(c);
String formatted;
if(source >= 0)
{
require(source < columns.size() && totals.get(source) != null && percentColumn(object(columns.get(source))) < 0, "percentOfColumn must reference a numeric raw column");
BigDecimal share = totals.get(source).signum() == 0 ? BigDecimal.ZERO : number(list(rows.get(r)).get(source)).divide(totals.get(source), MathContext.DECIMAL64);
value = share;
formatted = String.format(Locale.US, "%.1f%%", share.multiply(BigDecimal.valueOf(100)));
}
else
{
formatted = display(value, fields.get(c));
}
cells.put("c" + c, value);
displays.put("c" + c, formatted);
}
Map<String, Object> row = map("cells", cells, "display", displays);
if(hasFilter)
{
Object key = rowKeys.get(r);
scalar(key);
Map<String, Object> criterion = map("fieldName", rowFilter.get("fieldName"), "operator", rowFilter.getOrDefault("operator", "EQUALS"), "values", java.util.Collections.singletonList(key));
row.put("href", filterHref(context, text(rowFilter.get("tableName")), List.of(criterion)));
}
outputRows.add(row);
}
return (map("kind", "table", "title", text(input.get("title")), "columns", outputColumns, "rows", outputRows));
}
/*******************************************************************************
** A chart uses the table's raw values, formatted values and row links.
*******************************************************************************/
private Map<String, Object> chart(Map<String, Object> input, Map<String, Object> table, List<String> notes)
{
String type = text(input.get("chartType"));
require(List.of("bar", "pie", "line").contains(type), "invalid chartType");
Integer labelColumn = index(input.get("labelColumn"));
Integer valueColumn = index(input.get("valueColumn"));
Integer width = list(table.get("columns")).size();
require(labelColumn >= 0 && valueColumn >= 0 && labelColumn < width && valueColumn < width, "chart column index out of range");
List<Map<String, Object>> points = new ArrayList<>();
List<?> rows = list(table.get("rows"));
for(Object entry : rows)
{
Map<String, Object> row = object(entry);
Object value = object(row.get("cells")).get("c" + valueColumn);
number(value);
if(points.size() < 12)
{
Map<String, Object> point = map("label", object(row.get("display")).get("c" + labelColumn), "value", value, "display", object(row.get("display")).get("c" + valueColumn));
if(row.containsKey("href"))
{
point.put("href", row.get("href"));
}
points.add(point);
}
}
if(rows.size() > 12)
{
notes.add("Chart truncated to 12 points; the table retains all rows.");
}
return (map("kind", "chart", "chartType", type, "title", text(input.get("title")), "points", points));
}
/*******************************************************************************
** A filtered list link, including only validated metadata and raw values.
*******************************************************************************/
private Map<String, Object> filterLink(UiApiToolbox.Context context, Map<String, Object> item)
{
String tableName = text(item.get("tableName"));
return (map("label", label(item), "tableName", tableName, "href", filterHref(context, tableName, list(item.get("criteria")))));
}
/*******************************************************************************
** Serialize the UI's JSON filter format. The table path uses core navigate's
** helper; filter values stay raw inside the URL-encoded JSON.
*******************************************************************************/
private String filterHref(UiApiToolbox.Context context, String tableName, List<?> criteria)
{
visibleTable(context, tableName);
require(!criteria.isEmpty() && criteria.size() <= 20, "filter requires 1 to 20 criteria");
List<Map<String, Object>> output = new ArrayList<>();
for(Object entry : criteria)
{
Map<String, Object> criterion = object(entry);
String fieldName = text(criterion.get("fieldName"));
require(!fieldName.isBlank(), "filter requires fieldName");
field(context, map("tableName", tableName, "fieldName", fieldName));
String operator = text(criterion.get("operator"));
operator = operator.isEmpty() ? "EQUALS" : operator;
try
{
QCriteriaOperator.valueOf(operator);
}
catch(IllegalArgumentException e)
{
throw (new IllegalArgumentException("invalid criteria operator"));
}
List<?> values = list(criterion.get("values"));
require(values.size() <= 200, "too many filter values");
values.forEach(PresentResultsTool::scalar);
output.add(map("fieldName", fieldName, "operator", operator, "values", values));
}
return (safeHref(AiLinkBuilder.objectHref(tableName) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(map("criteria", output, "booleanOperator", "AND")), StandardCharsets.UTF_8)));
}
/*******************************************************************************
** Record ids are opaque path segments, encoded by core's link helper.
*******************************************************************************/
private Map<String, Object> recordLink(UiApiToolbox.Context context, Map<String, Object> item)
{
String tableName = text(item.get("tableName"));
visibleTable(context, tableName);
Object id = item.get("recordId");
require(id instanceof String || id instanceof Number, "recordId must be a string or number");
String recordId = text(id);
require(!recordId.isBlank() && !List.of(".", "..").contains(recordId), "invalid recordId");
return (map("label", label(item), "tableName", tableName, "recordId", recordId, "href", safeHref(AiLinkBuilder.recordHref(tableName, recordId))));
}
/*******************************************************************************
** Actions are either a next-message prompt or a visible table destination.
*******************************************************************************/
private Map<String, Object> action(UiApiToolbox.Context context, Map<String, Object> item)
{
String prompt = text(item.get("prompt"));
String destination = text(item.get("navigateTo"));
require(prompt.isBlank() != destination.isBlank(), "action requires exactly one of prompt or navigateTo");
if(!destination.isBlank())
{
visibleTable(context, destination);
return (map("label", label(item), "href", safeHref(AiLinkBuilder.objectHref(destination))));
}
return (map("label", label(item), "prompt", prompt));
}
/*******************************************************************************
** Preserve independently valid items and report dropped positions.
*******************************************************************************/
private static void addItems(List<Map<String, Object>> blocks, String kind, Object input, List<String> notes, Function<Map<String, Object>, Map<String, Object>> build)
{
if(input == null)
{
return;
}
List<?> entries = list(input);
require(entries.size() <= 100, "too many " + kind);
List<Map<String, Object>> items = new ArrayList<>();
for(Integer i = 0; i < entries.size(); i++)
{
try
{
items.add(build.apply(object(entries.get(i))));
}
catch(IllegalArgumentException e)
{
notes.add("Dropped " + kind + "[" + i + "]: " + e.getMessage());
}
}
if(!items.isEmpty())
{
blocks.add(map("kind", kind, "items", items));
}
}
/*******************************************************************************
** Metadata formats use QQQ field coercion and Java display format strings.
*******************************************************************************/
private static String display(Object value, QFieldMetaData field)
{
if(value == null)
{
return ("");
}
if(field != null)
{
try
{
Object typed = ValueUtils.getValueAsFieldType(field.getType(), value);
if(field.getDisplayFormat() != null)
{
return (String.format(Locale.US, field.getDisplayFormat(), typed));
}
value = typed;
}
catch(Exception e)
{
throw (new IllegalArgumentException("value does not match field format"));
}
}
if(value instanceof Number)
{
BigDecimal decimal = number(value);
return (decimal.stripTrailingZeros().scale() <= 0 ? String.format(Locale.US, "%,d", decimal.toBigIntegerExact()) : decimal.toPlainString());
}
return (text(value));
}
/*******************************************************************************
** Complete GFM fallback with real newlines; chart data is already in table.
*******************************************************************************/
private static String markdown(String summary, List<Map<String, Object>> blocks)
{
StringBuilder markdown = new StringBuilder(escape(summary)).append("\n");
for(Map<String, Object> block : blocks)
{
if("table".equals(block.get("kind")))
{
markdown.append("\n").append(escape(text(block.get("title")))).append("\n\n|");
List<?> columns = list(block.get("columns"));
for(Object column : columns)
{
markdown.append(' ').append(escape(text(object(column).get("label")))).append(" |");
}
markdown.append("\n|");
for(Object column : columns)
{
markdown.append("right".equals(object(column).get("align")) ? " ---: |" : " --- |");
}
for(Object entry : list(block.get("rows")))
{
Map<String, Object> row = object(entry);
markdown.append("\n|");
for(Integer c = 0; c < columns.size(); c++)
{
String value = escape(text(object(row.get("display")).get("c" + c)));
markdown.append(' ').append(c == 0 && row.containsKey("href") ? "[" + value + "](" + row.get("href") + ")" : value).append(" |");
}
}
markdown.append("\n");
}
else if(block.get("items") instanceof List<?> items)
{
markdown.append("\n");
for(Object entry : items)
{
Map<String, Object> item = object(entry);
String label = escape(text(item.get("label")));
markdown.append("- ");
if(item.containsKey("href"))
{
markdown.append('[').append(label).append("](").append(item.get("href")).append(')');
}
else
{
markdown.append(label);
if(item.containsKey("display") || item.containsKey("prompt"))
{
markdown.append(": ").append(escape(text(item.getOrDefault("display", item.get("prompt")))));
}
}
markdown.append("\n");
}
}
}
return (markdown.toString());
}
/*******************************************************************************
** Literal text only: no Markdown links, HTML or GFM URL autolinks from labels.
*******************************************************************************/
private static String escape(String value)
{
StringBuilder output = new StringBuilder();
for(char character : value.toCharArray())
{
if("\\`*_{}[]()<>|!&#:.@~".indexOf(character) >= 0)
{
output.append("&#").append((int) character).append(';');
}
else
{
output.append(character);
}
}
return (output.toString());
}
/*******************************************************************************
** Reject unsafe paths even if metadata contains unexpected names.
*******************************************************************************/
private static String safeHref(String href)
{
require(AiLinkBuilder.isSafeAppPath(href), "unsafe application path");
return (href);
}
/*******************************************************************************
** A column with no percentage uses -1 in strict mode.
*******************************************************************************/
private static Integer percentColumn(Map<String, Object> column)
{
return (column.get("percentOfColumn") == null ? -1 : index(column.get("percentOfColumn")));
}
/*******************************************************************************
** Indexes must be integral; never silently truncate model input.
*******************************************************************************/
private static Integer index(Object value)
{
try
{
return (number(value).intValueExact());
}
catch(ArithmeticException e)
{
throw (new IllegalArgumentException("index must be an integer"));
}
}
/*******************************************************************************
** Finite numeric input only.
*******************************************************************************/
private static BigDecimal number(Object value)
{
require(value instanceof Number, "numeric value required");
try
{
return (new BigDecimal(value.toString()));
}
catch(NumberFormatException e)
{
throw (new IllegalArgumentException("finite numeric value required"));
}
}
/*******************************************************************************
** Cells and filter values remain raw JSON scalars.
*******************************************************************************/
private static void scalar(Object value)
{
require(value == null || value instanceof String || value instanceof Number || value instanceof Boolean, "scalar value required");
if(value instanceof Number)
{
number(value);
}
if(value instanceof String)
{
text(value);
}
}
/*******************************************************************************
** Bounded single-line display text.
*******************************************************************************/
private static String text(Object value)
{
require(value == null || value instanceof String || value instanceof Number || value instanceof Boolean, "text or scalar required");
String text = value == null ? "" : String.valueOf(value);
require(text.length() <= 2000, "text exceeds 2000 characters");
return (text.replaceAll("[\\p{Cntrl}\\u2028\\u2029]", " "));
}
/*******************************************************************************
** Labels are mandatory plain text.
*******************************************************************************/
private static String label(Map<String, Object> item)
{
String label = text(item.get("label"));
require(!label.isBlank(), "label required");
return (label);
}
/*******************************************************************************
** Validate a JSON object without converting arbitrary values to strings.
*******************************************************************************/
@SuppressWarnings("unchecked")
private static Map<String, Object> object(Object value)
{
require(value instanceof Map<?, ?>, "object required");
return ((Map<String, Object>) value);
}
/*******************************************************************************
** Validate a JSON array.
*******************************************************************************/
private static List<?> list(Object value)
{
require(value instanceof List<?>, "array required");
return ((List<?>) value);
}
/*******************************************************************************
** Ordered output maps allow null raw cells.
*******************************************************************************/
private static Map<String, Object> map(Object... pairs)
{
Map<String, Object> map = new LinkedHashMap<>();
for(Integer i = 0; i < pairs.length; i += 2)
{
map.put((String) pairs[i], pairs[i + 1]);
}
return (map);
}
/*******************************************************************************
** Validation failures contain only server-authored explanations.
*******************************************************************************/
private static void require(Boolean condition, String message)
{
if(!condition)
{
throw (new IllegalArgumentException(message));
}
}
}
@@ -1,280 +0,0 @@
{
"type": "object",
"properties": {
"summary": {
"type": "string",
"maxLength": 300
},
"metrics": {
"type": "array",
"items": {
"$ref": "#/$defs/metric"
}
},
"table": {
"$ref": "#/$defs/table"
},
"chart": {
"$ref": "#/$defs/chart"
},
"filterLinks": {
"type": "array",
"items": {
"$ref": "#/$defs/filterLink"
}
},
"recordLinks": {
"type": "array",
"items": {
"$ref": "#/$defs/recordLink"
}
},
"actions": {
"type": "array",
"items": {
"$ref": "#/$defs/action"
}
}
},
"required": [
"summary"
],
"additionalProperties": false,
"$defs": {
"cell": {
"type": [
"string",
"number",
"boolean",
"null"
]
},
"metric": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"value": {
"type": "number"
},
"tableName": {
"type": "string"
},
"fieldName": {
"type": "string"
}
},
"required": [
"label",
"value"
],
"additionalProperties": false
},
"column": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"tableName": {
"type": "string"
},
"fieldName": {
"type": "string"
},
"percentOfColumn": {
"type": "integer"
}
},
"required": [
"label"
],
"additionalProperties": false
},
"rowFilter": {
"type": "object",
"properties": {
"tableName": {
"type": "string"
},
"fieldName": {
"type": "string"
},
"operator": {
"type": "string"
}
},
"required": [
"tableName",
"fieldName"
],
"additionalProperties": false
},
"criterion": {
"type": "object",
"properties": {
"fieldName": {
"type": "string"
},
"operator": {
"type": "string"
},
"values": {
"type": "array",
"items": {
"$ref": "#/$defs/cell"
}
}
},
"required": [
"fieldName",
"operator",
"values"
],
"additionalProperties": false
},
"filterLink": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"tableName": {
"type": "string"
},
"criteria": {
"$ref": "#/$defs/criteria"
}
},
"required": [
"label",
"tableName",
"criteria"
],
"additionalProperties": false
},
"recordLink": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"tableName": {
"type": "string"
},
"recordId": {
"$ref": "#/$defs/cell"
}
},
"required": [
"label",
"tableName",
"recordId"
],
"additionalProperties": false
},
"action": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"prompt": {
"type": "string"
},
"navigateTo": {
"type": "string"
}
},
"required": [
"label"
],
"additionalProperties": false
},
"chart": {
"type": "object",
"properties": {
"chartType": {
"type": "string",
"enum": [
"bar",
"pie",
"line"
]
},
"title": {
"type": "string"
},
"labelColumn": {
"type": "integer"
},
"valueColumn": {
"type": "integer"
}
},
"required": [
"chartType",
"labelColumn",
"valueColumn"
],
"additionalProperties": false
},
"row": {
"type": "array",
"items": {
"$ref": "#/$defs/cell"
}
},
"columns": {
"type": "array",
"items": {
"$ref": "#/$defs/column"
}
},
"rows": {
"type": "array",
"items": {
"$ref": "#/$defs/row"
}
},
"rowKeys": {
"type": "array",
"items": {
"$ref": "#/$defs/cell"
}
},
"criteria": {
"type": "array",
"items": {
"$ref": "#/$defs/criterion"
}
},
"table": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"columns": {
"$ref": "#/$defs/columns"
},
"rows": {
"$ref": "#/$defs/rows"
},
"rowFilter": {
"$ref": "#/$defs/rowFilter"
},
"rowKeys": {
"$ref": "#/$defs/rowKeys"
}
},
"required": [
"columns",
"rows"
],
"additionalProperties": false
}
},
"description": "Present only facts from previous tool results. Optional sections may be omitted. In strict mode use empty arrays, empty strings, empty table rows/columns and -1 for unused indexes; every property must be supplied. rowKeys belongs to table, one raw key per row. Percent columns may use null cells; the server computes them."
}
@@ -93,10 +93,6 @@ class ClassificationTest extends BaseAssistantTest
assertThat(record.getClassification()).isEqualTo(DataClassification.PHI);
page.observe(ModelPart.toolUse("1", "describe_table", Map.of("table", "page")), new AiToolResult());
assertThat(page.getClassification()).isEqualTo(DataClassification.PUBLIC);
page.observe(ModelPart.toolUse("present", "present_results", Map.of("summary", "Available results")), new AiToolResult());
assertThat(page.getClassification()).isEqualTo(DataClassification.PUBLIC);
record.observe(ModelPart.toolUse("present", "present_results", Map.of("summary", "Available results")), new AiToolResult());
assertThat(record.getClassification()).isEqualTo(DataClassification.PHI);
page.observe(ModelPart.toolUse("2", "search_records", Map.of()), new AiToolResult().withModelContent("<qqq_data>{\"results\":[{\"table\":\"media\"}]}</qqq_data>"));
assertThat(page.getClassification()).isEqualTo(DataClassification.PII);
page.observe(ModelPart.toolUse("3", "search_records", Map.of()), new AiToolResult());
@@ -226,13 +226,9 @@ public class AssistantTestFixture
update("page", new QRecord().withValue("id", 12).withValue("metaDescription", "Who I am and what I build."));
update("page", new QRecord().withValue("id", 12).withValue("content", "Body of page 12, rewritten with a longer story about the home lab."));
List<QRecord> media = new ArrayList<>();
for(Integer i = 0; i < 101; i++)
{
media.add(new QRecord().withValue("fileName", "image-" + i + (i < 81 ? ".jpg" : ".png"))
.withValue("mimeType", i < 81 ? "image/jpeg" : "image/png").withValue("width", 512));
}
insert("media", media);
insert("media", List.of(
new QRecord().withValue("fileName", "cover.heic").withValue("mimeType", "image/heic"),
new QRecord().withValue("fileName", "logo.png").withValue("mimeType", "image/png").withValue("width", 512)));
List<QRecord> stats = new ArrayList<>();
for(int d = 1; d <= 5; d++)
@@ -180,7 +180,6 @@ public class GoldenTasks
},
o -> live(o, "search_records", "/app/blog_post/1")));
tasks.add(PresentationGoldenTask.task());
return (tasks);
}
@@ -1,171 +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.testing;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qbits.assistant.engine.TurnEvent;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import static com.kingsrook.qbits.assistant.testing.GoldenTasks.map;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Owner's image breakdown, shared by deterministic and live provider evals.
*******************************************************************************/
public class PresentationGoldenTask
{
/*******************************************************************************
** Counts come from UI API tool results, including the omitted zero types.
*******************************************************************************/
public static GoldenTasks.Task task()
{
return (new GoldenTasks.Task("images-by-type-presentation", AssistantTestFixture.EDITOR, "How many images do I have by type?", provider ->
{
List<Object[]> calls = new ArrayList<>();
for(String mime : List.of("image/jpeg", "image/png", "image/webp", "image/gif", "image/svg+xml"))
{
calls.add(new Object[] {"count_records", map("table", "media", "filter", GoldenTasks.filter("AND", GoldenTasks.criterion("mimeType", "EQUALS", mime)))});
}
provider.thenToolUses(null, calls).thenRespond(request ->
{
List<ModelPart> results = EvalSupport.lastToolResults(request);
Integer jpeg = ((Number) EvalSupport.unwrap(results.get(0).getText()).get("count")).intValue();
Integer png = ((Number) EvalSupport.unwrap(results.get(1).getText()).get("count")).intValue();
for(Integer i = 2; i < results.size(); i++)
{
assertThat(((Number) EvalSupport.unwrap(results.get(i).getText()).get("count")).intValue()).isZero();
}
return (new ModelResponse().withParts(List.of(ModelPart.toolUse("present", "present_results", input(jpeg, png)))).withStopReason(ModelResponse.STOP_TOOL_USE));
}).thenText("");
}, PresentationGoldenTask::check, PresentationGoldenTask::check));
}
/*******************************************************************************
** The presentation input, deliberately using null percentage placeholders.
*******************************************************************************/
public static Map<String, Object> input(Integer jpeg, Integer png)
{
return (map("summary", (jpeg + png) + " media files are images: " + jpeg + " JPEG and " + png + " PNG.",
"metrics", List.of(map("label", "Images", "value", jpeg + png)),
"table", map("title", "Media by type", "columns", List.of(map("label", "Type"), map("label", "Count"), map("label", "Share", "percentOfColumn", 1)),
"rows", List.of(java.util.Arrays.asList("JPEG", jpeg, null), java.util.Arrays.asList("PNG", png, null)),
"rowFilter", map("tableName", "media", "fieldName", "mimeType"), "rowKeys", List.of("image/jpeg", "image/png")),
"chart", map("chartType", "pie", "title", "Share by type", "labelColumn", 0, "valueColumn", 1),
"filterLinks", List.of(filterLink("JPEG", "image/jpeg"), filterLink("PNG", "image/png"))));
}
/*******************************************************************************
** A raw MIME criterion for the presentation tool.
*******************************************************************************/
private static Map<String, Object> filterLink(String label, String mime)
{
return (map("label", "Open " + label + " media", "tableName", "media", "criteria", List.of(map("fieldName", "mimeType", "operator", "EQUALS", "values", List.of(mime)))));
}
/*******************************************************************************
** Check actual block semantics, allowing a live model to choose labels and
** column order. Values, shares and filter criteria must remain exact.
*******************************************************************************/
public static void check(TurnRunner.Outcome outcome)
{
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
assertThat(outcome.toolCalls()).anyMatch(call -> "count_records".equals(call.getToolName()) && "OK".equals(call.getOutcome()));
assertThat(outcome.events(TurnEvent.PRESENTATION)).hasSize(1);
Map<String, Object> part = object(outcome.events(TurnEvent.PRESENTATION).get(0).get("part"));
assertPart(part);
}
/*******************************************************************************
** Also used against the persisted part, not only the streamed event.
*******************************************************************************/
public static void assertPart(Map<String, Object> part)
{
assertThat(part.get("type")).isEqualTo("presentation");
assertThat((String) part.get("summary")).isNotBlank().doesNotContain("\n").hasSizeLessThanOrEqualTo(300);
assertThat((String) part.get("markdown")).contains("\n|");
Map<String, Object> table = block(part, "table");
List<?> rows = (List<?>) table.get("rows");
assertThat(rows).hasSize(2);
assertThat(rows).anySatisfy(row -> assertThat(object(object(row).get("display")).values()).contains("81", "80.2%"));
assertThat(rows).anySatisfy(row -> assertThat(object(object(row).get("display")).values()).contains("20", "19.8%"));
Map<String, Object> chart = block(part, "chart");
assertThat(chart.get("chartType")).isEqualTo("pie");
List<?> points = (List<?>) chart.get("points");
assertThat(points).hasSize(2);
assertThat(points.stream().map(p -> ((Number) object(p).get("value")).intValue()).toList()).containsExactlyInAnyOrder(81, 20);
List<?> links = (List<?>) block(part, "filterLinks").get("items");
assertThat(links).hasSize(2);
List<Object> mimes = new ArrayList<>();
for(Object entry : links)
{
String href = (String) object(entry).get("href");
assertThat(href).startsWith("/app/media?filter=");
try
{
Map<String, Object> filter = JsonUtils.toObject(URLDecoder.decode(href.substring(href.indexOf('=') + 1), StandardCharsets.UTF_8), new TypeReference<Map<String, Object>>() {});
assertThat(filter).containsEntry("booleanOperator", "AND");
List<?> criteria = (List<?>) filter.get("criteria");
assertThat(criteria).hasSize(1);
Map<String, Object> criterion = object(criteria.get(0));
assertThat(criterion).containsEntry("fieldName", "mimeType").containsEntry("operator", "EQUALS");
mimes.addAll((List<?>) criterion.get("values"));
}
catch(Exception e)
{
throw (new AssertionError("Invalid filtered list href", e));
}
}
assertThat(mimes).containsExactlyInAnyOrder("image/jpeg", "image/png");
}
/*******************************************************************************
** Find one block of a given kind.
*******************************************************************************/
public static Map<String, Object> block(Map<String, Object> part, String kind)
{
return (((List<?>) part.get("blocks")).stream().map(PresentationGoldenTask::object).filter(block -> kind.equals(block.get("kind"))).findFirst().orElseThrow());
}
/*******************************************************************************
** Typed test view of a JSON object.
*******************************************************************************/
@SuppressWarnings("unchecked")
public static Map<String, Object> object(Object value)
{
return ((Map<String, Object>) value);
}
}
@@ -59,8 +59,7 @@ public class TurnRunner
*******************************************************************************/
public String text()
{
return (events.stream().filter(e -> TurnEvent.TEXT_DELTA.equals(e.getType()) || TurnEvent.PRESENTATION.equals(e.getType()))
.map(e -> TurnEvent.PRESENTATION.equals(e.getType()) ? String.valueOf(((Map<?, ?>) e.getData().get("part")).get("markdown")) : String.valueOf(e.getData().get("text"))).collect(Collectors.joining()));
return (events.stream().filter(e -> TurnEvent.TEXT_DELTA.equals(e.getType())).map(e -> String.valueOf(e.getData().get("text"))).collect(Collectors.joining()));
}
@@ -1,275 +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.tools;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.engine.MessageParts;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.TurnEvent;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.PresentationGoldenTask;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.PresentationGoldenTask.block;
import static com.kingsrook.qbits.assistant.testing.PresentationGoldenTask.object;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Presentation persistence, permission boundaries, formatting and input limits.
*******************************************************************************/
class PresentResultsToolTest extends BaseAssistantTest
{
/*******************************************************************************
** Streamed, replayed and stored presentations agree; retention removes data.
*******************************************************************************/
@Test
void testPersistenceAndReplay() throws Exception
{
PresentationGoldenTask.task().script().accept(provider);
TurnRunner.Outcome outcome = runner.run(AssistantTestFixture.EDITOR, "How many images do I have by type?");
PresentationGoldenTask.check(outcome);
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.EDITOR, config));
Map<String, Object> event = outcome.events(TurnEvent.PRESENTATION).get(0);
AssistantMessage message = runner.runtime().getStore().getMessage(AssistantTestFixture.EDITOR, (Integer) event.get("messageId"));
MessageParts parts = MessageParts.fromJson(message.getPartsJson());
Map<String, Object> part = parts.list().stream().filter(p -> "presentation".equals(p.get("type"))).findFirst().orElseThrow();
PresentationGoldenTask.assertPart(part);
assertThat(part).isEqualTo(com.kingsrook.qqq.backend.core.utils.JsonUtils.toObject(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(event.get("part")), Map.class));
List<?> rows = (List<?>) block(part, "table").get("rows");
List<?> points = (List<?>) block(part, "chart").get("points");
List<?> links = (List<?>) block(part, "filterLinks").get("items");
for(Integer i = 0; i < 2; i++)
{
assertThat(object(rows.get(i)).get("href")).isEqualTo(object(links.get(i)).get("href"));
assertThat(object(points.get(i)).get("href")).isEqualTo(object(rows.get(i)).get("href"));
}
assertThat(runner.runtime().getStore().listEvents(AssistantTestFixture.EDITOR, outcome.turnId(), 0, 1000))
.anyMatch(e -> TurnEvent.PRESENTATION.equals(e.getEventType()));
assertThat(parts.text()).contains("81", "20", "\n|");
assertThat(parts.stripRecordData()).isTrue();
assertThat(parts.list()).noneMatch(p -> "presentation".equals(p.get("type")));
assertThat(outcome.toolCalls()).anySatisfy(call ->
{
assertThat(call.getToolName()).isEqualTo(PresentResultsTool.NAME);
assertThat(call.getInputJson()).contains("presentation");
assertThat(call.getResultJson()).contains("Do not repeat");
});
}
/*******************************************************************************
** The real UI metadata hides media from an analyst, as does presentation.
*******************************************************************************/
@Test
void testUiDenial() throws Exception
{
String user = AssistantTestFixture.ANALYST;
UiApiResult metadata = runner.runtime().getUiApiGateway().invoke(new UiApiCall("GET", "/metaData").withSessionUUID(user));
assertThat(object(metadata.json().get("tables"))).doesNotContainKey("media");
provider.thenToolUse(PresentResultsTool.NAME, input("summary", "The list is unavailable.", "recordLinks", List.of(input("label", "Image", "tableName", "media", "recordId", "1")))).thenText("");
TurnRunner.Outcome outcome = runner.run(user, "Open an image");
assertThat(outcome.toolCalls().get(0).getResultJson()).contains("table not visible through UI API: media");
Map<String, Object> part = object(outcome.events(TurnEvent.PRESENTATION).get(0).get("part"));
assertThat((List<?>) part.get("blocks")).isEmpty();
assertThat(outcome.toolCalls().get(0).getInputJson()).doesNotContain("recordId");
}
/*******************************************************************************
** Cache metadata once per turn and send the original session UUID.
*******************************************************************************/
@Test
void testMetadataCache() throws Exception
{
List<UiApiCall> calls = new ArrayList<>();
UiApiGateway gateway = new UiApiGateway()
{
/*******************************************************************************
** Fixture metadata gateway.
*******************************************************************************/
@Override
public boolean isInvocable(String method, String path)
{
return (true);
}
/*******************************************************************************
** Fixture metadata gateway.
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call)
{
calls.add(call);
return (new UiApiResult(200, "{\"tables\":{\"media\":{\"readPermission\":true}}}"));
}
};
UiApiToolbox.Context context = new UiApiToolbox.Context("editor-session", "UTC", gateway, PolicyService.ToolPolicy.defaults(), null);
PresentResultsTool tool = new PresentResultsTool(qInstance);
assertThat(tool.execute(context, "1", PresentationGoldenTask.input(81, 20)).getOutcome()).isEqualTo(AiToolOutcome.OK);
tool.execute(context, "2", PresentationGoldenTask.input(81, 20));
assertThat(calls).hasSize(1).allMatch(call -> "editor-session".equals(call.getSessionUUID()) && "/metaData".equals(call.getPathTemplate()));
new PresentResultsTool(qInstance).execute(context, "3", PresentationGoldenTask.input(81, 20));
assertThat(calls).hasSize(2);
}
/*******************************************************************************
** Unknown metadata is dropped independently without persisting its values.
*******************************************************************************/
@Test
void testUnknownMetadata()
{
AiToolResult result = execute(input("summary", "Available results.", "metrics", List.of(
input("label", "Unknown table", "value", 9876, "tableName", "missing"),
input("label", "Unknown field", "value", 5432, "tableName", "media", "fieldName", "missing"),
input("label", "Known", "value", 101))));
assertThat(result.getModelContent()).contains("Dropped metrics[0]", "unknown table", "Dropped metrics[1]", "unknown field");
assertThat(result.getRender().toString()).doesNotContain("9876", "5432");
assertThat((List<?>) block(result.getRender(), "metrics").get("items")).hasSize(1);
}
/*******************************************************************************
** Markdown punctuation, external URLs and pipes remain literal text.
*******************************************************************************/
@Test
void testUntrustedLabelsAndLinks()
{
AiToolResult result = execute(input("summary", "Available results.", "metrics", List.of(input("label", "[evil](https://evil.example) | <img>", "value", 1)),
"recordLinks", List.of(input("label", "Open", "tableName", "media", "recordId", "a/b?x#y")),
"actions", List.of(input("label", "Ask", "prompt", "Show images"), input("label", "Open media", "navigateTo", "media"), input("label", "Bad", "prompt", "Ask", "navigateTo", "media"))));
String markdown = (String) result.getRender().get("markdown");
assertThat(markdown).doesNotContain("https://", "<img>", "[evil]").contains("&#124;", "[Open](/app/media/a%2Fb%3Fx%23y)", "- Ask: Show images", "[Open media](/app/media)");
assertThat(result.getModelContent()).contains("exactly one");
}
/*******************************************************************************
** Formatting follows metadata while raw values remain numeric.
*******************************************************************************/
@Test
void testFormatting()
{
qInstance.getTable("media").addField(new QFieldMetaData("price", QFieldType.DECIMAL).withDisplayFormat("$%,.2f"));
AiToolResult result = execute(input("summary", "Amounts.", "metrics", List.of(input("label", "Amount", "value", 1234.5, "tableName", "media", "fieldName", "price"), input("label", "Count", "value", 12000)),
"table", input("columns", List.of(input("label", "Amount", "tableName", "media", "fieldName", "price")), "rows", List.of(List.of(1234.5)))));
assertThat(block(result.getRender(), "metrics").toString()).contains("$1,234.50", "12,000", "1234.5");
assertThat(block(result.getRender(), "table").toString()).contains("$1,234.50");
}
/*******************************************************************************
** Percentages cannot refer to labels, derived percentages or bad indexes.
*******************************************************************************/
@Test
void testInvalidPercentColumn()
{
for(Object source : List.of(0, 3, 1.5))
{
AiToolResult result = execute(input("summary", "Invalid share.", "table", input("columns", List.of(input("label", "Name"), input("label", "Share", "percentOfColumn", source)), "rows", List.of(List.of("JPEG", 0)))));
assertThat(result.getModelContent()).contains("Dropped table");
assertThat((List<?>) result.getRender().get("blocks")).isEmpty();
}
}
/*******************************************************************************
** Zero totals are defined, and charts never exceed twelve points.
*******************************************************************************/
@Test
void testChartLimitAndZeroTotal()
{
List<List<Object>> rows = new ArrayList<>();
for(Integer i = 0; i < 13; i++)
{
rows.add(List.of("Item " + i, 0, 0));
}
AiToolResult result = execute(input("summary", "Thirteen items.", "table", input("columns", List.of(input("label", "Item"), input("label", "Count"), input("label", "Share", "percentOfColumn", 1)), "rows", rows),
"chart", input("chartType", "bar", "labelColumn", 0, "valueColumn", 1)));
assertThat((List<?>) block(result.getRender(), "chart").get("points")).hasSize(12);
assertThat((List<?>) block(result.getRender(), "table").get("rows")).hasSize(13);
assertThat(result.getModelContent()).contains("truncated to 12");
assertThat(block(result.getRender(), "table").toString()).contains("0.0%");
}
/*******************************************************************************
** Operator validation does not coerce raw numeric/boolean filter values.
*******************************************************************************/
@Test
void testCriteriaValidation()
{
AiToolResult result = execute(input("summary", "Links.", "filterLinks", List.of(
input("label", "Invalid", "tableName", "media", "criteria", List.of(input("fieldName", "width", "operator", "INVENTED", "values", List.of(10)))),
input("label", "Valid", "tableName", "media", "criteria", List.of(input("fieldName", "width", "operator", "EQUALS", "values", List.of(512)))))));
assertThat(result.getModelContent()).contains("invalid criteria operator");
List<?> links = (List<?>) block(result.getRender(), "filterLinks").get("items");
String decoded = java.net.URLDecoder.decode((String) object(links.get(0)).get("href"), java.nio.charset.StandardCharsets.UTF_8);
assertThat(decoded).contains("\"values\":[512]");
}
/*******************************************************************************
** Invalid summaries produce an error tool result; no presentation is emitted.
*******************************************************************************/
@Test
void testInvalidSummaryAndAlwaysOffered() throws Exception
{
assertThat(runner.runtime().getContextBuilder().tools(new PolicyService.ToolPolicy(true, java.util.Set.of("navigate"), 20, true)))
.anyMatch(tool -> PresentResultsTool.NAME.equals(tool.getName()) && !Boolean.TRUE.equals(tool.getStrict()));
assertThat(execute(input("summary", "x".repeat(301))).getOutcome()).isEqualTo(AiToolOutcome.INVALID_INPUT);
provider.thenToolUse(PresentResultsTool.NAME, Map.of()).thenText("Unable to present.");
TurnRunner.Outcome outcome = runner.run(AssistantTestFixture.EDITOR, "Present");
assertThat(outcome.events(TurnEvent.PRESENTATION)).isEmpty();
assertThat(outcome.toolCalls().get(0).getOutcome()).isEqualTo("INVALID_INPUT");
}
/*******************************************************************************
** Use the fixture's real UI gateway for unit-level presentation checks.
*******************************************************************************/
private AiToolResult execute(Map<String, Object> input)
{
return (new PresentResultsTool(qInstance).execute(new UiApiToolbox.Context(AssistantTestFixture.EDITOR, "UTC", runner.runtime().getUiApiGateway(), PolicyService.ToolPolicy.defaults(), null), "present", input));
}
}