From a546212b3d04ce3ee50c133d0af8362e41036898 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 30 Jun 2026 14:19:06 +0200 Subject: [PATCH 01/10] feat(expert): human-in-the-loop tool permissions (#421) Add per-tool approval for the Expert's flow-building tools in the immersive editor. The agent gates each tool call at the toolsNode seam by class (read/write/delete) and per-tool preference; write/delete default to Ask and surface an inline approval card (Allow / Always allow / Never) that holds the call open with no session timeout, while read defaults to allow. - Catalog delivered over HTTP (GET /api/v1/expert/mcp/tools), curated to friendly names so raw tool identifiers never reach the browser; a per-response hash triggers a background refetch when the catalog drifts. - HITL state consolidated into the product-assistant store (defaults, per-tool preferences, pending-approval map) with SemVer version gating. - Settings panel groups versioned tool variants into one family and points update hints at the newest variant's required version. - Role inheritance is fail-closed: read-only members cannot enable or trigger write/delete tools and are shown why. --- forge/ee/routes/expert/index.js | 66 +++- frontend/src/api/expert.js | 17 +- .../expert/components/ExpertChatInput.vue | 16 +- .../components/ToolPermissionsSettings.vue | 337 ++++++++++++++++++ .../messages/components/AnswerWrapper.vue | 39 +- .../components/resources/ToolApprovalCard.vue | 147 ++++++++ frontend/src/stores/context.js | 9 +- frontend/src/stores/product-assistant.js | 139 +++++++- frontend/src/stores/product-expert.js | 119 ++++++- 9 files changed, 877 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/expert/components/ToolPermissionsSettings.vue create mode 100644 frontend/src/components/expert/components/messages/components/resources/ToolApprovalCard.vue diff --git a/forge/ee/routes/expert/index.js b/forge/ee/routes/expert/index.js index 99e78bb9a8..20a45a1ff3 100644 --- a/forge/ee/routes/expert/index.js +++ b/forge/ee/routes/expert/index.js @@ -43,8 +43,10 @@ module.exports = async function (app) { error: 'unauthorized' }) } - // Ensure users team access is valid - const teamId = request.body.context?.teamId // `context.teamId` is the hash provided in the body context by the client + // Ensure users team access is valid. `teamId` is the team hash provided by the + // client — in the body context for POST routes (/chat, /mcp/features) or as a + // query param for GET routes (/mcp/tools, which has no body). + const teamId = request.body?.context?.teamId || request.query?.teamId if (!teamId) { return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) } @@ -480,6 +482,66 @@ module.exports = async function (app) { reply.code(error.response?.status || 500).send({ code: error.response?.data?.code || 'unexpected_error', error: error.response?.data?.error || error.message }) } }) + + /** + * Retrieve the curated tool catalog for the Expert's human-in-the-loop permissions UI + * (#421). The MCP server(s) are static/global (not per-team registered), so unlike + * /mcp/features this is a thin read-only proxy: it forwards to the agent service's + * /mcp/tools endpoint, which returns friendly catalog entries only (raw MCP identifiers + * never leave the backend) plus a `hash` fingerprint of the catalog. Team access + + * feature gating are enforced by the shared preHandler above; read/write classification + * on each entry is what the client uses to decide which tools a role may enable. + */ + app.get('/mcp/tools', { + schema: { + hide: true, // dont show in swagger + querystring: { + type: 'object', + properties: { + teamId: { type: 'string', minLength: 10 } + }, + required: ['teamId'] + }, + response: { + 200: { + type: 'object', + properties: { + catalog: { + type: 'array', + items: { + type: 'object', + additionalProperties: true + } + }, + hash: { + type: ['string', 'null'] + } + } + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, + async (request, reply) => { + if (!request.isExpertAssistantEnabled) { + return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) + } + try { + const toolsUrl = `${app.expert.expertUrl.split('/').slice(0, -1).join('/')}/mcp/tools` + const response = await axios.get(toolsUrl, { + headers: { + Origin: request.headers.origin, + ...(app.expert.serviceToken ? { Authorization: `Bearer ${app.expert.serviceToken}` } : {}) + }, + timeout: app.expert.requestTimeout + }) + reply.send({ catalog: response.data?.catalog || [], hash: response.data?.hash || null }) + } catch (error) { + reply.code(error.response?.status || 500).send({ code: error.response?.data?.code || 'unexpected_error', error: error.response?.data?.error || error.message }) + } + }) } /** diff --git a/frontend/src/api/expert.js b/frontend/src/api/expert.js index 67ef3ce793..b44925e2a5 100644 --- a/frontend/src/api/expert.js +++ b/frontend/src/api/expert.js @@ -48,7 +48,22 @@ const getCapabilities = async (payload) => { }) } +/** + * Fetch the curated tool catalog for the human-in-the-loop permissions UI (#421). + * Read-only GET — proxied by forge to the agent's /mcp/tools endpoint, which serves + * friendly catalog entries only (raw MCP identifiers never leave the backend) plus a + * `hash` fingerprint the UI uses to refetch only when the catalog changes. + * @param {{ teamId: string }} params + * @returns {Promise<{ catalog: Array, hash: string|null }>} + */ +const getToolCatalog = async ({ teamId } = {}) => { + return client.get('/api/v1/expert/mcp/tools', { + params: { teamId } + }).then(res => res.data) +} + export default { chat, - getCapabilities + getCapabilities, + getToolCatalog } diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index e1f331511d..1ccb861c35 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -105,6 +105,11 @@ data-el="expert-question-cadence" /> +
+ Tool permissions +

Choose which flow-building actions the Expert can run, and which need your approval.

+ +
@@ -118,6 +123,7 @@ import FormHeading from '../../FormHeading.vue' import ResizeBar from '../../ResizeBar.vue' import CapabilitiesSelector from './CapabilitiesSelector.vue' +import ToolPermissionsSettings from './ToolPermissionsSettings.vue' import DefaultChip from './chips/DefaultChip.vue' import ContextSelector from './context-selection/index.vue' @@ -135,7 +141,8 @@ export default { ContextSelector, DefaultChip, FormHeading, - ResizeBar + ResizeBar, + ToolPermissionsSettings }, inject: { togglePinWithWidth: { @@ -248,6 +255,11 @@ export default { if (!immersive && this.planMode) { this.setPlanMode(false) } + // Flow-building tools only exist in immersive — fetch their catalog so the + // tool-permissions settings can render and the policy can be sent to the agent. + if (immersive) { + this.fetchToolCatalog() + } } }, pendingInput (text) { @@ -295,7 +307,7 @@ export default { }, methods: { ...mapActions(useProductAssistantStore, ['resetContextSelection']), - ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'handleMessageResponse', 'setPendingInput', 'setQuestionCadence', 'setPlanMode']), + ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'handleMessageResponse', 'setPendingInput', 'setQuestionCadence', 'setPlanMode', 'fetchToolCatalog']), openSettings () { this.$refs.settingsDialog.show() }, diff --git a/frontend/src/components/expert/components/ToolPermissionsSettings.vue b/frontend/src/components/expert/components/ToolPermissionsSettings.vue new file mode 100644 index 0000000000..fe7e8627ba --- /dev/null +++ b/frontend/src/components/expert/components/ToolPermissionsSettings.vue @@ -0,0 +1,337 @@ + + + + + diff --git a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue index 82201c1740..9011b7a687 100644 --- a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue +++ b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue @@ -92,6 +92,19 @@ @reject="onPlanReject" @streaming-complete="onComponentComplete('plan-list')" /> + + @@ -113,6 +126,7 @@ import QuestionsList from './resources/QuestionsList.vue' import ResourcesList from './resources/ResourcesList.vue' import RichContent from './resources/RichContent.vue' import SuggestionsList from './resources/SuggestionsList.vue' +import ToolApprovalCard from './resources/ToolApprovalCard.vue' import { useProductAssistantStore } from '@/stores/product-assistant.js' import { useProductExpertStore } from '@/stores/product-expert.js' @@ -131,7 +145,8 @@ export default { GuideStepsList, MessageBubble, GuideHeader, - IssuesList + IssuesList, + ToolApprovalCard }, props: { answer: { @@ -202,6 +217,9 @@ export default { hasPlan () { return this.isPlanAnswer && typeof this.answer.content === 'string' && this.answer.content.length > 0 }, + hasToolApproval () { + return this.answer.kind === 'tool-approval' && !!this.answer.id + }, isChatAnswer () { return !Object.hasOwnProperty.call(this.answer, 'kind') || this.answer.kind === 'chat' }, @@ -285,6 +303,13 @@ export default { if (this.componentStreamingOrder.indexOf(key) === 0) return true return this.streamedComponents.length >= this.componentStreamingOrder.indexOf(key) }, + shouldShowToolApproval () { + const key = 'tool-approval-card' + if (!this.componentStreamingOrder.includes(key)) return false + if (!this.hasToolApproval) return false + if (this.componentStreamingOrder.indexOf(key) === 0) return true + return this.streamedComponents.length >= this.componentStreamingOrder.indexOf(key) + }, shouldStream () { return !this.answer._streamed } @@ -320,7 +345,7 @@ export default { } }, methods: { - ...mapActions(useProductExpertStore, ['updateAnswerStreamedState', 'handleQuery', 'setPendingInput', 'requestPlanChange', 'resetComposer', 'setPlanMode']), + ...mapActions(useProductExpertStore, ['updateAnswerStreamedState', 'handleQuery', 'setPendingInput', 'requestPlanChange', 'resetComposer', 'setPlanMode', 'resolveToolApproval']), buildStreamingOrder () { // order matters // this is where the decision of the streaming order of components is decided @@ -335,6 +360,7 @@ export default { if (this.hasSuggestions) this.componentStreamingOrder.push('suggestions-list') if (this.hasQuestions) this.componentStreamingOrder.push('questions-list') if (this.hasPlan) this.componentStreamingOrder.push('plan-list') + if (this.hasToolApproval) this.componentStreamingOrder.push('tool-approval-card') }, async onComponentComplete (key) { if (!this.shouldStream) await this.waitFor(200) @@ -373,6 +399,15 @@ export default { this.resetComposer() this.handleQuery({ query: 'I do not want to proceed with this plan.' }) }, + onToolApprove () { + this.resolveToolApproval({ id: this.answer.id, approved: true, always: false }) + }, + onToolAllowAlways () { + this.resolveToolApproval({ id: this.answer.id, approved: true, always: true }) + }, + onToolDeny () { + this.resolveToolApproval({ id: this.answer.id, approved: false, always: false }) + }, handleClick (e) { const target = e.target // - Must be in the immersive editor diff --git a/frontend/src/components/expert/components/messages/components/resources/ToolApprovalCard.vue b/frontend/src/components/expert/components/messages/components/resources/ToolApprovalCard.vue new file mode 100644 index 0000000000..9ba1f7034a --- /dev/null +++ b/frontend/src/components/expert/components/messages/components/resources/ToolApprovalCard.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/frontend/src/stores/context.js b/frontend/src/stores/context.js index d22eee5f9f..7185e22eba 100644 --- a/frontend/src/stores/context.js +++ b/frontend/src/stores/context.js @@ -1,7 +1,9 @@ import { defineStore } from 'pinia' import teamApi from '../api/team.js' +import { hasAMinimumTeamRoleOf } from '../composables/Permissions.js' import product from '../services/product.js' +import { Roles } from '../utils/roles.js' import { useAccountAuthStore } from './account-auth.js' import { useProductAssistantStore } from './product-assistant.js' @@ -110,7 +112,12 @@ export const useContextStore = defineStore('context', { selectedNodes, scope, questionCadence: useProductExpertStore().questionCadence, - planMode: useProductExpertStore().planMode + planMode: useProductExpertStore().planMode, + // Human-in-the-loop tool permissions (#421). The agent gates each + // flow-building tool call against this map; canUseWriteTools drives + // role inheritance (fail-closed) for write/delete tools. + toolPermissions: assistantStore.resolvedToolPermissions, + canUseWriteTools: hasAMinimumTeamRoleOf(Roles.Member, state.teamMembership) } } }, diff --git a/frontend/src/stores/product-assistant.js b/frontend/src/stores/product-assistant.js index 0eae819916..8c9b4a2bbe 100644 --- a/frontend/src/stores/product-assistant.js +++ b/frontend/src/stores/product-assistant.js @@ -6,6 +6,27 @@ import { useContextStore } from '@/stores/context.js' const MAX_DEBUG_LOG_ENTRIES = 100 // maximum number of debug log entries to keep +// --- Expert tool permissions (human-in-the-loop, #421) ----------------------- +// Pending tool-approval resolvers, keyed by approval id. Kept at module scope +// (not in store state) so the Map and the Promise resolvers it holds are never +// wrapped in a reactive proxy, which would break their internals. +const pendingToolApprovals = new Map() + +const TOOL_POLICIES = ['allow', 'ask', 'deny'] +const isToolPolicy = (p) => TOOL_POLICIES.includes(p) +const TOOL_CLASSES = ['read', 'write', 'delete'] +// Fail-safe default when a class has no configured default: read allows, the rest ask. +const fallbackForToolClass = (cls) => (cls === 'read' ? 'allow' : 'ask') + +// Derive a tool's permission class from its catalog entry. Read tools view only; +// delete tools are destructive writes; everything else that changes flows is write. +export const classOf = (entry) => { + if (!entry) return 'write' + if (entry.toolClass === 'read') return 'read' + if (entry.toolClass === 'delete' || entry.destructive === true) return 'delete' + return 'write' +} + const eventsRegistry = { 'editor:open': { nodeRedEvent: 'editor:open', // this is the Node-RED event @@ -158,7 +179,13 @@ export const useProductAssistantStore = defineStore('product-assistant', { // internally; external consumers should read this.debugLog (the getter). debugLogEntries: [], editorState: { ...buildInitialEditorState() }, - pendingRequests: new Map() // key is transactionId, value is { resolve, reject, timeout, timestamp, type, action, params } + pendingRequests: new Map(), // key is transactionId, value is { resolve, reject, timeout, timestamp, type, action, params } + // Expert tool permissions (HITL, #421). The catalog + hash are refreshed from + // the agent; defaults + preferences are the user's choices (persisted below). + toolCatalog: [], + toolCatalogHash: null, + toolDefaults: { read: 'allow', write: 'ask', delete: 'ask' }, + toolPreferences: {} }), getters: { isImmersiveInstance: () => { @@ -260,6 +287,64 @@ export const useProductAssistantStore = defineStore('product-assistant', { // NOTE: this is achieved via dynamic event registration for 'flows:loaded' and 'runtime-state' events, // which requires nr-assistant version 0.10.1 or later. return state.editorState?.flowsLoaded || state.editorState?.runtimeState?.state === 'start' + }, + // --- Expert tool permissions (HITL, #421) --- + /** The standing default for a tool class ('read'|'write'|'delete'). */ + defaultForToolClass: (state) => (cls) => { + const d = state.toolDefaults?.[cls] + return isToolPolicy(d) ? d : fallbackForToolClass(cls) + }, + /** + * Effective policy for a catalog key. An explicit per-tool preference always + * wins; otherwise the standing default for the tool's class applies. + */ + toolPolicyFor: (state) => (key) => { + const explicit = state.toolPreferences[key] + if (isToolPolicy(explicit)) return explicit + const entry = state.toolCatalog.find(t => t.key === key) + const cls = classOf(entry) + const d = state.toolDefaults?.[cls] + return isToolPolicy(d) ? d : fallbackForToolClass(cls) + }, + /** + * The resolved permission map sent to the agent in the chat context: + * { defaults, tools: { [key]: 'allow'|'ask'|'deny' } }. + */ + resolvedToolPermissions: (state) => { + const defaults = {} + for (const cls of TOOL_CLASSES) { + defaults[cls] = isToolPolicy(state.toolDefaults?.[cls]) ? state.toolDefaults[cls] : fallbackForToolClass(cls) + } + const tools = {} + for (const t of state.toolCatalog) { + const explicit = state.toolPreferences[t.key] + tools[t.key] = isToolPolicy(explicit) ? explicit : defaults[classOf(t)] + } + return { defaults, tools } + }, + /** + * Availability of a tool against the instance's installed nr-assistant version + * (from `_meta.assistantMinVersion` / `assistantMaxVersion` on each entry). + * Returns { status: 'available'|'requires-update'|'deprecated', deprecated, requiredVersion }. + * - requires-update: instance is below the tool's min version (update to enable). + * - deprecated: instance is past the tool's max version (a newer variant supersedes it). + * - available + deprecated flag: in range, but a max is set so an update will supersede it. + */ + toolAvailabilityFor: (state) => (entry) => { + const version = state.version + const min = entry?.minVersion || null + const max = entry?.maxVersion || null + if (!version || !SemVer.valid(version)) { + // Without a known instance version we can't gate — treat as usable. + return { status: 'available', deprecated: !!max, requiredVersion: min } + } + if (min && SemVer.valid(min) && SemVer.lt(version, min)) { + return { status: 'requires-update', deprecated: false, requiredVersion: min } + } + if (max && SemVer.valid(max) && SemVer.gt(version, max)) { + return { status: 'deprecated', deprecated: true, requiredVersion: null } + } + return { status: 'available', deprecated: !!max, requiredVersion: null } } }, actions: { @@ -548,6 +633,52 @@ export const useProductAssistantStore = defineStore('product-assistant', { } }) }, + // --- Expert tool permissions (HITL, #421) --- + setToolCatalog (catalog, hash) { + this.toolCatalog = Array.isArray(catalog) ? catalog : [] + if (hash !== undefined) { + this.toolCatalogHash = hash || null + } + }, + setToolClassDefault (cls, policy) { + if (!TOOL_CLASSES.includes(cls) || !isToolPolicy(policy)) return + this.toolDefaults = { ...this.toolDefaults, [cls]: policy } + }, + setToolPreference (key, policy) { + if (!isToolPolicy(policy)) return + this.toolPreferences = { ...this.toolPreferences, [key]: policy } + }, + clearToolPreference (key) { + if (!(key in this.toolPreferences)) return + const next = { ...this.toolPreferences } + delete next[key] + this.toolPreferences = next + }, + // Pending approvals (module-level map; see note at top of file). + registerPendingApproval (id, resolve, meta = {}) { + pendingToolApprovals.set(id, { resolve, meta }) + }, + getPendingApproval (id) { + return pendingToolApprovals.get(id) || null + }, + resolvePendingApproval (id, approved) { + const entry = pendingToolApprovals.get(id) + if (!entry) return false + pendingToolApprovals.delete(id) + entry.resolve(!!approved) + return true + }, + hasPendingApprovals () { + return pendingToolApprovals.size > 0 + }, + // Resolve every open approval as denied — used when the user stops the chat so + // the agent's approval wait unblocks instead of hanging on an abandoned prompt. + rejectAllPendingApprovals () { + for (const entry of pendingToolApprovals.values()) { + entry.resolve(false) + } + pendingToolApprovals.clear() + }, sendMessage (payload) { const orchestrator = getAppOrchestrator() const contextStore = useContextStore() @@ -561,5 +692,11 @@ export const useProductAssistantStore = defineStore('product-assistant', { targetOrigin: (contextStore.instance || contextStore.device)?.url }) } + }, + // Only the user's HITL tool-permission choices persist across sessions; the + // catalog/hash and all editor/session state are re-derived each session. + persist: { + pick: ['toolDefaults', 'toolPreferences'], + storage: localStorage } }) diff --git a/frontend/src/stores/product-expert.js b/frontend/src/stores/product-expert.js index da26708210..8ad039b6c1 100644 --- a/frontend/src/stores/product-expert.js +++ b/frontend/src/stores/product-expert.js @@ -292,7 +292,8 @@ export const useProductExpertStore = defineStore('product-expert', { query, context: { ...useContextStore().expert, - agent: this.agentMode + agent: this.agentMode, + ...(contextOverrides || {}) } }, correlationData: transactionId, @@ -322,11 +323,36 @@ export const useProductExpertStore = defineStore('product-expert', { onDisconnect: this._onMqttDisconnect }) }, + async fetchToolCatalog () { + // Fetch the tool catalog for the permissions UI (#421) over HTTP + // (GET /api/v1/expert/mcp/tools), mirroring the insights `getCapabilities` + // pattern. This deliberately does NOT use MQTT — the catalog is needed before + // any chat, and we must not open (or keep open) the broker connection on mount. + // The agent replies with a curated, friendly catalog (raw tool identifiers + // never reach the browser) plus a `hash` we store to detect later drift. + const assistantStore = useProductAssistantStore() + if (!assistantStore.isImmersiveInstance && !assistantStore.isImmersiveDevice) return + + const teamId = useContextStore().expert?.teamId + if (!teamId) return + + try { + const { catalog, hash } = await expertApi.getToolCatalog({ teamId }) + assistantStore.setToolCatalog(catalog || [], hash || null) + } catch (e) { + // Non-fatal: the agent still gates safely with defaults if the catalog + // is unavailable; the settings UI simply shows no tools yet. + } + }, async handleInFlightRequest ({ topic, message, transactionId, sessionId, chatTransactionId } = {}) { - const inFlightRequest = this._inFlightRequests.values().next().value + // Match the originating chat request explicitly (not just the first entry) so a + // concurrent in-flight request — e.g. an open tool approval — can't shadow it and + // cause us to drop a valid in-flight request. + const inFlightRequest = Array.from(this._inFlightRequests.values()) + .find(r => r.transactionId === chatTransactionId) // dismiss inFlight requests that don't match the existing sessionId or the inFlight message transactionId - if (sessionId !== this.sessionId || inFlightRequest?.transactionId !== chatTransactionId) return + if (sessionId !== this.sessionId || !inFlightRequest) return const servicesOrchestrator = getAppOrchestrator() const assistantStore = useProductAssistantStore() @@ -389,6 +415,26 @@ export const useProductExpertStore = defineStore('product-expert', { this._onMqttError(e) } break + case parsedTopic.inflightType === 'expert:tool-approval': + // Human-in-the-loop approval request (#421). Render the approval card and + // wait — with no timeout — for the user's decision, then reply to the agent. + try { + const approved = await this.requestToolApproval(payload) + await mqttService.publishMessage(this.mqttConnectionKey, { + qos: 2, + topic: responseTopic, + payload: JSON.stringify({ approved }), + correlationData: transactionId, + userProperties: { + sessionId, + transactionId: chatTransactionId, + origin: window.origin || window.location.origin + } + }) + } catch (e) { + this._onMqttError(e) + } + break default: // do nothing } @@ -397,11 +443,76 @@ export const useProductExpertStore = defineStore('product-expert', { // ignore aborted messages through mqtt if (Object.prototype.hasOwnProperty.call(response, 'aborted') && response.aborted === true) return + // Tool-catalog freshness (#421): the agent stamps a catalog hash on every + // response. If it differs from what we hold, the catalog drifted (e.g. a + // rolling deploy landed a new tool version) — refetch the full list in the + // background. Only the small hash rides on each interaction. + const incomingHash = response?.toolCatalogHash + if (incomingHash && incomingHash !== useProductAssistantStore().toolCatalogHash) { + this.fetchToolCatalog() + } + if (response.answer && Array.isArray(response.answer)) { this.addAiMessage(response) this._clearInFlightUpdates() } }, + // Render a tool-approval card and return a Promise that resolves to the user's + // decision (true/false). The Promise stays open with no timeout (#421); it is + // resolved by resolveToolApproval (a card button) or cancelPendingToolApprovals + // (the chat stop). The agent holds its tool call paused on the MQTT round-trip. + requestToolApproval (payload = {}) { + const permStore = useProductAssistantStore() + const id = uuidv4() + this.addAiMessage({ + kind: 'tool-approval', + answer: [{ + kind: 'tool-approval', + id, + toolKey: payload.tool, + name: payload.name, + summary: payload.summary, + toolClass: payload.toolClass, + params: payload.params, + status: 'pending' + }] + }) + return new Promise((resolve) => { + permStore.registerPendingApproval(id, resolve, { toolKey: payload.tool }) + }) + }, + resolveToolApproval ({ id, approved, always } = {}) { + const permStore = useProductAssistantStore() + const entry = permStore.getPendingApproval(id) + if (!entry) return + // "Always allow" persists an allow preference for this tool. + if (always && approved && entry.meta?.toolKey) { + permStore.setToolPreference(entry.meta.toolKey, 'allow') + } + // Reflect the outcome on the card so its buttons disable. + this._setToolApprovalStatus(id, approved ? 'approved' : 'denied') + permStore.resolvePendingApproval(id, approved) + }, + // Deny every open approval (used when the user stops the chat) so the agent's + // approval wait unblocks instead of hanging on an abandoned prompt. + cancelPendingToolApprovals () { + const permStore = useProductAssistantStore() + if (!permStore.hasPendingApprovals()) return + for (const m of this._agentStore.messages) { + if (!Array.isArray(m.answer)) continue + for (const a of m.answer) { + if (a.kind === 'tool-approval' && a.status === 'pending') a.status = 'denied' + } + } + permStore.rejectAllPendingApprovals() + }, + _setToolApprovalStatus (id, status) { + for (const m of this._agentStore.messages) { + if (!Array.isArray(m.answer)) continue + const ans = m.answer.find(a => a.kind === 'tool-approval' && a.id === id) + if (ans) { ans.status = status; return } + } + }, async startOver () { const agentStore = this._agentStore agentStore.sessionId = uuidv4() @@ -1054,6 +1165,8 @@ export const useProductExpertStore = defineStore('product-expert', { this.addPredefinedAiMessage(payload.message, { isError: true, code: payload.code }) }, stopInflightChat () { + // Deny any open approval prompts first so the agent's paused tool call unblocks. + this.cancelPendingToolApprovals() if (this.shouldUseMqtt) { const inFlightRequest = this._inFlightRequests.values().next().value const servicesOrchestrator = getAppOrchestrator() From bdb36fbada95713e12d0ca7ca65000bc482fdc88 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 30 Jun 2026 15:39:54 +0200 Subject: [PATCH 02/10] refactor(expert): align tool-permissions UI with FlowFuse patterns Use FormHeading for the section titles and ff-data-table for both the action-type defaults and the flow-building tool list, replacing the bespoke section/group styling and the non-standard uppercase scope headers. Bordered table rows pair each tool with its permission control across the row rather than leaving them to float across whitespace; tool scope moves into a Type column. The approval card no longer sends or renders a tool summary; the tool name, scope and call parameters describe the action. --- .../components/ToolPermissionsSettings.vue | 185 ++++++++---------- .../messages/components/AnswerWrapper.vue | 1 - .../components/resources/ToolApprovalCard.vue | 9 - frontend/src/stores/product-expert.js | 1 - 4 files changed, 87 insertions(+), 109 deletions(-) diff --git a/frontend/src/components/expert/components/ToolPermissionsSettings.vue b/frontend/src/components/expert/components/ToolPermissionsSettings.vue index fe7e8627ba..103261cd17 100644 --- a/frontend/src/components/expert/components/ToolPermissionsSettings.vue +++ b/frontend/src/components/expert/components/ToolPermissionsSettings.vue @@ -1,23 +1,29 @@