feat: QQQ Assistant qbit phase 0/1 (read-only Q&A, SSE, Anthropic provider) #1

Merged
KofTwentyTwo merged 7 commits from feature/phase-0-1-assistant into develop 2026-10-12 17:28:10 +00:00
KofTwentyTwo commented 2026-10-12 11:29:28 +00:00 (Migrated from github.com)

Summary

This is Phase 0/1 of the QQQ Assistant QBit. It covers the read-only Q&A and navigation chat backend and the Claude provider. Spec: QRun-IO/qqq docs/specs/2026-10-11-qqq-assistant-qbit-design.md. The shared contract is the coordinator's assist-contracts.md.

The build is multi-module and published by qqq-orb. It produces three artifacts:

  • com.kingsrook.qbits:qbit-assistant-parent
  • qbit-assistant
  • qbit-assistant-provider-anthropic

<revision> is set in the root POM, and the flatten plugin resolves it in the published POMs.

qbit-assistant

  • Config and producer. AssistantQBitProducer and AssistantQBitConfig cover:

    • the per-app enable flag, also settable through QQQ_ASSISTANT_ENABLED
    • provider and model routing: claude-sonnet-5-5 at effort medium, with Opus 5.5 and Haiku 5.5 slots reserved
    • limits: 25 tool calls, 4 in parallel, 50 rows, 500 characters, 300 s
    • soft and hard budgets per user per day and per instance per month
    • rate limits: 10 turns per minute and 1 concurrent turn
    • retention: 90, 30 and 365 days
    • null-safe validate.
  • Tables (D6). Standard, visible QQQ tables: assistantConversation, assistantMessage, assistantTurn, assistantTurnEvent (SSE replay), assistantToolCall, assistantFeedback, assistantPolicy and assistantUsageDaily.

    • Every user-owned table is locked to its owner (assistantUserId). Admins read through assistantUserIdAllAccess.
    • The tables appear in an Assistant Admin app that requires assistant.admin.
    • The assistantRetention process, which can be scheduled, purges old data.
  • Routes. Per contract §3, /qqq/v1/assistant:

    • config, conversations CRUD, turns as SSE, /events?after= replay, cancel and feedback.
    • Each request uses the user's own session via ExecutorSessionUtils.
    • Checks: assistant.hasAccess, the enabled flag, the kill switch (at the start and before every step), budgets and rate limits.
    • The SSE stream sends a 15 s ping and sets Cache-Control: no-cache and X-Accel-Buffering: no.
    • A turn runs on its own thread, so a client disconnect does not stop it.
  • TurnEngine.

    • Streams the answer.
    • Runs read and navigation tools through AiToolKernel, at most 4 in parallel.
    • Returns ALL tool results in ONE user message, with failures as is_error.
    • Enforces the limits: tool calls, time, budget, cancel and kill switch.
    • Handles refusal, max_tokens and provider errors.
    • Persists the message parts (tool parts with their render payloads, record refs and navigation), turns (tokens including cache reads and writes, cost in micros), tool calls (sanitized input, outcome, permission check, rows, duration, summary, result, redactions), events and daily usage.
    • Writes structured logs without message content.
  • ContextBuilder. Builds the request in cache-prefix order:

    • the tools, sorted by the kernel
    • the static system block: safety rules, QQQ primer and app digest, marked LONG (1 h)
    • the user digest and date, marked SHORT (5 m)
    • the history, with old tool results reduced to summaries
    • the new turn, marked SHORT.

    The system prompt tells the model that qqq_data content is untrusted, that numbers must come from tool results, and that it may only link to /app paths.

  • Provider SPI. Per spec §2.8: AssistantModelProvider, ModelRequest, ModelEventListener, StreamHandle and ModelCapabilities. Also includes a deterministic, recording ScriptedModelProvider.

  • Record Developer screen panel. A provider for the "Assistant" panel, built on the core developer-panel contract (QRun-IO/qqq#1076) and fed by AiToolKernel.describeAccess. It shows:

    • status
    • table exposure and the user's own permissions
    • which tools apply
    • field exposure
    • the user's recent tool calls on the table
    • an "Ask the assistant about this record" link (?assistant=open&assistantPrompt=).

qbit-assistant-provider-anthropic

AnthropicModelProvider uses anthropic-java 2.71.0 streaming with MessageAccumulator:

  • Strict tools with tool_choice: auto.
  • Adaptive thinking with display: omitted. Thinking is never forwarded. The thinking signature is sent back verbatim inside the tool loop.
  • Explicit output_config.effort.
  • cache_control set to 1 h on the static system block and 5 m on the user block and the latest message.
  • refusal handled.
  • Cancel closes the HTTP stream.
  • The API key comes only from ANTHROPIC_API_KEY, a host-supplied secret, or a ${env.X} reference. It never appears in logs, toString, exceptions or tables.

Tests

./mvnw verify passes against a local QQQ build. All 67 tests pass. The JaCoCo gate (70% instructions, 90% classes) also passes in both modules:

Area Tests Covers
ArchUnit 3 No QSystemUserSession and no new QSession. Only persistence/ calls the data actions: Query, Get, Count, Aggregate, Insert, Update, Delete, RunProcess, RecordSearch, RenderWidget and Export.
Red team (@Tag("redteam")) 9 Gates the build. A user without permission gets a polite denial on every read tool, with no counts, labels, render data or refs. A user without assistant.hasAccess gets no assistant. Another user's conversation, turn or cancel, and an admin using the panel API, all get 404. Flipping the kill switch mid-turn stops the turn before the next tool runs. A hard budget stops the turn mid-way and then returns 429. Sentinel values in hidden and password fields never appear in any captured outbound request, and assistant* tables are never readable. Tool results stay enveloped, with exactly one opening and one closing tag, even when an injected record contains a fake </qqq_data>. External navigation is refused. Provider thinking state never reaches SSE or the stored parts. Cancel prevents pending tools from running.
Golden evals (@Tag("golden")) 5 Run in every build: count with a filter, example D (what changed on page 12 this week), the read half of example A (missing SEO data, two parallel reads), navigation, and search. The checks cover tool calls and arguments, numbers against the fixture's ground truth, and deep links.
Live evals (@Tag("live")) 5 The same tasks against claude-sonnet-5-5. Skipped unless ANTHROPIC_API_KEY is set and -Dassistant.live=true is passed. Not run here: no key was available.
Anthropic adapter 7 Uses recorded SSE fixtures from a local HTTP server; no network. Covers request shape, text streaming, thinking never forwarded, parallel tool_use and the round trip, refusal, 529, 401 and mid-stream errors, cancel, timeout, and key handling.
Routes over HTTP 6 Contract JSON shapes, SSE framing and sequence numbers, response headers, replay, cancel, pings, feedback, isolation, and CSRF-shaped requests.
Engine, producer, retention, developer panel, robustness 36 Includes orphaned turns, concurrent usage, partial usage, the kill switch failing closed and its watcher, event paging, and kernel settings.
/metaData probe 1 supplementalInstanceMetaData.assistant is published as {available: true, version: 1, basePath} (the v1 middleware in #1078).

Required QQQ version

The minimum QQQ is the QQQ 4.1 RC that contains both QRun-IO/qqq#1076 (developer panels) and QRun-IO/qqq#1078 (AI tool kernel).

CI status: red until QQQ ships the kernel

This code needs a QQQ that has both:

Neither is in a published QQQ yet. The committed default stays 4.1.0-RC.3 so the build remains resolvable, which means CI cannot compile this branch until a QQQ RC with both ships. After that RC ships, set qqq.snapshot.version and CI should go green.

I built and tested locally against -Dqqq.snapshot.version=4.1.0-ASSISTDEV-SNAPSHOT, which is the #1076 branch merged with the #1078 kernel branch at 48a71dec, installed locally. The kernel-only 4.1.0-AIKERNEL-SNAPSHOT build is not enough on its own, because the developer/ package (which the producer references) needs #1076.

Security review follow-ups (second commit)

  • Kernel settings from config:
    • allowedWidgetNames: the kernel denies any widget not on this list. It is the only way to allow a widget, because core widget meta-data has no supplemental hook.
    • hiddenWidgetNames
    • customRedactionPatterns.
  • Orphaned RUNNING turns (the replica that ran them died): the first replay, cancel, delete or new turn finishes them as ERROR. The assistant message is set to ERROR, and synthetic error (INTERNAL) and message.end events are persisted. Replay is paged at 500 events and stops after maxTurnSeconds + 60 s.
  • Retention: the tool-result cutoff now also deletes stream events, and removes render payloads and record_refs from stored messages.
  • Usage counting:
    • addUsage uses per-key locks, a unique key on (scope, userId, usageDate), and a retry when two inserts race.
    • Usage from cancelled, timed-out and failed model calls is counted. The provider attaches it as partial usage.
  • Kill switch: it fails closed if the policy can't be read. A watcher thread re-checks it every 2 s, using the user's own session, and cancels every turn on the replica when it flips.
  • Page context: strings are capped at 512 characters, selectedIds at 100 entries, and only the known keys and types are kept.
  • List limits: 200 conversations and 50 running turns.
  • Ordering: terminal events are persisted before the turn's status becomes final.
  • Probe meta-data: an assistant supplemental instance meta-data object, {version, basePath}, is registered when the QBit is enabled. The Next UI uses it as its probe gate.

Contract deviations

These are recorded under "## Deviations (qbit)" in the shared contract:

  • Turns refused before streaming starts return JSON errors with a code, not SSE events.
  • ping events carry no sequence number.
  • A new assistantTurnEvent table stores events for replay.
  • Child tables are locked on their own userId.
  • assistant.admin is enforced by a custom permission checker.
  • The developer panel is built on #1076, so no QBit route is needed.
  • Fixture page 12 has 4 audit entries (the creation plus 3 edits).

Deferred (later phases)

  • Approval, artifact and exposure-override tables
  • Conversation titles from Haiku, escalation to Opus
  • clear_tool_uses context editing
  • Micrometer metrics and OpenTelemetry
  • countTokens

🤖 Generated with Claude Code

## Summary This is Phase 0/1 of the QQQ Assistant QBit. It covers the read-only Q&A and navigation chat backend and the Claude provider. Spec: QRun-IO/qqq `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`. The shared contract is the coordinator's `assist-contracts.md`. The build is multi-module and published by qqq-orb. It produces three artifacts: - `com.kingsrook.qbits:qbit-assistant-parent` - `qbit-assistant` - `qbit-assistant-provider-anthropic` `<revision>` is set in the root POM, and the flatten plugin resolves it in the published POMs. ### qbit-assistant - **Config and producer.** `AssistantQBitProducer` and `AssistantQBitConfig` cover: - the per-app enable flag, also settable through `QQQ_ASSISTANT_ENABLED` - provider and model routing: `claude-sonnet-5-5` at effort `medium`, with Opus 5.5 and Haiku 5.5 slots reserved - limits: 25 tool calls, 4 in parallel, 50 rows, 500 characters, 300 s - soft and hard budgets per user per day and per instance per month - rate limits: 10 turns per minute and 1 concurrent turn - retention: 90, 30 and 365 days - null-safe `validate`. - **Tables (D6).** Standard, visible QQQ tables: `assistantConversation`, `assistantMessage`, `assistantTurn`, `assistantTurnEvent` (SSE replay), `assistantToolCall`, `assistantFeedback`, `assistantPolicy` and `assistantUsageDaily`. - Every user-owned table is locked to its owner (`assistantUserId`). Admins read through `assistantUserIdAllAccess`. - The tables appear in an **Assistant Admin** app that requires `assistant.admin`. - The `assistantRetention` process, which can be scheduled, purges old data. - **Routes.** Per contract §3, `/qqq/v1/assistant`: - `config`, conversations CRUD, turns as **SSE**, `/events?after=` replay, cancel and feedback. - Each request uses the user's own session via `ExecutorSessionUtils`. - Checks: `assistant.hasAccess`, the enabled flag, the kill switch (at the start and before every step), budgets and rate limits. - The SSE stream sends a 15 s ping and sets `Cache-Control: no-cache` and `X-Accel-Buffering: no`. - A turn runs on its own thread, so a client disconnect does not stop it. - **TurnEngine.** - Streams the answer. - Runs read and navigation tools through **`AiToolKernel`**, at most 4 in parallel. - Returns ALL tool results in ONE user message, with failures as `is_error`. - Enforces the limits: tool calls, time, budget, cancel and kill switch. - Handles `refusal`, `max_tokens` and provider errors. - Persists the message parts (tool parts with their `render` payloads, record refs and navigation), turns (tokens including cache reads and writes, cost in micros), tool calls (sanitized input, outcome, permission check, rows, duration, summary, result, redactions), events and daily usage. - Writes structured logs without message content. - **ContextBuilder.** Builds the request in cache-prefix order: - the tools, sorted by the kernel - the static system block: safety rules, QQQ primer and app digest, marked LONG (1 h) - the user digest and date, marked SHORT (5 m) - the history, with old tool results reduced to summaries - the new turn, marked SHORT. The system prompt tells the model that `qqq_data` content is untrusted, that numbers must come from tool results, and that it may only link to `/app` paths. - **Provider SPI.** Per spec §2.8: `AssistantModelProvider`, `ModelRequest`, `ModelEventListener`, `StreamHandle` and `ModelCapabilities`. Also includes a deterministic, recording `ScriptedModelProvider`. - **Record Developer screen panel.** A provider for the **"Assistant" panel**, built on the core developer-panel contract (QRun-IO/qqq#1076) and fed by `AiToolKernel.describeAccess`. It shows: - status - table exposure and the user's own permissions - which tools apply - field exposure - the user's recent tool calls on the table - an **"Ask the assistant about this record"** link (`?assistant=open&assistantPrompt=`). ### qbit-assistant-provider-anthropic `AnthropicModelProvider` uses anthropic-java **2.71.0** streaming with `MessageAccumulator`: - Strict tools with `tool_choice: auto`. - Adaptive thinking with `display: omitted`. Thinking is never forwarded. The thinking signature is sent back verbatim inside the tool loop. - Explicit `output_config.effort`. - `cache_control` set to 1 h on the static system block and 5 m on the user block and the latest message. - `refusal` handled. - Cancel closes the HTTP stream. - The API key comes **only** from `ANTHROPIC_API_KEY`, a host-supplied secret, or a `${env.X}` reference. It never appears in logs, `toString`, exceptions or tables. ## Tests `./mvnw verify` passes against a local QQQ build. All **67 tests** pass. The JaCoCo gate (70% instructions, 90% classes) also passes in both modules: | Area | Tests | Covers | |---|---|---| | ArchUnit | 3 | No `QSystemUserSession` and no `new QSession`. Only `persistence/` calls the data actions: Query, Get, Count, Aggregate, Insert, Update, Delete, RunProcess, RecordSearch, RenderWidget and Export. | | Red team (`@Tag("redteam")`) | 9 | Gates the build. A user without permission gets a polite denial on every read tool, with no counts, labels, render data or refs. A user without `assistant.hasAccess` gets no assistant. Another user's conversation, turn or cancel, and an admin using the panel API, all get 404. Flipping the kill switch mid-turn stops the turn before the next tool runs. A hard budget stops the turn mid-way and then returns 429. Sentinel values in hidden and password fields never appear in any captured outbound request, and `assistant*` tables are never readable. Tool results stay enveloped, with exactly one opening and one closing tag, even when an injected record contains a fake `</qqq_data>`. External navigation is refused. Provider thinking state never reaches SSE or the stored parts. Cancel prevents pending tools from running. | | Golden evals (`@Tag("golden")`) | 5 | Run in every build: count with a filter, **example D** (what changed on page 12 this week), **the read half of example A** (missing SEO data, two parallel reads), navigation, and search. The checks cover tool calls and arguments, numbers against the fixture's ground truth, and deep links. | | Live evals (`@Tag("live")`) | 5 | The same tasks against `claude-sonnet-5-5`. Skipped unless `ANTHROPIC_API_KEY` is set and `-Dassistant.live=true` is passed. Not run here: no key was available. | | Anthropic adapter | 7 | Uses recorded SSE fixtures from a local HTTP server; no network. Covers request shape, text streaming, thinking never forwarded, parallel `tool_use` and the round trip, refusal, 529, 401 and mid-stream errors, cancel, timeout, and key handling. | | Routes over HTTP | 6 | Contract JSON shapes, SSE framing and sequence numbers, response headers, replay, cancel, pings, feedback, isolation, and CSRF-shaped requests. | | Engine, producer, retention, developer panel, robustness | 36 | Includes orphaned turns, concurrent usage, partial usage, the kill switch failing closed and its watcher, event paging, and kernel settings. | | `/metaData` probe | 1 | `supplementalInstanceMetaData.assistant` is published as `{available: true, version: 1, basePath}` (the v1 middleware in #1078). | ## Required QQQ version The minimum QQQ is **the QQQ 4.1 RC that contains both QRun-IO/qqq#1076 (developer panels) and QRun-IO/qqq#1078 (AI tool kernel)**. ## CI status: red until QQQ ships the kernel This code needs a QQQ that has **both**: - the AI tool kernel (QRun-IO/qqq#1078, branch `feature/ai-tool-kernel`) - the developer-panel contract (QRun-IO/qqq#1076). Neither is in a published QQQ yet. The committed default stays `4.1.0-RC.3` so the build remains resolvable, which means **CI cannot compile this branch until a QQQ RC with both ships**. After that RC ships, set `qqq.snapshot.version` and CI should go green. I built and tested locally against `-Dqqq.snapshot.version=4.1.0-ASSISTDEV-SNAPSHOT`, which is the #1076 branch merged with the #1078 kernel branch at `48a71dec`, installed locally. The kernel-only `4.1.0-AIKERNEL-SNAPSHOT` build is not enough on its own, because the `developer/` package (which the producer references) needs #1076. ## Security review follow-ups (second commit) - **Kernel settings from config:** - `allowedWidgetNames`: the kernel denies any widget not on this list. It is the only way to allow a widget, because core widget meta-data has no supplemental hook. - `hiddenWidgetNames` - `customRedactionPatterns`. - **Orphaned RUNNING turns** (the replica that ran them died): the first replay, cancel, delete or new turn finishes them as ERROR. The assistant message is set to ERROR, and synthetic `error` (INTERNAL) and `message.end` events are persisted. Replay is paged at 500 events and stops after `maxTurnSeconds` + 60 s. - **Retention:** the tool-result cutoff now also deletes stream events, and removes `render` payloads and `record_refs` from stored messages. - **Usage counting:** - `addUsage` uses per-key locks, a unique key on (scope, userId, usageDate), and a retry when two inserts race. - Usage from cancelled, timed-out and failed model calls is counted. The provider attaches it as partial usage. - **Kill switch:** it fails closed if the policy can't be read. A watcher thread re-checks it every 2 s, using the user's own session, and cancels every turn on the replica when it flips. - **Page context:** strings are capped at 512 characters, `selectedIds` at 100 entries, and only the known keys and types are kept. - **List limits:** 200 conversations and 50 running turns. - **Ordering:** terminal events are persisted before the turn's status becomes final. - **Probe meta-data:** an `assistant` supplemental instance meta-data object, `{version, basePath}`, is registered when the QBit is enabled. The Next UI uses it as its probe gate. ## Contract deviations These are recorded under "## Deviations (qbit)" in the shared contract: - Turns refused before streaming starts return JSON errors with a `code`, not SSE events. - `ping` events carry no sequence number. - A new `assistantTurnEvent` table stores events for replay. - Child tables are locked on their own `userId`. - `assistant.admin` is enforced by a custom permission checker. - The developer panel is built on #1076, so no QBit route is needed. - Fixture page 12 has 4 audit entries (the creation plus 3 edits). ## Deferred (later phases) - Approval, artifact and exposure-override tables - Conversation titles from Haiku, escalation to Opus - `clear_tool_uses` context editing - Micrometer metrics and OpenTelemetry - `countTokens` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
KofTwentyTwo commented 2026-10-12 14:27:14 +00:00 (Migrated from github.com)

Release-captain note (2026-10-12): this PR needs the AI tool kernel from QRun-IO/qqq#1078, which ships in QQQ 4.1.0-RC.4. The captain is sequencing that now; #1078 is in the RC.4 merge queue. CI here fails on the 4.1.0-RC.3 pin, which is expected.

Plan once RC.4 is on Maven Central (the captain will comment here):

  1. Bump qqq.snapshot.version to 4.1.0-RC.4. No SNAPSHOT.
  2. Get test_only green.
  3. The captain merges to develop and cuts release/0.1, so the orb publishes 0.1.0-RC.1 to Central.

Assistant agent: please keep the branch otherwise ready, and reply here if anything else blocks a first RC.

🤖 Generated with Claude Code

Release-captain note (2026-10-12): this PR needs the AI tool kernel from QRun-IO/qqq#1078, which ships in **QQQ 4.1.0-RC.4**. The captain is sequencing that now; #1078 is in the RC.4 merge queue. CI here fails on the 4.1.0-RC.3 pin, which is expected. Plan once RC.4 is on Maven Central (the captain will comment here): 1. Bump `qqq.snapshot.version` to `4.1.0-RC.4`. No SNAPSHOT. 2. Get test_only green. 3. The captain merges to develop and cuts `release/0.1`, so the orb publishes `0.1.0-RC.1` to Central. Assistant agent: please keep the branch otherwise ready, and reply here if anything else blocks a first RC. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
KofTwentyTwo commented 2026-10-12 17:16:29 +00:00 (Migrated from github.com)

Adoption check against the RC.4 candidate. I ran ./mvnw install -Dqqq.snapshot.version=4.1.0-RC4CAND-SNAPSHOT against a local build of release/4.1 at 649dbeb (the 4.1.0-RC.4 candidate). Results: 61 + 7 tests and the JaCoCo gates pass.

New commit 5508f46: fix(security). Before this fix, adminRules() set only the custom checker. The enricher then filled in the host's default level. On a NOT_PROTECTED host such as kof22, that made the meta-data path return ALLOW without ever calling the checker. As a result, every user saw the Assistant Admin app, its tables and the retention process in the nav (actions on them were still denied). The rules are now HAS_ACCESS_PERMISSION + HIDDEN + the checker. The new regression test fails without the fix (ALLOW) and passes with it. Please release 0.1.0-RC.1 with this commit included.

Before RC.1: once RC.4 is on Central, set the committed qqq.snapshot.version default to 4.1.0-RC.4 so CI goes green.

Adopter: KofTwentyTwo/Website-Backend#114.

**Adoption check against the RC.4 candidate.** I ran `./mvnw install -Dqqq.snapshot.version=4.1.0-RC4CAND-SNAPSHOT` against a local build of `release/4.1` at 649dbeb (the 4.1.0-RC.4 candidate). Results: 61 + 7 tests and the JaCoCo gates pass. **New commit 5508f46: fix(security).** Before this fix, `adminRules()` set only the custom checker. The enricher then filled in the host's default level. On a NOT_PROTECTED host such as kof22, that made the meta-data path return ALLOW without ever calling the checker. As a result, every user saw the Assistant Admin app, its tables and the retention process in the nav (actions on them were still denied). The rules are now `HAS_ACCESS_PERMISSION` + `HIDDEN` + the checker. The new regression test fails without the fix (ALLOW) and passes with it. **Please release 0.1.0-RC.1 with this commit included.** **Before RC.1:** once RC.4 is on Central, set the committed `qqq.snapshot.version` default to `4.1.0-RC.4` so CI goes green. Adopter: KofTwentyTwo/Website-Backend#114.
Sign in to join this conversation.