Compare commits

..
Author SHA1 Message Date
james.maes 1719147114 Merge pull request 'feat(access): writes follow the signed-in person; no answer without a spending limit; a fourth review of the tool boundary closed' (#109) from feat/writes-follow-the-person into develop
maven / build (push) Successful in 8m39s
build / maven (push) Successful in 8m39s
2026-11-12 19:26:46 +00:00
james.maesandClaude Fable 5.2 cc5fe2a700 feat(access): writes follow the signed-in person; no answer without a spending limit; fix: a fourth review of the tool boundary
maven / build (push) Successful in 7m33s
build / maven (push) Successful in 7m33s
Package A2 of the actions design (docs/actions-and-mcp.md, sections 2 and 3),
with two owner decisions of 2026-11-12, and five findings of an independent
review of the tool boundary, each closed by the guarantee it gives.

Writes follow the person
- The data access of a table or process is declared in versioned code:
  AssistantQBitConfig.withDefaultTableAccess / withDefaultProcessAccess (both
  READ_WRITE) and a pin per object (withTableAccess, withProcessAccess).
  READ_WRITE grants nothing: the assistant may propose what the signed-in
  person may do themselves, and every change waits for their confirmation.
- An administrator's assistant_data_access row can only narrow (READ, NONE).
  It never raises a code default, a pin or a table kept from AI.
- Every assistant_* table and every process of the QBit is NONE by a pin in
  code that no row and no host pin lifts.
- The write tools are offered to a person who may create, edit or delete
  records of some table (asked of the UI API as that person, once per offer,
  within the tool-call time limit; no answer means no write tool). GET /config
  features.writes is that answer for the person.
- Settings that cannot be read, and were never read, allow nothing.
- The table action "Add rows for every table" is retired. The changeset
  assistant-data-access-follow-user removes the unedited READ rows it wrote,
  and no other row.

No answer without a spending limit
- A person for whom no spending limit is in force (BudgetService.limitInForce)
  is refused: the turn before anything is stored, every model call, every tool
  call, every embedding request, a scheduled turn. Restoring a limit restores
  service on the next request.

Review findings
- A pending action is never marked expired over its outcome: expiry is a
  decision made through the same claim as confirming and cancelling.
- No action stays RUNNING for ever: retention ends one left running for more
  than an hour as FAILED ("outcome unknown, check the record"), with a log row.
- A person is the session the authentication module resolved, not the object
  passed in: a hand-made permission gives nothing, a revoked one is gone.
- The assistant's own reads get their context from the tool-call service, for
  a person it checked; there is no public way to make a tool context from a
  session id, a gateway and a policy.
- The published test-jar holds the testing package only.
- AssistantStore's decision methods are final.

See CHANGELOG.md and UPGRADE.md (what a host must apply before deploying).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 13:11:20 -06:00
james.maesandClaude Fable 5.2 f1cfea7734 Merge the actions and MCP design (docs/actions-and-mcp-design) into feat/writes-follow-the-person
The design this branch builds package A2 of. It is merged here so that the
sentences of the design that the code and the owner's decisions of 2026-11-12
have overtaken can be corrected in the same change.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 13:04:01 -06:00
james.maes ad6d854d22 Merge pull request 'fix(tools): one decision per pending action, a tool surface closed at compile time; pending actions with before and after' (#108) from fix/atomic-confirm-and-closed-surface into develop
maven / build (push) Successful in 8m40s
build / maven (push) Successful in 8m40s
2026-11-12 17:49:07 +00:00
james.maesandClaude Fable 5.2 0dc325cd2c fix(tools): one decision per pending action, a tool surface closed at compile time; feat: pending actions with before and after
maven / build (push) Successful in 6m19s
build / maven (push) Successful in 6m19s
Closes the findings of a third independent review of the tool boundary and
builds the pending-action package of the actions design on top.

Guarantees:
- A pending action is confirmed or cancelled once, by exactly one request,
  on one server or several: the decision is an insert under a unique key
  (new table assistant_pending_action_claim). Whatever fails after the claim
  ends in FAILED or EXECUTED with a tool-call row, never in RUNNING.
- A tool cannot be run around the tool-call service from code outside the
  assistant's packages: execute, log and withdraw are package-private; a
  tool call request, a confirmed tool call and a tool context have no public
  constructor and are made only with the service's permit; the runtime has
  no public getter for the tool registry, the UI API gateway or its tool
  families. A host's own ToolFamily and AssistantTool compile unchanged.
- A signed-in person is one person: a session with no HTTP request is made a
  person only when the authentication module resolves the session's own id
  to the user the session names. The turn engine no longer makes a person.
- Only the credential a request's session was resolved from is forwarded to
  the UI API.
- A tool call that was cut short leaves no proposal anybody could confirm.
- The policy a tool call runs under is read fresh on every call.
- The hard budget is read on every call with one aggregate query per budget.
- The input kept in the tool-call log is redacted with the administrator's
  custom patterns too.

Pending actions (design package A3):
- update_record and delete_record read the record as the person when they
  propose; the card shows each field before and after (a delete: what the
  record holds), after the exposure rules of get_record.
- What was approved is what is written: the proposal keeps a version of the
  record (a digest, never values); a confirmed update or delete reads the
  record again and refuses when it changed, is gone or cannot be read.
- actionKind (record_create, record_update, record_delete, or the proposing
  tool's name), GET /assistant/actions (the person's own list), and
  approvalRetentionDays is now read by the retention process.
- UI API extension assistant 1.9.0 (additive).

Schema: three columns and two indexes on assistant_pending_action, and the
table assistant_pending_action_claim (docs/schema-postgres.sql, UPGRADE.md).

ChatToolSetTest and ToolCallChecksTest pass unchanged.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 11:35:43 -06:00
james.maes 3802c5be6c Merge pull request 'fix(tools): the tool-call service owns the policy, the identity and the confirmation' (#107) from fix/tool-service-owns-policy-and-identity into develop
maven / build (push) Successful in 8m36s
build / maven (push) Successful in 8m36s
2026-11-12 16:44:43 +00:00
james.maesandClaude Fable 5.2 7c9176fcc8 fix(tools): the tool-call service owns the policy, the identity and the confirmation
maven / build (push) Successful in 6m51s
build / maven (push) Successful in 6m51s
An independent security review of engine.ToolCallService (after PR 106)
found what a caller could still hand it. The chat's turn engine is the
only caller today and builds everything correctly, so none of it was
reachable; it is closed before an MCP endpoint calls the service. The
rule: every tool call runs as the signed-in person with their current
permissions and budget, never as the assistant; a refusal happens before
any data action; every call that ran has a log row with redacted input.

Numbered as in the review:

 1. A caller cannot supply a policy. The service reads it
    (PolicyService.current(false).tools(), narrowed for a scheduled
    task's conversation) and builds the tool Context itself; a caller
    hands it a tools.registry.ToolCall (person, id, name, input).
    Every change always shows a confirmation card (owner, 2026-11-12):
    ToolPolicy.writesRequireConfirmation is removed and UiApiToolbox
    always proposes. The assistant_policy column stays and is no longer
    read; no migration.
 2. New SignedInPerson binds a session to the credentials forwarded to
    the UI API. No public constructor: ofRequest(Context), only for
    AssistantSpecs (ArchUnit), or ofSession(session). The context's
    gateway is always runtime.getUiApiGateway(). AssistantApi and
    TurnEngine lose withCredentials.
 3. The hard budget is read on every call, not once per scope.
    executeAndLog(call, conversationId, hipaaRoute) makes the scope.
 4. confirm(person, pendingActionId) replaces executeConfirmed,
    confirmationRefusal and logConfirmed: the service loads the stored
    action of that person, checks it, claims it once, runs it, marks it
    and writes the row. AssistantApi.confirmAction is a thin caller and
    the only one (ArchUnit).
 5. The toolboxes cannot be called around the service: their entry
    points are package-private, the runtime has no getter for a toolbox
    (each family is handed its own), and ArchUnit forbids calls into
    tools.. from outside it except through the registry. The assistant's
    own reads (starter prompts, knowledge re-read) use the new read-only
    UiApiToolbox.read.
 6. TurnEngine.executeTools writes each started call's row in a per-call
    finally: after an interruption of the engine, after a runtime
    exception while a result is processed or logged, and for a call cut
    short by its time limit, with the real outcome when the worker
    finished. A row that cannot be written for a call that changed data
    is logged at ERROR.
 7. Whatever fails after a confirmation's claim ends in FAILED with a
    row, never in RUNNING.
 8. The service checks that the scope's conversation and turn belong to
    the call's person and works out the row's client channel itself
    (ToolCallScope.clientChannel is gone). hipaaRoute stays the
    caller's; the javadoc says why.
 9. AssistantQBitConfig.withToolFamily throws once the QBit is produced.

Tests: ToolCallServiceTest (one per item, on the service directly),
ToolCallRowsTest (item 6; all three fail on develop), and
ToolCallArchitectureTest (items 2, 4, 5). ChatToolSetTest and
ToolCallChecksTest pass with unchanged expectations.
UiApiToolsSecurityTest's two tests that ran writes with confirmation off
now propose and confirm.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 10:37:13 -06:00
james.maes 441e8218fd Merge pull request 'fix(tools): the tool-call service makes its own checks, for any caller' (#106) from fix/tool-call-service-enforces-its-checks into develop
maven / build (push) Successful in 5m31s
build / maven (push) Successful in 5m31s
2026-11-12 15:56:45 +00:00
james.maesandClaude Fable 5.2 d42c536dcf fix(tools): the tool-call service makes its own checks, for any caller
maven / build (push) Successful in 6m45s
build / maven (push) Successful in 6m45s
engine.ToolCallService trusted its caller for several checks. The chat's
turn engine made them around the service; a caller with no chat turn (an
MCP endpoint, later) would have skipped them. The rule that now holds for
any caller: a tool call runs as the signed-in person with their current
permissions and budget, a refusal happens before any data action, and the
call is logged with redacted input.

 1. execute refuses a tool the person is not offered now. It builds the
    offer from the request's policy and session (once per scope). Before,
    only the turn engine's allowed-tools set did, and navigate and
    search_help ran although the policy had switched them off.
 2. A request without a policy is refused. UiApiToolbox, KnowledgeTool
    and BulkLoadTools read a missing policy as "every tool"; they refuse.
 3. A request whose context was made for another session than its own is
    refused, for a call and for a confirmation. ConfirmedToolCall carries
    the confirming session.
 4. New executeAndLog: runs the call and writes the assistant_tool_call
    row, redacting the input itself, also for a refused call. Redaction
    and the row moved out of TurnEngine (redactInput is gone; what a
    presentation logs is AssistantTool.inputForLog). The turn engine runs
    its calls in parallel under its own time limit and writes rows in
    call order, so it uses the two halves, execute and
    log(request, outcome); an ArchUnit rule keeps every other class on
    executeAndLog. The chat's rows are unchanged.
 5. The service's javadoc says what it enforces and what a caller still
    supplies (rate limiting, per-request call limits and timeouts,
    classification).
 6. The confirm route refuses while the kill switch is on (503
    KILL_SWITCH) and when the policy no longer allows the tool (403),
    before the pending action is claimed, so it stays pending.
 7. Tool families are registered explicitly: the QBit's own list
    (AssistantRuntime.ownToolFamilies) plus
    AssistantQBitConfig.withToolFamily for a host or another QBit. The
    classpath scan is gone. The registered names are logged once at
    startup; a duplicate name or an empty registry stops the start.
 8. ToolRegistry.definitions sorts the keys of every object of an input
    schema, so two JVMs send the same bytes (a Map.of schema iterated in
    a per-JVM order, and the provider mappings serialise in iteration
    order).
 9. The service itself requires assistant access, checks the kill switch
    (fresh) and the hard budget (the policy's hard limits and a stopped
    budget row, read without a budget event or alert, when a scope's
    first call arrives). New BudgetService.stoppedReason.

Tests: ToolCallServiceTest (fixes 1 to 4, 6, 9, called with no turn),
ToolRegistryTest (7, 8), ToolCallArchitectureTest (the new rule).
ChatToolSetTest and ToolCallChecksTest pass unchanged. Test helpers that
handed a tool a null policy now hand it the defaults.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 09:46:52 -06:00
james.maes 21f634c167 Merge pull request 'refactor(tools): one tool registry (no behaviour change); build against QQQ 4.1.0-RC.32' (#105) from feat/tool-registry into develop
maven / build (push) Successful in 6m10s
build / maven (push) Successful in 6m11s
2026-11-12 15:15:51 +00:00
james.maesandClaude Fable 5.2 f035d6356b refactor(tools): one tool registry, no behaviour change
maven / build (push) Successful in 5m45s
build / maven (push) Successful in 5m45s
Every tool is registered once in tools.registry.ToolRegistry: its name, its
kind (READ, PROPOSE, DISPLAY), the checks it runs, its definition and how one
call of it runs. The chat's tool list, a tool call of a turn and a confirmed
write all look a tool up there; the turn engine and the confirm route no
longer choose a tool family with an if-chain.

"Run one tool call for this person under this policy, and log it" moves
out of TurnEngine into engine.ToolCallService, which the confirm route uses
too: the refusal of a tool that is not offered, the call (with the person's
own session) and the assistant_tool_call row.

What moved where:
 - ContextBuilder.tools: the per-family blocks become
   ToolRegistry.definitions(ToolOffer); each family's block is its
   AssistantTool.definition (UiApiToolFamily, engine.KernelToolFamily,
   KnowledgeToolFamily, PresentResultsToolFamily, BulkLoadToolFamily).
   isStrict moves to ToolRegistry.isStrict.
 - TurnEngine.executeTools: the 'Unknown tool' refusal is
   ToolCallService.refusal; the dispatch is ToolCallService.execute; each
   branch is its family's AssistantTool.execute (the data families keep the
   QContext init and clear around the call; the presenter stays one per turn
   through ToolCallScope.state).
 - TurnEngine.recordToolResult: the row is built by ToolCallService.log; the
   bulk-load log reduction and search_knowledge's operation are the tools'
   forLog and operationWithoutDetail.
 - AssistantApi.confirmAction: the if-chain is
   ToolCallService.executeConfirmed; the row is ToolCallService.logConfirmed.

UiApiToolbox, AnalysisTools, BulkLoadTools, KnowledgeTool, PresentResultsTool
and the core kernel keep all of their behaviour. A tool family is a class
under com.kingsrook.qbits.assistant.tools that implements ToolFamily; it is
found at startup, so a new family is new files only. The kernel family lives
in engine and is named by the runtime, because nothing under tools may touch
the kernel (ArchitectureTest).

Tests: ChatToolSetTest compares the chat's tool list (names, order,
descriptions, schemas, strict flags, in six scenarios) with the list written
from origin/develop at e02658d before any production code changed.
ToolCallChecksTest (permission, tool switch, data access, the person's own
request, confirmation, and the log row of each refusal) passes unchanged on
the code before and after. ToolRegistryTest and ToolCallArchitectureTest cover
the registry and bind 'only ToolCallService runs or logs a tool call'. No
existing test changed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:57:16 -06:00
james.maesandClaude Fable 5.2 f848092759 build: build against QQQ 4.1.0-RC.32
Moves qqq.snapshot.version from 4.1.0-RC.25 to 4.1.0-RC.32, the version
applications (GG Forge) pin. No behaviour change.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:27:00 -06:00
james.maesandClaude Fable 5.2 694309f528 docs(design): name the open write-mode draft the design would replace
maven / build (push) Successful in 4m48s
build / maven (push) Successful in 4m48s
The write-mode switch is not on develop but exists as an open draft
pull request (#21). Say so, and say what happens to it if the owner
confirms that every write is confirmed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:05:27 -06:00
james.maesandClaude Fable 5.2 798acd5541 docs(design): actions and MCP, the assistant does what the signed-in person may do
maven / build (push) Successful in 5m31s
build / maven (push) Successful in 5m31s
Draft design and work plan for owner review. Nothing is implemented here.

- Writes follow the person's permissions by default: data access defaults
  declared in code, an administrator can only restrict, every write is
  confirmed on a card that shows the values before and after.
- New tools: run a process the person may run, create and edit saved
  reports and saved analyses, schedule delivery by email.
- An MCP endpoint that serves the same tool registry as chat: read-only
  first, then writes as proposals approved in the application.
- Test strategy, repositories and release order, risks, decisions for the
  owner, and work packages that can be built in parallel.

docs/DESIGN.md gets a pointer under "Next phases".

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:04:23 -06:00
109 changed files with 16311 additions and 1143 deletions

No files matched your search

+83
View File
@@ -1,5 +1,88 @@
# Changelog
## Unreleased - Writes follow the signed-in person; no answer without a spending limit; a fourth review closed
Builds package A2 of the actions design (`docs/actions-and-mcp.md`, sections 2 and 3): the assistant may propose any change the signed-in person may make themselves, by default, with the defaults declared in versioned code. It also closes five findings of an independent review of the tool boundary, each described by the guarantee it gives. **Two behaviour changes need an administrator's attention before deploying: see UPGRADE.md.**
- **Writes follow the person, with no settings row** (owner decision, 2026-11-12). The data access of a table or a process is declared in code: `AssistantQBitConfig.withDefaultTableAccess` and `withDefaultProcessAccess` (both `READ_WRITE` by default) and a pin per object, `withTableAccess(table, mode)` and `withProcessAccess(process, mode)`. `READ_WRITE` grants nothing: it lets the assistant propose what the person may do themselves, and every change still waits for that person's confirmation card. Before, a table was `READ` until an administrator stored a `READ_WRITE` row.
- **An administrator's `assistant_data_access` row can only narrow.** `READ` makes a table read-only for the assistant, `NONE` hides it. A row never raises what the code says: not a code default of `READ`, not a pin, not a table whose metadata keeps it from AI. `DataAccessService.Decision.source` says what decided ("default", "pinned in code", "administrator setting", "sensitive table"), and the Data access card and every refusal show it.
- **The assistant's own tables and processes are `NONE` by a pin in code that nothing lifts**: every `assistant_*` table (policy, providers, models, prices, budgets and overrides, data access, profiles, skills, knowledge sources, scheduled tasks, conversations, tool calls, pending actions, usage) and every process of the QBit, whoever asks, with a `READ_WRITE` row and a host pin on it. This is in addition to core's channel wall.
- **The write tools are offered to a person who may change something.** `create_record`, `update_record` and `delete_record` (and the bulk-load save and run tools) are offered when the UI API says the person may create, edit or delete records of some table whose data access allows writes; `save_analysis` when they may save an analysis. The question is asked as the person, once per offer, within `toolCallTimeoutSeconds`; no answer means no write tool. `GET /config` `features.writes` is now that same answer for the person (it was "some table has a READ_WRITE row"). The person's permission for a particular change is still decided by the UI API when the confirmed change runs, with the screen's own status and message.
- **Settings that cannot be read allow nothing.** When the administrator's rows cannot be read and none were ever read, every table is `NONE` until they can be (the fallback used to be the old default). Rows read earlier stay in force.
- **"Add rows for every table" is retired** (`assistantDataAccessAddDefaults`, `DataAccessDefaultsProcess`), with its help. It wrote the old default out as rows, which would now read as restrictions. The shipped changeset `db/liquibase/qbit-assistant/assistant-data-access-follow-user.changelog.sql` removes the `READ` rows it wrote that nobody changed, and no other row. The Data access card on the Overview shows the effective mode and its source for every table without storing anything.
- **No answer without a spending limit** (owner decision, 2026-11-12). The assistant refuses a person for whom no spending limit is in force: a turn is refused before anything is stored and before any model call (HTTP 429, code `BUDGET`, "The assistant is not answering because no spending limit is in force. An administrator must set or switch on a budget (Assistant > Budgets) before it can be used."); so are every later model call of a running answer, every tool call, every embedding request and a scheduled turn (whose run records the sentence). A limit is in force when a hard limit above zero is declared in the code configuration or on the policy row, or a budget row is switched on, has a limit above zero and applies to the person (`BudgetService.limitInForce`). A row that is switched off, a zero limit, a row for somebody else and a soft limit alone do not count. Restoring a limit restores service on the next request. A row whose limit is not above zero is no longer evaluated at all (it used to fail the evaluation).
- **A pending action is never marked expired over its outcome** (review 1). Expiry is now one of the decisions an action can have: it goes through `AssistantStore.decidePendingAction` (a claim with the decision `EXPIRED`), like confirming and cancelling, so only an action that is still pending and unclaimed is expired. `ToolCallService.expiredIfDue` decides from the stored action and the runtime's clock, never from a copy read earlier. `AssistantStore.expirePendingAction(id)` is replaced by `expirePendingAction(userId, id, now)`, which answers whether it expired the action.
- **No action stays RUNNING for ever** (review 2). The retention process first ends confirmed changes that were left `RUNNING` for more than an hour (the server stopped while one ran) as `FAILED`, with "Outcome unknown: this change was started and its end was never recorded. Check the record before asking for it again." and a tool-call log row; they are then removed, with what they kept of the record, like any other decided action once older than `approvalRetentionDays`. `AssistantRetentionStep.purge(runtime, now)` does both steps; the process reports `pendingActionsFailed`.
- **A person is the session the authentication module resolved** (review 3). `SignedInPerson.ofSession(qInstance, session)` used to check the session's id and then keep the object it was given. It now makes the person from the session the module resolved: a permission added to a session by hand gives the person nothing, and a session kept from earlier does not keep a permission that has since been taken away. Only the time zone is carried over.
- **The assistant's own reads go through the service** (review 4). `UiApiToolbox.Context.forRead(sessionUUID, timezone, gateway, policy, credentials, turnId)` is gone: there is no public way to make a tool context from a session id, a gateway and a policy. `ToolCallService.readContext(person, timeout)` makes the context of a read the assistant makes for itself (the starter prompts, the knowledge index), after checking the person, assistant access and the kill switch, with the policy it reads itself and the runtime's gateway. `Context.forRead(timeout)` and `forReadWhateverTheToolSwitches()` only derive a narrower context from one the service made. These reads have no tool-call log row, by decision: nothing of a starter-prompt read reaches a model, a knowledge hit is read again inside a `search_knowledge` call that has its row, and an index run reads every record of its source as the administrator who started it and has its process record. The UI API's access log names the assistant as the client for all of them.
- **The published test-jar holds the fixture and the harness, nothing else** (review 5). It is built from `com/kingsrook/qbits/assistant/testing/**` only. The helpers this module's own tests use to reach behind the tool-call service (`RuntimeInternals`, `ToolCallServiceInternals`, `Toolboxes`) and the tests themselves are no longer published. The provider modules use `AssistantTestFixture`, `TurnRunner`, `GoldenTasks`, `PresentationGoldenTask`, `EvalSupport` and `CloudProviderTestServer`, all in that package, and are unchanged.
- **The decision cannot be replaced by a subclass.** `AssistantStore.decidePendingAction`, `expirePendingAction` and `failAbandonedPendingAction` are final. The store itself stays open to subclassing, which this repository's tests use to make a read or a write fail.
- New on `ToolCallService`: `offersAny(person, toolNames)`, `readContext`, `expiredIfDue`, `failAbandonedActions`. `ToolOffer` carries the credential of the person's request. `StarterPrompts.forUser(person, policy, ...)` is gone (the caller asks the service for the context); `BulkLoadToolFamily` no longer takes the data toolbox; `UiApiToolbox.definitions(policy, boolean)` is `definitions(policy, Set<String> writableTables)` and `UiApiToolbox.anyWritableTable()` is gone.
- UI API extension `assistant`: no new operation and no changed field; the description of `features.writes` says what it now means.
- Not in this change: any new tool (process, report and schedule tools are the next packages); the panel. The write tools' descriptions still end "unless the administrator turned confirmation off", which no longer can happen: they are part of the cached request prefix and are left for a change that touches it on purpose.
- Tests: `DataAccessPrecedenceTest` (the precedence as a table, for tables and processes; the pin on the assistant's own objects; sensitive tables; unreadable settings), `WritesFollowThePersonTest` (the tool lists for a person who may and may not change a table, with no settings row; narrowing to read-only and to not at all; a row cannot widen past the person, a pin or the assistant's own tables; the screen's own refusal), `DataAccessFollowUserChangelogTest` (the changeset twice on a fresh schema, on written-out and hand-written rows, and without the table), `NoSpendingLimitTest` (each way a limit is not in force; restored on the next request; a limit removed during an answer), `PendingActionExpiryTest` (a list that read the action before it was confirmed; a confirmation and an expiry at once, twenty rounds; an action left running), `SignedInPersonTest` (a hand-made permission, a revoked permission), `AssistantOwnReadsTest`, `TestJarContentsTest`, and new rules in `ToolCallArchitectureTest`. `ToolCallChecksTest` passes unchanged. `ChatToolSetTest` passes with the definitions unchanged; see UPGRADE.md for the one line of its expected list that changed and why.
- Test only: the random canaries of `TranscriptRedactionTest` and `ScheduledTasksTest` have no digits any more. The scan for a leaked canary looks for every five characters of it, and five digits in a row also turn up by chance in a stored timestamp's fraction of a second, which failed a build that leaked nothing.
## Unreleased - One decision per pending action, a tool surface closed at compile time, cards with before and after
Closes what a third independent review of the tool boundary found, and builds the pending-action package of the actions design on top (before and after values, kinds, a list, retention). Each fix is described by the guarantee it gives.
- **A pending action is decided once.** Confirming and cancelling are one decision, made by inserting the action's row into the new table `assistant_pending_action_claim`, whose `pending_action_id` is unique: of any number of requests for one action, at the same moment or later, on one server or several, exactly one runs it (or cancels it) and every other is told it is already handled. The claim used to be an unconditional status update followed by a re-read, which let two simultaneous confirmations both run. Whatever fails after the claim (a status that cannot be written or read back, a tool that throws) ends in `FAILED` or `EXECUTED` with a tool-call row, never in an action left `RUNNING`. `ToolCallService.cancel(person, id)` is new; `POST /actions/{id}/reject` goes through it.
- **A tool cannot be run around the tool-call service, by the compiler's word.** For code outside the assistant's packages: `ToolCallService.execute`, `log` and the new `withdraw` are package-private (the turn engine is their only caller); `ToolCallRequest`, `ConfirmedToolCall` and `UiApiToolbox.Context` are final classes without a public constructor, made only through factories that need the service's permit (`ToolCallService.Permit`, which only the service can create and never hands out; a factory called without it throws); `AssistantRuntime` no longer has public getters for the tool registry, the UI API gateway or its tool families, and `ToolCallService.getRegistry()` is gone. The runtime hands the registry and the gateway to the collaborators it builds. A host still registers its own family with `withToolFamily` and implements `AssistantTool` unchanged. New `ToolCallService.offers(person, toolName)`.
- **A person is one person.** `SignedInPerson.ofSession(qInstance, session)` replaces `ofSession(session)`: the authentication module resolves the session's own id, as it will when the tools call the UI API with it, and the person is made only when that is the user the session names. A session put together with one user and another's id is refused. The turn engine no longer makes a person from a session. `new AssistantApi(runtime, session)` makes none until it needs one, and refuses (403) to start a turn, confirm, cancel or explain access for a session that fails the check.
- **Only the credential the session was resolved from is forwarded.** `SignedInPerson.ofRequest` took every credential on the request; it now forwards the one session setup used (in its order: in-process token, `sessionId` cookie, `sessionUUID` cookie, `x-api-key`, `Authorization`).
- **A call that was cut short leaves nothing to confirm.** When the turn engine does not take a call's own outcome (it ran out of time, was stopped, failed, or could not be processed), the user is shown no card; the service withdraws what the call proposed (`EXPIRED`, claimed) and refuses a proposal the call makes afterwards.
- **The policy a call runs under is read fresh.** `execute` reads the policy row once per call, never from the cache; a tool switched off is refused on the next call. Without a row the code defaults apply, as the design says (a row only narrows). The README states what `policyCacheSeconds` still covers.
- **The hard budget is read on every call with one aggregate query per budget.** `BudgetService` summed every usage row of the period in memory; `AssistantStore.sumUsage` now asks the database for the sums.
- **The input kept in the tool-call log is redacted with `customRedactionPatterns` too.** It was redacted with the built-in patterns only.
- **Cards show before and after; what was approved is what is written.** `update_record` and `delete_record` read the record as the user when they propose (a record the user cannot read gets the read's own refusal and no card). The card has `actionKind`, the record's label, and per field the value before and after (a delete: what the record holds), after the exposure rules of `get_record`. The proposal keeps a version of the record (a SHA-256 over the whole record as the user can read it, never the values). On confirmation the record is read again: changed, gone or unreadable means the change is refused and the record is left as it is. Stricter than the design, which compares only the fields being changed: a digest of a single field could be searched for its value, a digest of the whole record cannot.
- **Kinds.** `assistant_pending_action.action_kind` and `tools.registry.ActionKinds`: `record_create`, `record_update`, `record_delete`; any other proposal's kind is its tool's name, so process runs, schedules and reports are added later without a schema change.
- **A list.** `GET /assistant/actions?status=&conversationId=`: the user's own pending actions, newest first, expiry evaluated on read. UI API extension `assistant` 1.9.0; `AssistantActionV1` gains optional fields only.
- **Retention.** `approvalRetentionDays` was declared, validated and documented, and never read. `assistantRetention` now deletes decided and lapsed pending actions older than it, with or without a conversation.
- Schema: three columns and two indexes on `assistant_pending_action`, and the table `assistant_pending_action_claim`. See UPGRADE.md.
- Not in this change, by the design's package list: the `channel` and `client_name` columns of a pending action (they arrive with the MCP proposals) and the panel's new card.
- Tests: `PendingActionDecisionTest` (two confirmations held at the same point run the change once; confirm or cancel, never both; a failure after the claim; the policy read fresh; the budget read without a usage row; the redaction patterns), `CutShortProposalTest`, `PendingActionsTest` (before and after, hidden values, the changed record, the list, retention), `SignedInPersonTest`, and `HostToolFamilyTest`, which is compiled from a package outside the assistant's, runs a host's family through the service, and compiles each former way around the service to show the compiler refuses it. `ChatToolSetTest` and `ToolCallChecksTest` pass unchanged.
## Unreleased - The tool-call service owns the policy, the identity and the confirmation
Closes what an independent security review found after "The tool-call service makes its own checks". None of it was reachable through the chat, which built everything correctly; it is closed before a second caller (an MCP route) uses the service. The rule: every tool call runs as the signed-in person with their current permissions and budget, never as the assistant; a refusal happens before any data action; every call that ran has a log row with redacted input.
- **Every change always shows a confirmation card, and nothing can switch that off** (owner decision, 2026-11-12). `PolicyService.ToolPolicy` no longer has `writesRequireConfirmation`, and `UiApiToolbox` always proposes a create, update or delete. Behaviour change for a host that had set **Writes require confirmation** to off on the policy row: writes now wait for the card like everywhere else. The `assistant_policy.writesRequireConfirmation` column and entity field stay in place and are no longer read (no migration; nothing for a host to apply); the field's help text says so.
- **A caller cannot supply a policy** (review 1). The service reads the policy itself (`PolicyService.current(false).tools()`, narrowed for a scheduled task's conversation exactly as the chat narrows it) and builds the tool `Context` itself. A caller hands it a new `tools.registry.ToolCall` (person, call id, tool name, input) and nothing else. Before, a caller could pass `new ToolPolicy(false, Set.of(), 200, false)` and get every tool, 200 rows, no PII redaction and writes without confirmation.
- **A session cannot be mixed with another person's credentials** (review 2). New `SignedInPerson` binds a session to the credentials the tools forward to the UI API. It has no public constructor: `SignedInPerson.ofRequest(Context)` takes the session the route's session setup resolved and the credentials of that same request (only `AssistantSpecs` may call it, ArchUnit), and `SignedInPerson.ofSession(session)` forwards nothing but that session's own id. `AssistantApi.withCredentials` and `TurnEngine.withCredentials` are gone (`new AssistantApi(runtime, person)`, `TurnEngine.withPerson`, which refuses a person of another session); `new AssistantApi(runtime, session)` is unchanged. The gateway in the tool context is always `runtime.getUiApiGateway()`.
- **The hard budget is read on every call** (review 3). It was remembered for the life of a scope, and the scope was whatever object the caller passed. The check only reads (no budget event, no alert, the chat's warning notice stays with the model call that reports it). `executeAndLog(call, conversationId, hipaaRoute)` now makes the scope itself.
- **A write runs only from its stored pending action** (review 4, 7). `ToolCallService.executeConfirmed(ConfirmedToolCall)`, `confirmationRefusal` and `logConfirmed` are replaced by one entry, `confirm(person, pendingActionId)`: the service loads the action for that person, checks expiry, status and that the proposing turn has finished, refuses while the kill switch is on or the policy no longer allows the tool (the action stays pending), claims it once, runs it, marks it and writes the log row itself. The caller chooses no table, record or values. Whatever fails after the claim (a tool that throws, stored values that are not JSON) ends in `FAILED` with a row; before, the action stayed `RUNNING` forever with no row. `AssistantApi.confirmAction` is a thin caller and the only one (ArchUnit). The route's answers are unchanged. The confirm route never re-read the record to compare it with the proposal, and still does not: the UI API decides with the record as it is at confirmation.
- **The toolboxes cannot be called around the service** (review 5). `UiApiToolbox.execute`, `executeConfirmedWrite`, `executeConfirmedAnalysis`, `BulkLoadTools.execute` and `executeConfirmed`, `KnowledgeTool.execute` and `PresentResultsTool.execute` are package-private. `AssistantRuntime.getToolbox()`, `getKnowledgeTool()` and `getBulkLoadTools()` are gone; each family is handed its toolbox (`AssistantRuntime.ownToolFamilies()` is now an instance method). New ArchUnit rules: no class outside `tools..` calls into it except through the registry (allow-list: the runtime's wiring, the value types, `UiApiToolbox.notAWriteTool`, and the new `UiApiToolbox.read`); only the service constructs a `ToolCallRequest` or a `ConfirmedToolCall`; only `AssistantApi` calls `confirm`; only `RagService` and the knowledge tool call `RagService.read`.
- New `UiApiToolbox.read(context, callId, toolName, input)`: the assistant's own reads (starter prompts, re-reading a knowledge hit). It runs `list_tables`, `describe_table`, `count_records` and `get_record` and refuses every other name, so it cannot propose or change anything. `StarterPrompts` and `RagService` use it; nothing else may (ArchUnit). `AssistantApi` reads "is any table writable" from the new `DataAccessService.anyWritableTable()`.
- **A call that ran always has its row** (review 6). `TurnEngine.executeTools` writes each started call's row in a per-call `finally`. An interruption of the engine while calls run is passed on only after the rows are written (before, no row was written). A runtime exception while a result is classified, shown or logged (for example a tool's `inputForLog`) costs neither that row nor the later ones; the result is then withheld from the model. A call that was cut short gets up to one second, in all, to hand back what it did: when it finished after all, the row is of its real outcome, with the operation, status, audit link and pending action it left behind (before: "Timed out" and nothing else); the model and the panel are still told it took too long. `ToolCallService.log` no longer throws and returns whether the row was written; a row that cannot be written for a call that left something behind (a proposed write, an audited change, a confirmed write) is logged at ERROR, not WARN.
- **The scope is checked, not believed** (review 8). Before a tool runs, the service checks that the conversation and the turn a scope names belong to the call's person (and the turn to that conversation); a call that names someone else's is refused (`assistant:request`) and its row does not carry them. The row's client channel is worked out by the service; `ToolCallScope.clientChannel()` is gone. `hipaaRoute` stays the caller's to say, because only the caller knows which model or client receives the result (javadoc of `ToolCallService` and `ToolCallScope.hipaaRoute`).
- `AssistantQBitConfig.withToolFamily` throws `IllegalStateException` when it is called after the QBit was produced (review 9); it was silently ignored.
- For a host or another QBit with its own `ToolFamily`: `AssistantTool.execute(ToolCallRequest)` and `executeConfirmed(ConfirmedToolCall)` are unchanged. A tool must not construct either record itself.
- Stricter than before, in the chat: the policy a tool call runs under is the administrator's row as it is when the call starts, not the copy the turn read when it began (a tool switched off mid-turn is refused at once); a turn whose person goes over a hard budget while its tools run has the remaining calls refused (`assistant:budget`).
- Tests: `ToolCallServiceTest` (one per review item, calling the service directly), new `ToolCallRowsTest` (rows of a call that ran and timed out, of a runtime exception while logging, of an interrupted engine; all three fail on the code before), `ToolCallArchitectureTest` (the rules above). `ChatToolSetTest` and `ToolCallChecksTest` pass with their expectations unchanged (`ChatToolSetTest` builds a `ToolPolicy` without the removed argument). `UiApiToolsSecurityTest.testWriteDeniedIsTheSameDenial` and `testDenialMatrix` ran writes with confirmation off; they now propose and confirm, and compare the confirmed write's status and message with the UI's.
## Unreleased - The tool-call service makes its own checks, for any caller
- `engine.ToolCallService` no longer trusts its caller for a check it can make itself. On every call, before the tool is reached, it checks that the request is whole (a session, a context made for that same session, a policy), that the person has assistant access, the kill switch (read fresh), the hard budget, and that the tool is offered to this person now under the request's policy. The chat's turn engine made these checks around the service; a caller with no chat turn would have skipped them. The service's javadoc lists what a caller still has to supply: rate limiting, how many calls a request may make and for how long, and classification.
- `execute` refuses a tool that is not offered. Before, only the turn engine's own list of allowed tools did, so a direct call ran `navigate` or `search_help` although the policy had switched them off (the kernel tools have no policy check of their own).
- A request without a policy is refused. `UiApiToolbox`, `KnowledgeTool` and `BulkLoadTools` treated a missing policy as "every tool is allowed"; they now refuse too.
- One call cannot mix two people: a request whose context was made for another session than its own is refused, for a call and for a confirmation. `ConfirmedToolCall` carries the confirming session.
- New `ToolCallService.executeAndLog`: runs the call and writes its `assistant_tool_call` row, redacting the input itself, for a refused call as for one that ran. The redaction and the row moved out of the turn engine (`TurnEngine.redactInput` is gone; what a presentation logs as its input is the tool's `inputForLog`). The turn engine still runs its calls in parallel under its own time limit and writes the rows in call order, so it uses the two halves of that path, `execute` and `log(request, outcome)`; an ArchUnit rule keeps every other class on `executeAndLog`. The chat's log rows are unchanged.
- Confirming a pending action (`POST /actions/{id}/confirm`) is refused while the kill switch is on (503 `KILL_SWITCH`) and when the policy no longer allows the tool (403). Both are checked before the action is claimed, so it stays pending. Before, a pending write could be confirmed while the kill switch was on.
- Tool families are registered explicitly. The classpath scan of `com.kingsrook.qbits.assistant.tools` is gone: any jar with a public `ToolFamily` under that package was registered and offered. The QBit's own families are the list `AssistantRuntime.ownToolFamilies()`; a host or another QBit registers one with the new `AssistantQBitConfig.withToolFamily(ToolFamily)` before the QBit is produced. The runtime logs the registered tool names once at startup (`Assistant tools registered`) and refuses to start on a duplicate name or when there is no tool. `ToolRegistry.discover` is replaced by `ToolRegistry.of(runtime, families)`.
- The tool list is the same bytes in every JVM. `ToolRegistry.definitions` now sorts the keys of every object of an input schema. A schema built with `Map.of` (`search_knowledge`, part of the bulk-load profile schema) iterated in an order that differs between JVMs and the provider mappings serialise in iteration order, which defeated prompt caching across pods and restarts. The order of `required` and other lists is unchanged. Visible effect: properties are listed alphabetically in every tool's schema, and the cached request prefix changes once with this version.
- Stricter than before, in the chat: a turn whose model call takes the person over a budget row's limit (`assistant_budget`, state STOPPED) now has the tool calls of that same response refused (`assistant:budget`) when they are the turn's first; before, they ran and the next model call stopped the turn. The policy's own hard limits already stopped the turn before its tools. Likewise a tool call that starts after the kill switch went on, and before the running turn's own poll noticed it, is refused (`assistant:killSwitch`) instead of run; the turn is cancelled either way.
- New `BudgetService.stoppedReason(userId, active)`: whether a budget has stopped a person, read without storing a budget event or raising an alert.
## Unreleased - One tool registry (no behaviour change); built against QQQ 4.1.0-RC.32
- The assistant builds against QQQ 4.1.0-RC.32 (was RC.25), the version applications pin. Nothing had to change for it; the chat's tool list is byte for byte the same under both versions.
- Every tool is now registered once, in `tools.registry.ToolRegistry`: its name, its kind (`READ`, `PROPOSE`, `DISPLAY`), the checks it runs, its definition (description and input schema) and how one call of it runs. The chat's tool list (`ContextBuilder.tools`), a tool call of a turn and a confirmed write (`POST /actions/{id}/confirm`) all look a tool up there; none of them chooses a tool family any more.
- "Run one tool call for this person under this policy, and log it" moved out of the turn engine into `engine.ToolCallService`, which the confirm route uses too: the refusal of a tool that is not offered, the call itself (with the person's own session), and the `assistant_tool_call` row.
- Nothing a person, a model or an administrator sees changed: the same tools are offered in the same order with the same definitions, a call goes through the same checks in the same order, refusals read the same and the same log rows are written. `ChatToolSetTest` holds the tool list of the code before this change and compares it; `ToolCallChecksTest` passes unchanged on the code before and after.
- A tool family is a class under `com.kingsrook.qbits.assistant.tools` that implements `ToolFamily` and has a public no-argument constructor. It is found when the runtime starts; no existing class has to name it. The runtime refuses to start if no family is found or two tools share a name.
- `UiApiToolbox`, `AnalysisTools`, `BulkLoadTools`, `KnowledgeTool`, `PresentResultsTool` and the core kernel keep all of their behaviour; `UiApiToolFamily`, `BulkLoadToolFamily`, `KnowledgeToolFamily`, `PresentResultsToolFamily` and `engine.KernelToolFamily` register their tools. `ContextBuilder` is constructed with the registry (`withKnowledgeTool` and `withBulkLoadTools` are gone).
## Unreleased - The assistant works for people who do not administer it on a host that enforces table permissions in core actions; a person's own budget (UI API extension 1.8.0)
- Fixed: on a host with `QInstance.enforcePermissionsInCoreActions` on, a person with `assistant.hasAccess` and without `assistant.admin` was refused before any model call ("Not authorized to access the assistant admin data."). The assistant read its settings (policy, providers, models, prices, budgets, data access) and wrote that person's messages, tool calls and usage through tables whose permission rule is `assistant.admin`, with the person's session. `AssistantStore` now marks every one of its calls as the assistant's own service code (`withPermissionBypass`), the way qbit-collaboration and qbit-backups do.
+23 -5
View File
@@ -14,7 +14,8 @@ This file provides guidance to Claude Code when working with code in this reposi
The panel UI lives in `qqq-frontend-next`. Data access goes only through the **QQQ UI API** (`/qqq/v1`, spec'd operations, the user's own session) via `uiapi/UiApiGateway` and `tools/UiApiToolbox`; only `navigate` and `search_help` (no records) use the core AI tool kernel.
- Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`. The owner decisions D1 to D6 at its top are binding.
- Writes (create/update/delete) are offered only for tables an admin set to READ_WRITE in `assistant_data_access`, and by default wait for the user's confirmation (pending action card).
- Writes (create/update/delete) follow the signed-in person: the assistant may propose any change that person may make themselves, with no settings row (the default, READ_WRITE, is declared in code; an `assistant_data_access` row can only narrow it). A write always waits for the user's confirmation (pending action card); nothing switches that off.
- The assistant answers nobody for whom no spending limit is in force (`BudgetService.limitInForce`): fail closed, until an administrator restores a limit.
## Build Commands
@@ -63,9 +64,25 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- **Data access:**
- The vector index stores no record content: only source/table/key/field/chunk, content hash and embedding. Retrieval re-reads every hit through the UI API as the asking user, then applies the existing get_record exposure/masking path.
- Only `persistence/` calls QueryAction, InsertAction and the other data actions (ArchUnit).
- Every tool is registered once in `tools/registry/ToolRegistry` (name, kind, checks, definition, execute) by a registered `ToolFamily`: the QBit's own are the explicit list `AssistantRuntime.ownToolFamilies()`, a host's or another QBit's come from `AssistantQBitConfig.withToolFamily`. Nothing is found on the classpath. A new family of the QBit is new files and one entry in that list.
- A tool is looked up only in the registry. Outside `tools/`, only `engine/ToolCallService` runs a tool or writes an `assistant_tool_call` row (ArchUnit); the turn engine and the confirm route both go through it. A tool call runs as the signed-in person, with their current permissions and budget, never as the assistant; a refusal happens before any data action; every call that ran has a log row with redacted input.
- `ToolCallService` trusts no caller and takes nothing it can find out itself. A caller hands it a `tools.registry.ToolCall` (a `SignedInPerson`, the call's id, name and input) and nothing else: the service reads the policy fresh on every call (`PolicyService.current(true)`, narrowed for a scheduled task's conversation; never the cache), builds the tool `Context` with the gateway the runtime handed it and keeps the proposed writes itself.
- The boundary holds at compile time for code outside the assistant's packages, not only by ArchUnit. Keep it so: `ToolCallRequest`, `ConfirmedToolCall` and `UiApiToolbox.Context` are final classes with no public constructor, made only through `of(ToolCallService.Permit, ...)`; the permit has a private constructor and the service never hands its own out. `AssistantRuntime` has no public getter for the tool registry, the UI API gateway, a toolbox or its tool families: it hands them to the collaborators it builds. Never add a public way to any of them, and never a public method on the service that runs a tool without writing its row.
- Its public entry points: `executeAndLog(call, conversationId, hipaaRoute)` for a caller with no turn (the service makes the scope); `confirm(person, pendingActionId)` and `cancel(person, pendingActionId)`, called only by `AssistantApi` (ArchUnit); `offers(person, toolName)` and `offersAny(person, toolNames)`; `readContext(person, timeout)` (only `AssistantApi` and `RagService`, ArchUnit); `expiredIfDue(userId, action)`; `failAbandonedActions(now)` for the retention process. `execute(call, scope)`, `log(call, scope, outcome)` and `withdraw(call, scope)` are package-private, for the turn engine only.
- On every call, before the tool is reached, it checks: a signed-in person, assistant access, the kill switch, that the scope's conversation and turn are that person's, the policy (unreadable refuses), the hard budget (that a spending limit is in force at all, then the limits; read on every call, never remembered per scope; one aggregate query per budget through `AssistantStore.sumUsage`, never the usage rows) and that the tool is offered to this person now. What a caller still supplies (rate limiting, per-request call limits and timeouts, classification, the HIPAA route) is listed in the service's javadoc.
- `SignedInPerson` binds a session to the one credential forwarded to the UI API. It has no public constructor: `ofRequest(Context)` (only `AssistantSpecs`, ArchUnit; it forwards only the credential the session was resolved from) or `ofSession(qInstance, session)` (the session's own id is the credential: the authentication module resolves it, it must be the user the session names, and the person IS the session the module resolved, never the object passed in: only its time zone is carried over). Never make a person from a session that was not checked, never read access, the owner key or a permission from a session object a caller handed over, never pass a session and credentials separately, and never let the turn engine make a person.
- The toolboxes cannot be called around the service: their entry points that run a tool are package-private, the runtime has no getter for a toolbox (each family is handed its own), and no class outside `tools/` calls into it except through the registry (ArchUnit, with a short allow-list: the runtime's wiring, the value types, and `UiApiToolbox.read`, the four read-only tools the starter prompts and the knowledge index use).
- `UiApiToolbox.read` needs a `Context`, and every context is made by the service: `ToolCallService.readContext(person, timeout)` for a read outside a tool call (it checks the person, assistant access and the kill switch, reads the policy itself and uses the runtime's gateway), or the context of the tool call the read is part of. `Context.forRead(timeout)` and `forReadWhateverTheToolSwitches()` only derive a narrower one. Never add a public way to make a context from a session id, a gateway or a policy. These own reads have no tool-call log row (nothing of them reaches a model; an index run has its process record) and do not ask the hard budget (they call no model).
- The turn engine writes one row per started call in a per-call `finally`: a call that ran past its time limit, one whose result could not be processed, and a batch the engine was interrupted in all keep their rows, of the real outcome when the worker finished. A row that cannot be written for a call that changed data is logged at ERROR.
- A missing policy refuses; it never means "every tool".
- Data tools run only through `UiApiToolbox` → `UiApiGateway` (spec'd `/qqq/v1` operations with the user's `sessionUUID`); never core data actions for user data. Only `navigate` and `search_help` use `AiToolKernel.execute`.
- Data access (`DataAccessService`) and the policy only ever narrow what the UI API allows; a table whose AI exposure is NONE stays NONE.
- A write runs only after the owner confirms its pending action (unless the policy turns confirmation off), with the confirming request's session, once.
- Data access (`DataAccessService`) and the policy only ever narrow what the UI API allows. The mode of a table or process is declared in versioned code: the default (`withDefaultTableAccess` / `withDefaultProcessAccess`, READ_WRITE) or a pin (`withTableAccess` / `withProcessAccess`). An administrator's `assistant_data_access` row narrows it and never raises it. A table whose AI exposure is NONE stays NONE, and so does every `assistant_*` table and every process of the QBit (`DataAccessService.isOwnTable`: a pin in code that no row and no host pin lifts). Rows that cannot be read and were never read mean NONE for everything, never the default.
- READ_WRITE grants nothing: the write tools are offered to a person only when the UI API says that person may create, edit or delete records of some table (`UiApiToolFamily.writableTables`, asked as the person, once per offer, within `toolCallTimeoutSeconds`; no answer means no write tool). The person's permission for the change itself is decided by the UI API when the confirmed change runs, with its own message. Never materialise defaults as rows again: the retired "Add rows for every table" action is why `assistant-data-access-follow-user.changelog.sql` exists.
- A write runs only after the owner confirms its pending action, with the confirming request's session, once; no policy or caller can turn confirmation off (`assistant_policy.writesRequireConfirmation` is no longer read). `ToolCallService.confirm` loads the stored action for that person, claims it, runs it and writes its row; whatever fails after the claim ends in FAILED with a row, never in RUNNING.
- A pending action is decided once: confirming, cancelling and expiring all go through `AssistantStore.decidePendingAction` (final; `expirePendingAction` is a claim with the decision EXPIRED), which inserts the action's row into `assistant_pending_action_claim` (unique `pendingActionId`). Never decide an action with a status update alone, never expire one from a copy read earlier or a time a caller names (`ToolCallService.expiredIfDue` decides from the stored action and the runtime's clock), and never drop that unique key.
- What was approved is what is written: `update_record` and `delete_record` read the record as the person when they propose and keep its version (a digest of the whole record, never values; `beforeJson` holds only what the card showed, after the exposure rules of get_record). A confirmed update or delete reads the record again and refuses when it changed, is gone or cannot be read. A proposal without a version is refused, not run.
- A tool call whose own outcome the turn engine did not take (timed out, stopped, failed, unprocessable) shows no card, so the engine has the service withdraw what it proposed; a proposal made after that is refused.
- Retention first ends pending actions left RUNNING for more than `ToolCallService.ABANDONED_AFTER` (one hour) as FAILED, with "outcome unknown, check the record" and a tool-call log row, then deletes decided and lapsed pending actions older than `approvalRetentionDays`, with or without a conversation. No action stays RUNNING for ever.
- Panel API queries also filter `userId = me`, so an admin's all-access key never mixes other users' conversations into the panel.
- **Caching:** the request prefix stays byte-stable. The order is tools (kernel-sorted), then system block 1 (rules + primer + app digest, LONG/1h), then system block 2 (user digest + date, SHORT/5m), then messages. Exactly one SHORT hint goes on the newest message. Don't put timestamps or ids in the system prompt.
- **Tool results:**
@@ -81,7 +98,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- **PHI routing:** PHI only to `hipaaEligible` providers; checked before every model call. Conversation classification never decreases.
- **HIPAA storage:** Conversation storage encrypted in HIPAA mode, only in `AssistantStore`; encryption keys are environment references and never logged or stored in tables.
- **Fallback:** fallback never crosses the PHI rule; never local-to-cloud by default. No retry after partial output from that call; provider switches rebuild neutral history without providerState.
- **Budgets:** budgets are checked before every model call; downgrade never crosses the PHI rule.
- **Budgets:** budgets are checked before every model call; downgrade never crosses the PHI rule. No spending limit in force for the person (`BudgetService.limitInForce`: a hard limit above zero in the code configuration or on the policy row, or an enabled budget row with a limit above zero that applies to them) refuses the turn before anything is stored, every later model call, every tool call and every embedding request. Never add a path that calls a model without that check.
- **Kill switch:** read fresh before every model call and every tool step.
## Tests
@@ -91,7 +108,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- sentinel values in hidden and password fields
- a prompt-injection record
- MOCK auth where the user-id prefix picks the permissions.
- `testing/` also holds `TurnRunner`, `GoldenTasks` and `EvalSupport`. They are published as a test-jar for the provider's live evals.
- `testing/` also holds `TurnRunner`, `GoldenTasks` and `EvalSupport`. The `testing` package, and nothing else, is published as a test-jar for the provider modules' tests (`TestJarContentsTest`). `RuntimeInternals`, `ToolCallServiceInternals` and `Toolboxes` reach behind the tool-call service for this module's own tests: keep them, and anything like them, out of `testing/`.
- The fixture declares a shared monthly spending limit in code (the assistant refuses with none in force); `AssistantTestFixture.withoutSpendingLimit(config)` takes it away for a test of the refusal. `author-*` users may change posts, pages and media; `editor-*` may only read.
- Tags:
- `golden` (`GoldenEvalTest`) and `redteam` (`RedTeamTest`) run in every build. Any failure fails the build.
- `live` is excluded unless you pass `-Dtest.excludedGroups= -Dgroups=live` and set the key.
+86 -5
View File
@@ -280,15 +280,18 @@ The nav lists the home and the main tables: **Providers**, **Conversations**, **
| `maxTurnSeconds` | 300 | Wall-clock limit for one answer. |
| `budgets` | none | `userDaily{Tokens,CostMicros}{Soft,Hard}` and `instanceMonthly{Tokens,CostMicros}{Soft,Hard}`. A soft limit adds a warning. A hard limit refuses new turns (429) and stops a running turn after its current step. Tokens counted are uncached input, cache writes and output. Cost is in micro-USD. |
| `maxTurnsPerUserPerMinute` / `maxConcurrentTurnsPerUser` | 10 / 1 | Rate limits. |
| `conversationRetentionDays` / `toolResultRetentionDays` / `approvalRetentionDays` | 90 / 30 / 365 | Used by `assistantRetention`. |
| `conversationRetentionDays` / `toolResultRetentionDays` / `approvalRetentionDays` | 90 / 30 / 365 | Used by `assistantRetention`. `approvalRetentionDays` is how long a pending action (a proposed change and what became of it) is kept after it was decided or lapsed, whether or not it belongs to a conversation; one in a conversation also goes when its conversation does. |
| `pendingActionTtlSeconds` | 900 | How long a proposed change waits for the user's confirmation before it lapses. |
| `policyCacheSeconds` | 30 | How long the settings rows are cached. **Tool calls and confirmations do not use this cache for the policy row**: the kill switch, the allowed tools, the row cap and the redaction switch are read fresh on every tool call and every confirmation, so switching a tool off takes effect on the next call. The cache still applies to what a turn reads when it starts (model, budgets from the policy row) and to the data access rows, profiles and providers: a table narrowed in `assistant_data_access` is narrowed at once on the server that saved the row and within this many seconds on the others. Without a policy row the defaults declared in code apply (every registered tool allowed, still narrowed by data access and the user's own permissions); a row only narrows them. |
| `retentionSchedulerName` | none | Runs `assistantRetention` daily on that scheduler. |
| `ownerSecurityKeyType` | `assistant_user_id` | Key type of the owner record locks. |
| `pricing` | Anthropic list prices, 2026-10 | USD per million tokens per model, used for cost and budgets. |
| `allowedWidgetNames` | none | Dashboard widgets `render_widget` may render. The kernel denies every widget not on this list. This config list is the **only** way to allow a widget: QQQ widget meta-data has no supplemental hook, so there is no per-widget "assistant-safe" flag. Allow only widgets that render the same data for every user who can see them. |
| `hiddenWidgetNames` | none | Widgets that are never rendered, even when allowed. |
| `customRedactionPatterns` | none | Extra regular expressions the kernel redacts from tool results. |
| `customRedactionPatterns` | none | Extra regular expressions redacted from tool results and from the input kept in the tool-call log. |
| `killSwitchPollMillis` | 2000 | How often a running turn re-checks the kill switch while it waits on the model. |
| `providerTypes` | none | `withProviderType(...)`: the types the Providers screen offers (`AnthropicProviderType`). |
| `toolFamilies` | none | `withToolFamily(...)`: a tool family (`tools.registry.ToolFamily`) of the host or of another QBit. Its tools join the assistant's own in the tool registry when the QBit is produced, so call it before that: a call after the QBit is produced throws `IllegalStateException` (it used to be ignored). This is the only way a tool gets in from outside the QBit: a family class that is only on the classpath is never registered. The runtime refuses to start when two tools share a name, and logs the registered tool names once. |
| `allowedApiKeyEnvNames` | none | Extra environment variable names a provider row may name as its key. Each type's default is always allowed. |
| `allowedBaseUrls` | none | Base URL overrides a provider row may use. The type's own URL is always allowed. |
| `secretResolver` | `System::getenv` | How a key variable name becomes the key (a host secret store, or tests). |
@@ -337,10 +340,26 @@ The system prompt tells the model the block is data, never instructions. A file
| `assistant.hasAccess` | Use the panel. It grants **no data access**: the assistant reads only what the user can read anyway. |
| `assistant.admin` | The Assistant app (home, settings and audit tables), Test connection and the retention process. |
There is no assistant-specific data permission. Everything the kernel reads is checked against the user's normal table permissions and record locks. The QBit never uses a system session (an ArchUnit test enforces this).
There is no assistant-specific data permission. Everything the assistant reads or proposes is checked against the user's normal table permissions and record locks. The QBit never uses a system session (an ArchUnit test enforces this).
When the QBit is enabled, it registers the supplemental instance meta-data `assistant`. The v1 `/metaData` response publishes it as `supplementalInstanceMetaData.assistant: {"available": true, "version": 1, "basePath": "/qqq/v1/assistant"}`. The Next UI only probes `/config` when that object is present.
### What the assistant may do with a table
The assistant may read what the signed-in person may read, and propose any change that person may make themselves. Nothing needs opening first: that default is declared in code, and the database only ever narrows it.
| What decides, in this order | Where | Can it widen? |
|---|---|---|
| The assistant's own tables and processes (`assistant_*`) are never available to it | a pin in the QBit's code | No, by nothing. |
| A table whose metadata keeps it from AI (AI exposure NONE) is never available | table metadata | No. |
| The application's code: a pin for the object, else the default | `AssistantQBitConfig.withTableAccess(table, mode)` / `withProcessAccess(process, mode)`, else `withDefaultTableAccess` / `withDefaultProcessAccess` (both `READ_WRITE`) | It is the ceiling. |
| An administrator's row on **Assistant > Data Access** | `assistant_data_access` | No. `READ` makes a table read-only for the assistant, `NONE` hides it; `READ_WRITE` is "no restriction". A row never raises what the code says. |
| The person's own permissions, record locks and validation | the UI API, on every call | They are the boundary. |
`READ_WRITE` grants nothing. The write tools (`create_record`, `update_record`, `delete_record`, and the save tools of the optional extensions) are offered to a person only when the UI API says that person may create, edit or delete records of some table; a person who may only read is not offered them. A change is only ever proposed: the person confirms it on a card, it then runs as that person, and a change they may not make is refused with the status and message the screen gives. The **Data access** card on the Overview shows, for the person looking, the access of every table and what decided it.
To keep the assistant read-only, or to open only some tables, say so in code (`withDefaultTableAccess(DataAccessMode.READ)`, then `withTableAccess("orders", DataAccessMode.READ_WRITE)`); see [UPGRADE.md](UPGRADE.md).
### Record locks on the assistant's own tables
Conversation data belongs to its user. Every user-owned table has a record lock on `userId` with key type `assistant_user_id`:
@@ -352,6 +371,23 @@ Conversation data belongs to its user. Every user-owned table has a record lock
AssistantSecurity.applySessionKeys(qSession, assistantConfig);
```
## What a host's code can call
Every tool call goes through `engine.ToolCallService`, and a host cannot run a tool or a change around it: the ways to do so do not compile from outside the assistant's packages.
| A host or another QBit can | With |
|---|---|
| Register its own tools | `AssistantQBitConfig.withToolFamily(ToolFamily)`, before the QBit is produced; implement `tools.registry.AssistantTool` (`execute(ToolCallRequest)`, and `executeConfirmed(ConfirmedToolCall)` for a tool that proposes). |
| Run one tool call for a signed-in person, logged | `runtime.getToolCalls().executeAndLog(new ToolCall(person, id, name, input), conversationId, hipaaRoute)` |
| Ask whether a person is offered a tool now | `runtime.getToolCalls().offers(person, toolName)`, or `offersAny(person, toolNames)` for several as one question |
| Make one of the assistant's own reads as a person (`list_tables`, `describe_table`, `count_records`, `get_record`; never a change, no log row) | `runtime.getToolCalls().readContext(person, timeout)`, then `UiApiToolbox.read(context, ...)`. The service checks the person, assistant access and the kill switch, and reads the policy itself. |
| Confirm or cancel a person's pending action | `runtime.getToolCalls().confirm(person, id)` / `cancel(person, id)` (inside this repository only `AssistantApi` does; the routes are `POST /actions/{id}/confirm` and `/reject`). |
| Name the signed-in person | `SignedInPerson.ofRequest(context)` in a route after session setup (it forwards only the credential the session was resolved from), or `SignedInPerson.ofSession(qInstance, session)` where there is no HTTP request: the authentication module resolves the session's own id, the person is made only when that is the user the session names, and the person is the session the module resolved (what it may do is what the module says now, not what the object passed in says). |
What is not available outside the assistant's packages: the tool registry, the UI API gateway and the tool families of the runtime (no public getter); running a call without its log row or writing a row without a call (`execute`, `log` and `withdraw` are package-private); constructing a `ToolCallRequest`, a `ConfirmedToolCall` or a tool `Context` (no public constructor; their `of(...)` factories need the service's permit, which only the service has, and refuse without it; there is no way to make a context from a session id, a gateway and a policy of the caller's choosing). A tool is handed its request by the service, so a tool that runs knows the checks ran first. This is a compile-time guarantee for code in other packages; it does not stop code that declares itself inside the assistant's own packages or uses reflection.
These are guarantees of the service the runtime built, `runtime.getToolCalls()`. The service's constructor is public (the runtime is in another package), so other code can construct a service of its own, with a registry, a store and a gateway of its own choosing. Such a service checks what those let it check; it does not run the runtime's checks, and it is not a way around them: it cannot be given the runtime's registry, gateway or toolboxes, and whatever it calls is still the UI API as a signed-in person, with that person's permissions. The single decision of a pending action cannot be replaced by a subclass of the store (`AssistantStore.decidePendingAction` is final).
## Data policy
What reaches the model provider:
@@ -377,6 +413,7 @@ Stored in your database:
- turn accounting: tokens, cache reads and writes, and cost
- streamed events, kept for replay
- feedback
- pending actions: the proposed values, what the card showed of the record before (after the exposure rules), a version digest of the record, and the outcome. Kept for `approvalRetentionDays`.
- daily usage.
Logs never contain message content.
@@ -394,6 +431,10 @@ Base path `/qqq/v1/assistant`. Every request is authenticated through the app's
| GET | `/turns/{id}/events?after={seq}` | Replay a turn's events after a sequence number, then follow it live. |
| POST | `/turns/{id}/cancel` | Stop an answer. |
| POST | `/messages/{id}/feedback` | Rate an answer: `{rating: 1 or -1, reason?, comment?}`. |
| GET | `/actions` | Your own pending actions, newest first (`?status=PENDING` for the ones still waiting for you, `?conversationId=`). |
| GET | `/actions/{id}` | One of your pending actions: its state and what its card shows. |
| POST | `/actions/{id}/confirm` | Confirm it: the stored change runs once, with your session. |
| POST | `/actions/{id}/reject` | Cancel it: nothing runs. |
The stream sends these events, each with a `seq`:
- `message.start`, `text.delta`
@@ -406,6 +447,17 @@ The stream sends these events, each with a `seq`:
The stream ends after `message.end`. Thinking is never streamed.
### Confirmation cards (pending actions)
A tool that changes something never changes it: it stores a pending action and the panel shows a card. Nothing runs until the user confirms the card, and no setting switches that off.
- **What the card shows.** `actionKind` says what is proposed: `record_create`, `record_update`, `record_delete`, or the name of the proposing tool (`save_analysis`, the bulk-load tools, a host's own). For a record change the card has the table and the record, and one line per field: an update shows the value before and after, a create the value, a delete what the record holds. The values before go through the same exposure rules as `get_record` (a hidden field is absent, a masked field is masked). `toolName` stays one of the three record tools for a record change, so a panel that only knows those still draws a working card.
- **What was approved is what is written.** An update or a delete is proposed for the record as the user can read it at that moment; a record they cannot read is refused then, with the UI API's own answer, and no card is made. The proposal keeps a version of the record (a digest of the whole record, never its values). On confirmation the record is read again as the confirming user: if it is gone, may no longer be read, or changed in any field, the change is refused ("This record changed after the change was proposed. Nothing was changed.") and the record is left as it is. The UI API has no conditional write, so a change made in the instant between that read and the write is not detected.
- **One decision.** A pending action is confirmed or cancelled once, by exactly one request, however many arrive together and on however many servers: the decision is a row in `assistant_pending_action_claim`, whose `pending_action_id` is unique. Keep that unique key.
- **Expiry.** A proposal lapses after `pendingActionTtlSeconds`; expiry is evaluated when it is read and when it is decided. Expiry is itself a decision, made through the same claim: an action somebody confirmed is never marked expired afterwards, and is reported as what it is.
- **A call that was cut short.** When a tool call runs out of time, is stopped or fails, the user is shown no card; what it proposed is withdrawn (marked `EXPIRED` and claimed) and what it would propose later is refused.
- **Retention.** `assistantRetention` first ends a confirmed change that was left `RUNNING` for more than an hour (the server stopped while it ran) as `FAILED`, with "Outcome unknown: this change was started and its end was never recorded. Check the record before asking for it again." and a tool-call row; it can never be confirmed again. It then deletes pending actions older than `approvalRetentionDays` that were decided or lapsed.
### SSE behind a proxy (Traefik, nginx)
- The responses carry `Cache-Control: no-cache` and `X-Accel-Buffering: no`. Do not compress or buffer `text/event-stream`.
@@ -424,6 +476,8 @@ All of these are standard, visible QQQ tables in the Assistant app. The model ne
| `assistant_turn_event` | Stream events, for replay. |
| `assistant_tool_call` | The audit of every tool call. |
| `assistant_feedback` | Feedback on answers. |
| `assistant_pending_action` | Proposed changes waiting for the user, and what became of them. |
| `assistant_pending_action_claim` | The one decision of a pending action (unique per action). |
| `assistant_policy` | The kill switch and overrides. |
| `assistant_usage_daily` | Usage rollups: per user per day, plus an instance row whose `userId` is null. |
| `assistant_provider` | Provider settings: type, model, key variable (never the key), limits, last connection test. |
@@ -688,6 +742,33 @@ by user, model, provider and tool. Existing `AssistantBudgets`/policy limits and
provider monthly caps continue to apply independently of these rows and overrides.
See [UPGRADE.md](UPGRADE.md) for the required additive schema.
### No answer without a spending limit
The assistant answers nobody for whom no spending limit is in force. A turn is
refused before anything is stored and before any model call (HTTP 429, code
`BUDGET`): "The assistant is not answering because no spending limit is in force.
An administrator must set or switch on a budget (Assistant > Budgets) before it
can be used." A tool call, an embedding request, the next model call of a running
answer and a scheduled turn are refused the same way. Restoring a limit restores
the assistant with the next request.
A spending limit is in force for a person when either holds:
- a budget row is switched on, has a limit above zero and applies to them: one
shared by everybody (INSTANCE; PROVIDER or MODEL for the provider or model in
use) or their own (USER for them; ROLE for a permission they hold);
- the code configuration or the policy row declares a hard limit above zero
(`AssistantBudgets`: the user's daily tokens or cost, or the instance's monthly
tokens or cost).
What does not put one in force: a row that is switched off; a row whose limit is
zero (it is not evaluated either); a row for somebody else or for a permission
the person does not hold; a soft limit alone, which only warns. A budget row has
no end date: its count starts again each period and it stays in force. An
override is an administrator's dated exception to a budget that stays in force,
so neither an override that has run out nor one that is running ("unlimited
until") takes its budget out of force.
### Profile routing and provider fallback
@@ -745,8 +826,8 @@ the task table. This feature does not provision an account or token.
**Run now**, **Approve write mode** and **Revoke write mode** are Review/Result
record actions. Write approval names the designated user and displays the exact
prompt SHA-256. Changing prompt, user, credential reference, profile or write-mode
request clears approval. Scheduled writes always wait for that user's panel
confirmation, even if normal policy disables confirmation.
request clears approval. Scheduled writes wait for that user's panel
confirmation, as every write does.
Each run keeps an owner-locked audit row and a normal conversation. RECORD adds
no notification; NOTIFICATION and BOTH also notify the designated user through
+115
View File
@@ -1,5 +1,120 @@
# Upgrading qbit-assistant
## Writes follow the signed-in person; the assistant needs a spending limit (Unreleased)
**After this upgrade the assistant proposes changes on every table the signed-in person may change themselves, with no settings row, and it refuses to answer anybody for whom no spending limit is in force.** Both are owner decisions of 2026-11-12. Every change still waits for the person's confirmation card, runs as that person through the UI API, and is refused there, with the screen's own message, when they may not make it. No DDL changes.
### What a host must apply
1. **Include the migration changeset** in the master changelog, once, after the assistant's tables:
```yaml
- include: { file: db/liquibase/qbit-assistant/assistant-data-access-follow-user.changelog.sql }
```
It deletes the rows of `assistant_data_access` that the retired table action "Add rows for every table" wrote and nobody changed since: type `TABLE`, mode `READ`, the action's fixed note, and a modify date less than one second after the create date. It touches no other row: not a row an administrator wrote or changed, not a `NONE` or `READ_WRITE` row, not a `PROCESS` row. Its rollback is empty. A host that never ran that action has nothing to remove and may still include it. A host with its own migration tool runs the one `DELETE` of that file.
Without the changeset nothing breaks: each of those rows now reads as a restriction, and the assistant stays read-only on that table until the row is deleted.
2. **Review the rows that remain** on Assistant > Data Access in every environment, before deploying. A row no longer opens anything. What each row means now:
| Row | Before | After |
|---|---|---|
| none | the assistant may read the table | the assistant may read it and propose what the person may change |
| `READ` | the same as no row | **a restriction**: the assistant never proposes a change to this table |
| `NONE` | the assistant never sees the table | unchanged |
| `READ_WRITE` | the assistant may propose changes to this table | no restriction, the same as no row: delete it or keep it |
Which rows exist in a deployed database cannot be read from code; an administrator has to look.
3. **Make sure a spending limit is in force**, or the assistant refuses every question after the upgrade. One is, when a budget row on Assistant > Budgets is switched on, has a limit above zero and applies to the person (a whole-instance budget applies to everybody), or when the code configuration or the policy row declares a hard limit above zero (`AssistantBudgets` user daily or instance monthly). The starter defaults changelog seeds a monthly whole-instance budget on a host that had no budget; a host that deleted or switched off its budgets, or never included the starter defaults and declares no hard limit, must add one. The Setup card's Budgets line says so.
### What changes for administrators
- **Data Access rows only restrict.** Add a row to make a table read-only for the assistant (`READ`) or to hide it (`NONE`). There is no longer a way to open a table from that screen, because nothing needs opening: the assistant follows each person's own permissions. The mode's help text and the Data access card on the Overview say what decided for each table ("default", "pinned in code", "administrator setting", "sensitive table").
- **The table action "Add rows for every table" is gone.** The Data access card shows the effective access of every table, and its source, without storing anything.
- **People who may change a table are offered the write tools at once**, on every table they may change. A person who may only read is not offered them. Tell the people who use the assistant: the card is the moment to check.
- **With no budget in force the assistant says so and stops**: "The assistant is not answering because no spending limit is in force. An administrator must set or switch on a budget (Assistant > Budgets) before it can be used." Switching a budget back on restores it with the next question. A budget that is switched off, or whose limit is zero, does not count. A temporary "unlimited until" override does not take a budget out of force: it is a dated exception to a budget that stays in place.
- **Scheduled tasks** are refused the same way, and the run's error is that sentence.
- **The retention process** now ends a confirmed change that was left running for more than an hour as failed, "Outcome unknown: ... Check the record before asking for it again.", with a tool-call row. Such an action could not be confirmed again before either; it is now also removed after `approvalRetentionDays` like any other.
- The assistant's own tables can never be opened to the assistant, by a row or by code.
### Keeping the assistant read-only, or opening single tables
In the application's configuration class, not in the database:
```java
config.withDefaultTableAccess(DataAccessMode.READ) // the assistant proposes no change anywhere ...
.withTableAccess("orders", DataAccessMode.READ_WRITE) // ... except where the code says so
.withTableAccess("payroll", DataAccessMode.NONE) // never shown to the assistant, whoever asks
.withDefaultProcessAccess(DataAccessMode.NONE); // no process, when the process tools arrive
```
A pin replaces the default for its object. An administrator's row can lower a pin or the default and can never raise either: with `withDefaultTableAccess(READ)` a `READ_WRITE` row does nothing.
### Java API, for a host or another QBit that called these directly
- `SignedInPerson.ofSession(qInstance, session)` returns a person whose `session()` is the session the authentication module resolved, not the object passed in. Code that put permissions, security keys or values on its own session object and expected the assistant to see them must put them where the authentication module does (its customizer). The time zone value is carried over.
- `UiApiToolbox.Context.forRead(sessionUUID, timezone, gateway, policy, credentials, turnId)` is gone. `runtime.getToolCalls().readContext(person, timeout)` makes the context of one of the assistant's own reads, for a signed-in person, and throws `QException` when the person has no assistant access or the kill switch is on.
- `StarterPrompts.forUser(person, policy, configured, historyOffered)` is gone; `forUser(context, userId, configured, historyOffered)` takes a context from `readContext`. `new StarterPrompts(toolbox, clock)` no longer takes a gateway.
- `AssistantStore.expirePendingAction(id)` is `expirePendingAction(userId, id, now)` and returns whether it expired the action; `decidePendingAction` is final.
- `UiApiToolbox.definitions(policy, boolean)` is `definitions(policy, Set<String> writableTables)`; `UiApiToolbox.anyWritableTable()` is gone (`DataAccessService.anyWritableTable()` still says whether any table's data access allows writes, for nobody in particular). `new BulkLoadToolFamily(bulkLoadTools)` takes one argument.
- `AssistantRetentionStep.Result` has an eighth component, `pendingActionsFailed`; `purge(runtime, now)` is the whole run, `purge(config, store, now)` everything but ending the actions left running.
- `processes.DataAccessDefaultsProcess` is gone.
- The test-jar of `qbit-assistant` holds the `testing` package only. A project that used `RuntimeInternals`, `ToolCallServiceInternals` or `Toolboxes` from it (none of the provider modules did) keeps its own copy in its own test sources, inside the assistant's package it reaches into; they were never an API.
- A host's own `ToolFamily` and its tools compile unchanged. A host's tool that proposes a change is offered as before; it decides its own offer in `definition(ToolOffer)`.
### An existing test's expectation that changed
`ChatToolSetTest` compares the chat's tool list with a file written before the tool registry existed. No definition changed and no line of the file changed. What changed is who is offered the write tools: the four "everything" scenarios now ask as a person who may change tables (with no settings row; they used to ask as a person who may only read, with `READ_WRITE` rows), and the scenario of a person who may not save an analysis lost its `save_analysis` line.
## One decision per pending action, a closed tool surface, cards with before and after (Unreleased, UI API extension assistant 1.9.0)
**Apply the "Pending actions" block at the end of `docs/schema-postgres.sql` before deploying.** It is guarded and safe to re-run. It adds:
- `assistant_pending_action.action_kind VARCHAR(64)`, `before_json TEXT`, `record_version VARCHAR(64)` (all nullable);
- two indexes on `assistant_pending_action`: `(user_id, status)` and `(create_date)`;
- the table `assistant_pending_action_claim` (`id`, `pending_action_id` NOT NULL with a cascading foreign key to `assistant_pending_action`, `user_id`, `decision`, `create_date`), with **`UNIQUE (pending_action_id)`** and an index on `user_id`.
The unique key is what makes a confirmation run once across servers. A host that maps table or column names with a table customizer maps the new table and columns the same way; a host with its own migration tool copies the block into it. Without the block, proposing and confirming fail (the columns and the table are written on every proposal and decision).
What a host may notice:
- A proposal made before the upgrade and confirmed after it: an update or a delete is refused as changed (it kept no version of the record) and the user asks again; a create runs. Proposals live 15 minutes by default.
- `update_record` and `delete_record` now read the record (`GET /table/{tableName}/{primaryKey}`, as the user) when they propose and again when the user confirms. A record the user cannot read is refused at proposal time with that operation's answer.
- Confirming an update or a delete of a record that changed in the meantime fails with "This record changed after the change was proposed. Nothing was changed."
- `assistantRetention` now deletes decided and lapsed pending actions older than `approvalRetentionDays` (default 365). The setting was documented and never read; check its value before the first run after the upgrade.
- New operation `GET /qqq/v1/assistant/actions`; `AssistantActionV1` has new optional fields (`actionKind`, `conversationId`, `createdAt`, `decidedAt`, `tableLabel`, `recordHref`, `changes`, `before`). A released panel keeps working: `toolName` and the existing fields are unchanged.
- The input kept in `assistant_tool_call.input_json` is now also redacted with `customRedactionPatterns`.
- A request that carries several credentials (a session cookie and an `Authorization` header, say) has only the one its session was resolved from forwarded to the UI API.
Java API, for a host or another QBit that called these directly:
- `SignedInPerson.ofSession(session)` is now `SignedInPerson.ofSession(qInstance, session)`. It asks the authentication module to resolve the session's own id and throws `IllegalArgumentException` when that is not the user the session names.
- `new AssistantApi(runtime, session)` still exists and still serves the user's own conversations. It starts a turn, confirms or cancels an action, and explains access only after the same check, and answers 403 when the check fails.
- Gone from the public surface: `AssistantRuntime.getToolRegistry()`, `getUiApiGateway()` and `ownToolFamilies()`; `ToolCallService.getRegistry()`; `ToolCallService.execute` and `log` (use `executeAndLog`); the public constructors of `ToolCallRequest`, `ConfirmedToolCall` and `UiApiToolbox.Context`, which are now final classes with the same accessors (a context for one of the assistant's own reads is `UiApiToolbox.Context.forRead(...)`). `new ToolCallService(runtime, registry, store)` takes the gateway as a fourth argument.
- `AccessExplainer.explain(...)` has an overload without the gateway argument; `RagService` and `StarterPrompts` are handed the gateway by the runtime.
- `UiApiToolbox.ProposedWrite` has three more components (`kind`, `before`, `recordVersion`); the seven-argument constructor still exists. `AssistantRetentionStep.Result` has a seventh component, `pendingActionsDeleted`.
- A host's own `ToolFamily` and its tools compile unchanged (they read a request through its accessors and never constructed one).
## Every write waits for confirmation; the tool-call service owns the policy and the identity (Unreleased)
No schema change and nothing to apply to the database. What a host may notice:
- **Writes require confirmation** on the `assistant_policy` row no longer does
anything: every create, update or delete the assistant proposes waits for the
user's confirmation card. The column `writes_require_confirmation` stays in
place and is not read. A host that had switched it off gets the card again.
- `AssistantQBitConfig.withToolFamily` throws when it is called after the QBit
was produced (it was ignored). Register tool families before producing it.
- Java API, for a host that called these directly: `AssistantApi.withCredentials`
is gone (`new AssistantApi(runtime, session)` is unchanged; a route uses
`new AssistantApi(runtime, SignedInPerson.ofRequest(context))`);
`AssistantRuntime.getToolbox()`, `getKnowledgeTool()` and `getBulkLoadTools()`
are gone; `PolicyService.ToolPolicy` has three components
(`allowedTools`, `maxRowsPerCall`, `redactPii`); `ToolCallScope.clientChannel()`
is gone. A host's own `ToolFamily` and its tools compile unchanged.
## Conversation management (Unreleased, UI API extension assistant 1.6.0)
No schema change. `assistant_conversation` is now usable by every user holding
+12 -10
View File
@@ -118,9 +118,9 @@ Owner: *"the agent should have to go through the same API that the UIUX does. th
1. **Tools via the UI API.** Every tool that reads or writes data, or runs a process, report or widget, runs a spec'd `/qqq/v1` operation with the calling user's own session. That means the UI's authentication, permission checks, record locks, validation, audit and error mapping. There are no direct core data actions and no elevated sessions.
2. **Data access is configured and verifiable in the UI.**
- Each table and process has an AI data access setting: NONE, READ or READ_WRITE. Sensitive tables default to NONE.
- Each table and process has an AI data access mode: NONE, READ or READ_WRITE. It is declared in code (READ_WRITE by default: the assistant follows the signed-in person) and an administrator's setting can only narrow it. Sensitive tables and the assistant's own are NONE.
- Admins can see what the assistant could do for a given user, and why.
3. **Writes need confirmation.** A write tool only *proposes* the change, as a pending action card. Nothing changes until the user confirms in the panel, unless the policy turns confirmation off.
3. **Writes need confirmation.** A write tool only *proposes* the change, as a pending action card. Nothing changes until the user confirms in the panel. No policy, setting or caller can turn confirmation off (owner, 2026-11-12).
4. **Persona and skills, never more access** (owner, 2026-10-13): *"in the config for the AI settings, we should be able to set a name and personality and extra skills etc for the agent - and again - the agent should be limited to only using our UIUX API and being able to only do the things that the currently logged in user could do with the same UIUX API via the UIUX"*.
- **Profiles** (`assistant_profile`, admin-edited): display name, icon, personality (tone instructions, at most 2,000 characters), greeting and up to 6 starter prompts.
- One profile is the default. Others are scoped to an app, to users holding a permission (a role), or to both. The most specific profile wins.
@@ -133,7 +133,7 @@ Owner: *"the agent should have to go through the same API that the UIUX does. th
- Every data, process, report or widget capability is a `/qqq/v1` operation, called through the gateway (core `UiApiInvoker` once it is released) with the CURRENT user's session.
- The tool list is the spec'd operations ∩ data access ∩ policy. Profiles and skills are never read when tools are built or run.
- It is enforced by ArchUnit: the tool, UI API and access code may not use QQQ table, process, report, dashboard or async actions, the core AI kernel, or the profile and skill classes. It is also enforced by a runtime guard: `AssistantStore` refuses any table that is not `assistant_*`.
- `UiApiToolsSecurityTest.testDenialMatrix` checks that a user without permission gets the identical status and message from the UI API and through the assistant for query, get, insert, update and delete. The assistant has no process or report tool, and process data access defaults to NONE.
- `UiApiToolsSecurityTest.testDenialMatrix` checks that a user without permission gets the identical status and message from the UI API and through the assistant for query, get, insert, update and delete. The assistant has no process or report tool yet; its own processes are NONE, and an application's follow the code default.
5. All other open questions in this section were decided as recommended (shared rule: "all recommendations accepted").
## Tools through the UI API (0.1.0-RC.6)
@@ -176,7 +176,7 @@ This matches core's design (`docs/UI-API-EXTENSIONS.md` §5, `UiApiInvoker`, QRu
`analyze_records` answers grouped questions (a count, sum, average, minimum, maximum or distinct count per group, for one or two group-by fields). The user needs what the Analyses screens need: read on the table and the table's analyze permission. The assistant adds its own limits first: data access READ on the table, and group-by, measure and filter fields whose values it may see (a hidden field is refused as an unknown one, before any request). It returns at most the policy's rows per call.
`save_analysis` is the first write that creates more than one record. It follows W below (create and schedule a report through the saved-analysis and schedule tables' own insert operations) with these fixed points:
- One proposal, one card: the pending action stores the whole plan (the analysis definition and the schedule); the card lists what will be created and the numbers as they are now. It is never auto-confirmed, also where the policy turns confirmation off for record writes.
- One proposal, one card: the pending action stores the whole plan (the analysis definition and the schedule); the card lists what will be created and the numbers as they are now. It is never auto-confirmed (no write is).
- The confirming user's session creates both records, the saved analysis first. If the schedule's insert is refused, the outcome says exactly that: the analysis exists, the schedule does not.
- The schedule is DAILY at a local time in an IANA time zone, emailed to the confirming user. A time zone is never guessed: the tool refuses a schedule without one and tells the model to ask.
- A schedule is refused, with the reason, while it could not run: the assistant reads the instance and asks the host (`withScheduledDeliveryCheck`). Whether a scheduled run can get the owner's current access when they are signed out is the host's to answer (a service principal, QQQ `docs/SERVICE-IDENTITY.md`).
@@ -188,13 +188,14 @@ Running processes and reports through the assistant (`/processes/...`, `/reports
### Layers (each one can only narrow the one before)
1. **The user's own permissions and record locks**, enforced by the UI API itself. A user who cannot do something in the UI gets the identical status and message through the assistant: the tool result is `DENIED`/`NOT_FOUND`/`INVALID_INPUT` with `"<operation> answered HTTP <status>: <message>"`.
2. **Data access** (`assistant_data_access`, editable by `assistant.admin`). A row sets NONE, READ or READ_WRITE per table (`TABLE`) or process (`PROCESS`). Without a row:
- tables whose AI exposure is NONE (the `assistant_*` tables, sessions, OAuth, and any table whose metadata says so) are NONE, and a row cannot raise them;
- other tables are READ, so the assistant never writes unless an admin allows it;
- processes are NONE.
The write tools are offered only when some table is READ_WRITE.
2. **Data access** (declared in code, narrowed by `assistant_data_access` rows that `assistant.admin` edits). What decides, most restrictive first:
- the `assistant_*` tables and the QBit's own processes are NONE; no row and no host pin raises them;
- tables whose AI exposure is NONE (sessions, OAuth, and any table whose metadata says so) are NONE;
- the code says the rest: a pin for the object (`withTableAccess`, `withProcessAccess`), else the default (`withDefaultTableAccess`, `withDefaultProcessAccess`), which is READ_WRITE: the assistant may propose what the signed-in person may do themselves;
- a row sets NONE or READ per table (`TABLE`) or process (`PROCESS`) and can only narrow what the code says. A READ_WRITE row is "no restriction".
The write tools are offered to a person who may create, edit or delete records of some table themselves (asked of the UI API as that person). See `docs/actions-and-mcp.md` sections 2 and 3.
3. **Policy** (`assistant_policy`):
- `writesRequireConfirmation` (default true);
- every write waits for the user's confirmation card; no policy switches that off (the `writesRequireConfirmation` column is no longer read);
- `allowedTools` (empty allows all);
- `maxRowsPerCall` (default 50, ceiling 200);
- `redactPii` (default true: secrets, card numbers and SSNs are redacted, with the core `AiRedactor`).
@@ -611,6 +612,7 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
## Next phases
- **Actions and MCP (draft, 2026-11-11):** writes that follow the person's permissions by default, process runs, saved reports, schedules with email, and an MCP endpoint are designed in [actions-and-mcp.md](actions-and-mcp.md). It supersedes the write-mode switch and the optional confirmation of section W above once approved.
- **(c) Data access screens:**
- the Data Access table, in the nav;
- 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);
File diff suppressed because it is too large. Load diff
+27
View File
@@ -750,3 +750,30 @@ CREATE INDEX IF NOT EXISTS assistant_client_error_turn ON assistant_client_error
-- Existing rows (and NULL) behave as ACTIVE, exactly as before. Safe to re-run.
ALTER TABLE assistant_provider ADD COLUMN IF NOT EXISTS setup_state VARCHAR(16) DEFAULT 'ACTIVE'
CONSTRAINT ck_assistant_provider_setup_state CHECK (setup_state IN ('ACTIVE', 'SETTING_UP'));
-- Pending actions (UI API extension assistant 1.9.0): what a card shows of the record as it
-- was, what kind of change is proposed, and the single decision of an action.
-- action_kind: record_create, record_update, record_delete, or the proposing tool's name.
-- before_json: the values the card showed before the change, after the exposure rules of
-- get_record (encrypted in HIPAA mode, like params_json). record_version: a digest of the
-- record as the proposing user could read it, never its values; a confirmed update or delete
-- runs only while the record still has that version.
-- assistant_pending_action_claim: one row per decided action. Its unique key is what lets
-- exactly one request confirm or cancel an action, on one server or several; do not drop it.
-- Safe to re-run.
ALTER TABLE assistant_pending_action ADD COLUMN IF NOT EXISTS action_kind VARCHAR(64);
ALTER TABLE assistant_pending_action ADD COLUMN IF NOT EXISTS before_json TEXT;
ALTER TABLE assistant_pending_action ADD COLUMN IF NOT EXISTS record_version VARCHAR(64);
CREATE INDEX IF NOT EXISTS assistant_pending_action_user_status ON assistant_pending_action (user_id, status);
CREATE INDEX IF NOT EXISTS assistant_pending_action_create_date ON assistant_pending_action (create_date);
CREATE TABLE IF NOT EXISTS assistant_pending_action_claim
(
id SERIAL PRIMARY KEY,
pending_action_id INTEGER NOT NULL CONSTRAINT fk_assistant_pending_action_claim_pending_action_id REFERENCES assistant_pending_action (id) ON DELETE CASCADE,
user_id VARCHAR(255),
decision VARCHAR(16),
create_date TIMESTAMP WITH TIME ZONE,
CONSTRAINT assistant_pending_action_claim_action UNIQUE (pending_action_id)
);
CREATE INDEX IF NOT EXISTS assistant_pending_action_claim_user_id ON assistant_pending_action_claim (user_id);
+2 -2
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-RC.29</revision>
<revision>0.4.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -217,7 +217,7 @@
<properties>
<!-- RC.7 provides classified-value tokens, log redaction and native help bundles; -->
<!-- RC.8 provides core job tracking (JobTracking, the Jobs tray). -->
<qqq.snapshot.version>4.1.0-RC.25</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.32</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
+10 -1
View File
@@ -154,7 +154,11 @@
</configuration>
</plugin>
<plugin>
<!-- the eval fixture and harness are reused by provider-module live evals -->
<!-- the eval fixture and harness are reused by provider-module live evals: -->
<!-- the published test-jar holds the testing package and nothing else. The -->
<!-- helpers this module's own tests use to reach behind the tool-call -->
<!-- service (RuntimeInternals, ToolCallServiceInternals, Toolboxes) and -->
<!-- the tests themselves are never published (TestJarContentsTest). -->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<executions>
@@ -162,6 +166,11 @@
<goals>
<goal>test-jar</goal>
</goals>
<configuration>
<includes>
<include>com/kingsrook/qbits/assistant/testing/**</include>
</includes>
</configuration>
</execution>
</executions>
</plugin>
@@ -22,9 +22,16 @@ import java.util.List;
/*******************************************************************************
** Usage budgets. Every limit is optional (null = unlimited). "Tokens" are
** billable tokens: uncached input + cache writes + output (cache reads are
** cheap and excluded; they still count toward cost). Cost is in micro-USD.
** Usage budgets. Every limit is optional (null = none declared here).
** "Tokens" are billable tokens: uncached input + cache writes + output (cache
** reads are cheap and excluded; they still count toward cost). Cost is in
** micro-USD.
**
** The assistant answers nobody for whom no spending limit is in force
** (BudgetService.limitInForce): a hard limit above zero here or on the
** policy row, or a budget row (assistant_budget) that is switched on and
** applies to the person. A host with neither is refused every question
** until an administrator adds a budget.
**
** A soft limit adds a warning notice to the turn; a hard limit refuses new
** turns (HTTP 429) and stops a running turn after its current step.
@@ -31,6 +31,7 @@ import java.util.Set;
import java.util.function.Function;
import java.util.regex.Pattern;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.classification.ClassificationSource;
import com.kingsrook.qbits.assistant.classification.ConfiguredClassificationSource;
import com.kingsrook.qbits.assistant.rag.VectorStore;
@@ -38,6 +39,7 @@ import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
import com.kingsrook.qbits.assistant.spi.ModelPricing;
import com.kingsrook.qbits.assistant.spi.TrustedCaBundle;
import com.kingsrook.qbits.assistant.tools.registry.ToolFamily;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
@@ -122,6 +124,18 @@ public class AssistantQBitConfig implements QBitConfig
private Integer approvalRetentionDays = 365;
private Integer policyCacheSeconds = 30;
private BigDecimal budgetCostLimitCeilingUsd = new BigDecimal("1000000");
//////////////////////////////////////////////////////////////////////////
// what the assistant may do with a table or a process when nothing //
// narrows it: declared here, in versioned code. READ_WRITE grants //
// nothing: it lets the assistant propose what the signed-in person //
// may do themselves. A pin replaces the default for one object; an //
// administrator's assistant_data_access row can only narrow the two. //
//////////////////////////////////////////////////////////////////////////
private DataAccessMode defaultTableAccess = DataAccessMode.READ_WRITE;
private DataAccessMode defaultProcessAccess = DataAccessMode.READ_WRITE;
private Map<String, DataAccessMode> tableAccess = new LinkedHashMap<>();
private Map<String, DataAccessMode> processAccess = new LinkedHashMap<>();
private String ownerSecurityKeyType = DEFAULT_OWNER_KEY_TYPE;
private String shortcut = DEFAULT_SHORTCUT;
private Set<String> allowedScheduledCredentialEnvNames = new LinkedHashSet<>();
@@ -166,6 +180,7 @@ public class AssistantQBitConfig implements QBitConfig
private transient MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
private transient Map<String, AssistantModelProvider> modelProviders = new LinkedHashMap<>();
private transient Map<String, AssistantProviderType> providerTypes = new LinkedHashMap<>();
private transient List<ToolFamily> toolFamilies = new ArrayList<>();
private transient Function<String, String> secretResolver = System::getenv;
private transient Function<String, String> emailRecipientResolver;
private transient AssistantRuntime runtime;
@@ -296,6 +311,14 @@ public class AssistantQBitConfig implements QBitConfig
{
errors.add("policyCacheSeconds must not be negative");
}
if(defaultTableAccess == null || defaultProcessAccess == null)
{
errors.add("defaultTableAccess and defaultProcessAccess are required (NONE, READ or READ_WRITE)");
}
if(tableAccess == null || processAccess == null || tableAccess.containsValue(null) || processAccess.containsValue(null))
{
errors.add("tableAccess and processAccess must name a mode (NONE, READ or READ_WRITE) for every object they pin");
}
if(maxRowsToModel != null && maxRowsToModel > 500)
{
@@ -1691,6 +1714,182 @@ public class AssistantQBitConfig implements QBitConfig
/*******************************************************************************
** Getter for defaultTableAccess: what the assistant may do with a table that
** has no pin (READ_WRITE unless a host says otherwise).
*******************************************************************************/
public DataAccessMode getDefaultTableAccess()
{
return (this.defaultTableAccess);
}
/*******************************************************************************
** Setter for defaultTableAccess
*******************************************************************************/
public void setDefaultTableAccess(DataAccessMode defaultTableAccess)
{
this.defaultTableAccess = defaultTableAccess;
}
/*******************************************************************************
** What the assistant may do with a table when nothing narrows it. The
** default, READ_WRITE, lets it propose any change the signed-in person may
** make themselves; it grants nothing, and every change still waits for
** that person's confirmation. READ keeps the assistant read-only except for
** the tables pinned READ_WRITE with {@link #withTableAccess}.
*******************************************************************************/
public AssistantQBitConfig withDefaultTableAccess(DataAccessMode defaultTableAccess)
{
this.defaultTableAccess = defaultTableAccess;
return (this);
}
/*******************************************************************************
** Getter for defaultProcessAccess: what the assistant may do with a process
** that has no pin (READ_WRITE, "may run", unless a host says otherwise).
*******************************************************************************/
public DataAccessMode getDefaultProcessAccess()
{
return (this.defaultProcessAccess);
}
/*******************************************************************************
** Setter for defaultProcessAccess
*******************************************************************************/
public void setDefaultProcessAccess(DataAccessMode defaultProcessAccess)
{
this.defaultProcessAccess = defaultProcessAccess;
}
/*******************************************************************************
** What the assistant may do with a process when nothing narrows it:
** READ_WRITE means it may propose a run the signed-in person may start
** themselves; NONE that it never sees the process.
*******************************************************************************/
public AssistantQBitConfig withDefaultProcessAccess(DataAccessMode defaultProcessAccess)
{
this.defaultProcessAccess = defaultProcessAccess;
return (this);
}
/*******************************************************************************
** Getter for tableAccess: the tables pinned in code, by name.
*******************************************************************************/
public Map<String, DataAccessMode> getTableAccess()
{
return (this.tableAccess);
}
/*******************************************************************************
** Setter for tableAccess
*******************************************************************************/
public void setTableAccess(Map<String, DataAccessMode> tableAccess)
{
this.tableAccess = tableAccess;
}
/*******************************************************************************
** Pin one table in code: the mode replaces the default for that table, and
** an administrator's row cannot raise it. Pin READ or NONE where the
** assistant must never propose a change (or never see the table), whoever
** asks.
*******************************************************************************/
public AssistantQBitConfig withTableAccess(String tableName, DataAccessMode mode)
{
this.tableAccess.put(tableName, mode);
return (this);
}
/*******************************************************************************
** Getter for processAccess: the processes pinned in code, by name.
*******************************************************************************/
public Map<String, DataAccessMode> getProcessAccess()
{
return (this.processAccess);
}
/*******************************************************************************
** Setter for processAccess
*******************************************************************************/
public void setProcessAccess(Map<String, DataAccessMode> processAccess)
{
this.processAccess = processAccess;
}
/*******************************************************************************
** Pin one process in code: the mode replaces the default for that process,
** and an administrator's row cannot raise it.
*******************************************************************************/
public AssistantQBitConfig withProcessAccess(String processName, DataAccessMode mode)
{
this.processAccess.put(processName, mode);
return (this);
}
/*******************************************************************************
** Register a tool family of the host or of another QBit: its tools join
** the assistant's own in the tool registry when the QBit is produced. This
** is the only way a tool gets in from outside the QBit; a family class that
** is merely on the classpath is never registered.
**
** Call it before the QBit is produced: once the config has its runtime the
** registry is complete, and a later call throws instead of being silently
** ignored. The runtime refuses to start when a registered
** tool has the name of another tool. A registered tool is
** offered and run like the assistant's own: as the signed-in person, after
** the checks of engine.ToolCallService, and it must apply its own checks to
** what it reads or writes (see tools.registry.ToolCheck).
*******************************************************************************/
public AssistantQBitConfig withToolFamily(ToolFamily toolFamily)
{
if(runtime != null)
{
throw (new IllegalStateException("withToolFamily was called after the assistant's runtime was built: the family would never be registered. Call it before the QBit is produced."));
}
if(toolFamily != null)
{
toolFamilies.add(toolFamily);
}
return (this);
}
/*******************************************************************************
** The tool families a host or another QBit registered, in that order.
*******************************************************************************/
@JsonIgnore
public List<ToolFamily> getToolFamilies()
{
return (toolFamilies);
}
/*******************************************************************************
** The registered provider types, by type id.
*******************************************************************************/
@@ -33,7 +33,6 @@ import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.processes.ConversationActionsProcess;
import com.kingsrook.qbits.assistant.processes.DataAccessDefaultsProcess;
import com.kingsrook.qbits.assistant.processes.DiscoverModelsProcess;
import com.kingsrook.qbits.assistant.processes.KnowledgeIndexProcess;
import com.kingsrook.qbits.assistant.processes.ScheduledTaskProcess;
@@ -190,7 +189,7 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
process.setSourceQBitName(qBitMetaData.getName());
qInstance.addProcess(process);
}
for(QProcessMetaData process : List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process(), DataAccessDefaultsProcess.process()))
for(QProcessMetaData process : List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process()))
{
process.setSourceQBitName(qBitMetaData.getName());
qInstance.addProcess(process);
@@ -21,6 +21,8 @@ import java.lang.ref.WeakReference;
import java.net.InetAddress;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@@ -34,12 +36,14 @@ import com.kingsrook.qbits.assistant.attachments.UploadRefSource;
import com.kingsrook.qbits.assistant.engine.BudgetService;
import com.kingsrook.qbits.assistant.engine.ClientErrors;
import com.kingsrook.qbits.assistant.engine.ContextBuilder;
import com.kingsrook.qbits.assistant.engine.KernelToolFamily;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.ProfileService;
import com.kingsrook.qbits.assistant.engine.StarterPrompts;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.engine.RateLimiter;
import com.kingsrook.qbits.assistant.engine.RunningTurn;
import com.kingsrook.qbits.assistant.engine.ToolCallService;
import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.rag.InMemoryVectorStore;
@@ -47,8 +51,14 @@ import com.kingsrook.qbits.assistant.rag.RagService;
import com.kingsrook.qbits.assistant.rag.VectorStore;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
import com.kingsrook.qbits.assistant.tools.KnowledgeToolFamily;
import com.kingsrook.qbits.assistant.tools.PresentResultsToolFamily;
import com.kingsrook.qbits.assistant.tools.UiApiToolFamily;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadToolFamily;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qbits.assistant.tools.registry.ToolFamily;
import com.kingsrook.qbits.assistant.tools.registry.ToolRegistry;
import com.kingsrook.qbits.assistant.uiapi.CoreUiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
@@ -93,6 +103,8 @@ public class AssistantRuntime
private final RateLimiter rateLimiter;
private final ClientErrors clientErrors;
private final AiToolKernel kernel;
private final ToolRegistry toolRegistry;
private final ToolCallService toolCalls;
private final ContextBuilder contextBuilder;
private final DataAccessService dataAccessService;
private final ProfileService profileService;
@@ -147,11 +159,6 @@ public class AssistantRuntime
AiExposurePolicy exposurePolicy = new AiExposurePolicy(settings);
this.dataAccessService = new DataAccessService(qInstance, config, store, exposurePolicy, clock);
this.profileService = new ProfileService(config, store, clock);
this.accessExplainer = new AccessExplainer(qInstance, dataAccessService);
this.toolbox = new UiApiToolbox(qInstance, dataAccessService, exposurePolicy, new AiRedactor(settings.getCustomRedactionPatterns()),
config.getMaxCellChars() == null ? AiToolKernelSettings.DEFAULT_MAX_CELL_CHARS : config.getMaxCellChars());
this.toolbox.withScheduledDeliveryCheck(config.getScheduledDeliveryCheck());
this.starterPrompts = new StarterPrompts(toolbox, clock);
UiApiGateway delegate = config.getUiApiGateway() != null ? config.getUiApiGateway() : new CoreUiApiGateway(this::uiApiBaseUrl, qInstance);
this.uiApiGateway = new UiApiGateway()
{
@@ -182,12 +189,26 @@ public class AssistantRuntime
return (delegate.invoke(call));
}
};
this.accessExplainer = new AccessExplainer(qInstance, dataAccessService, uiApiGateway);
this.toolbox = new UiApiToolbox(qInstance, dataAccessService, exposurePolicy, new AiRedactor(settings.getCustomRedactionPatterns()),
config.getMaxCellChars() == null ? AiToolKernelSettings.DEFAULT_MAX_CELL_CHARS : config.getMaxCellChars());
this.toolbox.withScheduledDeliveryCheck(config.getScheduledDeliveryCheck());
this.starterPrompts = new StarterPrompts(toolbox, clock);
VectorStore vectors = config.getVectorStore() == null ? new InMemoryVectorStore() : config.getVectorStore();
this.ragService = new RagService(this, vectors);
this.ragService = new RagService(this, vectors, uiApiGateway);
this.knowledgeTool = new KnowledgeTool(this, ragService, vectors);
this.bulkLoadTools = new BulkLoadTools(qInstance, dataAccessService, config.getBulkLoadRunner());
this.attachmentService = new AttachmentService(config.getAttachments(), new UploadRefSource());
this.contextBuilder = new ContextBuilder(config, kernel, toolbox).withKnowledgeTool(knowledgeTool).withBulkLoadTools(bulkLoadTools).withBudgets(budgetService);
///////////////////////////////////////////////////////////////////////
// every tool, registered once: the QBit's own families, named here, //
// and the ones a host or another QBit registered on the config. //
// Nothing is looked for on the classpath //
///////////////////////////////////////////////////////////////////////
List<ToolFamily> toolFamilies = new ArrayList<>(ownToolFamilies());
toolFamilies.addAll(config.getToolFamilies());
this.toolRegistry = ToolRegistry.of(this, toolFamilies);
this.toolCalls = new ToolCallService(this, toolRegistry, store, uiApiGateway);
this.contextBuilder = new ContextBuilder(config, kernel, toolRegistry).withBudgets(budgetService);
if(Boolean.TRUE.equals(config.getRegisterShutdownHook()))
{
///////////////////////////////////////////////////////////////////////
@@ -307,19 +328,49 @@ public class AssistantRuntime
/*******************************************************************************
** Getter for toolbox (the UI API data tools)
** The QBit's own tool families: the complete list. A new family of the
** QBit is added here; one of a host or another QBit is registered with
** AssistantQBitConfig.withToolFamily.
**
** Each family is handed the toolbox its tools run. The runtime has no
** getter for a toolbox, and this list is not public: a tool is run only by
** engine.ToolCallService, which the runtime hands its registry.
*******************************************************************************/
public UiApiToolbox getToolbox()
List<ToolFamily> ownToolFamilies()
{
return (toolbox);
return (List.of(new UiApiToolFamily(toolbox), new BulkLoadToolFamily(bulkLoadTools), new KnowledgeToolFamily(knowledgeTool), new PresentResultsToolFamily(), new KernelToolFamily()));
}
/*******************************************************************************
** Getter for uiApiGateway
** Every tool the assistant has; the only place a tool is looked up. Not
** public: the runtime hands the registry to the tool-call service and the
** context builder, and nothing else reaches a tool.
*******************************************************************************/
public UiApiGateway getUiApiGateway()
ToolRegistry toolRegistry()
{
return (toolRegistry);
}
/*******************************************************************************
** Runs and logs one tool call for the signed-in person.
*******************************************************************************/
public ToolCallService getToolCalls()
{
return (toolCalls);
}
/*******************************************************************************
** The gateway the tools call the UI API through. Not public: the runtime
** hands it to the collaborators that call the UI API as the signed-in
** person, and a tool is given it only in the context of a call.
*******************************************************************************/
UiApiGateway uiApiGateway()
{
return (uiApiGateway);
}
@@ -571,14 +622,6 @@ public class AssistantRuntime
/*******************************************************************************
** Permission-filtered retrieval tool.
*******************************************************************************/
public KnowledgeTool getKnowledgeTool()
{
return (knowledgeTool);
}
/*******************************************************************************
** Server-owned scheduled execution context.
*******************************************************************************/
@@ -598,17 +641,6 @@ public class AssistantRuntime
/*******************************************************************************
** The bulk-load tools (saved profiles, attached files, dry runs, loads).
*******************************************************************************/
public BulkLoadTools getBulkLoadTools()
{
return (bulkLoadTools);
}
/*******************************************************************************
** Stop accepting work, cancel, wait a bounded grace, then force finalization.
*******************************************************************************/
@@ -0,0 +1,232 @@
/*
* 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;
import java.util.Map;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import io.javalin.http.Context;
/*******************************************************************************
** The signed-in person a request is made for: their own session and the one
** credential the tools forward to the UI API, bound together when the person
** is made and never changed afterwards. A tool call, a turn and a
** confirmation are all made for one of these, so what the assistant checks
** (who the person is, what they may do) and what the UI API then acts as are
** the same person.
**
** There are two ways to get one, and no public constructor:
** - {@link #ofRequest(Context)}, for a route: the session the route's session
** setup put on this thread, with the one credential of that HTTP request
** the session was resolved from (not every credential the request carried).
** Only the assistant's route classes may call it (ArchUnit).
** - {@link #ofSession(QInstance, QSession)}, for code that has a session and
** no HTTP request (a process, a scheduled run): the credential is the
** session's own id. The instance's authentication module resolves that id,
** exactly as it will when the UI API is called with it, and the person is
** the session it resolved, never the object that was passed in: who the
** person is and what they may do (assistant access, the owner key, every
** permission) is read from what the authentication module says now. So a
** permission added to a session by hand gives the person nothing, and a
** session kept from earlier does not keep a permission that has since been
** taken away. A session that names one user and carries another user's id
** is refused.
**
** The credentials are never logged; toString leaves them out.
*******************************************************************************/
public final class SignedInPerson
{
private final QSession session;
private final UiApiCredentials credentials;
/*******************************************************************************
**
*******************************************************************************/
private SignedInPerson(QSession session, UiApiCredentials credentials)
{
if(session == null)
{
throw (new IllegalArgumentException("A signed-in person needs a session."));
}
this.session = session;
this.credentials = credentials;
}
/*******************************************************************************
** The person of the HTTP request being served on this thread: the session
** the route's session setup resolved for it (QContext), and the credential
** of that request the session was resolved from. Both come from the one
** request, so they are the same person's; neither is a value the caller
** chose.
*******************************************************************************/
public static SignedInPerson ofRequest(Context request)
{
if(request == null)
{
throw (new IllegalArgumentException("A request is needed to take its credentials."));
}
return (new SignedInPerson(QContext.getQSession(), resolvedFrom(UiApiCredentials.fromContext(request))));
}
/*******************************************************************************
** Of the credentials a request carries, the one its session was resolved
** from: session setup takes the first it finds of an in-process session
** token, the sessionId cookie, the sessionUUID cookie, the x-api-key header
** and the Authorization header, and ignores the rest. Only that one is
** forwarded, so the UI API resolves the same session and a second
** credential on the request (which may be another person's) goes nowhere.
*******************************************************************************/
static UiApiCredentials resolvedFrom(UiApiCredentials all)
{
if(all == null)
{
return (null);
}
if(StringUtils.hasContent(all.inProcessSessionToken()))
{
return (new UiApiCredentials(null, null, null, null, all.inProcessSessionToken()));
}
if(StringUtils.hasContent(all.sessionId()))
{
return (new UiApiCredentials(null, all.sessionId(), null, null));
}
if(StringUtils.hasContent(all.sessionUUID()))
{
return (new UiApiCredentials(all.sessionUUID(), null, null, null));
}
if(all.apiKey() != null)
{
return (new UiApiCredentials(null, null, null, all.apiKey()));
}
if(all.authorization() != null)
{
return (new UiApiCredentials(null, null, all.authorization(), null));
}
return (new UiApiCredentials(null, null, null, null));
}
/*******************************************************************************
** A person known by their session alone, with no HTTP request to take a
** credential from: the UI API will be called with this session's own id, so
** that id is resolved here first, by the instance's authentication module,
** and must be the session of the user this session names. Refused
** (IllegalArgumentException) when it is not, or cannot be resolved.
**
** The person is made from the session the authentication module resolved.
** Of the session passed in only two things are used: its id (the
** credential) and the user it names (which must be the one resolved). Its
** permissions, security keys and values are not: the person's time zone, a
** display preference that decides nothing, is the one value carried over.
*******************************************************************************/
public static SignedInPerson ofSession(QInstance qInstance, QSession session)
{
if(qInstance == null || session == null || !StringUtils.hasContent(session.getUuid()))
{
throw (new IllegalArgumentException("A signed-in person needs an instance and a session with an id."));
}
String claimed = AssistantSecurity.userId(session);
QSession resolved;
boolean entered = QContext.getQInstance() == null;
try
{
if(entered)
{
QContext.init(qInstance, session);
}
String key = QAuthenticationType.MOCK.equals(qInstance.getAuthentication().getType()) ? "sessionId" : "sessionUUID";
resolved = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication()).createSession(qInstance, Map.of(key, session.getUuid()));
}
catch(Exception e)
{
throw (new IllegalArgumentException("The session's id could not be resolved by the authentication module."));
}
finally
{
if(entered)
{
QContext.clear();
}
}
if(claimed == null || resolved == null || !claimed.equals(AssistantSecurity.userId(resolved)) || !session.getUuid().equals(resolved.getUuid()))
{
throw (new IllegalArgumentException("The session's id does not resolve to the user the session names."));
}
if(resolved != session && resolved.getValue(QSession.VALUE_KEY_USER_TIMEZONE) == null && session.getValue(QSession.VALUE_KEY_USER_TIMEZONE) != null)
{
resolved.setValue(QSession.VALUE_KEY_USER_TIMEZONE, session.getValue(QSession.VALUE_KEY_USER_TIMEZONE));
}
return (new SignedInPerson(resolved, null));
}
/*******************************************************************************
** The person's own session.
*******************************************************************************/
public QSession session()
{
return (session);
}
/*******************************************************************************
** The one credential of the person's request to forward to the UI API;
** null when the session's own id is the credential. Never log it.
*******************************************************************************/
public UiApiCredentials credentials()
{
return (credentials);
}
/*******************************************************************************
** The person's user id (the owner of their assistant rows); null when the
** session names nobody.
*******************************************************************************/
public String userId()
{
return (AssistantSecurity.userId(session));
}
/*******************************************************************************
** Without the credentials.
*******************************************************************************/
@Override
public String toString()
{
return ("SignedInPerson[" + userId() + "]");
}
}
@@ -53,6 +53,7 @@ public class AccessExplainer
private final QInstance qInstance;
private final DataAccessService dataAccess;
private final UiApiGateway gateway;
@@ -60,9 +61,35 @@ public class AccessExplainer
**
*******************************************************************************/
public AccessExplainer(QInstance qInstance, DataAccessService dataAccess)
{
this(qInstance, dataAccess, null);
}
/*******************************************************************************
** With the gateway the explanations are asked through: the runtime hands
** over its own, which it has no public getter for.
*******************************************************************************/
public AccessExplainer(QInstance qInstance, DataAccessService dataAccess, UiApiGateway gateway)
{
this.qInstance = qInstance;
this.dataAccess = dataAccess;
this.gateway = gateway;
}
/*******************************************************************************
** Explain through the explainer's own gateway, as the person of a session.
*******************************************************************************/
public List<TableAccess> explain(String sessionUUID, UiApiCredentials credentials, PolicyService.ToolPolicy policy, String tableName, String prompt, String turnId) throws QException
{
if(gateway == null)
{
throw (new QException("This access explainer was made without a UI API gateway."));
}
return (explain(gateway, sessionUUID, credentials, policy, tableName, prompt, turnId));
}
@@ -20,7 +20,10 @@ package com.kingsrook.qbits.assistant.access;
/*******************************************************************************
** What the assistant may do with a table or process, on top of (never instead
** of) the user's own permissions: NONE, READ (read tools), READ_WRITE (read
** and write tools; processes: run).
** and write tools; processes: run). READ_WRITE grants nothing: it is "no
** restriction", the assistant may propose what the signed-in person may do
** themselves. It is the default, declared in code (AssistantQBitConfig); an
** administrator's row can only narrow it (see DataAccessService).
*******************************************************************************/
public enum DataAccessMode
{
@@ -30,26 +30,58 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** The assistant's data access per table and process: the admin's
** `assistant_data_access` row, else the default.
** What the assistant may do with a table or a process: NONE, READ or
** READ_WRITE. READ_WRITE grants nothing. It means the assistant may propose
** what the signed-in person may do themselves: the person's own permissions,
** record locks and validation still decide, in the UI API, and every change
** waits for that person's confirmation.
**
** Defaults:
** - tables whose AI exposure is NONE (the `assistant_*` tables, sessions,
** OAuth, and any table its metadata marks NONE): NONE;
** - other tables: READ (the assistant never writes unless an admin says so);
** - processes: NONE.
** The mode is declared in versioned code and can only be narrowed in the
** database. What decides, in this order (the first that applies ends it,
** except that an administrator's row narrows what the code says):
** 1. the assistant's own objects (every assistant_* table: its settings,
** budgets, policy, providers, conversations and logs; and its own
** processes) are NONE. Nothing changes that: no row, no host pin.
** 2. metadata: a table whose AI exposure is NONE is NONE.
** 3. code: the host's pin for the object
** ({@link AssistantQBitConfig#withTableAccess},
** {@link AssistantQBitConfig#withProcessAccess}), else the code default
** ({@link AssistantQBitConfig#withDefaultTableAccess},
** {@link AssistantQBitConfig#withDefaultProcessAccess}; READ_WRITE unless
** a host says otherwise).
** 4. the administrator's `assistant_data_access` row for the object, when it
** is more restrictive than 3. A row never raises a mode: a row that asks
** for more than the code allows changes nothing.
**
** The mode only narrows: every call still runs through the UI API with the
** user's own session.
** The rows are cached for policyCacheSeconds and dropped when one is saved.
** When they cannot be read, the last rows read stay in force; when none were
** ever read, every object is NONE until they can be (a restriction that
** cannot be read is not a restriction lifted).
*******************************************************************************/
public class DataAccessService
{
private static final QLogger LOG = QLogger.getLogger(DataAccessService.class);
public static final String SOURCE_OWN = "the assistant's own object (pinned in code; nothing can raise it)";
public static final String SOURCE_SENSITIVE = "sensitive table (AI exposure NONE)";
public static final String SOURCE_PIN = "pinned in code";
public static final String SOURCE_DEFAULT = "default";
public static final String SOURCE_SETTING = "administrator setting";
public static final String SOURCE_UNREADABLE = "the administrator settings could not be read";
public static final String CANNOT_RAISE = " (the administrator setting cannot raise it)";
///////////////////////////////////////////////////////////////////////////
// the fixed note the retired "Add rows for every table" action wrote on //
// its rows. The changeset assistant-data-access-follow-user removes the //
// READ rows that still carry it and were never edited (see UPGRADE.md) //
///////////////////////////////////////////////////////////////////////////
public static final String MATERIALISED_DEFAULT_NOTE = "Added by 'Add rows for every table': the assistant's default for this table. The user's own permissions still apply.";
private final QInstance qInstance;
private final AssistantQBitConfig config;
private final AssistantStore store;
@@ -57,6 +89,7 @@ public class DataAccessService
private final Clock clock;
private volatile Map<String, AssistantDataAccess> rows;
private volatile Map<String, AssistantDataAccess> lastRead;
private volatile Instant loadedAt;
@@ -76,7 +109,8 @@ public class DataAccessService
/*******************************************************************************
** A decision and where it came from.
** A decision and what decided it ("default", "pinned in code",
** "administrator setting", "sensitive table ...").
*******************************************************************************/
public record Decision(DataAccessMode mode, String source)
{
@@ -84,6 +118,54 @@ public class DataAccessService
/*******************************************************************************
** Whether the assistant may never use a table, whoever asks and whatever a
** row or a host pin says: the assistant's own tables (assistant_*: its
** policy, providers, models, prices, budgets and overrides, data access
** settings, profiles, skills, knowledge sources, scheduled tasks,
** conversations, messages, turns, tool calls, pending actions, usage and
** audits). The prefix is the pin, so a table the QBit gains later is
** covered the day it is added.
*******************************************************************************/
public static boolean isOwnTable(String tableName)
{
return (tableName != null && tableName.startsWith(AssistantStore.OWN_TABLE_PREFIX));
}
/*******************************************************************************
** Whether a process is the assistant's own (produced by this QBit, or
** working on one of its tables): the assistant never runs or sees it.
*******************************************************************************/
private boolean isOwnProcess(QProcessMetaData process)
{
QTableMetaData ownTable = qInstance.getTable(AssistantDataAccess.TABLE_NAME);
String ownSource = ownTable == null ? null : ownTable.getSourceQBitName();
return ((ownSource != null && ownSource.equals(process.getSourceQBitName())) || isOwnTable(process.getTableName()));
}
/*******************************************************************************
** Whether the data access of any table allows writes at all. It says
** nothing about a person: which write tools a person is offered also
** depends on what that person may change (see tools.UiApiToolFamily).
*******************************************************************************/
public boolean anyWritableTable()
{
for(String tableName : qInstance.getTables().keySet())
{
if(forTable(tableName).mode().allowsWrite())
{
return (true);
}
}
return (false);
}
/*******************************************************************************
** The mode for a table (unknown table: NONE).
*******************************************************************************/
@@ -94,40 +176,78 @@ public class DataAccessService
{
return (new Decision(DataAccessMode.NONE, "unknown table"));
}
if(isOwnTable(tableName))
{
return (new Decision(DataAccessMode.NONE, SOURCE_OWN));
}
AssistantDataAccess row = rows().get(key(AssistantDataAccess.TYPE_TABLE, tableName));
DataAccessMode set = row == null ? null : DataAccessMode.parse(row.getMode());
AiExposure exposure = exposurePolicy.getTableExposure(table);
Map<String, AssistantDataAccess> settings = rows();
AssistantDataAccess row = settings == null ? null : settings.get(key(AssistantDataAccess.TYPE_TABLE, tableName));
DataAccessMode set = row == null ? null : DataAccessMode.parse(row.getMode());
////////////////////////////////////////////////////////////////////
// a table whose AI exposure is NONE stays NONE: its metadata says //
// its values must never reach a model, whatever the row says //
// its values must never reach a model, whatever anything says //
////////////////////////////////////////////////////////////////////
if(exposure == AiExposure.NONE)
if(exposurePolicy.getTableExposure(table) == AiExposure.NONE)
{
return (new Decision(DataAccessMode.NONE, set != null && set != DataAccessMode.NONE ? "sensitive table (AI exposure NONE; the setting cannot raise it)" : "sensitive table (AI exposure NONE)"));
return (new Decision(DataAccessMode.NONE, SOURCE_SENSITIVE + (set != null && set != DataAccessMode.NONE ? CANNOT_RAISE : "")));
}
if(set != null)
{
return (new Decision(set, "setting"));
}
return (new Decision(DataAccessMode.READ, "default"));
DataAccessMode pinned = config.getTableAccess() == null ? null : config.getTableAccess().get(tableName);
return (decide(pinned, config.getDefaultTableAccess(), settings == null, row != null, set));
}
/*******************************************************************************
** The mode for a process (unknown process: NONE; default NONE).
** The mode for a process (unknown process: NONE). READ_WRITE means "may be
** proposed to run"; there is nothing to read of a process, so READ allows
** no more than NONE does.
*******************************************************************************/
public Decision forProcess(String processName)
{
if(processName == null || qInstance.getProcess(processName) == null)
QProcessMetaData process = processName == null ? null : qInstance.getProcess(processName);
if(process == null)
{
return (new Decision(DataAccessMode.NONE, "unknown process"));
}
AssistantDataAccess row = rows().get(key(AssistantDataAccess.TYPE_PROCESS, processName));
DataAccessMode set = row == null ? null : DataAccessMode.parse(row.getMode());
return (set == null ? new Decision(DataAccessMode.NONE, "default") : new Decision(set, "setting"));
if(isOwnProcess(process))
{
return (new Decision(DataAccessMode.NONE, SOURCE_OWN));
}
Map<String, AssistantDataAccess> settings = rows();
AssistantDataAccess row = settings == null ? null : settings.get(key(AssistantDataAccess.TYPE_PROCESS, processName));
DataAccessMode pinned = config.getProcessAccess() == null ? null : config.getProcessAccess().get(processName);
return (decide(pinned, config.getDefaultProcessAccess(), settings == null, row != null, row == null ? null : DataAccessMode.parse(row.getMode())));
}
/*******************************************************************************
** What the code allows (the pin, else the default), narrowed by the
** administrator's row. The more restrictive of the two wins; a row never
** raises. A row whose mode cannot be read is NONE: a restriction that
** cannot be understood is the strictest one.
*******************************************************************************/
private static Decision decide(DataAccessMode pinned, DataAccessMode codeDefault, boolean unreadable, boolean hasRow, DataAccessMode set)
{
if(unreadable)
{
return (new Decision(DataAccessMode.NONE, SOURCE_UNREADABLE));
}
DataAccessMode code = pinned != null ? pinned : (codeDefault == null ? DataAccessMode.NONE : codeDefault);
String source = pinned != null ? SOURCE_PIN : SOURCE_DEFAULT;
if(!hasRow)
{
return (new Decision(code, source));
}
DataAccessMode restricted = set == null ? DataAccessMode.NONE : set;
if(restricted.ordinal() < code.ordinal())
{
return (new Decision(restricted, SOURCE_SETTING));
}
return (new Decision(code, restricted.ordinal() > code.ordinal() ? source + CANNOT_RAISE : source));
}
@@ -144,7 +264,8 @@ public class DataAccessService
/*******************************************************************************
** The rows, cached for policyCacheSeconds. A read failure keeps the last
** rows; with none, every table falls back to its default.
** rows read; with none ever read it answers null, and every object is NONE
** until the rows can be read.
*******************************************************************************/
private Map<String, AssistantDataAccess> rows()
{
@@ -162,13 +283,14 @@ public class DataAccessService
loaded.put(key(row.getObjectType(), row.getObjectName()), row);
}
rows = loaded;
lastRead = loaded;
loadedAt = clock.instant();
return (loaded);
}
catch(QException | RuntimeException e)
{
LOG.warn("Could not read the assistant data access settings; using the defaults", e);
return (current == null ? Map.of() : current);
LOG.warn("Could not read the assistant data access settings; keeping the last ones read, or allowing nothing when there are none (fail closed)", e);
return (lastRead);
}
}
@@ -31,6 +31,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.SignedInPerson;
import com.kingsrook.qbits.assistant.access.AccessExplainer;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.attachments.AttachmentService;
@@ -44,6 +45,7 @@ import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.ProfileService;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.engine.RunningTurn;
import com.kingsrook.qbits.assistant.engine.ToolCallService;
import com.kingsrook.qbits.assistant.engine.TurnEngine;
import com.kingsrook.qbits.assistant.engine.TurnEvent;
import com.kingsrook.qbits.assistant.model.AssistantClientError;
@@ -51,30 +53,28 @@ import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.persistence.TranscriptRedactor;
import com.kingsrook.qbits.assistant.spi.ModelCapabilities;
import com.kingsrook.qbits.assistant.tools.AnalysisTools;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qbits.assistant.tools.registry.ActionKinds;
import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -96,6 +96,10 @@ public class AssistantApi
/////////////////////////////////////////////////////////////////////////////
public static final String NOT_CONFIGURED = "NOT_CONFIGURED";
public static final int MAX_LISTED_ACTIONS = 200;
public static final List<String> ACTION_STATUSES = List.of(AssistantPendingAction.STATUS_PENDING, AssistantPendingAction.STATUS_RUNNING, AssistantPendingAction.STATUS_EXECUTED,
AssistantPendingAction.STATUS_FAILED, AssistantPendingAction.STATUS_REJECTED, AssistantPendingAction.STATUS_EXPIRED);
public static final String NO_MODEL_PROVIDER_MESSAGE = "No model provider is configured on this server. An administrator adds an enabled provider on "
+ "Assistant > Providers (for example type Anthropic, whose API key variable ANTHROPIC_API_KEY must be set in the server environment, or a local "
+ "OpenAI-compatible server such as Ollama).";
@@ -108,37 +112,57 @@ public class AssistantApi
private final AssistantStore store;
private final QSession session;
private final String userId;
private UiApiCredentials credentials;
private SignedInPerson person;
/*******************************************************************************
** Binds the API to a request's session and gives the session its own
** owner key (so the record locks admit the user's own rows).
** Binds the API to a session with no HTTP request behind it (a process, a
** table customizer, a test) and gives the session its own owner key (so the
** record locks admit the user's own rows): enough for the user's own
** conversations.
**
** Such an API has no signed-in person until it needs one. What acts as the
** person through the UI API (a turn, a confirmation or cancellation, the
** starter prompts, the access explanation) first has the authentication
** module resolve the session's own id (SignedInPerson.ofSession), and is
** refused when that is not the user the session names.
*******************************************************************************/
public AssistantApi(AssistantRuntime runtime, QSession session)
{
this.runtime = runtime;
this.config = runtime.getConfig();
this.store = runtime.getStore();
this.session = session;
this.userId = AssistantSecurity.userId(session);
if(session != null)
{
AssistantSecurity.ownerKey(session, config);
}
this(runtime, session, null);
}
/*******************************************************************************
** The credentials of the request this API serves, exactly as they came in;
** the tools forward them to the UI API. Never logged.
** Binds the API to the signed-in person of a request: their session and the
** request's credentials, bound together by the route's session setup. The
** tools forward those credentials to the UI API. There is no way to give
** the API a session and credentials separately.
*******************************************************************************/
public AssistantApi withCredentials(UiApiCredentials credentials)
public AssistantApi(AssistantRuntime runtime, SignedInPerson person)
{
this.credentials = credentials;
return (this);
this(runtime, person.session(), person);
}
/*******************************************************************************
**
*******************************************************************************/
private AssistantApi(AssistantRuntime runtime, QSession session, SignedInPerson person)
{
this.runtime = runtime;
this.config = runtime.getConfig();
this.store = runtime.getStore();
this.session = session;
this.person = person;
this.userId = AssistantSecurity.userId(session);
if(session != null)
{
AssistantSecurity.ownerKey(session, config);
}
}
@@ -196,7 +220,7 @@ public class AssistantApi
limits.put("attachmentTypes", AttachmentService.typeLabels(reason == null && visionModel()));
}
result.put("limits", limits);
result.put("features", Map.of("writes", runtime.getToolbox().anyWritableTable(), "pendingActions", true, "attachments", attachments));
result.put("features", Map.of("writes", reason == null && offeredWrites(), "pendingActions", true, "attachments", attachments));
ProfileService.Persona persona = runtime.getProfileService().persona(session, null);
Map<String, Object> profile = new LinkedHashMap<>();
profile.put("displayName", persona.displayName());
@@ -217,6 +241,28 @@ public class AssistantApi
/*******************************************************************************
** Whether this person is offered a record write tool now: true only for a
** person who may create, edit or delete records of some table themselves
** (and whom the policy and the data access let the assistant propose it
** for). It is the same question the tool-call service answers when the
** model's tool list is made, so the panel is told what is true for this
** person. Never throws: a person who cannot be confirmed is offered none.
*******************************************************************************/
private boolean offeredWrites()
{
try
{
return (runtime.getToolCalls().offersAny(person(), UiApiToolbox.WRITE_TOOLS));
}
catch(AssistantApiException | RuntimeException e)
{
return (false);
}
}
/*******************************************************************************
** This person's own budgets with what they have used (BudgetService.own):
** their own spending against their own limits, never a shared budget's count
@@ -251,6 +297,38 @@ public class AssistantApi
/*******************************************************************************
** The signed-in person this API acts as through the UI API. An API bound to
** a session alone resolves the session's own id first, once.
*******************************************************************************/
private SignedInPerson person() throws AssistantApiException
{
if(person == null)
{
if(session == null)
{
throw (new AssistantApiException(403, "NOT_ALLOWED", NO_PERMISSION_MESSAGE));
}
try
{
person = SignedInPerson.ofSession(runtime.getQInstance(), session);
///////////////////////////////////////////////////////////////////
// the person is the session the authentication module resolved, //
// not the object this API was given: it gets its owner key here //
///////////////////////////////////////////////////////////////////
AssistantSecurity.ownerKey(person.session(), config);
}
catch(IllegalArgumentException e)
{
LOG.warn("An assistant request's session could not be confirmed as its user; refusing to act as that user", logPair("reason", e.getMessage()));
throw (new AssistantApiException(403, "NOT_ALLOWED", "This session could not be confirmed as a signed-in user."));
}
}
return (person);
}
/*******************************************************************************
** The example prompts for this user (see StarterPrompts): built from the
** tables the user may read, plus the configured ones they may see. Never
@@ -260,12 +338,14 @@ public class AssistantApi
{
try
{
PolicyService.EffectivePolicy policy = runtime.getPolicyService().current(false);
UiApiToolbox.Context context = new UiApiToolbox.Context(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), runtime.getUiApiGateway(), policy.tools(), null, credentials);
boolean historyOffered = runtime.getToolbox().definitions(policy.tools(), false).stream().anyMatch(d -> UiApiToolbox.RECORD_HISTORY.equals(d.getName()));
return (runtime.getStarterPrompts().forUser(context, userId, persona.starterPrompts(), historyOffered));
///////////////////////////////////////////////////////////////////////
// the reads behind the prompts are the assistant's own: the service //
// makes their context for this person, after its own checks //
///////////////////////////////////////////////////////////////////////
return (runtime.getStarterPrompts().forUser(runtime.getToolCalls().readContext(person(), null), userId, persona.starterPrompts(),
runtime.getToolCalls().offers(person(), UiApiToolbox.RECORD_HISTORY)));
}
catch(QException | RuntimeException e)
catch(QException | AssistantApiException | RuntimeException e)
{
return (List.of());
}
@@ -463,6 +543,11 @@ public class AssistantApi
public RunningTurn startTurn(Integer conversationId, Map<String, Object> body) throws QException, AssistantApiException
{
requireAccess();
///////////////////////////////////////////////////////////////////////
// a turn runs tools as the signed-in person: without one (a session //
// that is not its user's) nothing is stored and no turn starts //
///////////////////////////////////////////////////////////////////////
SignedInPerson turnPerson = person();
if(runtime.isShuttingDown())
{
throw (new AssistantApiException(503, "SHUTTING_DOWN", "The server is restarting. Try again in a moment."));
@@ -505,6 +590,16 @@ public class AssistantApi
throw (new AssistantApiException(429, "BUDGET", "The assistant has reached this provider's monthly cost cap. Ask an administrator."));
}
///////////////////////////////////////////////////////////////////////
// nobody is answered while no spending limit is in force for them: //
// refused here, before anything is stored and before any model call, //
// until an administrator restores a limit (read fresh every time) //
///////////////////////////////////////////////////////////////////////
if(!runtime.getBudgetService().limitInForce(userId, active, policy.budgets()))
{
throw (new AssistantApiException(429, "BUDGET", BudgetService.NO_LIMIT_MESSAGE));
}
PolicyService.BudgetStatus budget = runtime.getPolicyService().checkBudget(policy, userId, 0, 0);
if(budget.hardExceeded())
{
@@ -591,8 +686,8 @@ public class AssistantApi
turn.setAssistantMessageId(assistantMessage.getId());
RunningTurn runningTurn = new RunningTurn(turn.getId(), userId);
TurnEngine engine = new TurnEngine(runtime, session, userId, conversation, userMessage, assistantMessage, turn, text, pageContext, policy, runningTurn,
budget.softExceeded() ? budget.message() : null).withCredentials(credentials).withTranscript(transcript);
TurnEngine engine = new TurnEngine(runtime, turnPerson.session(), userId, conversation, userMessage, assistantMessage, turn, text, pageContext, policy, runningTurn,
budget.softExceeded() ? budget.message() : null).withPerson(turnPerson).withTranscript(transcript);
runtime.register(runningTurn);
if(!runningTurn.isFinalized())
{
@@ -978,102 +1073,71 @@ public class AssistantApi
public Map<String, Object> getAction(Integer id) throws QException, AssistantApiException
{
requireAccess();
return (toJson(expireIfDue(requireAction(id))));
return (describe(expireIfDue(requireAction(id))));
}
/*******************************************************************************
** POST /actions/{id}/confirm: run the proposed write through the UI API
** with THIS request's session (the user's own), then record the result and
** a tool-call log row. A write is never run twice.
** GET /actions?status=&conversationId=: the user's own pending actions,
** newest first, of one status (PENDING for what still waits for them) and
** of one conversation when asked. Expiry is evaluated as they are read: an
** action past its time is marked, and reported, as EXPIRED.
*******************************************************************************/
public Map<String, Object> listActions(String status, Integer conversationId) throws QException, AssistantApiException
{
requireAccess();
String wanted = StringUtils.hasContent(status) ? status.trim().toUpperCase(Locale.ROOT) : null;
if(wanted != null && !ACTION_STATUSES.contains(wanted))
{
throw (new AssistantApiException(400, "Unknown status. Use one of " + String.join(", ", ACTION_STATUSES) + "."));
}
List<Map<String, Object>> list = new ArrayList<>();
for(AssistantPendingAction action : store.listOwnPendingActions(userId, wanted, conversationId, MAX_LISTED_ACTIONS))
{
action = expireIfDue(action);
if(wanted == null || wanted.equals(action.getStatus()))
{
list.add(describe(action));
}
}
return (Map.of("actions", list));
}
/*******************************************************************************
** POST /actions/{id}/confirm: have the tool-call service run the proposed
** write with THIS request's person (the user's own session), then put the
** result into the conversation. The service does the rest itself: it loads
** the stored action of this user, checks it, claims it so it never runs
** twice, runs it, marks it and writes its tool-call log row.
*******************************************************************************/
public Map<String, Object> confirmAction(Integer id) throws QException, AssistantApiException
{
requireAccess();
AssistantPendingAction action = expireIfDue(requireAction(id));
if(!AssistantPendingAction.STATUS_PENDING.equals(action.getStatus()))
{
throw (new AssistantApiException(409, "This action is already " + action.getStatus().toLowerCase() + "."));
}
AssistantTurn proposingTurn = store.getTurn(userId, action.getTurnId());
if(proposingTurn != null && isRunning(proposingTurn))
{
throw (new AssistantApiException(409, "Wait for the assistant to finish before confirming this action."));
}
///////////////////////////////////////////////////////////////////////
// claim it first, so a double click cannot run the write twice //
///////////////////////////////////////////////////////////////////////
Instant now = runtime.getClock().instant();
store.updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", "RUNNING").withValue("decidedAt", now));
AssistantPendingAction claimed = store.getPendingAction(userId, id);
if(claimed == null || !"RUNNING".equals(claimed.getStatus()))
{
throw (new AssistantApiException(409, "This action is already being handled."));
}
Map<String, Object> values;
ToolCallService.Confirmation confirmation;
try
{
values = action.getParamsJson() == null ? Map.of() : JsonUtils.toObject(action.getParamsJson(), new TypeReference<Map<String, Object>>() {});
confirmation = runtime.getToolCalls().confirm(person(), id);
}
catch(Exception e)
catch(ToolCallService.ConfirmationRefused e)
{
throw (new QException("The stored values of action " + id + " are not valid JSON", e));
throw (switch(e.getReason())
{
case NOT_FOUND -> new AssistantApiException(404, e.getMessage());
case NOT_PENDING, TURN_RUNNING, ALREADY_HANDLED -> new AssistantApiException(409, e.getMessage());
case KILL_SWITCH -> new AssistantApiException(503, "KILL_SWITCH", KILL_SWITCH_MESSAGE);
case NO_ACCESS, NOT_ALLOWED -> new AssistantApiException(403, "NOT_ALLOWED", e.getMessage());
});
}
PolicyService.EffectivePolicy policy = runtime.getPolicyService().current(true);
UiApiToolbox.Context context = new UiApiToolbox.Context(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), runtime.getUiApiGateway(), policy.tools(), null, credentials, action.getTurnId() == null ? null : String.valueOf(action.getTurnId()));
UiApiToolbox.Outcome outcome;
if(BulkLoadTools.WRITE_TOOLS.contains(action.getToolName()))
{
outcome = runtime.getBulkLoadTools().executeConfirmed(context, action.getToolCallId(), action.getToolName(), action.getTableName(), values);
}
else if(AnalysisTools.WRITE_TOOLS.contains(action.getToolName()))
{
///////////////////////////////////////////////////////////////////////
// a saved analysis and its schedule: created as, and emailed to, the //
// user who confirms (this request's session) //
///////////////////////////////////////////////////////////////////////
outcome = runtime.getToolbox().executeConfirmedAnalysis(context, action.getToolCallId(), action.getToolName(), values, userId);
}
else
{
outcome = runtime.getToolbox().executeConfirmedWrite(context, action.getToolCallId(), action.getToolName(), action.getTableName(), action.getRecordId(), values);
}
AiToolResult result = outcome.getResult();
boolean ok = result.getOutcome() == AiToolOutcome.OK;
store.updateFields(AssistantPendingAction.TABLE_NAME, new QRecord()
.withValue("id", id)
.withValue("status", ok ? AssistantPendingAction.STATUS_EXECUTED : AssistantPendingAction.STATUS_FAILED)
.withValue("httpStatus", outcome.getHttpStatus())
.withValue("resultSummary", truncate(result.getSummary(), 500)));
AssistantPendingAction action = confirmation.action();
UiApiToolbox.Outcome outcome = confirmation.outcome();
AiToolResult result = outcome.getResult();
reportDecision(action, result, result.getOutcome() == AiToolOutcome.OK);
store.insert(new AssistantToolCall()
.withTurnId(action.getTurnId())
.withClientChannel("assistant" + (action.getTurnId() == null ? "" : "/" + action.getTurnId()))
.withConversationId(action.getConversationId())
.withUserId(userId)
.withToolCallId(action.getToolCallId())
.withToolName(action.getToolName())
.withInputJson(truncate(new AiRedactor(List.of()).redact(action.getParamsJson() == null ? "{}" : action.getParamsJson(), new HashSet<>()), 4000))
.withOutcome(result.getOutcome() == null ? AiToolOutcome.ERROR.name() : result.getOutcome().name())
.withPermissionCheck(result.getPermissionCheck())
.withTableName(action.getTableName())
.withRowCount(result.getRowCount())
.withDurationMs(result.getDurationMs())
.withResultSummary(truncate("Confirmed: " + result.getSummary(), 500))
.withResultJson(result.getModelContent())
.withUiApiOperation(outcome.getOperation())
.withHttpStatus(outcome.getHttpStatus())
.withAuditHref(outcome.getAuditHref())
.withPendingActionId(id));
reportDecision(action, result, ok);
Map<String, Object> rs = toJson(store.getPendingAction(userId, id));
Map<String, Object> rs = describe(action);
rs.put("outcome", result.getOutcome() == null ? null : result.getOutcome().name());
rs.put("message", result.getModelContent());
if(outcome.getAuditHref() != null)
@@ -1152,14 +1216,19 @@ public class AssistantApi
public Map<String, Object> rejectAction(Integer id) throws QException, AssistantApiException
{
requireAccess();
AssistantPendingAction action = expireIfDue(requireAction(id));
if(!AssistantPendingAction.STATUS_PENDING.equals(action.getStatus()))
try
{
throw (new AssistantApiException(409, "This action is already " + action.getStatus().toLowerCase() + "."));
return (describe(runtime.getToolCalls().cancel(person(), id)));
}
catch(ToolCallService.ConfirmationRefused e)
{
throw (switch(e.getReason())
{
case NOT_FOUND -> new AssistantApiException(404, e.getMessage());
case NO_ACCESS, NOT_ALLOWED -> new AssistantApiException(403, "NOT_ALLOWED", e.getMessage());
default -> new AssistantApiException(409, e.getMessage());
});
}
store.updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", AssistantPendingAction.STATUS_REJECTED)
.withValue("decidedAt", runtime.getClock().instant()).withValue("resultSummary", "Cancelled by the user; nothing changed."));
return (toJson(store.getPendingAction(userId, id)));
}
@@ -1180,18 +1249,15 @@ public class AssistantApi
/*******************************************************************************
** Mark a pending action past its expiry as EXPIRED.
** The action as it is now, expired first when its time has passed. Expiry
** is decided by the tool-call service, through the same single decision as
** a confirmation or a cancellation: an action another request has just
** confirmed is never marked EXPIRED, and comes back as what it really is.
*******************************************************************************/
private AssistantPendingAction expireIfDue(AssistantPendingAction action) throws QException
{
if(AssistantPendingAction.STATUS_PENDING.equals(action.getStatus()) && action.getExpiresAt() != null && action.getExpiresAt().isBefore(runtime.getClock().instant()))
{
store.updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", action.getId()).withValue("status", AssistantPendingAction.STATUS_EXPIRED)
.withValue("resultSummary", "Expired before it was confirmed; nothing changed."));
action.setStatus(AssistantPendingAction.STATUS_EXPIRED);
action.setResultSummary("Expired before it was confirmed; nothing changed.");
}
return (action);
AssistantPendingAction current = runtime.getToolCalls().expiredIfDue(userId, action);
return (current == null ? action : current);
}
@@ -1205,17 +1271,92 @@ public class AssistantApi
map.put("id", action.getId());
map.put("status", action.getStatus());
map.put("toolName", action.getToolName());
map.put("actionKind", actionKind(action));
map.put("conversationId", action.getConversationId());
map.put("tableName", action.getTableName());
map.put("recordId", action.getRecordId());
map.put("summary", action.getSummary());
map.put("httpStatus", action.getHttpStatus());
map.put("resultSummary", action.getResultSummary());
map.put("createdAt", action.getCreateDate() == null ? null : action.getCreateDate().toString());
map.put("expiresAt", action.getExpiresAt() == null ? null : action.getExpiresAt().toString());
map.put("decidedAt", action.getDecidedAt() == null ? null : action.getDecidedAt().toString());
return (map);
}
/*******************************************************************************
** What a pending action proposes. One stored before kinds were kept is a
** record change by its tool's name, or else the tool's name.
*******************************************************************************/
static String actionKind(AssistantPendingAction action)
{
if(StringUtils.hasContent(action.getActionKind()))
{
return (action.getActionKind());
}
return (switch(String.valueOf(action.getToolName()))
{
case UiApiToolbox.CREATE_RECORD -> ActionKinds.RECORD_CREATE;
case UiApiToolbox.UPDATE_RECORD -> ActionKinds.RECORD_UPDATE;
case UiApiToolbox.DELETE_RECORD -> ActionKinds.RECORD_DELETE;
default -> action.getToolName();
});
}
/*******************************************************************************
** A pending action as its owner's card needs it: its state, and for a
** change to one record the table's label, a link to the record, and one
** line per field (an update: the value before and after; a create: the
** value; a delete: what the record held). The values before are what was
** stored with the proposal, after the exposure rules of get_record.
*******************************************************************************/
private Map<String, Object> describe(AssistantPendingAction action)
{
Map<String, Object> map = toJson(action);
String kind = actionKind(action);
if(!ActionKinds.isRecordWrite(kind) || action.getTableName() == null)
{
return (map);
}
QTableMetaData table = runtime.getQInstance().getTable(action.getTableName());
map.put("tableLabel", table == null || table.getLabel() == null ? action.getTableName() : table.getLabel());
if(action.getRecordId() != null)
{
map.put("recordHref", AiLinkBuilder.recordHref(action.getTableName(), action.getRecordId()));
}
Map<String, Object> values = storedObject(action.getParamsJson());
Map<String, Object> before = action.getBeforeJson() == null ? null : storedObject(action.getBeforeJson());
map.put("changes", ActionKinds.changes(table, values == null ? Map.of() : values, ActionKinds.RECORD_UPDATE.equals(kind) ? before : null));
if(ActionKinds.RECORD_DELETE.equals(kind))
{
map.put("before", ActionKinds.fieldLines(table, before));
}
return (map);
}
/*******************************************************************************
** A stored JSON object, or null when it cannot be read.
*******************************************************************************/
private static Map<String, Object> storedObject(String json)
{
try
{
return (json == null ? null : JsonUtils.toObject(json, new TypeReference<LinkedHashMap<String, Object>>() {}));
}
catch(Exception e)
{
return (null);
}
}
/*******************************************************************************
** GET /access?table=&prompt=: "explain access" for the calling user: per
** table, the user's UI permissions (GET /qqq/v1/metaData with their own
@@ -1240,7 +1381,7 @@ public class AssistantApi
requireTurn(turnId);
}
List<Map<String, Object>> tables = new ArrayList<>();
for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), credentials, runtime.getPolicyService().current(false).tools(), table, prompt, turnId == null ? null : String.valueOf(turnId)))
for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(session.getUuid(), person().credentials(), runtime.getPolicyService().current(false).tools(), table, prompt, turnId == null ? null : String.valueOf(turnId)))
{
if(!access.uiVisible() && access.mode() == DataAccessMode.NONE)
{
@@ -22,12 +22,14 @@ import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.SignedInPerson;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.api.AssistantApiException;
import com.kingsrook.qbits.assistant.api.TurnStreams;
import com.kingsrook.qbits.assistant.engine.RunningTurn;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.ExceptionUtils;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -36,7 +38,6 @@ import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
@@ -63,7 +64,7 @@ import io.javalin.http.HttpStatus;
public final class AssistantSpecs
{
public static final String EXTENSION_NAME = "assistant";
public static final String EXTENSION_VERSION = "1.8.0";
public static final String EXTENSION_VERSION = "1.9.0";
public static final String PREFIX = "/assistant";
@@ -98,6 +99,7 @@ public final class AssistantSpecs
.withSpec(CancelTurnSpec.class)
.withSpec(ActiveTurnsSpec.class)
.withSpec(FeedbackSpec.class)
.withSpec(ListActionsSpec.class)
.withSpec(GetActionSpec.class)
.withSpec(ConfirmActionSpec.class)
.withSpec(RejectActionSpec.class)
@@ -204,7 +206,8 @@ public final class AssistantSpecs
"model", string("The model that answers."),
"limits", freeObject("maxTurnSeconds and maxToolCallsPerTurn. When features.attachments is true also maxAttachmentBytes (the largest file read, in bytes), "
+ "maxAttachments (files per question) and attachmentTypes (human-readable type names for the file picker hint; images only when the model reads them)."),
"features", freeObject("writes: some table allows writes; pendingActions: writes wait for confirmation; attachments: files uploaded with POST /qqq/v1/uploads "
"features", freeObject("writes: this user is offered a record write tool, which they are when they may create, edit or delete records of some table themselves; "
+ "pendingActions: writes wait for confirmation; attachments: files uploaded with POST /qqq/v1/uploads "
+ "and sent as pageContext.attachments [{uploadRef, fileName}] are read for the question."),
"profile", ref("AssistantProfileV1"),
"conversationsTable", string("Since 1.6.0: the table name of the user's conversations (assistant_conversation) for the standard table UI "
@@ -246,12 +249,21 @@ public final class AssistantSpecs
"occurredAt", string("ISO-8601 instant.").withFormat("date-time").withNullable(true)));
rs.put("AssistantClientErrorResponseV1", object("Whether the report was stored: false when the user's reports are over the per-minute limit (still 202).",
"accepted", bool(null)));
rs.put("AssistantActionV1", object("A write the assistant proposed and its state.",
rs.put("AssistantActionV1", object("A change the assistant proposed and its state.",
"id", integer(null), "status", string("PENDING, RUNNING, EXECUTED, FAILED, REJECTED or EXPIRED."), "toolName", string(null), "tableName", string(null),
"recordId", string(null).withNullable(true), "summary", string(null), "httpStatus", integer("The UI API status of the write.").withNullable(true),
"resultSummary", string(null).withNullable(true), "expiresAt", string(null).withNullable(true),
"actionKind", string("What is proposed: record_create, record_update, record_delete, or the proposing tool's name. A client draws its plain card for a kind it does not know.").withNullable(true),
"conversationId", integer("The conversation the change was proposed in, if any.").withNullable(true),
"createdAt", string("ISO-8601.").withNullable(true), "decidedAt", string("ISO-8601.").withNullable(true),
"tableLabel", string("A record change: the table's label.").withNullable(true),
"recordHref", string("A record change to an existing record: its /app link.").withNullable(true),
"changes", array(freeObject("A record create or update, one per field written: field, label, value (after) and, for an update, before (the value when the change was proposed, as the user may see it)."), null).withNullable(true),
"before", array(freeObject("A record delete, one per field the user may see: field, label, value (when the delete was proposed)."), null).withNullable(true),
"outcome", string("After a confirm: the tool outcome.").withNullable(true), "message", string("After a confirm: what happened.").withNullable(true),
"href", string("After a confirm: the written record's /app link.").withNullable(true)));
rs.put("AssistantActionListResponseV1", object("The user's own pending actions, newest first.",
"actions", array(ref("AssistantActionV1"), null)));
rs.put("AssistantAccessResponseV1", object("Explain access: per table, the user's UI permissions, the assistant's data access, and each tool's verdict.",
"tables", array(freeObject("table, label, dataAccess, dataAccessSource, ui {visible, read, insert, edit, delete}, operations [{tool, operation, allowed, reason}]."), null)));
rs.put("AssistantActiveTurnsResponseV1", object("The caller's active runs, newest first; stale leases are reaped.",
@@ -428,7 +440,13 @@ public final class AssistantSpecs
////////////////////////////////////
}
}
AssistantApi api = new AssistantApi(runtime, QContext.getQSession()).withCredentials(context == null ? null : UiApiCredentials.fromContext(context));
///////////////////////////////////////////////////////////////////////
// the one place a request's session and its credentials are bound: //
// the session this route's session setup resolved, with the //
// credentials of that same request //
///////////////////////////////////////////////////////////////////////
QSession session = QContext.getQSession();
AssistantApi api = session == null || context == null ? new AssistantApi(runtime, session) : new AssistantApi(runtime, SignedInPerson.ofRequest(context));
return (handle(api, input));
}
@@ -1257,6 +1275,69 @@ public final class AssistantSpecs
/*******************************************************************************
** GET /assistant/actions
*******************************************************************************/
public static class ListActionsSpec extends AssistantJsonSpec
{
@Override
protected HttpMethod method()
{
return (HttpMethod.GET);
}
@Override
protected String path()
{
return (PREFIX + "/actions");
}
@Override
protected String summary()
{
return ("The user's own pending actions");
}
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of(queryParam("status", Type.STRING, "Only actions of this status: PENDING for the ones still waiting for the user. Every status by default."),
queryParam("conversationId", Type.INTEGER, "Only the actions proposed in this conversation.")));
}
@Override
protected String getResponseSchemaName()
{
return ("AssistantActionListResponseV1");
}
@Override
protected Object handle(AssistantApi api, JsonInput input) throws Exception
{
String conversationId = input.getQueryParam("conversationId");
try
{
return (api.listActions(input.getQueryParam("status"), StringUtils.hasContent(conversationId) ? Integer.valueOf(conversationId.trim()) : null));
}
catch(NumberFormatException e)
{
throw (new QBadRequestException("conversationId must be a number."));
}
}
}
/*******************************************************************************
** GET /assistant/actions/{id}
*******************************************************************************/
@@ -17,6 +17,7 @@
package com.kingsrook.qbits.assistant.engine;
import com.kingsrook.qbits.assistant.AssistantBudgets;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantBudgetEvent;
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
@@ -54,6 +55,11 @@ import java.util.Objects;
**
** Periods are UTC: a DAY starts at 00:00 UTC, a WEEK on Monday at 00:00 UTC
** (the ISO week) and a MONTH on the first of the month at 00:00 UTC.
**
** The assistant answers nobody for whom no spending limit is in force
** ({@link #limitInForce}): a turn, a tool call and an embedding request are
** refused before any model is called, until an administrator restores a
** limit. It applies to the next request, with no restart.
*******************************************************************************/
public class BudgetService
{
@@ -63,6 +69,8 @@ public class BudgetService
public static final List<String> PERIODS = List.of(PERIOD_DAY, PERIOD_WEEK, PERIOD_MONTH);
public static final String NO_LIMIT_MESSAGE = "The assistant is not answering because no spending limit is in force. An administrator must set or switch on a budget (Assistant > Budgets) before it can be used.";
private static final DateTimeFormatter RESET_DAY = DateTimeFormatter.ofPattern("EEEE d MMMM uuuu", Locale.ENGLISH);
private final AssistantStore store;
@@ -117,6 +125,91 @@ public class BudgetService
/*******************************************************************************
** Whether a budget row is a limit at all: switched on (the store reads only
** those) with a limit greater than zero. A row that is switched off, or
** whose limit is zero, negative or missing, limits nothing: it is not in
** force, it is never evaluated, and it does not count as a spending limit.
*******************************************************************************/
public static boolean isLimit(AssistantBudget budget)
{
return (budget != null && !Boolean.FALSE.equals(budget.getEnabled()) && budget.getLimitValue() != null && budget.getLimitValue().signum() > 0);
}
/*******************************************************************************
** The budget rows that are limits (see {@link #isLimit}), read now.
*******************************************************************************/
private List<AssistantBudget> limits() throws QException
{
List<AssistantBudget> rs = new ArrayList<>();
for(AssistantBudget budget : store.budgets())
{
if(isLimit(budget))
{
rs.add(budget);
}
}
return (rs);
}
/*******************************************************************************
** Whether a spending limit is in force for a person now, for the provider
** and model their request would use. One is, when either holds:
** - a hard limit greater than zero is declared in the code configuration or
** on the policy row: the person's own (userDailyTokensHard,
** userDailyCostMicrosHard) or the shared one (instanceMonthlyTokensHard,
** instanceMonthlyCostMicrosHard). A soft limit only warns, so it is not a
** spending limit;
** - a budget row is a limit ({@link #isLimit}: switched on, limit above
** zero) and applies to the person: the shared ones (INSTANCE; PROVIDER or
** MODEL for the provider or model in use) or the person's own (USER for
** them; ROLE for a permission they hold now).
**
** What does not put a limit in force: a row that is switched off, a row
** with a zero limit, a row for somebody else or for a permission the person
** does not hold, a soft limit alone. A budget row has no end date: its
** count starts again each period and it stays in force. An override is an
** administrator's dated exception to a budget that stays in force: an
** override that has run out changes nothing, and one that is running
** (more allowance, or no limit until a time) is that budget's decision for
** as long as it runs, never past its required expiry.
**
** Asked with the person's session in context (a ROLE budget is matched
** against their permissions). It only reads.
*******************************************************************************/
public boolean limitInForce(String userId, ProviderService.Active active, AssistantBudgets configured) throws QException
{
if(configured != null && (positive(configured.getUserDailyTokensHard()) || positive(configured.getUserDailyCostMicrosHard())
|| positive(configured.getInstanceMonthlyTokensHard()) || positive(configured.getInstanceMonthlyCostMicrosHard())))
{
return (true);
}
for(AssistantBudget budget : limits())
{
if(applies(budget, userId, active))
{
return (true);
}
}
return (false);
}
/*******************************************************************************
**
*******************************************************************************/
private static boolean positive(Long limit)
{
return (limit != null && limit > 0);
}
/*******************************************************************************
** Evaluate all applicable budgets. STOP wins; a downgrade is checked against
** the original and target scopes so switching cannot evade another budget.
@@ -125,7 +218,7 @@ public class BudgetService
{
List<String> notices = new ArrayList<>();
ProviderService.Active selected = active;
List<AssistantBudget> budgets = store.budgets();
List<AssistantBudget> budgets = limits();
List<Integer> applied = new ArrayList<>();
List<Snapshot> pending = new ArrayList<>();
List<String> messages = new ArrayList<>();
@@ -217,7 +310,7 @@ public class BudgetService
{
return (rs);
}
for(AssistantBudget budget : store.budgets())
for(AssistantBudget budget : limits())
{
boolean mine = switch(budget.getScopeType())
{
@@ -235,6 +328,30 @@ public class BudgetService
/*******************************************************************************
** What a person is told when a budget that applies to them (and to this
** provider and model, when there is one) has stopped them, or null. It only
** reads: no budget event is stored and no alert raised, so asking does not
** take the warning or the stop away from the model call that reports it.
*******************************************************************************/
public String stoppedReason(String userId, ProviderService.Active active) throws QException
{
for(AssistantBudget budget : limits())
{
if(applies(budget, userId, active))
{
Snapshot snapshot = snapshot(budget, userId);
if("STOPPED".equals(snapshot.state()))
{
return (stoppedMessage(snapshot));
}
}
}
return (null);
}
/*******************************************************************************
** One of a person's own budgets as a sentence: what is used, of how much, and
** when the count starts again.
@@ -397,13 +514,12 @@ public class BudgetService
// Shared accounting contains no conversation or request data.
}
}
BigDecimal used = BigDecimal.ZERO;
for(AssistantUsage usage : store.query(shared ? AssistantBudgetUsage.TABLE_NAME : AssistantUsage.TABLE_NAME, filter, AssistantUsage.class))
{
used = used.add("COST_USD".equals(budget.getMetric()) ? BigDecimal.valueOf(nz(usage.getCostMicros()), 6)
: BigDecimal.valueOf(nz(usage.getInputTokens())).add(BigDecimal.valueOf(nz(usage.getOutputTokens())))
.add(BigDecimal.valueOf(nz(usage.getCacheReadTokens()))).add(BigDecimal.valueOf(nz(usage.getCacheWriteTokens()))));
}
///////////////////////////////////////////////////////////////////////
// one aggregate query, whatever the number of usage rows: this runs //
// before every model call and every tool call //
///////////////////////////////////////////////////////////////////////
AssistantStore.UsageTotals totals = store.sumUsage(shared ? AssistantBudgetUsage.TABLE_NAME : AssistantUsage.TABLE_NAME, filter);
BigDecimal used = "COST_USD".equals(budget.getMetric()) ? BigDecimal.valueOf(totals.costMicros(), 6) : BigDecimal.valueOf(totals.tokens());
BigDecimal limit = budget.getLimitValue();
boolean unlimited = false;
List<AssistantBudgetOverride> overrides = new ArrayList<>();
@@ -424,8 +540,13 @@ public class BudgetService
limit = limit.add(override.getExtraValue());
}
}
BigDecimal percent = unlimited ? BigDecimal.ZERO : used.multiply(BigDecimal.valueOf(100)).divide(limit, 2, RoundingMode.DOWN);
String state = unlimited ? "UNLIMITED" : percent.compareTo(BigDecimal.valueOf(budget.getHardCeilingPercent())) >= 0 ? "STOPPED"
///////////////////////////////////////////////////////////////////////
// a row whose limit is not above zero limits nothing (isLimit); it //
// is only ever shown, never evaluated //
///////////////////////////////////////////////////////////////////////
boolean noLimit = limit == null || limit.signum() <= 0;
BigDecimal percent = unlimited || noLimit ? BigDecimal.ZERO : used.multiply(BigDecimal.valueOf(100)).divide(limit, 2, RoundingMode.DOWN);
String state = unlimited ? "UNLIMITED" : noLimit ? "NO_LIMIT" : percent.compareTo(BigDecimal.valueOf(budget.getHardCeilingPercent())) >= 0 ? "STOPPED"
: percent.compareTo(BigDecimal.valueOf(100)) >= 0 ? ("STOP".equals(budget.getAtLimitAction()) ? "STOPPED" : "DOWNGRADED")
: percent.compareTo(BigDecimal.valueOf(budget.getWarnPercent())) >= 0 ? "WARNED" : "OK";
return (new Snapshot(budget, scopeKey, from, used, unlimited ? null : limit, percent, state, overrides));
@@ -25,7 +25,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
@@ -35,11 +34,8 @@ import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.spi.SystemBlock;
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
import com.kingsrook.qbits.assistant.tools.PresentResultsTool;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolDefinition;
import com.kingsrook.qbits.assistant.tools.registry.ToolOffer;
import com.kingsrook.qbits.assistant.tools.registry.ToolRegistry;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -47,6 +43,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
@@ -76,10 +73,8 @@ public class ContextBuilder
private final AssistantQBitConfig config;
private final AiToolKernel kernel;
private final UiApiToolbox toolbox;
private final ToolRegistry registry;
private volatile String appDigest;
private KnowledgeTool knowledgeTool;
private BulkLoadTools bulkLoadTools;
private BudgetService budgets;
@@ -87,11 +82,11 @@ public class ContextBuilder
/*******************************************************************************
**
*******************************************************************************/
public ContextBuilder(AssistantQBitConfig config, AiToolKernel kernel, UiApiToolbox toolbox)
public ContextBuilder(AssistantQBitConfig config, AiToolKernel kernel, ToolRegistry registry)
{
this.config = config;
this.kernel = kernel;
this.toolbox = toolbox;
this.registry = registry;
}
@@ -107,9 +102,10 @@ public class ContextBuilder
/*******************************************************************************
** The tools a policy allows, sorted by name (a stable cache prefix): the UI
** API data tools (write tools only when some table allows writes) plus the
** kernel's navigate and search_help.
** The tools a policy allows, sorted by name (a stable cache prefix): every
** tool of the registry that is offered now (the UI API data tools, with
** write tools only when the person in context may change some table
** themselves, the kernel's navigate and search_help, and the rest).
*******************************************************************************/
public List<ModelToolDefinition> tools(PolicyService.ToolPolicy policy)
{
@@ -119,51 +115,24 @@ public class ContextBuilder
/*******************************************************************************
** Attribute tool-discovery requests to the turn that needs them.
** What the registry offers the person in context under the policy. Tool
** discovery requests are attributed to the turn that needs them.
*******************************************************************************/
private List<ModelToolDefinition> tools(PolicyService.ToolPolicy policy, String turnId)
{
Map<String, ModelToolDefinition> byName = new TreeMap<>();
for(ModelToolDefinition definition : toolbox.definitions(policy, toolbox.anyWritableTable()))
{
byName.put(definition.getName(), definition.withStrict(isStrict(definition.getName(), true)));
}
for(AiToolDefinition definition : kernel.getToolDefinitions())
{
if(KERNEL_TOOLS.contains(definition.getName()) && (policy == null || policy.allows(definition.getName())))
{
byName.put(definition.getName(), new ModelToolDefinition()
.withName(definition.getName())
.withDescription(definition.getDescription())
.withInputSchema(definition.getInputSchema())
.withStrict(isStrict(definition.getName(), !Boolean.FALSE.equals(definition.getStrict()))));
}
}
if(knowledgeTool != null && knowledgeTool.offered(policy, QContext.getQSession(), turnId))
{
byName.put(KnowledgeTool.NAME, knowledgeTool.definition());
}
byName.put(PresentResultsTool.NAME, PresentResultsTool.definition(isStrict(PresentResultsTool.NAME, true)));
if(bulkLoadTools != null && bulkLoadTools.offered())
{
for(ModelToolDefinition definition : bulkLoadTools.definitions(policy, toolbox.anyWritableTable()))
{
byName.put(definition.getName(), definition);
}
}
return (new ArrayList<>(byName.values()));
return (tools(policy, turnId, null));
}
/*******************************************************************************
** Strict only when the host names the tool in strictToolNames: the provider
** compiles every strict schema of a request into one grammar, which it
** refuses when it grows too large.
** The same, with the credential of the person's request (when there is
** one), so a UI API request made to find out what they are offered is made
** as that person.
*******************************************************************************/
boolean isStrict(String toolName, boolean strictCapable)
private List<ModelToolDefinition> tools(PolicyService.ToolPolicy policy, String turnId, UiApiCredentials credentials)
{
return (strictCapable && config.getStrictToolNames() != null && config.getStrictToolNames().contains(toolName));
return (registry.definitions(new ToolOffer(policy, QContext.getQSession(), turnId, credentials)));
}
@@ -257,12 +226,25 @@ public class ContextBuilder
*******************************************************************************/
public ModelRequest build(QSession session, String model, String effort, List<AssistantMessage> history, String userText, Map<String, Object> pageContext, PolicyService.ToolPolicy toolPolicy,
String profileInstructions, String turnId, List<ModelPart> attachmentParts)
{
return (build(session, model, effort, history, userText, pageContext, toolPolicy, profileInstructions, turnId, attachmentParts, null));
}
/*******************************************************************************
** The same, for a turn that has the credential of the request that started
** it: which tools the person is offered (the write tools only when they may
** change some table themselves) is asked of the UI API as that person.
*******************************************************************************/
public ModelRequest build(QSession session, String model, String effort, List<AssistantMessage> history, String userText, Map<String, Object> pageContext, PolicyService.ToolPolicy toolPolicy,
String profileInstructions, String turnId, List<ModelPart> attachmentParts, UiApiCredentials credentials)
{
ModelRequest request = new ModelRequest()
.withModel(model)
.withEffort(effort)
.withMaxTokens(config.getModels().getMainMaxOutputTokens())
.withTools(tools(toolPolicy, turnId));
.withTools(tools(toolPolicy, turnId, credentials));
request.getSystem().add(new SystemBlock(SystemPrompt.staticBlock(appDigest()), CacheHint.LONG));
request.getSystem().add(new SystemBlock(userBlock(session) + (StringUtils.hasContent(profileInstructions) ? "\n" + profileInstructions : ""), CacheHint.SHORT));
@@ -520,24 +502,4 @@ public class ContextBuilder
/*******************************************************************************
** Additive optional tool hook; UI API toolbox stays independently maintained.
*******************************************************************************/
public ContextBuilder withKnowledgeTool(KnowledgeTool knowledgeTool)
{
this.knowledgeTool = knowledgeTool;
return (this);
}
/*******************************************************************************
** Fluent setter for the bulk-load tools.
*******************************************************************************/
public ContextBuilder withBulkLoadTools(BulkLoadTools bulkLoadTools)
{
this.bulkLoadTools = bulkLoadTools;
return (this);
}
}
@@ -0,0 +1,160 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.tools.registry.AssistantTool;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallRequest;
import com.kingsrook.qbits.assistant.tools.registry.ToolCheck;
import com.kingsrook.qbits.assistant.tools.registry.ToolFamily;
import com.kingsrook.qbits.assistant.tools.registry.ToolKind;
import com.kingsrook.qbits.assistant.tools.registry.ToolOffer;
import com.kingsrook.qbits.assistant.tools.registry.ToolRegistry;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolCall;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolDefinition;
/*******************************************************************************
** The two tools that stay on the core AI tool kernel
** ({@link ContextBuilder#KERNEL_TOOLS}: navigate and search_help, which read
** no records) as registered tools. The kernel keeps all of their behaviour.
**
** This family lives here, and the runtime names it, because nothing under
** the tools package may touch the kernel (ArchitectureTest).
*******************************************************************************/
public class KernelToolFamily implements ToolFamily
{
private static final List<ToolCheck> CHECKS = List.of(ToolCheck.TOOL_SWITCH, ToolCheck.KERNEL);
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<AssistantTool> tools(AssistantRuntime runtime)
{
List<AssistantTool> tools = new ArrayList<>();
for(String name : new TreeSet<>(ContextBuilder.KERNEL_TOOLS))
{
tools.add(new Tool(runtime, name));
}
return (tools);
}
/*******************************************************************************
** One kernel tool.
*******************************************************************************/
private static final class Tool implements AssistantTool
{
private final AssistantRuntime runtime;
private final String name;
/*******************************************************************************
**
*******************************************************************************/
private Tool(AssistantRuntime runtime, String name)
{
this.runtime = runtime;
this.name = name;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String name()
{
return (name);
}
/*******************************************************************************
** navigate only builds an /app link for the panel to open (the page
** enforces permission when it opens); search_help reads help text.
*******************************************************************************/
@Override
public ToolKind kind()
{
return ("navigate".equals(name) ? ToolKind.DISPLAY : ToolKind.READ);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<ToolCheck> checks()
{
return (CHECKS);
}
/*******************************************************************************
** The kernel's own definition, when the policy allows the tool.
*******************************************************************************/
@Override
public ModelToolDefinition definition(ToolOffer offer)
{
Map<String, ModelToolDefinition> offered = offer.once(KernelToolFamily.class, () ->
{
Map<String, ModelToolDefinition> byName = new HashMap<>();
for(AiToolDefinition definition : runtime.getKernel().getToolDefinitions())
{
if(ContextBuilder.KERNEL_TOOLS.contains(definition.getName()) && offer.allows(definition.getName()))
{
byName.put(definition.getName(), new ModelToolDefinition()
.withName(definition.getName())
.withDescription(definition.getDescription())
.withInputSchema(definition.getInputSchema())
.withStrict(ToolRegistry.isStrict(runtime.getConfig(), definition.getName(), !Boolean.FALSE.equals(definition.getStrict()))));
}
}
return (byName);
});
return (offered.get(name));
}
/*******************************************************************************
** No records are read: the kernel runs the call with the person's session.
*******************************************************************************/
@Override
public ToolCallOutcome execute(ToolCallRequest request)
{
return (ToolCallOutcome.of(runtime.getKernel().execute(request.session(), new AiToolCall().withId(request.toolCallId()).withName(request.toolName()).withInput(request.input()))));
}
}
}
@@ -74,14 +74,17 @@ public class PolicyService
/*******************************************************************************
** The policy row's tool settings, with their defaults.
**
** @param writesRequireConfirmation every write waits for the user's
** confirmation card (default true)
** Whether a write waits for the person's confirmation is not one of them:
** every change always shows a confirmation card, and no policy can switch
** that off (the assistant_policy.writesRequireConfirmation column is still
** there and is no longer read).
**
** @param allowedTools tool names allowed; empty allows every tool
** @param maxRowsPerCall most rows one tool call returns to the model
** @param redactPii redact secrets and personal identifiers from tool
** results (default true)
*******************************************************************************/
public record ToolPolicy(boolean writesRequireConfirmation, Set<String> allowedTools, int maxRowsPerCall, boolean redactPii)
public record ToolPolicy(Set<String> allowedTools, int maxRowsPerCall, boolean redactPii)
{
public static final int DEFAULT_MAX_ROWS_PER_CALL = 50;
public static final int MAX_ROWS_PER_CALL_CEILING = 200;
@@ -93,7 +96,7 @@ public class PolicyService
*******************************************************************************/
public static ToolPolicy defaults()
{
return (new ToolPolicy(true, Set.of(), DEFAULT_MAX_ROWS_PER_CALL, true));
return (new ToolPolicy(Set.of(), DEFAULT_MAX_ROWS_PER_CALL, true));
}
@@ -129,7 +132,7 @@ public class PolicyService
}
}
int maxRows = row.getMaxRowsPerCall() == null || row.getMaxRowsPerCall() < 1 ? DEFAULT_MAX_ROWS_PER_CALL : Math.min(MAX_ROWS_PER_CALL_CEILING, row.getMaxRowsPerCall());
return (new ToolPolicy(!Boolean.FALSE.equals(row.getWritesRequireConfirmation()), allowed, maxRows, !Boolean.FALSE.equals(row.getRedactPii())));
return (new ToolPolicy(allowed, maxRows, !Boolean.FALSE.equals(row.getRedactPii())));
}
}
@@ -33,11 +33,7 @@ import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
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.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -77,8 +73,8 @@ public class StarterPrompts
private static final ObjectMapper MAPPER = new ObjectMapper();
private final UiApiToolbox toolbox;
private final Clock clock;
private final UiApiToolbox toolbox;
private final Clock clock;
@@ -125,7 +121,10 @@ public class StarterPrompts
** The prompts for this user: the configured lines they may see, then
** generated ones up to {@link #MAX_PROMPTS} in all. Never throws.
**
** @param context where the calls run: the user's session and the policy
** @param context where the calls run, made by the tool-call service for the
** person (ToolCallService.readContext: their session and credentials,
** after the checks of assistant access and the kill switch, with the
** policy the service read)
** @param userId the daily selection seed; with none, there are no generated prompts
** @param configured the profile's starter lines, tags included
** @param historyOffered whether the change-history tool is available
@@ -197,30 +196,7 @@ public class StarterPrompts
*******************************************************************************/
private static UiApiToolbox.Context limited(UiApiToolbox.Context context)
{
UiApiGateway inner = context.gateway();
UiApiGateway gateway = new UiApiGateway()
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean isInvocable(String method, String pathTemplate)
{
return (inner.isInvocable(method, pathTemplate));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call) throws QException
{
return (inner.invoke(call.withTimeout(CALL_TIMEOUT)));
}
};
return (new UiApiToolbox.Context(context.sessionUUID(), context.userTimezone(), gateway, context.policy(), null, context.credentials(), null));
return (context.forRead(CALL_TIMEOUT));
}
@@ -263,7 +239,7 @@ public class StarterPrompts
*******************************************************************************/
private List<TableRef> readableTables(UiApiToolbox.Context context)
{
UiApiToolbox.Outcome outcome = toolbox.execute(context, "starter-list", UiApiToolbox.LIST_TABLES, Map.of());
UiApiToolbox.Outcome outcome = toolbox.read(context, "starter-list", UiApiToolbox.LIST_TABLES, Map.of());
Map<String, Object> data = json(outcome);
List<TableRef> rs = new ArrayList<>();
if(data != null && data.get("tables") instanceof List<?> tables)
@@ -290,7 +266,7 @@ public class StarterPrompts
Map<String, Object> filter = new LinkedHashMap<>();
filter.put("booleanOperator", "AND");
filter.put("criteria", List.of());
UiApiToolbox.Outcome outcome = toolbox.execute(context, "starter-count", UiApiToolbox.COUNT_RECORDS, Map.of("table", table.name(), "filter", filter));
UiApiToolbox.Outcome outcome = toolbox.read(context, "starter-count", UiApiToolbox.COUNT_RECORDS, Map.of("table", table.name(), "filter", filter));
if(outcome.getResult().getOutcome() != AiToolOutcome.OK || outcome.getResult().getRender() == null)
{
return (false);
@@ -306,7 +282,7 @@ public class StarterPrompts
*******************************************************************************/
private String dateField(UiApiToolbox.Context context, TableRef table)
{
UiApiToolbox.Outcome outcome = toolbox.execute(context, "starter-describe", UiApiToolbox.DESCRIBE_TABLE, Map.of("table", table.name()));
UiApiToolbox.Outcome outcome = toolbox.read(context, "starter-describe", UiApiToolbox.DESCRIBE_TABLE, Map.of("table", table.name()));
Map<String, Object> data = json(outcome);
if(data == null || !(data.get("fields") instanceof List<?> fields))
{
@@ -24,13 +24,13 @@ import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
@@ -39,16 +39,18 @@ import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicIntegerArray;
import java.util.concurrent.atomic.AtomicLongArray;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.SignedInPerson;
import com.kingsrook.qbits.assistant.attachments.AttachmentService;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.persistence.TranscriptRedactor;
@@ -65,15 +67,16 @@ import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.spi.ModelUsage;
import com.kingsrook.qbits.assistant.spi.StreamHandle;
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
import com.kingsrook.qbits.assistant.tools.PresentResultsTool;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qbits.assistant.tools.registry.ToolCall;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallScope;
import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolCall;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -82,7 +85,6 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -115,13 +117,19 @@ public class TurnEngine implements Runnable
public static final String TIMEOUT_TEXT = "The assistant took too long to answer, so it was stopped. Ask again.";
public static final String RESTART_TEXT = "The server restarted while answering. Ask again.";
private static final AiRedactor INPUT_REDACTOR = new AiRedactor(List.of());
private static final int TEXT_FLUSH_CHARS = 48;
private static final long TEXT_FLUSH_MILLIS = 120;
///////////////////////////////////////////////////////////////////////////
// how long a batch waits, in all, for tool calls it stopped waiting for //
// (a time limit, a stop) to say what they did, so their log rows are of //
// the real outcome //
///////////////////////////////////////////////////////////////////////////
private static final long CUT_SHORT_GRACE_MILLIS = 1000;
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 ToolCallScope toolScope = new TurnToolScope();
private final AssistantRuntime runtime;
private final AssistantQBitConfig config;
private final AssistantStore store;
@@ -176,7 +184,7 @@ public class TurnEngine implements Runnable
///////////////////////////////////////////////////////////////////////////
private final AtomicReference<Thread> housekeeper = new AtomicReference<>();
private volatile long housekeeperStartedNanos;
private UiApiCredentials credentials;
private SignedInPerson person;
private volatile TranscriptRedactor transcript;
private boolean engineRunning;
@@ -189,7 +197,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;
@@ -212,13 +219,20 @@ public class TurnEngine implements Runnable
/*******************************************************************************
** The credentials of the request that started the turn, forwarded to the
** UI API by the tools (session cookies, Authorization, API key as they came
** in). Without them the session UUID is used. Never logged.
** The signed-in person the turn runs for, as the route's session setup
** bound them: this turn's session with the credential of the request that
** started it, which the tools forward to the UI API. The engine never makes
** a person from a session itself: a turn without one runs no tool (the
** tool-call service refuses a call that names nobody). A person of another
** session is refused.
*******************************************************************************/
public TurnEngine withCredentials(UiApiCredentials credentials)
public TurnEngine withPerson(SignedInPerson person)
{
this.credentials = credentials;
if(person == null || person.session() != session)
{
throw (new IllegalArgumentException("The turn's person must be the person of the turn's session."));
}
this.person = person;
return (this);
}
@@ -354,7 +368,7 @@ public class TurnEngine implements Runnable
attachments.notices().forEach(text -> notice("warning", text));
}
ModelRequest request = runtime.getContextBuilder().build(session, model, route.effort(), history, userText, pageContext, toolPolicy, profileText,
turn.getId() == null ? null : String.valueOf(turn.getId()), attachments.parts());
turn.getId() == null ? null : String.valueOf(turn.getId()), attachments.parts(), person == null ? null : person.credentials());
Integer maxPerRequest = active.row() == null ? null : active.row().getMaxOutputTokensPerRequest();
if(maxPerRequest != null && (request.getMaxTokens() == null || request.getMaxTokens() > maxPerRequest))
{
@@ -364,7 +378,6 @@ public class TurnEngine implements Runnable
{
request.setParallelToolCalls(false);
}
Set<String> allowedTools = runtime.getContextBuilder().toolNames(toolPolicy);
boolean toolLimitReached = false;
while(true)
@@ -442,7 +455,7 @@ public class TurnEngine implements Runnable
break;
}
ToolBatch batch = executeTools(toolUses, allowedTools);
ToolBatch batch = executeTools(toolUses);
toolLimitReached = batch.limitReached;
for(ModelMessage message : request.getMessages())
@@ -860,6 +873,15 @@ public class TurnEngine implements Runnable
return (new ModelResponse().withStopReason(ModelResponse.STOP_CANCELLED));
}
ScheduledTurn.checkCost(runtime, conversation.getId(), turn.getId());
///////////////////////////////////////////////////////////////////////
// no model is called for a person with no spending limit in force: //
// asked before every call, with the budgets as they are now, so a //
// limit switched off during an answer stops it at its next step //
///////////////////////////////////////////////////////////////////////
if(!runtime.getBudgetService().limitInForce(userId, candidate, runtime.getPolicyService().current(true).budgets()))
{
throw (new BudgetService.Blocked(BudgetService.NO_LIMIT_MESSAGE));
}
PolicyService.BudgetStatus configuredBudget = runtime.getPolicyService().checkConfiguredBudget(policy, userId, usage.billableTokens(), costMicros);
if(configuredBudget.hardExceeded())
{
@@ -1049,20 +1071,32 @@ public class TurnEngine implements Runnable
/*******************************************************************************
** Run a batch of tool calls through the kernel.
** Run a batch of tool calls through the tool-call service, which checks
** each one (a tool that is not offered is refused there) and runs it as the
** user. The limits of a turn are this engine's: how many calls, how many at
** once, how long each may take.
**
** Every call that was started has its tool-call log row when this method
** is left, however it is left: a call that ran past its time limit, one
** whose result could not be shown, and a batch the engine was interrupted
** in (the rows are written, then the interruption is passed on). The row
** is of what the call in fact did when its worker has finished.
*******************************************************************************/
private ToolBatch executeTools(List<ModelPart> toolUses, Set<String> allowedTools) throws QException, InterruptedException
private ToolBatch executeTools(List<ModelPart> toolUses) throws QException, InterruptedException
{
int allowed = Math.max(0, config.getMaxToolCallsPerTurn() - toolCallCount);
boolean limitReached = false;
AiToolResult[] results = new AiToolResult[toolUses.size()];
UiApiToolbox.Outcome[] outcomes = new UiApiToolbox.Outcome[toolUses.size()];
List<Future<?>> futures = new ArrayList<>();
List<Integer> futureIndexes = new ArrayList<>();
AtomicLongArray startedNanos = new AtomicLongArray(toolUses.size());
boolean[] failed = new boolean[toolUses.size()];
boolean[] timedOut = new boolean[toolUses.size()];
Semaphore permits = new Semaphore(Math.max(1, config.getMaxParallelToolCalls()));
int allowed = Math.max(0, config.getMaxToolCallsPerTurn() - toolCallCount);
boolean limitReached = false;
AiToolResult[] results = new AiToolResult[toolUses.size()];
UiApiToolbox.Outcome[] outcomes = new UiApiToolbox.Outcome[toolUses.size()];
ToolCall[] calls = new ToolCall[toolUses.size()];
List<Future<?>> futures = new ArrayList<>();
List<Integer> futureIndexes = new ArrayList<>();
AtomicLongArray startedNanos = new AtomicLongArray(toolUses.size());
AtomicIntegerArray begun = new AtomicIntegerArray(toolUses.size());
CountDownLatch[] finished = new CountDownLatch[toolUses.size()];
boolean[] failed = new boolean[toolUses.size()];
boolean[] timedOut = new boolean[toolUses.size()];
Semaphore permits = new Semaphore(Math.max(1, config.getMaxParallelToolCalls()));
for(int i = 0; i < toolUses.size(); i++)
{
@@ -1077,111 +1111,96 @@ public class TurnEngine implements Runnable
phase("tool", use.getToolName());
emit(TurnEvent.TOOL_START, map("messageId", assistantMessage.getId(), "toolCallId", use.getToolUseId(), "name", use.getToolName(), "summary", ToolLabels.running(use.getToolName())));
if(!allowedTools.contains(use.getToolName()))
{
results[i] = new AiToolResult().withToolCallId(use.getToolUseId()).withToolName(use.getToolName()).withOutcome(AiToolOutcome.INVALID_INPUT)
.withSummary("Unknown tool").withModelContent("Unknown tool \"" + use.getToolName() + "\". Use only the tools provided.");
continue;
}
calls[i] = new ToolCall(person, use.getToolUseId(), use.getToolName(), use.getInput());
finished[i] = new CountDownLatch(1);
final int index = i;
AiToolCall call = new AiToolCall().withId(use.getToolUseId()).withName(use.getToolName()).withInput(use.getInput());
futureIndexes.add(index);
futures.add(runtime.getExecutor().submit(() ->
{
if(!transcript.retainWorker())
{
return;
}
transcript.attach();
begun.set(index, 1);
try
{
QContext.init(runtime.getQInstance(), session);
permits.acquire();
startedNanos.set(index, System.nanoTime());
LOG.info("Assistant tool call started", logPair("turnId", turn.getId()), logPair("tool", call.getName()));
if(!transcript.retainWorker())
{
return;
}
transcript.attach();
try
{
if(!running.isCancelled())
QContext.init(runtime.getQInstance(), session);
permits.acquire();
startedNanos.set(index, System.nanoTime());
LOG.info("Assistant tool call started", logPair("turnId", turn.getId()), logPair("tool", call.getName()));
try
{
if(PresentResultsTool.NAME.equals(call.getName()))
if(!running.isCancelled())
{
results[index] = presenter.execute(toolContext(), call.getId(), call.getInput());
}
else if(ContextBuilder.KERNEL_TOOLS.contains(call.getName()))
{
///////////////////////////////////////////////////
// navigate / search_help: no records are read //
///////////////////////////////////////////////////
results[index] = runtime.getKernel().execute(session, call);
}
else
{
///////////////////////////////////////////////////////////
// data tools: a /qqq/v1 call with the user's session //
///////////////////////////////////////////////////////////
QContext.init(runtime.getQInstance(), session);
try
{
if(KnowledgeTool.NAME.equals(call.getName()))
{
results[index] = runtime.getKnowledgeTool().execute(toolContext(), call.getId(), call.getInput(), classification.getClassification(), conversation.getId(), turn.getId());
}
else if(BulkLoadTools.NAMES.contains(call.getName()))
{
outcomes[index] = runtime.getBulkLoadTools().execute(toolContext(), call.getId(), call.getName(), call.getInput(), hipaaRoute());
results[index] = outcomes[index].getResult();
}
else
{
outcomes[index] = runtime.getToolbox().execute(toolContext(), call.getId(), call.getName(), call.getInput());
results[index] = outcomes[index].getResult();
}
}
finally
{
QContext.clear();
}
///////////////////////////////////////////////////////////////
// the service checks the call, then the registry's tool //
// runs it, as the user: with the user's own session, the //
// policy the service read and its pending-action store //
///////////////////////////////////////////////////////////////
ToolCallOutcome done = runtime.getToolCalls().execute(calls[index], toolScope);
outcomes[index] = done.extras();
results[index] = done.result();
}
}
finally
{
permits.release();
LOG.info("Assistant tool call finished", logPair("turnId", turn.getId()), logPair("tool", call.getName()),
logPair("outcome", results[index] == null || results[index].getOutcome() == null ? null : results[index].getOutcome().name()),
logPair("durationMs", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos.get(index))));
}
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
catch(RuntimeException | Error e)
{
///////////////////////////////////////////////////////////////////
// internal details go to the log only; the model and the user //
// get a fixed, safe text //
///////////////////////////////////////////////////////////////////
LOG.warn("Tool kernel threw", hipaaSafe(e), logPair("turnId", turn.getId()), logPair("tool", call.getName()), logPair("errorClass", e.getClass().getSimpleName()));
failed[index] = true;
results[index] = failedResult(call.getId(), call.getName());
}
finally
{
permits.release();
LOG.info("Assistant tool call finished", logPair("turnId", turn.getId()), logPair("tool", call.getName()),
logPair("outcome", results[index] == null || results[index].getOutcome() == null ? null : results[index].getOutcome().name()),
logPair("durationMs", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos.get(index))));
transcript.releaseWorker();
TranscriptRedactor.detach();
QContext.clear();
}
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
catch(RuntimeException | Error e)
{
///////////////////////////////////////////////////////////////////
// internal details go to the log only; the model and the user //
// get a fixed, safe text //
///////////////////////////////////////////////////////////////////
LOG.warn("Tool kernel threw", hipaaSafe(e), logPair("turnId", turn.getId()), logPair("tool", call.getName()), logPair("errorClass", e.getClass().getSimpleName()));
failed[index] = true;
results[index] = failedResult(call.getId(), call.getName());
}
finally
{
transcript.releaseWorker();
TranscriptRedactor.detach();
QContext.clear();
///////////////////////////////////////////////////////////////
// whatever the worker did is now readable by the engine //
///////////////////////////////////////////////////////////////
finished[index].countDown();
}
}));
}
persistPendingEvents();
InterruptedException interrupted = null;
long toolTimeoutNanos = TimeUnit.SECONDS.toNanos(config.getToolCallTimeoutSeconds() == null ? 60 : config.getToolCallTimeoutSeconds());
for(int f = 0; f < futures.size(); f++)
{
Future<?> future = futures.get(f);
int index = futureIndexes.get(f);
if(interrupted != null)
{
////////////////////////////////////////////////////////////////
// the engine was told to stop: the calls it has not waited //
// for yet are stopped too //
////////////////////////////////////////////////////////////////
future.cancel(true);
continue;
}
///////////////////////////////////////////////////////////////////////
// each call has its own limit (from when it got a permit), and no //
// call outlives the turn's deadline //
@@ -1211,6 +1230,41 @@ public class TurnEngine implements Runnable
{
LOG.debug("tool future cancelled", logPair("turnId", turn.getId()));
}
catch(InterruptedException e)
{
////////////////////////////////////////////////////////////////
// not passed on yet: the calls that ran still get their rows //
////////////////////////////////////////////////////////////////
interrupted = e;
future.cancel(true);
LOG.info("Assistant turn interrupted while its tool calls ran", logPair("turnId", turn.getId()));
}
}
///////////////////////////////////////////////////////////////////////
// a call that was cut short (its time limit, a stop) has been //
// interrupted; give it a bounded moment to hand back what it did, //
// so its row can say so //
///////////////////////////////////////////////////////////////////////
long graceUntil = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(CUT_SHORT_GRACE_MILLIS);
for(int index : futureIndexes)
{
if(begun.get(index) == 0)
{
continue;
}
try
{
if(!finished[index].await(Math.max(0, graceUntil - System.nanoTime()), TimeUnit.NANOSECONDS))
{
LOG.warn("Assistant tool call did not stop when it was cut short; its log row cannot say what it did", logPair("turnId", turn.getId()), logPair("tool", toolUses.get(index).getToolName()));
}
}
catch(InterruptedException e)
{
interrupted = interrupted == null ? e : interrupted;
break;
}
}
List<ModelPart> modelResults = new ArrayList<>();
@@ -1223,33 +1277,87 @@ public class TurnEngine implements Runnable
continue;
}
AiToolResult result = timedOut[i] ? null : results[i];
UiApiToolbox.Outcome extras = timedOut[i] ? null : outcomes[i];
///////////////////////////////////////////////////////////////////////
// what the worker in fact gave back, if it has finished //
///////////////////////////////////////////////////////////////////////
boolean workerFinished = finished[i].getCount() == 0;
AiToolResult real = workerFinished ? results[i] : null;
UiApiToolbox.Outcome realExtras = workerFinished ? outcomes[i] : null;
boolean cutShort = timedOut[i] || (interrupted != null && real == null);
AiToolResult result = timedOut[i] ? null : real;
UiApiToolbox.Outcome extras = timedOut[i] ? null : realExtras;
if(result == null && failed[i])
{
result = failedResult(use.getToolUseId(), use.getToolName());
}
else if(result == null)
{
boolean stopped = running.isCancelled() && !timedOut[i];
boolean stopped = (running.isCancelled() || interrupted != null) && !timedOut[i];
result = new AiToolResult().withToolCallId(use.getToolUseId()).withToolName(use.getToolName()).withOutcome(AiToolOutcome.ERROR)
.withSummary(stopped ? "Not run (stopped)" : "Timed out")
.withModelContent(stopped ? "Not run: the user stopped the answer." : "The tool did not finish in time. Answer with what you have, or try a narrower request.");
timedOut[i] = !stopped;
}
classification.observe(use, result);
recordToolResult(use, result, extras);
if(timedOut[i])
{
notice("error", "A step took too long and was stopped (" + ToolLabels.running(use.getToolName()) + ").");
}
else if(failed[i] || (extras != null && extras.isFailed()))
{
notice("error", "A step failed (" + ToolLabels.running(use.getToolName()) + ").");
}
String content = result.getModelContent() == null ? "" : result.getModelContent();
modelResults.add(ModelPart.toolResult(use.getToolUseId(), content, result.getOutcome() != null && ERROR_OUTCOMES.contains(result.getOutcome())));
///////////////////////////////////////////////////////////////////////
// the row: the call's real outcome when it finished after all (a //
// call that ran and then ran out of time), and in any case what it //
// left behind (the operation, a proposed write, an audited change); //
// the model and the panel were told it took too long //
///////////////////////////////////////////////////////////////////////
AiToolResult rowResult = cutShort && real != null && AiToolOutcome.OK.equals(real.getOutcome()) ? real : result;
UiApiToolbox.Outcome rowExtras = cutShort ? realExtras : extras;
///////////////////////////////////////////////////////////////////////
// whether the call's own outcome is what the person is shown: not //
// when it ran out of time, was stopped, failed, or could not be //
// processed. A change it proposed has no card then //
///////////////////////////////////////////////////////////////////////
boolean taken = result == real && !failed[i] && interrupted == null;
ModelPart part = null;
try
{
if(interrupted == null)
{
part = showToolResult(use, result, timedOut[i], failed[i] || (extras != null && extras.isFailed()));
}
}
catch(RuntimeException e)
{
taken = false;
////////////////////////////////////////////////////////////////////
// a result that could not be classified or shown is not sent to //
// the model; the call still has its row, and so have the next //
////////////////////////////////////////////////////////////////////
LOG.warn("Assistant tool result could not be processed", hipaaSafe(e), logPair("turnId", turn.getId()), logPair("tool", use.getToolName()), logPair("errorClass", e.getClass().getSimpleName()));
part = ModelPart.toolResult(use.getToolUseId(), failedResult(use.getToolUseId(), use.getToolName()).getModelContent(), true);
}
finally
{
//////////////////////////////////////////////////////////////////////
// the service writes the row: it redacts the input itself, takes //
// the person from the call and checks the turn is theirs. It does //
// not throw, and reports a row it could not write //
//////////////////////////////////////////////////////////////////////
if(!taken)
{
////////////////////////////////////////////////////////////////
// no card is shown for this call: what it proposed is //
// withdrawn, and what it has not yet proposed never will be //
////////////////////////////////////////////////////////////////
runtime.getToolCalls().withdraw(calls[i], toolScope);
}
runtime.getToolCalls().log(calls[i], toolScope, new ToolCallOutcome(rowResult, rowExtras));
}
if(part != null)
{
modelResults.add(part);
}
}
if(interrupted != null)
{
throw (interrupted);
}
persistClassification();
persistPendingEvents();
@@ -1258,6 +1366,30 @@ public class TurnEngine implements Runnable
/*******************************************************************************
** Take a tool result into the turn: its classification, its events and
** message parts, a notice when the step failed or took too long; the answer
** is what goes back to the model.
*******************************************************************************/
private ModelPart showToolResult(ModelPart use, AiToolResult result, boolean tookTooLong, boolean stepFailed)
{
classification.observe(use, result);
recordToolResult(use, result);
if(tookTooLong)
{
notice("error", "A step took too long and was stopped (" + ToolLabels.running(use.getToolName()) + ").");
}
else if(stepFailed)
{
notice("error", "A step failed (" + ToolLabels.running(use.getToolName()) + ").");
}
String content = result.getModelContent() == null ? "" : result.getModelContent();
return (ModelPart.toolResult(use.getToolUseId(), content, result.getOutcome() != null && ERROR_OUTCOMES.contains(result.getOutcome())));
}
/*******************************************************************************
** The result of a tool call that threw: an ERROR (is_error) result with a
** fixed text; what went wrong is only in the log.
@@ -1271,14 +1403,15 @@ public class TurnEngine implements Runnable
/*******************************************************************************
** Audit row, events and message parts of one tool result. The render is
** checked first (RenderShapes): one that cannot be shown is left out and an
** error notice says so.
** Events and message parts of one tool result (its audit row is written by
** executeTools, whatever happens here). The render is checked first
** (RenderShapes): one that cannot be shown is left out and an error notice
** says so.
*******************************************************************************/
private void recordToolResult(ModelPart use, AiToolResult result, UiApiToolbox.Outcome extras)
private void recordToolResult(ModelPart use, AiToolResult result)
{
String outcome = result.getOutcome() == null ? AiToolOutcome.ERROR.name() : result.getOutcome().name();
String summary = StringUtils.hasContent(result.getSummary()) ? result.getSummary() : ToolLabels.done(use.getToolName());
String outcome = ToolCallService.outcomeName(result);
String summary = ToolCallService.summary(use.getToolName(), result);
List<Map<String, Object>> refs = new ArrayList<>();
for(AiRecordRef ref : result.getRecordRefs() == null ? List.<AiRecordRef>of() : result.getRecordRefs())
@@ -1334,109 +1467,73 @@ public class TurnEngine implements Runnable
emit(TurnEvent.UI_NAVIGATE, map("messageId", assistantMessage.getId(), "href", href));
parts.addNavigate(href);
}
}
try
/*******************************************************************************
** What a tool call may know about this turn. Every value is the server's:
** the classification reached so far, the turn's rows, and the turn's
** provider's HIPAA eligibility (never anything the model says).
*******************************************************************************/
private final class TurnToolScope implements ToolCallScope
{
private final Map<Class<?>, Object> state = new ConcurrentHashMap<>();
/*******************************************************************************
**
*******************************************************************************/
@Override
public DataClassification classification()
{
String inputJson = redactInput(PresentResultsTool.NAME.equals(use.getToolName()) ? (presentation ? result.getRender() : Map.of()) : use.getInput());
store.insert(new AssistantToolCall()
.withTurnId(turn.getId())
.withConversationId(conversation.getId())
.withUserId(userId)
.withToolCallId(use.getToolUseId())
.withToolName(use.getToolName())
.withClientChannel(scheduledTask == null ? "assistant/" + turn.getId() : "assistant/scheduled/" + scheduledTask.getId())
.withInputJson(BulkLoadTools.NAMES.contains(use.getToolName()) ? BulkLoadTools.forLog(inputJson) : inputJson)
.withOutcome(outcome)
.withPermissionCheck(result.getPermissionCheck())
.withTableName(result.getTableName())
.withProcessName(result.getProcessName())
.withWidgetName(result.getWidgetName())
.withRowCount(result.getRowCount())
.withDurationMs(result.getDurationMs())
.withResultSummary(summary)
.withResultJson(BulkLoadTools.NAMES.contains(use.getToolName()) ? BulkLoadTools.forLog(result.getModelContent()) : result.getModelContent())
.withRedactionsJson(result.getRedactions() == null || result.getRedactions().isEmpty() ? null : JsonUtils.toJson(result.getRedactions()))
.withUiApiOperation(extras == null ? ("search_knowledge".equals(use.getToolName()) ? "GET /table/{tableName}/{primaryKey}" : null) : extras.getOperation())
.withHttpStatus(extras == null ? null : extras.getHttpStatus())
.withAuditHref(extras == null ? null : extras.getAuditHref())
.withPendingActionId(extras == null ? null : extras.getPendingActionId()));
return (classification.getClassification());
}
catch(QException e)
/*******************************************************************************
**
*******************************************************************************/
@Override
public Integer conversationId()
{
LOG.warn("Could not record assistant tool call", hipaaSafe(e), logPair("turnId", turn.getId()), logPair("tool", use.getToolName()));
return (conversation.getId());
}
}
/*******************************************************************************
** Where a data tool runs: the user's own session (by its UUID, for the UI
** API), the gateway, the policy's tool settings, and the pending-action
** store for writes that need confirmation.
*******************************************************************************/
private boolean hipaaRoute()
{
///////////////////////////////////////////////////////////////////////
// the X-QQQ-AI-Route the bulk-load tools send: the turn's provider's //
// HIPAA eligibility, never anything the model says //
///////////////////////////////////////////////////////////////////////
return (active != null && active.row() != null && Boolean.TRUE.equals(active.row().getHipaaEligible()));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public Integer turnId()
{
return (turn.getId());
}
/*******************************************************************************
** The UI API tool context: the user's session, the gateway, the policy's
** tools and the pending-action hook.
*******************************************************************************/
private UiApiToolbox.Context toolContext()
{
return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), toolPolicy, this::proposeWrite, credentials, turn.getId() == null ? null : String.valueOf(turn.getId())));
}
/*******************************************************************************
** The X-QQQ-AI-Route the bulk-load tools send.
*******************************************************************************/
@Override
public boolean hipaaRoute()
{
return (active != null && active.row() != null && Boolean.TRUE.equals(active.row().getHipaaEligible()));
}
/*******************************************************************************
** The browser's timezone the session carries, if any.
*******************************************************************************/
private String userTimezone()
{
return (session.getValue(QSession.VALUE_KEY_USER_TIMEZONE));
}
/*******************************************************************************
** Record a write the user must confirm; nothing runs until they do.
*******************************************************************************/
private Integer proposeWrite(UiApiToolbox.ProposedWrite write) throws QException
{
Instant now = runtime.getClock().instant();
AssistantPendingAction action = store.insert(new AssistantPendingAction()
.withConversationId(conversation.getId())
.withTurnId(turn.getId())
.withUserId(userId)
.withToolCallId(write.toolCallId())
.withToolName(write.toolName())
.withUiApiOperation(write.operation())
.withTableName(write.tableName())
.withRecordId(write.recordId())
.withParamsJson(JsonUtils.toJson(write.values()))
.withSummary(write.summary())
.withStatus(AssistantPendingAction.STATUS_PENDING)
.withExpiresAt(now.plusSeconds(config.getPendingActionTtlSeconds() == null ? 900 : config.getPendingActionTtlSeconds())));
return (action.getId());
}
/*******************************************************************************
** The tool input as JSON with secrets redacted (for the tool-call log).
*******************************************************************************/
private String redactInput(Map<String, Object> input)
{
String json = JsonUtils.toJson(input);
return (INPUT_REDACTOR.redact(json, new HashSet<>()));
/*******************************************************************************
** State a tool keeps for this turn (the presenter).
*******************************************************************************/
@Override
public <T> T state(Class<T> type, Supplier<T> create)
{
return (type.cast(state.computeIfAbsent(type, key -> create.get())));
}
}
@@ -162,7 +162,7 @@ public final class AssistantAccessWidget
List<AccessExplainer.TableAccess> tables;
try
{
tables = runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), QContext.getQSession().getUuid(), runtime.getPolicyService().current(false).tools(), table, prompt);
tables = runtime.getAccessExplainer().explain(QContext.getQSession().getUuid(), null, runtime.getPolicyService().current(false).tools(), table, prompt, null);
}
catch(QException e)
{
@@ -142,7 +142,7 @@ public final class AssistantSetupStatus
}
else
{
items.add(new Item("Policy", AssistantPolicy.TABLE_NAME, State.OK, "Writes " + (Boolean.FALSE.equals(policies.get(0).getWritesRequireConfirmation()) ? "do not wait" : "wait") + " for confirmation.", null));
items.add(new Item("Policy", AssistantPolicy.TABLE_NAME, State.OK, "Writes wait for confirmation.", null));
}
////////////////////////
@@ -178,7 +178,8 @@ public final class AssistantSetupStatus
/////////////
List<AssistantBudget> budgets = store.budgets();
items.add(budgets.isEmpty()
? new Item("Budgets", AssistantBudget.TABLE_NAME, State.TODO, "No budget: spending is limited only by the policy's configured caps.", "Add a monthly instance budget (warn, downgrade, stop).")
? new Item("Budgets", AssistantBudget.TABLE_NAME, State.TODO, "No budget: the assistant answers only while a spending limit is in force, so without a hard limit in the code configuration or the policy it refuses every question.",
"Add a monthly instance budget (warn, downgrade, stop).")
: new Item("Budgets", AssistantBudget.TABLE_NAME, State.OK, budgets.size() + " budget" + (budgets.size() == 1 ? "" : "s") + " checked before every question.", null));
/////////////////
@@ -186,9 +187,10 @@ public final class AssistantSetupStatus
/////////////////
int accessRows = store.query(AssistantDataAccess.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantDataAccess.class).size();
items.add(accessRows == 0
? new Item("Data access", AssistantDataAccess.TABLE_NAME, State.DEFAULT, "Defaults: the assistant may read what each user can read; it proposes no changes.",
"Run Add rows for every table to review them, then raise tables to READ_WRITE where it may propose changes.")
: new Item("Data access", AssistantDataAccess.TABLE_NAME, State.OK, accessRows + " row" + (accessRows == 1 ? "" : "s") + "; tables without a row use the default.", null));
? new Item("Data access", AssistantDataAccess.TABLE_NAME, State.DEFAULT,
"Defaults: the assistant may read what each person may read, and propose the changes that person may make themselves; every change waits for their confirmation.",
"Add a row only to restrict the assistant: Read makes a table read-only for it, None hides the table from it.")
: new Item("Data access", AssistantDataAccess.TABLE_NAME, State.OK, accessRows + " row" + (accessRows == 1 ? "" : "s") + " restricting the assistant; tables without a row follow each person's own permissions.", null));
///////////////////////////////////////
// optional: knowledge and schedules //
@@ -38,8 +38,10 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** `assistant_data_access` rows: the object must exist (a table or process of
** this instance) and the mode must be NONE, READ or READ_WRITE; READ_WRITE
** on a process means "may run". After any change the runtime's cached modes
** this instance) and the mode must be NONE, READ or READ_WRITE. A row only
** restricts: READ_WRITE is "no restriction" (what an object has without a
** row; on a process it means "may run"), and no row raises what the code
** allows (DataAccessService). After any change the runtime's cached modes
** are dropped, so the next tool call uses the new setting.
*******************************************************************************/
public class AssistantDataAccessCustomizer implements TableCustomizerInterface
@@ -37,6 +37,7 @@ import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPendingActionClaim;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantProfile;
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
@@ -252,7 +253,15 @@ public class AssistantMetaDataBuilder
tables.add(readOnly(owned(base(AssistantPendingAction.TABLE_NAME, "Assistant Pending Action", "pending_actions", AssistantPendingAction.class), false))
.withRecordLabelFormat("%s #%s").withRecordLabelFields("toolName", "id"));
text(tables.get(tables.size() - 1), "paramsJson");
text(tables.get(tables.size() - 1), "paramsJson", "beforeJson");
///////////////////////////////////////////////////////////////////////
// the one decision of a pending action: its unique key is what lets //
// exactly one request confirm or cancel an action //
///////////////////////////////////////////////////////////////////////
tables.add(readOnly(owned(base(AssistantPendingActionClaim.TABLE_NAME, "Assistant Pending Action Claim", "task_alt", AssistantPendingActionClaim.class), false))
.withUniqueKey(new UniqueKey("pendingActionId"))
.withRecordLabelFormat("%s #%s").withRecordLabelFields("decision", "pendingActionId"));
tables.add(readOnly(owned(base(AssistantDataAudit.TABLE_NAME, "Assistant Data Audit", "shield", AssistantDataAudit.class), false))
.withoutCapabilities(Capability.TABLE_DELETE)
@@ -39,6 +39,7 @@ import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPendingActionClaim;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantProfile;
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
@@ -229,6 +230,11 @@ public final class AssistantRecordScreens
case AssistantSkill.TABLE_NAME -> skill(table);
case AssistantProfileSkill.TABLE_NAME -> profileSkill(table);
case AssistantPendingAction.TABLE_NAME -> pendingAction(table);
case AssistantPendingActionClaim.TABLE_NAME ->
{
link(table, "pendingActionId", AssistantPendingAction.TABLE_NAME, "Pending action");
sections(table);
}
case AssistantDataAudit.TABLE_NAME -> sections(table);
default ->
{
@@ -576,9 +582,9 @@ public final class AssistantRecordScreens
{
table.getField("objectType").withLabel("Type").withDefaultValue(AssistantDataAccess.TYPE_TABLE);
table.getField("objectName").withLabel("Table or process name")
.withHelpContent(new QHelpContent("The table or process name, e.g. blog_post. Tables without a row use the default: NONE for sensitive tables, READ for the others. Processes default to NONE."));
.withHelpContent(new QHelpContent("The table or process name, e.g. blog_post. Without a row the assistant follows the signed-in person's own permissions (sensitive tables stay NONE). A row can only restrict that."));
table.getField("mode").withLabel("Assistant access").withDefaultValue(DataAccessMode.READ.name())
.withHelpContent(new QHelpContent("NONE: the assistant never sees it. READ: the read tools may use it. READ and write: the create, update and delete tools may too. The assistant always acts with the user's own permissions and record locks; this can only narrow them. A sensitive table (AI exposure NONE) stays NONE."));
.withHelpContent(new QHelpContent("NONE: the assistant never sees it. READ: the assistant may read it and never proposes a change to it. READ and write: no restriction (what a table has without a row). The assistant always acts with the person's own permissions and record locks; a row can only narrow them, never raise what the application's code allows. A sensitive table (AI exposure NONE) stays NONE."));
sections(table,
section("access", "Data Access", "admin_panel_settings", Tier.T1, "id", "objectType", "objectName", "mode"),
section("notes", "Notes", "notes", Tier.T2, "notes"));
@@ -649,14 +655,17 @@ public final class AssistantRecordScreens
private static void pendingAction(QTableMetaData table)
{
json(table, "paramsJson");
json(table, "beforeJson");
link(table, "turnId", AssistantTurn.TABLE_NAME, "Turn");
table.getField("paramsJson").withLabel("Values");
table.getField("beforeJson").withLabel("Values before");
table.getField("actionKind").withLabel("Kind");
table.getField("uiApiOperation").withLabel("UI API operation");
table.getField("httpStatus").withLabel("HTTP status");
sections(table,
section("action", "Pending Action", "pending_actions", Tier.T1, "id", "summary", "status", "toolName", "tableName", "recordId", "userId", "conversationId", "turnId"),
section("action", "Pending Action", "pending_actions", Tier.T1, "id", "summary", "status", "actionKind", "toolName", "tableName", "recordId", "userId", "conversationId", "turnId"),
section("decision", "Decision and result", "fact_check", Tier.T2, "expiresAt", "decidedAt", "uiApiOperation", "httpStatus", "resultSummary"),
section("payload", "Values", "data_object", Tier.T3, "paramsJson", "toolCallId"));
section("payload", "Values", "data_object", Tier.T3, "paramsJson", "beforeJson", "recordVersion", "toolCallId"));
}
@@ -731,7 +740,7 @@ public final class AssistantRecordScreens
help(table, "mainModel", "The model id answers come from; empty uses the model configured for the application.");
help(table, "killSwitch", "On: the assistant takes no new requests from anyone.");
help(table, "killSwitchReason", "Shown to users while the kill switch is on.");
help(table, "writesRequireConfirmation", "On (or empty): every create, update or delete the assistant proposes waits for the user to confirm it in the panel. Off: writes run at once, still with the user's own permissions.");
help(table, "writesRequireConfirmation", "No longer used. Every create, update or delete the assistant proposes waits for the user to confirm it in the panel; this cannot be switched off.");
help(table, "allowedTools", "Comma-separated tool names the assistant may use (e.g. list_tables, query_records, count_records). Empty allows every tool.");
help(table, "maxRowsPerCall", "Most rows one tool call returns to the model. Empty: 50; at most 200.");
help(table, "redactPii", "On (or empty): API keys, tokens, private keys, card numbers and social security numbers are redacted from tool results before the model sees them.");
@@ -26,7 +26,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
/*******************************************************************************
** The assistant's data access for one table or process: NONE (never), READ (read tools) or READ_WRITE (read and write tools). The assistant still acts with the user's own session, so this only ever narrows what the user may do. Tables and processes without a row use the default (NONE for sensitive tables, READ for other tables, NONE for processes).
** The assistant's data access for one table or process: NONE (never), READ (read tools) or READ_WRITE (read and write tools). The assistant still acts with the user's own session, so this only ever narrows what the user may do. A row can only restrict: tables and processes without one follow the code default (the signed-in person's own permissions; NONE for sensitive tables and for the assistant's own), and a row never raises what the code allows.
*******************************************************************************/
public class AssistantDataAccess extends QRecordEntity
{
@@ -25,12 +25,21 @@ import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** A write the assistant proposed and the user must confirm. Confirming runs the stored UI API operation with the confirming user's own session; nothing runs before that.
** A change the assistant proposed and the user must confirm. Confirming runs the
** stored UI API operation with the confirming user's own session; nothing runs
** before that.
**
** actionKind says what is proposed (see tools.registry.ActionKinds); beforeJson
** holds what the card showed of the record as it was (after the same exposure
** rules as get_record); recordVersion is a digest of the record as the person
** could read it when the change was proposed, never its values, and a confirmed
** update or delete runs only while the record still has that version.
*******************************************************************************/
public class AssistantPendingAction extends QRecordEntity
{
public static final String TABLE_NAME = "assistant_pending_action";
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_RUNNING = "RUNNING";
public static final String STATUS_EXECUTED = "EXECUTED";
public static final String STATUS_FAILED = "FAILED";
public static final String STATUS_REJECTED = "REJECTED";
@@ -69,6 +78,15 @@ public class AssistantPendingAction extends QRecordEntity
@QField(isEditable = false)
private String summary;
@QField(isEditable = false, maxLength = 64)
private String actionKind;
@QField(isEditable = false)
private String beforeJson;
@QField(isEditable = false, maxLength = 64)
private String recordVersion;
@QField(isEditable = false, maxLength = 16)
private String status;
@@ -650,4 +668,96 @@ public class AssistantPendingAction extends QRecordEntity
return (this);
}
/*******************************************************************************
** Getter for actionKind
*******************************************************************************/
public String getActionKind()
{
return (this.actionKind);
}
/*******************************************************************************
** Setter for actionKind
*******************************************************************************/
public void setActionKind(String actionKind)
{
this.actionKind = actionKind;
}
/*******************************************************************************
** Fluent setter for actionKind
*******************************************************************************/
public AssistantPendingAction withActionKind(String actionKind)
{
this.actionKind = actionKind;
return (this);
}
/*******************************************************************************
** Getter for beforeJson
*******************************************************************************/
public String getBeforeJson()
{
return (this.beforeJson);
}
/*******************************************************************************
** Setter for beforeJson
*******************************************************************************/
public void setBeforeJson(String beforeJson)
{
this.beforeJson = beforeJson;
}
/*******************************************************************************
** Fluent setter for beforeJson
*******************************************************************************/
public AssistantPendingAction withBeforeJson(String beforeJson)
{
this.beforeJson = beforeJson;
return (this);
}
/*******************************************************************************
** Getter for recordVersion
*******************************************************************************/
public String getRecordVersion()
{
return (this.recordVersion);
}
/*******************************************************************************
** Setter for recordVersion
*******************************************************************************/
public void setRecordVersion(String recordVersion)
{
this.recordVersion = recordVersion;
}
/*******************************************************************************
** Fluent setter for recordVersion
*******************************************************************************/
public AssistantPendingAction withRecordVersion(String recordVersion)
{
this.recordVersion = recordVersion;
return (this);
}
}
@@ -0,0 +1,227 @@
/*
* 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.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** The one decision of a pending action: whoever inserts this row decides the
** action (confirms it or cancels it), and nobody else can. pendingActionId is
** unique, so of two requests that decide the same action at the same moment,
** on one server or on two, exactly one inserts its row and the other is told
** the action is already handled.
*******************************************************************************/
public class AssistantPendingActionClaim extends QRecordEntity
{
public static final String TABLE_NAME = "assistant_pending_action_claim";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isEditable = false, isRequired = true)
private Integer pendingActionId;
@QField(isEditable = false, maxLength = 255)
private String userId;
@QField(isEditable = false, maxLength = 16)
private String decision;
@QField(isEditable = false)
private Instant createDate;
/*******************************************************************************
** Default constructor.
*******************************************************************************/
public AssistantPendingActionClaim()
{
}
/*******************************************************************************
** Constructor that populates from a QRecord.
*******************************************************************************/
public AssistantPendingActionClaim(QRecord record) throws QException
{
populateFromQRecord(record);
}
/*******************************************************************************
** Getter for id
*******************************************************************************/
public Integer getId()
{
return (this.id);
}
/*******************************************************************************
** Setter for id
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Fluent setter for id
*******************************************************************************/
public AssistantPendingActionClaim withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Getter for pendingActionId
*******************************************************************************/
public Integer getPendingActionId()
{
return (this.pendingActionId);
}
/*******************************************************************************
** Setter for pendingActionId
*******************************************************************************/
public void setPendingActionId(Integer pendingActionId)
{
this.pendingActionId = pendingActionId;
}
/*******************************************************************************
** Fluent setter for pendingActionId
*******************************************************************************/
public AssistantPendingActionClaim withPendingActionId(Integer pendingActionId)
{
this.pendingActionId = pendingActionId;
return (this);
}
/*******************************************************************************
** Getter for userId
*******************************************************************************/
public String getUserId()
{
return (this.userId);
}
/*******************************************************************************
** Setter for userId
*******************************************************************************/
public void setUserId(String userId)
{
this.userId = userId;
}
/*******************************************************************************
** Fluent setter for userId
*******************************************************************************/
public AssistantPendingActionClaim withUserId(String userId)
{
this.userId = userId;
return (this);
}
/*******************************************************************************
** Getter for decision
*******************************************************************************/
public String getDecision()
{
return (this.decision);
}
/*******************************************************************************
** Setter for decision
*******************************************************************************/
public void setDecision(String decision)
{
this.decision = decision;
}
/*******************************************************************************
** Fluent setter for decision
*******************************************************************************/
public AssistantPendingActionClaim withDecision(String decision)
{
this.decision = decision;
return (this);
}
/*******************************************************************************
** Getter for createDate
*******************************************************************************/
public Instant getCreateDate()
{
return (this.createDate);
}
/*******************************************************************************
** Setter for createDate
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Fluent setter for createDate
*******************************************************************************/
public AssistantPendingActionClaim withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
}
@@ -617,7 +617,7 @@ public class AssistantPolicy extends QRecordEntity
/*******************************************************************************
** Getter for writesRequireConfirmation (whether every write waits for the user to confirm a card (default true))
** Getter for writesRequireConfirmation (no longer read: every write always waits for the user to confirm a card; the column stays)
*******************************************************************************/
public Boolean getWritesRequireConfirmation()
{
@@ -44,6 +44,7 @@ import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPendingActionClaim;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
@@ -57,6 +58,7 @@ import com.kingsrook.qbits.assistant.scheduled.ScheduledTasks;
import com.kingsrook.qbits.assistant.spi.ModelUsage;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
@@ -64,6 +66,10 @@ import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
@@ -77,6 +83,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
@@ -124,10 +131,17 @@ public class AssistantStore
AssistantConversation.TABLE_NAME, List.of("title", "contextJson"),
AssistantToolCall.TABLE_NAME, List.of("inputJson", "resultJson", "redactionsJson", "resultSummary"),
AssistantTurnEvent.TABLE_NAME, List.of("dataJson"),
AssistantPendingAction.TABLE_NAME, List.of("paramsJson", "summary", "resultSummary"));
AssistantPendingAction.TABLE_NAME, List.of("paramsJson", "beforeJson", "summary", "resultSummary"));
private static final Map<String, Object> USAGE_LOCKS = new ConcurrentHashMap<>();
private static final Object DECISION_LOCK = new Object();
private static final List<String> DECIDED_STATUSES = List.of(AssistantPendingAction.STATUS_EXECUTED, AssistantPendingAction.STATUS_FAILED,
AssistantPendingAction.STATUS_REJECTED, AssistantPendingAction.STATUS_EXPIRED);
public static final String EXPIRED_SUMMARY = "Expired before it was confirmed; nothing changed.";
static final String TRUSTED_REASON = "Assistant store: the assistant's own tables, behind the assistant API's access check; owner record locks still apply.";
@@ -373,6 +387,15 @@ public class AssistantStore
delete(table, owned(userId).withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId));
}
delete(AssistantDataAudit.TABLE_NAME, owned(userId).withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId));
List<Serializable> actionIds = new ArrayList<>();
for(AssistantPendingAction action : query(AssistantPendingAction.TABLE_NAME, owned(userId).withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId), AssistantPendingAction.class))
{
actionIds.add(action.getId());
}
if(!actionIds.isEmpty())
{
delete(AssistantPendingActionClaim.TABLE_NAME, owned(userId).withCriteria("pendingActionId", QCriteriaOperator.IN, actionIds));
}
delete(AssistantPendingAction.TABLE_NAME, owned(userId).withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId));
}
@@ -482,6 +505,246 @@ public class AssistantStore
/*******************************************************************************
** The user's own pending actions, newest first: of one status (null for
** any) and of one conversation (null for all, with or without one).
*******************************************************************************/
public List<AssistantPendingAction> listOwnPendingActions(String userId, String status, Integer conversationId, int limit) throws QException
{
QQueryFilter filter = owned(userId);
if(status != null)
{
filter.withCriteria("status", QCriteriaOperator.EQUALS, status);
}
if(conversationId != null)
{
filter.withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId);
}
return (query(AssistantPendingAction.TABLE_NAME, filter.withOrderBy(new QFilterOrderBy("id", false)).withLimit(limit), AssistantPendingAction.class));
}
/*******************************************************************************
** Decide one of the user's own pending actions, once: true for the one
** caller that may go on (to run a confirmed change, or having cancelled
** it), false for every other. Two requests can never both be answered true
** for one action.
**
** The decision is the insert of the action's claim row, whose
** pendingActionId is unique: the database is the arbiter between servers,
** and the monitor between threads of one (it also covers a backend that
** does not enforce the unique key itself). The action must still be
** PENDING when the claim is made; its status is then set to the one given
** (RUNNING for a confirmation, REJECTED for a cancellation, EXPIRED for an
** action that lapsed: see {@link #expirePendingAction}).
**
** Once the claim is in, the action is decided, whatever happens next: when
** the status cannot be written the claim stands, the caller is told so by
** {@link DecidedButNotMarked}, and no later request can decide the action
** again.
**
** Final: a subclass of the store (this repository's tests have some, to
** make a read or a write fail) cannot replace the decision. What it can
** override are the reads and writes the decision is made of, and whatever
** those do, the unique key of the claim row still admits one claim.
*******************************************************************************/
public final boolean decidePendingAction(String userId, Integer id, String status, String resultSummary, Instant now) throws QException
{
synchronized(DECISION_LOCK)
{
AssistantPendingAction action = getPendingAction(userId, id);
if(action == null || !AssistantPendingAction.STATUS_PENDING.equals(action.getStatus()))
{
return (false);
}
QQueryFilter claimed = new QQueryFilter().withCriteria("pendingActionId", QCriteriaOperator.EQUALS, id);
if(!query(AssistantPendingActionClaim.TABLE_NAME, claimed, AssistantPendingActionClaim.class).isEmpty())
{
return (false);
}
try
{
insert(new AssistantPendingActionClaim().withPendingActionId(id).withUserId(userId).withDecision(status).withCreateDate(now));
}
catch(QException e)
{
////////////////////////////////////////////////////////////////
// another server inserted the claim first (the unique key), //
// or the insert failed and nothing is claimed //
////////////////////////////////////////////////////////////////
if(!query(AssistantPendingActionClaim.TABLE_NAME, claimed, AssistantPendingActionClaim.class).isEmpty())
{
return (false);
}
throw (e);
}
QRecord decided = new QRecord().withValue("id", id).withValue("status", status).withValue("decidedAt", now);
if(resultSummary != null)
{
decided.setValue("resultSummary", resultSummary);
}
try
{
updateFields(AssistantPendingAction.TABLE_NAME, decided);
}
catch(QException | RuntimeException e)
{
throw (new DecidedButNotMarked(e));
}
return (true);
}
}
/*******************************************************************************
** Mark one of the user's own pending actions that lapsed before it was
** decided. Expiry is a decision like the other two: it goes through
** {@link #decidePendingAction} (a claim with the decision EXPIRED), so it
** is made only for an action that is still PENDING and that nobody has
** claimed. True when this call expired it; false when it was already
** decided (confirmed and running or done, cancelled, expired), in which
** case nothing is written and the action stays what it is.
*******************************************************************************/
public final boolean expirePendingAction(String userId, Integer id, Instant now) throws QException
{
return (decidePendingAction(userId, id, AssistantPendingAction.STATUS_EXPIRED, EXPIRED_SUMMARY, now));
}
/*******************************************************************************
** Record how a claimed pending action ended.
*******************************************************************************/
public void finishPendingAction(Integer id, String status, Integer httpStatus, String resultSummary) throws QException
{
updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", status).withValue("httpStatus", httpStatus).withValue("resultSummary", resultSummary));
}
/*******************************************************************************
** Withdraw a pending action nobody was shown (the call that proposed it was
** cut short): it is claimed, so it can never be confirmed, and marked
** EXPIRED with the reason. One that was already decided is left as it is.
*******************************************************************************/
public void withdrawPendingAction(String userId, Integer id, String resultSummary, Instant now) throws QException
{
synchronized(DECISION_LOCK)
{
AssistantPendingAction action = getPendingAction(userId, id);
if(action == null || !(AssistantPendingAction.STATUS_PENDING.equals(action.getStatus()) || TranscriptRedactor.STATUS_PREPARING.equals(action.getStatus())))
{
return;
}
QQueryFilter claimed = new QQueryFilter().withCriteria("pendingActionId", QCriteriaOperator.EQUALS, id);
if(query(AssistantPendingActionClaim.TABLE_NAME, claimed, AssistantPendingActionClaim.class).isEmpty())
{
insert(new AssistantPendingActionClaim().withPendingActionId(id).withUserId(userId).withDecision(AssistantPendingAction.STATUS_EXPIRED).withCreateDate(now));
}
if(TranscriptRedactor.current() != null)
{
TranscriptRedactor.current().withdrawn(AssistantPendingAction.TABLE_NAME, id);
}
updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", AssistantPendingAction.STATUS_EXPIRED)
.withValue("decidedAt", now).withValue("resultSummary", resultSummary));
}
}
/*******************************************************************************
** Pending actions that were claimed for a confirmation before a time and
** are still RUNNING: the change was started and its end was never recorded
** (the server stopped in the middle). Whoever they belong to, oldest first,
** after an id.
*******************************************************************************/
public List<AssistantPendingAction> listPendingActionsRunningSince(Instant claimedBefore, Integer afterId, int limit) throws QException
{
return (query(AssistantPendingAction.TABLE_NAME, new QQueryFilter()
.withCriteria("status", QCriteriaOperator.EQUALS, AssistantPendingAction.STATUS_RUNNING)
.withCriteria("id", QCriteriaOperator.GREATER_THAN, afterId == null ? 0 : afterId)
.withSubFilters(List.of(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
.withCriteria("decidedAt", QCriteriaOperator.LESS_THAN, claimedBefore)
.withSubFilters(List.of(new QQueryFilter()
.withCriteria("decidedAt", QCriteriaOperator.IS_BLANK)
.withCriteria("createDate", QCriteriaOperator.LESS_THAN, claimedBefore)))))
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(limit), AssistantPendingAction.class));
}
/*******************************************************************************
** End a pending action that was left RUNNING as FAILED, when it still is
** RUNNING and was claimed before the time given: true when this call did.
** Its claim stays, so it can never be confirmed again.
*******************************************************************************/
public final boolean failAbandonedPendingAction(Integer id, Instant claimedBefore, String resultSummary) throws QException
{
synchronized(DECISION_LOCK)
{
AssistantPendingAction action = first(query(AssistantPendingAction.TABLE_NAME, new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id), AssistantPendingAction.class));
Instant claimed = action == null ? null : (action.getDecidedAt() != null ? action.getDecidedAt() : action.getCreateDate());
if(action == null || !AssistantPendingAction.STATUS_RUNNING.equals(action.getStatus()) || claimed == null || !claimed.isBefore(claimedBefore))
{
return (false);
}
updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", AssistantPendingAction.STATUS_FAILED).withValue("resultSummary", resultSummary));
return (true);
}
}
/*******************************************************************************
** A pending action was decided by this caller (its claim row is in), and
** its status could not be written. Nobody else can decide it.
*******************************************************************************/
public static final class DecidedButNotMarked extends QException
{
/*******************************************************************************
**
*******************************************************************************/
private DecidedButNotMarked(Throwable cause)
{
super("The pending action was decided, but its status could not be recorded.", cause);
}
}
/*******************************************************************************
** Delete pending actions created before a time that are decided, or were
** never decided and have lapsed, with their claims; returns how many. It
** does not matter whether an action belongs to a conversation. One that is
** still RUNNING is kept here: the retention process first ends the ones
** left RUNNING long ago as FAILED (engine.ToolCallService
** .failAbandonedActions), and those then go like any other decided action.
*******************************************************************************/
public int deletePendingActionsBefore(Instant cutoff, Instant now, int limit) throws QException
{
List<AssistantPendingAction> actions = query(AssistantPendingAction.TABLE_NAME, new QQueryFilter()
.withCriteria("createDate", QCriteriaOperator.LESS_THAN, cutoff)
.withSubFilters(List.of(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
.withCriteria("status", QCriteriaOperator.IN, DECIDED_STATUSES)
.withSubFilters(List.of(new QQueryFilter()
.withCriteria("status", QCriteriaOperator.EQUALS, AssistantPendingAction.STATUS_PENDING)
.withCriteria("expiresAt", QCriteriaOperator.LESS_THAN, now)))))
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(limit), AssistantPendingAction.class);
if(actions.isEmpty())
{
return (0);
}
List<Serializable> ids = new ArrayList<>();
actions.forEach(action -> ids.add(action.getId()));
delete(AssistantPendingActionClaim.TABLE_NAME, new QQueryFilter().withCriteria("pendingActionId", QCriteriaOperator.IN, ids));
new DeleteAction().execute(trusted(new DeleteInput(AssistantPendingAction.TABLE_NAME)).withPrimaryKeys(ids));
return (actions.size());
}
/*******************************************************************************
** The user's turns still running (any replica).
*******************************************************************************/
@@ -947,6 +1210,58 @@ public class AssistantStore
/*******************************************************************************
** What the usage rows of a filter add up to, in one aggregate query: the
** rows themselves are never read, however many there are. For the budget
** checks, which run before every model call and every tool call.
*******************************************************************************/
public UsageTotals sumUsage(String tableName, QQueryFilter filter) throws QException
{
requireOwnTable(tableName);
Aggregate cost = new Aggregate("costMicros", AggregateOperator.SUM);
Aggregate input = new Aggregate("inputTokens", AggregateOperator.SUM);
Aggregate output = new Aggregate("outputTokens", AggregateOperator.SUM);
Aggregate cacheRead = new Aggregate("cacheReadTokens", AggregateOperator.SUM);
Aggregate cacheWrite = new Aggregate("cacheWriteTokens", AggregateOperator.SUM);
List<AggregateResult> results = new AggregateAction().execute(trusted(new AggregateInput(tableName)).withFilter(filter)
.withAggregates(List.of(cost, input, output, cacheRead, cacheWrite))).getResults();
if(CollectionUtils.nullSafeIsEmpty(results))
{
return (new UsageTotals(0, 0, 0, 0, 0));
}
AggregateResult result = results.get(0);
return (new UsageTotals(sum(result, cost), sum(result, input), sum(result, output), sum(result, cacheRead), sum(result, cacheWrite)));
}
/*******************************************************************************
** One sum of an aggregate result; nothing to add up is zero.
*******************************************************************************/
private static long sum(AggregateResult result, Aggregate aggregate)
{
Long value = ValueUtils.getValueAsLong(result.getAggregateValue(aggregate));
return (value == null ? 0 : value);
}
/*******************************************************************************
** The sums of a set of usage rows.
*******************************************************************************/
public record UsageTotals(long costMicros, long inputTokens, long outputTokens, long cacheReadTokens, long cacheWriteTokens)
{
/*******************************************************************************
** Every token counted: input, output, cache reads and cache writes.
*******************************************************************************/
public long tokens()
{
return (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens);
}
}
/*******************************************************************************
** Raw rows of one of the qbit's tables (the app home's reads), at most
** `limit`.
@@ -52,8 +52,10 @@ import com.kingsrook.qqq.backend.core.utils.JsonUtils;
*******************************************************************************/
public class TranscriptRedactor implements LogRedactorInterface, AutoCloseable
{
public static final String STATUS_PREPARING = "PREPARING";
private static final ThreadLocal<TranscriptRedactor> CURRENT = new ThreadLocal<>();
private static final Set<String> PAYLOAD_FIELDS = Set.of("partsJson", "inputJson", "resultJson", "redactionsJson", "dataJson", "paramsJson",
private static final Set<String> PAYLOAD_FIELDS = Set.of("partsJson", "inputJson", "resultJson", "redactionsJson", "dataJson", "paramsJson", "beforeJson",
"summary", "resultSummary", "reason", "title", "contextJson", "errorMessage", "errorText", "error", "requestId", "comment", "providerRequestId", "providerRequestIds", "auditHref", "toolCallId", "stopReason");
private final ClassificationSource classifications;
private final QInstance instance;
@@ -339,7 +341,7 @@ public class TranscriptRedactor implements LogRedactorInterface, AutoCloseable
}
if(!flushing && copy.getValues().containsKey("paramsJson") && !AssistantPendingAction.STATUS_REJECTED.equals(copy.getValueString("status")))
{
copy.setValue("status", "PREPARING");
copy.setValue("status", STATUS_PREPARING);
}
return (copy);
}
@@ -405,6 +407,22 @@ public class TranscriptRedactor implements LogRedactorInterface, AutoCloseable
/*******************************************************************************
** A staged proposal was withdrawn: its values are never stored, and the
** flush does not make it confirmable.
*******************************************************************************/
public synchronized void withdrawn(String table, Serializable id)
{
QRecord content = pending.get(List.of(table, String.valueOf(id)));
if(content != null)
{
content.getValues().remove("paramsJson");
content.getValues().remove("beforeJson");
}
}
/*******************************************************************************
** Flush only redacted copies. A crash/failure leaves value-free placeholders.
** Redacted proposals cannot later submit token strings to an application field.
@@ -45,7 +45,14 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** (the audit row stays);
** - finalizes abandoned runs (still RUNNING past the hard stream limit, e.g.
** after a pod restart nobody looked at) as interrupted;
** - deletes client error reports older than toolResultRetentionDays.
** - deletes client error reports older than toolResultRetentionDays;
** - ends the confirmed changes that were left RUNNING (the server stopped
** while one ran) as FAILED, with "outcome unknown, check the record" and a
** tool-call log row, so that none stays RUNNING for ever and what it stored
** of the record is removed with the rest;
** - deletes pending actions older than approvalRetentionDays that were
** decided or have lapsed, whether or not they belong to a conversation
** (one in a conversation also goes when its conversation does).
** It works on the rows the running session can see: run on a schedule (the
** scheduler's session) or by an admin holding the owner all-access key.
*******************************************************************************/
@@ -67,13 +74,15 @@ public class AssistantRetentionStep implements BackendStep
{
throw (new QException("The assistant QBit is not configured in this instance."));
}
Result result = purge(runtime.getConfig(), runtime.getStore(), runtime.getClock().instant());
Result result = purge(runtime, runtime.getClock().instant());
output.addValue("conversationsDeleted", result.conversationsDeleted());
output.addValue("toolResultsCleared", result.toolResultsCleared());
output.addValue("turnEventsDeleted", result.turnEventsDeleted());
output.addValue("messagesStripped", result.messagesStripped());
output.addValue("turnsFinalized", result.turnsFinalized());
output.addValue("clientErrorsDeleted", result.clientErrorsDeleted());
output.addValue("pendingActionsDeleted", result.pendingActionsDeleted());
output.addValue("pendingActionsFailed", result.pendingActionsFailed());
}
@@ -81,14 +90,32 @@ public class AssistantRetentionStep implements BackendStep
/*******************************************************************************
** What was purged.
*******************************************************************************/
public record Result(int conversationsDeleted, int toolResultsCleared, int turnEventsDeleted, int messagesStripped, int turnsFinalized, int clientErrorsDeleted)
public record Result(int conversationsDeleted, int toolResultsCleared, int turnEventsDeleted, int messagesStripped, int turnsFinalized, int clientErrorsDeleted, int pendingActionsDeleted,
int pendingActionsFailed)
{
}
/*******************************************************************************
** Purge relative to `now`.
** The whole retention run relative to `now`: first the confirmed changes
** left RUNNING are ended as FAILED (the tool-call service does it, and
** writes each one's log row), then everything else is purged, so an action
** that was left RUNNING is removed like any other once it is old enough.
*******************************************************************************/
public static Result purge(AssistantRuntime runtime, Instant now) throws QException
{
int failed = runtime.getToolCalls().failAbandonedActions(now);
Result purged = purge(runtime.getConfig(), runtime.getStore(), now);
return (new Result(purged.conversationsDeleted(), purged.toolResultsCleared(), purged.turnEventsDeleted(), purged.messagesStripped(), purged.turnsFinalized(),
purged.clientErrorsDeleted(), purged.pendingActionsDeleted(), failed));
}
/*******************************************************************************
** Purge relative to `now`: everything but ending the confirmed changes left
** RUNNING, which needs the runtime (see the other purge).
*******************************************************************************/
public static Result purge(AssistantQBitConfig config, AssistantStore store, Instant now) throws QException
{
@@ -179,10 +206,27 @@ public class AssistantRetentionStep implements BackendStep
}
}
///////////////////////////////////////////////////////////////////////
// the record of what was proposed and what became of it is kept for //
// approvalRetentionDays, then removed: a proposal made outside a //
// conversation has nothing else that would ever remove it //
///////////////////////////////////////////////////////////////////////
Instant approvalCutoff = now.minus(Duration.ofDays(config.getApprovalRetentionDays()));
int pendingActionsDeleted = 0;
while(true)
{
int count = store.deletePendingActionsBefore(approvalCutoff, now, BATCH);
pendingActionsDeleted += count;
if(count < BATCH)
{
break;
}
}
LOG.info("Assistant retention ran", logPair("conversationsDeleted", deleted), logPair("toolResultsCleared", cleared),
logPair("turnEventsDeleted", eventsDeleted), logPair("messagesStripped", messagesStripped), logPair("turnsFinalized", turnsFinalized),
logPair("clientErrorsDeleted", clientErrorsDeleted));
return (new Result(deleted, cleared, eventsDeleted, messagesStripped, turnsFinalized, clientErrorsDeleted));
logPair("clientErrorsDeleted", clientErrorsDeleted), logPair("pendingActionsDeleted", pendingActionsDeleted));
return (new Result(deleted, cleared, eventsDeleted, messagesStripped, turnsFinalized, clientErrorsDeleted, pendingActionsDeleted, 0));
}
}
@@ -1,268 +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.processes;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.access.DataAccessService;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** "Add rows for every table": a table action on assistant_data_access that
** writes one row per application table that has none yet, with the access
** the assistant already gets by default (READ, or NONE where the table's AI
** exposure is NONE). It changes no behavior on its own; it makes the defaults
** visible and editable. Existing rows are never touched. Standard streamed
** ETL: Review lists what will be added, Result what was added.
*******************************************************************************/
public final class DataAccessDefaultsProcess
{
public static final String NAME = "assistantDataAccessAddDefaults";
public static final String NOTE = "Added by 'Add rows for every table': the assistant's default for this table. The user's own permissions still apply.";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private DataAccessDefaultsProcess()
{
}
/*******************************************************************************
** The process: a table-level action (no records need to be selected).
*******************************************************************************/
public static QProcessMetaData process()
{
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, AssistantDataAccess.TABLE_NAME);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, AssistantDataAccess.TABLE_NAME);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, false);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
"These rows will be added: one per table that has none, with the access the assistant already has by default. Existing rows are not changed.");
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values);
process.setName(NAME);
process.setLabel("Add rows for every table");
process.setIcon(new QIcon("playlist_add"));
process.setTableName(AssistantDataAccess.TABLE_NAME);
process.setMinInputRecords(0);
process.setPermissionRules(AssistantMetaDataBuilder.adminRules());
return (process);
}
/*******************************************************************************
** The rows that are missing: one per application table without a row.
*******************************************************************************/
public static List<AssistantDataAccess> missingRows(QInstance qInstance, DataAccessService access, AssistantStore store) throws QException
{
////////////////////////////////////////////////////////////////////
// fresh rows: a row deleted on another server must not come back //
// from this server's cache as a "default" //
////////////////////////////////////////////////////////////////////
access.invalidate();
Set<String> existing = new HashSet<>();
for(AssistantDataAccess row : store.query(AssistantDataAccess.TABLE_NAME, new QQueryFilter().withLimit(10_000), AssistantDataAccess.class))
{
if(row.getObjectType() == null || AssistantDataAccess.TYPE_TABLE.equals(row.getObjectType()))
{
existing.add(row.getObjectName());
}
}
List<AssistantDataAccess> rs = new ArrayList<>();
for(QTableMetaData table : qInstance.getTables().values().stream().sorted((a, b) -> a.getName().compareTo(b.getName())).toList())
{
if(table.getName().startsWith("assistant_") || existing.contains(table.getName()))
{
continue;
}
DataAccessMode mode = access.forTable(table.getName()).mode();
rs.add(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName(table.getName())
.withMode(mode.name()).withNotes(NOTE));
}
return (rs);
}
/*******************************************************************************
** The running assistant (or a clear error).
*******************************************************************************/
private static AssistantRuntime runtime() throws QException
{
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
if(runtime == null)
{
throw (new QException("The assistant is not running on this server."));
}
return (runtime);
}
/*******************************************************************************
** Extract: the missing rows, computed (no query of the selected records).
*******************************************************************************/
public static class Extract extends AbstractExtractStep
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public Integer doCount(RunBackendStepInput input) throws QException
{
AssistantRuntime runtime = runtime();
return (missingRows(QContext.getQInstance(), runtime.getDataAccessService(), runtime.getStore()).size());
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
AssistantRuntime runtime = runtime();
for(AssistantDataAccess row : missingRows(QContext.getQInstance(), runtime.getDataAccessService(), runtime.getStore()))
{
getRecordPipe().addRecord(row.toQRecord());
}
}
}
/*******************************************************************************
** Transform: counts by mode for the review screen.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final Map<String, ProcessSummaryLine> byMode = new HashMap<>();
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord record : input.getRecords())
{
String mode = record.getValueString("mode");
byMode.computeIfAbsent(mode, m -> DiscoverModelsProcess.line("NONE".equals(m) ? Status.INFO : Status.OK,
"table will get a row with access " + m, "tables will get a row with access " + m,
"table got a row with access " + m, "tables got a row with access " + m)).incrementCountAndAddPrimaryKey(record.getValue("objectName"));
output.getRecords().add(record);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
byMode.values().forEach(line -> line.addSelfToListIfAnyCount(lines));
if(lines.isEmpty())
{
lines.add(new ProcessSummaryLine(Status.INFO, null, "Every table already has a row."));
}
return (lines);
}
}
/*******************************************************************************
** Load: insert through the store (the user's own session; admin only).
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine added = DiscoverModelsProcess.line(Status.OK, "row was added", "rows were added", "row was added", "rows were added");
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
AssistantRuntime runtime = runtime();
for(QRecord record : input.getRecords())
{
AssistantDataAccess row = runtime.getStore().insert(new AssistantDataAccess(record));
added.incrementCountAndAddPrimaryKey(row.getId());
output.getRecords().add(row.toQRecord());
}
runtime.getDataAccessService().invalidate();
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
added.addSelfToListIfAnyCount(lines);
return (lines);
}
}
}
@@ -126,6 +126,10 @@ public class EmbeddingService
{
throw (new QException("The assistant kill switch is on; embeddings were not sent."));
}
if(!runtime.getBudgetService().limitInForce(userId, active, policy.budgets()))
{
throw (new QException("No spending limit is in force; embeddings were not sent. An administrator must set or switch on a budget."));
}
if(budget.stopped() || !Objects.equals(active.model(), budget.active().model()) || !Objects.equals(active.providerId(), budget.active().providerId())
|| runtime.getPolicyService().checkConfiguredBudget(policy, userId, 0, 0).hardExceeded()
|| (cap != null && runtime.getProviderService().monthToDateCostMicros(active.providerId()) >= cap))
@@ -26,10 +26,11 @@ import java.util.Objects;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.SignedInPerson;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
@@ -54,6 +55,7 @@ public class RagService
{
private final AssistantRuntime runtime;
private final VectorStore vectors;
private final UiApiGateway gateway;
private final AiExposurePolicy exposure;
private final UiApiToolbox reader;
private final EmbeddingService embeddings;
@@ -64,10 +66,11 @@ public class RagService
** Reuse get_record's helpers through its public execution path, without
** imposing the chat cell-length cap on indexing or snippet boundaries.
*******************************************************************************/
public RagService(AssistantRuntime runtime, VectorStore vectors)
public RagService(AssistantRuntime runtime, VectorStore vectors, UiApiGateway gateway)
{
this.runtime = runtime;
this.vectors = vectors;
this.gateway = gateway;
var settings = AssistantRuntime.kernelSettings(runtime.getConfig());
exposure = new AiExposurePolicy(settings);
reader = new UiApiToolbox(runtime.getQInstance(), runtime.getDataAccessService(), exposure, new AiRedactor(settings.getCustomRedactionPatterns()), Integer.MAX_VALUE);
@@ -140,13 +143,13 @@ public class RagService
/*******************************************************************************
** Transient safe values from get_record, with the asking user's session.
** The context is one the tool-call service made: of the search_knowledge
** call this read is part of (which the service checked and logs), or of
** the administrator's index run (ToolCallService.readContext).
*******************************************************************************/
public UiApiToolbox.Outcome read(UiApiToolbox.Context context, String table, String pk)
{
PolicyService.ToolPolicy policy = context.policy();
UiApiToolbox.Context internal = new UiApiToolbox.Context(context.sessionUUID(), context.userTimezone(), context.gateway(),
new PolicyService.ToolPolicy(true, Set.of(), policy == null ? 50 : policy.maxRowsPerCall(), policy == null || policy.redactPii()), null, context.credentials(), context.turnId());
return (reader.execute(internal, "knowledge-read", UiApiToolbox.GET_RECORD, Map.of("table", table, "id", pk)));
return (reader.read(context.forReadWhateverTheToolSwitches(), "knowledge-read", UiApiToolbox.GET_RECORD, Map.of("table", table, "id", pk)));
}
@@ -222,7 +225,7 @@ public class RagService
{
checkKillSwitch();
filter.setSkip(skip);
UiApiResult page = runtime.getUiApiGateway().invoke(new UiApiCall("POST", "/table/{tableName}/query").withPathParam("tableName", source.getTableName())
UiApiResult page = gateway.invoke(new UiApiCall("POST", "/table/{tableName}/query").withPathParam("tableName", source.getTableName())
.withSessionUUID(context.sessionUUID()).withCredentials(context.credentials()).withUserTimezone(context.userTimezone()).withJsonBody("{\"filter\":" + JsonUtils.toJson(filter) + "}"));
if(!page.isSuccess())
{
@@ -376,11 +379,24 @@ public class RagService
/*******************************************************************************
** Current caller context, never a stored or service session.
** The context of the index run's reads: the administrator who started it,
** never a stored or service session. Their session is confirmed by the
** authentication module (SignedInPerson.ofSession) and the tool-call
** service makes the context, after its checks of assistant access and the
** kill switch, with the policy it reads itself.
*******************************************************************************/
public UiApiToolbox.Context context() throws QException
UiApiToolbox.Context context() throws QException
{
return (new UiApiToolbox.Context(QContext.getQSession().getUuid(), QContext.getQSession().getValue("userTimezone"), runtime.getUiApiGateway(), runtime.getPolicyService().current(true).tools(), null));
SignedInPerson person;
try
{
person = SignedInPerson.ofSession(runtime.getQInstance(), QContext.getQSession());
}
catch(IllegalArgumentException e)
{
throw (new QException("Knowledge indexing runs as the signed-in administrator who started it; this session could not be confirmed as one."));
}
return (runtime.getToolCalls().readContext(person, null));
}
@@ -24,8 +24,10 @@ package com.kingsrook.qbits.assistant.scheduled;
import java.time.Instant;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.SignedInPerson;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.api.AssistantApiException;
import com.kingsrook.qbits.assistant.engine.BudgetService;
import com.kingsrook.qbits.assistant.engine.MessageParts;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.engine.RunningTurn;
@@ -40,7 +42,6 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
@@ -119,7 +120,7 @@ public class ScheduledTaskRunner
{
session = login.login(runtime, task);
QContext.init(runtime.getQInstance(), session);
AssistantApi api = new AssistantApi(runtime, session).withCredentials(UiApiCredentials.ofSessionUUID(session.getUuid()));
AssistantApi api = new AssistantApi(runtime, SignedInPerson.ofSession(runtime.getQInstance(), session));
Integer conversationId = ((Number) api.createConversation(Map.of("title", task.getName())).get("id")).intValue();
run.setConversationId(conversationId);
store.updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", conversationId)
@@ -148,7 +149,13 @@ public class ScheduledTaskRunner
}
catch(AssistantApiException e)
{
run.withStatus(blocked(e.getCode()) ? "BLOCKED" : "FAILED").withError("Turn could not start: " + e.getCode());
///////////////////////////////////////////////////////////////////////
// a turn refused because no spending limit is in force says so in //
// full (a fixed sentence, no data): the administrator who reads the //
// run must know a budget has to be set or switched on //
///////////////////////////////////////////////////////////////////////
boolean noLimit = BudgetService.NO_LIMIT_MESSAGE.equals(e.getMessage());
run.withStatus(blocked(e.getCode()) ? "BLOCKED" : "FAILED").withError("Turn could not start: " + (noLimit ? BudgetService.NO_LIMIT_MESSAGE : e.getCode()));
}
catch(InterruptedException e)
{
@@ -110,7 +110,7 @@ public class ScheduledTurn
{
allowed = Set.of("__no_tools__");
}
return (new PolicyService.ToolPolicy(true, allowed, policy.maxRowsPerCall(), policy.redactPii()));
return (new PolicyService.ToolPolicy(allowed, policy.maxRowsPerCall(), policy.redactPii()));
}
@@ -185,8 +185,11 @@ public class AnalysisTools
/*******************************************************************************
** Whether save_analysis is offered: the extension, the saved-analysis table,
** and an administrator who allowed the assistant to write to it.
** Whether save_analysis can be offered at all: the extension, the
** saved-analysis table, and data access that allows writes to it (the
** default; an administrator's row or a pin in code can take it away). Which
** person is offered it also depends on whether they may save an analysis
** themselves (UiApiToolbox.definitions).
*******************************************************************************/
public boolean savingOffered()
{
@@ -799,8 +802,7 @@ public class AnalysisTools
DataAccessService.Decision decision = dataAccess.forTable(tableName);
if(qInstance.getTable(tableName) == null || !decision.mode().allowsWrite())
{
UiApiToolbox.deny(outcome.getResult(), "dataAccess:" + decision.mode(), "The assistant may not " + what + " here (data access " + decision.mode() + " on " + tableLabel(tableName) + ", " + decision.source()
+ "). An administrator sets it to READ_WRITE.");
UiApiToolbox.deny(outcome.getResult(), "dataAccess:" + decision.mode(), "The assistant may not " + what + " here (assistant access " + decision.mode() + " on " + tableLabel(tableName) + ": " + decision.source() + ").");
return (false);
}
return (true);
@@ -101,7 +101,7 @@ public class KnowledgeTool
{
return (false);
}
return (runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), null, policy, null, null, turnId).stream()
return (runtime.getAccessExplainer().explain(session.getUuid(), null, policy, null, null, turnId).stream()
.anyMatch(t -> tables.contains(t.table()) && t.uiRead() && t.mode().allowsRead()));
}
catch(QException e)
@@ -127,13 +127,13 @@ public class KnowledgeTool
/*******************************************************************************
** Expected denials disclose only a filtered count, never hit identifiers.
*******************************************************************************/
public AiToolResult execute(UiApiToolbox.Context context, String callId, Map<String, Object> input, DataClassification classification, Integer conversationId, Integer turnId)
AiToolResult execute(UiApiToolbox.Context context, String callId, Map<String, Object> input, DataClassification classification, Integer conversationId, Integer turnId)
{
AiToolResult result = new AiToolResult().withToolName(NAME).withToolCallId(callId);
Long started = System.nanoTime();
try
{
if(context.policy() != null && !context.policy().allows(NAME))
if(context.policy() == null || !context.policy().allows(NAME))
{
return (result.withOutcome(AiToolOutcome.DENIED).withSummary("Not allowed").withModelContent("The policy does not allow search_knowledge."));
}
@@ -0,0 +1,162 @@
/*
* 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.List;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.tools.registry.AssistantTool;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallRequest;
import com.kingsrook.qbits.assistant.tools.registry.ToolCheck;
import com.kingsrook.qbits.assistant.tools.registry.ToolFamily;
import com.kingsrook.qbits.assistant.tools.registry.ToolKind;
import com.kingsrook.qbits.assistant.tools.registry.ToolOffer;
/*******************************************************************************
** search_knowledge ({@link KnowledgeTool}) as a registered tool.
** KnowledgeTool keeps all of its behaviour.
*******************************************************************************/
public class KnowledgeToolFamily implements ToolFamily
{
private static final List<ToolCheck> CHECKS = List.of(ToolCheck.TOOL_SWITCH, ToolCheck.DATA_ACCESS, ToolCheck.CLASSIFICATION, ToolCheck.PERSON_PERMISSIONS, ToolCheck.FIELD_EXPOSURE);
private final KnowledgeTool knowledgeTool;
/*******************************************************************************
** The knowledge tool is handed over by the runtime that made it; nothing
** outside this package can reach it to run it around the service.
*******************************************************************************/
public KnowledgeToolFamily(KnowledgeTool knowledgeTool)
{
this.knowledgeTool = knowledgeTool;
}
/*******************************************************************************
** The knowledge tool, for this package (and its tests).
*******************************************************************************/
KnowledgeTool knowledgeTool()
{
return (knowledgeTool);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<AssistantTool> tools(AssistantRuntime runtime)
{
return (List.of(new Tool(runtime, knowledgeTool)));
}
/*******************************************************************************
** The knowledge search.
*******************************************************************************/
private static final class Tool implements AssistantTool
{
private final AssistantRuntime runtime;
private final KnowledgeTool tool;
/*******************************************************************************
**
*******************************************************************************/
private Tool(AssistantRuntime runtime, KnowledgeTool tool)
{
this.runtime = runtime;
this.tool = tool;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String name()
{
return (KnowledgeTool.NAME);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ToolKind kind()
{
return (ToolKind.READ);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<ToolCheck> checks()
{
return (CHECKS);
}
/*******************************************************************************
** Offered to a person who can read an enabled source's table on screen.
*******************************************************************************/
@Override
public ModelToolDefinition definition(ToolOffer offer)
{
return (tool.offered(offer.policy(), offer.session(), offer.turnId()) ? tool.definition() : null);
}
/*******************************************************************************
** Every hit is read again through the UI API with the person's session.
*******************************************************************************/
@Override
public ToolCallOutcome execute(ToolCallRequest request)
{
return (request.inPersonsContext(runtime.getQInstance(), () -> ToolCallOutcome.of(tool.execute(request.context(), request.toolCallId(), request.input(),
request.scope().classification(), request.scope().conversationId(), request.scope().turnId()))));
}
/*******************************************************************************
** The operation each hit is read with.
*******************************************************************************/
@Override
public String operationWithoutDetail()
{
return ("GET /table/{tableName}/{primaryKey}");
}
}
}
@@ -117,7 +117,7 @@ public class PresentResultsTool
/*******************************************************************************
** Preserve valid items when others are dropped; never echo rejected values.
*******************************************************************************/
public synchronized AiToolResult execute(UiApiToolbox.Context context, String callId, Map<String, Object> input)
synchronized AiToolResult execute(UiApiToolbox.Context context, String callId, Map<String, Object> input)
{
AiToolResult result = new AiToolResult().withToolCallId(callId).withToolName(NAME);
try
@@ -0,0 +1,147 @@
/*
* 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.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.tools.registry.AssistantTool;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallRequest;
import com.kingsrook.qbits.assistant.tools.registry.ToolCheck;
import com.kingsrook.qbits.assistant.tools.registry.ToolFamily;
import com.kingsrook.qbits.assistant.tools.registry.ToolKind;
import com.kingsrook.qbits.assistant.tools.registry.ToolOffer;
import com.kingsrook.qbits.assistant.tools.registry.ToolRegistry;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
/*******************************************************************************
** present_results ({@link PresentResultsTool}) as a registered tool.
** PresentResultsTool keeps all of its behaviour.
*******************************************************************************/
public class PresentResultsToolFamily implements ToolFamily
{
///////////////////////////////////////////////////////////////////////////
// the tables a presentation links to are checked against the metadata //
// the person's own session is given //
///////////////////////////////////////////////////////////////////////////
private static final List<ToolCheck> CHECKS = List.of(ToolCheck.PERSON_PERMISSIONS);
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<AssistantTool> tools(AssistantRuntime runtime)
{
return (List.of(new Tool(runtime)));
}
/*******************************************************************************
** The presentation.
*******************************************************************************/
private static final class Tool implements AssistantTool
{
private final AssistantRuntime runtime;
/*******************************************************************************
**
*******************************************************************************/
private Tool(AssistantRuntime runtime)
{
this.runtime = runtime;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String name()
{
return (PresentResultsTool.NAME);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ToolKind kind()
{
return (ToolKind.DISPLAY);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<ToolCheck> checks()
{
return (CHECKS);
}
/*******************************************************************************
** Always offered: the policy's allowed tools do not switch it off.
*******************************************************************************/
@Override
public ModelToolDefinition definition(ToolOffer offer)
{
return (PresentResultsTool.definition(ToolRegistry.isStrict(runtime.getConfig(), PresentResultsTool.NAME, true)));
}
/*******************************************************************************
** One presenter per scope (in chat: per turn), shared by its calls.
*******************************************************************************/
@Override
public ToolCallOutcome execute(ToolCallRequest request)
{
PresentResultsTool presenter = request.scope().state(PresentResultsTool.class, () -> new PresentResultsTool(runtime.getQInstance()));
return (ToolCallOutcome.of(presenter.execute(request.context(), request.toolCallId(), request.input())));
}
/*******************************************************************************
** The log keeps the presentation that was shown (checked and cut to what
** the person may see), never the model's raw input; nothing for a call
** that showed nothing.
*******************************************************************************/
@Override
public Map<String, Object> inputForLog(Map<String, Object> input, AiToolResult result)
{
return (AiToolOutcome.OK.equals(result.getOutcome()) && result.getRender() != null ? result.getRender() : Map.of());
}
}
}
@@ -0,0 +1,313 @@
/*
* 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.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.access.AccessExplainer;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.tools.registry.AssistantTool;
import com.kingsrook.qbits.assistant.tools.registry.ConfirmedToolCall;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallRequest;
import com.kingsrook.qbits.assistant.tools.registry.ToolCheck;
import com.kingsrook.qbits.assistant.tools.registry.ToolFamily;
import com.kingsrook.qbits.assistant.tools.registry.ToolKind;
import com.kingsrook.qbits.assistant.tools.registry.ToolOffer;
import com.kingsrook.qbits.assistant.tools.registry.ToolRegistry;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
import static com.kingsrook.qbits.assistant.tools.registry.ToolCheck.CONFIRMATION;
import static com.kingsrook.qbits.assistant.tools.registry.ToolCheck.DATA_ACCESS;
import static com.kingsrook.qbits.assistant.tools.registry.ToolCheck.FIELD_EXPOSURE;
import static com.kingsrook.qbits.assistant.tools.registry.ToolCheck.PERSON_PERMISSIONS;
import static com.kingsrook.qbits.assistant.tools.registry.ToolCheck.SPECD_OPERATION;
import static com.kingsrook.qbits.assistant.tools.registry.ToolCheck.TOOL_SWITCH;
/*******************************************************************************
** The UI API data tools ({@link UiApiToolbox}, with the analysis tools it
** carries) as registered tools: one per operation the toolbox knows. The
** toolbox keeps all of their behaviour; this class only says which tools
** there are and hands each call to it.
*******************************************************************************/
public class UiApiToolFamily implements ToolFamily
{
private static final QLogger LOG = QLogger.getLogger(UiApiToolFamily.class);
private static final Object WRITABLE_TABLES = new Object();
private static final List<ToolCheck> READ_THEN_EXPOSE = List.of(TOOL_SWITCH, SPECD_OPERATION, DATA_ACCESS, PERSON_PERMISSIONS, FIELD_EXPOSURE);
private static final List<ToolCheck> EXPOSE_THEN_READ = List.of(TOOL_SWITCH, SPECD_OPERATION, DATA_ACCESS, FIELD_EXPOSURE, PERSON_PERMISSIONS);
private static final List<ToolCheck> PROPOSED_WRITE = List.of(TOOL_SWITCH, SPECD_OPERATION, DATA_ACCESS, FIELD_EXPOSURE, CONFIRMATION, PERSON_PERMISSIONS);
///////////////////////////////////////////////////////////////////////////
// the checks of each tool, in the order the toolbox first applies them //
///////////////////////////////////////////////////////////////////////////
private static final Map<String, List<ToolCheck>> CHECKS = Map.ofEntries(
Map.entry(UiApiToolbox.LIST_TABLES, List.of(TOOL_SWITCH, SPECD_OPERATION, PERSON_PERMISSIONS, DATA_ACCESS)),
Map.entry(UiApiToolbox.DESCRIBE_TABLE, READ_THEN_EXPOSE),
Map.entry(UiApiToolbox.QUERY_RECORDS, EXPOSE_THEN_READ),
Map.entry(UiApiToolbox.COUNT_RECORDS, EXPOSE_THEN_READ),
Map.entry(UiApiToolbox.GET_RECORD, READ_THEN_EXPOSE),
Map.entry(UiApiToolbox.SEARCH_RECORDS, EXPOSE_THEN_READ),
Map.entry(UiApiToolbox.RENDER_WIDGET, List.of(TOOL_SWITCH, SPECD_OPERATION, PERSON_PERMISSIONS)),
Map.entry(UiApiToolbox.RECORD_HISTORY, READ_THEN_EXPOSE),
Map.entry(UiApiToolbox.CREATE_RECORD, PROPOSED_WRITE),
Map.entry(UiApiToolbox.UPDATE_RECORD, PROPOSED_WRITE),
Map.entry(UiApiToolbox.DELETE_RECORD, PROPOSED_WRITE),
Map.entry(AnalysisTools.ANALYZE_RECORDS, EXPOSE_THEN_READ),
Map.entry(AnalysisTools.SAVE_ANALYSIS, List.of(TOOL_SWITCH, SPECD_OPERATION, DATA_ACCESS, FIELD_EXPOSURE, PERSON_PERMISSIONS, CONFIRMATION)));
private final UiApiToolbox toolbox;
/*******************************************************************************
** The toolbox is handed over by the runtime that made it; nothing outside
** this package can reach it to run a tool around the service.
*******************************************************************************/
public UiApiToolFamily(UiApiToolbox toolbox)
{
this.toolbox = toolbox;
}
/*******************************************************************************
** The tables the person of an offer may change through the assistant now:
** those the UI API says that person may create, edit or delete records in
** (GET /metaData, asked as the person: the answer the screens get) and
** whose data access allows writes. Worked out once per offer.
**
** The write tools are offered to a person only when this is not empty, so
** they follow the person's own permissions with no settings row, and a
** person who may change nothing is not offered them. When the UI API
** cannot be asked, or does not answer within the time one tool call may
** take (toolCallTimeoutSeconds), the answer is "none": no write tool is
** offered, and the question never holds up the answer it is asked for.
*******************************************************************************/
public static Set<String> writableTables(AssistantRuntime runtime, ToolOffer offer)
{
return (offer.once(WRITABLE_TABLES, () ->
{
Set<String> writable = new TreeSet<>();
if(offer.session() == null || offer.session().getUuid() == null)
{
return (writable);
}
Future<List<AccessExplainer.TableAccess>> asked = null;
try
{
QInstance instance = QContext.getQInstance() == null ? runtime.getQInstance() : QContext.getQInstance();
QSession session = QContext.getQSession() == null ? offer.session() : QContext.getQSession();
Integer seconds = runtime.getConfig().getToolCallTimeoutSeconds();
asked = runtime.getExecutor().submit(() ->
{
QContext.init(instance, session);
try
{
return (runtime.getAccessExplainer().explain(offer.session().getUuid(), offer.credentials(), offer.policy(), null, null, offer.turnId()));
}
finally
{
QContext.clear();
}
});
for(AccessExplainer.TableAccess table : asked.get(seconds == null ? 60 : seconds, TimeUnit.SECONDS))
{
if(table.mode().allowsWrite() && table.uiVisible() && (table.uiInsert() || table.uiEdit() || table.uiDelete()))
{
writable.add(table.table());
}
}
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
asked.cancel(true);
writable.clear();
}
catch(TimeoutException | ExecutionException | RuntimeException e)
{
if(asked != null)
{
asked.cancel(true);
}
LOG.warn("Could not ask the UI API what the person may change; offering no write tool (fail closed)", logPair("errorClass", e.getClass().getSimpleName()));
writable.clear();
}
return (writable);
}));
}
/*******************************************************************************
** The toolbox, for this package (and its tests).
*******************************************************************************/
UiApiToolbox toolbox()
{
return (toolbox);
}
/*******************************************************************************
** One tool per operation of the toolbox (the optional extensions' included;
** whether a host has them is decided when the tool is offered).
*******************************************************************************/
@Override
public List<AssistantTool> tools(AssistantRuntime runtime)
{
TreeSet<String> names = new TreeSet<>(UiApiToolbox.OPERATIONS.keySet());
names.addAll(UiApiToolbox.EXTENSION_OPERATIONS.keySet());
List<AssistantTool> tools = new ArrayList<>();
for(String name : names)
{
tools.add(new Tool(runtime, toolbox, name));
}
return (tools);
}
/*******************************************************************************
** One UI API data tool.
*******************************************************************************/
private static final class Tool implements AssistantTool
{
private final AssistantRuntime runtime;
private final UiApiToolbox toolbox;
private final String name;
/*******************************************************************************
**
*******************************************************************************/
private Tool(AssistantRuntime runtime, UiApiToolbox toolbox, String name)
{
this.runtime = runtime;
this.toolbox = toolbox;
this.name = name;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String name()
{
return (name);
}
/*******************************************************************************
** The record writes and save_analysis propose; everything else reads.
*******************************************************************************/
@Override
public ToolKind kind()
{
return (UiApiToolbox.WRITE_TOOLS.contains(name) || AnalysisTools.WRITE_TOOLS.contains(name) ? ToolKind.PROPOSE : ToolKind.READ);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<ToolCheck> checks()
{
return (CHECKS.get(name));
}
/*******************************************************************************
** The toolbox's definitions for the policy are built once per offer (the
** write tools only when the person may change some table: see
** {@link UiApiToolFamily#writableTables}).
*******************************************************************************/
@Override
public ModelToolDefinition definition(ToolOffer offer)
{
Map<String, ModelToolDefinition> offered = offer.once(UiApiToolFamily.class, () ->
{
Map<String, ModelToolDefinition> byName = new HashMap<>();
///////////////////////////////////////////////////////////////////
// what the person may change is asked only when the policy lets //
// a tool that needs the answer be offered at all //
///////////////////////////////////////////////////////////////////
boolean asks = offer.allows(AnalysisTools.SAVE_ANALYSIS) || UiApiToolbox.WRITE_TOOLS.stream().anyMatch(offer::allows);
for(ModelToolDefinition definition : toolbox.definitions(offer.policy(), asks ? writableTables(runtime, offer) : Set.of()))
{
byName.put(definition.getName(), definition.withStrict(ToolRegistry.isStrict(runtime.getConfig(), definition.getName(), true)));
}
return (byName);
});
return (offered.get(name));
}
/*******************************************************************************
** A /qqq/v1 call with the person's session.
*******************************************************************************/
@Override
public ToolCallOutcome execute(ToolCallRequest request)
{
return (request.inPersonsContext(runtime.getQInstance(), () ->
ToolCallOutcome.of(toolbox.execute(request.context(), request.toolCallId(), request.toolName(), request.input()))));
}
/*******************************************************************************
** A saved analysis and its schedule are created as, and emailed to, the
** person who confirms; every other name goes to the record write, which
** refuses a tool that is not one.
*******************************************************************************/
@Override
public UiApiToolbox.Outcome executeConfirmed(ConfirmedToolCall call)
{
if(AnalysisTools.WRITE_TOOLS.contains(name))
{
return (toolbox.executeConfirmedAnalysis(call.context(), call.toolCallId(), call.toolName(), call.values(), call.userId()));
}
return (toolbox.executeConfirmedWrite(call.context(), call.toolCallId(), call.toolName(), call.tableName(), call.recordId(), call.values(), call.recordVersion()));
}
}
}
@@ -18,20 +18,28 @@ package com.kingsrook.qbits.assistant.tools;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import com.kingsrook.qbits.assistant.persistence.TranscriptRedactor;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.access.DataAccessService;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.ToolCallService;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.tools.registry.ActionKinds;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
@@ -74,9 +82,12 @@ import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
**
** On top of that, and never instead of it:
** - the data access mode per table ({@link DataAccessService}): NONE hides
** the table, READ allows read tools, READ_WRITE adds the write tools;
** - the policy: allowed tools, max rows per call, PII redaction, and whether
** writes wait for the user's confirmation (a pending action);
** the table, READ allows read tools, READ_WRITE (the default, declared in
** code) lets the write tools propose what the person may change
** themselves; an administrator's row can only narrow it;
** - the policy: allowed tools, max rows per call, PII redaction. A change
** always waits for the user's confirmation (a pending action); no policy
** switches that off;
** - the AI field exposure from table metadata: NONE and METADATA_ONLY values
** never reach the model, MASKED values are masked.
**
@@ -136,8 +147,17 @@ public class UiApiToolbox
Map.entry(AnalysisTools.ANALYZE_RECORDS, new String[] {"POST", AnalysisTools.PIVOT_PATH}),
Map.entry(AnalysisTools.SAVE_ANALYSIS, new String[] {"POST", AnalysisTools.PIVOT_PATH}));
/////////////////////////////////////////////////////////////////////////
// what the assistant may read for itself through read(): no tool that //
// proposes or changes anything //
/////////////////////////////////////////////////////////////////////////
public static final Set<String> ASSISTANT_READS = Set.of(LIST_TABLES, DESCRIBE_TABLE, COUNT_RECORDS, GET_RECORD);
public static final int DEFAULT_LIMIT = 20;
public static final String CHECK_STALE = "pending:stale";
public static final String STALE_MESSAGE = "This record changed after the change was proposed. Nothing was changed.";
private final QInstance qInstance;
private final DataAccessService dataAccess;
private final AiExposurePolicy exposurePolicy;
@@ -164,14 +184,49 @@ public class UiApiToolbox
/*******************************************************************************
** Where a tool call runs: the user's session, the gateway, the policy, and
** what to do with a write that needs confirmation.
** what to do with a change that needs confirmation.
**
** There is no public constructor, and no public way to make one from a
** session id, a gateway or a policy the caller chose: every context is made
** by engine.ToolCallService with its permit ({@link #of}), for a person it
** checked, with the gateway the runtime handed it and the policy it read.
** The context of a read the assistant makes for itself is one of those too
** (ToolCallService.readContext); {@link #forRead} and
** {@link #forReadWhateverTheToolSwitches} only derive a narrower one from a
** context that already exists.
*******************************************************************************/
public record Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions, UiApiCredentials credentials, String turnId)
public static final class Context
{
private final String sessionUUID;
private final String userTimezone;
private final UiApiGateway gateway;
private final PolicyService.ToolPolicy policy;
private final PendingActions pendingActions;
private final UiApiCredentials credentials;
private final String turnId;
/*******************************************************************************
** For this package only (and its tests).
*******************************************************************************/
Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions, UiApiCredentials credentials, String turnId)
{
this.sessionUUID = sessionUUID;
this.userTimezone = userTimezone;
this.gateway = gateway;
this.policy = policy;
this.pendingActions = pendingActions;
this.credentials = credentials;
this.turnId = turnId;
}
/*******************************************************************************
** With the session UUID only (no request credentials captured).
*******************************************************************************/
public Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions)
Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions)
{
this(sessionUUID, userTimezone, gateway, policy, pendingActions, null, null);
}
@@ -181,13 +236,79 @@ public class UiApiToolbox
/*******************************************************************************
** With the request credentials, outside a turn.
*******************************************************************************/
public Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions, UiApiCredentials credentials)
Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions, UiApiCredentials credentials)
{
this(sessionUUID, userTimezone, gateway, policy, pendingActions, credentials, null);
}
/*******************************************************************************
** The context of a tool call, made by the tool-call service. Without the
** service's permit it is refused.
*******************************************************************************/
public static Context of(ToolCallService.Permit permit, String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions,
UiApiCredentials credentials, String turnId)
{
Objects.requireNonNull(permit, "Only the tool-call service makes the context of a tool call.");
return (new Context(sessionUUID, userTimezone, gateway, policy, pendingActions, credentials, turnId));
}
/*******************************************************************************
** This context for a read the assistant makes for itself (see
** {@link UiApiToolbox#read}): the same person, credentials, policy and
** turn, nowhere to keep a proposed change, and every call it makes held
** to a time limit (null: the gateway's own).
*******************************************************************************/
public Context forRead(Duration timeout)
{
UiApiGateway inner = gateway;
UiApiGateway limited = timeout == null || inner == null ? inner : new UiApiGateway()
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean isInvocable(String method, String pathTemplate)
{
return (inner.isInvocable(method, pathTemplate));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call) throws QException
{
return (inner.invoke(call.withTimeout(timeout)));
}
};
return (new Context(sessionUUID, userTimezone, limited, policy, null, credentials, turnId));
}
/*******************************************************************************
** This context for a read that is part of a tool call the service already
** checked and logs (a knowledge hit read again before it is shown): the
** same person, credentials, gateway and turn, nowhere to keep a proposed
** change, and the policy's row cap and redaction without its tool
** switches (the tool that was switched on is the one being run; the read
** is how it works). Data access, field exposure and what the person may
** read still decide.
*******************************************************************************/
public Context forReadWhateverTheToolSwitches()
{
PolicyService.ToolPolicy reads = new PolicyService.ToolPolicy(Set.of(), policy == null ? PolicyService.ToolPolicy.DEFAULT_MAX_ROWS_PER_CALL : policy.maxRowsPerCall(), policy == null || policy.redactPii());
return (new Context(sessionUUID, userTimezone, gateway, reads, null, credentials, turnId));
}
/*******************************************************************************
** A call made by this context: the caller's credentials, timezone and
** the client detail (the turn id, labelled assistant/turnId in the access
@@ -197,12 +318,83 @@ public class UiApiToolbox
{
return (call.withSessionUUID(sessionUUID).withCredentials(credentials).withUserTimezone(userTimezone).withClientDetail(turnId));
}
/*******************************************************************************
** The person's session id.
*******************************************************************************/
public String sessionUUID()
{
return (sessionUUID);
}
/*******************************************************************************
** The person's time zone, when the session knows it.
*******************************************************************************/
public String userTimezone()
{
return (userTimezone);
}
/*******************************************************************************
** The UI API, to be called as the person.
*******************************************************************************/
public UiApiGateway gateway()
{
return (gateway);
}
/*******************************************************************************
** The administrator's tool settings in force.
*******************************************************************************/
public PolicyService.ToolPolicy policy()
{
return (policy);
}
/*******************************************************************************
** Where a proposed change is kept; null in a context made for a read.
*******************************************************************************/
public PendingActions pendingActions()
{
return (pendingActions);
}
/*******************************************************************************
** The credentials of the person's request; null when the session's own id
** is the credential. Never log them.
*******************************************************************************/
public UiApiCredentials credentials()
{
return (credentials);
}
/*******************************************************************************
** The turn the call belongs to; null outside a turn.
*******************************************************************************/
public String turnId()
{
return (turnId);
}
}
/*******************************************************************************
** Records a proposed write; returns its id (or throws).
** Records a proposed change; returns its id (or throws).
*******************************************************************************/
@FunctionalInterface
public interface PendingActions
@@ -216,10 +408,33 @@ public class UiApiToolbox
/*******************************************************************************
** A write waiting for confirmation.
** A change waiting for confirmation.
**
** @param toolCallId the proposing tool call's id
** @param toolName the proposing tool
** @param operation the UI API operation the change will run
** @param tableName the table it changes
** @param recordId the record it changes; null for a new one
** @param values what is written
** @param summary the change in words
** @param kind what is proposed (see ActionKinds); null means the
** tool's name
** @param before what the card shows of the record as it is now,
** after the exposure rules; null when there is none
** @param recordVersion a digest of the record as the person can read it
** now, never its values; null when there is no record
*******************************************************************************/
public record ProposedWrite(String toolCallId, String toolName, String operation, String tableName, String recordId, Map<String, Object> values, String summary)
public record ProposedWrite(String toolCallId, String toolName, String operation, String tableName, String recordId, Map<String, Object> values, String summary, String kind,
Map<String, Object> before, String recordVersion)
{
/*******************************************************************************
** A change to something that is not one existing record: its kind is the
** tool's name.
*******************************************************************************/
public ProposedWrite(String toolCallId, String toolName, String operation, String tableName, String recordId, Map<String, Object> values, String summary)
{
this(toolCallId, toolName, operation, tableName, recordId, values, summary, null, null, null);
}
}
@@ -339,7 +554,7 @@ public class UiApiToolbox
/*******************************************************************************
** The tool definitions, sorted by name (a stable cache prefix). Which of
** them a user may use is decided per call; the policy's allowed tools and
** the user's data access narrow the list first ({@link #definitions(PolicyService.ToolPolicy, boolean)}).
** what the person may change narrow the list first ({@link #definitions(PolicyService.ToolPolicy, Set)}).
*******************************************************************************/
public List<ModelToolDefinition> definitions()
{
@@ -435,15 +650,20 @@ public class UiApiToolbox
/*******************************************************************************
** The definitions a policy allows; write tools only when some table allows
** writes (anyWritableTable).
** The definitions a policy allows, for a person: the record write tools
** only when that person may change some table through the assistant, and
** save_analysis only when they may save an analysis (writableTables: see
** UiApiToolFamily.writableTables).
*******************************************************************************/
public List<ModelToolDefinition> definitions(PolicyService.ToolPolicy policy, boolean anyWritableTable)
public List<ModelToolDefinition> definitions(PolicyService.ToolPolicy policy, Set<String> writableTables)
{
List<ModelToolDefinition> rs = new ArrayList<>();
Set<String> writable = writableTables == null ? Set.of() : writableTables;
List<ModelToolDefinition> rs = new ArrayList<>();
for(ModelToolDefinition definition : definitions())
{
if((policy == null || policy.allows(definition.getName())) && (anyWritableTable || !WRITE_TOOLS.contains(definition.getName())))
String name = definition.getName();
boolean offered = WRITE_TOOLS.contains(name) ? !writable.isEmpty() : (!AnalysisTools.SAVE_ANALYSIS.equals(name) || writable.contains(AnalysisTools.SAVED_TABLE));
if((policy == null || policy.allows(name)) && offered)
{
rs.add(definition);
}
@@ -454,26 +674,37 @@ public class UiApiToolbox
/*******************************************************************************
** Whether any table's data access allows writes (so write tools are offered).
** A read the assistant makes for itself, with the person's session: the
** tables a person may read for their starter prompts, a knowledge hit read
** again before it is shown, a record read for the knowledge index. It is
** not a tool call of a model (those go through engine.ToolCallService and
** are logged), and it is the only way into the toolbox from outside this
** package: it runs the four tools named in {@link #ASSISTANT_READS} and
** nothing else, so it can never propose or make a change.
**
** It needs a context, and a context is only ever made by the tool-call
** service for a person it checked (assistant access, the kill switch, the
** policy it read itself): ToolCallService.readContext for a read outside a
** tool call, or the context of the tool call the read is part of. Never
** throws: failures become error outcomes.
*******************************************************************************/
public boolean anyWritableTable()
public Outcome read(Context context, String callId, String toolName, Map<String, Object> input)
{
for(String tableName : qInstance.getTables().keySet())
if(!ASSISTANT_READS.contains(toolName))
{
if(dataAccess.forTable(tableName).mode().allowsWrite())
{
return (true);
}
String message = "Not one of the assistant's own reads: " + toolName;
return (new Outcome(new AiToolResult().withToolCallId(callId).withToolName(toolName).withOutcome(AiToolOutcome.INVALID_INPUT).withSummary(shortText(message)).withModelContent(message)));
}
return (false);
return (execute(context, callId, toolName, input));
}
/*******************************************************************************
** Run one tool call. Never throws: failures become error outcomes.
** Run one tool call. Never throws: failures become error outcomes. Only
** this package calls it (the tool family, for engine.ToolCallService).
*******************************************************************************/
public Outcome execute(Context context, String toolCallId, String toolName, Map<String, Object> input)
Outcome execute(Context context, String toolCallId, String toolName, Map<String, Object> input)
{
AiToolResult result = new AiToolResult().withToolCallId(toolCallId).withToolName(toolName);
Outcome outcome = new Outcome(result);
@@ -486,8 +717,11 @@ public class UiApiToolbox
throw (AiToolException.invalidInput("Unknown tool \"" + toolName + "\". Use only the tools provided."));
}
outcome.operation = operation[0] + " " + operation[1];
if(context.policy() != null && !context.policy().allows(toolName))
if(context.policy() == null || !context.policy().allows(toolName))
{
///////////////////////////////////////////////////////////////
// no policy is not "every tool": a call without one refuses //
///////////////////////////////////////////////////////////////
deny(result, "policy:tool", "The administrator has not allowed the " + toolName + " tool.");
return (outcome);
}
@@ -541,9 +775,15 @@ public class UiApiToolbox
/*******************************************************************************
** Run a confirmed write (from a pending action) with the confirming user's
** session. Same mapping as a direct write.
** session. Only this package calls it (the tool family, for the service's
** confirm).
**
** An update or a delete runs only on the record the person was shown: the
** record is read again as the confirming person, and when it is gone, may
** no longer be read, or is not as it was when the change was proposed, the
** change is refused and the record is left as it is.
*******************************************************************************/
public Outcome executeConfirmedWrite(Context context, String toolCallId, String toolName, String tableName, String recordId, Map<String, Object> values)
Outcome executeConfirmedWrite(Context context, String toolCallId, String toolName, String tableName, String recordId, Map<String, Object> values, String recordVersion)
{
AiToolResult result = new AiToolResult().withToolCallId(toolCallId).withToolName(toolName).withTableName(tableName);
Outcome outcome = new Outcome(result);
@@ -559,7 +799,11 @@ public class UiApiToolbox
DataAccessService.Decision decision = dataAccess.forTable(tableName);
if(!decision.mode().allowsWrite())
{
deny(result, "dataAccess:" + decision.mode(), "The assistant may not write to " + tableName + " (data access " + decision.mode() + ", " + decision.source() + ").");
deny(result, "dataAccess:" + decision.mode(), "The assistant may not change " + tableName + " (assistant access " + decision.mode() + ": " + decision.source() + ").");
return (outcome);
}
if(!CREATE_RECORD.equals(toolName) && !unchangedSinceProposed(context, outcome, tableName, recordId, recordVersion))
{
return (outcome);
}
runWrite(context, outcome, toolName, tableName, recordId, values);
@@ -581,12 +825,28 @@ public class UiApiToolbox
/*******************************************************************************
** The refusal of a confirmation whose tool proposes no write: what
** executeConfirmedWrite answers for such a name (the tool registry gives it
** for a tool, or a name, that has no confirmed write of its own).
*******************************************************************************/
public static Outcome notAWriteTool(String toolCallId, String toolName, String tableName)
{
String message = "Not a write tool: " + toolName;
AiToolResult result = new AiToolResult().withToolCallId(toolCallId).withToolName(toolName).withTableName(tableName)
.withOutcome(AiToolOutcome.INVALID_INPUT).withSummary(shortText(message)).withModelContent(message);
result.setDurationMs(0L);
return (new Outcome(result));
}
/*******************************************************************************
** Run a confirmed save_analysis (from a pending action) with the confirming
** user's session: the saved analysis and, when one was proposed, its
** schedule, emailed to that user.
*******************************************************************************/
public Outcome executeConfirmedAnalysis(Context context, String toolCallId, String toolName, Map<String, Object> values, String userId)
Outcome executeConfirmedAnalysis(Context context, String toolCallId, String toolName, Map<String, Object> values, String userId)
{
AiToolResult result = new AiToolResult().withToolCallId(toolCallId).withToolName(toolName).withTableName(AnalysisTools.SAVED_TABLE);
Outcome outcome = new Outcome(result);
@@ -1277,9 +1537,13 @@ public class UiApiToolbox
/*******************************************************************************
** create / update / delete: checked against data access and the user's
** table permissions as the UI reports them, then proposed (pending action)
** or run.
** create / update / delete: checked against data access, then proposed
** (pending action). An update or a delete reads the record as the person
** first, so a record they may not read is refused here with the UI API's
** own answer. A write runs only from executeConfirmedWrite, once the person
** confirmed, as that person: the UI API decides with their permissions as
** they are then, and a person who may not make the change gets the answer
** the screen would give them.
*******************************************************************************/
private void write(Context context, Outcome outcome, String toolName, Map<String, Object> in) throws QException
{
@@ -1293,7 +1557,7 @@ public class UiApiToolbox
}
if(!decision.mode().allowsWrite())
{
deny(outcome.result, "dataAccess:" + decision.mode(), "The assistant may only read " + label(table) + " (data access " + decision.mode() + ", " + decision.source() + "). Writes need READ_WRITE.");
deny(outcome.result, "dataAccess:" + decision.mode(), "The assistant may only read " + label(table) + " (assistant access " + decision.mode() + ": " + decision.source() + "). It cannot propose changes there.");
return;
}
String recordId = CREATE_RECORD.equals(toolName) ? null : requiredString(in, "id");
@@ -1316,47 +1580,137 @@ public class UiApiToolbox
}
}
if(context.policy() == null || context.policy().writesRequireConfirmation())
///////////////////////////////////////////////////////////////////////
// an update or a delete is proposed for the record as the person can //
// read it now. A record they cannot read is refused here, with the //
// operation's own answer, and no card is made. What the card shows //
// of the record goes through the exposure rules of get_record; the //
// version kept for the confirmation is a digest, never the values //
///////////////////////////////////////////////////////////////////////
Map<String, Object> before = null;
String version = null;
String recordLabel = null;
if(recordId != null)
{
String summary = describeWrite(toolName, table, recordId, values);
Integer actionId = context.pendingActions() == null ? null
: context.pendingActions().propose(new ProposedWrite(outcome.result.getToolCallId(), toolName, outcome.operation, tableName, recordId, values, summary));
if(actionId == null)
String proposing = outcome.operation;
Map<String, Object> record = readForWrite(context, outcome, tableName, recordId);
if(record == null)
{
throw (new QException("The write could not be proposed."));
return;
}
outcome.pendingActionId = actionId;
List<Map<String, Object>> changes = new ArrayList<>();
values.forEach((name, value) ->
{
QFieldMetaData field = table.getFields().get(name);
Map<String, Object> change = new LinkedHashMap<>();
change.put("field", name);
change.put("label", field == null || field.getLabel() == null ? name : field.getLabel());
change.put("value", value == null ? null : String.valueOf(value));
changes.add(change);
});
Map<String, Object> render = new LinkedHashMap<>();
render.put("type", "pending_action");
render.put("actionId", actionId);
render.put("toolName", toolName);
render.put("tableName", tableName);
render.put("tableLabel", label(table));
render.put("recordId", recordId);
render.put("summary", summary);
render.put("changes", changes);
if(recordId != null)
{
render.put("href", AiLinkBuilder.recordHref(tableName, recordId));
}
outcome.result.withOutcome(AiToolOutcome.OK).withTableName(tableName).withRender(render).withSummary("Waiting for confirmation: " + summary)
.withPermissionCheck("pending:" + actionId)
.withModelContent("Proposed, not done yet: " + summary + ". The user must confirm it in the panel; nothing has changed. Tell the user what will change and ask them to confirm. Do not call the tool again for this change.");
return;
outcome.operation = proposing;
outcome.httpStatus = null;
Set<String> redactions = new LinkedHashSet<>();
before = visibleValues(table, record, DELETE_RECORD.equals(toolName) ? null : values.keySet(), context, redactions);
recordLabel = sanitize(record.get("recordLabel") instanceof String l ? l : recordId, context, redactions);
outcome.result.getRedactions().addAll(redactions);
version = recordVersion(tableName, recordId, record);
}
runWrite(context, outcome, toolName, tableName, recordId, values);
///////////////////////////////////////////////////////////////////////
// a change is always proposed: the person confirms it on a card, and //
// nothing (no policy, no caller) can make it run without that //
///////////////////////////////////////////////////////////////////////
String kind = switch(toolName)
{
case CREATE_RECORD -> ActionKinds.RECORD_CREATE;
case UPDATE_RECORD -> ActionKinds.RECORD_UPDATE;
default -> ActionKinds.RECORD_DELETE;
};
String summary = describeWrite(toolName, table, recordId, values);
Integer actionId = context.pendingActions() == null ? null
: context.pendingActions().propose(new ProposedWrite(outcome.result.getToolCallId(), toolName, outcome.operation, tableName, recordId, values, summary, kind, before, version));
if(actionId == null)
{
throw (new QException("The write could not be proposed."));
}
outcome.pendingActionId = actionId;
Map<String, Object> render = new LinkedHashMap<>();
render.put("type", "pending_action");
render.put("actionId", actionId);
render.put("toolName", toolName);
render.put("actionKind", kind);
render.put("tableName", tableName);
render.put("tableLabel", label(table));
render.put("recordId", recordId);
render.put("summary", summary);
render.put("changes", ActionKinds.changes(table, values, before));
if(recordId != null)
{
render.put("recordLabel", recordLabel);
render.put("href", AiLinkBuilder.recordHref(tableName, recordId));
}
if(DELETE_RECORD.equals(toolName))
{
render.put("before", ActionKinds.fieldLines(table, before));
}
outcome.result.withOutcome(AiToolOutcome.OK).withTableName(tableName).withRender(render).withSummary("Waiting for confirmation: " + summary)
.withPermissionCheck("pending:" + actionId)
.withModelContent("Proposed, not done yet: " + summary + ". The user must confirm it in the panel; nothing has changed. Tell the user what will change and ask them to confirm. Do not call the tool again for this change.");
}
/*******************************************************************************
** GET the record a change is about, as the person: the record, or null
** when the UI API refused (the outcome then says what it answered).
*******************************************************************************/
private Map<String, Object> readForWrite(Context context, Outcome outcome, String tableName, String recordId) throws QException
{
UiApiResult response = call(context, outcome, new UiApiCall("GET", "/table/{tableName}/{primaryKey}").withPathParam("tableName", tableName).withPathParam("primaryKey", recordId));
return (response.isSuccess() ? asMap(asMap(response.json()).get("record")) : null);
}
/*******************************************************************************
** The version of a record as one person can read it: a digest of its
** stored values. It says whether a record is still what it was, and
** nothing else: the values cannot be read back from it, since it covers
** the whole record and not one field.
*******************************************************************************/
static String recordVersion(String tableName, String recordId, Map<String, Object> record) throws QException
{
try
{
String canonical = tableName + "\n" + recordId + "\n" + JsonUtils.toJson(new TreeMap<>(asMap(record.get("values"))));
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8))));
}
catch(NoSuchAlgorithmException e)
{
throw (new QException("The record's version could not be worked out.", e));
}
}
/*******************************************************************************
** Whether the record of a confirmed update or delete is still as it was
** when the change was proposed. When it is not (or is gone, or may no
** longer be read, or the proposal kept no version) the outcome is the
** refusal and nothing is written.
*******************************************************************************/
private boolean unchangedSinceProposed(Context context, Outcome outcome, String tableName, String recordId, String recordVersion) throws QException
{
String writing = outcome.operation;
if(recordVersion != null)
{
Map<String, Object> record = readForWrite(context, outcome, tableName, recordId);
if(record == null)
{
return (false);
}
outcome.operation = writing;
outcome.httpStatus = null;
if(recordVersion.equals(recordVersion(tableName, recordId, record)))
{
return (true);
}
}
outcome.result.withOutcome(AiToolOutcome.INVALID_INPUT).withPermissionCheck(CHECK_STALE).withSummary(STALE_MESSAGE)
.withModelContent(STALE_MESSAGE + " Read the record again and propose the change from its current values.");
return (false);
}
@@ -0,0 +1,211 @@
/*
* 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.bulkload;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.tools.UiApiToolFamily;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.registry.AssistantTool;
import com.kingsrook.qbits.assistant.tools.registry.ConfirmedToolCall;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallRequest;
import com.kingsrook.qbits.assistant.tools.registry.ToolCheck;
import com.kingsrook.qbits.assistant.tools.registry.ToolFamily;
import com.kingsrook.qbits.assistant.tools.registry.ToolKind;
import com.kingsrook.qbits.assistant.tools.registry.ToolOffer;
/*******************************************************************************
** The bulk-load tools ({@link BulkLoadTools}) as registered tools.
** BulkLoadTools keeps all of their behaviour; this class only says which
** tools there are and hands each call to it.
*******************************************************************************/
public class BulkLoadToolFamily implements ToolFamily
{
private static final List<String> NAMES = List.of(BulkLoadTools.LIST, BulkLoadTools.DESCRIBE, BulkLoadTools.ATTACH, BulkLoadTools.SUGGEST, BulkLoadTools.DRY_RUN,
BulkLoadTools.SAVE, BulkLoadTools.RUN);
private static final List<ToolCheck> READ_CHECKS = List.of(ToolCheck.TOOL_SWITCH, ToolCheck.DATA_ACCESS, ToolCheck.PERSON_PERMISSIONS);
private static final List<ToolCheck> PROPOSE_CHECKS = List.of(ToolCheck.TOOL_SWITCH, ToolCheck.DATA_ACCESS, ToolCheck.CONFIRMATION, ToolCheck.PERSON_PERMISSIONS);
private final BulkLoadTools bulkLoadTools;
/*******************************************************************************
** The bulk-load tools are handed over by the runtime that made them;
** nothing outside this package can reach them to run a tool around the
** service.
*******************************************************************************/
public BulkLoadToolFamily(BulkLoadTools bulkLoadTools)
{
this.bulkLoadTools = bulkLoadTools;
}
/*******************************************************************************
** The bulk-load tools, for this package (and its tests).
*******************************************************************************/
BulkLoadTools bulkLoadTools()
{
return (bulkLoadTools);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<AssistantTool> tools(AssistantRuntime runtime)
{
List<AssistantTool> tools = new ArrayList<>();
for(String name : NAMES)
{
tools.add(new Tool(runtime, bulkLoadTools, name));
}
return (tools);
}
/*******************************************************************************
** One bulk-load tool.
*******************************************************************************/
private static final class Tool implements AssistantTool
{
private final AssistantRuntime runtime;
private final BulkLoadTools bulkLoadTools;
private final String name;
/*******************************************************************************
**
*******************************************************************************/
private Tool(AssistantRuntime runtime, BulkLoadTools bulkLoadTools, String name)
{
this.runtime = runtime;
this.bulkLoadTools = bulkLoadTools;
this.name = name;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String name()
{
return (name);
}
/*******************************************************************************
** Saving a profile and running a load propose; the rest read.
*******************************************************************************/
@Override
public ToolKind kind()
{
return (BulkLoadTools.WRITE_TOOLS.contains(name) ? ToolKind.PROPOSE : ToolKind.READ);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<ToolCheck> checks()
{
return (BulkLoadTools.WRITE_TOOLS.contains(name) ? PROPOSE_CHECKS : READ_CHECKS);
}
/*******************************************************************************
** Offered where the instance has saved bulk load profiles; the definitions
** for the policy are built once per offer (the write tools only when the
** person may change some table).
*******************************************************************************/
@Override
public ModelToolDefinition definition(ToolOffer offer)
{
Map<String, ModelToolDefinition> offered = offer.once(BulkLoadToolFamily.class, () ->
{
Map<String, ModelToolDefinition> byName = new HashMap<>();
if(bulkLoadTools.offered())
{
for(ModelToolDefinition definition : bulkLoadTools.definitions(offer.policy(), BulkLoadTools.WRITE_TOOLS.stream().anyMatch(offer::allows) && !UiApiToolFamily.writableTables(runtime, offer).isEmpty()))
{
byName.put(definition.getName(), definition);
}
}
return (byName);
});
return (offered.get(name));
}
/*******************************************************************************
** /qqq/v1 calls with the person's session; the X-QQQ-AI-Route they send is
** the scope's (the turn's provider's HIPAA eligibility), never the model's.
*******************************************************************************/
@Override
public ToolCallOutcome execute(ToolCallRequest request)
{
return (request.inPersonsContext(runtime.getQInstance(), () ->
ToolCallOutcome.of(bulkLoadTools.execute(request.context(), request.toolCallId(), request.toolName(), request.input(), request.scope().hipaaRoute()))));
}
/*******************************************************************************
** A confirmed save or run; the tools that propose nothing refuse.
*******************************************************************************/
@Override
public UiApiToolbox.Outcome executeConfirmed(ConfirmedToolCall call)
{
if(!BulkLoadTools.WRITE_TOOLS.contains(name))
{
return (AssistantTool.super.executeConfirmed(call));
}
return (bulkLoadTools.executeConfirmed(call.context(), call.toolCallId(), call.toolName(), call.tableName(), call.values()));
}
/*******************************************************************************
** The log keeps the profile and the counts, not the file's values.
*******************************************************************************/
@Override
public String forLog(String json)
{
return (BulkLoadTools.forLog(json));
}
}
}
@@ -119,7 +119,8 @@ public class BulkLoadTools
/*******************************************************************************
** The definitions the policy allows (writes only when anyWritable).
** The definitions the policy allows (the save and run tools only when
** anyWritable: the person may change some table themselves).
*******************************************************************************/
public List<ModelToolDefinition> definitions(PolicyService.ToolPolicy policy, boolean anyWritable)
{
@@ -194,7 +195,7 @@ public class BulkLoadTools
** Run a tool. hipaaRoute is the turn's provider's HIPAA eligibility (sent
** as X-QQQ-AI-Route; the model cannot set it).
*******************************************************************************/
public UiApiToolbox.Outcome execute(UiApiToolbox.Context context, String toolCallId, String toolName, Map<String, Object> input, boolean hipaaRoute)
UiApiToolbox.Outcome execute(UiApiToolbox.Context context, String toolCallId, String toolName, Map<String, Object> input, boolean hipaaRoute)
{
AiToolResult result = new AiToolResult().withToolCallId(toolCallId).withToolName(toolName);
Map<String, Object> in = input == null ? Map.of() : input;
@@ -203,7 +204,7 @@ public class BulkLoadTools
long started = System.nanoTime();
try
{
if(context.policy() != null && !context.policy().allows(toolName))
if(context.policy() == null || !context.policy().allows(toolName))
{
deny(result, "policy:tool", "The administrator has not allowed the " + toolName + " tool.");
return (UiApiToolbox.Outcome.of(result, null, null, null));
@@ -252,7 +253,7 @@ public class BulkLoadTools
** Run a confirmed write (from a pending action) with the confirming user's
** session.
*******************************************************************************/
public UiApiToolbox.Outcome executeConfirmed(UiApiToolbox.Context context, String toolCallId, String toolName, String tableName, Map<String, Object> values)
UiApiToolbox.Outcome executeConfirmed(UiApiToolbox.Context context, String toolCallId, String toolName, String tableName, Map<String, Object> values)
{
AiToolResult result = new AiToolResult().withToolCallId(toolCallId).withToolName(toolName).withTableName(tableName);
Call call = new Call(context, false);
@@ -266,7 +267,7 @@ public class BulkLoadTools
DataAccessService.Decision decision = dataAccess.forTable(SAVE.equals(toolName) ? PROFILE_TABLE : tableName);
if(!decision.mode().allowsWrite())
{
deny(result, "dataAccess:" + decision.mode(), "The assistant may not write here (data access " + decision.mode() + ", " + decision.source() + ").");
deny(result, "dataAccess:" + decision.mode(), "The assistant may not change this table (assistant access " + decision.mode() + ": " + decision.source() + ").");
return (UiApiToolbox.Outcome.of(result, null, null, null));
}
if(SAVE.equals(toolName))
@@ -444,7 +445,7 @@ public class BulkLoadTools
DataAccessService.Decision decision = dataAccess.forTable(SAVE.equals(toolName) ? PROFILE_TABLE : tableName);
if(!decision.mode().allowsWrite())
{
deny(result, "dataAccess:" + decision.mode(), "The assistant may only read here (data access " + decision.mode() + ", " + decision.source() + "). Writes need READ_WRITE.");
deny(result, "dataAccess:" + decision.mode(), "The assistant may only read here (assistant access " + decision.mode() + ": " + decision.source() + "). It cannot propose changes there.");
return (null);
}
@@ -0,0 +1,120 @@
/*
* 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.registry;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** What a pending action proposes (assistant_pending_action.actionKind, and
** actionKind in the card and the API), and what the card of a change to one
** record shows. The card a client draws is chosen by the kind; a client that
** does not know a kind draws its plain card.
**
** A proposing tool names its kind in UiApiToolbox.ProposedWrite. A proposal
** that names none has its tool's name as its kind, so a tool family of a host
** needs no entry here, and a later kind (a process run, a schedule, a report)
** is a new constant and no schema change.
*******************************************************************************/
public final class ActionKinds
{
public static final String RECORD_CREATE = "record_create";
public static final String RECORD_UPDATE = "record_update";
public static final String RECORD_DELETE = "record_delete";
/*******************************************************************************
** Constants only.
*******************************************************************************/
private ActionKinds()
{
}
/*******************************************************************************
** Whether a kind is a change to one record (its card shows the record, and
** for an update or a delete its values as they were).
*******************************************************************************/
public static boolean isRecordWrite(String kind)
{
return (RECORD_CREATE.equals(kind) || RECORD_UPDATE.equals(kind) || RECORD_DELETE.equals(kind));
}
/*******************************************************************************
** What a card shows as the changes of a create or an update: one line per
** field written, with its label and the value after; for an update also
** the value before, as the person may see it.
*******************************************************************************/
public static List<Map<String, Object>> changes(QTableMetaData table, Map<String, Object> values, Map<String, Object> before)
{
List<Map<String, Object>> changes = new ArrayList<>();
values.forEach((name, value) ->
{
Map<String, Object> change = new LinkedHashMap<>();
change.put("field", name);
change.put("label", fieldLabel(table, name));
if(before != null)
{
change.put("before", before.get(name) == null ? null : String.valueOf(before.get(name)));
}
change.put("value", value == null ? null : String.valueOf(value));
changes.add(change);
});
return (changes);
}
/*******************************************************************************
** What a card shows of a record that is to be deleted: one line per field
** the person may see, with its label and its value.
*******************************************************************************/
public static List<Map<String, Object>> fieldLines(QTableMetaData table, Map<String, Object> visible)
{
List<Map<String, Object>> lines = new ArrayList<>();
(visible == null ? Map.<String, Object>of() : visible).forEach((name, value) ->
{
Map<String, Object> line = new LinkedHashMap<>();
line.put("field", name);
line.put("label", fieldLabel(table, name));
line.put("value", value == null ? null : String.valueOf(value));
lines.add(line);
});
return (lines);
}
/*******************************************************************************
** A field's label, or its name when the table does not know it.
*******************************************************************************/
private static String fieldLabel(QTableMetaData table, String name)
{
QFieldMetaData field = table == null ? null : table.getFields().get(name);
return (field == null || field.getLabel() == null ? name : field.getLabel());
}
}
@@ -0,0 +1,120 @@
/*
* 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.registry;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
/*******************************************************************************
** One tool of the assistant, registered once in the {@link ToolRegistry}:
** its name, its kind, the checks it runs, its definition (description and
** input schema) and how one call of it is run.
**
** A tool call always runs as the signed-in person: with that person's own
** session, current permissions and policy, never as the assistant.
*******************************************************************************/
public interface AssistantTool
{
/*******************************************************************************
** The name the model calls the tool by; unique in the registry.
*******************************************************************************/
String name();
/*******************************************************************************
** Whether the tool reads, proposes a write or displays.
*******************************************************************************/
ToolKind kind();
/*******************************************************************************
** The checks one call of this tool goes through, in the order each first
** applies.
*******************************************************************************/
List<ToolCheck> checks();
/*******************************************************************************
** The definition (name, description, input schema, strict flag) this person
** is offered now under this policy, or null when the tool is not offered:
** the host lacks what it needs, the policy switched it off, or the person
** may not use it.
*******************************************************************************/
ModelToolDefinition definition(ToolOffer offer);
/*******************************************************************************
** Run one call as the person of the request. Refusals and bad input are
** results, not exceptions.
*******************************************************************************/
ToolCallOutcome execute(ToolCallRequest request);
/*******************************************************************************
** Run a write this tool proposed, now that the person confirmed it, with the
** confirming request's session. A tool that proposes nothing refuses, as the
** confirm route always has for such a name.
*******************************************************************************/
default UiApiToolbox.Outcome executeConfirmed(ConfirmedToolCall call)
{
return (UiApiToolbox.notAWriteTool(call.toolCallId(), call.toolName(), call.tableName()));
}
/*******************************************************************************
** What the tool-call log keeps as a call's input, before the service
** redacts it: the model's input, unless the tool's input is not what
** should be kept (a presentation keeps what was shown instead).
*******************************************************************************/
default Map<String, Object> inputForLog(Map<String, Object> input, AiToolResult result)
{
return (input);
}
/*******************************************************************************
** The input or result JSON as the tool-call log may keep it (a tool whose
** JSON can carry more than the log should hold reduces it here).
*******************************************************************************/
default String forLog(String json)
{
return (json);
}
/*******************************************************************************
** The UI API operation the tool-call log names for a call of this tool that
** has no operation detail of its own; null for none.
*******************************************************************************/
default String operationWithoutDetail()
{
return (null);
}
}
@@ -0,0 +1,187 @@
/*
* 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.registry;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.assistant.engine.ToolCallService;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** A proposed change the person has confirmed: the pending action's tool call,
** to be run once with the confirming request's own session (the context).
**
** Only engine.ToolCallService can make one, from the stored pending action it
** loaded for that person and claimed: there is no public constructor, and
** {@link #of} needs the service's permit, which the service never hands out.
** A tool that is given one therefore knows the person saw the change on a
** card and confirmed it; a caller chooses none of the table, the record or
** the values, and nothing else can run a change.
*******************************************************************************/
public final class ConfirmedToolCall
{
private final QSession session;
private final UiApiToolbox.Context context;
private final String toolCallId;
private final String toolName;
private final String tableName;
private final String recordId;
private final Map<String, Object> values;
private final String userId;
private final String recordVersion;
/*******************************************************************************
** For this package only (and its tests).
*******************************************************************************/
@SuppressWarnings("checkstyle:ParameterNumber")
ConfirmedToolCall(QSession session, UiApiToolbox.Context context, String toolCallId, String toolName, String tableName, String recordId, Map<String, Object> values, String userId, String recordVersion)
{
this.session = session;
this.context = context;
this.toolCallId = toolCallId;
this.toolName = toolName;
this.tableName = tableName;
this.recordId = recordId;
this.values = values;
this.userId = userId;
this.recordVersion = recordVersion;
}
/*******************************************************************************
** The confirmed call of a claimed pending action, made by the tool-call
** service. Without the service's permit it is refused.
**
** @param permit the tool-call service's own permit
** @param session the confirming person's own session
** @param context that same session for the UI API, with the gateway
** and the policy's tool settings
** @param toolCallId the proposing tool call's id
** @param toolName the tool that proposed the change
** @param tableName the pending action's table
** @param recordId the pending action's record, if it has one
** @param values the stored values of the proposal
** @param userId the confirming person (the session's)
** @param recordVersion the version of the record as it was when the change
** was proposed, if the proposal kept one
*******************************************************************************/
@SuppressWarnings("checkstyle:ParameterNumber")
public static ConfirmedToolCall of(ToolCallService.Permit permit, QSession session, UiApiToolbox.Context context, String toolCallId, String toolName, String tableName, String recordId,
Map<String, Object> values, String userId, String recordVersion)
{
Objects.requireNonNull(permit, "Only the tool-call service makes a confirmed tool call.");
return (new ConfirmedToolCall(session, context, toolCallId, toolName, tableName, recordId, values, userId, recordVersion));
}
/*******************************************************************************
** The confirming person's own session.
*******************************************************************************/
public QSession session()
{
return (session);
}
/*******************************************************************************
** That same session for the UI API, with the gateway and the policy's tool
** settings.
*******************************************************************************/
public UiApiToolbox.Context context()
{
return (context);
}
/*******************************************************************************
** The proposing tool call's id.
*******************************************************************************/
public String toolCallId()
{
return (toolCallId);
}
/*******************************************************************************
** The tool that proposed the change.
*******************************************************************************/
public String toolName()
{
return (toolName);
}
/*******************************************************************************
** The pending action's table.
*******************************************************************************/
public String tableName()
{
return (tableName);
}
/*******************************************************************************
** The pending action's record, if it has one.
*******************************************************************************/
public String recordId()
{
return (recordId);
}
/*******************************************************************************
** The stored values of the proposal.
*******************************************************************************/
public Map<String, Object> values()
{
return (values);
}
/*******************************************************************************
** The confirming person (the session's).
*******************************************************************************/
public String userId()
{
return (userId);
}
/*******************************************************************************
** The version of the record as it was when the change was proposed (see
** UiApiToolbox.ProposedWrite); null when the proposal kept none.
*******************************************************************************/
public String recordVersion()
{
return (recordVersion);
}
}
@@ -0,0 +1,38 @@
/*
* 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.registry;
import java.util.Map;
import com.kingsrook.qbits.assistant.SignedInPerson;
/*******************************************************************************
** One tool call as a caller hands it to engine.ToolCallService: who it is
** for, and what the model (or another client) asked for. It carries nothing
** the service decides for itself: no policy, no gateway, no credentials apart
** from the person's own, no place to keep a proposed write. The service makes
** those, and the {@link ToolCallRequest} a tool is run with.
**
** @param person the signed-in person the call runs as
** @param toolCallId the caller's id of the call
** @param toolName the tool called
** @param input the call's input
*******************************************************************************/
public record ToolCall(SignedInPerson person, String toolCallId, String toolName, Map<String, Object> input)
{
}
@@ -0,0 +1,48 @@
/*
* 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.registry;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
/*******************************************************************************
** What one tool call gave back: the result for the model and the panel, and,
** for a tool that works through a UI API operation, that operation's detail
** for the tool-call log (null for a tool that has none).
*******************************************************************************/
public record ToolCallOutcome(AiToolResult result, UiApiToolbox.Outcome extras)
{
/*******************************************************************************
** A result with no UI API operation detail.
*******************************************************************************/
public static ToolCallOutcome of(AiToolResult result)
{
return (new ToolCallOutcome(result, null));
}
/*******************************************************************************
** The outcome of a UI API tool: its result and the operation's detail.
*******************************************************************************/
public static ToolCallOutcome of(UiApiToolbox.Outcome outcome)
{
return (new ToolCallOutcome(outcome.getResult(), outcome));
}
}
@@ -0,0 +1,166 @@
/*
* 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.registry;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
import com.kingsrook.qbits.assistant.engine.ToolCallService;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** One tool call as a tool is run with it, for one person. Only
** engine.ToolCallService can make one, from the {@link ToolCall} a caller
** handed it: there is no public constructor, and {@link #of} needs the
** service's permit, which the service never hands out. A tool that is given
** a request therefore knows the service's checks ran first; nothing else can
** run a tool.
**
** The context, with the gateway, the policy in force and where a proposed
** change is kept, is the service's own, never a caller's.
*******************************************************************************/
public final class ToolCallRequest
{
private final QSession session;
private final UiApiToolbox.Context context;
private final String toolCallId;
private final String toolName;
private final Map<String, Object> input;
private final ToolCallScope scope;
/*******************************************************************************
** For this package only (and its tests).
*******************************************************************************/
ToolCallRequest(QSession session, UiApiToolbox.Context context, String toolCallId, String toolName, Map<String, Object> input, ToolCallScope scope)
{
this.session = session;
this.context = context;
this.toolCallId = toolCallId;
this.toolName = toolName;
this.input = input;
this.scope = scope;
}
/*******************************************************************************
** The request of one call, made by the tool-call service. Without the
** service's permit it is refused.
**
** @param permit the tool-call service's own permit
** @param session the signed-in person's own session
** @param context that same session for the UI API, with the runtime's
** gateway, the administrator's policy as the service read
** it and where a proposed change is kept
** @param toolCallId the caller's id of the call
** @param toolName the tool called
** @param input the call's input
** @param scope where the call runs
*******************************************************************************/
public static ToolCallRequest of(ToolCallService.Permit permit, QSession session, UiApiToolbox.Context context, String toolCallId, String toolName, Map<String, Object> input, ToolCallScope scope)
{
Objects.requireNonNull(permit, "Only the tool-call service makes a tool call request.");
return (new ToolCallRequest(session, context, toolCallId, toolName, input, scope));
}
/*******************************************************************************
** Run a data tool with the person's session as the thread's QQQ context,
** and leave no context behind.
*******************************************************************************/
public <T> T inPersonsContext(QInstance qInstance, Supplier<T> call)
{
QContext.init(qInstance, session);
try
{
return (call.get());
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** The signed-in person's own session.
*******************************************************************************/
public QSession session()
{
return (session);
}
/*******************************************************************************
** That same session for the UI API (its sessionUUID is the session's, its
** credentials the person's own).
*******************************************************************************/
public UiApiToolbox.Context context()
{
return (context);
}
/*******************************************************************************
** The caller's id of the call.
*******************************************************************************/
public String toolCallId()
{
return (toolCallId);
}
/*******************************************************************************
** The tool called.
*******************************************************************************/
public String toolName()
{
return (toolName);
}
/*******************************************************************************
** The call's input.
*******************************************************************************/
public Map<String, Object> input()
{
return (input);
}
/*******************************************************************************
** Where the call runs.
*******************************************************************************/
public ToolCallScope scope()
{
return (scope);
}
}
@@ -0,0 +1,72 @@
/*
* 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.registry;
import java.util.function.Supplier;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
/*******************************************************************************
** What a tool call may know about where it runs (in chat: the turn). All of
** it is decided by the server; nothing here comes from the model.
**
** engine.ToolCallService does not take a scope's word for whose it is: it
** checks that the conversation and the turn belong to the call's person
** before a tool runs, and works out the client channel of the log row
** itself. Whether the route is HIPAA eligible stays the scope's to say (see
** {@link #hipaaRoute()}).
*******************************************************************************/
public interface ToolCallScope
{
/*******************************************************************************
** The data classification reached so far.
*******************************************************************************/
DataClassification classification();
/*******************************************************************************
** The conversation the call belongs to.
*******************************************************************************/
Integer conversationId();
/*******************************************************************************
** The turn the call belongs to.
*******************************************************************************/
Integer turnId();
/*******************************************************************************
** Whether the model that asked for the call is HIPAA eligible. This is the
** one value the service cannot check: only the caller knows which model (or
** which client outside the chat) will receive the result. A caller that
** cannot vouch for its route answers false.
*******************************************************************************/
boolean hipaaRoute();
/*******************************************************************************
** State a tool keeps for as long as the scope lives (in chat: one turn),
** created on first use and then shared by the scope's calls.
*******************************************************************************/
<T> T state(Class<T> type, Supplier<T> create);
}
@@ -0,0 +1,77 @@
/*
* 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.registry;
/*******************************************************************************
** The checks a tool call can go through. A tool lists the ones it runs, in
** the order each first applies ({@link AssistantTool#checks()}). Every one of
** them only narrows what the signed-in person may already do; none can turn
** a refusal of another into success.
*******************************************************************************/
public enum ToolCheck
{
/////////////////////////////////////////////////////////////////////////
// the policy's allowed tools (logged as "policy:tool"); also decides //
// whether the tool is offered at all //
/////////////////////////////////////////////////////////////////////////
TOOL_SWITCH,
/////////////////////////////////////////////////////////////////////////
// the operation behind the tool is a spec'd UI API operation that the //
// gateway may invoke (logged as "uiApi:notSpecd") //
/////////////////////////////////////////////////////////////////////////
SPECD_OPERATION,
/////////////////////////////////////////////////////////////////////////
// the assistant's data access for the table (logged as //
// "dataAccess:<mode>"): NONE refuses reads, READ refuses writes //
/////////////////////////////////////////////////////////////////////////
DATA_ACCESS,
/////////////////////////////////////////////////////////////////////////
// fields whose values the assistant may not see are refused in //
// filters and inputs, and masked or left out of results //
/////////////////////////////////////////////////////////////////////////
FIELD_EXPOSURE,
/////////////////////////////////////////////////////////////////////////
// the turn's data classification decides which model may be sent the //
// text //
/////////////////////////////////////////////////////////////////////////
CLASSIFICATION,
/////////////////////////////////////////////////////////////////////////
// the write waits as a pending action for the person's confirmation //
// in the panel (logged as "pending:<id>") //
/////////////////////////////////////////////////////////////////////////
CONFIRMATION,
/////////////////////////////////////////////////////////////////////////
// the /qqq/v1 operation runs with the person's own session: core //
// applies their permissions, record locks and the channel wall, and //
// the tool result carries the operation's own status (logged as //
// "uiApi:<operation>:<status>") //
/////////////////////////////////////////////////////////////////////////
PERSON_PERMISSIONS,
/////////////////////////////////////////////////////////////////////////
// the core AI tool kernel's own checks, with the person's session //
// (navigate and search_help only: no records are read) //
/////////////////////////////////////////////////////////////////////////
KERNEL
}
@@ -0,0 +1,50 @@
/*
* 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.registry;
import java.util.List;
import com.kingsrook.qbits.assistant.AssistantRuntime;
/*******************************************************************************
** A group of tools that share their code (the UI API data tools, the
** bulk-load tools, ...). A family is how tools get into the
** {@link ToolRegistry}, and only a registered family does: the QBit's own
** are named by the runtime (AssistantRuntime.ownToolFamilies), and a host or
** another QBit registers one with AssistantQBitConfig.withToolFamily before
** the QBit is produced. A family class that is only on the classpath
** registers nothing.
**
** A registered family's tools run inside the assistant with the signed-in
** person's session, so registering one is trusting its code: every call
** still goes through the checks of engine.ToolCallService first, and the
** tool applies its own checks after them (see {@link ToolCheck}).
**
** tools() runs while the runtime is still being constructed: keep the
** runtime and ask it for collaborators when a tool is offered or called,
** not here.
*******************************************************************************/
public interface ToolFamily
{
/*******************************************************************************
** Every tool of this family, whether or not a given host or person is
** offered it (that is decided per request, by
** {@link AssistantTool#definition(ToolOffer)}).
*******************************************************************************/
List<AssistantTool> tools(AssistantRuntime runtime);
}
@@ -0,0 +1,42 @@
/*
* 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.registry;
/*******************************************************************************
** What a tool does, as far as authorisation is concerned.
*******************************************************************************/
public enum ToolKind
{
/////////////////////////////////////////////////////////////////////////
// reads as the signed-in person; needs no confirmation //
/////////////////////////////////////////////////////////////////////////
READ,
/////////////////////////////////////////////////////////////////////////
// proposes a write: nothing is saved until the person confirms the //
// pending action (unless the policy turned confirmation off), and the //
// write then runs with the confirming request's session //
/////////////////////////////////////////////////////////////////////////
PROPOSE,
/////////////////////////////////////////////////////////////////////////
// shows something in the panel (a presentation, a page to open); it //
// changes nothing //
/////////////////////////////////////////////////////////////////////////
DISPLAY
}
@@ -0,0 +1,141 @@
/*
* 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.registry;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
** One question "which tools is this person offered now": the policy in
** force, the person's own session (with the credential of their request,
** when there is one, so a UI API request made to answer the question is made
** as that person), and the turn the question belongs to (so that request is
** attributed to the turn).
**
** An offer lives for one such question. A family whose tools share one
** answer (one list of definitions, one look at the data access) works it out
** once per offer with {@link #once(Object, Supplier)}.
*******************************************************************************/
public final class ToolOffer
{
private final PolicyService.ToolPolicy policy;
private final QSession session;
private final String turnId;
private final UiApiCredentials credentials;
private final Map<Object, Object> answers = new HashMap<>();
/*******************************************************************************
** @param policy the policy's tool settings; null narrows nothing
** @param session the person's own session; null when there is none
** @param turnId the turn the offer is made for; null outside a turn
*******************************************************************************/
public ToolOffer(PolicyService.ToolPolicy policy, QSession session, String turnId)
{
this(policy, session, turnId, null);
}
/*******************************************************************************
** @param policy the policy's tool settings; null narrows nothing
** @param session the person's own session; null when there is none
** @param turnId the turn the offer is made for; null outside a turn
** @param credentials the credential of the person's request; null when the
** session's own id is the credential
*******************************************************************************/
public ToolOffer(PolicyService.ToolPolicy policy, QSession session, String turnId, UiApiCredentials credentials)
{
this.policy = policy;
this.session = session;
this.turnId = turnId;
this.credentials = credentials;
}
/*******************************************************************************
** Whether the policy allows a tool by name (no policy allows every tool).
*******************************************************************************/
public boolean allows(String toolName)
{
return (policy == null || policy.allows(toolName));
}
/*******************************************************************************
** An answer worked out at most once for this offer, by key.
*******************************************************************************/
@SuppressWarnings("unchecked")
public <T> T once(Object key, Supplier<T> answer)
{
if(!answers.containsKey(key))
{
answers.put(key, answer.get());
}
return ((T) answers.get(key));
}
/*******************************************************************************
** Getter for policy
*******************************************************************************/
public PolicyService.ToolPolicy policy()
{
return (policy);
}
/*******************************************************************************
** Getter for session
*******************************************************************************/
public QSession session()
{
return (session);
}
/*******************************************************************************
** Getter for turnId
*******************************************************************************/
public String turnId()
{
return (turnId);
}
/*******************************************************************************
** The credential of the person's request; null when the session's own id
** is the credential. Never log it.
*******************************************************************************/
public UiApiCredentials credentials()
{
return (credentials);
}
}
@@ -0,0 +1,186 @@
/*
* 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.registry;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Every tool the assistant has, sorted by name. The only place a tool is
** looked up: the chat's tool list, a tool call and a confirmed write all go
** through it, so they cannot disagree about which tools exist.
**
** Tools come only from the {@link ToolFamily} instances the runtime is
** given: the QBit's own, which the runtime names, and those a host or
** another QBit registered with AssistantQBitConfig.withToolFamily. Nothing
** is looked for on the classpath: a class that merely exists is not a tool.
*******************************************************************************/
public class ToolRegistry
{
private static final QLogger LOG = QLogger.getLogger(ToolRegistry.class);
private final Map<String, AssistantTool> tools;
/*******************************************************************************
** A registry of exactly these tools. Two tools may not share a name, and a
** registry without a tool is an error, never an assistant without tools.
*******************************************************************************/
public ToolRegistry(Collection<AssistantTool> tools)
{
if(tools.isEmpty())
{
throw (new IllegalStateException("The assistant has no tools: no tool family was registered, or none has a tool"));
}
Map<String, AssistantTool> byName = new TreeMap<>();
for(AssistantTool tool : tools)
{
if(!StringUtils.hasContent(tool.name()) || tool.kind() == null || tool.checks() == null)
{
throw (new IllegalStateException("An assistant tool must have a name, a kind and its checks: " + tool.getClass().getName()));
}
if(byName.put(tool.name(), tool) != null)
{
throw (new IllegalStateException("Two assistant tools are named " + tool.name()));
}
}
this.tools = Collections.unmodifiableMap(byName);
}
/*******************************************************************************
** The registry of a runtime: the tools of exactly the families given, and
** one log line naming them. Starting fails when two tools share a name or
** there is no tool at all.
*******************************************************************************/
public static ToolRegistry of(AssistantRuntime runtime, List<ToolFamily> families)
{
List<AssistantTool> tools = new ArrayList<>();
for(ToolFamily family : families)
{
tools.addAll(family.tools(runtime));
}
ToolRegistry registry = new ToolRegistry(tools);
LOG.info("Assistant tools registered", logPair("count", registry.tools.size()), logPair("tools", String.join(",", registry.tools.keySet())),
logPair("families", families.stream().map(family -> family.getClass().getName()).toList()));
return (registry);
}
/*******************************************************************************
** Every tool, sorted by name.
*******************************************************************************/
public List<AssistantTool> tools()
{
return (new ArrayList<>(tools.values()));
}
/*******************************************************************************
** The tool of a name, or null when there is none.
*******************************************************************************/
public AssistantTool find(String name)
{
return (name == null ? null : tools.get(name));
}
/*******************************************************************************
** The definitions of the tools this offer's person is offered now, sorted by
** name, each input schema with the keys of every object sorted (a stable
** cache prefix). A schema built with Map.of iterates in an order that
** differs from one JVM to the next, and the provider mappings serialise a
** schema in the order it iterates; sorted here, every pod and every restart
** sends the same bytes, whichever family built the schema.
*******************************************************************************/
public List<ModelToolDefinition> definitions(ToolOffer offer)
{
List<ModelToolDefinition> definitions = new ArrayList<>();
for(AssistantTool tool : tools.values())
{
ModelToolDefinition definition = tool.definition(offer);
if(definition != null)
{
definitions.add(new ModelToolDefinition().withName(definition.getName()).withDescription(definition.getDescription()).withStrict(definition.getStrict())
.withInputSchema(definition.getInputSchema() == null ? null : sortedObject(definition.getInputSchema())));
}
}
return (definitions);
}
/*******************************************************************************
** A copy of a JSON object with its keys sorted, at every depth.
*******************************************************************************/
private static Map<String, Object> sortedObject(Map<?, ?> object)
{
Map<String, Object> sorted = new TreeMap<>();
object.forEach((key, value) -> sorted.put(String.valueOf(key), sortedValue(value)));
return (sorted);
}
/*******************************************************************************
** A JSON value with the keys of every object in it sorted. A list keeps its
** order, which is part of what it says.
*******************************************************************************/
private static Object sortedValue(Object value)
{
if(value instanceof Map<?, ?> object)
{
return (sortedObject(object));
}
if(value instanceof List<?> list)
{
List<Object> copy = new ArrayList<>();
list.forEach(item -> copy.add(sortedValue(item)));
return (copy);
}
return (value);
}
/*******************************************************************************
** Strict only when the host names the tool in strictToolNames: the provider
** compiles every strict schema of a request into one grammar, which it
** refuses when it grows too large.
*******************************************************************************/
public static boolean isStrict(AssistantQBitConfig config, String toolName, boolean strictCapable)
{
return (strictCapable && config.getStrictToolNames() != null && config.getStrictToolNames().contains(toolName));
}
}
@@ -0,0 +1,58 @@
-- liquibase formatted sql
-- qbit-assistant: writes follow the signed-in person's own permissions.
--
-- From this release the assistant may propose any change the signed-in person
-- may make themselves, on every table, with no settings row: that default is
-- declared in code (AssistantQBitConfig.withDefaultTableAccess, READ_WRITE),
-- and an assistant_data_access row can only narrow it. A READ row therefore
-- now means "the assistant never proposes a change to this table".
--
-- The retired table action "Add rows for every table" wrote the OLD default
-- out as rows: one READ row per table that had none. Left in place, every one
-- of them would now be read as an administrator's restriction and writes would
-- stay off on those tables. This changeset removes those rows, and only those:
-- rows of type TABLE, mode READ, that still carry the action's fixed note and
-- were never changed after the action wrote them.
--
-- Include it from the host's master changelog, once, after the changesets that
-- create the assistant_* tables:
--
-- - include: { file: db/liquibase/qbit-assistant/assistant-data-access-follow-user.changelog.sql }
--
-- What it never touches:
-- * a row an administrator wrote (no note, or another note);
-- * a row an administrator changed after the action wrote it (its mode, its
-- note, or anything else: its modify date moved);
-- * a NONE or READ_WRITE row, and every PROCESS row.
-- Every row it leaves keeps its meaning and now reads as a restriction. The
-- direction of any mistake is the safe one: a row wrongly kept leaves a table
-- read-only for the assistant, which an administrator can see on
-- Assistant > Data Access and delete.
--
-- "Never changed" is read from the dates: the application sets a row's create
-- date and modify date in the same insert, a few microseconds apart, and moves
-- the modify date on every later save. A row whose modify date is less than one
-- second after its create date was not edited by a person.
--
-- Rules for this file, as for the starter defaults:
-- * rollback is empty: a deleted row carried no decision of an administrator,
-- and restoring it would switch writes off again;
-- * portable SQL (PostgreSQL and H2);
-- * a precondition on the table, so a host that has not created it yet simply
-- skips the changeset (MARK_RAN).
-- DataAccessFollowUserChangelogTest runs this file twice against a fresh schema
-- and against a schema that holds written-out and hand-written rows.
-- changeset qbit-assistant:data-access-follow-user-1
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_data_access WHERE 1 = 0
-- comment: Remove the unedited READ rows written by the retired action "Add rows for every table", so tables follow the code default (the signed-in person's own permissions). Rows an administrator wrote or changed are kept and now read as restrictions.
DELETE FROM assistant_data_access
WHERE object_type = 'TABLE'
AND mode = 'READ'
AND notes = 'Added by ''Add rows for every table'': the assistant''s default for this table. The user''s own permissions still apply.'
AND create_date IS NOT NULL
AND modify_date IS NOT NULL
AND modify_date < create_date + INTERVAL '1' SECOND;
-- rollback empty
@@ -459,9 +459,9 @@ entries:
content: |
Values, from least to most: **Low**, **Medium**, **High**, **Xhigh** and **Max**. Empty uses your application’s configured default effort. Models that do not support reasoning effort ignore it.
- key: table:assistant_policy;field:writesRequireConfirmation
summary: When on, or empty, every change the assistant proposes waits for the user to confirm it in the panel.
summary: No longer used. Every change the assistant proposes waits for the user to confirm it in the panel.
content: |
Off lets proposed creates, updates and deletes run at once. They still use only the asking user's own permissions and record locks, and tables set to read only in data access rules stay read only. Leave this on unless you have a clear reason.
This setting has no effect any more: a proposed create, update or delete always shows a confirmation card, and nothing can switch that off. A confirmed change still uses only the confirming user's own permissions and record locks, and tables set to read only in data access rules stay read only.
- key: table:assistant_policy;field:instanceMonthlyTokensHard
summary: The monthly token total for the whole site at which new questions are refused; empty means no token limit.
content: |
@@ -909,7 +909,7 @@ entries:
content: |
Each row sets the assistant's access to one table or action. The setting can only restrict the assistant: it always acts with the signed-in user's own permissions and record locks, so a row can never give anyone more than they already have.
Without a row, a table defaults to **Read**, and an action defaults to **None**. Tables that are marked as never to be shown to an AI stay **None** whatever you enter. There can be one row per table or action. Changes take effect within about 30 seconds.
Without a row, the assistant follows the signed-in user: it may read what they may read and propose the changes they may make themselves, and every change waits for that user to confirm it. Add a row only to restrict it: **Read** makes a table read-only for the assistant, **None** hides it. A row never gives the assistant more than the application allows: where the application's code keeps a table read-only or hidden, a row cannot change that. Tables that are marked as never to be shown to an AI, and the assistant's own tables, stay **None** whatever you enter. There can be one row per table or action. Changes take effect within about 30 seconds.
See the **Data access** card on the **Overview** to check what the assistant can do for you on a given table.
- key: table:assistant_data_access;section:access
@@ -929,7 +929,7 @@ entries:
- key: table:assistant_data_access;field:mode
summary: 'What the assistant may do: {{values:assistant_data_access_mode}}.'
content: |
**None**: the assistant never sees it. **Read**: it may look up and count records. **Read and write**: it may also propose creating, changing or deleting records, and may run an action; by default each change still waits for the user to confirm it in the panel. Default for a new rule is {{default:assistant_data_access.mode}}.
**None**: the assistant never sees it. **Read**: it may look up and count records and never proposes a change. **Read and write**: no restriction, which is what a table or action has without a row: the assistant may propose what the signed-in user may do themselves, and each change waits for that user to confirm it in the panel. Default for a new rule is {{default:assistant_data_access.mode}}.
- key: table:assistant_data_access;field:notes
summary: Optional note about the rule, up to {{maxLength:assistant_data_access.notes}} characters.
- key: table:assistant_profile;slot:help
@@ -1105,6 +1105,36 @@ entries:
summary: The values the assistant proposed to save, in stored form.
content: |
Use these read-only details to review the proposed change. Sensitive values may be replaced before storage. A proposal whose parameters were redacted cannot be confirmed.
- key: table:assistant_pending_action;field:actionKind
summary: What kind of change is proposed, which decides how its confirmation card is drawn.
content: |
record_create, record_update or record_delete for a change to one record; otherwise the name of the tool that proposed it (for example save_analysis).
- key: table:assistant_pending_action;field:beforeJson
summary: What the record held when the change was proposed, as the confirmation card showed it.
content: |
For an update, the values of the fields being changed; for a delete, the fields the user may see. Values the assistant may not show are left out or masked, as they are when the assistant opens a record. Empty for a new record.
- key: table:assistant_pending_action;field:recordVersion
summary: A fingerprint of the record as it was when the change was proposed.
content: |
When the user confirms an update or a delete, the record is read again and compared with this fingerprint. If the record changed in the meantime, the change is not made and the user is told. The fingerprint is not the record's values and they cannot be read from it.
- key: table:assistant_pending_action_claim;slot:help
summary: The single decision of a proposed change, kept so that it can only be confirmed or cancelled once.
content: |
One row is written when a user confirms or cancels a proposed change. Because there can be only one row per proposal, two requests for the same proposal (a double click, two browser tabs, two servers) cannot both run it.
Rows are created automatically, cannot be edited, and are removed together with their proposal.
- key: table:assistant_pending_action_claim;section:details
summary: Which proposal was decided, by whom and how.
- key: table:assistant_pending_action_claim;section:dates
summary: When the decision was made.
- key: table:assistant_pending_action_claim;field:pendingActionId
summary: The proposed change this decision belongs to.
- key: table:assistant_pending_action_claim;field:userId
summary: The user who confirmed or cancelled the change.
- key: table:assistant_pending_action_claim;field:decision
summary: What was decided for the proposed change.
content: |
**RUNNING**: the user confirmed it. **REJECTED**: the user cancelled it. **EXPIRED**: the proposal was withdrawn before anybody saw it.
- key: table:assistant_data_audit;slot:help
summary: A record of conversations that touched sensitive data or were blocked from reaching a provider.
content: |
@@ -1166,7 +1196,11 @@ entries:
- key: table:assistant_budget;slot:help
summary: Spending or token limits for a day or a month, for the whole site, a user, a role, a provider or a model.
content: |
A budget watches how much the assistant has used in the current UTC day or month. As use grows it warns, then either switches to a cheaper model or stops new answers, depending on the settings:
A budget watches how much the assistant has used in the current UTC day or month. As use grows it warns, then either switches to a cheaper model or stops new answers, depending on the settings.
The assistant only answers while a spending limit is in force. If no budget that is turned on applies to a user (and the application declares no hard limit of its own), the assistant refuses that user's questions and says that an administrator must set or turn on a budget. Turning a budget back on restores the assistant with the next question. A budget that is turned off, or whose limit is zero, does not count.
How a budget acts as use grows:
1. At **Warn at percent** the user sees a notice and an alert is raised.
2. At 100 percent the action in **At limit** applies: **Downgrade** switches to another model for the rest of the period, **Stop** refuses new questions.
@@ -1185,6 +1219,8 @@ entries:
summary: The warnings, downgrades and stops this budget has triggered.
- key: table:assistant_budget;field:enabled
summary: Turn off to stop applying the budget without deleting it. New budgets start turned on.
content: |
A budget that is turned off limits nothing. If that leaves a user with no spending limit at all, the assistant stops answering them until a budget is turned on again.
- key: table:assistant_budget;field:name
summary: A name for the budget; it is shown to users in warning and stop messages.
- key: table:assistant_budget;field:metric
@@ -1380,8 +1416,8 @@ entries:
summary: How far the assistant may go with a table or action, never beyond the user's own permissions.
content: |
- **None**: the assistant cannot see or use it.
- **Read**: it may look up and count records.
- **Read and write**: it may also propose changes, which the user confirms, and run actions.
- **Read**: it may look up and count records, and never proposes a change.
- **Read and write**: no restriction, which is what a table or action has without a rule. The assistant may propose what the user may do themselves, and the user confirms each change.
- key: possibleValueSource:assistant_provider_type;slot:help
summary: The kind of AI provider connection installed on this server, for example Anthropic or an OpenAI-compatible server.
content: |
@@ -1572,13 +1608,6 @@ entries:
content: |
Select conversations in **{{label:assistant_conversation}}** and choose **Delete conversations**. Each conversation is deleted with its messages, turns, tool calls, feedback and pending actions. A conversation whose answer is still in progress is skipped. Stop the answer first, then delete it.
- key: process:assistantDataAccessAddDefaults;slot:help
summary: Add missing data access rules for application tables using their current effective access.
content: |
Use **Add rows for every table** from **{{label:assistant_data_access}}** to make the current table access rules visible as records. Review the proposed rows before continuing.
You add one rule for each application table that has no rule yet. Existing rules stay unchanged, and the assistant's own tables are skipped. The new rules preserve the access already in effect; review individual rules afterward if you want to change that access. Only assistant administrators can run this action.
- key: possibleValueSource:assistant_scheduled_task;slot:help
summary: Choose the saved task associated with this execution.
@@ -13,8 +13,16 @@ components:
type: "object"
type: "array"
type: "object"
AssistantActionListResponseV1:
description: "The user's own pending actions, newest first."
properties:
actions:
items:
$ref: "#/components/schemas/AssistantActionV1"
type: "array"
type: "object"
AssistantActionV1:
description: "A write the assistant proposed and its state."
description: "A change the assistant proposed and its state."
properties:
id:
type: "integer"
@@ -40,6 +48,49 @@ components:
expiresAt:
nullable: true
type: "string"
actionKind:
description: "What is proposed: record_create, record_update, record_delete,\
\ or the proposing tool's name. A client draws its plain card for a kind\
\ it does not know."
nullable: true
type: "string"
conversationId:
description: "The conversation the change was proposed in, if any."
nullable: true
type: "integer"
createdAt:
description: "ISO-8601."
nullable: true
type: "string"
decidedAt:
description: "ISO-8601."
nullable: true
type: "string"
tableLabel:
description: "A record change: the table's label."
nullable: true
type: "string"
recordHref:
description: "A record change to an existing record: its /app link."
nullable: true
type: "string"
changes:
items:
additionalProperties: true
description: "A record create or update, one per field written: field,\
\ label, value (after) and, for an update, before (the value when the\
\ change was proposed, as the user may see it)."
type: "object"
nullable: true
type: "array"
before:
items:
additionalProperties: true
description: "A record delete, one per field the user may see: field,\
\ label, value (when the delete was proposed)."
type: "object"
nullable: true
type: "array"
outcome:
description: "After a confirm: the tool outcome."
nullable: true
@@ -199,10 +250,11 @@ components:
type: "object"
features:
additionalProperties: true
description: "writes: some table allows writes; pendingActions: writes wait\
\ for confirmation; attachments: files uploaded with POST /qqq/v1/uploads\
\ and sent as pageContext.attachments [{uploadRef, fileName}] are read\
\ for the question."
description: "writes: this user is offered a record write tool, which they\
\ are when they may create, edit or delete records of some table themselves;\
\ pendingActions: writes wait for confirmation; attachments: files uploaded\
\ with POST /qqq/v1/uploads and sent as pageContext.attachments [{uploadRef,\
\ fileName}] are read for the question."
type: "object"
profile:
$ref: "#/components/schemas/AssistantProfileV1"
@@ -490,7 +542,7 @@ info:
description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\
\ and data access explanations"
title: "QQQ Middleware API extension: assistant"
version: "1.8.0"
version: "1.9.0"
openapi: "3.0.3"
paths:
/qqq/v1/assistant/config:
@@ -1361,6 +1413,86 @@ paths:
summary: "Thumbs up or down on an assistant message"
tags:
- "assistant"
/qqq/v1/assistant/actions:
get:
description: "The user's own pending actions"
operationId: "getAssistantActions"
parameters:
- description: "Only actions of this status: PENDING for the ones still waiting\
\ for the user. Every status by default."
in: "query"
name: "status"
required: false
schema:
type: "string"
- description: "Only the actions proposed in this conversation."
in: "query"
name: "conversationId"
required: false
schema:
type: "integer"
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/AssistantActionListResponseV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not authenticated, or the session or token is invalid or expired\
\ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "The user's own pending actions"
tags:
- "assistant"
/qqq/v1/assistant/actions/{id}:
get:
description: "A write the assistant proposed, and its state"
@@ -0,0 +1,407 @@
/*
* 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.example.assistanthost;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import javax.tools.Diagnostic;
import javax.tools.DiagnosticCollector;
import javax.tools.JavaCompiler;
import javax.tools.JavaFileObject;
import javax.tools.SimpleJavaFileObject;
import javax.tools.ToolProvider;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.SignedInPerson;
import com.kingsrook.qbits.assistant.engine.ToolCallService;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.registry.AssistantTool;
import com.kingsrook.qbits.assistant.tools.registry.ConfirmedToolCall;
import com.kingsrook.qbits.assistant.tools.registry.ToolCall;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallRequest;
import com.kingsrook.qbits.assistant.tools.registry.ToolCheck;
import com.kingsrook.qbits.assistant.tools.registry.ToolFamily;
import com.kingsrook.qbits.assistant.tools.registry.ToolKind;
import com.kingsrook.qbits.assistant.tools.registry.ToolOffer;
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.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The module as a host application sees it: from a package that is not the
** assistant's, with nothing but its public surface.
**
** A host registers its own tool family and its tools run through the
** tool-call service, with every check and a log row. What a host cannot do
** is run a tool, or a change, around the service: the ways that used to
** exist do not compile any more from outside the assistant's packages (this
** test compiles each of them and expects the compiler to refuse), and the
** one thing that still compiles (a factory called without the service's
** permit) is refused when it runs.
*******************************************************************************/
class HostToolFamilyTest extends BaseAssistantTest
{
private static final String HOST_READ = "host_lookup";
private static final String HOST_PROPOSE = "host_change";
private static final AtomicInteger CONFIRMED = new AtomicInteger();
private static final String IMPORTS = """
package com.example.assistanthost;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.SignedInPerson;
import com.kingsrook.qbits.assistant.engine.ToolCallService;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.registry.*;
import com.kingsrook.qqq.backend.core.model.session.QSession;
""";
/*******************************************************************************
** The host's family, registered the one way there is.
*******************************************************************************/
@Override
protected void customizeConfig(AssistantQBitConfig config)
{
config.withToolFamily(new HostFamily());
}
/*******************************************************************************
** A host's tools run through the service like the assistant's own: the
** read tool answers and has its row; the proposing tool only proposes, and
** its change runs once the person confirms it.
*******************************************************************************/
@Test
void aHostsOwnToolFamilyRunsThroughTheService() throws Exception
{
CONFIRMED.set(0);
AssistantRuntime runtime = config.getRuntime();
SignedInPerson editor = SignedInPerson.ofSession(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.EDITOR, config));
assertThat(runtime.getToolCalls().offers(editor, HOST_READ)).isTrue();
ToolCallOutcome read = runtime.getToolCalls().executeAndLog(new ToolCall(editor, "host-1", HOST_READ, Map.of("question", "anything")), null, false);
assertThat(read.result().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(read.result().getModelContent()).isEqualTo("Looked it up as " + AssistantTestFixture.EDITOR);
ToolCallOutcome proposed = runtime.getToolCalls().executeAndLog(new ToolCall(editor, "host-2", HOST_PROPOSE, Map.of()), null, false);
assertThat(proposed.result().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(CONFIRMED.get()).as("nothing ran yet").isZero();
ToolCallService.Confirmation confirmation = runtime.getToolCalls().confirm(editor, proposed.extras().getPendingActionId());
assertThat(confirmation.action().getStatus()).isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
assertThat(CONFIRMED.get()).as("the host's change ran once, after the confirmation").isEqualTo(1);
assertThatThrownBy(() -> runtime.getToolCalls().confirm(editor, proposed.extras().getPendingActionId())).isInstanceOf(ToolCallService.ConfirmationRefused.class);
assertThat(CONFIRMED.get()).isEqualTo(1);
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.EDITOR, config));
assertThat(runtime.getStore().query(AssistantToolCall.TABLE_NAME, new QQueryFilter(), AssistantToolCall.class)).as("every call has its row: the read, the proposal, the confirmed change")
.extracting(AssistantToolCall::getToolName).containsExactlyInAnyOrder(HOST_READ, HOST_PROPOSE, HOST_PROPOSE);
}
/*******************************************************************************
** What a host is given compiles from its own package: implementing a tool
** and a family, registering it, and the service's entry points.
*******************************************************************************/
@Test
void whatAHostMayUseCompilesFromItsOwnPackage() throws Exception
{
assertThat(compile("""
class Allowed
{
Object use(AssistantRuntime runtime, SignedInPerson person, AssistantTool tool, ToolCallRequest request, ConfirmedToolCall confirmed) throws Exception
{
runtime.getToolCalls().offers(person, "host_lookup");
runtime.getToolCalls().executeAndLog(new ToolCall(person, "id", "host_lookup", Map.of()), null, false);
runtime.getToolCalls().confirm(person, 1);
runtime.getToolCalls().cancel(person, 1);
request.context().gateway();
request.context().pendingActions();
request.session();
confirmed.values();
confirmed.recordVersion();
return (SignedInPerson.ofSession(runtime.getQInstance(), person.session()));
}
}
""")).as("the control: the compiler and its classpath work").isEmpty();
}
/*******************************************************************************
** Every way around the service is refused by the compiler from outside the
** assistant's packages: the registry and the gateway cannot be asked for,
** a call cannot be run without its row or a row written without a call, a
** person cannot be made from an unchecked session, and nothing a tool is
** run with can be constructed.
*******************************************************************************/
@Test
void theWaysAroundTheServiceDoNotCompile() throws Exception
{
Map<String, String> bypasses = Map.ofEntries(
Map.entry("the runtime's tool registry", "Object o = runtime.getToolRegistry();"),
Map.entry("the runtime's tool registry, by its new name", "Object o = runtime.toolRegistry();"),
Map.entry("the runtime's tool families", "Object o = runtime.ownToolFamilies();"),
Map.entry("the runtime's UI API gateway", "Object o = runtime.getUiApiGateway();"),
Map.entry("the runtime's UI API gateway, by its new name", "Object o = runtime.uiApiGateway();"),
Map.entry("the service's registry", "Object o = runtime.getToolCalls().getRegistry();"),
Map.entry("running a call without its row", "Object o = runtime.getToolCalls().execute(new ToolCall(person, \"id\", \"list_tables\", Map.of()), scope);"),
Map.entry("writing a row without a call", "Object o = runtime.getToolCalls().log(new ToolCall(person, \"id\", \"list_tables\", Map.of()), scope, null);"),
Map.entry("withdrawing somebody's proposal", "runtime.getToolCalls().withdraw(new ToolCall(person, \"id\", \"list_tables\", Map.of()), scope);"),
Map.entry("a tool call request", "Object o = new ToolCallRequest(session, context, \"id\", \"create_record\", Map.of(), scope);"),
Map.entry("a confirmed tool call", "Object o = new ConfirmedToolCall(session, context, \"id\", \"create_record\", \"t\", null, Map.of(), \"u\");"),
Map.entry("a confirmed tool call with a version", "Object o = new ConfirmedToolCall(session, context, \"id\", \"create_record\", \"t\", null, Map.of(), \"u\", null);"),
Map.entry("a tool context", "Object o = new UiApiToolbox.Context(\"uuid\", null, null, null, null, null, null);"),
Map.entry("a tool context, the short way", "Object o = new UiApiToolbox.Context(\"uuid\", null, null, null, null);"),
Map.entry("the service's permit", "Object o = new ToolCallService.Permit();"),
Map.entry("a person from an unchecked session", "Object o = SignedInPerson.ofSession(session);"));
for(Map.Entry<String, String> bypass : bypasses.entrySet())
{
List<String> errors = compile("""
class Bypass
{
void use(AssistantRuntime runtime, SignedInPerson person, QSession session, UiApiToolbox.Context context, ToolCallScope scope) throws Exception
{
%s
}
}
""".formatted(bypass.getValue()));
assertThat(errors).as(bypass.getKey() + " must not compile from a host's package: " + bypass.getValue()).isNotEmpty();
}
}
/*******************************************************************************
** The factories that take the service's permit are public (the service is
** in another package than what it makes), and nobody but the service has a
** permit: without one they refuse. The service's public entry points are
** these and no others.
*******************************************************************************/
@Test
void withoutTheServicesPermitNothingAToolRunsWithIsMade()
{
assertThatThrownBy(() -> ToolCallRequest.of(null, null, null, "id", "create_record", Map.of(), null)).isInstanceOf(NullPointerException.class).hasMessageContaining("Only the tool-call service");
assertThatThrownBy(() -> ConfirmedToolCall.of(null, null, null, "id", "create_record", "t", null, Map.of(), "u", null)).isInstanceOf(NullPointerException.class).hasMessageContaining("Only the tool-call service");
assertThatThrownBy(() -> UiApiToolbox.Context.of(null, "uuid", null, null, null, null, null, null)).isInstanceOf(NullPointerException.class).hasMessageContaining("Only the tool-call service");
assertThat(ToolCallService.Permit.class.getDeclaredConstructors()).allMatch(constructor -> Modifier.isPrivate(constructor.getModifiers()));
assertThat(Arrays.stream(ToolCallService.class.getDeclaredFields()).filter(field -> field.getType() == ToolCallService.Permit.class)).as("the permit is the service's own")
.allMatch(field -> Modifier.isPrivate(field.getModifiers()));
assertThat(Arrays.stream(ToolCallService.class.getDeclaredMethods()).filter(method -> Modifier.isPublic(method.getModifiers()))).as("no public method hands the permit out")
.noneMatch(method -> method.getReturnType() == ToolCallService.Permit.class);
assertThat(Arrays.stream(ToolCallService.class.getDeclaredMethods()).filter(method -> Modifier.isPublic(method.getModifiers())).map(Method::getName).distinct().sorted())
.as("the service's public entry points").containsExactly("cancel", "confirm", "executeAndLog", "expiredIfDue", "failAbandonedActions", "offers", "offersAny", "outcomeName", "readContext", "summary");
assertThat(Arrays.stream(AssistantRuntime.class.getDeclaredMethods()).filter(method -> Modifier.isPublic(method.getModifiers())).map(method -> method.getReturnType().getSimpleName()))
.as("the runtime hands out no registry, gateway, toolbox or tool family").doesNotContain("ToolRegistry", "UiApiGateway", "UiApiToolbox", "KnowledgeTool", "BulkLoadTools").noneMatch(type -> type.contains("ToolFamily"));
}
/*******************************************************************************
** Compile one class of a host's package against this test's classpath; the
** compiler's errors (none when it compiles). Nothing is kept.
*******************************************************************************/
private static List<String> compile(String body) throws Exception
{
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
assertThat(compiler).as("the tests run on a JDK").isNotNull();
String name = body.contains("class Allowed") ? "Allowed" : "Bypass";
String source = IMPORTS + body;
JavaFileObject file = new SimpleJavaFileObject(URI.create("string:///com/example/assistanthost/" + name + ".java"), JavaFileObject.Kind.SOURCE)
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public CharSequence getCharContent(boolean ignoreEncodingErrors)
{
return (source);
}
};
Path out = Files.createTempDirectory("assistant-host-compile");
try
{
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
String classpath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path"));
compiler.getTask(null, null, diagnostics, List.of("-proc:none", "-classpath", classpath, "-d", out.toString()), null, List.of(file)).call();
List<String> errors = new ArrayList<>();
for(Diagnostic<? extends JavaFileObject> diagnostic : diagnostics.getDiagnostics())
{
if(diagnostic.getKind() == Diagnostic.Kind.ERROR)
{
errors.add(diagnostic.getMessage(Locale.ROOT));
}
}
return (errors);
}
finally
{
try(var files = Files.walk(out))
{
files.sorted((a, b) -> b.compareTo(a)).forEach(path -> path.toFile().delete());
}
}
}
/*******************************************************************************
** A host's family: one tool that reads, one that proposes a change.
*******************************************************************************/
private static final class HostFamily implements ToolFamily
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<AssistantTool> tools(AssistantRuntime runtime)
{
return (List.of(new HostTool(HOST_READ), new HostTool(HOST_PROPOSE)));
}
}
/*******************************************************************************
** One of the host's tools.
*******************************************************************************/
private static final class HostTool implements AssistantTool
{
private final String name;
/*******************************************************************************
**
*******************************************************************************/
private HostTool(String name)
{
this.name = name;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String name()
{
return (name);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ToolKind kind()
{
return (HOST_PROPOSE.equals(name) ? ToolKind.PROPOSE : ToolKind.READ);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<ToolCheck> checks()
{
return (HOST_PROPOSE.equals(name) ? List.of(ToolCheck.TOOL_SWITCH, ToolCheck.CONFIRMATION) : List.of(ToolCheck.TOOL_SWITCH));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ModelToolDefinition definition(ToolOffer offer)
{
return (offer.allows(name) ? new ModelToolDefinition().withName(name).withDescription("A host's tool.").withStrict(false).withInputSchema(Map.of("type", "object")) : null);
}
/*******************************************************************************
** The read answers as the person of the request; the other proposes.
*******************************************************************************/
@Override
public ToolCallOutcome execute(ToolCallRequest request)
{
AiToolResult result = new AiToolResult().withToolCallId(request.toolCallId()).withToolName(name).withOutcome(AiToolOutcome.OK);
if(HOST_READ.equals(name))
{
return (ToolCallOutcome.of(result.withSummary("Looked it up").withModelContent("Looked it up as " + request.context().sessionUUID())));
}
try
{
Integer actionId = request.context().pendingActions().propose(new UiApiToolbox.ProposedWrite(request.toolCallId(), name, "POST /host", "host_thing", null, Map.of("name", "x"), "Change a thing of the host's"));
return (ToolCallOutcome.of(UiApiToolbox.Outcome.of(result.withSummary("Proposed").withModelContent("Proposed."), "POST /host", null, actionId)));
}
catch(QException e)
{
throw (new IllegalStateException(e));
}
}
/*******************************************************************************
** The host's change, once the person confirmed it.
*******************************************************************************/
@Override
public UiApiToolbox.Outcome executeConfirmed(ConfirmedToolCall call)
{
CONFIRMED.incrementAndGet();
AiToolResult result = new AiToolResult().withToolCallId(call.toolCallId()).withToolName(name).withOutcome(AiToolOutcome.OK).withSummary("Changed").withModelContent("Changed.");
result.setDurationMs(0L);
return (UiApiToolbox.Outcome.of(result, "POST /host", 200, null));
}
}
}
@@ -37,6 +37,7 @@ import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qbits.assistant.tools.FilterLinkBuilder;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qbits.assistant.tools.Toolboxes;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
@@ -72,6 +73,7 @@ class AssistantAuditAttributionTest extends BaseAssistantTest
AssistantPendingAction action = store.insert(new AssistantPendingAction().withUserId(AssistantTestFixture.ADMIN).withTurnId(turn.getId())
.withToolCallId("write-1").withToolName(tool).withTableName("page").withRecordId("12")
.withParamsJson(JsonUtils.toJson(tool.equals("delete_record") ? Map.of() : Map.of("title", "Assistant changed this page")))
.withRecordVersion(tool.equals("create_record") ? null : Toolboxes.recordVersion(config.getRuntime(), AssistantTestFixture.ADMIN, "page", "12"))
.withStatus(AssistantPendingAction.STATUS_PENDING).withExpiresAt(Instant.now().plusSeconds(600)));
Map<String, Object> result = new AssistantApi(config.getRuntime(), QContext.getQSession()).confirmAction(action.getId());
assertThat(result.get("status")).isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
@@ -25,10 +25,12 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qbits.assistant.tools.Toolboxes;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
@@ -110,8 +112,8 @@ class AssistantChannelWallTest extends BaseAssistantTest
UiApiResult uiMetadata = ui(base, "GET", "/metaData", null);
assertThat(uiMetadata.status()).isEqualTo(200);
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
UiApiToolbox.Outcome listed = config.getRuntime().getToolbox().execute(new UiApiToolbox.Context(AssistantTestFixture.ADMIN, null,
config.getRuntime().getUiApiGateway(), null, null, null, "42"), "list", UiApiToolbox.LIST_TABLES, Map.of());
UiApiToolbox.Outcome listed = Toolboxes.execute(config.getRuntime(), Toolboxes.context(AssistantTestFixture.ADMIN, null,
RuntimeInternals.gateway(config.getRuntime()), PolicyService.ToolPolicy.defaults(), null, null, "42"), "list", UiApiToolbox.LIST_TABLES, Map.of());
for(String table : qInstance.getTables().keySet().stream().filter(name -> name.startsWith("assistant")).toList())
{
assertThat(((Map<?, ?>) metadata.json().get("tables")).containsKey(table)).isFalse();
@@ -145,7 +147,7 @@ class AssistantChannelWallTest extends BaseAssistantTest
*******************************************************************************/
private UiApiResult invoke(UiApiCall call) throws Exception
{
return (config.getRuntime().getUiApiGateway().invoke(call.withSessionUUID(AssistantTestFixture.ADMIN).withClientDetail("42").withHeader("X-QQQ-Client", "next-ui")));
return (RuntimeInternals.gateway(config.getRuntime()).invoke(call.withSessionUUID(AssistantTestFixture.ADMIN).withClientDetail("42").withHeader("X-QQQ-Client", "next-ui")));
}
@@ -252,11 +252,10 @@ class AssistantDefaultsChangelogTest extends BaseAssistantTest
assertThat(decision.notices()).singleElement().asString().contains("80%", "Monthly spend (whole instance)");
////////////////////////////////////////////////
// the seeded policy: writes wait, 50 rows max //
// the seeded policy: 50 rows max //
////////////////////////////////////////////////
PolicyService.EffectivePolicy policy = config.getRuntime().getPolicyService().current(true);
assertThat(policy.killSwitch()).isFalse();
assertThat(policy.tools().writesRequireConfirmation()).isTrue();
assertThat(policy.tools().maxRowsPerCall()).isEqualTo(50);
}
@@ -59,7 +59,7 @@ class AssistantTablesGuardrailTest extends BaseAssistantTest
Map.entry("assistant_provider", "host connection config: needs the host's key variable (kof22 seeds its providers)"),
Map.entry("assistant_model", "Discover models on a provider, or the host"),
Map.entry("assistant_rag_source", "host-specific: a host table plus an embedding model"),
Map.entry("assistant_data_access", "defaults apply without rows; 'Add rows for every table' writes them out"),
Map.entry("assistant_data_access", "the code defaults apply without rows; an administrator adds a row only to restrict"),
Map.entry("assistant_budget_override", "an administrator, when a budget is reached"),
Map.entry("assistant_budget_event", "runtime: a budget threshold is crossed"),
Map.entry("assistant_budget_usage", "runtime: every model call"),
@@ -70,6 +70,7 @@ class AssistantTablesGuardrailTest extends BaseAssistantTest
Map.entry("assistant_turn_event", "runtime: every answer's event stream"),
Map.entry("assistant_tool_call", "runtime: every tool call"),
Map.entry("assistant_pending_action", "runtime: a proposed change"),
Map.entry("assistant_pending_action_claim", "runtime: a proposed change is confirmed or cancelled"),
Map.entry("assistant_feedback", "user content: a rating"),
Map.entry("assistant_client_error", "runtime: the panel reports an error it hit"),
Map.entry("assistant_usage", "runtime: every model call"),
@@ -0,0 +1,239 @@
/*
* 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;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.assistant.access.DataAccessService;
import liquibase.Contexts;
import liquibase.Liquibase;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.ClassLoaderResourceAccessor;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The shipped migration of the data access settings
** (db/liquibase/qbit-assistant/assistant-data-access-follow-user.changelog.sql):
** it removes the READ rows the retired "Add rows for every table" action
** wrote and nobody changed, and no other row; it runs once; and a host
** without the table skips it.
*******************************************************************************/
class DataAccessFollowUserChangelogTest
{
static final String CHANGELOG = "/db/liquibase/qbit-assistant/assistant-data-access-follow-user.changelog.sql";
private static final String NOTE = DataAccessService.MATERIALISED_DEFAULT_NOTE;
private static final Instant WRITTEN = Instant.parse("2026-10-20T09:15:30.123456Z");
/*******************************************************************************
** A fresh schema: nothing to remove, twice, and the changeset ran once.
*******************************************************************************/
@Test
void freshSchemaTwice() throws Exception
{
try(Connection connection = schema(true))
{
runChangelog(connection);
runChangelog(connection);
assertThat(AssistantDefaultsChangelogTest.ranChangesets(connection)).hasSize(1).containsEntry("data-access-follow-user-1", "EXECUTED");
assertThat(names(connection)).isEmpty();
}
}
/*******************************************************************************
** Written-out and hand-written rows: only the unedited READ rows of the
** retired action go. A second run changes nothing, and a row like them
** written after the migration stays (the changeset runs once).
*******************************************************************************/
@Test
void removesOnlyTheUneditedRowsTheRetiredActionWrote() throws Exception
{
try(Connection connection = schema(true))
{
/////////////////////////////////////////////////////////////////////
// written by the action and never touched: removed. The dates of //
// one are equal, of the other a few microseconds apart, which is //
// how the application writes a row's two dates in one insert //
/////////////////////////////////////////////////////////////////////
row(connection, "TABLE", "written_same_instant", "READ", NOTE, WRITTEN, WRITTEN);
row(connection, "TABLE", "written_microseconds_apart", "READ", NOTE, WRITTEN, WRITTEN.plusNanos(41_000));
/////////////////////////////////////////////////////////////////////
// written by the action, kept: it changes nothing (NONE), or an //
// administrator changed it afterwards //
/////////////////////////////////////////////////////////////////////
row(connection, "TABLE", "written_none_for_a_sensitive_table", "NONE", NOTE, WRITTEN, WRITTEN);
row(connection, "TABLE", "written_then_saved_again", "READ", NOTE, WRITTEN, WRITTEN.plusSeconds(90));
row(connection, "TABLE", "written_then_raised", "READ_WRITE", NOTE, WRITTEN, WRITTEN.plusSeconds(3600));
row(connection, "TABLE", "written_then_lowered", "NONE", NOTE, WRITTEN, WRITTEN.plusSeconds(3600));
row(connection, "TABLE", "written_then_annotated", "READ", NOTE + " Reviewed by the data owner.", WRITTEN, WRITTEN.plusSeconds(7200));
/////////////////////////////////////////////////////////////////////
// an administrator's own rows: kept, whatever they say //
/////////////////////////////////////////////////////////////////////
row(connection, "TABLE", "by_hand_read", "READ", null, WRITTEN, WRITTEN);
row(connection, "TABLE", "by_hand_read_with_a_note", "READ", "Finance asked for read-only.", WRITTEN, WRITTEN);
row(connection, "TABLE", "by_hand_none", "NONE", "Never show this to the assistant.", WRITTEN, WRITTEN);
row(connection, "TABLE", "by_hand_read_write", "READ_WRITE", null, WRITTEN, WRITTEN);
row(connection, "PROCESS", "a_process_with_the_same_note", "READ", NOTE, WRITTEN, WRITTEN);
row(connection, "TABLE", "no_dates_at_all", "READ", NOTE, null, null);
runChangelog(connection);
List<String> kept = List.of("a_process_with_the_same_note", "by_hand_none", "by_hand_read", "by_hand_read_with_a_note", "by_hand_read_write", "no_dates_at_all",
"written_none_for_a_sensitive_table", "written_then_annotated", "written_then_lowered", "written_then_raised", "written_then_saved_again");
assertThat(names(connection)).containsExactlyInAnyOrderElementsOf(kept);
row(connection, "TABLE", "written_after_the_migration", "READ", NOTE, WRITTEN, WRITTEN);
runChangelog(connection);
List<String> after = new ArrayList<>(kept);
after.add("written_after_the_migration");
assertThat(names(connection)).as("the changeset ran once").containsExactlyInAnyOrderElementsOf(after);
assertThat(AssistantDefaultsChangelogTest.ranChangesets(connection)).hasSize(1).containsEntry("data-access-follow-user-1", "EXECUTED");
}
}
/*******************************************************************************
** A host that has not created the table yet: marked ran, nothing fails.
*******************************************************************************/
@Test
void hostWithoutTheTableSkipsIt() throws Exception
{
try(Connection connection = schema(false))
{
runChangelog(connection);
assertThat(AssistantDefaultsChangelogTest.ranChangesets(connection)).containsEntry("data-access-follow-user-1", "MARK_RAN");
}
}
/*******************************************************************************
** The note in the changeset is the note the retired action wrote, letter
** for letter, and the changeset is guarded like the starter defaults.
*******************************************************************************/
@Test
void theNoteIsTheRetiredActionsNote() throws Exception
{
String text;
try(InputStream stream = DataAccessFollowUserChangelogTest.class.getResourceAsStream(CHANGELOG))
{
assertThat(stream).as(CHANGELOG + " is on the classpath").isNotNull();
text = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
List<String> changesets = AssistantDefaultsChangelogTest.changesets(text);
assertThat(changesets).hasSize(1);
assertThat(changesets.get(0)).contains("notes = '" + NOTE.replace("'", "''") + "'")
.contains("object_type = 'TABLE'").contains("mode = 'READ'")
.contains("-- preconditions onFail:MARK_RAN onError:MARK_RAN").contains("-- rollback empty");
assertThat(NOTE).as("the note the action stamped on its rows, unchanged since the release that added the action")
.isEqualTo("Added by 'Add rows for every table': the assistant's default for this table. The user's own permissions still apply.");
}
/*******************************************************************************
** A fresh H2 (PostgreSQL mode), with the data access table from the
** reference DDL when asked.
*******************************************************************************/
private static Connection schema(boolean withTable) throws Exception
{
Connection connection = DriverManager.getConnection("jdbc:h2:mem:followuser" + System.nanoTime() + ";MODE=PostgreSQL;DEFAULT_NULL_ORDERING=HIGH");
if(withTable)
{
String ddl = Files.readString(Path.of("..", "docs", "schema-postgres.sql"), StandardCharsets.UTF_8);
try(Statement statement = connection.createStatement())
{
List<String> created = AssistantDefaultsChangelogTest.statements(ddl).stream().filter(sql -> sql.startsWith("CREATE TABLE assistant_data_access")).toList();
assertThat(created).as("the reference DDL creates assistant_data_access").hasSize(1);
statement.execute(created.get(0).replace("TIMESTAMPTZ", "TIMESTAMP WITH TIME ZONE"));
}
}
return (connection);
}
/*******************************************************************************
**
*******************************************************************************/
private static void runChangelog(Connection connection) throws Exception
{
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
new Liquibase(CHANGELOG.substring(1), new ClassLoaderResourceAccessor(), database).update(new Contexts());
}
/*******************************************************************************
**
*******************************************************************************/
private static void row(Connection connection, String type, String name, String mode, String notes, Instant created, Instant modified) throws Exception
{
try(PreparedStatement insert = connection.prepareStatement("INSERT INTO assistant_data_access (object_type, object_name, mode, notes, create_date, modify_date) VALUES (?, ?, ?, ?, ?, ?)"))
{
insert.setString(1, type);
insert.setString(2, name);
insert.setString(3, mode);
insert.setString(4, notes);
insert.setTimestamp(5, created == null ? null : Timestamp.from(created));
insert.setTimestamp(6, modified == null ? null : Timestamp.from(modified));
insert.executeUpdate();
}
}
/*******************************************************************************
** The object names of the rows that are there, sorted.
*******************************************************************************/
private static List<String> names(Connection connection) throws Exception
{
List<String> rs = new ArrayList<>();
try(Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery("SELECT object_name FROM assistant_data_access ORDER BY object_name"))
{
while(result.next())
{
rs.add(result.getString(1));
}
}
return (rs);
}
}
@@ -0,0 +1,70 @@
/*
* 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;
import java.util.List;
import com.kingsrook.qbits.assistant.tools.registry.ToolFamily;
import com.kingsrook.qbits.assistant.tools.registry.ToolRegistry;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
/*******************************************************************************
** What a runtime keeps to itself, for tests. Main code outside the runtime's
** own package cannot reach its tool registry, its tool families or the
** gateway its tools call the UI API through (none of them has a public
** getter); a test that checks one of them on its own gets it here.
*******************************************************************************/
public final class RuntimeInternals
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private RuntimeInternals()
{
}
/*******************************************************************************
** The gateway the runtime's tools call the UI API through.
*******************************************************************************/
public static UiApiGateway gateway(AssistantRuntime runtime)
{
return (runtime.uiApiGateway());
}
/*******************************************************************************
** The runtime's tool registry.
*******************************************************************************/
public static ToolRegistry registry(AssistantRuntime runtime)
{
return (runtime.toolRegistry());
}
/*******************************************************************************
** The QBit's own tool families.
*******************************************************************************/
public static List<ToolFamily> ownToolFamilies(AssistantRuntime runtime)
{
return (runtime.ownToolFamilies());
}
}
@@ -0,0 +1,214 @@
/*
* 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;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.api.AssistantApiException;
import com.kingsrook.qbits.assistant.engine.ToolCallService;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.registry.ToolCall;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ADMIN;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.NO_ACCESS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** A signed-in person is one person: the user the assistant checks and the
** credential the UI API then acts with cannot name two.
*******************************************************************************/
class SignedInPersonTest extends BaseAssistantTest
{
/*******************************************************************************
** A session put together by hand, naming one user and carrying the session
** id of another, is not a person: the id is resolved by the authentication
** module, as the UI API will resolve it, and it is somebody else's. (It
** used to be accepted: the assistant then checked the first user's access
** while the data was read and written as the second.)
*******************************************************************************/
@Test
void aSessionNamingOneUserWithAnothersIdIsNotAPerson()
{
QSession forged = new QSession().withUser(new QUser().withIdReference(ADMIN).withFullName("Not really the administrator"));
forged.setUuid(EDITOR);
forged.setPermissions(AssistantTestFixture.permissionsFor(ADMIN));
assertThatThrownBy(() -> SignedInPerson.ofSession(qInstance, forged)).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("does not resolve to the user");
QSession nameless = new QSession().withUser(new QUser().withIdReference(ADMIN));
nameless.setUuid(null);
assertThatThrownBy(() -> SignedInPerson.ofSession(qInstance, nameless)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> SignedInPerson.ofSession(null, forged)).isInstanceOf(IllegalArgumentException.class);
SignedInPerson real = SignedInPerson.ofSession(qInstance, AssistantTestFixture.sessionFor(EDITOR));
assertThat(real.userId()).isEqualTo(EDITOR);
assertThat(real.credentials()).as("the session's own id is the credential").isNull();
}
/*******************************************************************************
** A person is the session the authentication module resolved, not the
** object the caller passed: a session made by hand for a real user, with a
** permission that user does not hold added to it, gives the person
** nothing. Somebody without assistant access does not get it this way, and
** an administrator's permission cannot be put on.
*******************************************************************************/
@Test
void aPermissionAddedToASessionByHandGivesThePersonNothing() throws Exception
{
QSession handMade = AssistantTestFixture.sessionFor(NO_ACCESS);
handMade.getPermissions().add(AssistantQBitConfig.ACCESS_PERMISSION);
handMade.getPermissions().add(AssistantQBitConfig.ADMIN_PERMISSION);
handMade.getPermissions().add("blog_post.read");
assertThat(handMade.hasPermission(AssistantQBitConfig.ACCESS_PERMISSION)).as("the object passed in says so").isTrue();
SignedInPerson person = SignedInPerson.ofSession(qInstance, handMade);
assertThat(person.session()).isNotSameAs(handMade);
assertThat(person.userId()).isEqualTo(NO_ACCESS);
assertThat(person.session().getUuid()).isEqualTo(NO_ACCESS);
assertThat(person.session().hasPermission(AssistantQBitConfig.ACCESS_PERMISSION)).as("what the authentication module says").isFalse();
assertThat(person.session().hasPermission(AssistantQBitConfig.ADMIN_PERMISSION)).isFalse();
ToolCallOutcome refused = config.getRuntime().getToolCalls().executeAndLog(new ToolCall(person, "call-1", UiApiToolbox.LIST_TABLES, Map.of()), null, false);
assertThat(refused.result().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(refused.result().getPermissionCheck()).isEqualTo(ToolCallService.CHECK_ACCESS);
assertThatThrownBy(() -> config.getRuntime().getToolCalls().readContext(person, null)).hasMessageContaining("do not have access");
/////////////////////////////////////////////////////////////////////
// the same through the assistant API bound to that session: what //
// acts as the person is refused //
/////////////////////////////////////////////////////////////////////
QContext.init(qInstance, handMade);
AssistantApi api = new AssistantApi(config.getRuntime(), handMade);
assertThatThrownBy(() -> api.confirmAction(1)).isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(403));
}
/*******************************************************************************
** A session somebody kept does not keep what an administrator has since
** taken away: the next person made from it has the permissions of now. The
** revoked assistant access is seen on the next call, and so is a revoked
** permission to change a table.
*******************************************************************************/
@Test
void aRevokedPermissionIsSeenOnTheNextCall() throws Exception
{
QSession kept = AssistantTestFixture.ownerSession(EDITOR, config);
ToolCallService service = config.getRuntime().getToolCalls();
assertThat(service.executeAndLog(new ToolCall(SignedInPerson.ofSession(qInstance, kept), "call-1", UiApiToolbox.LIST_TABLES, Map.of()), null, false).result().getOutcome())
.isEqualTo(AiToolOutcome.OK);
AssistantTestFixture.revoke(EDITOR, AssistantQBitConfig.ACCESS_PERMISSION);
assertThat(kept.hasPermission(AssistantQBitConfig.ACCESS_PERMISSION)).as("the kept object still says yes").isTrue();
SignedInPerson now = SignedInPerson.ofSession(qInstance, kept);
assertThat(now.session().hasPermission(AssistantQBitConfig.ACCESS_PERMISSION)).isFalse();
ToolCallOutcome refused = service.executeAndLog(new ToolCall(now, "call-2", UiApiToolbox.LIST_TABLES, Map.of()), null, false);
assertThat(refused.result().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(refused.result().getPermissionCheck()).isEqualTo(ToolCallService.CHECK_ACCESS);
QSession author = AssistantTestFixture.ownerSession(AssistantTestFixture.AUTHOR, config);
assertThat(service.offers(SignedInPerson.ofSession(qInstance, author), UiApiToolbox.UPDATE_RECORD)).isTrue();
for(String table : List.of("blog_post", "page", "media"))
{
for(String permission : List.of("insert", "edit", "delete"))
{
AssistantTestFixture.revoke(AssistantTestFixture.AUTHOR, table + "." + permission);
}
}
assertThat(service.offers(SignedInPerson.ofSession(qInstance, author), UiApiToolbox.UPDATE_RECORD)).as("no longer may change anything").isFalse();
}
/*******************************************************************************
** The person's time zone, a display preference that decides nothing, is the
** one thing carried over from the session passed in.
*******************************************************************************/
@Test
void onlyTheTimeZoneIsCarriedOverFromTheSessionPassedIn()
{
QSession session = AssistantTestFixture.sessionFor(EDITOR);
session.setValue(QSession.VALUE_KEY_USER_TIMEZONE, "America/Chicago");
session.setValue("somethingElse", "not carried over");
session.withSecurityKeyValue("anyKey", "any value");
SignedInPerson person = SignedInPerson.ofSession(qInstance, session);
assertThat(person.session().getValue(QSession.VALUE_KEY_USER_TIMEZONE)).isEqualTo("America/Chicago");
assertThat(person.session().getValue("somethingElse")).isNull();
assertThat(person.session().getSecurityKeyValues("anyKey")).isNullOrEmpty();
}
/*******************************************************************************
** The assistant API bound to such a session acts for nobody: it starts no
** turn, confirms and cancels no action and explains no access.
*******************************************************************************/
@Test
void anApiOfAForgedSessionActsForNobody() throws Exception
{
QSession forged = new QSession().withUser(new QUser().withIdReference(ADMIN).withFullName("Not really the administrator"));
forged.setUuid(EDITOR);
forged.setPermissions(AssistantTestFixture.permissionsFor(ADMIN));
QContext.init(qInstance, forged);
AssistantApi api = new AssistantApi(config.getRuntime(), forged);
assertThatThrownBy(() -> api.confirmAction(1)).isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(403));
assertThatThrownBy(() -> api.rejectAction(1)).isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(403));
assertThatThrownBy(() -> api.access("blog_post", null)).isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(403));
Integer conversationId = ((Number) api.createConversation(Map.of("title", "Of a forged session")).get("id")).intValue();
assertThatThrownBy(() -> api.startTurn(conversationId, Map.of("text", "list the tables"))).isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(403));
}
/*******************************************************************************
** Of the credentials a request carries, only the one its session was
** resolved from is forwarded: session setup takes the sessionId cookie
** before the sessionUUID cookie, that before the API key, and that before
** the Authorization header. A second credential on the request (which may
** be another person's) goes nowhere.
*******************************************************************************/
@Test
void onlyTheCredentialTheSessionWasResolvedFromIsForwarded()
{
UiApiCredentials all = SignedInPerson.resolvedFrom(new UiApiCredentials("uuid-cookie", "id-cookie", "Bearer header", "api-key"));
assertThat(all).isEqualTo(new UiApiCredentials(null, "id-cookie", null, null));
assertThat(SignedInPerson.resolvedFrom(new UiApiCredentials("uuid-cookie", null, "Bearer header", "api-key"))).isEqualTo(new UiApiCredentials("uuid-cookie", null, null, null));
assertThat(SignedInPerson.resolvedFrom(new UiApiCredentials(null, " ", "Bearer header", "api-key"))).isEqualTo(new UiApiCredentials(null, null, null, "api-key"));
assertThat(SignedInPerson.resolvedFrom(new UiApiCredentials(null, null, "Bearer header", null))).isEqualTo(new UiApiCredentials(null, null, "Bearer header", null));
assertThat(SignedInPerson.resolvedFrom(new UiApiCredentials("uuid-cookie", "id-cookie", "Bearer header", "api-key", "in-process"))).isEqualTo(new UiApiCredentials(null, null, null, null, "in-process"));
assertThat(SignedInPerson.resolvedFrom(new UiApiCredentials(null, null, null, null)).isPresent()).as("no credential: the session's own id is used").isFalse();
assertThat(SignedInPerson.resolvedFrom(null)).isNull();
}
}
@@ -0,0 +1,111 @@
/*
* 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;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.kingsrook.qbits.assistant.engine.ToolCallServiceInternals;
import com.kingsrook.qbits.assistant.tools.Toolboxes;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** What this module publishes as its test-jar: the fixture application and
** the eval harness of the testing package, for the provider modules' own
** tests, and nothing else.
**
** This module's tests have helpers that reach behind the tool-call service
** on purpose (RuntimeInternals: the registry, the gateway and the tool
** families; ToolCallServiceInternals: a call without its log row;
** Toolboxes: a tool run with no service in front of it, and a context made
** by hand). Published, they would hand any project that depends on the
** test-jar a public way around the service. They are not published, and the
** published package does not need them.
*******************************************************************************/
class TestJarContentsTest
{
private static final String PUBLISHED = "com/kingsrook/qbits/assistant/testing/**";
/*******************************************************************************
** The test-jar is built from the testing package only.
*******************************************************************************/
@Test
void theTestJarHoldsTheTestingPackageAndNothingElse() throws Exception
{
String pom = Files.readString(Path.of("pom.xml"), StandardCharsets.UTF_8);
Matcher execution = Pattern.compile("<goal>test-jar</goal>\\s*</goals>\\s*<configuration>(.*?)</configuration>", Pattern.DOTALL).matcher(pom);
assertThat(execution.find()).as("the test-jar execution says what it includes").isTrue();
assertThat(execution.find()).as("there is one test-jar execution").isFalse();
execution.reset();
execution.find();
List<String> includes = new ArrayList<>();
Matcher include = Pattern.compile("<include>([^<]+)</include>").matcher(execution.group(1));
while(include.find())
{
includes.add(include.group(1).strip());
}
assertThat(includes).as("what is published").containsExactly(PUBLISHED);
assertThat(execution.group(1)).as("an include list, not an exclude list: a new helper is unpublished unless it is put in the testing package").doesNotContain("<exclude>");
for(Class<?> helper : List.of(RuntimeInternals.class, ToolCallServiceInternals.class, Toolboxes.class))
{
assertThat(helper.getPackageName()).as(helper.getSimpleName() + " is not in the published package").doesNotStartWith("com.kingsrook.qbits.assistant.testing");
}
}
/*******************************************************************************
** The published package stands on its own: it uses no test class outside
** itself (so the test-jar works without the rest of this module's tests),
** and in particular none of the helpers that reach behind the service.
*******************************************************************************/
@Test
void thePublishedPackageNeedsNoTestClassOutsideItself()
{
JavaClasses testing = new ClassFileImporter().importPackages("com.kingsrook.qbits.assistant.testing");
assertThat(testing).as("the testing package was found").isNotEmpty();
noClasses().should().dependOnClassesThat(new DescribedPredicate<JavaClass>("a test class of this module outside the testing package")
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean test(JavaClass type)
{
boolean testClass = type.getSource().map(source -> source.getUri().toString().contains("/test-classes/")).orElse(false);
return (testClass && !type.getPackageName().startsWith("com.kingsrook.qbits.assistant.testing"));
}
}).because("only the testing package is published in the test-jar").check(testing);
}
}
@@ -0,0 +1,342 @@
/*
* 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.access;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.exposure.AiExposurePolicy;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import static com.kingsrook.qbits.assistant.access.DataAccessMode.NONE;
import static com.kingsrook.qbits.assistant.access.DataAccessMode.READ;
import static com.kingsrook.qbits.assistant.access.DataAccessMode.READ_WRITE;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** What decides the assistant's access to a table or a process, and in which
** order: the assistant's own objects and sensitive tables are NONE whatever
** anything says; then the code (the host's pin, else the code default); then
** the administrator's row, which can only narrow what the code says.
*******************************************************************************/
class DataAccessPrecedenceTest extends BaseAssistantTest
{
private static final String PIN = DataAccessService.SOURCE_PIN;
private static final String DEFAULT = DataAccessService.SOURCE_DEFAULT;
private static final String SETTING = DataAccessService.SOURCE_SETTING;
private static final String RAISE = DataAccessService.CANNOT_RAISE;
/*******************************************************************************
** Every combination of the code default, a pin and an administrator's row:
** the mode and what decided it. null means "none declared".
*******************************************************************************/
static Stream<Arguments> precedence()
{
return (Stream.of(
///////////////////////////////////////////////////////////////////
// nothing but the default //
///////////////////////////////////////////////////////////////////
Arguments.of(READ_WRITE, null, null, READ_WRITE, DEFAULT),
Arguments.of(READ, null, null, READ, DEFAULT),
Arguments.of(NONE, null, null, NONE, DEFAULT),
///////////////////////////////////////////////////////////////////
// a row narrows the default, and never raises it //
///////////////////////////////////////////////////////////////////
Arguments.of(READ_WRITE, null, READ, READ, SETTING),
Arguments.of(READ_WRITE, null, NONE, NONE, SETTING),
Arguments.of(READ_WRITE, null, READ_WRITE, READ_WRITE, DEFAULT),
Arguments.of(READ, null, READ_WRITE, READ, DEFAULT + RAISE),
Arguments.of(READ, null, NONE, NONE, SETTING),
Arguments.of(READ, null, READ, READ, DEFAULT),
Arguments.of(NONE, null, READ_WRITE, NONE, DEFAULT + RAISE),
Arguments.of(NONE, null, READ, NONE, DEFAULT + RAISE),
///////////////////////////////////////////////////////////////////
// a pin replaces the default for its object //
///////////////////////////////////////////////////////////////////
Arguments.of(READ_WRITE, READ, null, READ, PIN),
Arguments.of(READ_WRITE, NONE, null, NONE, PIN),
Arguments.of(READ, READ_WRITE, null, READ_WRITE, PIN),
Arguments.of(NONE, READ, null, READ, PIN),
///////////////////////////////////////////////////////////////////
// a row narrows a pin, and never raises it //
///////////////////////////////////////////////////////////////////
Arguments.of(READ_WRITE, READ, READ_WRITE, READ, PIN + RAISE),
Arguments.of(READ_WRITE, NONE, READ_WRITE, NONE, PIN + RAISE),
Arguments.of(READ_WRITE, NONE, READ, NONE, PIN + RAISE),
Arguments.of(READ_WRITE, READ, NONE, NONE, SETTING),
Arguments.of(READ, READ_WRITE, READ, READ, SETTING),
Arguments.of(READ, READ_WRITE, NONE, NONE, SETTING),
Arguments.of(READ, READ_WRITE, READ_WRITE, READ_WRITE, PIN)));
}
/*******************************************************************************
** A table that is not sensitive and not the assistant's own.
*******************************************************************************/
@ParameterizedTest(name = "table: default {0}, pin {1}, row {2} -> {3} ({4})")
@MethodSource("precedence")
void aTableIsWhatTheCodeSaysNarrowedByTheRow(DataAccessMode codeDefault, DataAccessMode pin, DataAccessMode row, DataAccessMode expected, String source) throws Exception
{
config.setDefaultTableAccess(codeDefault);
if(pin != null)
{
config.withTableAccess("media", pin);
}
if(row != null)
{
row(AssistantDataAccess.TYPE_TABLE, "media", row.name());
}
DataAccessService.Decision decision = access().forTable("media");
assertThat(decision.mode()).isEqualTo(expected);
assertThat(decision.source()).isEqualTo(source);
assertThat(access().forTable("page").mode()).as("a table with no pin and no row has the default").isEqualTo(codeDefault);
}
/*******************************************************************************
** A process of the application, by the same rules.
*******************************************************************************/
@ParameterizedTest(name = "process: default {0}, pin {1}, row {2} -> {3} ({4})")
@MethodSource("precedence")
void aProcessIsWhatTheCodeSaysNarrowedByTheRow(DataAccessMode codeDefault, DataAccessMode pin, DataAccessMode row, DataAccessMode expected, String source) throws Exception
{
qInstance.addProcess(new QProcessMetaData().withName("monthEndClose").withLabel("Month-end close"));
config.setDefaultProcessAccess(codeDefault);
if(pin != null)
{
config.withProcessAccess("monthEndClose", pin);
}
if(row != null)
{
row(AssistantDataAccess.TYPE_PROCESS, "monthEndClose", row.name());
}
DataAccessService.Decision decision = access().forProcess("monthEndClose");
assertThat(decision.mode()).isEqualTo(expected);
assertThat(decision.source()).isEqualTo(source);
}
/*******************************************************************************
** With no pin and no row, the assistant follows the person: a table and a
** process are READ_WRITE, declared in code.
*******************************************************************************/
@Test
void theCodeDefaultsFollowThePerson()
{
AssistantQBitConfig fresh = new AssistantQBitConfig();
assertThat(fresh.getDefaultTableAccess()).isEqualTo(READ_WRITE);
assertThat(fresh.getDefaultProcessAccess()).isEqualTo(READ_WRITE);
assertThat(fresh.getTableAccess()).isEmpty();
assertThat(fresh.getProcessAccess()).isEmpty();
assertThat(access().forTable("blog_post")).isEqualTo(new DataAccessService.Decision(READ_WRITE, DEFAULT));
List<String> errors = new ArrayList<>();
new AssistantQBitConfig().withDefaultTableAccess(null).validate(null, errors);
assertThat(errors).anyMatch(error -> error.contains("defaultTableAccess"));
errors.clear();
new AssistantQBitConfig().withTableAccess("orders", null).validate(null, errors);
assertThat(errors).anyMatch(error -> error.contains("tableAccess"));
}
/*******************************************************************************
** The code pin nobody can lift: every table of the assistant itself (its
** policy, providers, models, prices, budgets and overrides, data access
** settings, profiles, skills, knowledge sources, scheduled tasks,
** conversations, tool calls, pending actions, usage) is NONE for the
** assistant, whoever asks, with a READ_WRITE row and a READ_WRITE pin on
** it; so is every process of the assistant. A table whose metadata says it
** is not exposed to AI is NONE the same way.
*******************************************************************************/
@Test
void theAssistantsOwnObjectsAndSensitiveTablesAreNeverRaised() throws Exception
{
List<String> own = qInstance.getTables().keySet().stream().filter(name -> name.startsWith("assistant_")).sorted().toList();
assertThat(own).as("the settings, budgets, policy and provider tables are among the pinned ones").contains(
"assistant_policy", "assistant_provider", "assistant_model", "assistant_model_price", "assistant_budget", "assistant_budget_override", "assistant_data_access",
"assistant_profile", "assistant_skill", "assistant_rag_source", "assistant_scheduled_task", "assistant_conversation", "assistant_tool_call", "assistant_pending_action",
"assistant_pending_action_claim", "assistant_usage");
String assistantQBit = qInstance.getTable(AssistantDataAccess.TABLE_NAME).getSourceQBitName();
for(QTableMetaData table : qInstance.getTables().values())
{
if(assistantQBit.equals(table.getSourceQBitName()))
{
assertThat(DataAccessService.isOwnTable(table.getName())).as("every table this QBit produces is covered by the pin: " + table.getName()).isTrue();
}
}
for(String tableName : own)
{
config.withTableAccess(tableName, READ_WRITE);
row(AssistantDataAccess.TYPE_TABLE, tableName, READ_WRITE.name());
}
List<String> ownProcesses = qInstance.getProcesses().values().stream().filter(process -> assistantQBit.equals(process.getSourceQBitName())).map(QProcessMetaData::getName).sorted().toList();
assertThat(ownProcesses).isNotEmpty();
for(String processName : ownProcesses)
{
config.withProcessAccess(processName, READ_WRITE);
row(AssistantDataAccess.TYPE_PROCESS, processName, READ_WRITE.name());
}
for(String tableName : own)
{
assertThat(access().forTable(tableName)).as(tableName).isEqualTo(new DataAccessService.Decision(NONE, DataAccessService.SOURCE_OWN));
}
for(String processName : ownProcesses)
{
assertThat(access().forProcess(processName)).as(processName).isEqualTo(new DataAccessService.Decision(NONE, DataAccessService.SOURCE_OWN));
}
///////////////////////////////////////////////////////////////////////
// a table the metadata keeps from AI (here by its name, as core's //
// exposure policy reads an OAuth table): NONE with nothing said, and //
// a READ_WRITE pin and a READ_WRITE row change nothing //
///////////////////////////////////////////////////////////////////////
QTableMetaData sensitive = AssistantTestFixture.table("oauth_client_token", "OAuth Client Token", "id");
qInstance.addTable(sensitive);
assertThat(exposure().getTableExposure(sensitive)).isEqualTo(AiExposure.NONE);
assertThat(access().forTable(sensitive.getName())).isEqualTo(new DataAccessService.Decision(NONE, DataAccessService.SOURCE_SENSITIVE));
config.withTableAccess(sensitive.getName(), READ_WRITE);
row(AssistantDataAccess.TYPE_TABLE, sensitive.getName(), READ_WRITE.name());
assertThat(access().forTable(sensitive.getName())).isEqualTo(new DataAccessService.Decision(NONE, DataAccessService.SOURCE_SENSITIVE + RAISE));
assertThat(access().forTable("no_such_table").mode()).isEqualTo(NONE);
assertThat(access().forProcess("noSuchProcess").mode()).isEqualTo(NONE);
assertThat(access().forTable(null).mode()).isEqualTo(NONE);
}
/*******************************************************************************
** A restriction that cannot be read is not a restriction lifted: when the
** administrator's rows were never read, every object is NONE; when they
** were, the last rows read stay in force. A row whose mode cannot be
** understood is the strictest one.
*******************************************************************************/
@Test
void settingsThatCannotBeReadAllowNothing() throws Exception
{
AssistantRuntime runtime = config.getRuntime();
boolean[] broken = {true};
AssistantStore failing = new AssistantStore(config)
{
/*******************************************************************************
** The rows, with one more whose mode no release ever wrote (the record
** screen refuses such a mode; a database can still hold one).
*******************************************************************************/
@Override
public <T extends QRecordEntity> List<T> query(String tableName, QQueryFilter filter, Class<T> entityClass) throws QException
{
if(!AssistantDataAccess.TABLE_NAME.equals(tableName))
{
return (super.query(tableName, filter, entityClass));
}
if(broken[0])
{
throw (new QException("The database went away."));
}
List<T> rows = new ArrayList<>(super.query(tableName, filter, entityClass));
rows.add(entityClass.cast(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName("page").withMode("WRITE_EVERYTHING")));
return (rows);
}
};
config.setPolicyCacheSeconds(0);
DataAccessService service = new DataAccessService(qInstance, config, failing, exposure(), Clock.systemUTC());
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
assertThat(service.forTable("blog_post")).isEqualTo(new DataAccessService.Decision(NONE, DataAccessService.SOURCE_UNREADABLE));
assertThat(service.forProcess(qInstance.getProcesses().keySet().iterator().next()).mode()).isEqualTo(NONE);
assertThat(service.anyWritableTable()).isFalse();
runtime.getStore().insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName("blog_post").withMode(READ.name()));
broken[0] = false;
assertThat(service.forTable("blog_post")).isEqualTo(new DataAccessService.Decision(READ, SETTING));
assertThat(service.forTable("media")).isEqualTo(new DataAccessService.Decision(READ_WRITE, DEFAULT));
assertThat(service.forTable("page")).as("a mode that cannot be understood").isEqualTo(new DataAccessService.Decision(NONE, SETTING));
assertThat(service.anyWritableTable()).isTrue();
broken[0] = true;
assertThat(service.forTable("blog_post")).as("the last rows read stay in force").isEqualTo(new DataAccessService.Decision(READ, SETTING));
service.invalidate();
assertThat(service.forTable("blog_post")).isEqualTo(new DataAccessService.Decision(READ, SETTING));
}
/*******************************************************************************
**
*******************************************************************************/
private DataAccessService access()
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
config.getRuntime().getDataAccessService().invalidate();
return (config.getRuntime().getDataAccessService());
}
/*******************************************************************************
**
*******************************************************************************/
private AiExposurePolicy exposure()
{
return (new AiExposurePolicy(AssistantRuntime.kernelSettings(config)));
}
/*******************************************************************************
** An administrator's row, written straight to the store (the record screen
** refuses a mode it does not know; a database can still hold one).
*******************************************************************************/
private void row(String type, String name, String mode) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
config.getRuntime().getStore().insert(new AssistantDataAccess().withObjectType(type).withObjectName(name).withMode(mode));
config.getRuntime().getDataAccessService().invalidate();
}
}
@@ -21,6 +21,7 @@ import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.RuntimeInternals;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
import com.kingsrook.qbits.assistant.home.AssistantHomeData;
@@ -160,16 +161,18 @@ class ProfilesSkillsAndAccessTest extends BaseAssistantTest
void testExplainAccess() throws Exception
{
UiApiTestServer.serve(qInstance, config);
save(AssistantDataAccess.TABLE_NAME, Map.of("objectType", "TABLE", "objectName", "page", "mode", "READ_WRITE"));
save(AssistantDataAccess.TABLE_NAME, Map.of("objectType", "TABLE", "objectName", "media", "mode", "READ"));
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.EDITOR));
AssistantApi editor = new AssistantApi(config.getRuntime(), QContext.getQSession());
Map<String, Object> blog = only(editor.access("blog_post", null));
assertThat(verdict(blog, "query_records")).containsEntry("allowed", true);
assertThat(String.valueOf(verdict(blog, "create_record").get("reason"))).contains("READ_WRITE");
Map<String, Object> page = only(editor.access("page", null));
assertThat(page.get("dataAccess")).isEqualTo("READ_WRITE");
assertThat(String.valueOf(verdict(page, "create_record").get("reason"))).contains("no insert permission");
assertThat(blog.get("dataAccess")).as("no row: the table follows the person").isEqualTo("READ_WRITE");
assertThat(String.valueOf(verdict(blog, "create_record").get("reason"))).as("the person's own permission decides").contains("no insert permission");
Map<String, Object> media = only(editor.access("media", null));
assertThat(media.get("dataAccess")).as("the administrator's row narrows").isEqualTo("READ");
assertThat(String.valueOf(verdict(media, "create_record").get("reason"))).contains("READ_WRITE").contains("administrator setting");
assertThat(verdict(media, "query_records")).containsEntry("allowed", true);
assertThat(editor.access(null, "how many blog posts were published")).extracting(m -> ((List<?>) m.get("tables")).size()).isEqualTo(1);
QContext.clear();
@@ -185,7 +188,7 @@ class ProfilesSkillsAndAccessTest extends BaseAssistantTest
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
Map<String, Object> adminPage = only(new AssistantApi(config.getRuntime(), QContext.getQSession()).access("page", null));
assertThat(verdict(adminPage, "create_record")).containsEntry("allowed", true);
String html = AssistantAccessWidget.render(config.getRuntime().getAccessExplainer().explain(config.getRuntime().getUiApiGateway(), AssistantTestFixture.ADMIN,
String html = AssistantAccessWidget.render(config.getRuntime().getAccessExplainer().explain(RuntimeInternals.gateway(config.getRuntime()), AssistantTestFixture.ADMIN,
config.getRuntime().getPolicyService().current(false).tools(), null, null), "Admin", "/app");
assertThat(html).contains("Page").contains("create_record").contains("Read and write").doesNotContain("<table");
QContext.clear();
@@ -0,0 +1,417 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.api;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPendingActionClaim;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.processes.AssistantRetentionStep;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.ToolCalls;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.registry.ActionKinds;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ADMIN;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ANALYST;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Pending actions as the person's card needs them: what the record held
** before and what it will hold after, the kind of change, a list of the
** person's own, and their removal after the retention time. And the rule the
** card stands for: what was approved is what is written, so a record that
** changed after the proposal is left as it is.
*******************************************************************************/
class PendingActionsTest extends BaseAssistantTest
{
/*******************************************************************************
** An update's card shows each field with its value before and after, the
** record and the table; the stored action keeps its kind, what the card
** showed and a version of the record (a digest, not its values).
*******************************************************************************/
@Test
@SuppressWarnings("unchecked")
void anUpdateShowsEachFieldBeforeAndAfter() throws Exception
{
writable("blog_post");
String titleNow = String.valueOf(post(1).getValue("title"));
ToolCallOutcome proposed = propose(ADMIN, UiApiToolbox.UPDATE_RECORD, input("table", "blog_post", "id", "1", "values", Map.of("title", "A better title")));
assertThat(proposed.result().getOutcome()).as(proposed.result().getModelContent()).isEqualTo(AiToolOutcome.OK);
assertThat(proposed.extras().getHttpStatus()).as("nothing was written").isNull();
Map<String, Object> card = (Map<String, Object>) proposed.result().getRender();
assertThat(card).containsEntry("type", "pending_action").containsEntry("toolName", UiApiToolbox.UPDATE_RECORD).containsEntry("actionKind", ActionKinds.RECORD_UPDATE)
.containsEntry("tableName", "blog_post").containsEntry("recordId", "1").containsKeys("tableLabel", "recordLabel", "href");
assertThat((List<Map<String, Object>>) card.get("changes")).singleElement().satisfies(change ->
assertThat(change).containsEntry("field", "title").containsEntry("before", titleNow).containsEntry("value", "A better title").containsKey("label"));
AssistantPendingAction stored = action(ADMIN, proposed.extras().getPendingActionId());
assertThat(stored.getActionKind()).isEqualTo(ActionKinds.RECORD_UPDATE);
assertThat(stored.getBeforeJson()).contains(titleNow);
assertThat(stored.getRecordVersion()).as("a digest").matches("[0-9a-f]{64}").doesNotContain(titleNow);
Map<String, Object> read = api(ADMIN).getAction(stored.getId());
assertThat(read).containsEntry("status", "PENDING").containsEntry("actionKind", ActionKinds.RECORD_UPDATE).containsKeys("tableLabel", "recordHref", "expiresAt", "createdAt");
assertThat((List<Map<String, Object>>) read.get("changes")).singleElement().satisfies(change ->
assertThat(change).containsEntry("field", "title").containsEntry("before", titleNow).containsEntry("value", "A better title"));
}
/*******************************************************************************
** A delete's card shows what the record holds, by the exposure rules of
** get_record: a hidden field and a secret are not on it, nor stored.
*******************************************************************************/
@Test
@SuppressWarnings("unchecked")
void aDeleteShowsWhatGoesAndNeverAHiddenValue() throws Exception
{
writable("blog_post");
ToolCallOutcome proposed = propose(ADMIN, UiApiToolbox.DELETE_RECORD, input("table", "blog_post", "id", "1"));
assertThat(proposed.result().getOutcome()).as(proposed.result().getModelContent()).isEqualTo(AiToolOutcome.OK);
Map<String, Object> card = (Map<String, Object>) proposed.result().getRender();
assertThat(card).containsEntry("actionKind", ActionKinds.RECORD_DELETE);
assertThat((List<Map<String, Object>>) card.get("before")).as("the record's visible fields").extracting(line -> line.get("field")).contains("title").doesNotContain("internalNotes", "apiSecret");
AssistantPendingAction stored = action(ADMIN, proposed.extras().getPendingActionId());
assertThat(stored.getBeforeJson()).contains("title").doesNotContain(AssistantTestFixture.HIDDEN_SENTINEL, AssistantTestFixture.PASSWORD_SENTINEL);
assertThat(String.valueOf(card)).doesNotContain(AssistantTestFixture.HIDDEN_SENTINEL, AssistantTestFixture.PASSWORD_SENTINEL);
assertThat((List<Map<String, Object>>) api(ADMIN).getAction(stored.getId()).get("before")).extracting(line -> line.get("field")).contains("title");
}
/*******************************************************************************
** What was approved is what is written. A record that changed after the
** change was proposed is not written: the update is refused with the
** reason and the later change stays; the delete is refused and the record
** stays. A record that did not change is updated.
*******************************************************************************/
@Test
void aRecordThatChangedAfterTheProposalIsLeftAsItIs() throws Exception
{
writable("blog_post");
Integer update = propose(ADMIN, UiApiToolbox.UPDATE_RECORD, input("table", "blog_post", "id", "1", "values", Map.of("title", "The assistant's title"))).extras().getPendingActionId();
Integer delete = propose(ADMIN, UiApiToolbox.DELETE_RECORD, input("table", "blog_post", "id", "2")).extras().getPendingActionId();
Integer clean = propose(ADMIN, UiApiToolbox.UPDATE_RECORD, input("table", "blog_post", "id", "3", "values", Map.of("title", "Unchanged in between"))).extras().getPendingActionId();
///////////////////////////////////////////////////////////////////////
// somebody else changes records 1 and 2 in the application //
///////////////////////////////////////////////////////////////////////
changeInTheApplication(1, "title", "Somebody else's title");
changeInTheApplication(2, "excerpt", "Somebody else's excerpt");
Map<String, Object> updated = api(ADMIN).confirmAction(update);
assertThat(updated).containsEntry("status", AssistantPendingAction.STATUS_FAILED).containsEntry("outcome", AiToolOutcome.INVALID_INPUT.name());
assertThat(String.valueOf(updated.get("message"))).startsWith(UiApiToolbox.STALE_MESSAGE);
assertThat(updated.get("resultSummary")).isEqualTo(UiApiToolbox.STALE_MESSAGE);
assertThat(post(1).getValue("title")).as("the later change was not overwritten").isEqualTo("Somebody else's title");
Map<String, Object> deleted = api(ADMIN).confirmAction(delete);
assertThat(deleted).containsEntry("status", AssistantPendingAction.STATUS_FAILED);
assertThat(post(2)).as("the record is still there").isNotNull();
assertThat(toolCalls(ADMIN)).filteredOn(row -> UiApiToolbox.CHECK_STALE.equals(row.getPermissionCheck())).as("each refusal has its row").hasSize(2);
Map<String, Object> written = api(ADMIN).confirmAction(clean);
assertThat(written).containsEntry("status", AssistantPendingAction.STATUS_EXECUTED);
assertThat(post(3).getValue("title")).isEqualTo("Unchanged in between");
}
/*******************************************************************************
** An update or a delete is proposed for the record as the person can read
** it: for a record they cannot read (or that does not exist) the answer is
** the UI API's own, and no card is made.
*******************************************************************************/
@Test
void aRecordThePersonCannotReadGetsNoCard() throws Exception
{
writable("blog_post");
ToolCallOutcome gone = propose(ADMIN, UiApiToolbox.UPDATE_RECORD, input("table", "blog_post", "id", "987654", "values", Map.of("title", "x")));
assertThat(gone.result().getOutcome()).isEqualTo(AiToolOutcome.NOT_FOUND);
assertThat(gone.extras().getPendingActionId()).isNull();
///////////////////////////////////////////////////////////////////////
// the author may change posts, pages and media, and may not read //
// the daily stats: the UI API's own refusal, and no card //
///////////////////////////////////////////////////////////////////////
ToolCallOutcome denied = propose(AssistantTestFixture.AUTHOR, UiApiToolbox.DELETE_RECORD, input("table", "analytics_daily_stat", "id", "1"));
assertThat(denied.result().getOutcome()).isIn(AiToolOutcome.DENIED, AiToolOutcome.NOT_FOUND);
assertThat(denied.result().getPermissionCheck()).startsWith("uiApi:");
assertThat(denied.extras().getPendingActionId()).isNull();
///////////////////////////////////////////////////////////////////////
// the analyst may change nothing: no write tool is offered to them, //
// and the call is refused before the record is asked for //
///////////////////////////////////////////////////////////////////////
ToolCallOutcome notOffered = propose(ANALYST, UiApiToolbox.DELETE_RECORD, input("table", "blog_post", "id", "1"));
assertThat(notOffered.result().getOutcome()).isEqualTo(AiToolOutcome.INVALID_INPUT);
assertThat(notOffered.result().getSummary()).isEqualTo("Unknown tool");
assertThat(notOffered.extras()).isNull();
assertThat(actions(ADMIN)).as("no pending action of anybody's").isEmpty();
}
/*******************************************************************************
** The list is the person's own: newest first, of one status when asked, of
** one conversation when asked; an action past its time is reported as
** expired, not as waiting. Nobody sees another person's.
*******************************************************************************/
@Test
@SuppressWarnings("unchecked")
void aPersonListsTheirOwnPendingActions() throws Exception
{
writable("blog_post");
Integer conversation = ((Number) api(ADMIN).createConversation(Map.of("title", "With a proposal")).get("id")).intValue();
Integer first = propose(ADMIN, UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "First"))).extras().getPendingActionId();
Integer second = config.getRuntime().getToolCalls().executeAndLog(ToolCalls.call(config, ADMIN, UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "Second"))), conversation, false)
.extras().getPendingActionId();
Integer lapsed = propose(ADMIN, UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "Lapsed"))).extras().getPendingActionId();
asUser(ADMIN, () -> store().updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", lapsed).withValue("expiresAt", Instant.now().minusSeconds(5))));
api(ADMIN).rejectAction(first);
List<Map<String, Object>> waiting = (List<Map<String, Object>>) api(ADMIN).listActions("pending", null).get("actions");
assertThat(waiting).extracting(action -> action.get("id")).as("only what still waits for the person").containsExactly(second);
assertThat(waiting.get(0)).containsEntry("actionKind", ActionKinds.RECORD_CREATE).containsEntry("conversationId", conversation).containsKey("changes");
List<Map<String, Object>> all = (List<Map<String, Object>>) api(ADMIN).listActions(null, null).get("actions");
assertThat(all).extracting(action -> action.get("id")).as("newest first").containsExactly(lapsed, second, first);
assertThat(all).extracting(action -> action.get("status")).containsExactly("EXPIRED", "PENDING", "REJECTED");
assertThat((List<Map<String, Object>>) api(ADMIN).listActions(null, conversation).get("actions")).extracting(action -> action.get("id")).containsExactly(second);
assertThat((List<Map<String, Object>>) api(EDITOR).listActions(null, null).get("actions")).as("another person sees none of them").isEmpty();
assertThatThrownBy(() -> api(EDITOR).getAction(second)).isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(404));
assertThatThrownBy(() -> api(ADMIN).listActions("NO_SUCH_STATUS", null)).isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(400));
assertThatThrownBy(() -> api(ADMIN).rejectAction(first)).as("one decision only").isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(409));
}
/*******************************************************************************
** approvalRetentionDays is real: decided and lapsed pending actions older
** than it are removed with their claims, with or without a conversation.
** Younger ones stay, and one whose end was never recorded stays.
*******************************************************************************/
@Test
void oldPendingActionsAreRemovedWithOrWithoutAConversation() throws Exception
{
config.setApprovalRetentionDays(30);
writable("blog_post");
Integer conversation = ((Number) api(ADMIN).createConversation(Map.of("title", "Kept")).get("id")).intValue();
Integer cancelled = propose(ADMIN, UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "Cancelled"))).extras().getPendingActionId();
Integer lapsed = propose(ADMIN, UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "Never decided"))).extras().getPendingActionId();
Integer inAThread = config.getRuntime().getToolCalls().executeAndLog(ToolCalls.call(config, ADMIN, UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "In a conversation"))), conversation, false)
.extras().getPendingActionId();
Integer stuck = propose(ADMIN, UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "Stuck"))).extras().getPendingActionId();
api(ADMIN).rejectAction(cancelled);
api(ADMIN).confirmAction(inAThread);
asUser(ADMIN, () -> store().updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", stuck).withValue("status", AssistantPendingAction.STATUS_RUNNING)));
assertThat(claims()).hasSize(2);
AssistantRetentionStep.Result young = asUser(ADMIN, () -> AssistantRetentionStep.purge(config, store(), Instant.now().plus(Duration.ofDays(29))));
assertThat(young.pendingActionsDeleted()).as("nothing is old enough").isZero();
assertThat(actions(ADMIN)).hasSize(4);
AssistantRetentionStep.Result old = asUser(ADMIN, () -> AssistantRetentionStep.purge(config, store(), Instant.now().plus(Duration.ofDays(31))));
assertThat(old.pendingActionsDeleted()).as("the cancelled one, the lapsed one and the one in a conversation").isEqualTo(3);
assertThat(old.conversationsDeleted()).as("the conversation itself is younger than its own retention").isZero();
assertThat(actions(ADMIN)).extracting(AssistantPendingAction::getId).as("an action left RUNNING is kept for the operator").containsExactly(stuck);
assertThat(claims()).as("the claims went with their actions").isEmpty();
assertThat(lapsed).isNotNull();
}
/*******************************************************************************
** One tool call of a person's, with its row and no conversation.
*******************************************************************************/
private ToolCallOutcome propose(String user, String toolName, Map<String, Object> input) throws Exception
{
return (config.getRuntime().getToolCalls().executeAndLog(ToolCalls.call(config, user, toolName, input), null, false));
}
/*******************************************************************************
** The assistant API of a fixture person, as a process would bind it.
*******************************************************************************/
private AssistantApi api(String user)
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config));
return (new AssistantApi(config.getRuntime(), QContext.getQSession()));
}
/*******************************************************************************
**
*******************************************************************************/
private AssistantStore store()
{
return (config.getRuntime().getStore());
}
/*******************************************************************************
** Something done with a person's session as the thread's context.
*******************************************************************************/
private <T> T asUser(String user, Work<T> work) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config));
return (work.run());
}
/*******************************************************************************
**
*******************************************************************************/
private void asUser(String user, Change change) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config));
change.run();
}
/*******************************************************************************
**
*******************************************************************************/
private interface Work<T>
{
/*******************************************************************************
**
*******************************************************************************/
T run() throws Exception;
}
/*******************************************************************************
**
*******************************************************************************/
private interface Change
{
/*******************************************************************************
**
*******************************************************************************/
void run() throws Exception;
}
/*******************************************************************************
**
*******************************************************************************/
private void writable(String tableName) throws Exception
{
asUser(ADMIN, () ->
{
store().insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName(tableName).withMode(DataAccessMode.READ_WRITE.name()));
config.getRuntime().getDataAccessService().invalidate();
});
}
/*******************************************************************************
**
*******************************************************************************/
private AssistantPendingAction action(String user, Integer id) throws Exception
{
return (asUser(user, () -> store().getPendingAction(user, id)));
}
/*******************************************************************************
** Every pending action the person may see, oldest first.
*******************************************************************************/
private List<AssistantPendingAction> actions(String user) throws Exception
{
return (asUser(user, () -> store().query(AssistantPendingAction.TABLE_NAME, new QQueryFilter(), AssistantPendingAction.class)));
}
/*******************************************************************************
**
*******************************************************************************/
private List<AssistantPendingActionClaim> claims() throws Exception
{
return (asUser(ADMIN, () -> store().query(AssistantPendingActionClaim.TABLE_NAME, new QQueryFilter(), AssistantPendingActionClaim.class)));
}
/*******************************************************************************
**
*******************************************************************************/
private List<AssistantToolCall> toolCalls(String user) throws Exception
{
return (asUser(user, () -> store().query(AssistantToolCall.TABLE_NAME, new QQueryFilter(), AssistantToolCall.class)));
}
/*******************************************************************************
** A blog post as the application stores it now.
*******************************************************************************/
private QRecord post(Integer id) throws Exception
{
return (asUser(ADMIN, () -> new GetAction().execute(new GetInput("blog_post").withPrimaryKey(id)).getRecord()));
}
/*******************************************************************************
** A change somebody makes in the application, not through the assistant.
*******************************************************************************/
private void changeInTheApplication(Integer id, String field, String value) throws Exception
{
asUser(ADMIN, () ->
{
new UpdateAction().execute(new UpdateInput("blog_post").withRecord(new QRecord().withValue("id", id).withValue(field, value)));
});
}
}
@@ -0,0 +1,217 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.SignedInPerson;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qbits.assistant.tools.Toolboxes;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ADMIN;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.NO_ACCESS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The reads the assistant makes for itself (list_tables, describe_table,
** count_records and get_record: for the starter prompts and the knowledge
** index) run with a context, and a context is only ever made by the
** tool-call service, for a person it checked. There is no way to make one
** from a session id, a gateway and a policy of the caller's choosing.
*******************************************************************************/
class AssistantOwnReadsTest extends BaseAssistantTest
{
/*******************************************************************************
** The compiler's side of it: the only public static way to a context takes
** the service's permit, and no public method of a context takes a session
** id, a gateway, a policy or credentials.
*******************************************************************************/
@Test
void thereIsNoPublicWayToMakeAContextFromASessionIdAGatewayAndAPolicy()
{
assertThat(UiApiToolbox.Context.class.getConstructors()).as("no public constructor").isEmpty();
List<Method> publicStatics = Arrays.stream(UiApiToolbox.Context.class.getDeclaredMethods()).filter(method -> Modifier.isPublic(method.getModifiers()) && Modifier.isStatic(method.getModifiers())).toList();
assertThat(publicStatics).as("the one public factory").singleElement().satisfies(factory ->
{
assertThat(factory.getName()).isEqualTo("of");
assertThat(factory.getParameterTypes()[0]).as("it takes the service's permit, which only the service has").isEqualTo(ToolCallService.Permit.class);
});
assertThatThrownBy(() -> UiApiToolbox.Context.of(null, EDITOR, null, null, PolicyService.ToolPolicy.defaults(), null, null, null)).isInstanceOf(NullPointerException.class)
.hasMessageContaining("Only the tool-call service");
assertThat(Arrays.stream(UiApiToolbox.Context.class.getDeclaredMethods()).filter(method -> Modifier.isPublic(method.getModifiers()) && !Modifier.isStatic(method.getModifiers())))
.as("what a context can be asked to derive takes nothing that says who, through what, or under which policy").allSatisfy(method ->
assertThat(method.getParameterTypes()).as(method.getName()).doesNotContain(String.class, UiApiGateway.class, PolicyService.ToolPolicy.class,
UiApiCredentials.class, QSession.class));
}
/*******************************************************************************
** The service makes the context only for a person who is signed in and
** holds assistant access, and only while the kill switch is off; it reads
** the policy itself.
*******************************************************************************/
@Test
void theServiceChecksThePersonAndTheKillSwitchBeforeItMakesAContext() throws Exception
{
ToolCallService service = config.getRuntime().getToolCalls();
assertThatThrownBy(() -> service.readContext(null, null)).isInstanceOf(QException.class).hasMessageContaining("no signed-in person");
assertThatThrownBy(() -> service.readContext(person(NO_ACCESS), null)).isInstanceOf(QException.class).hasMessage("You do not have access to the assistant.");
UiApiToolbox.Context context = service.readContext(person(EDITOR), Duration.ofSeconds(5));
assertThat(context.sessionUUID()).isEqualTo(EDITOR);
assertThat(context.pendingActions()).as("nowhere to keep a proposed change").isNull();
assertThat(context.policy()).isEqualTo(PolicyService.ToolPolicy.defaults());
///////////////////////////////////////////////////////////////////////
// the reads run in the person's request, as the starter prompts do //
///////////////////////////////////////////////////////////////////////
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
UiApiToolbox.Outcome listed = Toolboxes.data(config.getRuntime()).read(context, "own-read", UiApiToolbox.LIST_TABLES, Map.of());
assertThat(listed.getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(listed.getResult().getModelContent()).contains("\"blog_post\"").doesNotContain("analytics_daily_stat");
for(String notARead : List.of(UiApiToolbox.CREATE_RECORD, UiApiToolbox.UPDATE_RECORD, UiApiToolbox.DELETE_RECORD, UiApiToolbox.QUERY_RECORDS, UiApiToolbox.SEARCH_RECORDS))
{
UiApiToolbox.Outcome refused = Toolboxes.data(config.getRuntime()).read(context, "own-read", notARead, Map.of("table", "blog_post", "id", "1", "values", Map.of("title", "x")));
assertThat(refused.getResult().getOutcome()).as(notARead).isEqualTo(AiToolOutcome.INVALID_INPUT);
assertThat(refused.getResult().getModelContent()).startsWith("Not one of the assistant's own reads");
}
assertThat(asAdmin(() -> config.getRuntime().getStore().query(AssistantToolCall.TABLE_NAME, new QQueryFilter(), AssistantToolCall.class))).as("these reads have no tool-call log row").isEmpty();
///////////////////////////////////////////////////////////////////////
// the policy is the administrator's row as it is now, not a caller's //
///////////////////////////////////////////////////////////////////////
asAdmin(() -> config.getRuntime().getStore().insert(new AssistantPolicy().withName("default").withAllowedTools(UiApiToolbox.COUNT_RECORDS).withMaxRowsPerCall(7)));
UiApiToolbox.Context narrowed = service.readContext(person(EDITOR), null);
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
assertThat(narrowed.policy().allows(UiApiToolbox.LIST_TABLES)).isFalse();
assertThat(Toolboxes.data(config.getRuntime()).read(narrowed, "own-read", UiApiToolbox.LIST_TABLES, Map.of()).getResult().getOutcome()).as("switched off for everybody").isEqualTo(AiToolOutcome.DENIED);
UiApiToolbox.Context partOfACall = narrowed.forReadWhateverTheToolSwitches();
assertThat(partOfACall.policy().allows(UiApiToolbox.GET_RECORD)).isTrue();
assertThat(partOfACall.policy().maxRowsPerCall()).as("the row cap stays").isEqualTo(7);
assertThat(partOfACall.policy().redactPii()).isTrue();
assertThat(partOfACall.pendingActions()).isNull();
assertThat(partOfACall.sessionUUID()).isEqualTo(EDITOR);
asAdmin(() ->
{
config.getRuntime().getStore().delete(AssistantPolicy.TABLE_NAME, new QQueryFilter());
return (config.getRuntime().getStore().insert(new AssistantPolicy().withName("default").withKillSwitch(true)));
});
assertThatThrownBy(() -> service.readContext(person(EDITOR), null)).isInstanceOf(QException.class).hasMessage("The assistant has been turned off by an administrator.");
}
/*******************************************************************************
** The starter prompts of GET /config are made with such a context: a
** person gets prompts for the tables they may read, and with the kill
** switch on nobody is read for.
*******************************************************************************/
@Test
@SuppressWarnings("unchecked")
void theStarterPromptsAreReadThroughTheService() throws Exception
{
UiApiTestServer.SCHEDULED_REQUESTS.clear();
Map<String, Object> profile = (Map<String, Object>) api(EDITOR).config().get("profile");
assertThat((List<String>) profile.get("starterPrompts")).isNotEmpty();
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).as("read as the person").isNotEmpty().allSatisfy(request -> assertThat(request.get("session")).isEqualTo(EDITOR));
asAdmin(() -> config.getRuntime().getStore().insert(new AssistantPolicy().withName("default").withKillSwitch(true)));
config.getRuntime().getPolicyService().invalidate();
UiApiTestServer.SCHEDULED_REQUESTS.clear();
Map<String, Object> off = (Map<String, Object>) api(EDITOR).config().get("profile");
assertThat((List<String>) off.get("starterPrompts")).isEmpty();
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).as("nothing is read with the assistant switched off").isEmpty();
}
/*******************************************************************************
**
*******************************************************************************/
private AssistantApi api(String person)
{
QContext.init(qInstance, AssistantTestFixture.sessionFor(person));
return (new AssistantApi(config.getRuntime(), QContext.getQSession()));
}
/*******************************************************************************
**
*******************************************************************************/
private SignedInPerson person(String user)
{
return (SignedInPerson.ofSession(qInstance, AssistantTestFixture.ownerSession(user, config)));
}
/*******************************************************************************
**
*******************************************************************************/
private <T> T asAdmin(Work<T> work) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(ADMIN, config));
try
{
return (work.run());
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
**
*******************************************************************************/
private interface Work<T>
{
/*******************************************************************************
**
*******************************************************************************/
T run() throws Exception;
}
}
@@ -0,0 +1,334 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.SignedInPerson;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPendingActionClaim;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.registry.AssistantTool;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallRequest;
import com.kingsrook.qbits.assistant.tools.registry.ToolCheck;
import com.kingsrook.qbits.assistant.tools.registry.ToolFamily;
import com.kingsrook.qbits.assistant.tools.registry.ToolKind;
import com.kingsrook.qbits.assistant.tools.registry.ToolOffer;
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.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** A call the turn engine cut short leaves no proposal anybody could confirm:
** the model is told it took too long and the person is shown no card, so a
** pending action the call stored while it was being stopped is withdrawn,
** and one it would store later is never stored.
*******************************************************************************/
class CutShortProposalTest extends BaseAssistantTest
{
private static final String PROPOSES_THEN_LINGERS = "proposes_then_lingers";
private static final String PROPOSES_TOO_LATE = "proposes_too_late";
private static final AtomicReference<String> LATE_ANSWER = new AtomicReference<>();
private static volatile CountDownLatch lateDone = new CountDownLatch(1);
/*******************************************************************************
** A one-second tool limit and the family of this test.
*******************************************************************************/
@Override
protected void customizeConfig(AssistantQBitConfig config)
{
config.withToolCallTimeoutSeconds(1);
config.withToolFamily(new SlowFamily());
}
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void forget()
{
LATE_ANSWER.set(null);
lateDone = new CountDownLatch(1);
}
/*******************************************************************************
** The call stores its proposal and is still at work when its time is up:
** it finishes inside the moment the engine gives a stopped call. The
** proposal is there, but nobody saw a card for it, so it is withdrawn: it
** is not pending, it is claimed, and a confirmation is refused. (Before,
** it stayed PENDING and could be confirmed.)
*******************************************************************************/
@Test
void aProposalOfACallThatRanOutOfTimeIsWithdrawn() throws Exception
{
provider.thenToolUse(PROPOSES_THEN_LINGERS, Map.of()).thenText("That took too long.");
TurnRunner.Outcome outcome = runner.run(EDITOR, "propose it slowly");
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
assertThat(outcome.events(TurnEvent.TOOL_RESULT)).singleElement().satisfies(result -> assertThat(result).containsEntry("outcome", "ERROR").containsEntry("summary", "Timed out"));
List<AssistantPendingAction> actions = actions();
assertThat(actions).as("the call did store its proposal").hasSize(1);
AssistantPendingAction action = actions.get(0);
assertThat(action.getStatus()).as("withdrawn, not waiting for anybody").isEqualTo(AssistantPendingAction.STATUS_EXPIRED);
assertThat(claims()).as("and claimed, so no request can decide it").singleElement().satisfies(claim -> assertThat(claim.getPendingActionId()).isEqualTo(action.getId()));
assertThat(outcome.toolCalls()).singleElement().satisfies(row -> assertThat(row.getPendingActionId()).as("the row still says what the call left behind").isEqualTo(action.getId()));
SignedInPerson editor = SignedInPerson.ofSession(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
assertThatThrownBy(() -> runtime().getToolCalls().confirm(editor, action.getId()))
.isInstanceOfSatisfying(ToolCallService.ConfirmationRefused.class, refused -> assertThat(refused.getReason()).isEqualTo(ToolCallService.ConfirmationRefused.Reason.NOT_PENDING));
}
/*******************************************************************************
** The call does not stop when it is cut short and proposes only after the
** engine has moved on: the service refuses to store the proposal, so there
** is no pending action at all.
*******************************************************************************/
@Test
void aCallThatWasCutShortCanNoLongerPropose() throws Exception
{
provider.thenToolUse(PROPOSES_TOO_LATE, Map.of()).thenText("That took too long.");
TurnRunner.Outcome outcome = runner.run(EDITOR, "propose it far too late");
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
assertThat(outcome.events(TurnEvent.TOOL_RESULT)).singleElement().satisfies(result -> assertThat(result).containsEntry("outcome", "ERROR").containsEntry("summary", "Timed out"));
assertThat(lateDone.await(30, TimeUnit.SECONDS)).as("the call did go on to propose").isTrue();
assertThat(LATE_ANSWER.get()).as("and was refused").startsWith("refused:");
assertThat(actions()).as("nothing was stored").isEmpty();
}
/*******************************************************************************
**
*******************************************************************************/
private AssistantRuntime runtime()
{
return (config.getRuntime());
}
/*******************************************************************************
** Every pending action of the editor's.
*******************************************************************************/
private List<AssistantPendingAction> actions() throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
try
{
return (runtime().getStore().query(AssistantPendingAction.TABLE_NAME, new QQueryFilter(), AssistantPendingAction.class));
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** Every claim of the editor's.
*******************************************************************************/
private List<AssistantPendingActionClaim> claims() throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
try
{
return (runtime().getStore().query(AssistantPendingActionClaim.TABLE_NAME, new QQueryFilter(), AssistantPendingActionClaim.class));
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** Wait without giving way to an interrupt, as a call that does not stop.
*******************************************************************************/
private static void linger(long millis)
{
long until = System.currentTimeMillis() + millis;
while(System.currentTimeMillis() < until)
{
try
{
Thread.sleep(Math.max(1, until - System.currentTimeMillis()));
}
catch(InterruptedException e)
{
/////////////////////////////////
// this call does not stop //
/////////////////////////////////
}
}
}
/*******************************************************************************
** Two tools that propose a change and take longer than the limit.
*******************************************************************************/
private static final class SlowFamily implements ToolFamily
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<AssistantTool> tools(AssistantRuntime runtime)
{
return (List.of(new SlowTool(PROPOSES_THEN_LINGERS), new SlowTool(PROPOSES_TOO_LATE)));
}
}
/*******************************************************************************
** One of them.
*******************************************************************************/
private static final class SlowTool implements AssistantTool
{
private final String name;
/*******************************************************************************
**
*******************************************************************************/
private SlowTool(String name)
{
this.name = name;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String name()
{
return (name);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ToolKind kind()
{
return (ToolKind.PROPOSE);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<ToolCheck> checks()
{
return (List.of(ToolCheck.TOOL_SWITCH, ToolCheck.CONFIRMATION));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ModelToolDefinition definition(ToolOffer offer)
{
return (offer.allows(name) ? new ModelToolDefinition().withName(name).withDescription("For tests.").withStrict(false).withInputSchema(Map.of("type", "object")) : null);
}
/*******************************************************************************
** One proposes and then lingers past the limit (0.6 s into the engine's
** moment for a stopped call); the other lingers past the limit and that
** moment (2.6 s) and only then proposes.
*******************************************************************************/
@Override
public ToolCallOutcome execute(ToolCallRequest request)
{
UiApiToolbox.ProposedWrite write = new UiApiToolbox.ProposedWrite(request.toolCallId(), name, "POST /nowhere", "blog_post", null, Map.of("title", "x"), "A change nobody was shown");
AiToolResult result = new AiToolResult().withToolCallId(request.toolCallId()).withToolName(name).withOutcome(AiToolOutcome.OK).withSummary("Proposed").withModelContent("Proposed.");
if(PROPOSES_THEN_LINGERS.equals(name))
{
try
{
Integer actionId = request.context().pendingActions().propose(write);
linger(1600);
return (ToolCallOutcome.of(UiApiToolbox.Outcome.of(result, "POST /nowhere", null, actionId)));
}
catch(QException e)
{
throw (new IllegalStateException(e));
}
}
linger(2600);
try
{
Integer actionId = request.context().pendingActions().propose(write);
LATE_ANSWER.set("stored:" + actionId);
return (ToolCallOutcome.of(UiApiToolbox.Outcome.of(result, "POST /nowhere", null, actionId)));
}
catch(QException e)
{
LATE_ANSWER.set("refused:" + e.getMessage());
return (ToolCallOutcome.of(result.withOutcome(AiToolOutcome.ERROR).withSummary("Not proposed").withModelContent("Not proposed.")));
}
finally
{
lateDone.countDown();
}
}
}
}
@@ -0,0 +1,424 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.api.AssistantApiException;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.ToolCalls;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ADMIN;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ANALYST;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.OTHER_EDITOR;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The assistant answers nobody for whom no spending limit is in force: a
** turn is refused before any model call, with a message that says an
** administrator must restore a limit, and a tool call is refused the same
** way. Restoring a limit restores service on the next request.
**
** "In force" is BudgetService.limitInForce: a hard limit above zero in the
** code configuration or on the policy row, or a budget row that is switched
** on, has a limit above zero and applies to the person. Each way a limit can
** be there without being in force is a case here.
*******************************************************************************/
class NoSpendingLimitTest extends BaseAssistantTest
{
private final List<AssistantBudget> unvalidated = new ArrayList<>();
/*******************************************************************************
** A runtime whose store can also hold budget rows the record screen would
** refuse (a zero or a negative limit: a database can still hold one), and
** no spending limit of the fixture's.
*******************************************************************************/
@BeforeEach
void noLimitYet()
{
config.getRuntime().close();
config.setRuntime(new AssistantRuntime(qInstance, config, Clock.systemUTC(), new AssistantStore(config)
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<AssistantBudget> budgets() throws QException
{
List<AssistantBudget> rs = new ArrayList<>(super.budgets());
rs.addAll(unvalidated);
return (rs);
}
}));
AssistantTestFixture.withoutSpendingLimit(config);
UiApiTestServer.SCHEDULED_REQUESTS.clear();
}
/*******************************************************************************
** No budget row and no hard limit in the code configuration: refused, with
** nothing started, stored or asked of a model or of the UI API.
*******************************************************************************/
@Test
void withNoLimitAtAllNobodyIsAnsweredAndNothingStarts() throws Exception
{
assertRefused(EDITOR);
assertRefused(ADMIN);
assertThat(provider.getRequests()).as("no model was called").isEmpty();
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).as("no UI API request was made").isEmpty();
assertThat(asAdmin(() -> store().query(AssistantToolCall.TABLE_NAME, new QQueryFilter(), AssistantToolCall.class))).as("the refused tool calls are logged").allSatisfy(row ->
{
assertThat(row.getOutcome()).isEqualTo("DENIED");
assertThat(row.getPermissionCheck()).isEqualTo(ToolCallService.CHECK_BUDGET);
assertThat(row.getHttpStatus()).isNull();
});
assertThat(BudgetService.NO_LIMIT_MESSAGE).contains("no spending limit is in force").contains("An administrator must");
}
/*******************************************************************************
** A budget row that is switched off limits nothing.
*******************************************************************************/
@Test
void aDisabledRowIsNotInForce() throws Exception
{
AssistantBudget budget = budget("Everyone, a month", "INSTANCE", null, new BigDecimal("50"));
assertAnswered(EDITOR);
asAdmin(() ->
{
store().updateFields(AssistantBudget.TABLE_NAME, new QRecord().withValue("id", budget.getId()).withValue("enabled", false));
return (null);
});
assertRefused(EDITOR);
}
/*******************************************************************************
** A row whose limit is zero, negative or missing limits nothing: it is not
** in force, and it does not break the evaluation of a budget that is.
*******************************************************************************/
@Test
void aZeroLimitIsNotInForce() throws Exception
{
unvalidated.add(new AssistantBudget().withId(9001).withName("Zero").withEnabled(true).withScopeType("INSTANCE").withPeriod("MONTH").withMetric("COST_USD")
.withLimitValue(BigDecimal.ZERO).withWarnPercent(80).withAtLimitAction("STOP").withHardCeilingPercent(120));
unvalidated.add(new AssistantBudget().withId(9002).withName("Negative").withEnabled(true).withScopeType("USER").withScopeValue(EDITOR).withPeriod("DAY").withMetric("TOKENS")
.withLimitValue(new BigDecimal("-5")).withWarnPercent(80).withAtLimitAction("STOP").withHardCeilingPercent(120));
unvalidated.add(new AssistantBudget().withId(9003).withName("Missing").withEnabled(true).withScopeType("INSTANCE").withPeriod("MONTH").withMetric("TOKENS")
.withWarnPercent(80).withAtLimitAction("STOP").withHardCeilingPercent(120));
for(AssistantBudget notALimit : unvalidated)
{
assertThat(BudgetService.isLimit(notALimit)).as(notALimit.getName()).isFalse();
}
assertRefused(EDITOR);
budget("Everyone, a month", "INSTANCE", null, new BigDecimal("50"));
assertAnswered(EDITOR);
assertThat(asUser(EDITOR, () -> config.getRuntime().getBudgetService().snapshot(unvalidated.get(0), EDITOR)).state()).isEqualTo("NO_LIMIT");
}
/*******************************************************************************
** A row for somebody else, or for a permission the person does not hold,
** is not a limit on this person; one for them, or for a permission they
** hold, is.
*******************************************************************************/
@Test
void aRowThatDoesNotApplyToThePersonIsNotInForceForThem() throws Exception
{
budget("The other editor's", "USER", OTHER_EDITOR, new BigDecimal("5"));
budget("Administrators", "ROLE", AssistantQBitConfig.ADMIN_PERMISSION, new BigDecimal("5"));
assertRefused(EDITOR);
assertAnswered(OTHER_EDITOR);
assertAnswered(ADMIN);
budget("Everybody who may use the assistant", "ROLE", AssistantQBitConfig.ACCESS_PERMISSION, new BigDecimal("5"));
assertAnswered(EDITOR);
assertAnswered(ANALYST);
}
/*******************************************************************************
** A soft limit only warns: alone, it is not a spending limit. Neither is a
** hard limit of zero. A hard limit above zero in the code configuration is
** one: the person's own daily limit, or the shared monthly one.
*******************************************************************************/
@Test
void onlyAHardLimitInTheCodeConfigurationCounts() throws Exception
{
config.getBudgets().setUserDailyTokensSoft(1_000L);
config.getBudgets().setInstanceMonthlyCostMicrosSoft(1_000_000L);
assertRefused(EDITOR);
config.getBudgets().setUserDailyTokensHard(0L);
config.getBudgets().setUserDailyTokensSoft(null);
assertRefused(EDITOR);
config.getBudgets().setUserDailyTokensHard(1_000_000L);
assertAnswered(EDITOR);
config.getBudgets().setUserDailyTokensHard(null);
assertRefused(EDITOR);
config.getBudgets().setInstanceMonthlyCostMicrosHard(50_000_000L);
assertAnswered(EDITOR);
}
/*******************************************************************************
** A budget row has no end date: it is in force in every period. An
** override is an administrator's dated exception to a budget that stays in
** force: one that has run out changes nothing, and one that is running
** (here: no limit until tomorrow) does not take the budget out of force.
*******************************************************************************/
@Test
void anOverrideDoesNotTakeABudgetOutOfForce() throws Exception
{
AssistantBudget budget = budget("Everyone, a month", "INSTANCE", null, new BigDecimal("50"));
Instant now = Instant.now();
asAdmin(() -> store().insert(new AssistantBudgetOverride().withBudgetId(budget.getId()).withReason("Quarter end").withUnlimitedUntil(now.plusSeconds(1800)).withExpiresAt(now.plusSeconds(3600))));
assertThat(asUser(EDITOR, () -> config.getRuntime().getBudgetService().snapshot(budget, EDITOR)).state()).isEqualTo("UNLIMITED");
assertAnswered(EDITOR);
asAdmin(() ->
{
store().delete(AssistantBudgetOverride.TABLE_NAME, new QQueryFilter());
return (null);
});
unvalidatedOverride(budget, now.minusSeconds(7200), now.minusSeconds(3600));
assertThat(asUser(EDITOR, () -> config.getRuntime().getBudgetService().snapshot(budget, EDITOR)).state()).as("the override has run out: the budget's own limit").isEqualTo("OK");
assertAnswered(EDITOR);
}
/*******************************************************************************
** An administrator who switches the limit back on restores service on the
** next request, and one who switches it off stops it on the next request,
** with no restart: a turn, and a tool call of a caller with no chat.
*******************************************************************************/
@Test
void switchingALimitBackOnRestoresServiceOnTheNextRequest() throws Exception
{
assertRefused(EDITOR);
AssistantBudget budget = budget("Everyone, a month", "INSTANCE", null, new BigDecimal("50"));
assertAnswered(EDITOR);
for(boolean enabled : List.of(false, true, false))
{
asAdmin(() ->
{
store().updateFields(AssistantBudget.TABLE_NAME, new QRecord().withValue("id", budget.getId()).withValue("enabled", enabled));
return (null);
});
if(enabled)
{
assertAnswered(EDITOR);
}
else
{
assertRefused(EDITOR);
}
}
}
/*******************************************************************************
** A limit switched off while an answer is being made stops the answer at
** its next step: no further model call is made.
*******************************************************************************/
@Test
void aLimitSwitchedOffDuringAnAnswerStopsItBeforeTheNextModelCall() throws Exception
{
config.getBudgets().setInstanceMonthlyCostMicrosHard(50_000_000L);
provider.thenRespond(request ->
{
///////////////////////////////////////////////////////////////////
// while the model answers its first call, the limit is removed //
///////////////////////////////////////////////////////////////////
config.getBudgets().setInstanceMonthlyCostMicrosHard(null);
return (new ModelResponse().withParts(new ArrayList<>(List.of(ModelPart.toolUse("toolu_0_0", UiApiToolbox.LIST_TABLES, Map.of())))).withStopReason(ModelResponse.STOP_TOOL_USE));
}).thenText("never reached");
TurnRunner.Outcome outcome = runner.run(EDITOR, "Which tables are there?");
assertThat(outcome.errorCode()).isEqualTo("BUDGET");
assertThat(String.valueOf(outcome.events(TurnEvent.ERROR).get(0).get("message"))).isEqualTo(BudgetService.NO_LIMIT_MESSAGE);
assertThat(provider.getRequests()).as("the model was called once, before the limit went").hasSize(1);
assertThat(outcome.toolCalls()).as("the step's tool call was refused, and logged").singleElement().satisfies(row -> assertThat(row.getPermissionCheck()).isEqualTo(ToolCallService.CHECK_BUDGET));
}
/*******************************************************************************
** A turn is refused with 429 BUDGET and the message, before anything is
** stored; a tool call with no chat around it is refused by the service.
*******************************************************************************/
private void assertRefused(String person) throws Exception
{
int requests = provider.getRequests().size();
int conversations = conversations(person);
assertThatThrownBy(() -> runner.run(person, "How many pages are there?")).as(person).isInstanceOfSatisfying(AssistantApiException.class, e ->
{
assertThat(e.getStatus()).isEqualTo(429);
assertThat(e.getCode()).isEqualTo("BUDGET");
assertThat(e.getMessage()).isEqualTo(BudgetService.NO_LIMIT_MESSAGE);
});
assertThat(provider.getRequests()).as("no model call").hasSize(requests);
assertThat(conversations(person)).as("the runner's conversation aside, nothing was stored for the turn").isLessThanOrEqualTo(conversations + 1);
ToolCallOutcome call = config.getRuntime().getToolCalls().executeAndLog(ToolCalls.call(config, person, UiApiToolbox.LIST_TABLES, Map.of()), null, false);
assertThat(call.result().getOutcome()).as(person).isEqualTo(AiToolOutcome.DENIED);
assertThat(call.result().getPermissionCheck()).isEqualTo(ToolCallService.CHECK_BUDGET);
assertThat(call.result().getModelContent()).isEqualTo(BudgetService.NO_LIMIT_MESSAGE);
}
/*******************************************************************************
** A turn is answered, and a tool call runs.
*******************************************************************************/
private void assertAnswered(String person) throws Exception
{
provider.thenText("There are twelve.");
TurnRunner.Outcome outcome = runner.run(person, "How many pages are there?");
assertThat(outcome.errorCode()).as(person).isNull();
assertThat(outcome.text()).contains("twelve");
assertThat(config.getRuntime().getToolCalls().executeAndLog(ToolCalls.call(config, person, UiApiToolbox.LIST_TABLES, Map.of()), null, false).result().getOutcome()).as(person).isEqualTo(AiToolOutcome.OK);
}
/*******************************************************************************
** A budget row an administrator adds on the record screen.
*******************************************************************************/
private AssistantBudget budget(String name, String scopeType, String scopeValue, BigDecimal limit) throws Exception
{
return (asAdmin(() -> store().insert(new AssistantBudget().withName(name).withScopeType(scopeType).withScopeValue(scopeValue).withPeriod("MONTH").withMetric("COST_USD").withLimitValue(limit)
.withAtLimitAction("STOP"))));
}
/*******************************************************************************
** An override that has already run out (the record screen accepts one whose
** expiry is in the past only by accident of time; it is written here as it
** is some hours after it was granted).
*******************************************************************************/
private void unvalidatedOverride(AssistantBudget budget, Instant unlimitedUntil, Instant expiresAt) throws Exception
{
asAdmin(() -> store().insert(new AssistantBudgetOverride().withBudgetId(budget.getId()).withReason("Last quarter end").withUnlimitedUntil(unlimitedUntil).withExpiresAt(expiresAt)));
}
/*******************************************************************************
**
*******************************************************************************/
private int conversations(String person) throws Exception
{
return (asUser(person, () -> store().query(AssistantConversation.TABLE_NAME, new QQueryFilter(), AssistantConversation.class)).size());
}
/*******************************************************************************
**
*******************************************************************************/
private AssistantStore store()
{
return (config.getRuntime().getStore());
}
/*******************************************************************************
**
*******************************************************************************/
private <T> T asAdmin(Work<T> work) throws Exception
{
return (asUser(ADMIN, work));
}
/*******************************************************************************
** Something done with a person's session as the thread's context.
*******************************************************************************/
private <T> T asUser(String person, Work<T> work) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(person, config));
try
{
return (work.run());
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
**
*******************************************************************************/
private interface Work<T>
{
/*******************************************************************************
**
*******************************************************************************/
T run() throws Exception;
}
}
@@ -0,0 +1,623 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.SignedInPerson;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantBudgetUsage;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPendingActionClaim;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantUsage;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.ToolCalls;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
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.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ADMIN;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The guarantees of the tool-call service that a third review asked for,
** each called on the service directly:
** - a pending action is decided once, by exactly one request, however many
** arrive together;
** - whatever fails after the claim ends in FAILED (or EXECUTED) with a row,
** never in an action left RUNNING;
** - the policy a call runs under is the administrator's row as it is now;
** - the hard budget is read on every call without reading the usage rows;
** - a log row's input is redacted with the administrator's patterns too.
*******************************************************************************/
class PendingActionDecisionTest extends BaseAssistantTest
{
private static final String EMPLOYEE_NUMBER = "EMP-481516";
private final GatedClock clock = new GatedClock();
private FaultyStore store;
/*******************************************************************************
** An administrator's own redaction pattern, a long policy cache (so a test
** would see a stale policy if the service used it), and a runtime whose
** clock and store the tests can hold up and break.
*******************************************************************************/
@Override
protected void customizeConfig(AssistantQBitConfig config)
{
config.withCustomRedactionPatterns(List.of("EMP-\\d{6}"));
config.withPolicyCacheSeconds(3600);
}
/*******************************************************************************
** Replace the produced runtime with one on the test's clock and store.
*******************************************************************************/
private void useTestRuntime()
{
config.getRuntime().close();
store = new FaultyStore(config);
config.setRuntime(new AssistantRuntime(qInstance, config, clock, store));
}
/*******************************************************************************
** Two confirmations of one action that arrive together: both have read the
** action as PENDING before either claims it (the gate holds them there).
** Exactly one runs the change; the other is told the action is already
** handled. One record, one confirmation row. With a claim that is an
** unconditional update followed by a re-read, both ran and two records were
** created.
*******************************************************************************/
@Test
void twoConfirmationsAtOnceRunTheChangeOnce() throws Exception
{
useTestRuntime();
Integer actionId = proposeAPost("Confirmed twice at once");
clock.gate = new CyclicBarrier(2);
ExecutorService pool = Executors.newFixedThreadPool(2, runnable -> new Thread(runnable, GatedClock.GATED_THREAD));
List<Future<Object>> answers = new ArrayList<>();
try
{
for(int i = 0; i < 2; i++)
{
answers.add(pool.submit(() ->
{
try
{
return (service().confirm(person(ADMIN), actionId));
}
catch(ToolCallService.ConfirmationRefused refused)
{
return (refused);
}
finally
{
QContext.clear();
}
}));
}
pool.shutdown();
assertThat(pool.awaitTermination(60, TimeUnit.SECONDS)).isTrue();
}
finally
{
clock.gate = null;
pool.shutdownNow();
}
List<Object> results = List.of(answers.get(0).get(), answers.get(1).get());
assertThat(clock.passed.get()).as("both requests had read the action before either claimed it").isEqualTo(2);
assertThat(results).filteredOn(ToolCallService.Confirmation.class::isInstance).as("one request ran the change").hasSize(1);
assertThat(results).filteredOn(ToolCallService.ConfirmationRefused.class::isInstance).as("the other was refused").singleElement()
.satisfies(refused -> assertThat(((ToolCallService.ConfirmationRefused) refused).getReason()).isEqualTo(ToolCallService.ConfirmationRefused.Reason.ALREADY_HANDLED));
assertThat(postsTitled("Confirmed twice at once")).as("the record was created once").hasSize(1);
assertThat(own(AssistantPendingActionClaim.TABLE_NAME, AssistantPendingActionClaim.class)).as("one claim").hasSize(1);
assertThat(pendingAction(actionId).getStatus()).isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
assertThat(own(AssistantToolCall.TABLE_NAME, AssistantToolCall.class)).filteredOn(row -> actionId.equals(row.getPendingActionId()) && String.valueOf(row.getResultSummary()).startsWith("Confirmed"))
.as("one row of a confirmed change").hasSize(1);
}
/*******************************************************************************
** A confirmation and a cancellation are the same single decision: once one
** of them has it, the other is refused, in either order.
*******************************************************************************/
@Test
void anActionIsConfirmedOrCancelledNeverBoth() throws Exception
{
Integer cancelled = proposeAPost("Cancelled first");
assertThat(service().cancel(person(ADMIN), cancelled).getStatus()).isEqualTo(AssistantPendingAction.STATUS_REJECTED);
assertRefused(() -> service().confirm(person(ADMIN), cancelled), ToolCallService.ConfirmationRefused.Reason.NOT_PENDING);
assertThat(postsTitled("Cancelled first")).isEmpty();
Integer confirmed = proposeAPost("Confirmed first");
assertThat(service().confirm(person(ADMIN), confirmed).action().getStatus()).isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
assertRefused(() -> service().cancel(person(ADMIN), confirmed), ToolCallService.ConfirmationRefused.Reason.NOT_PENDING);
assertRefused(() -> service().cancel(person(EDITOR), confirmed), ToolCallService.ConfirmationRefused.Reason.NOT_FOUND);
assertThat(postsTitled("Confirmed first")).hasSize(1);
}
/*******************************************************************************
** The action cannot be read back after its change ran: the person still
** gets the outcome of their change, the action is EXECUTED and has its row.
*******************************************************************************/
@Test
void aConfirmationThatCannotBeReadBackStillAnswersAndHasItsRow() throws Exception
{
useTestRuntime();
Integer actionId = proposeAPost("Read back fails");
store.failReadingDecided = true;
ToolCallService.Confirmation confirmation;
try
{
confirmation = service().confirm(person(ADMIN), actionId);
}
finally
{
store.failReadingDecided = false;
}
assertThat(confirmation.outcome().getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(confirmation.action().getStatus()).as("answered from what was written").isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
assertThat(pendingAction(actionId).getStatus()).isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
assertThat(postsTitled("Read back fails")).hasSize(1);
assertThat(own(AssistantToolCall.TABLE_NAME, AssistantToolCall.class)).filteredOn(row -> actionId.equals(row.getPendingActionId())).as("the proposal's row and the confirmation's").hasSize(2);
}
/*******************************************************************************
** The action is claimed and then cannot be marked RUNNING: its change is
** not run, it ends FAILED with a row, and it can never be decided again.
*******************************************************************************/
@Test
void aClaimThatCannotBeMarkedEndsFailedWithARowAndIsNeverRun() throws Exception
{
useTestRuntime();
Integer actionId = proposeAPost("Never started");
store.failMarkingRunning = true;
ToolCallService.Confirmation confirmation;
try
{
confirmation = service().confirm(person(ADMIN), actionId);
}
finally
{
store.failMarkingRunning = false;
}
assertThat(confirmation.outcome().getResult().getOutcome()).isEqualTo(AiToolOutcome.ERROR);
assertThat(confirmation.action().getStatus()).isEqualTo(AssistantPendingAction.STATUS_FAILED);
assertThat(pendingAction(actionId).getStatus()).as("never left RUNNING or PENDING").isEqualTo(AssistantPendingAction.STATUS_FAILED);
assertThat(postsTitled("Never started")).as("nothing was written").isEmpty();
assertThat(own(AssistantToolCall.TABLE_NAME, AssistantToolCall.class)).filteredOn(row -> actionId.equals(row.getPendingActionId()) && "ERROR".equals(row.getOutcome())).as("the failed confirmation's row").hasSize(1);
assertRefused(() -> service().confirm(person(ADMIN), actionId), ToolCallService.ConfirmationRefused.Reason.NOT_PENDING);
}
/*******************************************************************************
** A tool the administrator switches off is refused on the very next call,
** although the policy cache (an hour here) still holds the row that allowed
** it. Without a policy row every registered tool is allowed: the defaults
** are code, and a row only narrows them.
*******************************************************************************/
@Test
void aToolSwitchedOffIsRefusedOnTheNextCall() throws Exception
{
assertThat(execute(EDITOR, UiApiToolbox.LIST_TABLES, Map.of()).result().getOutcome()).as("no policy row: the defaults").isEqualTo(AiToolOutcome.OK);
assertThat(config.getRuntime().getPolicyService().current(false).tools().allows(UiApiToolbox.LIST_TABLES)).isTrue();
asAdmin(() -> config.getRuntime().getStore().insert(new AssistantPolicy().withName("default").withAllowedTools(UiApiToolbox.COUNT_RECORDS)));
ToolCallOutcome refused = execute(EDITOR, UiApiToolbox.LIST_TABLES, Map.of());
assertThat(refused.result().getOutcome()).as("switched off, with the cache not yet expired").isEqualTo(AiToolOutcome.INVALID_INPUT);
assertThat(refused.result().getSummary()).isEqualTo("Unknown tool");
assertThat(service().offers(person(EDITOR), UiApiToolbox.LIST_TABLES)).isFalse();
assertThat(service().offers(person(EDITOR), UiApiToolbox.COUNT_RECORDS)).isTrue();
}
/*******************************************************************************
** The hard budget is checked on every call and stops a person who is over
** it, and the check never reads a usage row: what was used is one
** aggregate query per budget, however many rows there are.
*******************************************************************************/
@Test
void theHardBudgetIsReadOnEveryCallWithoutReadingUsageRows() throws Exception
{
useTestRuntime();
asAdmin(() ->
{
store.insert(new AssistantBudget().withName("Everyone, a month").withScopeType("INSTANCE").withPeriod("MONTH").withMetric("TOKENS").withLimitValue(BigDecimal.valueOf(1000)).withAtLimitAction("STOP"));
for(int i = 0; i < 25; i++)
{
store.insert(new AssistantUsage().withUserId(ADMIN).withPurpose("TURN").withStatus("OK").withCostMicros(10L).withInputTokens(10L).withOutputTokens(10L));
}
return (null);
});
store.usageRowReads.set(0);
store.usageSums.set(0);
assertThat(execute(EDITOR, UiApiToolbox.LIST_TABLES, Map.of()).result().getOutcome()).as("500 of 1000 tokens used").isEqualTo(AiToolOutcome.OK);
assertThat(execute(EDITOR, UiApiToolbox.LIST_TABLES, Map.of()).result().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(store.usageSums.get()).as("read on every call").isEqualTo(2);
assertThat(store.usageRowReads.get()).as("no usage row was read").isZero();
asAdmin(() -> store.insert(new AssistantUsage().withUserId(ADMIN).withPurpose("TURN").withStatus("OK").withCostMicros(10L).withInputTokens(600L).withOutputTokens(0L)));
store.usageRowReads.set(0);
ToolCallOutcome stopped = execute(EDITOR, UiApiToolbox.LIST_TABLES, Map.of());
assertThat(stopped.result().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(stopped.result().getPermissionCheck()).isEqualTo(ToolCallService.CHECK_BUDGET);
assertThat(store.usageRowReads.get()).isZero();
}
/*******************************************************************************
** The input a log row keeps is redacted with the administrator's own
** patterns, as a tool result is: for a call and for a confirmed change.
*******************************************************************************/
@Test
void aLogRowsInputIsRedactedWithTheAdministratorsPatterns() throws Exception
{
writable("blog_post");
ToolCallOutcome proposed = service().executeAndLog(ToolCalls.call(config, ADMIN, UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "Badge " + EMPLOYEE_NUMBER))), null, false);
assertThat(proposed.result().getOutcome()).as(proposed.result().getModelContent()).isEqualTo(AiToolOutcome.OK);
service().confirm(person(ADMIN), proposed.extras().getPendingActionId());
List<AssistantToolCall> rows = own(AssistantToolCall.TABLE_NAME, AssistantToolCall.class);
assertThat(rows).as("the proposal's row and the confirmation's").hasSize(2).allSatisfy(row ->
{
assertThat(row.getInputJson()).as("the row keeps the input").contains("Badge");
assertThat(row.getInputJson()).as("with the administrator's pattern applied").doesNotContain(EMPLOYEE_NUMBER);
});
}
/*******************************************************************************
** A proposed post of the administrator's: the pending action's id.
*******************************************************************************/
private Integer proposeAPost(String title) throws Exception
{
writable("blog_post");
ToolCallOutcome proposed = service().executeAndLog(ToolCalls.call(config, ADMIN, UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", title, "status", "DRAFT"))), null, false);
assertThat(proposed.result().getOutcome()).as(proposed.result().getModelContent()).isEqualTo(AiToolOutcome.OK);
return (proposed.extras().getPendingActionId());
}
/*******************************************************************************
**
*******************************************************************************/
private ToolCallService service()
{
return (config.getRuntime().getToolCalls());
}
/*******************************************************************************
** A fixture person, with a session of their own.
*******************************************************************************/
private SignedInPerson person(String user)
{
return (SignedInPerson.ofSession(qInstance, AssistantTestFixture.ownerSession(user, config)));
}
/*******************************************************************************
** A call of a person's, in a scope of its own, with no turn and no row.
*******************************************************************************/
private ToolCallOutcome execute(String user, String toolName, Map<String, Object> input)
{
return (service().execute(ToolCalls.call(config, user, toolName, input), ToolCalls.scope()));
}
/*******************************************************************************
**
*******************************************************************************/
private static void assertRefused(Decision decision, ToolCallService.ConfirmationRefused.Reason reason)
{
assertThatThrownBy(decision::decide).isInstanceOfSatisfying(ToolCallService.ConfirmationRefused.class, refused -> assertThat(refused.getReason()).isEqualTo(reason));
}
/*******************************************************************************
** A confirmation or a cancellation.
*******************************************************************************/
private interface Decision
{
/*******************************************************************************
**
*******************************************************************************/
Object decide() throws Exception;
}
/*******************************************************************************
** Something done with the administrator's session as the thread's context.
*******************************************************************************/
private <T> T asAdmin(Work<T> work) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(ADMIN, config));
try
{
return (work.run());
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
**
*******************************************************************************/
private interface Work<T>
{
/*******************************************************************************
**
*******************************************************************************/
T run() throws Exception;
}
/*******************************************************************************
**
*******************************************************************************/
private void writable(String tableName) throws Exception
{
asAdmin(() ->
{
if(config.getRuntime().getStore().query(AssistantDataAccess.TABLE_NAME, new QQueryFilter().withCriteria("objectName", QCriteriaOperator.EQUALS, tableName), AssistantDataAccess.class).isEmpty())
{
config.getRuntime().getStore().insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName(tableName).withMode(DataAccessMode.READ_WRITE.name()));
config.getRuntime().getDataAccessService().invalidate();
}
return (null);
});
}
/*******************************************************************************
**
*******************************************************************************/
private AssistantPendingAction pendingAction(Integer actionId) throws Exception
{
return (asAdmin(() -> config.getRuntime().getStore().getPendingAction(ADMIN, actionId)));
}
/*******************************************************************************
** Every row of one of the assistant's tables, oldest first.
*******************************************************************************/
private <T extends QRecordEntity> List<T> own(String tableName, Class<T> entity) throws Exception
{
return (asAdmin(() -> config.getRuntime().getStore().query(tableName, new QQueryFilter(), entity)));
}
/*******************************************************************************
** The posts of a title, read from the application's own table.
*******************************************************************************/
private List<QRecord> postsTitled(String title) throws Exception
{
return (asAdmin(() -> new QueryAction().execute(new QueryInput("blog_post").withFilter(new QQueryFilter().withCriteria("title", QCriteriaOperator.EQUALS, title))).getRecords()));
}
/*******************************************************************************
** The system clock, with a gate two confirming threads meet at: a
** confirmation asks for the time after it has read the action and before
** it claims it.
*******************************************************************************/
private static final class GatedClock extends Clock
{
private static final String GATED_THREAD = "confirming";
private final AtomicInteger passed = new AtomicInteger();
private final ThreadLocal<Boolean> met = new ThreadLocal<>();
private volatile CyclicBarrier gate;
/*******************************************************************************
**
*******************************************************************************/
@Override
public Instant instant()
{
CyclicBarrier barrier = gate;
if(barrier != null && GATED_THREAD.equals(Thread.currentThread().getName()) && !Boolean.TRUE.equals(met.get()))
{
met.set(true);
try
{
barrier.await(20, TimeUnit.SECONDS);
passed.incrementAndGet();
}
catch(Exception e)
{
////////////////////////////////////////////////////////
// the other request never came: go on without it //
////////////////////////////////////////////////////////
}
}
return (Instant.now());
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ZoneId getZone()
{
return (ZoneId.of("UTC"));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public Clock withZone(ZoneId zone)
{
return (this);
}
}
/*******************************************************************************
** The store, with faults a test switches on and counts of how usage is
** read.
*******************************************************************************/
private static final class FaultyStore extends AssistantStore
{
private final AtomicInteger usageRowReads = new AtomicInteger();
private final AtomicInteger usageSums = new AtomicInteger();
private volatile boolean failReadingDecided;
private volatile boolean failMarkingRunning;
/*******************************************************************************
**
*******************************************************************************/
private FaultyStore(AssistantQBitConfig config)
{
super(config);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public AssistantPendingAction getPendingAction(String userId, Integer id) throws QException
{
AssistantPendingAction action = super.getPendingAction(userId, id);
if(failReadingDecided && action != null && List.of(AssistantPendingAction.STATUS_EXECUTED, AssistantPendingAction.STATUS_FAILED).contains(action.getStatus()))
{
throw (new QException("The database went away."));
}
return (action);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void updateFields(String tableName, QRecord record) throws QException
{
if(failMarkingRunning && AssistantPendingAction.TABLE_NAME.equals(tableName) && AssistantPendingAction.STATUS_RUNNING.equals(record.getValueString("status")))
{
throw (new QException("The database went away."));
}
super.updateFields(tableName, record);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public <T extends QRecordEntity> List<T> query(String tableName, QQueryFilter filter, Class<T> entityClass) throws QException
{
if(AssistantUsage.TABLE_NAME.equals(tableName) || AssistantBudgetUsage.TABLE_NAME.equals(tableName))
{
usageRowReads.incrementAndGet();
}
return (super.query(tableName, filter, entityClass));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public UsageTotals sumUsage(String tableName, QQueryFilter filter) throws QException
{
usageSums.incrementAndGet();
return (super.sumUsage(tableName, filter));
}
}
}
@@ -0,0 +1,553 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import java.io.Serializable;
import java.lang.reflect.Modifier;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.SignedInPerson;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPendingActionClaim;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.processes.AssistantRetentionStep;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.ToolCalls;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ADMIN;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** A pending action is decided once, and expiry is one of the decisions:
** - an action somebody confirmed is never marked EXPIRED afterwards, by a
** list that read it before it was confirmed, or by anything else;
** - of a confirmation and an expiry that arrive together exactly one wins;
** - an action left RUNNING by a server that stopped is ended as FAILED
** ("outcome unknown") by the retention process, with a log row, and then
** removed like any other once it is old enough.
*******************************************************************************/
class PendingActionExpiryTest extends BaseAssistantTest
{
private static final String LISTING = "listing";
private final MovingClock clock = new MovingClock();
private RacingStore store;
/*******************************************************************************
** A runtime on a clock the test moves and a store whose list can be held
** between its read and what its caller does next.
*******************************************************************************/
@BeforeEach
void useTestRuntime()
{
config.getRuntime().close();
store = new RacingStore(config);
config.setRuntime(new AssistantRuntime(qInstance, config, clock, store));
}
/*******************************************************************************
** The review's case. A list reads the action while it is still pending, a
** moment before its time is up. The person's confirmation, made in that
** moment, claims it and runs the change. The time passes, and the list goes
** on to expire what it read. The action stays EXECUTED and is reported as
** EXECUTED: the person is never told "expired, nothing changed" about a
** change that was made (they would ask for it again, and a create would be
** made twice).
*******************************************************************************/
@Test
void aListThatReadTheActionBeforeItWasConfirmedDoesNotExpireItAfterwards() throws Exception
{
Integer actionId = proposeAPost("Confirmed a moment before it lapsed");
Instant expires = pendingAction(actionId).getExpiresAt();
clock.now = expires.minusSeconds(1);
store.readByTheList = new CountDownLatch(1);
store.letTheListGoOn = new CountDownLatch(1);
ExecutorService pool = Executors.newSingleThreadExecutor(runnable -> new Thread(runnable, LISTING));
Future<Map<String, Object>> listing;
try
{
listing = pool.submit(() ->
{
QContext.init(qInstance, AssistantTestFixture.sessionFor(ADMIN));
try
{
return (new AssistantApi(config.getRuntime(), QContext.getQSession()).listActions(null, null));
}
finally
{
QContext.clear();
}
});
assertThat(store.readByTheList.await(30, TimeUnit.SECONDS)).as("the list has read the action as PENDING").isTrue();
ToolCallService.Confirmation confirmation = service().confirm(person(ADMIN), actionId);
assertThat(confirmation.action().getStatus()).isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
clock.now = expires.plusSeconds(5);
store.letTheListGoOn.countDown();
Map<String, Object> listed = listing.get(30, TimeUnit.SECONDS);
@SuppressWarnings("unchecked")
List<Map<String, Object>> actions = (List<Map<String, Object>>) listed.get("actions");
assertThat(actions).singleElement().satisfies(action ->
{
assertThat(action).as("reported as what it is").containsEntry("status", AssistantPendingAction.STATUS_EXECUTED);
assertThat(String.valueOf(action.get("resultSummary"))).doesNotContain("Expired");
});
}
finally
{
store.letTheListGoOn.countDown();
pool.shutdownNow();
}
AssistantPendingAction stored = pendingAction(actionId);
assertThat(stored.getStatus()).as("the status of a change that was made is not overwritten").isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
assertThat(stored.getResultSummary()).doesNotContain("Expired");
assertThat(postsTitled("Confirmed a moment before it lapsed")).as("made once").hasSize(1);
assertThat(own(AssistantPendingActionClaim.TABLE_NAME, AssistantPendingActionClaim.class)).singleElement()
.satisfies(claim -> assertThat(claim.getDecision()).as("the one decision was the confirmation").isEqualTo(AssistantPendingAction.STATUS_RUNNING));
///////////////////////////////////////////////////////////////////////
// a second click after the time is up finds it done, not expired //
///////////////////////////////////////////////////////////////////////
assertThatThrownBy(() -> service().confirm(person(ADMIN), actionId)).isInstanceOfSatisfying(ToolCallService.ConfirmationRefused.class,
refused -> assertThat(refused.getMessage()).contains("executed"));
assertThatThrownBy(() -> service().cancel(person(ADMIN), actionId)).isInstanceOf(ToolCallService.ConfirmationRefused.class);
assertThat(pendingAction(actionId).getStatus()).isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
assertThat(postsTitled("Confirmed a moment before it lapsed")).hasSize(1);
}
/*******************************************************************************
** Two threads, one action, again and again: one claims it for a
** confirmation while the other expires it. Exactly one of them is answered
** true every time, there is one claim, and the action's status is the
** winner's: never EXPIRED over RUNNING, never RUNNING over EXPIRED.
*******************************************************************************/
@Test
void ofAConfirmationAndAnExpiryAtOnceExactlyOneWins() throws Exception
{
int confirmedFirst = 0;
for(int round = 0; round < 20; round++)
{
Integer actionId = proposeAPost("Raced " + round);
Instant now = clock.instant();
CyclicBarrier together = new CyclicBarrier(2);
ExecutorService pool = Executors.newFixedThreadPool(2);
try
{
Future<Boolean> confirming = pool.submit(() -> decide(together, () -> store.decidePendingAction(ADMIN, actionId, AssistantPendingAction.STATUS_RUNNING, null, now)));
Future<Boolean> expiring = pool.submit(() -> decide(together, () -> store.expirePendingAction(ADMIN, actionId, now)));
boolean confirmed = confirming.get(30, TimeUnit.SECONDS);
boolean expired = expiring.get(30, TimeUnit.SECONDS);
assertThat(confirmed ^ expired).as("round " + round + ": exactly one decision").isTrue();
AssistantPendingAction stored = pendingAction(actionId);
assertThat(stored.getStatus()).as("round " + round).isEqualTo(confirmed ? AssistantPendingAction.STATUS_RUNNING : AssistantPendingAction.STATUS_EXPIRED);
assertThat(own(AssistantPendingActionClaim.TABLE_NAME, AssistantPendingActionClaim.class)).filteredOn(claim -> actionId.equals(claim.getPendingActionId())).singleElement()
.satisfies(claim -> assertThat(claim.getDecision()).isEqualTo(stored.getStatus()));
confirmedFirst += confirmed ? 1 : 0;
}
finally
{
pool.shutdownNow();
}
}
assertThat(confirmedFirst).as("rounds the confirmation won, of 20").isBetween(0, 20);
}
/*******************************************************************************
** Expiry goes through the single decision and nothing else: it is refused
** for an action that is running, done, cancelled or already expired, and
** made, with its claim, for one that is pending. An action is never
** expired before its time, whatever the caller's copy of it says. A
** subclass of the store cannot replace the decision.
*******************************************************************************/
@Test
void expiryIsADecisionLikeTheOthers() throws Exception
{
Instant now = clock.instant();
Integer running = proposeAPost("Running");
assertThat(asAdmin(() -> store.decidePendingAction(ADMIN, running, AssistantPendingAction.STATUS_RUNNING, null, now))).isTrue();
assertThat(asAdmin(() -> store.expirePendingAction(ADMIN, running, now.plusSeconds(3600)))).as("a running action is not expired").isFalse();
assertThat(pendingAction(running).getStatus()).isEqualTo(AssistantPendingAction.STATUS_RUNNING);
Integer cancelled = proposeAPost("Cancelled");
service().cancel(person(ADMIN), cancelled);
assertThat(asAdmin(() -> store.expirePendingAction(ADMIN, cancelled, now.plusSeconds(3600)))).isFalse();
assertThat(pendingAction(cancelled).getStatus()).isEqualTo(AssistantPendingAction.STATUS_REJECTED);
Integer lapsed = proposeAPost("Lapsed");
assertThat(asAdmin(() -> store.expirePendingAction(ADMIN, lapsed, now.plusSeconds(3600)))).isTrue();
assertThat(asAdmin(() -> store.expirePendingAction(ADMIN, lapsed, now.plusSeconds(3600)))).as("once").isFalse();
AssistantPendingAction expired = pendingAction(lapsed);
assertThat(expired.getStatus()).isEqualTo(AssistantPendingAction.STATUS_EXPIRED);
assertThat(expired.getResultSummary()).isEqualTo(AssistantStore.EXPIRED_SUMMARY);
assertThat(own(AssistantPendingActionClaim.TABLE_NAME, AssistantPendingActionClaim.class)).filteredOn(claim -> lapsed.equals(claim.getPendingActionId())).singleElement()
.satisfies(claim -> assertThat(claim.getDecision()).isEqualTo(AssistantPendingAction.STATUS_EXPIRED));
assertThatThrownBy(() -> service().confirm(person(ADMIN), lapsed)).isInstanceOfSatisfying(ToolCallService.ConfirmationRefused.class,
refused -> assertThat(refused.getReason()).isEqualTo(ToolCallService.ConfirmationRefused.Reason.NOT_PENDING));
assertThat(postsTitled("Lapsed")).isEmpty();
///////////////////////////////////////////////////////////////////////
// a caller's copy that claims the time is up does not expire an //
// action whose time is not //
///////////////////////////////////////////////////////////////////////
Integer notDue = proposeAPost("Not due");
AssistantPendingAction copy = pendingAction(notDue);
copy.setExpiresAt(now.minusSeconds(60));
AssistantPendingAction answered = asAdmin(() -> service().expiredIfDue(ADMIN, copy));
assertThat(answered.getStatus()).isEqualTo(AssistantPendingAction.STATUS_PENDING);
assertThat(pendingAction(notDue).getStatus()).isEqualTo(AssistantPendingAction.STATUS_PENDING);
assertThat(asAdmin(() -> service().expiredIfDue(AssistantTestFixture.EDITOR, copy))).as("not this person's").isNull();
AssistantPendingAction due = pendingAction(notDue);
clock.now = due.getExpiresAt().plusSeconds(1);
assertThat(asAdmin(() -> service().expiredIfDue(ADMIN, due)).getStatus()).isEqualTo(AssistantPendingAction.STATUS_EXPIRED);
for(String decision : List.of("decidePendingAction", "expirePendingAction", "failAbandonedPendingAction"))
{
assertThat(AssistantStore.class.getMethods()).filteredOn(method -> method.getName().equals(decision)).as(decision).isNotEmpty()
.allSatisfy(method -> assertThat(Modifier.isFinal(method.getModifiers())).as(decision + " cannot be overridden").isTrue());
}
}
/*******************************************************************************
** A confirmed change whose server stopped while it ran stays RUNNING: its
** end was never recorded. The retention process leaves it alone for an
** hour, then ends it as FAILED with "outcome unknown, check the record" and
** a tool-call log row; it can never be confirmed again; and once it is
** older than approvalRetentionDays it is removed, with what it kept of the
** record before the change, like any other decided action.
*******************************************************************************/
@Test
void anActionLeftRunningIsEndedAsFailedAndThenRemoved() throws Exception
{
ToolCallOutcome proposed = service().executeAndLog(ToolCalls.call(config, ADMIN, UiApiToolbox.UPDATE_RECORD, input("table", "page", "id", "12", "values", Map.of("title", "Never finished"))), null, false);
assertThat(proposed.result().getOutcome()).as(proposed.result().getModelContent()).isEqualTo(AiToolOutcome.OK);
Integer actionId = proposed.extras().getPendingActionId();
Instant claimed = clock.instant();
assertThat(pendingAction(actionId).getBeforeJson()).as("what the card showed of the record").contains("About");
///////////////////////////////////////////////////////////////////////
// the confirmation claims it, and the server stops before the end //
///////////////////////////////////////////////////////////////////////
assertThat(asAdmin(() -> store.decidePendingAction(ADMIN, actionId, AssistantPendingAction.STATUS_RUNNING, null, claimed))).isTrue();
AssistantRetentionStep.Result soon = asAdmin(() -> AssistantRetentionStep.purge(config.getRuntime(), claimed.plus(Duration.ofMinutes(59))));
assertThat(soon.pendingActionsFailed()).as("it may still be running").isZero();
assertThat(pendingAction(actionId).getStatus()).isEqualTo(AssistantPendingAction.STATUS_RUNNING);
AssistantRetentionStep.Result yearOfRunning = asAdmin(() -> AssistantRetentionStep.purge(config, store, claimed.plus(Duration.ofDays(400))));
assertThat(yearOfRunning.pendingActionsDeleted()).as("without the first step a running action is kept for ever").isZero();
AssistantRetentionStep.Result later = asAdmin(() -> AssistantRetentionStep.purge(config.getRuntime(), claimed.plus(Duration.ofMinutes(61))));
assertThat(later.pendingActionsFailed()).isEqualTo(1);
assertThat(later.pendingActionsDeleted()).as("not old enough to be removed").isZero();
AssistantPendingAction failed = pendingAction(actionId);
assertThat(failed.getStatus()).isEqualTo(AssistantPendingAction.STATUS_FAILED);
assertThat(failed.getResultSummary()).isEqualTo(ToolCallService.ABANDONED_SUMMARY).contains("Outcome unknown").contains("Check the record");
assertThat(own(AssistantToolCall.TABLE_NAME, AssistantToolCall.class)).filteredOn(row -> actionId.equals(row.getPendingActionId()) && "ERROR".equals(row.getOutcome())).singleElement().satisfies(row ->
{
assertThat(row.getUserId()).as("the row is the person's whose change it was").isEqualTo(ADMIN);
assertThat(row.getToolName()).isEqualTo(UiApiToolbox.UPDATE_RECORD);
assertThat(row.getTableName()).isEqualTo("page");
assertThat(row.getResultSummary()).isEqualTo("Confirmed: " + ToolCallService.ABANDONED_SUMMARY);
});
assertThatThrownBy(() -> service().confirm(person(ADMIN), actionId)).isInstanceOfSatisfying(ToolCallService.ConfirmationRefused.class,
refused -> assertThat(refused.getReason()).isEqualTo(ToolCallService.ConfirmationRefused.Reason.NOT_PENDING));
assertThat(asAdmin(() -> AssistantRetentionStep.purge(config.getRuntime(), claimed.plus(Duration.ofMinutes(90)))).pendingActionsFailed()).as("once").isZero();
AssistantRetentionStep.Result old = asAdmin(() -> AssistantRetentionStep.purge(config.getRuntime(), claimed.plus(Duration.ofDays(config.getApprovalRetentionDays() + 1))));
assertThat(old.pendingActionsDeleted()).isEqualTo(1);
assertThat(pendingAction(actionId)).as("gone, with the values it kept").isNull();
assertThat(own(AssistantPendingActionClaim.TABLE_NAME, AssistantPendingActionClaim.class)).isEmpty();
}
/*******************************************************************************
** The retention process, as the scheduler runs it, does both steps.
*******************************************************************************/
@Test
void theRetentionProcessEndsActionsLeftRunning() throws Exception
{
Integer actionId = proposeAPost("Left running");
assertThat(asAdmin(() -> store.decidePendingAction(ADMIN, actionId, AssistantPendingAction.STATUS_RUNNING, null, clock.instant()))).isTrue();
clock.now = clock.instant().plus(Duration.ofHours(2));
RunProcessOutput output = asAdmin(() ->
{
RunProcessInput input = new RunProcessInput();
input.setProcessName(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME);
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
return (new RunProcessAction().execute(input));
});
assertThat(output.getValues()).containsEntry("pendingActionsFailed", (Serializable) 1);
assertThat(pendingAction(actionId).getStatus()).isEqualTo(AssistantPendingAction.STATUS_FAILED);
assertThat(postsTitled("Left running")).as("nothing was run by the retention process").isEmpty();
}
/*******************************************************************************
** One decision, started when both threads are ready.
*******************************************************************************/
private boolean decide(CyclicBarrier together, Work<Boolean> decision) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(ADMIN, config));
try
{
together.await(20, TimeUnit.SECONDS);
return (decision.run());
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** A proposed post of the administrator's: the pending action's id.
*******************************************************************************/
private Integer proposeAPost(String title) throws Exception
{
ToolCallOutcome proposed = service().executeAndLog(ToolCalls.call(config, ADMIN, UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", title, "status", "DRAFT"))), null, false);
assertThat(proposed.result().getOutcome()).as(proposed.result().getModelContent()).isEqualTo(AiToolOutcome.OK);
return (proposed.extras().getPendingActionId());
}
/*******************************************************************************
**
*******************************************************************************/
private ToolCallService service()
{
return (config.getRuntime().getToolCalls());
}
/*******************************************************************************
** A fixture person, with a session of their own.
*******************************************************************************/
private SignedInPerson person(String user)
{
return (SignedInPerson.ofSession(qInstance, AssistantTestFixture.ownerSession(user, config)));
}
/*******************************************************************************
**
*******************************************************************************/
private AssistantPendingAction pendingAction(Integer actionId) throws Exception
{
return (asAdmin(() -> config.getRuntime().getStore().getPendingAction(ADMIN, actionId)));
}
/*******************************************************************************
** Every row of one of the assistant's tables, oldest first.
*******************************************************************************/
private <T extends QRecordEntity> List<T> own(String tableName, Class<T> entity) throws Exception
{
return (asAdmin(() -> config.getRuntime().getStore().query(tableName, new QQueryFilter(), entity)));
}
/*******************************************************************************
** The posts of a title, read from the application's own table.
*******************************************************************************/
private List<QRecord> postsTitled(String title) throws Exception
{
return (asAdmin(() -> new QueryAction().execute(new QueryInput("blog_post").withFilter(new QQueryFilter().withCriteria("title", QCriteriaOperator.EQUALS, title))).getRecords()));
}
/*******************************************************************************
** Something done with the administrator's session as the thread's context.
*******************************************************************************/
private <T> T asAdmin(Work<T> work) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(ADMIN, config));
try
{
return (work.run());
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
**
*******************************************************************************/
private interface Work<T>
{
/*******************************************************************************
**
*******************************************************************************/
T run() throws Exception;
}
/*******************************************************************************
** A clock the test moves.
*******************************************************************************/
private static final class MovingClock extends Clock
{
private volatile Instant now = Instant.now();
/*******************************************************************************
**
*******************************************************************************/
@Override
public Instant instant()
{
return (now);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ZoneId getZone()
{
return (ZoneId.of("UTC"));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public Clock withZone(ZoneId zone)
{
return (this);
}
}
/*******************************************************************************
** The store, with a list that can be held after it has read: what a slow
** request looks like to the one that overtakes it.
*******************************************************************************/
private static final class RacingStore extends AssistantStore
{
private volatile CountDownLatch readByTheList;
private volatile CountDownLatch letTheListGoOn;
/*******************************************************************************
**
*******************************************************************************/
private RacingStore(AssistantQBitConfig config)
{
super(config);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<AssistantPendingAction> listOwnPendingActions(String userId, String status, Integer conversationId, int limit) throws QException
{
List<AssistantPendingAction> read = new ArrayList<>(super.listOwnPendingActions(userId, status, conversationId, limit));
CountDownLatch hold = letTheListGoOn;
if(hold != null && LISTING.equals(Thread.currentThread().getName()))
{
readByTheList.countDown();
try
{
hold.await(30, TimeUnit.SECONDS);
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
}
return (read);
}
}
}
@@ -24,18 +24,20 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.RuntimeInternals;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qbits.assistant.tools.Toolboxes;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
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.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
@@ -72,7 +74,7 @@ class StarterPromptsTest extends BaseAssistantTest
private UiApiToolbox.Context context(String user, UiApiGateway gateway)
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config));
return (new UiApiToolbox.Context(user, null, gateway, PolicyService.ToolPolicy.defaults(), null));
return (Toolboxes.context(user, null, gateway, PolicyService.ToolPolicy.defaults(), null));
}
@@ -83,7 +85,7 @@ class StarterPromptsTest extends BaseAssistantTest
private StarterPrompts prompts(Instant now)
{
UiApiTestServer.serve(qInstance, config);
return (new StarterPrompts(config.getRuntime().getToolbox(), Clock.fixed(now, ZoneOffset.UTC)));
return (new StarterPrompts(Toolboxes.data(config.getRuntime()), Clock.fixed(now, ZoneOffset.UTC)));
}
@@ -96,7 +98,7 @@ class StarterPromptsTest extends BaseAssistantTest
void promptsNameOnlyTablesTheUserCanRead() throws Exception
{
StarterPrompts starter = prompts(NOW);
UiApiGateway gateway = config.getRuntime().getUiApiGateway();
UiApiGateway gateway = RuntimeInternals.gateway(config.getRuntime());
List<String> analyst = starter.forUser(context(AssistantTestFixture.ANALYST, gateway), AssistantTestFixture.ANALYST, List.of(), false);
assertThat(analyst).isNotEmpty().hasSizeLessThanOrEqualTo(3);
@@ -123,10 +125,10 @@ class StarterPromptsTest extends BaseAssistantTest
new DeleteAction().execute(new DeleteInput("page").withQueryFilter(new QQueryFilter()));
QContext.clear();
UiApiGateway gateway = config.getRuntime().getUiApiGateway();
UiApiGateway gateway = RuntimeInternals.gateway(config.getRuntime());
for(int day = 0; day < 20; day++)
{
StarterPrompts daily = new StarterPrompts(config.getRuntime().getToolbox(), Clock.fixed(NOW.plusSeconds(86400L * day), ZoneOffset.UTC));
StarterPrompts daily = new StarterPrompts(Toolboxes.data(config.getRuntime()), Clock.fixed(NOW.plusSeconds(86400L * day), ZoneOffset.UTC));
List<String> rs = daily.forUser(context(AssistantTestFixture.EDITOR, gateway), AssistantTestFixture.EDITOR, List.of(), true);
assertThat(rs).isNotEmpty();
assertThat(String.join("|", rs)).doesNotContain("pages");
@@ -141,7 +143,7 @@ class StarterPromptsTest extends BaseAssistantTest
@Test
void stableForTheDay() throws Exception
{
UiApiGateway gateway = config.getRuntime().getUiApiGateway();
UiApiGateway gateway = RuntimeInternals.gateway(config.getRuntime());
List<String> first = prompts(NOW).forUser(context(AssistantTestFixture.EDITOR, gateway), AssistantTestFixture.EDITOR, List.of(), true);
List<String> later = prompts(NOW.plusSeconds(3600)).forUser(context(AssistantTestFixture.EDITOR, gateway), AssistantTestFixture.EDITOR, List.of(), true);
assertThat(later).isEqualTo(first);
@@ -193,7 +195,7 @@ class StarterPromptsTest extends BaseAssistantTest
void accessIsRecheckedWithBoundedCalls() throws Exception
{
UiApiTestServer.serve(qInstance, config);
UiApiGateway delegate = config.getRuntime().getUiApiGateway();
UiApiGateway delegate = RuntimeInternals.gateway(config.getRuntime());
AtomicInteger calls = new AtomicInteger();
UiApiGateway counting = new UiApiGateway()
{
@@ -218,7 +220,7 @@ class StarterPromptsTest extends BaseAssistantTest
return (delegate.invoke(call));
}
};
StarterPrompts starter = new StarterPrompts(config.getRuntime().getToolbox(), Clock.fixed(NOW, ZoneOffset.UTC));
StarterPrompts starter = new StarterPrompts(Toolboxes.data(config.getRuntime()), Clock.fixed(NOW, ZoneOffset.UTC));
starter.forUser(context(AssistantTestFixture.EDITOR, counting), AssistantTestFixture.EDITOR, List.of(), false);
int firstCalls = calls.get();
@@ -262,7 +264,7 @@ class StarterPromptsTest extends BaseAssistantTest
void configuredPromptsRespectTotalLimit() throws Exception
{
StarterPrompts starter = prompts(NOW);
List<String> result = starter.forUser(context(AssistantTestFixture.LIMITED, config.getRuntime().getUiApiGateway()), AssistantTestFixture.LIMITED,
List<String> result = starter.forUser(context(AssistantTestFixture.LIMITED, RuntimeInternals.gateway(config.getRuntime())), AssistantTestFixture.LIMITED,
List.of("[blog_post] Denied", "[none] One", "[none] Two", "[media] Denied too", "[none] Three", "[none] Four"), false);
assertThat(result).containsExactly("One", "Two", "Three");
}
@@ -276,7 +278,7 @@ class StarterPromptsTest extends BaseAssistantTest
void nextConfigReflectsRevokedDataAccess() throws Exception
{
UiApiTestServer.serve(qInstance, config);
context(AssistantTestFixture.ANALYST, config.getRuntime().getUiApiGateway());
context(AssistantTestFixture.ANALYST, RuntimeInternals.gateway(config.getRuntime()));
AssistantApi api = new AssistantApi(config.getRuntime(), QContext.getQSession());
assertThat(String.valueOf(((Map<?, ?>) api.config().get("profile")).get("starterPrompts"))).contains("daily stats");
@@ -284,7 +286,7 @@ class StarterPromptsTest extends BaseAssistantTest
config.getRuntime().getStore().insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE)
.withObjectName("analytics_daily_stat").withMode("NONE"));
config.getRuntime().getDataAccessService().invalidate();
context(AssistantTestFixture.ANALYST, config.getRuntime().getUiApiGateway());
context(AssistantTestFixture.ANALYST, RuntimeInternals.gateway(config.getRuntime()));
assertThat(((Map<?, ?>) api.config().get("profile")).get("starterPrompts")).isEqualTo(List.of());
}
@@ -297,14 +299,14 @@ class StarterPromptsTest extends BaseAssistantTest
void changedSessionPermissionsAndToolPolicyAreRechecked() throws Exception
{
StarterPrompts starter = prompts(NOW);
UiApiGateway gateway = config.getRuntime().getUiApiGateway();
UiApiGateway gateway = RuntimeInternals.gateway(config.getRuntime());
assertThat(starter.forUser(context(AssistantTestFixture.EDITOR, gateway), "same-user", List.of(), false)).isNotEmpty();
assertThat(starter.forUser(context(AssistantTestFixture.LIMITED, gateway), "same-user", List.of("[blog_post] Old access"), false)).isEmpty();
UiApiToolbox.Context allowed = context(AssistantTestFixture.ANALYST, gateway);
assertThat(starter.forUser(allowed, "same-user", List.of(), false)).isNotEmpty();
UiApiToolbox.Context denied = new UiApiToolbox.Context(allowed.sessionUUID(), null, gateway,
new PolicyService.ToolPolicy(true, Set.of(UiApiToolbox.DESCRIBE_TABLE), 50, true), null);
UiApiToolbox.Context denied = Toolboxes.context(allowed.sessionUUID(), null, gateway,
new PolicyService.ToolPolicy(Set.of(UiApiToolbox.DESCRIBE_TABLE), 50, true), null);
assertThat(starter.forUser(denied, "same-user", List.of(), false)).isEmpty();
}
@@ -317,7 +319,7 @@ class StarterPromptsTest extends BaseAssistantTest
void historyAvailabilityIsNotCached() throws Exception
{
StarterPrompts starter = prompts(NOW);
UiApiToolbox.Context context = context(AssistantTestFixture.ANALYST, config.getRuntime().getUiApiGateway());
UiApiToolbox.Context context = context(AssistantTestFixture.ANALYST, RuntimeInternals.gateway(config.getRuntime()));
assertThat(String.join("|", starter.forUser(context, AssistantTestFixture.ANALYST, List.of(), true))).contains("What changed");
assertThat(String.join("|", starter.forUser(context, AssistantTestFixture.ANALYST, List.of(), false))).doesNotContain("What changed");
}
@@ -331,8 +333,8 @@ class StarterPromptsTest extends BaseAssistantTest
void revokedDateFieldRemovesRecencyPrompts() throws Exception
{
StarterPrompts starter = prompts(NOW);
UiApiToolbox.Context context = context(AssistantTestFixture.ANALYST, config.getRuntime().getUiApiGateway());
String described = config.getRuntime().getToolbox().execute(context, "describe-date-fixture", UiApiToolbox.DESCRIBE_TABLE,
UiApiToolbox.Context context = context(AssistantTestFixture.ANALYST, RuntimeInternals.gateway(config.getRuntime()));
String described = Toolboxes.execute(config.getRuntime(), context, "describe-date-fixture", UiApiToolbox.DESCRIBE_TABLE,
Map.of("table", "analytics_daily_stat")).getResult().getModelContent();
assertThat(described).contains("\"name\":\"statDate\"", "\"type\":\"DATE\"");
assertThat(String.join("|", starter.forUser(context, AssistantTestFixture.ANALYST, List.of(), false))).contains("recent");
@@ -353,7 +355,7 @@ class StarterPromptsTest extends BaseAssistantTest
void changedVisibleRowsAreRechecked() throws Exception
{
StarterPrompts starter = prompts(NOW);
UiApiGateway gateway = config.getRuntime().getUiApiGateway();
UiApiGateway gateway = RuntimeInternals.gateway(config.getRuntime());
assertThat(starter.forUser(context(AssistantTestFixture.ANALYST, gateway), AssistantTestFixture.ANALYST, List.of(), false)).isNotEmpty();
QContext.init(qInstance, AssistantTestFixture.sessionFor("seeder-0"));
new DeleteAction().execute(new DeleteInput("analytics_daily_stat").withQueryFilter(new QQueryFilter()));
@@ -0,0 +1,311 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.api.AssistantApiException;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ADMIN;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ANALYST;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.LIMITED;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.NO_ACCESS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** One tool call of the chat goes through the checks it always went through,
** in the order it always did, whichever family the registry hands it to:
**
** 1. the person must hold assistant access (before the kill switch, and
** before a model is called or anything is logged);
** 2. the tool must be offered (the policy's allowed tools);
** 3. the assistant's data access for the table;
** 4. the /qqq/v1 operation, with the person's own session: its refusal is the
** tool's refusal;
** 5. a write waits for the person's confirmation and then runs with the
** confirming session.
**
** A refused call leaves the tool-call log row it always left, and an earlier
** check refuses before a later one is reached: the UI API test server sees
** every request the assistant makes, so "nothing ran" is what it did not see.
*******************************************************************************/
class ToolCallChecksTest extends BaseAssistantTest
{
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void forgetEarlierRequests()
{
UiApiTestServer.SCHEDULED_REQUESTS.clear();
}
/*******************************************************************************
** Without assistant access nothing starts: no conversation, no model call,
** no UI API request, no log row. The permission is checked before the
** policy: with the kill switch on, that person is still told "no access",
** and a person with access is told the assistant is switched off.
*******************************************************************************/
@Test
void aPersonWithoutAssistantAccessIsRefusedBeforeAnythingRuns() throws Exception
{
provider.thenToolUse(UiApiToolbox.COUNT_RECORDS, input("table", "blog_post", "filter", filter("AND"))).thenText("never");
assertThatThrownBy(() -> runner.run(NO_ACCESS, "How many posts are there?"))
.isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(403));
policy(new AssistantPolicy().withName("default").withKillSwitch(true));
assertThatThrownBy(() -> runner.run(NO_ACCESS, "How many posts are there?"))
.isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(403));
assertThatThrownBy(() -> runner.run(EDITOR, "How many posts are there?"))
.isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getCode()).isEqualTo("KILL_SWITCH"));
assertThat(provider.getRequests()).as("no model was called").isEmpty();
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).as("no UI API request was made").isEmpty();
assertThat(everyToolCall()).as("nothing was logged").isEmpty();
}
/*******************************************************************************
** A tool the policy does not allow is not offered, and a call to it is
** refused before the tool is reached: no request, and the row of an unknown
** tool (no permission check, no operation, no status).
*******************************************************************************/
@Test
void aToolThePolicySwitchedOffIsRefusedBeforeItRunsAndLogged() throws Exception
{
policy(new AssistantPolicy().withName("default").withAllowedTools(UiApiToolbox.LIST_TABLES));
provider.thenToolUse(UiApiToolbox.COUNT_RECORDS, input("table", "blog_post", "filter", filter("AND"))).thenText("I cannot count them.");
TurnRunner.Outcome outcome = runner.run(EDITOR, "How many posts are there?");
assertThat(provider.getRequests().get(0).getTools()).extracting(tool -> tool.getName()).doesNotContain(UiApiToolbox.COUNT_RECORDS).contains(UiApiToolbox.LIST_TABLES);
assertThat(requestsFor("blog_post")).isEmpty();
assertThat(outcome.toolCalls()).hasSize(1);
AssistantToolCall row = outcome.toolCalls().get(0);
assertThat(row.getToolName()).isEqualTo(UiApiToolbox.COUNT_RECORDS);
assertThat(row.getUserId()).isEqualTo(EDITOR);
assertThat(row.getOutcome()).isEqualTo("INVALID_INPUT");
assertThat(row.getResultSummary()).isEqualTo("Unknown tool");
assertThat(row.getResultJson()).isEqualTo("Unknown tool \"count_records\". Use only the tools provided.");
assertThat(row.getPermissionCheck()).isNull();
assertThat(row.getUiApiOperation()).isNull();
assertThat(row.getHttpStatus()).isNull();
assertThat(row.getClientChannel()).isEqualTo("assistant/" + outcome.turnId());
}
/*******************************************************************************
** The assistant's data access is checked before the person's request is
** made: on a table the assistant may not use, a person who could read it on
** screen and one who could not get the same refusal, and no request is made.
*******************************************************************************/
@Test
void dataAccessRefusesBeforeThePersonsRequestIsMade() throws Exception
{
setAccess("page", DataAccessMode.NONE);
for(String person : List.of(EDITOR, LIMITED))
{
provider.thenToolUse(UiApiToolbox.GET_RECORD, input("table", "page", "id", "12")).thenText("I cannot open it.");
TurnRunner.Outcome outcome = runner.run(person, "Open page 12");
assertThat(requestsFor("page")).as(person).isEmpty();
assertThat(outcome.toolCalls()).as(person).hasSize(1);
AssistantToolCall row = outcome.toolCalls().get(0);
assertThat(row.getUserId()).isEqualTo(person);
assertThat(row.getOutcome()).as(person).isEqualTo("DENIED");
assertThat(row.getPermissionCheck()).as(person).isEqualTo("dataAccess:NONE");
assertThat(row.getTableName()).isEqualTo("page");
assertThat(row.getUiApiOperation()).isEqualTo("GET /table/{tableName}/{primaryKey}");
assertThat(row.getHttpStatus()).as("no request is made").isNull();
}
}
/*******************************************************************************
** Past the assistant's own checks the call is the person's own request: it
** carries that person's session and the assistant's channel label, and what
** the operation answers that person is what the tool answers (a refusal for
** one who may not read the table on screen, the count for one who may).
*******************************************************************************/
@Test
void aCallRunsAsThePersonAndItsRefusalIsTheOperationsOwn() throws Exception
{
provider.thenToolUse(UiApiToolbox.COUNT_RECORDS, input("table", "blog_post", "filter", filter("AND"))).thenText("I cannot count them.");
TurnRunner.Outcome refused = runner.run(ANALYST, "How many posts are there?");
assertThat(requestsFor("blog_post")).hasSize(1);
assertThat(requestsFor("blog_post").get(0)).containsEntry("session", ANALYST).containsEntry("client", "assistant/" + refused.turnId());
assertThat(requestsFor("blog_post").get(0).get("path")).endsWith("/table/blog_post/count");
AssistantToolCall refusedRow = refused.toolCalls().get(0);
assertThat(refusedRow.getUserId()).isEqualTo(ANALYST);
assertThat(refusedRow.getOutcome()).isEqualTo("DENIED");
assertThat(refusedRow.getHttpStatus()).isEqualTo(403);
assertThat(refusedRow.getPermissionCheck()).startsWith("uiApi:").endsWith(":403");
assertThat(refusedRow.getUiApiOperation()).isEqualTo("POST /table/{tableName}/count");
assertThat(refusedRow.getClientChannel()).isEqualTo("assistant/" + refused.turnId());
UiApiTestServer.SCHEDULED_REQUESTS.clear();
provider.thenToolUse(UiApiToolbox.COUNT_RECORDS, input("table", "blog_post", "filter", filter("AND"))).thenText("There are some.");
TurnRunner.Outcome allowed = runner.run(EDITOR, "How many posts are there?");
assertThat(requestsFor("blog_post")).hasSize(1);
assertThat(requestsFor("blog_post").get(0)).containsEntry("session", EDITOR).containsEntry("client", "assistant/" + allowed.turnId());
AssistantToolCall allowedRow = allowed.toolCalls().get(0);
assertThat(allowedRow.getUserId()).isEqualTo(EDITOR);
assertThat(allowedRow.getOutcome()).isEqualTo("OK");
assertThat(allowedRow.getHttpStatus()).isEqualTo(200);
assertThat(allowedRow.getPermissionCheck()).startsWith("uiApi:").endsWith(":200");
}
/*******************************************************************************
** A write is only proposed in the turn (a pending action, its log row, no
** request); the person's confirmation then runs it once with the confirming
** session, and leaves the "Confirmed" row.
*******************************************************************************/
@Test
void aWriteWaitsForThePersonAndRunsWithTheConfirmingSession() throws Exception
{
setAccess("blog_post", DataAccessMode.READ_WRITE);
provider.thenToolUse(UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "Proposed in a turn", "status", "DRAFT"))).thenText("Please confirm.");
TurnRunner.Outcome outcome = runner.run(ADMIN, "Create a draft post");
assertThat(requestsFor("blog_post")).as("nothing runs before the confirmation").isEmpty();
assertThat(outcome.toolCalls()).hasSize(1);
AssistantToolCall proposed = outcome.toolCalls().get(0);
assertThat(proposed.getOutcome()).isEqualTo("OK");
assertThat(proposed.getPendingActionId()).isNotNull();
assertThat(proposed.getPermissionCheck()).isEqualTo("pending:" + proposed.getPendingActionId());
assertThat(proposed.getUiApiOperation()).isEqualTo("POST /table/{tableName}");
assertThat(proposed.getHttpStatus()).isNull();
QContext.init(qInstance, AssistantTestFixture.sessionFor(ADMIN));
Map<String, Object> confirmed = new AssistantApi(config.getRuntime(), QContext.getQSession()).confirmAction(proposed.getPendingActionId());
assertThat(confirmed).containsEntry("status", AssistantPendingAction.STATUS_EXECUTED).containsEntry("outcome", "OK");
assertThat(requestsFor("blog_post")).hasSize(1);
assertThat(requestsFor("blog_post").get(0)).containsEntry("session", ADMIN).containsEntry("client", "assistant/" + outcome.turnId());
assertThat(requestsFor("blog_post").get(0).get("path")).endsWith("/table/blog_post");
List<AssistantToolCall> rows = runner.toolCalls(ADMIN, outcome.turnId());
assertThat(rows).hasSize(2);
AssistantToolCall ran = rows.stream().filter(row -> row.getHttpStatus() != null).findFirst().orElseThrow();
assertThat(ran.getToolName()).isEqualTo(UiApiToolbox.CREATE_RECORD);
assertThat(ran.getUserId()).isEqualTo(ADMIN);
assertThat(ran.getResultSummary()).startsWith("Confirmed: ");
assertThat(ran.getOutcome()).isEqualTo("OK");
assertThat(ran.getHttpStatus()).isBetween(200, 299);
assertThat(ran.getUiApiOperation()).isEqualTo("POST /table/{tableName}");
assertThat(ran.getPendingActionId()).isEqualTo(proposed.getPendingActionId());
assertThat(ran.getTableName()).isEqualTo("blog_post");
assertThat(ran.getClientChannel()).isEqualTo("assistant/" + outcome.turnId());
}
/*******************************************************************************
** The requests the UI API server saw for a table.
*******************************************************************************/
private static List<Map<String, String>> requestsFor(String tableName)
{
return (UiApiTestServer.SCHEDULED_REQUESTS.stream().filter(request -> request.get("path").contains("/table/" + tableName)).toList());
}
/*******************************************************************************
** Every tool-call log row there is, whoever it belongs to.
*******************************************************************************/
private List<AssistantToolCall> everyToolCall() throws Exception
{
QContext.init(qInstance, AssistantTestFixture.sessionFor(ADMIN));
try
{
return (config.getRuntime().getStore().query(AssistantToolCall.TABLE_NAME, new QQueryFilter(), AssistantToolCall.class));
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** The administrator's policy row.
*******************************************************************************/
private void policy(AssistantPolicy policy) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.sessionFor(ADMIN));
try
{
config.getRuntime().getStore().insert(policy);
config.getRuntime().getPolicyService().invalidate();
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** The administrator's data access row for a table.
*******************************************************************************/
private void setAccess(String tableName, DataAccessMode mode) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.sessionFor(ADMIN));
try
{
config.getRuntime().getStore().insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName(tableName).withMode(mode.name()));
config.getRuntime().getDataAccessService().invalidate();
}
finally
{
QContext.clear();
}
}
}
@@ -0,0 +1,289 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.registry.AssistantTool;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallRequest;
import com.kingsrook.qbits.assistant.tools.registry.ToolCheck;
import com.kingsrook.qbits.assistant.tools.registry.ToolFamily;
import com.kingsrook.qbits.assistant.tools.registry.ToolKind;
import com.kingsrook.qbits.assistant.tools.registry.ToolOffer;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Review fix 6: a tool call of a turn that ran has its tool-call log row,
** with what it in fact did, however the turn's batch of calls ended: the
** call ran past its time limit, its row could not be prepared the usual way,
** or the engine was interrupted while it ran. The three tools of the family
** registered here each do their work (a result that names an operation, its
** status and a pending action) and then make one of those happen.
*******************************************************************************/
class ToolCallRowsTest extends BaseAssistantTest
{
private static final String LATE_TOOL = "late_tool";
private static final String BAD_LOG_TOOL = "bad_log_tool";
private static final String STOPPING_TOOL = "stopping_tool";
/////////////////////////////////////////////////////////////////////////
// what each tool leaves behind: an obviously made-up pending action //
/////////////////////////////////////////////////////////////////////////
private static final Integer LEFT_BEHIND = 424_242;
/*******************************************************************************
** A one-second tool limit and the family of this test.
*******************************************************************************/
@Override
protected void customizeConfig(AssistantQBitConfig config)
{
config.withToolCallTimeoutSeconds(1);
config.withToolFamily(new RowsFamily());
}
/*******************************************************************************
** A call that did its work and then ran out of time: the model and the
** panel are told it took too long, and the row says what it did (before:
** "Timed out", with no operation, status or pending action).
*******************************************************************************/
@Test
void aCallThatRanAndThenTimedOutHasARowOfWhatItDid() throws Exception
{
provider.thenToolUse(LATE_TOOL, Map.of()).thenText("That took too long.");
TurnRunner.Outcome outcome = runner.run(EDITOR, "do the slow thing");
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
assertThat(outcome.events(TurnEvent.TOOL_RESULT)).singleElement().satisfies(result -> assertThat(result).containsEntry("outcome", "ERROR").containsEntry("summary", "Timed out"));
assertThat(outcome.toolCalls()).singleElement().satisfies(row ->
{
assertThat(row.getToolName()).isEqualTo(LATE_TOOL);
assertThat(row.getOutcome()).isEqualTo("OK");
assertThat(row.getResultSummary()).isEqualTo("Did it");
assertDidItsWork(row);
});
}
/*******************************************************************************
** A runtime exception while a row is prepared (here the tool's own
** inputForLog throws) costs neither that row nor the rows of the calls
** after it, and the turn goes on (before: the exception left the batch, so
** that call and every later one had no row).
*******************************************************************************/
@Test
void aRuntimeExceptionWhileLoggingCostsNoRow() throws Exception
{
provider.thenToolUses("Two steps.", List.of(new Object[] {BAD_LOG_TOOL, Map.of("note", "kept nowhere")}, new Object[] {UiApiToolbox.LIST_TABLES, Map.of()})).thenText("Done.");
TurnRunner.Outcome outcome = runner.run(EDITOR, "do both");
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
List<AssistantToolCall> rows = outcome.toolCalls().stream().sorted((a, b) -> a.getId().compareTo(b.getId())).toList();
assertThat(rows).extracting(AssistantToolCall::getToolName).containsExactly(BAD_LOG_TOOL, UiApiToolbox.LIST_TABLES);
assertThat(rows.get(0).getOutcome()).isEqualTo("OK");
assertThat(rows.get(0).getResultSummary()).isEqualTo("Did it");
assertThat(rows.get(0).getInputJson()).as("the input the tool could not prepare is not kept").isEqualTo("{}");
assertDidItsWork(rows.get(0));
assertThat(rows.get(1).getOutcome()).isEqualTo("OK");
}
/*******************************************************************************
** The engine is interrupted while a call runs (the person stops the
** answer): the call that ran still has its row, of what it did, and the
** turn ends as stopped (before: the interruption left the batch at once
** and no row was written).
*******************************************************************************/
@Test
void aCallThatRanWhileTheEngineWasInterruptedHasARow() throws Exception
{
provider.thenToolUse(STOPPING_TOOL, Map.of()).thenText("Never said.");
TurnRunner.Outcome outcome = runner.run(EDITOR, "do it, then I stop you");
assertThat(outcome.end()).containsEntry("status", "CANCELLED");
assertThat(outcome.toolCalls()).singleElement().satisfies(row ->
{
assertThat(row.getToolName()).isEqualTo(STOPPING_TOOL);
assertThat(row.getOutcome()).isEqualTo("OK");
assertThat(row.getResultSummary()).isEqualTo("Did it");
assertDidItsWork(row);
});
}
/*******************************************************************************
** The row carries what the call left behind.
*******************************************************************************/
private static void assertDidItsWork(AssistantToolCall row)
{
assertThat(row.getUserId()).isEqualTo(EDITOR);
assertThat(row.getTurnId()).isNotNull();
assertThat(row.getUiApiOperation()).isEqualTo("POST /somewhere");
assertThat(row.getHttpStatus()).isEqualTo(201);
assertThat(row.getPendingActionId()).isEqualTo(LEFT_BEHIND);
}
/*******************************************************************************
** The three tools.
*******************************************************************************/
private static final class RowsFamily implements ToolFamily
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<AssistantTool> tools(AssistantRuntime runtime)
{
return (List.of(new RowsTool(runtime, LATE_TOOL), new RowsTool(runtime, BAD_LOG_TOOL), new RowsTool(runtime, STOPPING_TOOL)));
}
}
/*******************************************************************************
** A tool that does its work, then makes its call end badly.
*******************************************************************************/
private static final class RowsTool implements AssistantTool
{
private final AssistantRuntime runtime;
private final String name;
/*******************************************************************************
**
*******************************************************************************/
private RowsTool(AssistantRuntime runtime, String name)
{
this.runtime = runtime;
this.name = name;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String name()
{
return (name);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ToolKind kind()
{
return (ToolKind.READ);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<ToolCheck> checks()
{
return (List.of(ToolCheck.TOOL_SWITCH));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ModelToolDefinition definition(ToolOffer offer)
{
return (offer.allows(name) ? new ModelToolDefinition().withName(name).withDescription("For tests.").withStrict(false).withInputSchema(Map.of("type", "object")) : null);
}
/*******************************************************************************
** The work is done first; then the late tool outlives its time limit (and
** hands its outcome back when it is interrupted), and the stopping tool
** stops its own turn, as the person's Stop does.
*******************************************************************************/
@Override
public ToolCallOutcome execute(ToolCallRequest request)
{
AiToolResult result = new AiToolResult().withToolCallId(request.toolCallId()).withToolName(name).withOutcome(AiToolOutcome.OK).withSummary("Did it").withModelContent("Done.");
ToolCallOutcome done = ToolCallOutcome.of(UiApiToolbox.Outcome.of(result, "POST /somewhere", 201, LEFT_BEHIND));
if(LATE_TOOL.equals(name))
{
try
{
Thread.sleep(30_000);
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
}
if(STOPPING_TOOL.equals(name))
{
runtime.getRunningTurn(request.scope().turnId()).cancel("USER");
}
return (done);
}
/*******************************************************************************
** One tool cannot say what its row keeps as input.
*******************************************************************************/
@Override
public Map<String, Object> inputForLog(Map<String, Object> input, AiToolResult result)
{
if(BAD_LOG_TOOL.equals(name))
{
throw (new IllegalStateException("boom"));
}
return (input);
}
}
}
@@ -0,0 +1,69 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import com.kingsrook.qbits.assistant.tools.registry.ToolCall;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallScope;
/*******************************************************************************
** The halves of a tool call that only the turn engine may use, for tests.
** Main code outside the engine's package cannot run a call without writing
** its row (execute, log and withdraw are package-private); a test that checks
** one half on its own gets it here.
*******************************************************************************/
public final class ToolCallServiceInternals
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ToolCallServiceInternals()
{
}
/*******************************************************************************
** Run one call after the service's checks, without its row.
*******************************************************************************/
public static ToolCallOutcome execute(ToolCallService service, ToolCall call, ToolCallScope scope)
{
return (service.execute(call, scope));
}
/*******************************************************************************
** Write the row of a call.
*******************************************************************************/
public static boolean log(ToolCallService service, ToolCall call, ToolCallScope scope, ToolCallOutcome outcome)
{
return (service.log(call, scope, outcome));
}
/*******************************************************************************
** Withdraw what a call proposed.
*******************************************************************************/
public static void withdraw(ToolCallService service, ToolCall call, ToolCallScope scope)
{
service.withdraw(call, scope);
}
}
@@ -0,0 +1,914 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.RecordComponent;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.RuntimeInternals;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.SignedInPerson;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.api.AssistantApiException;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.ToolCalls;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qbits.assistant.tools.Toolboxes;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.registry.AssistantTool;
import com.kingsrook.qbits.assistant.tools.registry.ConfirmedToolCall;
import com.kingsrook.qbits.assistant.tools.registry.ToolCall;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallRequest;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallScope;
import com.kingsrook.qbits.assistant.tools.registry.ToolCheck;
import com.kingsrook.qbits.assistant.tools.registry.ToolFamily;
import com.kingsrook.qbits.assistant.tools.registry.ToolKind;
import com.kingsrook.qbits.assistant.tools.registry.ToolOffer;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
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.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ADMIN;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ANALYST;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.LIMITED;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.NO_ACCESS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The tool-call service is safe for a caller that has no chat turn around
** it: everything a call needs is found out by the service itself (the policy,
** the gateway, whose the conversation is, the budget), before the tool is
** reached, and the call is logged by the service with its input redacted. A
** caller hands it a person and a call, or a person and a pending action's
** id, and nothing else. Each test calls the service directly, the way a later
** caller will, with nothing of the turn engine in between; the UI API test
** server sees every request the assistant makes, so "nothing ran" is what it
** did not see.
*******************************************************************************/
class ToolCallServiceTest extends BaseAssistantTest
{
/////////////////////////////////////////////////////////////////////////
// an obviously made-up number in the shape the input redactor removes //
/////////////////////////////////////////////////////////////////////////
private static final String FAKE_SSN = "123-45-6789";
private static final String THROWING_TOOL = "throwing_tool";
private static final String PROPOSING_TOOL = "proposing_tool";
/*******************************************************************************
** A registered family with a tool that always throws, and one that
** proposes a write whose confirmation throws.
*******************************************************************************/
@Override
protected void customizeConfig(AssistantQBitConfig config)
{
config.withToolFamily(new ThrowingFamily());
}
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void forgetEarlierRequests()
{
UiApiTestServer.SCHEDULED_REQUESTS.clear();
}
/*******************************************************************************
** The service refuses a tool the person is not offered now, whoever calls:
** a write tool for a person who may change no table, and a tool the administrator's
** policy switched off (the kernel's navigate has no policy check of its
** own). The refusal is the one of a name without a tool, and nothing is
** requested. A tool the same policy allows runs.
*******************************************************************************/
@Test
void aToolThatIsNotOfferedIsRefusedByTheServiceItself() throws Exception
{
assertUnknownTool(execute(EDITOR, UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "Never proposed"))), UiApiToolbox.CREATE_RECORD);
policy(new AssistantPolicy().withName("default").withAllowedTools(UiApiToolbox.LIST_TABLES));
assertUnknownTool(execute(EDITOR, "navigate", input("target", "blog_post")), "navigate");
assertUnknownTool(execute(EDITOR, UiApiToolbox.COUNT_RECORDS, input("table", "blog_post", "filter", filter("AND"))), UiApiToolbox.COUNT_RECORDS);
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).as("nothing was requested of a table (what the person may change is asked of the UI API, as the person, to decide the offer)")
.noneMatch(seen -> seen.get("path").contains("/table/")).allSatisfy(seen -> assertThat(seen).containsEntry("session", EDITOR));
UiApiTestServer.SCHEDULED_REQUESTS.clear();
assertUnknownTool(execute(EDITOR, UiApiToolbox.COUNT_RECORDS, input("table", "blog_post", "filter", filter("AND"))), UiApiToolbox.COUNT_RECORDS);
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).as("with every write tool switched off by the policy, nothing at all is requested").isEmpty();
ToolCallOutcome allowed = execute(EDITOR, UiApiToolbox.LIST_TABLES, Map.of());
assertThat(allowed.result().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).isNotEmpty().allSatisfy(seen -> assertThat(seen).containsEntry("session", EDITOR));
}
/*******************************************************************************
** Review fix 1: a caller cannot supply a policy. What a caller hands the
** service has no place for one, and no entry point of the service takes a
** policy, a tool context or a gateway; the policy a call runs under is the
** administrator's row, read by the service.
*******************************************************************************/
@Test
void aCallerCannotSupplyAPolicy() throws Exception
{
assertThat(Arrays.stream(ToolCall.class.getRecordComponents()).map(RecordComponent::getName)).containsExactly("person", "toolCallId", "toolName", "input");
for(Method method : ToolCallService.class.getDeclaredMethods())
{
if(Modifier.isPublic(method.getModifiers()))
{
assertThat(method.getParameterTypes()).as(method.getName() + " takes nothing the service decides itself")
.doesNotContain(PolicyService.ToolPolicy.class, PolicyService.EffectivePolicy.class, UiApiToolbox.Context.class, UiApiGateway.class, UiApiCredentials.class, QSession.class,
ToolCallRequest.class, ConfirmedToolCall.class, UiApiToolbox.PendingActions.class);
}
}
///////////////////////////////////////////////////////////////////////
// the administrator's row decides: one row per call, two tools //
///////////////////////////////////////////////////////////////////////
policy(new AssistantPolicy().withName("default").withAllowedTools(UiApiToolbox.LIST_TABLES + "," + UiApiToolbox.QUERY_RECORDS).withMaxRowsPerCall(1));
ToolCallOutcome queried = execute(EDITOR, UiApiToolbox.QUERY_RECORDS, input("table", "blog_post", "filter", filter("AND")));
assertThat(queried.result().getOutcome()).as(queried.result().getModelContent()).isEqualTo(AiToolOutcome.OK);
assertThat(queried.result().getRowCount()).as("the administrator's row cap").isEqualTo(1);
assertUnknownTool(execute(EDITOR, UiApiToolbox.COUNT_RECORDS, input("table", "blog_post", "filter", filter("AND"))), UiApiToolbox.COUNT_RECORDS);
}
/*******************************************************************************
** Review fix 1: a write always produces a proposal. Nothing can switch the
** confirmation off: the administrator's old setting (the column is still
** there) is not read any more, so the write tool only records a pending
** action and the record is not created.
*******************************************************************************/
@Test
void aWriteAlwaysProducesAProposal() throws Exception
{
writable("blog_post");
policy(new AssistantPolicy().withName("default").withWritesRequireConfirmation(false));
ToolCallOutcome outcome = service().executeAndLog(ToolCalls.call(config, ADMIN, UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "Only proposed", "status", "DRAFT"))), null, false);
assertThat(outcome.result().getOutcome()).as(outcome.result().getModelContent()).isEqualTo(AiToolOutcome.OK);
assertThat(outcome.extras().getPendingActionId()).as("a proposal").isNotNull();
assertThat(outcome.extras().getHttpStatus()).as("no write was made").isNull();
assertThat(outcome.result().getModelContent()).contains("Proposed, not done yet");
assertThat(requestsFor("blog_post")).as("nothing was written").isEmpty();
assertThat(pendingAction(ADMIN, outcome.extras().getPendingActionId()).getStatus()).isEqualTo(AssistantPendingAction.STATUS_PENDING);
assertThat(everyToolCall()).singleElement().satisfies(row -> assertThat(row.getPendingActionId()).isEqualTo(outcome.extras().getPendingActionId()));
}
/*******************************************************************************
** The tools that read the policy's switch themselves still refuse when they
** are handed a context without a policy (no policy is not "every tool").
*******************************************************************************/
@Test
void aToolHandedNoPolicyRefuses()
{
UiApiToolbox.Context noPolicy = Toolboxes.context(EDITOR, "UTC", RuntimeInternals.gateway(config.getRuntime()), null, null);
AssistantRuntime runtime = config.getRuntime();
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
AiToolResult data = Toolboxes.execute(runtime, noPolicy, "call-1", UiApiToolbox.LIST_TABLES, Map.of()).getResult();
assertThat(data.getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(data.getPermissionCheck()).isEqualTo("policy:tool");
AiToolResult knowledge = Toolboxes.search(runtime, noPolicy, "call-2", Map.of("query", "posts"), DataClassification.PUBLIC, null, null);
assertThat(knowledge.getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).as("nothing was requested").isEmpty();
}
/*******************************************************************************
** Review fix 2: one call cannot mix two people. A person is made from a
** session alone (no forwarded credentials: the UI API gets that session's
** own id) or by the route from one HTTP request; there is no constructor
** that takes a session and credentials, and the service takes neither
** loose (see aCallerCannotSupplyAPolicy). Every request of a call is made
** as the call's person.
*******************************************************************************/
@Test
void aSessionWithAnotherPersonsCredentialsCannotBeMade() throws Exception
{
assertThat(SignedInPerson.class.getConstructors()).as("no public constructor").isEmpty();
assertThat(Arrays.stream(SignedInPerson.class.getDeclaredMethods()).filter(method -> Modifier.isPublic(method.getModifiers()) && Modifier.isStatic(method.getModifiers())))
.as("the only ways to get a person").allSatisfy(factory ->
{
assertThat(factory.getParameterTypes()).as(factory.getName() + " takes a request, or the instance and a session it then has resolved; never credentials")
.doesNotContain(UiApiCredentials.class).hasSizeLessThanOrEqualTo(2);
});
QSession analyst = AssistantTestFixture.ownerSession(ANALYST, config);
SignedInPerson person = SignedInPerson.ofSession(config.getRuntime().getQInstance(), analyst);
assertThat(person.session()).as("the person is the session the authentication module resolved, not the object passed in").isNotSameAs(analyst);
assertThat(person.session().getUuid()).isEqualTo(analyst.getUuid());
assertThat(person.credentials()).as("the session's own id is the credential").isNull();
assertThat(person.userId()).isEqualTo(ANALYST);
assertThat(person.toString()).contains(ANALYST);
assertThatThrownBy(() -> SignedInPerson.ofSession(config.getRuntime().getQInstance(), null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> SignedInPerson.ofRequest(null)).isInstanceOf(IllegalArgumentException.class);
ToolCallOutcome outcome = service().execute(new ToolCall(person, "call-1", UiApiToolbox.LIST_TABLES, Map.of()), ToolCalls.scope());
assertThat(outcome.result().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).isNotEmpty().allSatisfy(seen -> assertThat(seen).containsEntry("session", ANALYST));
}
/*******************************************************************************
** executeAndLog runs the call and writes its row itself, with the input
** redacted, for a call that ran, for one that was refused and for one whose
** tool threw. The caller prepares nothing.
*******************************************************************************/
@Test
void executeAndLogWritesTheRowWithRedactedInputAlsoForARefusedCall() throws Exception
{
Map<String, Object> input = input("table", "blog_post", "filter", filter("AND", "title", "EQUALS", List.of(FAKE_SSN)));
ToolCallOutcome ran = service().executeAndLog(ToolCalls.call(config, EDITOR, UiApiToolbox.COUNT_RECORDS, input), null, false);
assertThat(ran.result().getOutcome()).isEqualTo(AiToolOutcome.OK);
ToolCallOutcome threw = service().executeAndLog(ToolCalls.call(config, EDITOR, THROWING_TOOL, input("note", FAKE_SSN)), null, false);
assertThat(threw.result().getOutcome()).isEqualTo(AiToolOutcome.ERROR);
assertThat(threw.result().getModelContent()).doesNotContain("boom");
policy(new AssistantPolicy().withName("default").withAllowedTools(UiApiToolbox.LIST_TABLES));
ToolCallOutcome refused = service().executeAndLog(ToolCalls.call(config, EDITOR, UiApiToolbox.COUNT_RECORDS, input), null, false);
assertUnknownTool(refused, UiApiToolbox.COUNT_RECORDS);
List<AssistantToolCall> rows = everyToolCall();
assertThat(rows).hasSize(3).allSatisfy(row ->
{
assertThat(row.getUserId()).isEqualTo(EDITOR);
assertThat(row.getToolCallId()).isEqualTo("call-1");
assertThat(row.getClientChannel()).isEqualTo("assistant");
assertThat(row.getInputJson()).doesNotContain(FAKE_SSN);
});
AssistantToolCall ranRow = rows.get(0);
assertThat(ranRow.getToolName()).isEqualTo(UiApiToolbox.COUNT_RECORDS);
assertThat(ranRow.getOutcome()).isEqualTo("OK");
assertThat(ranRow.getInputJson()).contains("blog_post").contains("title");
assertThat(ranRow.getUiApiOperation()).isEqualTo("POST /table/{tableName}/count");
assertThat(ranRow.getHttpStatus()).isEqualTo(200);
AssistantToolCall threwRow = rows.get(1);
assertThat(threwRow.getToolName()).isEqualTo(THROWING_TOOL);
assertThat(threwRow.getOutcome()).isEqualTo("ERROR");
assertThat(threwRow.getResultSummary()).isEqualTo("Failed");
AssistantToolCall refusedRow = rows.get(2);
assertThat(refusedRow.getToolName()).isEqualTo(UiApiToolbox.COUNT_RECORDS);
assertThat(refusedRow.getOutcome()).isEqualTo("INVALID_INPUT");
assertThat(refusedRow.getResultSummary()).isEqualTo("Unknown tool");
assertThat(refusedRow.getInputJson()).contains("blog_post");
assertThat(refusedRow.getHttpStatus()).isNull();
assertThat(requestsFor("blog_post")).as("only the call that was offered reached the UI API").hasSize(1);
}
/*******************************************************************************
** A call that names no person at all is refused and, having nobody to
** belong to, leaves no row; so is a call with no scope.
*******************************************************************************/
@Test
void aCallThatNamesNoPersonIsRefusedAndHasNoRow() throws Exception
{
ToolCallOutcome nobody = service().executeAndLog(new ToolCall(null, "call-1", UiApiToolbox.LIST_TABLES, Map.of()), null, false);
assertRefused(nobody, ToolCallService.CHECK_REQUEST);
assertThat(service().executeAndLog(null, null, false).result().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertRefused(service().execute(ToolCalls.call(config, EDITOR, UiApiToolbox.LIST_TABLES, Map.of()), null), ToolCallService.CHECK_REQUEST);
assertThat(everyToolCall()).isEmpty();
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).isEmpty();
}
/*******************************************************************************
** Review fix 3: the hard budget stops a call that no turn admitted, for the
** person who is over it and for nobody else, and it is read on every call:
** a scope that was let through before the limit is refused after it.
*******************************************************************************/
@Test
void theHardBudgetIsCheckedByTheServiceOnEveryCall() throws Exception
{
provider.thenText("Hello.");
runner.run(EDITOR, "Hello");
UiApiTestServer.SCHEDULED_REQUESTS.clear();
ToolCall call = ToolCalls.call(config, EDITOR, UiApiToolbox.LIST_TABLES, Map.of());
ToolCallScope admitted = ToolCalls.scope();
assertThat(service().execute(call, admitted).result().getOutcome()).isEqualTo(AiToolOutcome.OK);
UiApiTestServer.SCHEDULED_REQUESTS.clear();
config.getBudgets().withUserDailyTokensHard(1L);
ToolCallOutcome afterTheLimit = service().execute(call, admitted);
assertRefused(afterTheLimit, ToolCallService.CHECK_BUDGET);
assertThat(afterTheLimit.result().getModelContent()).contains("daily assistant usage limit");
assertRefused(execute(EDITOR, UiApiToolbox.LIST_TABLES, Map.of()), ToolCallService.CHECK_BUDGET);
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).as("nothing was requested").isEmpty();
assertThat(execute(LIMITED, UiApiToolbox.LIST_TABLES, Map.of()).result().getPermissionCheck())
.as("a person who has used nothing").isNotEqualTo(ToolCallService.CHECK_BUDGET);
}
/*******************************************************************************
** Review fix 4: a write with no stored pending action cannot be executed.
** The one way to run a write is confirm(person, id): an id that names no
** action of that person (none at all, or another person's) is refused, and
** the other person's action stays pending and unwritten.
*******************************************************************************/
@Test
void aWriteWithNoStoredPendingActionOfThePersonCannotBeExecuted() throws Exception
{
Integer actionId = proposeAPost();
UiApiTestServer.SCHEDULED_REQUESTS.clear();
assertConfirmationRefused(ADMIN, 987_654, ToolCallService.ConfirmationRefused.Reason.NOT_FOUND);
assertConfirmationRefused(ADMIN, null, ToolCallService.ConfirmationRefused.Reason.NOT_FOUND);
assertConfirmationRefused(EDITOR, actionId, ToolCallService.ConfirmationRefused.Reason.NOT_FOUND);
assertThat(pendingAction(ADMIN, actionId).getStatus()).isEqualTo(AssistantPendingAction.STATUS_PENDING);
assertThat(requestsFor("blog_post")).as("nothing was written").isEmpty();
assertThat(everyToolCall()).as("only the proposal's row").hasSize(1);
}
/*******************************************************************************
** Review fix 4: a confirm runs the stored proposal (the caller names only
** the person and the id), marks the action and writes its own row; it runs
** once.
*******************************************************************************/
@Test
void aConfirmRunsTheStoredProposalOnceAndWritesItsOwnRow() throws Exception
{
Integer actionId = proposeAPost();
UiApiTestServer.SCHEDULED_REQUESTS.clear();
ToolCallService.Confirmation confirmation = service().confirm(SignedInPerson.ofSession(config.getRuntime().getQInstance(), AssistantTestFixture.sessionFor(ADMIN)), actionId);
assertThat(confirmation.outcome().getResult().getOutcome()).as(confirmation.outcome().getResult().getModelContent()).isEqualTo(AiToolOutcome.OK);
assertThat(confirmation.action().getStatus()).isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
assertThat(requestsFor("blog_post")).as("the write, as the confirming person").isNotEmpty().allSatisfy(seen -> assertThat(seen).containsEntry("session", ADMIN));
List<AssistantToolCall> rows = everyToolCall();
assertThat(rows).as("the proposal's row and the confirmation's").hasSize(2);
AssistantToolCall row = rows.get(1);
assertThat(row.getPendingActionId()).isEqualTo(actionId);
assertThat(row.getUserId()).isEqualTo(ADMIN);
assertThat(row.getToolName()).isEqualTo(UiApiToolbox.CREATE_RECORD);
assertThat(row.getTableName()).isEqualTo("blog_post");
assertThat(row.getOutcome()).isEqualTo("OK");
assertThat(row.getResultSummary()).startsWith("Confirmed: ");
assertThat(row.getUiApiOperation()).isEqualTo("POST /table/{tableName}");
assertThat(row.getHttpStatus()).isBetween(200, 299);
assertThat(row.getInputJson()).contains("Proposed in a turn");
assertConfirmationRefused(ADMIN, actionId, ToolCallService.ConfirmationRefused.Reason.NOT_PENDING);
assertThat(everyToolCall()).as("no second write, no second row").hasSize(2);
}
/*******************************************************************************
** Review fix 7: a confirmed action never sticks in RUNNING. A tool that
** throws, and stored values that cannot be read, both end in FAILED with a
** log row; what went wrong is not shown.
*******************************************************************************/
@Test
void aConfirmWhoseToolThrowsEndsFailedWithARow() throws Exception
{
ToolCallOutcome proposed = service().executeAndLog(ToolCalls.call(config, EDITOR, PROPOSING_TOOL, Map.of()), null, false);
Integer actionId = proposed.extras().getPendingActionId();
assertThat(actionId).isNotNull();
ToolCallService.Confirmation confirmation = service().confirm(SignedInPerson.ofSession(config.getRuntime().getQInstance(), AssistantTestFixture.sessionFor(EDITOR)), actionId);
assertThat(confirmation.outcome().getResult().getOutcome()).isEqualTo(AiToolOutcome.ERROR);
assertThat(confirmation.outcome().getResult().getModelContent()).doesNotContain("boom");
assertThat(confirmation.action().getStatus()).isEqualTo(AssistantPendingAction.STATUS_FAILED);
assertThat(pendingAction(EDITOR, actionId).getStatus()).isEqualTo(AssistantPendingAction.STATUS_FAILED);
List<AssistantToolCall> rows = everyToolCall();
assertThat(rows).hasSize(2);
assertThat(rows.get(1).getPendingActionId()).isEqualTo(actionId);
assertThat(rows.get(1).getOutcome()).isEqualTo("ERROR");
assertThat(rows.get(1).getResultSummary()).isEqualTo("Confirmed: Failed");
assertThat(rows.get(1).getResultJson()).doesNotContain("boom");
///////////////////////////////////////////////////////////////////////
// stored values that are not JSON any more //
///////////////////////////////////////////////////////////////////////
Integer unreadable = service().executeAndLog(ToolCalls.call(config, EDITOR, PROPOSING_TOOL, Map.of()), null, false).extras().getPendingActionId();
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
try
{
config.getRuntime().getStore().updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", unreadable).withValue("paramsJson", "{not json"));
}
finally
{
QContext.clear();
}
ToolCallService.Confirmation second = service().confirm(SignedInPerson.ofSession(config.getRuntime().getQInstance(), AssistantTestFixture.sessionFor(EDITOR)), unreadable);
assertThat(second.outcome().getResult().getOutcome()).isEqualTo(AiToolOutcome.ERROR);
assertThat(pendingAction(EDITOR, unreadable).getStatus()).isEqualTo(AssistantPendingAction.STATUS_FAILED);
assertThat(everyToolCall()).filteredOn(row -> unreadable.equals(row.getPendingActionId()) && "ERROR".equals(row.getOutcome())).hasSize(1);
}
/*******************************************************************************
** A write the person confirms while the kill switch is on is refused before
** the pending action is claimed, so it stays pending and can be confirmed
** once the switch is off; through the route and by the service itself.
*******************************************************************************/
@Test
void aConfirmationWhileTheKillSwitchIsOnIsRefusedAndTheActionStaysPending() throws Exception
{
Integer actionId = proposeAPost();
policy(new AssistantPolicy().withName("default").withKillSwitch(true));
UiApiTestServer.SCHEDULED_REQUESTS.clear();
QSession admin = AssistantTestFixture.sessionFor(ADMIN);
AssistantApi api = new AssistantApi(config.getRuntime(), admin);
QContext.init(qInstance, admin);
assertThatThrownBy(() -> api.confirmAction(actionId)).isInstanceOfSatisfying(AssistantApiException.class, e ->
{
assertThat(e.getStatus()).isEqualTo(503);
assertThat(e.getCode()).isEqualTo("KILL_SWITCH");
});
QContext.init(qInstance, admin);
assertThat(api.getAction(actionId)).containsEntry("status", AssistantPendingAction.STATUS_PENDING);
assertThat(requestsFor("blog_post")).as("nothing was written").isEmpty();
assertThat(everyToolCall()).as("only the proposal's row").hasSize(1);
QContext.clear();
assertConfirmationRefused(ADMIN, actionId, ToolCallService.ConfirmationRefused.Reason.KILL_SWITCH);
assertThat(pendingAction(ADMIN, actionId).getStatus()).isEqualTo(AssistantPendingAction.STATUS_PENDING);
assertThat(requestsFor("blog_post")).as("nothing was written").isEmpty();
}
/*******************************************************************************
** So is a confirmation of a tool the policy has switched off since the
** write was proposed.
*******************************************************************************/
@Test
void aConfirmationOfAToolThePolicySwitchedOffIsRefusedAndTheActionStaysPending() throws Exception
{
Integer actionId = proposeAPost();
policy(new AssistantPolicy().withName("default").withAllowedTools(UiApiToolbox.LIST_TABLES));
UiApiTestServer.SCHEDULED_REQUESTS.clear();
QSession admin = AssistantTestFixture.sessionFor(ADMIN);
AssistantApi api = new AssistantApi(config.getRuntime(), admin);
QContext.init(qInstance, admin);
assertThatThrownBy(() -> api.confirmAction(actionId)).isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(403));
QContext.init(qInstance, admin);
assertThat(api.getAction(actionId)).containsEntry("status", AssistantPendingAction.STATUS_PENDING);
assertThat(requestsFor("blog_post")).as("nothing was written").isEmpty();
QContext.clear();
assertConfirmationRefused(ADMIN, actionId, ToolCallService.ConfirmationRefused.Reason.NOT_ALLOWED);
assertThat(pendingAction(ADMIN, actionId).getStatus()).isEqualTo(AssistantPendingAction.STATUS_PENDING);
}
/*******************************************************************************
** With no chat turn around the call, the service itself requires assistant
** access, and checks it before the kill switch: a person without access is
** told so even while the switch is on. A confirmation needs it too.
*******************************************************************************/
@Test
void assistantAccessIsRequiredByTheService() throws Exception
{
assertRefused(execute(NO_ACCESS, UiApiToolbox.LIST_TABLES, Map.of()), ToolCallService.CHECK_ACCESS);
policy(new AssistantPolicy().withName("default").withKillSwitch(true));
assertRefused(execute(NO_ACCESS, UiApiToolbox.LIST_TABLES, Map.of()), ToolCallService.CHECK_ACCESS);
assertConfirmationRefused(NO_ACCESS, 1, ToolCallService.ConfirmationRefused.Reason.NO_ACCESS);
assertThatThrownBy(() -> service().confirm(null, 1)).isInstanceOfSatisfying(ToolCallService.ConfirmationRefused.class,
e -> assertThat(e.getReason()).isEqualTo(ToolCallService.ConfirmationRefused.Reason.NO_ACCESS));
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).as("nothing was requested").isEmpty();
}
/*******************************************************************************
** The kill switch stops a call that no turn admitted.
*******************************************************************************/
@Test
void theKillSwitchIsCheckedByTheService() throws Exception
{
assertThat(execute(EDITOR, UiApiToolbox.LIST_TABLES, Map.of()).result().getOutcome()).isEqualTo(AiToolOutcome.OK);
UiApiTestServer.SCHEDULED_REQUESTS.clear();
policy(new AssistantPolicy().withName("default").withKillSwitch(true));
ToolCallOutcome outcome = execute(EDITOR, UiApiToolbox.LIST_TABLES, Map.of());
assertRefused(outcome, ToolCallService.CHECK_KILL_SWITCH);
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).as("nothing was requested").isEmpty();
}
/*******************************************************************************
** Review fix 8: the scope is checked, not believed. A call that names
** another person's conversation, or a turn that is not the person's, is
** refused before anything is requested, and its row does not carry the
** other person's conversation. The person's own conversation is accepted
** and is on the row.
*******************************************************************************/
@Test
void aScopeNamingSomeoneElsesConversationIsRefused() throws Exception
{
Integer editors = conversationOf(EDITOR);
Integer analysts = conversationOf(ANALYST);
ToolCallOutcome someoneElses = service().executeAndLog(ToolCalls.call(config, ANALYST, UiApiToolbox.LIST_TABLES, Map.of()), editors, false);
assertRefused(someoneElses, ToolCallService.CHECK_REQUEST);
assertThat(someoneElses.result().getModelContent()).contains("not this person's");
assertRefused(service().execute(ToolCalls.call(config, ANALYST, UiApiToolbox.LIST_TABLES, Map.of()), ToolCalls.scope(editors, null)), ToolCallService.CHECK_REQUEST);
assertRefused(service().execute(ToolCalls.call(config, ANALYST, UiApiToolbox.LIST_TABLES, Map.of()), ToolCalls.scope(analysts, 987_654)), ToolCallService.CHECK_REQUEST);
assertRefused(service().execute(ToolCalls.call(config, ANALYST, UiApiToolbox.LIST_TABLES, Map.of()), ToolCalls.scope(null, 987_654)), ToolCallService.CHECK_REQUEST);
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).as("nothing was requested").isEmpty();
ToolCallOutcome own = service().executeAndLog(ToolCalls.call(config, ANALYST, UiApiToolbox.LIST_TABLES, Map.of()), analysts, false);
assertThat(own.result().getOutcome()).isEqualTo(AiToolOutcome.OK);
List<AssistantToolCall> rows = everyToolCall();
assertThat(rows).hasSize(2).allSatisfy(row -> assertThat(row.getUserId()).isEqualTo(ANALYST));
assertThat(rows.get(0).getConversationId()).as("a refused call's row never names another person's conversation").isNull();
assertThat(rows.get(0).getOutcome()).isEqualTo("DENIED");
assertThat(rows.get(1).getConversationId()).isEqualTo(analysts);
assertThat(rows.get(1).getClientChannel()).isEqualTo("assistant");
}
/*******************************************************************************
** Review fix 9: a tool family registered after the QBit was produced would
** never be in the registry; the config says so instead of ignoring it.
*******************************************************************************/
@Test
void aToolFamilyRegisteredAfterTheQBitIsProducedIsRefused()
{
assertThat(config.getRuntime()).isNotNull();
assertThatThrownBy(() -> config.withToolFamily(new ThrowingFamily())).isInstanceOf(IllegalStateException.class).hasMessageContaining("before the QBit is produced");
assertThat(config.getToolFamilies()).hasSize(1);
assertThat(new AssistantQBitConfig().withToolFamily(new ThrowingFamily()).getToolFamilies()).hasSize(1);
}
/*******************************************************************************
**
*******************************************************************************/
private ToolCallService service()
{
return (config.getRuntime().getToolCalls());
}
/*******************************************************************************
** A call of a person's, in a scope of its own, with no turn and no row.
*******************************************************************************/
private ToolCallOutcome execute(String person, String toolName, Map<String, Object> input)
{
return (service().execute(ToolCalls.call(config, person, toolName, input), ToolCalls.scope()));
}
/*******************************************************************************
** A refusal by one of the service's own checks.
*******************************************************************************/
private static void assertRefused(ToolCallOutcome outcome, String check)
{
assertThat(outcome.extras()).isNull();
assertThat(outcome.result().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(outcome.result().getPermissionCheck()).isEqualTo(check);
}
/*******************************************************************************
** The refusal of a tool that is not offered: the one of an unknown name.
*******************************************************************************/
private static void assertUnknownTool(ToolCallOutcome outcome, String toolName)
{
assertThat(outcome.extras()).as(toolName).isNull();
assertThat(outcome.result().getOutcome()).as(toolName).isEqualTo(AiToolOutcome.INVALID_INPUT);
assertThat(outcome.result().getSummary()).as(toolName).isEqualTo("Unknown tool");
assertThat(outcome.result().getModelContent()).isEqualTo("Unknown tool \"" + toolName + "\". Use only the tools provided.");
}
/*******************************************************************************
** A confirmation the service refuses, for that reason.
*******************************************************************************/
private void assertConfirmationRefused(String person, Integer actionId, ToolCallService.ConfirmationRefused.Reason reason)
{
assertThatThrownBy(() -> service().confirm(SignedInPerson.ofSession(config.getRuntime().getQInstance(), AssistantTestFixture.sessionFor(person)), actionId))
.isInstanceOfSatisfying(ToolCallService.ConfirmationRefused.class, e -> assertThat(e.getReason()).isEqualTo(reason));
}
/*******************************************************************************
** Let the assistant write to a table.
*******************************************************************************/
private void writable(String tableName) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.sessionFor(ADMIN));
try
{
config.getRuntime().getStore().insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName(tableName).withMode(DataAccessMode.READ_WRITE.name()));
config.getRuntime().getDataAccessService().invalidate();
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** A write the administrator's turn proposed: the pending action's id.
*******************************************************************************/
private Integer proposeAPost() throws Exception
{
writable("blog_post");
provider.thenToolUse(UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "Proposed in a turn", "status", "DRAFT"))).thenText("Please confirm.");
TurnRunner.Outcome outcome = runner.run(ADMIN, "Create a draft post");
assertThat(outcome.toolCalls()).hasSize(1);
assertThat(outcome.toolCalls().get(0).getPendingActionId()).isNotNull();
return (outcome.toolCalls().get(0).getPendingActionId());
}
/*******************************************************************************
** A new conversation of a person's: its id.
*******************************************************************************/
private Integer conversationOf(String person) throws Exception
{
QSession session = AssistantTestFixture.sessionFor(person);
QContext.init(qInstance, session);
try
{
return (((Number) new AssistantApi(config.getRuntime(), session).createConversation(Map.of("title", "Of " + person)).get("id")).intValue());
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** A person's pending action, as it is stored now.
*******************************************************************************/
private AssistantPendingAction pendingAction(String person, Integer actionId) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(person, config));
try
{
return (config.getRuntime().getStore().getPendingAction(person, actionId));
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** The requests the UI API server saw for a table.
*******************************************************************************/
private static List<Map<String, String>> requestsFor(String tableName)
{
return (UiApiTestServer.SCHEDULED_REQUESTS.stream().filter(request -> request.get("path").contains("/table/" + tableName)).toList());
}
/*******************************************************************************
** Every tool-call log row there is, whoever it belongs to, oldest first.
*******************************************************************************/
private List<AssistantToolCall> everyToolCall() throws Exception
{
QContext.init(qInstance, AssistantTestFixture.sessionFor(ADMIN));
try
{
return (config.getRuntime().getStore().query(AssistantToolCall.TABLE_NAME, new QQueryFilter(), AssistantToolCall.class).stream()
.sorted((a, b) -> a.getId().compareTo(b.getId())).toList());
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** The administrator's policy row.
*******************************************************************************/
private void policy(AssistantPolicy policy) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.sessionFor(ADMIN));
try
{
config.getRuntime().getStore().insert(policy);
config.getRuntime().getPolicyService().invalidate();
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** A registered family with two tools: one that throws, and one that
** proposes a write whose confirmation throws.
*******************************************************************************/
private static final class ThrowingFamily implements ToolFamily
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<AssistantTool> tools(AssistantRuntime runtime)
{
return (List.of(new TestTool(THROWING_TOOL), new TestTool(PROPOSING_TOOL)));
}
}
/*******************************************************************************
** One of the family's tools.
*******************************************************************************/
private static final class TestTool implements AssistantTool
{
private final String name;
/*******************************************************************************
**
*******************************************************************************/
private TestTool(String name)
{
this.name = name;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String name()
{
return (name);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ToolKind kind()
{
return (PROPOSING_TOOL.equals(name) ? ToolKind.PROPOSE : ToolKind.READ);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<ToolCheck> checks()
{
return (PROPOSING_TOOL.equals(name) ? List.of(ToolCheck.TOOL_SWITCH, ToolCheck.CONFIRMATION) : List.of(ToolCheck.TOOL_SWITCH));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ModelToolDefinition definition(ToolOffer offer)
{
return (offer.allows(name) ? new ModelToolDefinition().withName(name).withDescription("For tests.").withStrict(false).withInputSchema(Map.of("type", "object")) : null);
}
/*******************************************************************************
** One throws; the other proposes a write in the store the service gave it.
*******************************************************************************/
@Override
public ToolCallOutcome execute(ToolCallRequest request)
{
if(THROWING_TOOL.equals(name))
{
throw (new IllegalStateException("boom"));
}
try
{
Integer actionId = request.context().pendingActions().propose(new UiApiToolbox.ProposedWrite(request.toolCallId(), name, "POST /nowhere", "blog_post", null, Map.of("title", "x"), "A write whose confirmation throws"));
AiToolResult result = new AiToolResult().withToolCallId(request.toolCallId()).withToolName(name).withOutcome(AiToolOutcome.OK).withSummary("Proposed").withModelContent("Proposed.");
return (ToolCallOutcome.of(UiApiToolbox.Outcome.of(result, "POST /nowhere", null, actionId)));
}
catch(QException e)
{
throw (new IllegalStateException(e));
}
}
/*******************************************************************************
** The confirmation of the proposing tool's write throws.
*******************************************************************************/
@Override
public UiApiToolbox.Outcome executeConfirmed(ConfirmedToolCall call)
{
throw (new IllegalStateException("boom"));
}
}
}
@@ -86,8 +86,13 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
@Tag("redteam")
class TranscriptRedactionTest extends BaseAssistantTest
{
private final String phi = "CANARY-PHI-" + UUID.randomUUID();
private final String pii = "CANARY-PII-" + UUID.randomUUID();
///////////////////////////////////////////////////////////////////////////
// random, and without digits: the scan looks for every five characters //
// of a canary, and five digits in a row also occur by chance in the //
// fraction of a second of a stored timestamp, which is not a leak //
///////////////////////////////////////////////////////////////////////////
private final String phi = "CANARY-PHI-" + UUID.randomUUID().toString().replaceAll("[0-9]", "q");
private final String pii = "CANARY-PII-" + UUID.randomUUID().toString().replaceAll("[0-9]", "q");
@@ -21,29 +21,23 @@ import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
import com.kingsrook.qbits.assistant.model.AssistantProfile;
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantSkill;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.processes.DataAccessDefaultsProcess;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The home's Setup card and the "Add rows for every table" action.
** The home's Setup card.
*******************************************************************************/
class AssistantSetupTest extends BaseAssistantTest
{
@@ -62,7 +56,9 @@ class AssistantSetupTest extends BaseAssistantTest
assertThat(items.get("Budgets").nextStep()).contains("monthly instance budget");
assertThat(items.get("Profiles").state()).isEqualTo(AssistantSetupStatus.State.DEFAULT);
assertThat(items.get("Data access").state()).isEqualTo(AssistantSetupStatus.State.DEFAULT);
assertThat(items.get("Data access").nextStep()).contains("Add rows for every table");
assertThat(items.get("Data access").detail()).contains("propose the changes that person may make themselves");
assertThat(items.get("Data access").nextStep()).contains("only to restrict");
assertThat(qInstance.getProcess("assistantDataAccessAddDefaults")).as("the action that wrote the defaults out as rows is retired").isNull();
assertThat(items.get("Knowledge sources").state()).isEqualTo(AssistantSetupStatus.State.OPTIONAL);
AssistantStore store = adminStore();
@@ -118,56 +114,6 @@ class AssistantSetupTest extends BaseAssistantTest
/*******************************************************************************
** The action writes the defaults out as rows (READ, or NONE for sensitive
** tables), keeps existing rows, adds nothing the second time, and the
** written rows are what DataAccessService then reads.
*******************************************************************************/
@Test
void addRowsForEveryTable() throws Exception
{
AssistantStore store = adminStore();
store.insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName("media")
.withMode(DataAccessMode.READ_WRITE.name()));
runAction();
List<AssistantDataAccess> rows = adminStore().query(AssistantDataAccess.TABLE_NAME, new QQueryFilter(), AssistantDataAccess.class);
long appTables = qInstance.getTables().keySet().stream().filter(t -> !t.startsWith("assistant_")).count();
assertThat(rows).hasSize((int) appTables);
assertThat(rows).noneMatch(r -> r.getObjectName().startsWith("assistant_"));
assertThat(rows).filteredOn(r -> r.getObjectName().equals("media")).singleElement()
.extracting(AssistantDataAccess::getMode).isEqualTo("READ_WRITE");
for(AssistantDataAccess row : rows)
{
if(!"media".equals(row.getObjectName()))
{
assertThat(row.getNotes()).isEqualTo(DataAccessDefaultsProcess.NOTE);
assertThat(config.getRuntime().getDataAccessService().forTable(row.getObjectName()).source()).as(row.getObjectName()).isEqualTo("setting");
assertThat(config.getRuntime().getDataAccessService().forTable(row.getObjectName()).mode().name()).isEqualTo(row.getMode());
}
}
assertThat(rows).anyMatch(r -> "READ".equals(r.getMode()));
runAction();
assertThat(adminStore().query(AssistantDataAccess.TABLE_NAME, new QQueryFilter(), AssistantDataAccess.class)).hasSize(rows.size());
}
/*******************************************************************************
**
*******************************************************************************/
private void runAction() throws Exception
{
adminStore();
RunProcessInput input = new RunProcessInput();
input.setProcessName(DataAccessDefaultsProcess.NAME);
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
new RunProcessAction().execute(input);
}
/*******************************************************************************
**
*******************************************************************************/
@@ -99,6 +99,7 @@ class SchemaForeignKeyPostgresTest extends BaseAssistantTest
rule("assistant_client_error", "turn_id", "assistant_turn", "n");
rule("assistant_pending_action", "conversation_id", "assistant_conversation", "c");
rule("assistant_pending_action", "turn_id", "assistant_turn", "n");
rule("assistant_pending_action_claim", "pending_action_id", "assistant_pending_action", "c");
rule("assistant_data_audit", "conversation_id", "assistant_conversation", "n");
rule("assistant_data_audit", "turn_id", "assistant_turn", "n");
rule("assistant_data_audit", "provider_id", "assistant_provider", "a");
@@ -22,6 +22,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import com.kingsrook.qbits.assistant.RuntimeInternals;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.TurnClassification;
@@ -45,6 +46,7 @@ import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
import com.kingsrook.qbits.assistant.tools.Toolboxes;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
@@ -61,8 +63,8 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@@ -244,7 +246,7 @@ class RagTest extends BaseAssistantTest
private AiToolResult search(String user, DataClassification classification) throws Exception
{
as(user);
return (config.getRuntime().getKnowledgeTool().execute(config.getRuntime().getRagService().context(), "search-1", Map.of("query", "Drain upgrade verify", "limit", 8), classification, null, null));
return (Toolboxes.search(config.getRuntime(), config.getRuntime().getRagService().context(), "search-1", Map.of("query", "Drain upgrade verify", "limit", 8), classification, null, null));
}
@@ -318,7 +320,7 @@ class RagTest extends BaseAssistantTest
assertThat(vectors.chunks(source.getId())).allSatisfy(v -> assertThat(v.contentHash()).matches("[a-f0-9]{64}"));
assertThat(ChunkVector.class.getRecordComponents()).extracting(java.lang.reflect.RecordComponent::getName)
.containsExactly("source", "table", "pk", "field", "chunkIndex", "contentHash", "embedding");
assertThat(config.getRuntime().getUiApiGateway().invoke(new UiApiCall("DELETE", "/table/{tableName}/{primaryKey}")
assertThat(RuntimeInternals.gateway(config.getRuntime()).invoke(new UiApiCall("DELETE", "/table/{tableName}/{primaryKey}")
.withPathParam("tableName", "blog_post").withPathParam("primaryKey", "1").withSessionUUID(AssistantTestFixture.ADMIN)).status()).isEqualTo(200);
RagService.Result changed = rag.index(source);
assertThat(changed.errors()).as(changed.reason()).isZero();
@@ -340,7 +342,7 @@ class RagTest extends BaseAssistantTest
UiApiTestServer.serve(qInstance, config);
UiApiTestServer.SCHEDULED_REQUESTS.clear();
config.getRuntime().getContextBuilder().build(QContext.getQSession(), "model", null, List.of(), "question", Map.of(), PolicyService.ToolPolicy.defaults(), null, "42");
UiApiToolbox.Context context = new UiApiToolbox.Context("invalid-session", null, config.getRuntime().getUiApiGateway(),
UiApiToolbox.Context context = Toolboxes.context("invalid-session", null, RuntimeInternals.gateway(config.getRuntime()),
PolicyService.ToolPolicy.defaults(), null, UiApiCredentials.ofSessionUUID(AssistantTestFixture.EDITOR), "42");
assertThat(config.getRuntime().getRagService().read(context, "blog_post", "1").getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).hasSizeGreaterThanOrEqualTo(2).allSatisfy(request ->
@@ -361,15 +363,15 @@ class RagTest extends BaseAssistantTest
assertThat(allowed.getOutcome()).as(allowed.getModelContent()).isEqualTo(AiToolOutcome.OK);
assertThat(allowed.getRowCount()).isPositive();
assertThat(allowed.getModelContent()).contains("Drain, upgrade, verify.", "/app/blog_post/").doesNotContain(AssistantTestFixture.HIDDEN_SENTINEL, AssistantTestFixture.PASSWORD_SENTINEL);
assertThat(config.getRuntime().getKnowledgeTool().offered(PolicyService.ToolPolicy.defaults(), QContext.getQSession())).isTrue();
assertThat(Toolboxes.knowledge(config.getRuntime()).offered(PolicyService.ToolPolicy.defaults(), QContext.getQSession())).isTrue();
AiToolResult denied = search(AssistantTestFixture.ANALYST, DataClassification.PUBLIC);
assertThat(denied.getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(denied.getRowCount()).isZero();
assertThat(denied.getSummary()).doesNotEndWith("filtered 0");
assertThat(denied.getRecordRefs()).isEmpty();
assertThat(denied.getModelContent()).doesNotContain("blog_post", "Drain", "/app/");
assertThat(config.getRuntime().getKnowledgeTool().offered(PolicyService.ToolPolicy.defaults(), QContext.getQSession())).isFalse();
assertThat(config.getRuntime().getUiApiGateway().invoke(new UiApiCall("GET", "/table/{tableName}/{primaryKey}")
assertThat(Toolboxes.knowledge(config.getRuntime()).offered(PolicyService.ToolPolicy.defaults(), QContext.getQSession())).isFalse();
assertThat(RuntimeInternals.gateway(config.getRuntime()).invoke(new UiApiCall("GET", "/table/{tableName}/{primaryKey}")
.withPathParam("tableName", "blog_post").withPathParam("primaryKey", "1").withSessionUUID(AssistantTestFixture.ANALYST)).status()).isEqualTo(403);
}
@@ -393,7 +395,7 @@ class RagTest extends BaseAssistantTest
as(AssistantTestFixture.ADMIN);
config.getRuntime().getStore().insert(new AssistantDataAccess().withObjectType("TABLE").withObjectName("blog_post").withMode("NONE"));
assertThat(rag.validate(source)).anyMatch(s -> s.contains("NONE"));
assertThat(config.getRuntime().getKnowledgeTool().offered(PolicyService.ToolPolicy.defaults(), QContext.getQSession())).isFalse();
assertThat(Toolboxes.knowledge(config.getRuntime()).offered(PolicyService.ToolPolicy.defaults(), QContext.getQSession())).isFalse();
assertThat(search(AssistantTestFixture.EDITOR, DataClassification.PUBLIC).getRowCount()).isZero();
}
@@ -418,7 +420,7 @@ class RagTest extends BaseAssistantTest
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", embeddingProvider.getId()).withValue("hipaaEligible", true));
assertThat(config.getRuntime().getRagService().index(patient).errors()).isZero();
as(AssistantTestFixture.EDITOR);
AiToolResult result = config.getRuntime().getKnowledgeTool().execute(config.getRuntime().getRagService().context(), "phi", Map.of("query", "diagnosis", "tables", List.of("patient")), DataClassification.PUBLIC, null, null);
AiToolResult result = Toolboxes.search(config.getRuntime(), config.getRuntime().getRagService().context(), "phi", Map.of("query", "diagnosis", "tables", List.of("patient")), DataClassification.PUBLIC, null, null);
assertThat(result.getRowCount()).isPositive();
TurnClassification classification = new TurnClassification(config.getClassificationSource(), new AssistantConversation().withMaxClassification("PUBLIC"), null);
classification.observe(ModelPart.toolUse("phi", KnowledgeTool.NAME, Map.of()), result);
@@ -545,7 +547,7 @@ class RagTest extends BaseAssistantTest
qInstance.getAuthentication().withCustomizer(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(LockedSessions.class));
var table = qInstance.getTable("blog_post");
table.withRecordSecurityLock(new com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock().withFieldName("slug").withSecurityKeyType("rag_owner"));
assertThat(config.getRuntime().getUiApiGateway().invoke(new UiApiCall("PATCH", "/table/{tableName}/{primaryKey}")
assertThat(RuntimeInternals.gateway(config.getRuntime()).invoke(new UiApiCall("PATCH", "/table/{tableName}/{primaryKey}")
.withPathParam("tableName", "blog_post").withPathParam("primaryKey", "1").withSessionUUID(AssistantTestFixture.ADMIN)
.withJsonBody(Map.of("slug", AssistantTestFixture.EDITOR))).status()).isEqualTo(200);
RagService.Result indexed = config.getRuntime().getRagService().index(source);
@@ -556,7 +558,7 @@ class RagTest extends BaseAssistantTest
AiToolResult other = search(AssistantTestFixture.OTHER_EDITOR, DataClassification.PUBLIC);
assertThat(other.getRowCount()).isZero();
assertThat(other.getSummary()).doesNotEndWith("filtered 0");
Integer status = config.getRuntime().getUiApiGateway().invoke(new UiApiCall("GET", "/table/{tableName}/{primaryKey}")
Integer status = RuntimeInternals.gateway(config.getRuntime()).invoke(new UiApiCall("GET", "/table/{tableName}/{primaryKey}")
.withPathParam("tableName", "blog_post").withPathParam("primaryKey", "1").withSessionUUID(AssistantTestFixture.OTHER_EDITOR)).status();
assertThat(status).isIn(403, 404);
}
@@ -574,7 +576,7 @@ class RagTest extends BaseAssistantTest
source.setChunkChars(100);
source.setChunkOverlapChars(20);
config.getRuntime().getStore().update(source);
var gateway = config.getRuntime().getUiApiGateway();
var gateway = RuntimeInternals.gateway(config.getRuntime());
var update = new UiApiCall("PATCH", "/table/{tableName}/{primaryKey}").withPathParam("tableName", "blog_post").withPathParam("primaryKey", "1").withSessionUUID(AssistantTestFixture.ADMIN);
assertThat(gateway.invoke(update.withJsonBody(Map.of("content", "Knowledge ".repeat(40)))).status()).isEqualTo(200);
RagService.Result first = config.getRuntime().getRagService().index(source);
@@ -615,11 +617,11 @@ class RagTest extends BaseAssistantTest
KnowledgeIndexProcess.ClearReview clearReview = new KnowledgeIndexProcess.ClearReview();
clearReview.runOnePage(new RunBackendStepInput().withRecords(List.of(source.toQRecord())), clearOutput);
assertThat(clearReview.getProcessSummary(clearOutput, false)).hasSize(1);
var policy = new PolicyService.ToolPolicy(true, Set.of("search_help"), 50, true);
assertThat(config.getRuntime().getKnowledgeTool().offered(policy, QContext.getQSession())).isFalse();
var context = new UiApiToolbox.Context(AssistantTestFixture.ADMIN, null, config.getRuntime().getUiApiGateway(), policy, null);
assertThat(config.getRuntime().getKnowledgeTool().execute(context, "denied", Map.of("query", "x"), DataClassification.PUBLIC, null, null).getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(config.getRuntime().getKnowledgeTool().execute(config.getRuntime().getRagService().context(), "invalid", Map.of(), DataClassification.PUBLIC, null, null).getOutcome()).isEqualTo(AiToolOutcome.INVALID_INPUT);
var policy = new PolicyService.ToolPolicy(Set.of("search_help"), 50, true);
assertThat(Toolboxes.knowledge(config.getRuntime()).offered(policy, QContext.getQSession())).isFalse();
var context = Toolboxes.context(AssistantTestFixture.ADMIN, null, RuntimeInternals.gateway(config.getRuntime()), policy, null);
assertThat(Toolboxes.search(config.getRuntime(), context, "denied", Map.of("query", "x"), DataClassification.PUBLIC, null, null).getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(Toolboxes.search(config.getRuntime(), config.getRuntime().getRagService().context(), "invalid", Map.of(), DataClassification.PUBLIC, null, null).getOutcome()).isEqualTo(AiToolOutcome.INVALID_INPUT);
config.getBudgets().withUserDailyTokensHard(1L);
assertThatThrownBy(() -> new EmbeddingService(config.getRuntime()).embed(model.getId(), List.of("blocked"), DataClassification.PUBLIC, "blog_post", null, null)).hasMessageContaining("budget");
}
@@ -37,6 +37,7 @@ import java.util.concurrent.Callable;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.Executors;
import java.util.stream.Stream;
import com.kingsrook.qbits.assistant.RuntimeInternals;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.AssistantSecurity;
@@ -220,13 +221,37 @@ class ScheduledTasksTest extends BaseAssistantTest
/*******************************************************************************
** A scheduled turn is a turn: with no spending limit in force for the
** designated person it is refused before any model call, the run records
** why, and the next run after an administrator restores a limit works.
*******************************************************************************/
@Test
void aScheduledTurnIsRefusedWhileNoSpendingLimitIsInForce() throws Exception
{
AssistantTestFixture.withoutSpendingLimit(config);
provider.thenText("Today's report");
AssistantScheduledRun refused = scheduledRunner().run(task(), false);
assertThat(refused.getStatus()).isNotEqualTo("OK");
assertThat(refused.getError()).contains("no spending limit is in force");
assertThat(provider.getRequests()).as("no model was called").isEmpty();
admin();
config.getBudgets().setInstanceMonthlyCostMicrosHard(AssistantTestFixture.FIXTURE_SPENDING_LIMIT_MICROS);
AssistantScheduledRun answered = scheduledRunner().run(task(), true);
assertThat(answered.getStatus()).as(answered.getError()).isEqualTo("OK");
assertThat(answered.getSummary()).isEqualTo("Today's report");
}
/*******************************************************************************
** Scheduled summaries are derived from the redacted stored message, not live SSE.
*******************************************************************************/
@Test
void scheduledSummaryCanary() throws Exception
{
String marker = "CANARY-PII-" + UUID.randomUUID();
String marker = "CANARY-PII-" + UUID.randomUUID().toString().replaceAll("[0-9]", "q");
qInstance.getTable("page").getField("title").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
new UpdateAction().execute(new UpdateInput("page").withRecord(new QRecord().withValue("id", 1).withValue("title", marker)));
provider.thenToolUse("get_record", Map.of("table", "page", "id", "1")).thenText(marker);
@@ -589,7 +614,7 @@ class ScheduledTasksTest extends BaseAssistantTest
{
qInstance.getTable("blog_post").withRecordSecurityLock(new RecordSecurityLock()
.withFieldName("slug").withSecurityKeyType(config.getOwnerSecurityKeyType()));
assertThat(runtime.getUiApiGateway().invoke(new UiApiCall("PATCH", "/table/{tableName}/{primaryKey}")
assertThat(RuntimeInternals.gateway(runtime).invoke(new UiApiCall("PATCH", "/table/{tableName}/{primaryKey}")
.withPathParam("tableName", "blog_post").withPathParam("primaryKey", "1").withSessionUUID(AssistantTestFixture.ADMIN)
.withJsonBody(Map.of("slug", AssistantTestFixture.EDITOR))).status()).isEqualTo(200);
provider.thenToolUse("query_records", input("table", "blog_post", "filter", filter("AND"))).thenText("Your one post");
@@ -24,6 +24,8 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.assistant.AssistantBudgets;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantQBitProducer;
import com.kingsrook.qbits.assistant.AssistantSecurity;
@@ -81,6 +83,17 @@ public class AssistantTestFixture
public static final String LIMITED = "limited-1";
public static final String NO_ACCESS = "nobody-1";
public static final String ADMIN = "admin-1";
public static final String AUTHOR = "author-1";
///////////////////////////////////////////////////////////////////////////
// the assistant answers nobody while no spending limit is in force. The //
// fixture declares one in code (a shared monthly limit no test reaches) //
// unless the test's config has a hard limit of its own; a test of the //
// refusal takes it away again with withoutSpendingLimit //
///////////////////////////////////////////////////////////////////////////
public static final long FIXTURE_SPENDING_LIMIT_MICROS = 1_000_000_000_000L;
private static final Map<String, Set<String>> REVOKED = new ConcurrentHashMap<>();
public static final String HIDDEN_SENTINEL = "HIDDEN_SENTINEL_7f3a2c";
public static final String PASSWORD_SENTINEL = "PASSWORD_SENTINEL_91c2d4";
@@ -113,6 +126,13 @@ public class AssistantTestFixture
config.setRegisterShutdownHook(false);
MemoryRecordStore.fullReset();
QContext.clear();
REVOKED.clear();
AssistantBudgets budgets = config.getBudgets();
if(budgets != null && budgets.getUserDailyTokensHard() == null && budgets.getUserDailyCostMicrosHard() == null && budgets.getInstanceMonthlyTokensHard() == null
&& budgets.getInstanceMonthlyCostMicrosHard() == null)
{
budgets.setInstanceMonthlyCostMicrosHard(FIXTURE_SPENDING_LIMIT_MICROS);
}
QInstance qInstance = new QInstance();
qInstance.addBackend(new QBackendMetaData().withName(BACKEND).withBackendType(MemoryBackendModule.class));
@@ -185,6 +205,34 @@ public class AssistantTestFixture
/*******************************************************************************
** Take away the spending limit the fixture declared in code, for a test of
** what the assistant does with none in force (it reads the code
** configuration's budgets on every request).
*******************************************************************************/
public static void withoutSpendingLimit(AssistantQBitConfig config)
{
if(config.getBudgets() != null && Long.valueOf(FIXTURE_SPENDING_LIMIT_MICROS).equals(config.getBudgets().getInstanceMonthlyCostMicrosHard()))
{
config.getBudgets().setInstanceMonthlyCostMicrosHard(null);
}
}
/*******************************************************************************
** Take a permission away from a user from now on, as an administrator
** would in the identity provider: every session the MOCK authentication
** module resolves for that user afterwards is without it. A session object
** somebody still holds keeps what it was made with. Reset by build().
*******************************************************************************/
public static void revoke(String userId, String permission)
{
REVOKED.computeIfAbsent(userId, key -> ConcurrentHashMap.newKeySet()).add(permission);
}
/*******************************************************************************
** More metadata for one test class's instance.
*******************************************************************************/
@@ -331,7 +379,7 @@ public class AssistantTestFixture
{
session.withSecurityKeyValue(AssistantMetaDataBuilder.allAccessKeyName(AssistantQBitConfig.DEFAULT_OWNER_KEY_TYPE), true);
}
if(userId.startsWith("reporter") || userId.startsWith("viewer") || userId.startsWith("grouper"))
if(userId.startsWith("reporter") || userId.startsWith("viewer") || userId.startsWith("grouper") || userId.startsWith("author"))
{
///////////////////////////////////////////////////////////////////////
// the data-analysis qbit's owner key, as a host's sign-in sets it //
@@ -346,6 +394,7 @@ public class AssistantTestFixture
/*******************************************************************************
** Permissions by user-id prefix:
** editor-* assistant + read on blog_post, page, media and the audits
** author-* what an editor has, plus insert, edit and delete on blog_post, page and media, and their own saved analyses
** analyst-* assistant + read on analytics_daily_stat only
** limited-* assistant only (no table reads at all)
** admin-* assistant + assistant.admin + everything
@@ -359,7 +408,8 @@ public class AssistantTestFixture
{
Set<String> permissions = new HashSet<>();
boolean all = userId.startsWith("seeder") || userId.startsWith("admin");
if(all || userId.startsWith("editor") || userId.startsWith("analyst") || userId.startsWith("limited") || userId.startsWith("reporter") || userId.startsWith("viewer") || userId.startsWith("grouper"))
boolean author = userId.startsWith("author");
if(all || author || userId.startsWith("editor") || userId.startsWith("analyst") || userId.startsWith("limited") || userId.startsWith("reporter") || userId.startsWith("viewer") || userId.startsWith("grouper"))
{
permissions.add(AssistantQBitConfig.ACCESS_PERMISSION);
}
@@ -371,7 +421,7 @@ public class AssistantTestFixture
{
permissions.add("print_job.analyze");
}
if(userId.startsWith("reporter"))
if(userId.startsWith("reporter") || author)
{
permissions.addAll(List.of("data_analysis.saved.read", "data_analysis.saved.insert", "data_analysis.saved.edit", "data_analysis.saved.delete"));
}
@@ -381,7 +431,7 @@ public class AssistantTestFixture
}
List<String> readable = new ArrayList<>();
if(all || userId.startsWith("editor"))
if(all || author || userId.startsWith("editor"))
{
readable.addAll(List.of("blog_post", "page", "media", "patient", AuditsMetaDataProvider.TABLE_NAME_AUDIT, AuditsMetaDataProvider.TABLE_NAME_AUDIT_DETAIL,
AuditsMetaDataProvider.TABLE_NAME_AUDIT_TABLE, AuditsMetaDataProvider.TABLE_NAME_AUDIT_USER));
@@ -393,11 +443,12 @@ public class AssistantTestFixture
for(String table : readable)
{
permissions.add(table + ".read");
if(all)
if(all || (author && List.of("blog_post", "page", "media").contains(table)))
{
permissions.addAll(List.of(table + ".insert", table + ".edit", table + ".delete"));
}
}
permissions.removeAll(REVOKED.getOrDefault(userId, Set.of()));
return (permissions);
}
@@ -0,0 +1,151 @@
/*
* 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.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.SignedInPerson;
import com.kingsrook.qbits.assistant.tools.registry.ToolCall;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallScope;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
/*******************************************************************************
** Tool calls as a caller hands them to the tool-call service, for tests that
** call the service directly with nothing of the turn engine in between.
*******************************************************************************/
public final class ToolCalls
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ToolCalls()
{
}
/*******************************************************************************
** A call ("call-1") of a fixture person, with a session of their own.
*******************************************************************************/
public static ToolCall call(AssistantQBitConfig config, String person, String toolName, Map<String, Object> input)
{
return (new ToolCall(SignedInPerson.ofSession(config.getRuntime().getQInstance(), AssistantTestFixture.ownerSession(person, config)), "call-1", toolName, input));
}
/*******************************************************************************
** A scope that names no conversation and no turn.
*******************************************************************************/
public static ToolCallScope scope()
{
return (new Scope(null, null));
}
/*******************************************************************************
** A scope that names a conversation and a turn (either may be null), as a
** caller would claim them: the service checks whose they are.
*******************************************************************************/
public static ToolCallScope scope(Integer conversationId, Integer turnId)
{
return (new Scope(conversationId, turnId));
}
/*******************************************************************************
** A scope a caller made.
*******************************************************************************/
private static final class Scope implements ToolCallScope
{
private final Integer conversationId;
private final Integer turnId;
private final Map<Class<?>, Object> state = new ConcurrentHashMap<>();
/*******************************************************************************
**
*******************************************************************************/
private Scope(Integer conversationId, Integer turnId)
{
this.conversationId = conversationId;
this.turnId = turnId;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public DataClassification classification()
{
return (DataClassification.PUBLIC);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public Integer conversationId()
{
return (conversationId);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public Integer turnId()
{
return (turnId);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean hipaaRoute()
{
return (false);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public <T> T state(Class<T> type, Supplier<T> create)
{
return (type.cast(state.computeIfAbsent(type, key -> create.get())));
}
}
}
@@ -28,6 +28,7 @@ import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;
import com.kingsrook.qbits.assistant.RuntimeInternals;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.api.AssistantApi;
@@ -163,15 +164,14 @@ class AnalysisSaveTest extends BaseAssistantTest
/*******************************************************************************
** Never without the confirmation, even where the administrator turned
** confirmation off for record writes; and saving alone needs no schedule.
** Never without the confirmation (no policy can switch it off, for this
** tool or any other); and saving alone needs no schedule.
*******************************************************************************/
@Test
void alwaysAProposalAndSavingAloneWorks() throws Exception
{
PolicyService.ToolPolicy noConfirmation = new PolicyService.ToolPolicy(false, Set.of(), 50, true);
Map<String, Object> in = lastDayByFormat(null, null);
UiApiToolbox.Outcome proposed = tool(AnalysisTestFixture.REPORTER, noConfirmation, in);
Map<String, Object> in = lastDayByFormat(null, null);
UiApiToolbox.Outcome proposed = tool(AnalysisTestFixture.REPORTER, PolicyService.ToolPolicy.defaults(), in);
assertThat(proposed.getResult().getOutcome()).as(proposed.getResult().getModelContent()).isEqualTo(AiToolOutcome.OK);
assertThat(proposed.getPendingActionId()).isNotNull();
assertThat(count(SAVED)).isZero();
@@ -252,13 +252,13 @@ class AnalysisSaveTest extends BaseAssistantTest
setAccess(SCHEDULE, DataAccessMode.READ);
UiApiToolbox.Outcome readOnlySchedules = tool(AnalysisTestFixture.REPORTER, null, lastDayByFormat("09:00", "America/Chicago"));
assertThat(readOnlySchedules.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(readOnlySchedules.getResult().getModelContent()).contains("READ_WRITE");
assertThat(readOnlySchedules.getResult().getModelContent()).contains("assistant access READ").contains("administrator setting");
setAccess(SAVED, DataAccessMode.READ);
assertThat(toolNames()).doesNotContain(AnalysisTools.SAVE_ANALYSIS).contains(AnalysisTools.ANALYZE_RECORDS);
UiApiToolbox.Outcome readOnly = tool(AnalysisTestFixture.REPORTER, null, lastDayByFormat(null, null));
assertThat(readOnly.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(readOnly.getResult().getModelContent()).contains("READ_WRITE");
assertThat(readOnly.getResult().getModelContent()).contains("assistant access READ").contains("administrator setting");
assertThat(count(SAVED)).isZero();
assertThat(count(SCHEDULE)).isZero();
@@ -386,7 +386,7 @@ class AnalysisSaveTest extends BaseAssistantTest
QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config));
try
{
return (config.getRuntime().getToolbox().execute(new UiApiToolbox.Context(user, null, config.getRuntime().getUiApiGateway(), policy,
return (Toolboxes.execute(config.getRuntime(), Toolboxes.context(user, null, RuntimeInternals.gateway(config.getRuntime()), policy == null ? PolicyService.ToolPolicy.defaults() : policy,
write -> config.getRuntime().getStore().insert(new AssistantPendingAction().withUserId(user).withToolCallId(write.toolCallId()).withToolName(write.toolName()).withTableName(write.tableName())
.withRecordId(write.recordId()).withUiApiOperation(write.operation()).withParamsJson(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(write.values()))
.withSummary(write.summary()).withStatus(AssistantPendingAction.STATUS_PENDING)
@@ -26,6 +26,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.RuntimeInternals;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.TurnEvent;
@@ -204,13 +205,13 @@ class AnalysisToolsTest extends BaseAssistantTest
assertThat(outcome.getHttpStatus()).as("no request is made").isNull();
}
PolicyService.ToolPolicy oneRow = new PolicyService.ToolPolicy(true, Set.of(), 1, true);
PolicyService.ToolPolicy oneRow = new PolicyService.ToolPolicy(Set.of(), 1, true);
UiApiToolbox.Outcome capped = tool(AnalysisTestFixture.REPORTER, oneRow, input("table", AnalysisTestFixture.TABLE, "groupBy", List.of(Map.of("field", "format")), "filter", filter("AND"), "limit", 100));
assertThat(capped.getResult().getOutcome()).as(capped.getResult().getModelContent()).isEqualTo(AiToolOutcome.TRUNCATED);
assertThat(capped.getResult().getRowCount()).isEqualTo(1);
assertThat(capped.getResult().getModelContent()).contains("\"hasMore\":true");
PolicyService.ToolPolicy onlyCount = new PolicyService.ToolPolicy(true, Set.of(UiApiToolbox.COUNT_RECORDS), 50, true);
PolicyService.ToolPolicy onlyCount = new PolicyService.ToolPolicy(Set.of(UiApiToolbox.COUNT_RECORDS), 50, true);
UiApiToolbox.Outcome refused = tool(AnalysisTestFixture.REPORTER, onlyCount, input("table", AnalysisTestFixture.TABLE, "groupBy", List.of(Map.of("field", "format")), "filter", filter("AND")));
assertThat(refused.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(refused.getHttpStatus()).isNull();
@@ -234,7 +235,7 @@ class AnalysisToolsTest extends BaseAssistantTest
assertThat(config.getRuntime().getContextBuilder().toolNames()).contains(AnalysisTools.ANALYZE_RECORDS);
for(String[] operation : UiApiToolbox.EXTENSION_OPERATIONS.values())
{
assertThat(config.getRuntime().getUiApiGateway().isInvocable(operation[0], operation[1])).as(operation[0] + " " + operation[1]).isTrue();
assertThat(RuntimeInternals.gateway(config.getRuntime()).isInvocable(operation[0], operation[1])).as(operation[0] + " " + operation[1]).isTrue();
}
provider.thenToolUse(AnalysisTools.ANALYZE_RECORDS, input("table", AnalysisTestFixture.TABLE, "groupBy", List.of(Map.of("field", "format")),
@@ -286,7 +287,7 @@ class AnalysisToolsTest extends BaseAssistantTest
QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config));
try
{
return (config.getRuntime().getToolbox().execute(new UiApiToolbox.Context(user, null, config.getRuntime().getUiApiGateway(), policy, null), "call-1", AnalysisTools.ANALYZE_RECORDS, in));
return (Toolboxes.execute(config.getRuntime(), Toolboxes.context(user, null, RuntimeInternals.gateway(config.getRuntime()), policy == null ? PolicyService.ToolPolicy.defaults() : policy, null), "call-1", AnalysisTools.ANALYZE_RECORDS, in));
}
finally
{
@@ -0,0 +1,116 @@
/*
* 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.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.tools.registry.AssistantTool;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallOutcome;
import com.kingsrook.qbits.assistant.tools.registry.ToolCallRequest;
import com.kingsrook.qbits.assistant.tools.registry.ToolCheck;
import com.kingsrook.qbits.assistant.tools.registry.ToolFamily;
import com.kingsrook.qbits.assistant.tools.registry.ToolKind;
import com.kingsrook.qbits.assistant.tools.registry.ToolOffer;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
/*******************************************************************************
** A tool family as another jar could bring one: a public class with a public
** no-argument constructor under the assistant's tools package. Being on the
** classpath registers nothing (ToolRegistryTest); a host that wants it
** registers it with AssistantQBitConfig.withToolFamily.
*******************************************************************************/
public class ClasspathOnlyToolFamily implements ToolFamily
{
public static final String NAME = "classpath_only_tool";
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<AssistantTool> tools(AssistantRuntime runtime)
{
return (List.of(new Tool()));
}
/*******************************************************************************
** A tool that reads nothing and says it ran.
*******************************************************************************/
private static final class Tool implements AssistantTool
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public String name()
{
return (NAME);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ToolKind kind()
{
return (ToolKind.READ);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<ToolCheck> checks()
{
return (List.of(ToolCheck.TOOL_SWITCH));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ModelToolDefinition definition(ToolOffer offer)
{
return (offer.allows(NAME) ? new ModelToolDefinition().withName(NAME).withDescription("Says that it ran.").withStrict(false).withInputSchema(Map.of("type", "object")) : null);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ToolCallOutcome execute(ToolCallRequest request)
{
return (ToolCallOutcome.of(new AiToolResult().withToolCallId(request.toolCallId()).withToolName(NAME).withOutcome(AiToolOutcome.OK).withSummary("Ran").withModelContent("ran")));
}
}
}
@@ -20,6 +20,7 @@ package com.kingsrook.qbits.assistant.tools;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.RuntimeInternals;
import com.kingsrook.qbits.assistant.engine.MessageParts;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.TurnEvent;
@@ -93,7 +94,7 @@ class PresentResultsToolTest extends BaseAssistantTest
void testUiDenial() throws Exception
{
String user = AssistantTestFixture.ANALYST;
UiApiResult metadata = runner.runtime().getUiApiGateway().invoke(new UiApiCall("GET", "/metaData").withSessionUUID(user));
UiApiResult metadata = RuntimeInternals.gateway(runner.runtime()).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");
@@ -135,7 +136,7 @@ class PresentResultsToolTest extends BaseAssistantTest
return (new UiApiResult(200, "{\"tables\":{\"media\":{\"readPermission\":true}}}"));
}
};
UiApiToolbox.Context context = new UiApiToolbox.Context("editor-session", "UTC", gateway, PolicyService.ToolPolicy.defaults(), null);
UiApiToolbox.Context context = Toolboxes.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));
@@ -254,7 +255,7 @@ class PresentResultsToolTest extends BaseAssistantTest
@Test
void testInvalidSummaryAndAlwaysOffered() throws Exception
{
assertThat(runner.runtime().getContextBuilder().tools(new PolicyService.ToolPolicy(true, java.util.Set.of("navigate"), 20, true)))
assertThat(runner.runtime().getContextBuilder().tools(new PolicyService.ToolPolicy(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.");
@@ -270,6 +271,6 @@ class PresentResultsToolTest extends BaseAssistantTest
*******************************************************************************/
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));
return (new PresentResultsTool(qInstance).execute(Toolboxes.context(AssistantTestFixture.EDITOR, "UTC", RuntimeInternals.gateway(runner.runtime()), PolicyService.ToolPolicy.defaults(), null), "present", input));
}
}
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more