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 58d5c170e8..457bf11d35 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 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: {
@@ -292,10 +299,14 @@ export default {
minHeight: 120,
maxViewportMarginY: 80
})
+ // Fetch the tool catalog as soon as the Expert panel mounts (not only in the
+ // editor) so the permissions settings can render everywhere. Flow-building
+ // tools are still only usable from an instance editor (see isImmersive below).
+ this.fetchToolCatalog()
},
methods: {
...mapActions(useProductAssistantStore, ['resetContextSelection']),
- ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'setPendingInput', 'setQuestionCadence', 'setPlanMode']),
+ ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', '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..828950ac12
--- /dev/null
+++ b/frontend/src/components/expert/components/ToolPermissionsSettings.vue
@@ -0,0 +1,355 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue
index 82201c1740..e73ad318cd 100644
--- a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue
+++ b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue
@@ -1,6 +1,6 @@
-
-
+
+
+
+
@@ -113,6 +125,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 +144,8 @@ export default {
GuideStepsList,
MessageBubble,
GuideHeader,
- IssuesList
+ IssuesList,
+ ToolApprovalCard
},
props: {
answer: {
@@ -202,6 +216,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 +302,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 +344,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 +359,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 +398,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/MessageBubble.vue b/frontend/src/components/expert/components/messages/components/MessageBubble.vue
index 2d6de846b3..3751b2ff75 100644
--- a/frontend/src/components/expert/components/messages/components/MessageBubble.vue
+++ b/frontend/src/components/expert/components/messages/components/MessageBubble.vue
@@ -1,7 +1,7 @@
@@ -19,6 +19,14 @@ export default {
required: false,
type: String,
default: null
+ },
+ // Strips the bubble chrome (background + padding) so a self-contained
+ // card (e.g. the tool approval card) renders standalone, not nested
+ // inside an AI bubble.
+ bare: {
+ required: false,
+ type: Boolean,
+ default: false
}
},
computed: {
@@ -49,6 +57,14 @@ export default {
border-bottom-left-radius: 0.125rem;
}
+ &.message-bubble--bare {
+ padding: 0;
+
+ &.ai-message {
+ background-color: transparent;
+ }
+ }
+
&.human-message {
background-color: var(--ff-color-accent);
color: var(--ff-color-text-on-brand);
diff --git a/frontend/src/components/expert/components/messages/components/resources/JsonViewer.vue b/frontend/src/components/expert/components/messages/components/resources/JsonViewer.vue
new file mode 100644
index 0000000000..4ee8cffeba
--- /dev/null
+++ b/frontend/src/components/expert/components/messages/components/resources/JsonViewer.vue
@@ -0,0 +1,174 @@
+
+
+
+
+
+
+
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..acc8f1a654
--- /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..d9175a7174 100644
--- a/frontend/src/stores/product-assistant.js
+++ b/frontend/src/stores/product-assistant.js
@@ -6,6 +6,42 @@ 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'
+}
+
+// Tool groups partition the catalog into the sections shown in the permissions UI
+// (flow-building vs FlowFuse platform). Each group is meant to carry its own
+// read/write/delete defaults so a default for one never silently applies to the other.
+export const TOOL_GROUPS = { FLOW_BUILDING: 'flow-building', PLATFORM: 'platform' }
+
+// Which group a catalog entry belongs to. Today every tool the agent serves is a
+// flow-building tool, so everything maps to 'flow-building'.
+// TODO(platform-tools): once Steve's platform-tool work is merged into the agent,
+// platform tools arrive in the same catalog. Map them to TOOL_GROUPS.PLATFORM here —
+// e.g. off a `group`/`target` field added to the catalog entry in buildToolCatalog
+// (get_mcp_tools.js) or a tool annotation — so the UI routes them to the FlowFuse
+// Platform Tools section and applies that group's own defaults (see the toolDefaults
+// TODO below for namespacing the defaults by group at the same time).
+export const groupOf = (entry) => entry?.group || TOOL_GROUPS.FLOW_BUILDING
+
const eventsRegistry = {
'editor:open': {
nodeRedEvent: 'editor:open', // this is the Node-RED event
@@ -158,7 +194,18 @@ 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,
+ // Standing read/write/delete defaults. Today these are the flow-building tools'
+ // defaults (the only tools that exist).
+ // TODO(platform-tools): when platform tools land (see groupOf), namespace these
+ // by tool group so flow-building and platform actions carry independent defaults,
+ // and migrate the persisted shape from { read, write, delete } accordingly.
+ toolDefaults: { read: 'allow', write: 'ask', delete: 'ask' },
+ toolPreferences: {}
}),
getters: {
isImmersiveInstance: () => {
@@ -260,6 +307,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 +653,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 +712,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 d88bd54b57..27195f40db 100644
--- a/frontend/src/stores/product-expert.js
+++ b/frontend/src/stores/product-expert.js
@@ -301,7 +301,8 @@ export const useProductExpertStore = defineStore('product-expert', {
query,
context: {
...useContextStore().expert,
- agent: this.agentMode
+ agent: this.agentMode,
+ ...(contextOverrides || {})
}
},
correlationData: transactionId,
@@ -331,11 +332,38 @@ 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.
+ // Fetched whenever the Expert panel mounts, not only in the editor: the
+ // permissions UI lists every tool everywhere. Flow-building tools are simply
+ // shown as usable only from an instance editor (gated in the settings UI).
+ const assistantStore = useProductAssistantStore()
+
+ 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()
@@ -398,6 +426,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
}
@@ -406,11 +454,75 @@ 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,
+ 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()
@@ -1063,6 +1175,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()