From 0dc325cd2ca5fe357b90ed70f51934c3eebb6fb1 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 12 Nov 2026 11:35:43 -0600 Subject: [PATCH] fix(tools): one decision per pending action, a tool surface closed at compile time; feat: pending actions with before and after 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 --- CHANGELOG.md | 20 + CLAUDE.md | 13 +- README.md | 38 +- UPGRADE.md | 29 + docs/schema-postgres.sql | 27 + .../qbits/assistant/AssistantRuntime.java | 32 +- .../qbits/assistant/SignedInPerson.java | 122 +++- .../assistant/access/AccessExplainer.java | 27 + .../qbits/assistant/api/AssistantApi.java | 198 +++++- .../assistant/api/specs/AssistantSpecs.java | 80 ++- .../qbits/assistant/engine/BudgetService.java | 13 +- .../assistant/engine/StarterPrompts.java | 34 +- .../assistant/engine/ToolCallService.java | 414 +++++++++--- .../qbits/assistant/engine/TurnEngine.java | 26 +- .../assistant/home/AssistantAccessWidget.java | 2 +- .../metadata/AssistantMetaDataBuilder.java | 11 +- .../metadata/AssistantRecordScreens.java | 13 +- .../model/AssistantPendingAction.java | 112 +++- .../model/AssistantPendingActionClaim.java | 227 +++++++ .../assistant/persistence/AssistantStore.java | 259 +++++++- .../persistence/TranscriptRedactor.java | 22 +- .../processes/AssistantRetentionStep.java | 29 +- .../qbits/assistant/rag/RagService.java | 15 +- .../scheduled/ScheduledTaskRunner.java | 2 +- .../qbits/assistant/tools/KnowledgeTool.java | 2 +- .../assistant/tools/UiApiToolFamily.java | 2 +- .../qbits/assistant/tools/UiApiToolbox.java | 301 ++++++++- .../assistant/tools/registry/ActionKinds.java | 120 ++++ .../tools/registry/ConfirmedToolCall.java | 171 ++++- .../tools/registry/ToolCallRequest.java | 130 +++- .../main/resources/help/qbit-assistant.yaml | 30 + .../resources/openapi/qqq-v1/assistant.yaml | 135 +++- .../assistanthost/HostToolFamilyTest.java | 407 ++++++++++++ .../AssistantAuditAttributionTest.java | 2 + .../assistant/AssistantChannelWallTest.java | 6 +- .../AssistantTablesGuardrailTest.java | 1 + .../qbits/assistant/RuntimeInternals.java | 70 ++ .../qbits/assistant/SignedInPersonTest.java | 112 ++++ .../access/ProfilesSkillsAndAccessTest.java | 3 +- .../assistant/api/PendingActionsTest.java | 403 +++++++++++ .../engine/CutShortProposalTest.java | 334 ++++++++++ .../engine/PendingActionDecisionTest.java | 623 ++++++++++++++++++ .../assistant/engine/StarterPromptsTest.java | 27 +- .../engine/ToolCallServiceInternals.java | 69 ++ .../assistant/engine/ToolCallServiceTest.java | 19 +- .../SchemaForeignKeyPostgresTest.java | 1 + .../qbits/assistant/rag/RagTest.java | 15 +- .../scheduled/ScheduledTasksTest.java | 3 +- .../qbits/assistant/testing/ToolCalls.java | 2 +- .../assistant/tools/AnalysisSaveTest.java | 3 +- .../assistant/tools/AnalysisToolsTest.java | 5 +- .../tools/PresentResultsToolTest.java | 7 +- .../qbits/assistant/tools/Toolboxes.java | 58 +- .../tools/UiApiToolsSecurityTest.java | 31 +- .../tools/bulkload/BulkLoadToolsTest.java | 8 +- .../registry/ToolCallArchitectureTest.java | 43 +- .../tools/registry/ToolRegistryTest.java | 36 +- 57 files changed, 4614 insertions(+), 330 deletions(-) create mode 100644 qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantPendingActionClaim.java create mode 100644 qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/registry/ActionKinds.java create mode 100644 qbit-assistant/src/test/java/com/example/assistanthost/HostToolFamilyTest.java create mode 100644 qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/RuntimeInternals.java create mode 100644 qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/SignedInPersonTest.java create mode 100644 qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/PendingActionsTest.java create mode 100644 qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/CutShortProposalTest.java create mode 100644 qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/PendingActionDecisionTest.java create mode 100644 qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/ToolCallServiceInternals.java diff --git a/CHANGELOG.md b/CHANGELOG.md index ab3edcc..9e8eada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 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. diff --git a/CLAUDE.md b/CLAUDE.md index c53b3ae..a449614 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,16 +65,21 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def - 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 (`PolicyService.current(false).tools()`, narrowed for a scheduled task's conversation), builds the tool `Context` with `runtime.getUiApiGateway()` and keeps the proposed writes itself. Only the service constructs a `ToolCallRequest` or a `ConfirmedToolCall` (ArchUnit). - - Its entry points: `executeAndLog(call, conversationId, hipaaRoute)` for a caller with no turn (the service makes the scope); `confirm(person, pendingActionId)`, called only by `AssistantApi` (ArchUnit); `execute(call, scope)` and `log(call, scope, outcome)` for the turn engine only (ArchUnit). - - 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 (read on every call, never remembered per scope) 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 credentials forwarded to the UI API. It has no public constructor: `ofRequest(Context)` (only `AssistantSpecs`, ArchUnit) or `ofSession(session)` (no forwarded credentials). Never pass a session and credentials separately. + - `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)`. `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 (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, and it must be the user the session names). Never make a person from a session that was not checked, 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). - 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, 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 and cancelling both go through `AssistantStore.decidePendingAction`, which inserts the action's row into `assistant_pending_action_claim` (unique `pendingActionId`). Never decide an action with a status update alone, 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 deletes decided and lapsed pending actions older than `approvalRetentionDays`, with or without a conversation; one left RUNNING is kept. - 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:** diff --git a/README.md b/README.md index e8e9c0e..345d4aa 100644 --- a/README.md +++ b/README.md @@ -280,13 +280,15 @@ 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. | @@ -353,6 +355,20 @@ 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)` | +| 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, and the person is made only when that is the user the session names. | + +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). 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. + ## Data policy What reaches the model provider: @@ -378,6 +394,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. @@ -395,6 +412,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` @@ -407,6 +428,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. +- **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` deletes pending actions older than `approvalRetentionDays` that were decided or lapsed. One still `RUNNING` is kept: its end was never recorded. + ### 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`. @@ -425,6 +457,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. | diff --git a/UPGRADE.md b/UPGRADE.md index cbe6176..53f1d4b 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,34 @@ # Upgrading qbit-assistant +## 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: diff --git a/docs/schema-postgres.sql b/docs/schema-postgres.sql index f628eb4..680b183 100644 --- a/docs/schema-postgres.sql +++ b/docs/schema-postgres.sql @@ -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); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java index 8ea6215..c45dcaa 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java @@ -159,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() { @@ -194,8 +189,13 @@ 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, uiApiGateway); 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()); @@ -207,7 +207,7 @@ public class AssistantRuntime List toolFamilies = new ArrayList<>(ownToolFamilies()); toolFamilies.addAll(config.getToolFamilies()); this.toolRegistry = ToolRegistry.of(this, toolFamilies); - this.toolCalls = new ToolCallService(this, toolRegistry, store); + this.toolCalls = new ToolCallService(this, toolRegistry, store, uiApiGateway); this.contextBuilder = new ContextBuilder(config, kernel, toolRegistry).withBudgets(budgetService); if(Boolean.TRUE.equals(config.getRegisterShutdownHook())) { @@ -333,10 +333,10 @@ public class AssistantRuntime ** AssistantQBitConfig.withToolFamily. ** ** Each family is handed the toolbox its tools run. The runtime has no - ** getter for a toolbox: outside the tools packages a tool is reached only - ** through the registry, and run only by engine.ToolCallService. + ** 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 List ownToolFamilies() + List ownToolFamilies() { return (List.of(new UiApiToolFamily(toolbox), new BulkLoadToolFamily(bulkLoadTools, toolbox), new KnowledgeToolFamily(knowledgeTool), new PresentResultsToolFamily(), new KernelToolFamily())); } @@ -344,9 +344,11 @@ public class AssistantRuntime /******************************************************************************* - ** Every tool the assistant has; the only place a tool is looked up. + ** 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 ToolRegistry getToolRegistry() + ToolRegistry toolRegistry() { return (toolRegistry); } @@ -364,9 +366,11 @@ public class AssistantRuntime /******************************************************************************* - ** Getter for uiApiGateway + ** 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. *******************************************************************************/ - public UiApiGateway getUiApiGateway() + UiApiGateway uiApiGateway() { return (uiApiGateway); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/SignedInPerson.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/SignedInPerson.java index 87f4459..c4e14e1 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/SignedInPerson.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/SignedInPerson.java @@ -17,26 +17,38 @@ 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 - ** credentials the tools forward to the UI API, bound together when the - ** request's session is set up and never changed afterwards. A tool call, a - ** turn and a confirmation are all made for one of these, so none of them can - ** be handed one person's session with another person's credentials. + ** 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 credentials of that same HTTP request. + ** 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(QSession)}, for code that has a session and no HTTP - ** request (a process, a scheduled run): no forwarded credentials at all, so - ** the UI API is called with that session's own id and nothing else. + ** - {@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 + ** made only when it resolves to the user the session names. A session that + ** was put together by hand, with one user and another user's id, is + ** refused: there is no way to make a person from a session that was not + ** checked. ** ** The credentials are never logged; toString leaves them out. *******************************************************************************/ @@ -64,9 +76,10 @@ public final class SignedInPerson /******************************************************************************* ** The person of the HTTP request being served on this thread: the session - ** the route's session setup resolved for it (QContext), and that request's - ** own credentials exactly as they came in. Both come from the one request, - ** so they are the same person's; neither is a value the caller chose. + ** 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) { @@ -74,17 +87,90 @@ public final class SignedInPerson { throw (new IllegalArgumentException("A request is needed to take its credentials.")); } - return (new SignedInPerson(QContext.getQSession(), UiApiCredentials.fromContext(request))); + return (new SignedInPerson(QContext.getQSession(), resolvedFrom(UiApiCredentials.fromContext(request)))); } /******************************************************************************* - ** A person known by their session alone, with no HTTP request to take - ** credentials from: the UI API is called with this session's own id. + ** 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. *******************************************************************************/ - public static SignedInPerson ofSession(QSession session) + 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. + *******************************************************************************/ + 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.")); + } return (new SignedInPerson(session, null)); } @@ -101,8 +187,8 @@ public final class SignedInPerson /******************************************************************************* - ** The credentials of the person's request, to forward to the UI API; null - ** when the session's own id is the credential. Never log them. + ** 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() { diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java index d82ecff..5fd9623 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java @@ -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 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)); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java index 963b234..547d9d6 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +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; @@ -58,18 +59,19 @@ 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.UiApiToolbox; -import com.kingsrook.qbits.assistant.tools.registry.AssistantTool; -import com.kingsrook.qbits.assistant.tools.registry.ToolOffer; +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.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; @@ -94,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 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)."; @@ -106,19 +112,25 @@ public class AssistantApi private final AssistantStore store; private final QSession session; private final String userId; - private final SignedInPerson person; + private SignedInPerson person; /******************************************************************************* ** Binds the API to a session with no HTTP request behind it (a process, a - ** scheduled run, a test) and gives the session its own owner key (so the - ** record locks admit the user's own rows). The tools call the UI API with - ** that session's own id. + ** 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, session, session == null ? null : SignedInPerson.ofSession(session)); + this(runtime, session, null); } @@ -263,6 +275,33 @@ 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); + } + 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 @@ -273,12 +312,9 @@ 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, person.credentials()); - AssistantTool history = runtime.getToolRegistry().find(UiApiToolbox.RECORD_HISTORY); - boolean historyOffered = history != null && history.definition(new ToolOffer(policy.tools(), session, null)) != null; - return (runtime.getStarterPrompts().forUser(context, userId, persona.starterPrompts(), historyOffered)); + return (runtime.getStarterPrompts().forUser(person(), policy.tools(), persona.starterPrompts(), runtime.getToolCalls().offers(person(), UiApiToolbox.RECORD_HISTORY))); } - catch(QException | RuntimeException e) + catch(QException | AssistantApiException | RuntimeException e) { return (List.of()); } @@ -476,6 +512,11 @@ public class AssistantApi public RunningTurn startTurn(Integer conversationId, Map 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.")); @@ -605,7 +646,7 @@ public class AssistantApi 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).withPerson(person).withTranscript(transcript); + budget.softExceeded() ? budget.message() : null).withPerson(turnPerson).withTranscript(transcript); runtime.register(runningTurn); if(!runningTurn.isFinalized()) { @@ -991,7 +1032,35 @@ public class AssistantApi public Map getAction(Integer id) throws QException, AssistantApiException { requireAccess(); - return (toJson(expireIfDue(requireAction(id)))); + return (describe(expireIfDue(requireAction(id)))); + } + + + + /******************************************************************************* + ** 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 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> list = new ArrayList<>(); + for(AssistantPendingAction action : store.listOwnPendingActions(userId, wanted, conversationId, MAX_LISTED_ACTIONS)) + { + expireIfDue(action); + if(wanted == null || wanted.equals(action.getStatus())) + { + list.add(describe(action)); + } + } + return (Map.of("actions", list)); } @@ -1009,7 +1078,7 @@ public class AssistantApi ToolCallService.Confirmation confirmation; try { - confirmation = runtime.getToolCalls().confirm(person, id); + confirmation = runtime.getToolCalls().confirm(person(), id); } catch(ToolCallService.ConfirmationRefused e) { @@ -1027,7 +1096,7 @@ public class AssistantApi AiToolResult result = outcome.getResult(); reportDecision(action, result, result.getOutcome() == AiToolOutcome.OK); - Map rs = toJson(action); + Map rs = describe(action); rs.put("outcome", result.getOutcome() == null ? null : result.getOutcome().name()); rs.put("message", result.getModelContent()); if(outcome.getAuditHref() != null) @@ -1106,14 +1175,19 @@ public class AssistantApi public Map 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))); } @@ -1140,8 +1214,7 @@ public class AssistantApi { 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.")); + store.expirePendingAction(action.getId()); action.setStatus(AssistantPendingAction.STATUS_EXPIRED); action.setResultSummary("Expired before it was confirmed; nothing changed."); } @@ -1159,17 +1232,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 describe(AssistantPendingAction action) + { + Map 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 values = storedObject(action.getParamsJson()); + Map 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 storedObject(String json) + { + try + { + return (json == null ? null : JsonUtils.toObject(json, new TypeReference>() {})); + } + 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 @@ -1194,7 +1342,7 @@ public class AssistantApi requireTurn(turnId); } List> tables = new ArrayList<>(); - for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), person.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) { diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java index 945eb28..65e0815 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java @@ -64,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"; @@ -99,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) @@ -247,12 +248,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.", @@ -435,8 +445,7 @@ public final class AssistantSpecs // credentials of that same request // /////////////////////////////////////////////////////////////////////// QSession session = QContext.getQSession(); - AssistantApi api = session == null ? new AssistantApi(runtime, (QSession) null) - : new AssistantApi(runtime, context == null ? SignedInPerson.ofSession(session) : SignedInPerson.ofRequest(context)); + AssistantApi api = session == null || context == null ? new AssistantApi(runtime, session) : new AssistantApi(runtime, SignedInPerson.ofRequest(context)); return (handle(api, input)); } @@ -1265,6 +1274,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 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} *******************************************************************************/ diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/BudgetService.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/BudgetService.java index 2c0240c..225277a 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/BudgetService.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/BudgetService.java @@ -421,13 +421,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 overrides = new ArrayList<>(); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/StarterPrompts.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/StarterPrompts.java index abe988d..d402bfc 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/StarterPrompts.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/StarterPrompts.java @@ -32,6 +32,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; import com.fasterxml.jackson.databind.ObjectMapper; +import com.kingsrook.qbits.assistant.SignedInPerson; import com.kingsrook.qbits.assistant.tools.UiApiToolbox; import com.kingsrook.qbits.assistant.uiapi.UiApiCall; import com.kingsrook.qbits.assistant.uiapi.UiApiGateway; @@ -39,6 +40,7 @@ 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.model.session.QSession; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -79,6 +81,7 @@ public class StarterPrompts private final UiApiToolbox toolbox; private final Clock clock; + private final UiApiGateway gateway; @@ -114,9 +117,38 @@ public class StarterPrompts ** *******************************************************************************/ public StarterPrompts(UiApiToolbox toolbox, Clock clock) + { + this(toolbox, clock, null); + } + + + + /******************************************************************************* + ** With the gateway the person's reads go through: the runtime hands over + ** its own, which it has no public getter for. + *******************************************************************************/ + public StarterPrompts(UiApiToolbox toolbox, Clock clock, UiApiGateway gateway) { this.toolbox = toolbox; this.clock = clock; + this.gateway = gateway; + } + + + + /******************************************************************************* + ** The prompts for a signed-in person, read through the gateway this was + ** made with, with that person's own session and credentials. Never throws. + *******************************************************************************/ + public List forUser(SignedInPerson person, PolicyService.ToolPolicy policy, List configured, boolean historyOffered) + { + if(person == null || gateway == null) + { + return (List.of()); + } + QSession session = person.session(); + return (forUser(UiApiToolbox.Context.forRead(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), gateway, policy, person.credentials(), null), + person.userId(), configured, historyOffered)); } @@ -220,7 +252,7 @@ public class StarterPrompts return (inner.invoke(call.withTimeout(CALL_TIMEOUT))); } }; - return (new UiApiToolbox.Context(context.sessionUUID(), context.userTimezone(), gateway, context.policy(), null, context.credentials(), null)); + return (UiApiToolbox.Context.forRead(context.sessionUUID(), context.userTimezone(), gateway, context.policy(), context.credentials(), null)); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ToolCallService.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ToolCallService.java index 93e3502..221d8ce 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ToolCallService.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ToolCallService.java @@ -18,10 +18,11 @@ package com.kingsrook.qbits.assistant.engine; import java.time.Instant; +import java.util.HashMap; import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; import com.fasterxml.jackson.core.type.TypeReference; @@ -46,6 +47,7 @@ import com.kingsrook.qbits.assistant.tools.registry.ToolCallRequest; import com.kingsrook.qbits.assistant.tools.registry.ToolCallScope; import com.kingsrook.qbits.assistant.tools.registry.ToolOffer; import com.kingsrook.qbits.assistant.tools.registry.ToolRegistry; +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; @@ -53,7 +55,6 @@ import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor; 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.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.utils.JsonUtils; @@ -73,19 +74,21 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; ** happens before any data action; every call that ran has a log row with its ** input redacted. ** - ** The entry points: + ** The entry points, and there are no others (a tool cannot be run around + ** the service: what a tool is run with can only be made with the service's + ** {@link Permit}, which is never handed out): ** - {@link #executeAndLog(ToolCall, Integer, boolean)}: one call for a ** caller with no turn. The service makes the scope itself, from the person ** and a conversation it checks is theirs, runs the call and writes its row. - ** - {@link #confirm(SignedInPerson, Integer)}: a write the person confirmed. + ** - {@link #confirm(SignedInPerson, Integer)}: a change the person confirmed. ** The service loads the stored pending action of that person, checks it, - ** claims it once, runs it, marks it and writes its row. Only the assistant - ** API calls it (ArchUnit). - ** - {@link #execute(ToolCall, ToolCallScope)} and - ** {@link #log(ToolCall, ToolCallScope, ToolCallOutcome)}: the two halves of - ** executeAndLog for the turn engine, which runs its calls in parallel under - ** its own time limit and writes the rows in call order afterwards. No other - ** class may use them (ArchUnit). + ** claims it (exactly one request can), runs it, marks it and writes its + ** row. Only the assistant API calls it (ArchUnit). + ** - execute, log and withdraw: the halves of executeAndLog for the turn + ** engine, which runs its calls in parallel under its own time limit, writes + ** the rows in call order afterwards and withdraws what a call it cut short + ** proposed. They are package-private: no class outside this package can + ** run a call without its row. ** ** What the service does itself on every call, in this order, before the tool ** is reached (so a refusal happens before any data action): @@ -99,9 +102,11 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; ** 4. the scope is the person's: a conversation or a turn it names belongs to ** that person (and the turn to that conversation). The client channel of ** the log row is worked out here, not taken from the caller. - ** 5. the policy: the administrator's current tool settings, read by the - ** service (narrowed for a scheduled task's conversation, as the chat - ** does). A policy that cannot be read refuses. + ** 5. the policy: the administrator's tool settings as they are now, read + ** fresh by the service on every call, never from a cache (narrowed for a + ** scheduled task's conversation, as the chat does). A policy that cannot + ** be read refuses. A host with no policy row has the defaults declared in + ** code: the row can only narrow them. ** 6. the hard budget (the policy's hard limits, and a budget that has stopped ** the person), on every call. It only reads: no budget event, no alert, ** nothing charged, and the chat's warning notice is left for the model @@ -153,23 +158,52 @@ public class ToolCallService private static final String CHANNEL = "assistant"; - private static final AiRedactor INPUT_REDACTOR = new AiRedactor(List.of()); + static final String WITHDRAWN_SUMMARY = "The step that proposed this was stopped before it was shown; nothing changed."; + + private static final Permit PERMIT = new Permit(); private final AssistantRuntime runtime; private final ToolRegistry registry; private final AssistantStore store; + private final UiApiGateway gateway; + private final AiRedactor inputRedactor; /******************************************************************************* ** The runtime is still being constructed here: its collaborators are asked - ** for when a call arrives. + ** for when a call arrives. The registry and the gateway are handed over by + ** the runtime, which has no public getter for either. *******************************************************************************/ - public ToolCallService(AssistantRuntime runtime, ToolRegistry registry, AssistantStore store) + public ToolCallService(AssistantRuntime runtime, ToolRegistry registry, AssistantStore store, UiApiGateway gateway) { this.runtime = runtime; this.registry = registry; this.store = store; + this.gateway = gateway; + /////////////////////////////////////////////////////////////////////// + // the log row's input is redacted with the same patterns as a tool // + // result: the kernel's own and the administrator's custom ones // + /////////////////////////////////////////////////////////////////////// + this.inputRedactor = new AiRedactor(AssistantRuntime.kernelSettings(runtime.getConfig()).getCustomRedactionPatterns()); + } + + + + /******************************************************************************* + ** What only this service has: the permit a tool call request, a confirmed + ** tool call and the context of a tool call are made with. No other class + ** can make one (the constructor is private) and the service never hands + ** its own out, so nothing but the service can make what a tool runs with. + *******************************************************************************/ + public static final class Permit + { + /******************************************************************************* + ** + *******************************************************************************/ + private Permit() + { + } } @@ -255,6 +289,13 @@ public class ToolCallService private PolicyService.ToolPolicy basePolicy; private PolicyService.ToolPolicy policy; private ToolOffer offer; + + /////////////////////////////////////////////////////////////////////// + // the pending action each call of the scope proposed, and the calls // + // whose outcome the caller did not take (it cut them short) // + /////////////////////////////////////////////////////////////////////// + private final Map proposed = new HashMap<>(); + private final Set cutShort = new HashSet<>(); } @@ -396,9 +437,10 @@ public class ToolCallService ** Run one call as its person, after every check of the service. A refusal ** is a result, and nothing ran. Only the turn engine calls this, and logs ** the outcome with {@link #log}; every other caller uses - ** {@link #executeAndLog}. + ** {@link #executeAndLog}. Package-private: a class outside this package + ** cannot run a call without its row. *******************************************************************************/ - public ToolCallOutcome execute(ToolCall call, ToolCallScope scope) + ToolCallOutcome execute(ToolCall call, ToolCallScope scope) { if(call == null || call.person() == null || call.person().userId() == null) { @@ -413,22 +455,42 @@ public class ToolCallService try(PersonsContext ignored = new PersonsContext(session)) { AssistantSecurity.ownerKey(session, runtime.getConfig()); - AiToolResult closed = accessOrKillSwitch(session, result(call)); - if(closed != null) + AiToolResult noAccess = noAccess(session, result(call)); + if(noAccess != null) { - return (ToolCallOutcome.of(closed)); + return (ToolCallOutcome.of(noAccess)); + } + + /////////////////////////////////////////////////////////////////////// + // the policy is read once per call, fresh: the kill switch and the // + // tool settings a call runs under are the administrator's row as it // + // is now. A policy that cannot be read counts as switched off // + /////////////////////////////////////////////////////////////////////// + PolicyService.EffectivePolicy effective; + try + { + effective = runtime.getPolicyService().current(true); + } + catch(QException | RuntimeException e) + { + LOG.warn("Could not read the assistant policy; treating the kill switch as ON (fail closed)", logPair("errorClass", e.getClass().getSimpleName())); + return (ToolCallOutcome.of(denied(result(call), CHECK_KILL_SWITCH, "The assistant has been turned off by an administrator."))); + } + if(effective.killSwitch()) + { + return (ToolCallOutcome.of(denied(result(call), CHECK_KILL_SWITCH, "The assistant has been turned off by an administrator."))); } Admission admission = admission(call.person(), scope); ToolCallRequest request; synchronized(admission) { - AiToolResult refused = refusal(call, admission); + AiToolResult refused = refusal(call, admission, effective); if(refused != null) { return (ToolCallOutcome.of(refused)); } - request = new ToolCallRequest(session, context(call.person(), admission), call.toolCallId(), call.toolName(), call.input(), scope); + request = ToolCallRequest.of(PERMIT, session, context(call.person(), admission, call.toolCallId()), call.toolCallId(), call.toolName(), call.input(), scope); } return (registry.find(call.toolName()).execute(request)); } @@ -488,7 +550,7 @@ public class ToolCallService ** The refusal of a call by the scope, the policy, the hard budget or the ** offer, or null when its tool may run. The admission is the caller's lock. *******************************************************************************/ - private AiToolResult refusal(ToolCall call, Admission admission) + private AiToolResult refusal(ToolCall call, Admission admission, PolicyService.EffectivePolicy effective) { AiToolResult result = result(call); if(!admission.owned) @@ -496,10 +558,8 @@ public class ToolCallService return (denied(result, CHECK_REQUEST, "The tool call names a conversation or a turn that is not this person's.")); } - PolicyService.EffectivePolicy effective; try { - effective = runtime.getPolicyService().current(false); policy(admission, effective.tools()); } catch(QException | RuntimeException e) @@ -544,64 +604,119 @@ public class ToolCallService /******************************************************************************* ** The context a tool runs with, made here and nowhere else: the person's - ** own session and credentials, the runtime's gateway, the policy the - ** service read, and the service's own store of proposed writes. + ** own session and credentials, the gateway the runtime handed the service, + ** the policy the service read, and the service's own store of proposed + ** changes. *******************************************************************************/ - private UiApiToolbox.Context context(SignedInPerson person, Admission admission) + private UiApiToolbox.Context context(SignedInPerson person, Admission admission, String toolCallId) { QSession session = person.session(); String userId = person.userId(); - Integer conversationId = admission.conversationId; - Integer turnId = admission.turnId; - return (new UiApiToolbox.Context(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), runtime.getUiApiGateway(), admission.policy, - write -> propose(userId, conversationId, turnId, write), person.credentials(), turnId == null ? null : String.valueOf(turnId))); + Integer turnId = admission.turnId; + return (UiApiToolbox.Context.of(PERMIT, session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), gateway, admission.policy, + write -> propose(admission, userId, toolCallId, write), person.credentials(), turnId == null ? null : String.valueOf(turnId))); } /******************************************************************************* - ** Record a write the person must confirm; nothing runs until they do - ** ({@link #confirm}). + ** Record a change the person must confirm; nothing runs until they do + ** ({@link #confirm}). A call whose outcome its caller did not take (it was + ** cut short) proposes nothing: the person would never see its card. *******************************************************************************/ - private Integer propose(String userId, Integer conversationId, Integer turnId, UiApiToolbox.ProposedWrite write) throws QException + private Integer propose(Admission admission, String userId, String toolCallId, UiApiToolbox.ProposedWrite write) throws QException { - Instant now = runtime.getClock().instant(); - Integer ttl = runtime.getConfig().getPendingActionTtlSeconds(); - AssistantPendingAction action = store.insert(new AssistantPendingAction() - .withConversationId(conversationId) - .withTurnId(turnId) - .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(ttl == null ? 900 : ttl))); - return (action.getId()); + synchronized(admission) + { + if(admission.cutShort.contains(toolCallId)) + { + throw (new QException("The step was stopped before it could propose its change; nothing was proposed.")); + } + Instant now = runtime.getClock().instant(); + Integer ttl = runtime.getConfig().getPendingActionTtlSeconds(); + AssistantPendingAction action = store.insert(new AssistantPendingAction() + .withConversationId(admission.conversationId) + .withTurnId(admission.turnId) + .withUserId(userId) + .withToolCallId(write.toolCallId()) + .withToolName(write.toolName()) + .withActionKind(write.kind() == null ? write.toolName() : write.kind()) + .withUiApiOperation(write.operation()) + .withTableName(write.tableName()) + .withRecordId(write.recordId()) + .withParamsJson(JsonUtils.toJson(write.values())) + .withBeforeJson(write.before() == null ? null : JsonUtils.toJson(write.before())) + .withRecordVersion(write.recordVersion()) + .withSummary(write.summary()) + .withStatus(AssistantPendingAction.STATUS_PENDING) + .withExpiresAt(now.plusSeconds(ttl == null ? 900 : ttl))); + admission.proposed.put(toolCallId, action.getId()); + return (action.getId()); + } } /******************************************************************************* - ** Assistant access first (a person without it is told so even while the - ** kill switch is on), then the kill switch, read fresh (a policy that - ** cannot be read counts as on); null when both let the call through. + ** Withdraw what a call proposed, for the turn engine when it did not take + ** the call's own outcome (the call ran past its time limit, was stopped, or + ** failed): the person is shown no card for it, so a pending action it + ** stored must not stay confirmable, and one it has not stored yet is never + ** stored. Never throws. Package-private, like {@link #execute}. *******************************************************************************/ - private AiToolResult accessOrKillSwitch(QSession session, AiToolResult result) + void withdraw(ToolCall call, ToolCallScope scope) { - AiToolResult noAccess = noAccess(session, result); - if(noAccess != null) + if(call == null || call.person() == null || call.person().userId() == null || scope == null) { - return (noAccess); + return; } - if(runtime.getPolicyService().isKillSwitchOn()) + QSession session = call.person().session(); + try(PersonsContext ignored = new PersonsContext(session)) { - return (denied(result, CHECK_KILL_SWITCH, "The assistant has been turned off by an administrator.")); + AssistantSecurity.ownerKey(session, runtime.getConfig()); + Admission admission = admission(call.person(), scope); + synchronized(admission) + { + admission.cutShort.add(call.toolCallId()); + Integer actionId = admission.proposed.get(call.toolCallId()); + if(actionId != null) + { + store.withdrawPendingAction(call.person().userId(), actionId, WITHDRAWN_SUMMARY, runtime.getClock().instant()); + } + } + } + catch(QException | RuntimeException e) + { + LOG.error("A pending action of an assistant tool call that was cut short could not be withdrawn", logPair("tool", call.toolName()), logPair("toolCallId", call.toolCallId()), + logPair("turnId", scope.turnId()), logPair("errorClass", e.getClass().getSimpleName())); + } + } + + + + /******************************************************************************* + ** Whether a tool is one this person is offered now, under the policy as it + ** is now: the question a caller may ask without running anything. False + ** when the policy cannot be read. + *******************************************************************************/ + public boolean offers(SignedInPerson person, String toolName) + { + if(person == null || toolName == null) + { + return (false); + } + QSession session = person.session(); + try(PersonsContext ignored = new PersonsContext(session)) + { + AssistantSecurity.ownerKey(session, runtime.getConfig()); + AssistantTool tool = registry.find(toolName); + return (tool != null && tool.definition(new ToolOffer(runtime.getPolicyService().current(true).tools(), session, null)) != null); + } + catch(QException | RuntimeException e) + { + LOG.warn("Could not read the assistant policy; no tool is offered (fail closed)", logPair("errorClass", e.getClass().getSimpleName())); + return (false); } - return (null); } @@ -761,13 +876,16 @@ public class ToolCallService ** running; the kill switch is on; the administrator's policy no longer ** allows the tool; or another request claimed it first. ** - ** Otherwise the action is claimed (RUNNING) so it can run only once, the - ** registry's tool runs its confirmed write with this person's session (the - ** UI API decides, with the person's permissions as they are now, and - ** refuses a record that is gone), the action is marked EXECUTED or FAILED - ** and the service writes the tool-call log row. Whatever goes wrong after - ** the claim (stored values that cannot be read, a tool that throws) ends in - ** FAILED with a row, never in an action left RUNNING. A confirmed write + ** Otherwise the action is claimed (RUNNING): the claim is one insert under + ** a unique key, so of any number of requests for one action exactly one + ** runs it. The registry's tool then runs its confirmed change with this + ** person's session (the UI API decides, with the person's permissions as + ** they are now; an update or a delete is refused when the record is gone + ** or is no longer as it was when the change was proposed), the action is + ** marked EXECUTED or FAILED and the service writes the tool-call log row. + ** Whatever goes wrong after the claim (stored values that cannot be read, a + ** tool that throws, a status that cannot be written or read back) ends in + ** FAILED with a row, never in an action left RUNNING. A confirmed change ** spends no model budget, so the hard budget does not stop it. *******************************************************************************/ public Confirmation confirm(SignedInPerson person, Integer pendingActionId) throws QException, ConfirmationRefused @@ -795,8 +913,7 @@ public class ToolCallService Instant now = runtime.getClock().instant(); if(AssistantPendingAction.STATUS_PENDING.equals(action.getStatus()) && action.getExpiresAt() != null && action.getExpiresAt().isBefore(now)) { - 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.")); + store.expirePendingAction(action.getId()); action.setStatus(AssistantPendingAction.STATUS_EXPIRED); } if(!AssistantPendingAction.STATUS_PENDING.equals(action.getStatus())) @@ -835,19 +952,105 @@ public class ToolCallService } /////////////////////////////////////////////////////////////////////// - // claim it first, so a double click cannot run the write twice // + // claim it: of all the requests that confirm or cancel this action, // + // at the same moment or later, on this server or another, exactly // + // one is answered true. Every other is told it is already handled // /////////////////////////////////////////////////////////////////////// - store.updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", action.getId()).withValue("status", "RUNNING").withValue("decidedAt", now)); - AssistantPendingAction claimed = store.getPendingAction(userId, action.getId()); - if(claimed == null || !"RUNNING".equals(claimed.getStatus())) + boolean marked = true; + try { - throw (new ConfirmationRefused(ConfirmationRefused.Reason.ALREADY_HANDLED, "This action is already being handled.")); + if(!store.decidePendingAction(userId, action.getId(), AssistantPendingAction.STATUS_RUNNING, null, now)) + { + throw (new ConfirmationRefused(ConfirmationRefused.Reason.ALREADY_HANDLED, "This action is already being handled.")); + } + } + catch(AssistantStore.DecidedButNotMarked e) + { + /////////////////////////////////////////////////////////////////// + // the claim is this request's, and nobody else can decide the // + // action any more; its change is not run, and it ends FAILED // + /////////////////////////////////////////////////////////////////// + LOG.error("A pending assistant action was claimed but could not be marked RUNNING; it is not run", logPair("pendingActionId", action.getId()), logPair("tool", action.getToolName())); + marked = false; } - UiApiToolbox.Outcome outcome = runClaimed(person, action, policy); - finishClaimed(action, userId, outcome); - AssistantPendingAction decided = store.getPendingAction(userId, action.getId()); - return (new Confirmation(decided == null ? action : decided, outcome)); + /////////////////////////////////////////////////////////////////////// + // from here on the action is this request's: whatever goes wrong // + // ends in FAILED with a row, never in an action left RUNNING // + /////////////////////////////////////////////////////////////////////// + UiApiToolbox.Outcome outcome = marked ? runClaimed(person, action, policy) : failedOutcome(action, "The change could not be started; nothing changed. Ask for it again."); + boolean ok = finishClaimed(action, userId, outcome); + try + { + AssistantPendingAction decided = store.getPendingAction(userId, action.getId()); + if(decided != null) + { + return (new Confirmation(decided, outcome)); + } + } + catch(QException | RuntimeException e) + { + LOG.warn("A confirmed assistant action could not be read again; answering from what was written", logPair("pendingActionId", action.getId()), logPair("errorClass", e.getClass().getSimpleName())); + } + action.setStatus(ok ? AssistantPendingAction.STATUS_EXECUTED : AssistantPendingAction.STATUS_FAILED); + action.setDecidedAt(now); + action.setHttpStatus(outcome.getHttpStatus()); + action.setResultSummary(truncate(outcome.getResult().getSummary(), 500)); + return (new Confirmation(action, outcome)); + } + } + + + + /******************************************************************************* + ** Cancel a pending action for its person: nothing runs. It goes through + ** the same single decision as a confirmation, so an action is either + ** confirmed or cancelled, never both. Answers the action as it is now. + *******************************************************************************/ + public AssistantPendingAction cancel(SignedInPerson person, Integer pendingActionId) throws QException, ConfirmationRefused + { + if(person == null || person.userId() == null) + { + throw (new ConfirmationRefused(ConfirmationRefused.Reason.NO_ACCESS, "The cancellation has no signed-in person.")); + } + QSession session = person.session(); + String userId = person.userId(); + try(PersonsContext ignored = new PersonsContext(session)) + { + AssistantSecurity.ownerKey(session, runtime.getConfig()); + AiToolResult noAccess = noAccess(session, new AiToolResult()); + if(noAccess != null) + { + throw (new ConfirmationRefused(ConfirmationRefused.Reason.NO_ACCESS, noAccess.getModelContent())); + } + AssistantPendingAction action = pendingActionId == null ? null : store.getPendingAction(userId, pendingActionId); + if(action == null) + { + throw (new ConfirmationRefused(ConfirmationRefused.Reason.NOT_FOUND, "Action not found.")); + } + Instant now = runtime.getClock().instant(); + if(AssistantPendingAction.STATUS_PENDING.equals(action.getStatus()) && action.getExpiresAt() != null && action.getExpiresAt().isBefore(now)) + { + store.expirePendingAction(action.getId()); + action.setStatus(AssistantPendingAction.STATUS_EXPIRED); + } + if(!AssistantPendingAction.STATUS_PENDING.equals(action.getStatus())) + { + throw (new ConfirmationRefused(ConfirmationRefused.Reason.NOT_PENDING, "This action is already " + String.valueOf(action.getStatus()).toLowerCase() + ".")); + } + try + { + if(!store.decidePendingAction(userId, action.getId(), AssistantPendingAction.STATUS_REJECTED, "Cancelled by the user; nothing changed.", now)) + { + throw (new ConfirmationRefused(ConfirmationRefused.Reason.ALREADY_HANDLED, "This action is already being handled.")); + } + } + catch(AssistantStore.DecidedButNotMarked e) + { + LOG.error("A pending assistant action was cancelled but could not be marked REJECTED; it can no longer be confirmed", logPair("pendingActionId", action.getId())); + throw (e); + } + return (store.getPendingAction(userId, action.getId())); } } @@ -864,11 +1067,12 @@ public class ToolCallService { Map values = action.getParamsJson() == null ? Map.of() : JsonUtils.toObject(action.getParamsJson(), new TypeReference>() {}); QSession session = person.session(); - UiApiToolbox.Context context = new UiApiToolbox.Context(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), runtime.getUiApiGateway(), policy, null, + UiApiToolbox.Context context = UiApiToolbox.Context.of(PERMIT, session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), gateway, policy, null, person.credentials(), action.getTurnId() == null ? null : String.valueOf(action.getTurnId())); AssistantTool tool = registry.find(action.getToolName()); UiApiToolbox.Outcome outcome = tool == null ? UiApiToolbox.notAWriteTool(action.getToolCallId(), action.getToolName(), action.getTableName()) - : tool.executeConfirmed(new ConfirmedToolCall(session, context, action.getToolCallId(), action.getToolName(), action.getTableName(), action.getRecordId(), values, person.userId())); + : tool.executeConfirmed(ConfirmedToolCall.of(PERMIT, session, context, action.getToolCallId(), action.getToolName(), action.getTableName(), action.getRecordId(), values, person.userId(), + action.getRecordVersion())); if(outcome != null && outcome.getResult() != null) { return (outcome); @@ -879,8 +1083,18 @@ public class ToolCallService { LOG.warn("A confirmed assistant action threw", logPair("pendingActionId", action.getId()), logPair("tool", action.getToolName()), logPair("errorClass", e.getClass().getSimpleName())); } + return (failedOutcome(action, "The change failed unexpectedly. Check the record before trying again.")); + } + + + + /******************************************************************************* + ** The fixed ERROR outcome of a claimed action that did not give one itself. + *******************************************************************************/ + private static UiApiToolbox.Outcome failedOutcome(AssistantPendingAction action, String message) + { AiToolResult failed = new AiToolResult().withToolCallId(action.getToolCallId()).withToolName(action.getToolName()).withTableName(action.getTableName()) - .withOutcome(AiToolOutcome.ERROR).withSummary("Failed").withModelContent("The change failed unexpectedly. Check the record before trying again."); + .withOutcome(AiToolOutcome.ERROR).withSummary("Failed").withModelContent(message); failed.setDurationMs(0L); return (UiApiToolbox.Outcome.of(failed, null, null, null)); } @@ -892,19 +1106,16 @@ public class ToolCallService ** The row is written even when the mark fails. A confirmed write may have ** changed data, so a mark or a row that cannot be written is an ERROR in ** the log for the operator, never only a warning; the person still gets the - ** outcome of their change. + ** outcome of their change. Never throws; answers whether the change was + ** made. *******************************************************************************/ - private void finishClaimed(AssistantPendingAction action, String userId, UiApiToolbox.Outcome outcome) + private boolean finishClaimed(AssistantPendingAction action, String userId, UiApiToolbox.Outcome outcome) { AiToolResult result = outcome.getResult(); boolean ok = result.getOutcome() == AiToolOutcome.OK; try { - store.updateFields(AssistantPendingAction.TABLE_NAME, new QRecord() - .withValue("id", action.getId()) - .withValue("status", ok ? AssistantPendingAction.STATUS_EXECUTED : AssistantPendingAction.STATUS_FAILED) - .withValue("httpStatus", outcome.getHttpStatus()) - .withValue("resultSummary", truncate(result.getSummary(), 500))); + store.finishPendingAction(action.getId(), ok ? AssistantPendingAction.STATUS_EXECUTED : AssistantPendingAction.STATUS_FAILED, outcome.getHttpStatus(), truncate(result.getSummary(), 500)); } catch(QException | RuntimeException e) { @@ -922,7 +1133,7 @@ public class ToolCallService .withUserId(userId) .withToolCallId(action.getToolCallId()) .withToolName(action.getToolName()) - .withInputJson(truncate(INPUT_REDACTOR.redact(action.getParamsJson() == null ? "{}" : action.getParamsJson(), new HashSet<>()), 4000)) + .withInputJson(truncate(inputRedactor.redact(action.getParamsJson() == null ? "{}" : action.getParamsJson(), new HashSet<>()), 4000)) .withOutcome(outcomeName(result)) .withPermissionCheck(result.getPermissionCheck()) .withTableName(action.getTableName()) @@ -941,6 +1152,7 @@ public class ToolCallService logPair("tool", action.getToolName()), logPair("outcome", outcomeName(result)), logPair("errorClass", e.getClass().getSimpleName())); } } + return (ok); } @@ -956,9 +1168,11 @@ public class ToolCallService ** It never throws, so one row that cannot be written does not cost the ** rows after it: the answer says whether the row was written. A row that ** could not be written is reported here, as an ERROR when the call left - ** something behind (a proposed write, an audited change). + ** something behind (a proposed write, an audited change). Package-private, + ** like {@link #execute}: no class outside this package can write a row for + ** a call that did not run. *******************************************************************************/ - public boolean log(ToolCall call, ToolCallScope scope, ToolCallOutcome outcome) + boolean log(ToolCall call, ToolCallScope scope, ToolCallOutcome outcome) { try { @@ -994,7 +1208,7 @@ public class ToolCallService owned = admission.owned; channel = admission.channel; } - String inputJson = INPUT_REDACTOR.redact(JsonUtils.toJson(inputForLog(tool, call, result)), new HashSet<>()); + String inputJson = inputRedactor.redact(JsonUtils.toJson(inputForLog(tool, call, result)), new HashSet<>()); store.insert(new AssistantToolCall() .withTurnId(owned ? scope.turnId() : null) .withConversationId(owned ? scope.conversationId() : null) @@ -1090,16 +1304,6 @@ public class ToolCallService - /******************************************************************************* - ** Getter for registry - *******************************************************************************/ - public ToolRegistry getRegistry() - { - return (registry); - } - - - /******************************************************************************* ** *******************************************************************************/ diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java index 97db8ea..a97abde 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java @@ -210,7 +210,6 @@ public class TurnEngine implements Runnable this.policy = policy; this.running = running; this.softBudgetNotice = softBudgetNotice; - this.person = SignedInPerson.ofSession(session); this.startedAt = turn.getStartedAt() == null ? runtime.getClock().instant() : turn.getStartedAt(); this.deadline = startedAt.plusSeconds(config.getMaxTurnSeconds()); running.setStartedAt(startedAt); @@ -221,9 +220,11 @@ public class TurnEngine implements Runnable /******************************************************************************* ** The signed-in person the turn runs for, as the route's session setup - ** bound them: this turn's session with the credentials of the request that - ** started it, which the tools forward to the UI API. Without it the turn - ** runs with the session alone. A person of another session is refused. + ** 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 withPerson(SignedInPerson person) { @@ -1299,7 +1300,13 @@ public class TurnEngine implements Runnable AiToolResult rowResult = cutShort && real != null && AiToolOutcome.OK.equals(real.getOutcome()) ? real : result; UiApiToolbox.Outcome rowExtras = cutShort ? realExtras : extras; - ModelPart part = null; + /////////////////////////////////////////////////////////////////////// + // 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) @@ -1309,6 +1316,7 @@ public class TurnEngine implements Runnable } 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 // @@ -1323,6 +1331,14 @@ public class TurnEngine implements Runnable // 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) diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/home/AssistantAccessWidget.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/home/AssistantAccessWidget.java index 7880ea8..11a8034 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/home/AssistantAccessWidget.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/home/AssistantAccessWidget.java @@ -162,7 +162,7 @@ public final class AssistantAccessWidget List 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) { diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantMetaDataBuilder.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantMetaDataBuilder.java index 178535e..ea573cb 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantMetaDataBuilder.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantMetaDataBuilder.java @@ -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) diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantRecordScreens.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantRecordScreens.java index 36387d7..87676e6 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantRecordScreens.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantRecordScreens.java @@ -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 -> { @@ -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")); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantPendingAction.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantPendingAction.java index f358a98..409c303 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantPendingAction.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantPendingAction.java @@ -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); + } + } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantPendingActionClaim.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantPendingActionClaim.java new file mode 100644 index 0000000..d1ce1ce --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantPendingActionClaim.java @@ -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); + } + +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java index a433303..9d4eb48 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java @@ -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,15 @@ 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 USAGE_LOCKS = new ConcurrentHashMap<>(); + private static final Object DECISION_LOCK = new Object(); + + private static final List DECIDED_STATUSES = List.of(AssistantPendingAction.STATUS_EXECUTED, AssistantPendingAction.STATUS_FAILED, + AssistantPendingAction.STATUS_REJECTED, AssistantPendingAction.STATUS_EXPIRED); + 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 +385,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 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 +503,190 @@ 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 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). + ** + ** 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. + *******************************************************************************/ + public 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 a pending action that lapsed before it was decided. + *******************************************************************************/ + public void expirePendingAction(Integer id) throws QException + { + updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", AssistantPendingAction.STATUS_EXPIRED) + .withValue("resultSummary", "Expired before it was confirmed; nothing changed.")); + } + + + + /******************************************************************************* + ** 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)); + } + } + + + + /******************************************************************************* + ** 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: it shows a change whose end was never recorded. + *******************************************************************************/ + public int deletePendingActionsBefore(Instant cutoff, Instant now, int limit) throws QException + { + List 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 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 +1152,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 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`. diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/TranscriptRedactor.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/TranscriptRedactor.java index 4d2b1d8..f51e5ac 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/TranscriptRedactor.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/TranscriptRedactor.java @@ -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 CURRENT = new ThreadLocal<>(); - private static final Set PAYLOAD_FIELDS = Set.of("partsJson", "inputJson", "resultJson", "redactionsJson", "dataJson", "paramsJson", + private static final Set 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. diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/processes/AssistantRetentionStep.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/processes/AssistantRetentionStep.java index 0579c33..c2652a1 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/processes/AssistantRetentionStep.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/processes/AssistantRetentionStep.java @@ -45,7 +45,10 @@ 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; + ** - 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. *******************************************************************************/ @@ -74,6 +77,7 @@ public class AssistantRetentionStep implements BackendStep output.addValue("messagesStripped", result.messagesStripped()); output.addValue("turnsFinalized", result.turnsFinalized()); output.addValue("clientErrorsDeleted", result.clientErrorsDeleted()); + output.addValue("pendingActionsDeleted", result.pendingActionsDeleted()); } @@ -81,7 +85,7 @@ 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) { } @@ -179,10 +183,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)); } } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/RagService.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/RagService.java index bb3f3ca..9d9b688 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/RagService.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/RagService.java @@ -30,6 +30,7 @@ import com.kingsrook.qbits.assistant.engine.PolicyService; 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); @@ -144,8 +147,8 @@ public class RagService 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(Set.of(), policy == null ? 50 : policy.maxRowsPerCall(), policy == null || policy.redactPii()), null, context.credentials(), context.turnId()); + UiApiToolbox.Context internal = UiApiToolbox.Context.forRead(context.sessionUUID(), context.userTimezone(), context.gateway(), + new PolicyService.ToolPolicy(Set.of(), policy == null ? 50 : policy.maxRowsPerCall(), policy == null || policy.redactPii()), context.credentials(), context.turnId()); return (reader.read(internal, "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()) { @@ -378,9 +381,9 @@ public class RagService /******************************************************************************* ** Current caller context, never a stored or service session. *******************************************************************************/ - 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)); + return (UiApiToolbox.Context.forRead(QContext.getQSession().getUuid(), QContext.getQSession().getValue("userTimezone"), gateway, runtime.getPolicyService().current(true).tools(), null, null)); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTaskRunner.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTaskRunner.java index 2a5b922..eb7e860 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTaskRunner.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTaskRunner.java @@ -119,7 +119,7 @@ public class ScheduledTaskRunner { session = login.login(runtime, task); QContext.init(runtime.getQInstance(), session); - AssistantApi api = new AssistantApi(runtime, SignedInPerson.ofSession(session)); + 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) diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/KnowledgeTool.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/KnowledgeTool.java index 659db60..1bc6c91 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/KnowledgeTool.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/KnowledgeTool.java @@ -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) diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolFamily.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolFamily.java index ea72846..db86974 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolFamily.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolFamily.java @@ -218,7 +218,7 @@ public class UiApiToolFamily implements ToolFamily { 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())); + return (toolbox.executeConfirmedWrite(call.context(), call.toolCallId(), call.toolName(), call.tableName(), call.recordId(), call.values(), call.recordVersion())); } } } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java index 7ea8508..687b680 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java @@ -18,20 +18,27 @@ 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.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; @@ -144,6 +151,9 @@ public class UiApiToolbox 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; @@ -170,14 +180,46 @@ 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. The context of a tool call (the only one + ** that has somewhere to keep a proposed change) is made by + ** engine.ToolCallService with its permit ({@link #of}); the context of a + ** read the assistant makes for itself ({@link #forRead}) has nowhere to + ** keep one, and no tool is run with it. *******************************************************************************/ - 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); } @@ -187,13 +229,37 @@ 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)); + } + + + + /******************************************************************************* + ** The context of a read the assistant makes for itself (see + ** {@link UiApiToolbox#read}): it has nowhere to keep a proposed change. + *******************************************************************************/ + public static Context forRead(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, UiApiCredentials credentials, String turnId) + { + return (new Context(sessionUUID, userTimezone, gateway, policy, 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 @@ -203,12 +269,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 @@ -222,10 +359,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 values, String summary) + public record ProposedWrite(String toolCallId, String toolName, String operation, String tableName, String recordId, Map values, String summary, String kind, + Map 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 values, String summary) + { + this(toolCallId, toolName, operation, tableName, recordId, values, summary, null, null, null); + } } @@ -567,8 +727,13 @@ public class UiApiToolbox ** Run a confirmed write (from a pending action) with the confirming user's ** 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. *******************************************************************************/ - Outcome executeConfirmedWrite(Context context, String toolCallId, String toolName, String tableName, String recordId, Map values) + Outcome executeConfirmedWrite(Context context, String toolCallId, String toolName, String tableName, String recordId, Map values, String recordVersion) { AiToolResult result = new AiToolResult().withToolCallId(toolCallId).withToolName(toolName).withTableName(tableName); Outcome outcome = new Outcome(result); @@ -587,6 +752,10 @@ public class UiApiToolbox deny(result, "dataAccess:" + decision.mode(), "The assistant may not write to " + tableName + " (data 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); } catch(AiToolException e) @@ -1357,42 +1526,71 @@ public class UiApiToolbox } } + /////////////////////////////////////////////////////////////////////// + // 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 before = null; + String version = null; + String recordLabel = null; + if(recordId != null) + { + String proposing = outcome.operation; + Map record = readForWrite(context, outcome, tableName, recordId); + if(record == null) + { + return; + } + outcome.operation = proposing; + outcome.httpStatus = null; + Set 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); + } + /////////////////////////////////////////////////////////////////////// // 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)); + : 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; - List> changes = new ArrayList<>(); - values.forEach((name, value) -> - { - QFieldMetaData field = table.getFields().get(name); - Map 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 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", changes); + 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."); @@ -1400,6 +1598,69 @@ public class UiApiToolbox + /******************************************************************************* + ** 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 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 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 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); + } + + + /******************************************************************************* ** The write itself: one UI API call with the user's session. *******************************************************************************/ diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/registry/ActionKinds.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/registry/ActionKinds.java new file mode 100644 index 0000000..c9fbecf --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/registry/ActionKinds.java @@ -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> changes(QTableMetaData table, Map values, Map before) + { + List> changes = new ArrayList<>(); + values.forEach((name, value) -> + { + Map 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> fieldLines(QTableMetaData table, Map visible) + { + List> lines = new ArrayList<>(); + (visible == null ? Map.of() : visible).forEach((name, value) -> + { + Map 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()); + } +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/registry/ConfirmedToolCall.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/registry/ConfirmedToolCall.java index 600eaf4..0b9b3d1 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/registry/ConfirmedToolCall.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/registry/ConfirmedToolCall.java @@ -18,27 +18,170 @@ 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 write the person has confirmed: the pending action's tool call, + ** 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 makes one (ArchUnit), from the stored pending - ** action it loaded for that person and claimed; a caller chooses none of the - ** table, the record or the values. ** - ** @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 write - ** @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) + ** 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 record ConfirmedToolCall(QSession session, UiApiToolbox.Context context, String toolCallId, String toolName, String tableName, String recordId, Map values, String userId) +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 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 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 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 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); + } } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/registry/ToolCallRequest.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/registry/ToolCallRequest.java index 993fd97..e7f0cb2 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/registry/ToolCallRequest.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/registry/ToolCallRequest.java @@ -18,7 +18,9 @@ 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; @@ -27,22 +29,63 @@ 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 makes one (ArchUnit), from the {@link ToolCall} a - ** caller handed it: the context, with the gateway, the policy in force and - ** where a proposed write is kept, is the service's own, never a caller's. + ** 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. ** - ** @param session the signed-in person's own session - ** @param context that same session for the UI API (its sessionUUID is the - ** session's, its credentials the person's own), with the - ** runtime's gateway, the administrator's policy as the - ** service read it and where a proposed write is kept - ** @param toolCallId the model's id of the call - ** @param toolName the tool called - ** @param input the model's input - ** @param scope where the call runs + ** 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 record ToolCallRequest(QSession session, UiApiToolbox.Context context, String toolCallId, String toolName, Map input, ToolCallScope scope) +public final class ToolCallRequest { + private final QSession session; + private final UiApiToolbox.Context context; + private final String toolCallId; + private final String toolName; + private final Map input; + private final ToolCallScope scope; + + + + /******************************************************************************* + ** For this package only (and its tests). + *******************************************************************************/ + ToolCallRequest(QSession session, UiApiToolbox.Context context, String toolCallId, String toolName, Map 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 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. @@ -59,4 +102,65 @@ public record ToolCallRequest(QSession session, UiApiToolbox.Context context, St 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 input() + { + return (input); + } + + + + /******************************************************************************* + ** Where the call runs. + *******************************************************************************/ + public ToolCallScope scope() + { + return (scope); + } } diff --git a/qbit-assistant/src/main/resources/help/qbit-assistant.yaml b/qbit-assistant/src/main/resources/help/qbit-assistant.yaml index 4650587..434f603 100644 --- a/qbit-assistant/src/main/resources/help/qbit-assistant.yaml +++ b/qbit-assistant/src/main/resources/help/qbit-assistant.yaml @@ -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: | diff --git a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml index 727f7ab..79ce2e7 100644 --- a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml +++ b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml @@ -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 @@ -490,7 +541,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 +1412,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" diff --git a/qbit-assistant/src/test/java/com/example/assistanthost/HostToolFamilyTest.java b/qbit-assistant/src/test/java/com/example/assistanthost/HostToolFamilyTest.java new file mode 100644 index 0000000..21a8cfb --- /dev/null +++ b/qbit-assistant/src/test/java/com/example/assistanthost/HostToolFamilyTest.java @@ -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 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 bypass : bypasses.entrySet()) + { + List 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", "offers", "outcomeName", "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 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 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 errors = new ArrayList<>(); + for(Diagnostic 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 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 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)); + } + } +} diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantAuditAttributionTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantAuditAttributionTest.java index 8ac3a09..f46d41d 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantAuditAttributionTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantAuditAttributionTest.java @@ -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 result = new AssistantApi(config.getRuntime(), QContext.getQSession()).confirmAction(action.getId()); assertThat(result.get("status")).isEqualTo(AssistantPendingAction.STATUS_EXECUTED); diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantChannelWallTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantChannelWallTest.java index a7b2446..09e2d1f 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantChannelWallTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantChannelWallTest.java @@ -112,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 = Toolboxes.execute(config.getRuntime(), new UiApiToolbox.Context(AssistantTestFixture.ADMIN, null, - config.getRuntime().getUiApiGateway(), PolicyService.ToolPolicy.defaults(), 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(); @@ -147,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"))); } diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantTablesGuardrailTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantTablesGuardrailTest.java index 6c29a37..ca8b2a8 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantTablesGuardrailTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantTablesGuardrailTest.java @@ -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"), diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/RuntimeInternals.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/RuntimeInternals.java new file mode 100644 index 0000000..78e6c2e --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/RuntimeInternals.java @@ -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 ownToolFamilies(AssistantRuntime runtime) + { + return (runtime.ownToolFamilies()); + } +} diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/SignedInPersonTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/SignedInPersonTest.java new file mode 100644 index 0000000..999f3ca --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/SignedInPersonTest.java @@ -0,0 +1,112 @@ +/* + * 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.qbits.assistant.api.AssistantApi; +import com.kingsrook.qbits.assistant.api.AssistantApiException; +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 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(); + } + + + + /******************************************************************************* + ** 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(); + } +} diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/access/ProfilesSkillsAndAccessTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/access/ProfilesSkillsAndAccessTest.java index b4e4616..3c048a2 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/access/ProfilesSkillsAndAccessTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/access/ProfilesSkillsAndAccessTest.java @@ -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; @@ -185,7 +186,7 @@ class ProfilesSkillsAndAccessTest extends BaseAssistantTest QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN)); Map 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(" card = (Map) 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>) 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 read = api(ADMIN).getAction(stored.getId()); + assertThat(read).containsEntry("status", "PENDING").containsEntry("actionKind", ActionKinds.RECORD_UPDATE).containsKeys("tableLabel", "recordHref", "expiresAt", "createdAt"); + assertThat((List>) 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 card = (Map) proposed.result().getRender(); + assertThat(card).containsEntry("actionKind", ActionKinds.RECORD_DELETE); + assertThat((List>) 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>) 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 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 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 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(); + + ToolCallOutcome denied = propose(ANALYST, UiApiToolbox.DELETE_RECORD, input("table", "blog_post", "id", "1")); + assertThat(denied.result().getOutcome()).isIn(AiToolOutcome.DENIED, AiToolOutcome.NOT_FOUND); + assertThat(denied.extras().getPendingActionId()).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> waiting = (List>) 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> all = (List>) 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>) api(ADMIN).listActions(null, conversation).get("actions")).extracting(action -> action.get("id")).containsExactly(second); + + assertThat((List>) 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 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 asUser(String user, Work 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 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 actions(String user) throws Exception + { + return (asUser(user, () -> store().query(AssistantPendingAction.TABLE_NAME, new QQueryFilter(), AssistantPendingAction.class))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private List claims() throws Exception + { + return (asUser(ADMIN, () -> store().query(AssistantPendingActionClaim.TABLE_NAME, new QQueryFilter(), AssistantPendingActionClaim.class))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private List 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))); + }); + } +} diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/CutShortProposalTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/CutShortProposalTest.java new file mode 100644 index 0000000..04f622d --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/CutShortProposalTest.java @@ -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 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 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 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 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 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 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(); + } + } + } +} diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/PendingActionDecisionTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/PendingActionDecisionTest.java new file mode 100644 index 0000000..b9c2abe --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/PendingActionDecisionTest.java @@ -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> 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 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 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 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 asAdmin(Work work) throws Exception + { + QContext.init(qInstance, AssistantTestFixture.ownerSession(ADMIN, config)); + try + { + return (work.run()); + } + finally + { + QContext.clear(); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private interface Work + { + /******************************************************************************* + ** + *******************************************************************************/ + 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 List own(String tableName, Class 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 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 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 List query(String tableName, QQueryFilter filter, Class 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)); + } + } +} diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/StarterPromptsTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/StarterPromptsTest.java index 3f34370..a8d8b4d 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/StarterPromptsTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/StarterPromptsTest.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +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; @@ -73,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)); } @@ -97,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 analyst = starter.forUser(context(AssistantTestFixture.ANALYST, gateway), AssistantTestFixture.ANALYST, List.of(), false); assertThat(analyst).isNotEmpty().hasSizeLessThanOrEqualTo(3); @@ -124,7 +125,7 @@ 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(Toolboxes.data(config.getRuntime()), Clock.fixed(NOW.plusSeconds(86400L * day), ZoneOffset.UTC)); @@ -142,7 +143,7 @@ class StarterPromptsTest extends BaseAssistantTest @Test void stableForTheDay() throws Exception { - UiApiGateway gateway = config.getRuntime().getUiApiGateway(); + UiApiGateway gateway = RuntimeInternals.gateway(config.getRuntime()); List first = prompts(NOW).forUser(context(AssistantTestFixture.EDITOR, gateway), AssistantTestFixture.EDITOR, List.of(), true); List later = prompts(NOW.plusSeconds(3600)).forUser(context(AssistantTestFixture.EDITOR, gateway), AssistantTestFixture.EDITOR, List.of(), true); assertThat(later).isEqualTo(first); @@ -194,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() { @@ -263,7 +264,7 @@ class StarterPromptsTest extends BaseAssistantTest void configuredPromptsRespectTotalLimit() throws Exception { StarterPrompts starter = prompts(NOW); - List result = starter.forUser(context(AssistantTestFixture.LIMITED, config.getRuntime().getUiApiGateway()), AssistantTestFixture.LIMITED, + List 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"); } @@ -277,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"); @@ -285,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()); } @@ -298,13 +299,13 @@ 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, + 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(); } @@ -318,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"); } @@ -332,7 +333,7 @@ class StarterPromptsTest extends BaseAssistantTest void revokedDateFieldRemovesRecencyPrompts() 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())); 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\""); @@ -354,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())); diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/ToolCallServiceInternals.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/ToolCallServiceInternals.java new file mode 100644 index 0000000..b1f105d --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/ToolCallServiceInternals.java @@ -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); + } +} diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/ToolCallServiceTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/ToolCallServiceTest.java index a7800df..a8367b6 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/ToolCallServiceTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/ToolCallServiceTest.java @@ -24,6 +24,7 @@ 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; @@ -207,7 +208,7 @@ class ToolCallServiceTest extends BaseAssistantTest @Test void aToolHandedNoPolicyRefuses() { - UiApiToolbox.Context noPolicy = new UiApiToolbox.Context(EDITOR, "UTC", config.getRuntime().getUiApiGateway(), null, null); + UiApiToolbox.Context noPolicy = Toolboxes.context(EDITOR, "UTC", RuntimeInternals.gateway(config.getRuntime()), null, null); AssistantRuntime runtime = config.getRuntime(); QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config)); @@ -238,17 +239,17 @@ class ToolCallServiceTest extends BaseAssistantTest 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.getParameterCount()).as(factory.getName() + " takes one thing: a session, or a request").isEqualTo(1); - assertThat(factory.getParameterTypes()).doesNotContain(UiApiCredentials.class); + 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(analyst); + SignedInPerson person = SignedInPerson.ofSession(config.getRuntime().getQInstance(), analyst); assertThat(person.session()).isSameAs(analyst); 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(null)).isInstanceOf(IllegalArgumentException.class); + 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()); @@ -395,7 +396,7 @@ class ToolCallServiceTest extends BaseAssistantTest Integer actionId = proposeAPost(); UiApiTestServer.SCHEDULED_REQUESTS.clear(); - ToolCallService.Confirmation confirmation = service().confirm(SignedInPerson.ofSession(AssistantTestFixture.sessionFor(ADMIN)), actionId); + 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)); @@ -431,7 +432,7 @@ class ToolCallServiceTest extends BaseAssistantTest Integer actionId = proposed.extras().getPendingActionId(); assertThat(actionId).isNotNull(); - ToolCallService.Confirmation confirmation = service().confirm(SignedInPerson.ofSession(AssistantTestFixture.sessionFor(EDITOR)), actionId); + 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); @@ -457,7 +458,7 @@ class ToolCallServiceTest extends BaseAssistantTest { QContext.clear(); } - ToolCallService.Confirmation second = service().confirm(SignedInPerson.ofSession(AssistantTestFixture.sessionFor(EDITOR)), unreadable); + 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); @@ -666,7 +667,7 @@ class ToolCallServiceTest extends BaseAssistantTest *******************************************************************************/ private void assertConfirmationRefused(String person, Integer actionId, ToolCallService.ConfirmationRefused.Reason reason) { - assertThatThrownBy(() -> service().confirm(SignedInPerson.ofSession(AssistantTestFixture.sessionFor(person)), actionId)) + assertThatThrownBy(() -> service().confirm(SignedInPerson.ofSession(config.getRuntime().getQInstance(), AssistantTestFixture.sessionFor(person)), actionId)) .isInstanceOfSatisfying(ToolCallService.ConfirmationRefused.class, e -> assertThat(e.getReason()).isEqualTo(reason)); } diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/persistence/SchemaForeignKeyPostgresTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/persistence/SchemaForeignKeyPostgresTest.java index c51ae3e..c28496a 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/persistence/SchemaForeignKeyPostgresTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/persistence/SchemaForeignKeyPostgresTest.java @@ -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"); diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/rag/RagTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/rag/RagTest.java index c56bfca..191d5f8 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/rag/RagTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/rag/RagTest.java @@ -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; @@ -319,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(); @@ -341,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 -> @@ -370,7 +371,7 @@ class RagTest extends BaseAssistantTest assertThat(denied.getRecordRefs()).isEmpty(); assertThat(denied.getModelContent()).doesNotContain("blog_post", "Drain", "/app/"); assertThat(Toolboxes.knowledge(config.getRuntime()).offered(PolicyService.ToolPolicy.defaults(), QContext.getQSession())).isFalse(); - assertThat(config.getRuntime().getUiApiGateway().invoke(new UiApiCall("GET", "/table/{tableName}/{primaryKey}") + 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); } @@ -546,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); @@ -557,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); } @@ -575,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); @@ -618,7 +619,7 @@ class RagTest extends BaseAssistantTest assertThat(clearReview.getProcessSummary(clearOutput, false)).hasSize(1); var policy = new PolicyService.ToolPolicy(Set.of("search_help"), 50, true); assertThat(Toolboxes.knowledge(config.getRuntime()).offered(policy, QContext.getQSession())).isFalse(); - var context = new UiApiToolbox.Context(AssistantTestFixture.ADMIN, null, config.getRuntime().getUiApiGateway(), policy, null); + 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); diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTasksTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTasksTest.java index 1113a0d..931cd90 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTasksTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTasksTest.java @@ -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; @@ -589,7 +590,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"); diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/ToolCalls.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/ToolCalls.java index bec1c03..e86a082 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/ToolCalls.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/ToolCalls.java @@ -47,7 +47,7 @@ public final class ToolCalls *******************************************************************************/ public static ToolCall call(AssistantQBitConfig config, String person, String toolName, Map input) { - return (new ToolCall(SignedInPerson.ofSession(AssistantTestFixture.ownerSession(person, config)), "call-1", toolName, input)); + return (new ToolCall(SignedInPerson.ofSession(config.getRuntime().getQInstance(), AssistantTestFixture.ownerSession(person, config)), "call-1", toolName, input)); } diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/AnalysisSaveTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/AnalysisSaveTest.java index c85719a..4acf3e7 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/AnalysisSaveTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/AnalysisSaveTest.java @@ -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; @@ -385,7 +386,7 @@ class AnalysisSaveTest extends BaseAssistantTest QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config)); try { - return (Toolboxes.execute(config.getRuntime(), new UiApiToolbox.Context(user, null, config.getRuntime().getUiApiGateway(), policy == null ? PolicyService.ToolPolicy.defaults() : 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) diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/AnalysisToolsTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/AnalysisToolsTest.java index 44c4b2d..d0da189 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/AnalysisToolsTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/AnalysisToolsTest.java @@ -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; @@ -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 (Toolboxes.execute(config.getRuntime(), new UiApiToolbox.Context(user, null, config.getRuntime().getUiApiGateway(), policy == null ? PolicyService.ToolPolicy.defaults() : 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 { diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/PresentResultsToolTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/PresentResultsToolTest.java index 8ad4bd0..8522ee9 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/PresentResultsToolTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/PresentResultsToolTest.java @@ -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)); @@ -270,6 +271,6 @@ class PresentResultsToolTest extends BaseAssistantTest *******************************************************************************/ private AiToolResult execute(Map 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)); } } diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/Toolboxes.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/Toolboxes.java index bd2ce47..cf9bcd7 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/Toolboxes.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/Toolboxes.java @@ -19,9 +19,16 @@ package com.kingsrook.qbits.assistant.tools; import java.util.Map; import com.kingsrook.qbits.assistant.AssistantRuntime; +import com.kingsrook.qbits.assistant.RuntimeInternals; +import com.kingsrook.qbits.assistant.engine.PolicyService; import com.kingsrook.qbits.assistant.tools.registry.ToolFamily; +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.AiToolResult; import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; /******************************************************************************* @@ -47,7 +54,7 @@ public final class Toolboxes *******************************************************************************/ public static UiApiToolbox data(AssistantRuntime runtime) { - for(ToolFamily family : runtime.ownToolFamilies()) + for(ToolFamily family : RuntimeInternals.ownToolFamilies(runtime)) { if(family instanceof UiApiToolFamily dataTools) { @@ -64,7 +71,7 @@ public final class Toolboxes *******************************************************************************/ public static KnowledgeTool knowledge(AssistantRuntime runtime) { - for(ToolFamily family : runtime.ownToolFamilies()) + for(ToolFamily family : RuntimeInternals.ownToolFamilies(runtime)) { if(family instanceof KnowledgeToolFamily knowledgeTools) { @@ -94,4 +101,51 @@ public final class Toolboxes { return (knowledge(runtime).execute(context, callId, input, classification, conversationId, turnId)); } + + + + /******************************************************************************* + ** A context as a test makes it. Main code cannot: the context of a tool + ** call is the tool-call service's, and one for a read has nowhere to keep + ** a proposed change. + *******************************************************************************/ + public static UiApiToolbox.Context context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, UiApiToolbox.PendingActions pendingActions, + UiApiCredentials credentials, String turnId) + { + return (new UiApiToolbox.Context(sessionUUID, userTimezone, gateway, policy, pendingActions, credentials, turnId)); + } + + + + /******************************************************************************* + ** With the request credentials, outside a turn. + *******************************************************************************/ + public static UiApiToolbox.Context context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, UiApiToolbox.PendingActions pendingActions, + UiApiCredentials credentials) + { + return (new UiApiToolbox.Context(sessionUUID, userTimezone, gateway, policy, pendingActions, credentials, null)); + } + + + + /******************************************************************************* + ** With the session UUID only. + *******************************************************************************/ + public static UiApiToolbox.Context context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, UiApiToolbox.PendingActions pendingActions) + { + return (new UiApiToolbox.Context(sessionUUID, userTimezone, gateway, policy, pendingActions, null, null)); + } + + + + /******************************************************************************* + ** The version of a record as a session can read it now: what a proposed + ** update or delete keeps, for a test that stores a pending action itself. + *******************************************************************************/ + public static String recordVersion(AssistantRuntime runtime, String sessionUUID, String tableName, String recordId) throws QException + { + UiApiResult got = RuntimeInternals.gateway(runtime).invoke(new UiApiCall("GET", "/table/{tableName}/{primaryKey}").withPathParam("tableName", tableName).withPathParam("primaryKey", recordId) + .withSessionUUID(sessionUUID)); + return (UiApiToolbox.recordVersion(tableName, recordId, UiApiToolbox.asMap(UiApiToolbox.asMap(got.json()).get("record")))); + } } diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java index e54305a..04fc4e3 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java @@ -22,9 +22,11 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +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.api.AssistantApi; import com.kingsrook.qbits.assistant.engine.PolicyService; @@ -94,7 +96,7 @@ class UiApiToolsSecurityTest extends BaseAssistantTest UiApiTestServer.serve(qInstance, config); for(String table : List.of("auditTable", "audit", "auditDetail")) { - UiApiResult result = config.getRuntime().getUiApiGateway().invoke(new UiApiCall("POST", "/table/{tableName}/query") + UiApiResult result = RuntimeInternals.gateway(config.getRuntime()).invoke(new UiApiCall("POST", "/table/{tableName}/query") .withPathParam("tableName", table).withJsonBody(Map.of()).withSessionUUID(AssistantTestFixture.EDITOR)); assertThat(result.status()).isEqualTo(403); } @@ -184,10 +186,10 @@ class UiApiToolsSecurityTest extends BaseAssistantTest QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config)); try { - return (Toolboxes.execute(config.getRuntime(), new UiApiToolbox.Context(user, null, config.getRuntime().getUiApiGateway(), policy == null ? PolicyService.ToolPolicy.defaults() : 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).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) + .withSummary(write.summary()).withStatus(AssistantPendingAction.STATUS_PENDING).withActionKind(write.kind()).withRecordVersion(write.recordVersion()) .withExpiresAt(java.time.Instant.now().plusSeconds(600))).getId()), "call-1", toolName, in)); } finally @@ -226,6 +228,21 @@ class UiApiToolsSecurityTest extends BaseAssistantTest private Map proposedThenConfirmed(String user, String toolName, Map in) throws Exception { UiApiToolbox.Outcome proposed = tool(user, null, toolName, in); + if(!UiApiToolbox.CREATE_RECORD.equals(toolName) && proposed.getResult().getOutcome() != AiToolOutcome.OK) + { + /////////////////////////////////////////////////////////////////////// + // an update or a delete is proposed for the record as the person // + // can read it: one they cannot read is refused when it is proposed, // + // with the answer of the UI API's own read, and there is no card // + /////////////////////////////////////////////////////////////////////// + assertThat(proposed.getPendingActionId()).as("a refused proposal makes no card").isNull(); + assertThat(proposed.getOperation()).as("refused by the read of the record").isEqualTo("GET /table/{tableName}/{primaryKey}"); + Map refused = new LinkedHashMap<>(); + refused.put("httpStatus", proposed.getHttpStatus()); + refused.put("outcome", proposed.getResult().getOutcome().name()); + refused.put("message", proposed.getResult().getModelContent()); + return (refused); + } assertThat(proposed.getResult().getOutcome()).as(toolName + " proposes").isEqualTo(AiToolOutcome.OK); assertThat(proposed.getPendingActionId()).as(toolName + " proposes").isNotNull(); assertThat(proposed.getHttpStatus()).as("nothing runs before confirmation").isNull(); @@ -411,13 +428,13 @@ class UiApiToolsSecurityTest extends BaseAssistantTest void testOnlySpecdOperationsWithASession() { UiApiTestServer.serve(qInstance, config); - assertThatThrownBy(() -> config.getRuntime().getUiApiGateway().invoke(new UiApiCall("POST", "/assistant/conversations").withSessionUUID(AssistantTestFixture.ADMIN))) + assertThatThrownBy(() -> RuntimeInternals.gateway(config.getRuntime()).invoke(new UiApiCall("POST", "/assistant/conversations").withSessionUUID(AssistantTestFixture.ADMIN))) .hasMessageContaining("Not a spec'd"); - assertThatThrownBy(() -> config.getRuntime().getUiApiGateway().invoke(new UiApiCall("GET", "/metaData"))) + assertThatThrownBy(() -> RuntimeInternals.gateway(config.getRuntime()).invoke(new UiApiCall("GET", "/metaData"))) .hasMessageContaining("session"); for(String[] operation : UiApiToolbox.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(); } } @@ -436,7 +453,7 @@ class UiApiToolsSecurityTest extends BaseAssistantTest assertThat(UiApiToolbox.OPERATIONS.keySet()).doesNotContainAnyElementsOf(UiApiToolbox.EXTENSION_OPERATIONS.keySet()); for(String[] operation : UiApiToolbox.EXTENSION_OPERATIONS.values()) { - assertThat(config.getRuntime().getUiApiGateway().isInvocable(operation[0], operation[1])).as(operation[0] + " " + operation[1]).isFalse(); + assertThat(RuntimeInternals.gateway(config.getRuntime()).isInvocable(operation[0], operation[1])).as(operation[0] + " " + operation[1]).isFalse(); } UiApiToolbox.Outcome outcome = tool(AssistantTestFixture.ADMIN, null, AnalysisTools.ANALYZE_RECORDS, input("table", "blog_post", "groupBy", List.of(Map.of("field", "status")), "filter", filter("AND"))); assertThat(outcome.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED); diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/bulkload/BulkLoadToolsTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/bulkload/BulkLoadToolsTest.java index 98bb75f..c2c9127 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/bulkload/BulkLoadToolsTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/bulkload/BulkLoadToolsTest.java @@ -22,6 +22,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; +import com.kingsrook.qbits.assistant.tools.Toolboxes; +import com.kingsrook.qbits.assistant.RuntimeInternals; import com.fasterxml.jackson.core.type.TypeReference; import com.kingsrook.qbits.assistant.AssistantQBitConfig; import com.kingsrook.qbits.assistant.access.DataAccessMode; @@ -129,7 +131,7 @@ class BulkLoadToolsTest extends BaseAssistantTest *******************************************************************************/ private BulkLoadTools bulkLoadTools() { - for(ToolFamily family : config.getRuntime().ownToolFamilies()) + for(ToolFamily family : RuntimeInternals.ownToolFamilies(config.getRuntime())) { if(family instanceof BulkLoadToolFamily bulkLoad) { @@ -149,7 +151,7 @@ class BulkLoadToolsTest extends BaseAssistantTest QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config)); try { - return (bulkLoadTools().execute(new UiApiToolbox.Context("session-1", null, server, PolicyService.ToolPolicy.defaults(), + return (bulkLoadTools().execute(Toolboxes.context("session-1", null, server, PolicyService.ToolPolicy.defaults(), write -> config.getRuntime().getStore().insert(new AssistantPendingAction().withUserId(AssistantTestFixture.ADMIN).withToolName(write.toolName()) .withTableName(write.tableName()).withUiApiOperation(write.operation()).withParamsJson(JsonUtils.toJson(write.values())) .withSummary(write.summary()).withStatus(AssistantPendingAction.STATUS_PENDING).withExpiresAt(java.time.Instant.now().plusSeconds(600))).getId()), @@ -230,7 +232,7 @@ class BulkLoadToolsTest extends BaseAssistantTest BulkLoadTools older = new BulkLoadTools(qInstance, config.getRuntime().getDataAccessService(), null); FakeBulkLoadServer oldApi = new FakeBulkLoadServer(false); QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config)); - UiApiToolbox.Outcome outcome = older.execute(new UiApiToolbox.Context("s", null, oldApi, PolicyService.ToolPolicy.defaults(), null), "c", BulkLoadTools.SUGGEST, + UiApiToolbox.Outcome outcome = older.execute(Toolboxes.context("s", null, oldApi, PolicyService.ToolPolicy.defaults(), null), "c", BulkLoadTools.SUGGEST, Map.of("tableName", "blog_post", "uploadRef", "up_1"), false); assertThat(outcome.getResult().getOutcome()).isEqualTo(AiToolOutcome.ERROR); assertThat(outcome.getResult().getModelContent()).contains("does not offer"); diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/registry/ToolCallArchitectureTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/registry/ToolCallArchitectureTest.java index 322fc94..86e610d 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/registry/ToolCallArchitectureTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/registry/ToolCallArchitectureTest.java @@ -17,6 +17,8 @@ package com.kingsrook.qbits.assistant.tools.registry; +import java.lang.reflect.Modifier; +import java.util.List; import java.util.Set; import java.util.regex.Pattern; import com.kingsrook.qbits.assistant.AssistantRuntime; @@ -39,6 +41,7 @@ import com.tngtech.archunit.core.importer.ImportOption; import io.javalin.http.Context; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; @@ -70,10 +73,10 @@ class ToolCallArchitectureTest /////////////////////////////////////////////////////////////////////////// // the only classes that make a tool context: the service, for a tool // - // call; the API and the starter prompts, and the knowledge index, for // - // the assistant's own reads // + // call; the starter prompts and the knowledge index, for the // + // assistant's own reads (and the context's own factories) // /////////////////////////////////////////////////////////////////////////// - private static final Set CONTEXT_MAKERS = Set.of(ToolCallService.class.getName(), AssistantApi.class.getName(), StarterPrompts.class.getName(), RagService.class.getName()); + private static final Set CONTEXT_MAKERS = Set.of(ToolCallService.class.getName(), StarterPrompts.class.getName(), RagService.class.getName(), UiApiToolbox.Context.class.getName()); private static JavaClasses classes; @@ -148,9 +151,39 @@ class ToolCallArchitectureTest @Test void onlyTheServiceMakesWhatAToolRunsWith() { + /////////////////////////////////////////////////////////////////////// + // the compiler holds this for code outside this repository: neither // + // type, nor a tool's context, has a public constructor // + /////////////////////////////////////////////////////////////////////// + for(Class type : List.of(ToolCallRequest.class, ConfirmedToolCall.class, UiApiToolbox.Context.class)) + { + assertThat(type.getConstructors()).as(type.getSimpleName() + " has no public constructor").isEmpty(); + assertThat(Modifier.isFinal(type.getModifiers())).as(type.getSimpleName() + " is final").isTrue(); + } + assertThat(ToolCallService.Permit.class.getDeclaredConstructors()).as("only the service makes its permit").allMatch(constructor -> Modifier.isPrivate(constructor.getModifiers())); + + /////////////////////////////////////////////////////////////////////// + // and inside it, only the service uses the factories that take its // + // permit; the constructors are called by nothing but those factories // + /////////////////////////////////////////////////////////////////////// noClasses().that().resideInAPackage(BASE).and().doNotHaveFullyQualifiedName(ToolCallService.class.getName()) + .should().callMethodWhere(target(owner(ToolCallRequest.class.getName()).or(owner(ConfirmedToolCall.class.getName())).or(owner(UiApiToolbox.Context.class.getName()))) + .and(new DescribedPredicate>("the factory that takes the service's permit") + { + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public boolean test(JavaCall call) + { + return ("of".equals(call.getTarget().getName())); + } + })) + .because("a tool's request, its context and a confirmed write are built by ToolCallService, never by its caller") + .check(classes); + noClasses().that().resideInAPackage(BASE).and().doNotHaveFullyQualifiedName(ToolCallRequest.class.getName()).and().doNotHaveFullyQualifiedName(ConfirmedToolCall.class.getName()) .should().callConstructorWhere(target(owner(ToolCallRequest.class.getName()).or(owner(ConfirmedToolCall.class.getName())))) - .because("a tool's request and a confirmed write are built by ToolCallService, never by its caller") + .because("a tool's request and a confirmed write are built through the factories that take the service's permit") .check(classes); } @@ -247,7 +280,7 @@ class ToolCallArchitectureTest String owner = call.getTargetOwner().getName(); String name = call.getTarget().getName(); String origin = call.getOriginOwner().getName(); - if(owner.equals(UiApiToolbox.Context.class.getName()) && "".equals(name)) + if(owner.equals(UiApiToolbox.Context.class.getName()) && ("".equals(name) || "of".equals(name) || "forRead".equals(name))) { return (CONTEXT_MAKERS.stream().anyMatch(maker -> origin.equals(maker) || origin.startsWith(maker + "$"))); } diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/registry/ToolRegistryTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/registry/ToolRegistryTest.java index 12ed7fd..3a5597f 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/registry/ToolRegistryTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/registry/ToolRegistryTest.java @@ -22,12 +22,15 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.TreeMap; +import com.kingsrook.qbits.assistant.tools.Toolboxes; +import com.kingsrook.qbits.assistant.RuntimeInternals; import com.fasterxml.jackson.databind.ObjectMapper; import com.kingsrook.qbits.assistant.AssistantQBitConfig; import com.kingsrook.qbits.assistant.AssistantRuntime; import com.kingsrook.qbits.assistant.engine.KernelToolFamily; import com.kingsrook.qbits.assistant.engine.PolicyService; import com.kingsrook.qbits.assistant.engine.ToolCallService; +import com.kingsrook.qbits.assistant.engine.ToolCallServiceInternals; import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider; import com.kingsrook.qbits.assistant.spi.ModelToolDefinition; import com.kingsrook.qbits.assistant.testing.AssistantTestFixture; @@ -92,7 +95,7 @@ class ToolRegistryTest extends BaseAssistantTest @Test void everyToolIsRegisteredOnceWithItsKindAndChecks() { - List tools = config.getRuntime().getToolRegistry().tools(); + List tools = RuntimeInternals.registry(config.getRuntime()).tools(); assertThat(tools).extracting(AssistantTool::name).containsExactlyElementsOf(KINDS.keySet()); for(AssistantTool tool : tools) @@ -100,11 +103,10 @@ class ToolRegistryTest extends BaseAssistantTest assertThat(tool.kind()).as(tool.name()).isEqualTo(KINDS.get(tool.name())); assertThat(tool.checks()).as(tool.name()).isNotEmpty().doesNotHaveDuplicates(); assertThat(tool.checks().contains(ToolCheck.CONFIRMATION)).as(tool.name() + " waits for a confirmation").isEqualTo(tool.kind() == ToolKind.PROPOSE); - assertThat(config.getRuntime().getToolRegistry().find(tool.name())).isSameAs(tool); + assertThat(RuntimeInternals.registry(config.getRuntime()).find(tool.name())).isSameAs(tool); } - assertThat(config.getRuntime().getToolRegistry().find("no_such_tool")).isNull(); - assertThat(config.getRuntime().getToolRegistry().find(null)).isNull(); - assertThat(config.getRuntime().getToolCalls().getRegistry()).isSameAs(config.getRuntime().getToolRegistry()); + assertThat(RuntimeInternals.registry(config.getRuntime()).find("no_such_tool")).isNull(); + assertThat(RuntimeInternals.registry(config.getRuntime()).find(null)).isNull(); } @@ -116,7 +118,7 @@ class ToolRegistryTest extends BaseAssistantTest @Test void onlyTheFamiliesGivenAreRegistered() { - ToolRegistry own = ToolRegistry.of(config.getRuntime(), config.getRuntime().ownToolFamilies()); + ToolRegistry own = ToolRegistry.of(config.getRuntime(), RuntimeInternals.ownToolFamilies(config.getRuntime())); assertThat(own.tools()).extracting(AssistantTool::name).containsExactlyElementsOf(KINDS.keySet()); ToolRegistry kernelOnly = ToolRegistry.of(config.getRuntime(), List.of(new KernelToolFamily())); @@ -136,11 +138,11 @@ class ToolRegistryTest extends BaseAssistantTest { assertThat(new ClasspathOnlyToolFamily().tools(config.getRuntime())).extracting(AssistantTool::name).containsExactly(ClasspathOnlyToolFamily.NAME); - assertThat(config.getRuntime().getToolRegistry().find(ClasspathOnlyToolFamily.NAME)).isNull(); - assertThat(config.getRuntime().getToolRegistry().tools()).extracting(AssistantTool::name).containsExactlyElementsOf(KINDS.keySet()); + assertThat(RuntimeInternals.registry(config.getRuntime()).find(ClasspathOnlyToolFamily.NAME)).isNull(); + assertThat(RuntimeInternals.registry(config.getRuntime()).tools()).extracting(AssistantTool::name).containsExactlyElementsOf(KINDS.keySet()); assertThat(offeredToTheEditor(qInstance, config)).doesNotContain(ClasspathOnlyToolFamily.NAME); - ToolCallOutcome outcome = config.getRuntime().getToolCalls().execute(ToolCalls.call(config, AssistantTestFixture.EDITOR, ClasspathOnlyToolFamily.NAME, Map.of()), ToolCalls.scope()); + ToolCallOutcome outcome = ToolCallServiceInternals.execute(config.getRuntime().getToolCalls(), ToolCalls.call(config, AssistantTestFixture.EDITOR, ClasspathOnlyToolFamily.NAME, Map.of()), ToolCalls.scope()); assertThat(outcome.result().getOutcome()).isEqualTo(AiToolOutcome.INVALID_INPUT); assertThat(outcome.result().getSummary()).isEqualTo("Unknown tool"); } @@ -161,10 +163,10 @@ class ToolRegistryTest extends BaseAssistantTest try { assertThat(hostConfig.getToolFamilies()).hasSize(1); - assertThat(hostConfig.getRuntime().getToolRegistry().tools()).extracting(AssistantTool::name).contains(ClasspathOnlyToolFamily.NAME).containsAll(KINDS.keySet()).hasSize(KINDS.size() + 1); + assertThat(RuntimeInternals.registry(hostConfig.getRuntime()).tools()).extracting(AssistantTool::name).contains(ClasspathOnlyToolFamily.NAME).containsAll(KINDS.keySet()).hasSize(KINDS.size() + 1); assertThat(offeredToTheEditor(host, hostConfig)).contains(ClasspathOnlyToolFamily.NAME); - ToolCallOutcome outcome = hostConfig.getRuntime().getToolCalls().execute(ToolCalls.call(hostConfig, AssistantTestFixture.EDITOR, ClasspathOnlyToolFamily.NAME, Map.of()), ToolCalls.scope()); + ToolCallOutcome outcome = ToolCallServiceInternals.execute(hostConfig.getRuntime().getToolCalls(), ToolCalls.call(hostConfig, AssistantTestFixture.EDITOR, ClasspathOnlyToolFamily.NAME, Map.of()), ToolCalls.scope()); assertThat(outcome.result().getOutcome()).isEqualTo(AiToolOutcome.OK); assertThat(outcome.result().getModelContent()).isEqualTo("ran"); } @@ -265,7 +267,7 @@ class ToolRegistryTest extends BaseAssistantTest @Test void aRegistryRefusesWhatItCannotHold() { - List twice = new ArrayList<>(config.getRuntime().getToolRegistry().tools()); + List twice = new ArrayList<>(RuntimeInternals.registry(config.getRuntime()).tools()); twice.add(twice.get(0)); assertThatThrownBy(() -> new ToolRegistry(twice)).isInstanceOf(IllegalStateException.class).hasMessageContaining(twice.get(0).name()); @@ -278,7 +280,7 @@ class ToolRegistryTest extends BaseAssistantTest assertThatThrownBy(() -> new ToolRegistry(List.of())).isInstanceOf(IllegalStateException.class).hasMessageContaining("no tools"); assertThatThrownBy(() -> ToolRegistry.of(config.getRuntime(), List.of())).isInstanceOf(IllegalStateException.class).hasMessageContaining("no tools"); - List clash = new ArrayList<>(config.getRuntime().ownToolFamilies()); + List clash = new ArrayList<>(RuntimeInternals.ownToolFamilies(config.getRuntime())); clash.add(new KernelToolFamily()); assertThatThrownBy(() -> ToolRegistry.of(config.getRuntime(), clash)).isInstanceOf(IllegalStateException.class).hasMessageContaining("navigate"); } @@ -293,7 +295,7 @@ class ToolRegistryTest extends BaseAssistantTest void aCallToANameWithoutAToolIsRefused() { ToolCallService calls = config.getRuntime().getToolCalls(); - ToolCallOutcome outcome = calls.execute(ToolCalls.call(config, AssistantTestFixture.EDITOR, "no_such_tool", Map.of()), ToolCalls.scope()); + ToolCallOutcome outcome = ToolCallServiceInternals.execute(calls, ToolCalls.call(config, AssistantTestFixture.EDITOR, "no_such_tool", Map.of()), ToolCalls.scope()); assertThat(outcome.extras()).isNull(); assertThat(outcome.result().getOutcome()).isEqualTo(AiToolOutcome.INVALID_INPUT); @@ -315,9 +317,9 @@ class ToolRegistryTest extends BaseAssistantTest { for(String name : List.of(UiApiToolbox.LIST_TABLES, AnalysisTools.ANALYZE_RECORDS, BulkLoadTools.LIST, KnowledgeTool.NAME, PresentResultsTool.NAME, "navigate", "no_such_tool")) { - AssistantTool tool = config.getRuntime().getToolRegistry().find(name); + AssistantTool tool = RuntimeInternals.registry(config.getRuntime()).find(name); UiApiToolbox.Outcome now = tool == null ? UiApiToolbox.notAWriteTool("call-1", name, "blog_post") - : tool.executeConfirmed(new ConfirmedToolCall(AssistantTestFixture.sessionFor(AssistantTestFixture.EDITOR), context(), "call-1", name, "blog_post", "1", Map.of("title", "x"), AssistantTestFixture.EDITOR)); + : tool.executeConfirmed(new ConfirmedToolCall(AssistantTestFixture.sessionFor(AssistantTestFixture.EDITOR), context(), "call-1", name, "blog_post", "1", Map.of("title", "x"), AssistantTestFixture.EDITOR, null)); assertThat(now.getResult().getOutcome()).as(name).isEqualTo(AiToolOutcome.INVALID_INPUT); assertThat(now.getResult().getModelContent()).as(name).isEqualTo("Not a write tool: " + name); @@ -363,7 +365,7 @@ class ToolRegistryTest extends BaseAssistantTest *******************************************************************************/ private UiApiToolbox.Context context() { - return (new UiApiToolbox.Context(AssistantTestFixture.EDITOR, "UTC", config.getRuntime().getUiApiGateway(), PolicyService.ToolPolicy.defaults(), null)); + return (Toolboxes.context(AssistantTestFixture.EDITOR, "UTC", RuntimeInternals.gateway(config.getRuntime()), PolicyService.ToolPolicy.defaults(), null)); } -- 2.54.0