fix(plugin-ai): enforce frontend tool approval at runtime (#10166)

This commit is contained in:
YANG QIA
2026-07-23 13:41:01 +08:00
committed by GitHub
parent c4697fbba3
commit 68643e6890
8 changed files with 84 additions and 34 deletions
@@ -25,7 +25,7 @@ export default defineTools({
definition: {
name: EXECUTE_FRONTEND_TOOL_NAME,
description:
'Execute a frontend tool from the current frontendToolCatalog. Use loadFrontendTool first when you need its input schema. Never use a tool id that is not present in the current catalog. Call this tool directly when the user requests the operation; do not ask for a separate confirmation in chat because the runtime pauses and shows the approval UI when required.',
'Execute a frontend tool from the current frontendToolCatalog. Use loadFrontendTool first when you need its input schema. Never use a tool id that is not present in the current catalog.',
schema: z.object({
toolId: z.string().describe('The exact tool id from the current frontendToolCatalog.'),
args: z
@@ -10,7 +10,6 @@
import { defineTools } from '@nocobase/ai';
import { z } from 'zod';
import {
FRONTEND_TOOL_RUNTIME_APPROVAL_INSTRUCTION,
LOAD_FRONTEND_TOOL_NAME,
isFrontendToolManifest,
isFrontendToolInvokeResult,
@@ -30,7 +29,7 @@ export default defineTools({
definition: {
name: LOAD_FRONTEND_TOOL_NAME,
description:
'Load the complete input schema for one frontend tool. This is not a tool discovery API: only call it with an exact tool id copied from the current frontendToolCatalog, and never invent an id such as "current-workspace". After loading a suitable tool, call executeFrontendTool directly. Do not ask for a separate confirmation in chat because the runtime handles approval.',
'Load the complete input schema for one frontend tool. This is not a tool discovery API: only call it with an exact tool id copied from the current frontendToolCatalog, and never invent an id such as "current-workspace". After loading a suitable tool, call executeFrontendTool with arguments that match the returned schema.',
schema: z.object({
toolId: z.string().describe('The exact tool id from the current frontendToolCatalog.'),
}),
@@ -69,7 +68,6 @@ export default defineTools({
title: result.value.title,
description: result.value.description,
inputSchema: result.value.inputSchema,
instructions: FRONTEND_TOOL_RUNTIME_APPROVAL_INSTRUCTION,
},
};
},
@@ -9,8 +9,6 @@
export const LOAD_FRONTEND_TOOL_NAME = 'loadFrontendTool';
export const EXECUTE_FRONTEND_TOOL_NAME = 'executeFrontendTool';
export const FRONTEND_TOOL_RUNTIME_APPROVAL_INSTRUCTION =
'Tool permission is enforced by the runtime. Do not ask the user for a separate confirmation in chat. Call executeFrontendTool directly; when approval is required, the UI will pause and show the Allow use action before execution.';
export type FrontendToolPermission = 'ASK' | 'ALLOW';
@@ -13,7 +13,6 @@ import {
extractFrontendToolManifests,
findCurrentFrontendTool,
prepareToolsForFrontendConversation,
resolveFlowModelWorkContext,
shouldAutoExecuteFrontendTool,
} from '../frontend-tools';
import loadFrontendTool from '../../ai/tools/loadFrontendTool';
@@ -59,7 +58,8 @@ describe('frontend tools', () => {
expect(prepared).toHaveLength(3);
expect(prepared[1].definition.description).toContain('frontendToolCatalog');
expect(prepared[1].definition.description).toContain(frontendTool.id);
expect(prepared[1].definition.description).toContain('Do not ask the user for a separate confirmation');
expect(prepared[1].definition.description).not.toContain('permission');
expect(prepared[1].definition.description).not.toContain('approval');
expect(prepared[1].definition.schema.safeParse({ toolId: frontendTool.id }).success).toBe(true);
expect(prepared[1].definition.schema.safeParse({ toolId: 'block-1' }).success).toBe(false);
expect(prepared[1].definition.schema.safeParse({ toolId: '__catalog__' }).success).toBe(false);
@@ -69,7 +69,7 @@ describe('frontend tools', () => {
expect(prepared[2].definition.schema.safeParse({ toolId: '__catalog__', args: {} }).success).toBe(false);
});
it('extracts valid manifests but keeps tool metadata out of work context content', async () => {
it('extracts valid manifests from work context', () => {
const workContext = {
type: 'flow-model',
uid: 'block-1',
@@ -78,13 +78,6 @@ describe('frontend tools', () => {
};
expect(extractFrontendToolManifests([workContext])).toEqual([frontendTool]);
const resolved = await resolveFlowModelWorkContext({} as Context, workContext);
expect(resolved).toContain('dashboard context');
expect(resolved).not.toContain('frontendToolCatalog');
expect(resolved).not.toContain(frontendTool.id);
expect(resolved).not.toContain('inputSchema');
expect(resolved).not.toContain('"force"');
});
it('binds the first frontend tool context to the conversation and reuses it for later messages', async () => {
@@ -190,8 +183,6 @@ describe('frontend tools', () => {
title: frontendTool.title,
description: frontendTool.description,
inputSchema: frontendTool.inputSchema,
instructions:
'Tool permission is enforced by the runtime. Do not ask the user for a separate confirmation in chat. Call executeFrontendTool directly; when approval is required, the UI will pause and show the Allow use action before execution.',
},
});
await expect(
@@ -0,0 +1,66 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import type { Context } from '@nocobase/actions';
import { describe, expect, it } from 'vitest';
import { createWorkContextHandler } from '../manager/work-context-handler';
describe('work context handler', () => {
it('excludes internal fields from every context type before model resolution', async () => {
const handler = createWorkContextHandler({} as never);
handler.registerStrategy('custom-context', {
resolve: async (_ctx, contextItem) => JSON.stringify(contextItem),
});
const frontendTools = [
{
id: 'context-1:update_record',
blockUid: 'context-1',
name: 'update_record',
description: 'Update the current record.',
permission: 'ASK',
inputSchema: {
type: 'object',
properties: { value: { type: 'string' } },
},
},
];
const resolved = await handler.resolve({} as Context, [
{
type: 'page-element',
uid: 'context-1',
content: { customer: 'Northwind' },
frontendTools,
},
{
type: 'flow-model',
uid: 'context-2',
content: { collection: 'orders' },
frontendTools,
},
{
type: 'custom-context',
uid: 'context-3',
content: { status: 'active' },
frontendTools,
},
]);
expect(resolved).toHaveLength(3);
for (const context of resolved) {
expect(context).not.toContain('frontendTools');
expect(context).not.toContain('permission');
expect(context).not.toContain('inputSchema');
expect(context).not.toContain('update_record');
}
expect(resolved[0]).toContain('Northwind');
expect(resolved[1]).toContain('orders');
expect(resolved[2]).toContain('active');
});
});
@@ -11,7 +11,6 @@ import type { Context } from '@nocobase/actions';
import { z } from 'zod';
import {
EXECUTE_FRONTEND_TOOL_NAME,
FRONTEND_TOOL_RUNTIME_APPROVAL_INSTRUCTION,
LOAD_FRONTEND_TOOL_NAME,
type FrontendToolManifest,
isFrontendToolManifest,
@@ -158,9 +157,7 @@ export const prepareToolsForFrontendConversation = <T extends { definition: { na
...tool,
definition: {
...tool.definition,
description: `${tool.definition.description}\n\nfrontendToolCatalog: ${JSON.stringify(
catalog,
)}\n${FRONTEND_TOOL_RUNTIME_APPROVAL_INSTRUCTION}`,
description: `${tool.definition.description}\n\nfrontendToolCatalog: ${JSON.stringify(catalog)}`,
schema: isLoader
? z.object({ toolId: toolIdSchema })
: z.object({
@@ -172,11 +169,6 @@ export const prepareToolsForFrontendConversation = <T extends { definition: { na
});
};
export const resolveFlowModelWorkContext = async (_ctx: Context, contextItem: WorkContext): Promise<string> => {
const { frontendTools: _definitions, ...context } = contextItem;
return JSON.stringify(context);
};
export const readFrontendToolResult = (
ctx: Context,
toolCallId: string,
@@ -13,6 +13,8 @@ import { AIMessage, WorkContext, WorkContextHandler, WorkContextStrategies } fro
import { Context } from '@nocobase/actions';
import _ from 'lodash';
const MODEL_EXCLUDED_FIELDS = new Set(['frontendTools']);
export const createWorkContextHandler = (plugin: PluginAIServer): WorkContextHandler =>
new WorkContextHandlerImpl(plugin);
@@ -58,12 +60,19 @@ class WorkContextHandlerImpl implements WorkContextHandler {
if (!contextItem) {
return '';
}
const { resolve } = this.strategies.get(contextItem.type) ?? {};
const modelContext = this.excludeInternalFields(contextItem);
const { resolve } = this.strategies.get(modelContext.type) ?? {};
if (resolve) {
return await resolve(ctx, contextItem);
return await resolve(ctx, modelContext);
}
return await this.defaultStrategy.resolve?.(ctx, contextItem);
return await this.defaultStrategy.resolve?.(ctx, modelContext);
}
private excludeInternalFields(contextItem: WorkContext): WorkContext {
return Object.fromEntries(
Object.entries(contextItem).filter(([field]) => !MODEL_EXCLUDED_FIELDS.has(field)),
) as WorkContext;
}
}
@@ -56,7 +56,6 @@ import {
import { KnowledgeBaseManager } from './ai-employees/ai-knowledge-base';
import { LLMStreamCachedManager } from './manager/llm-stream-manager';
import { appendAIFileAttachmentSource } from './attachments';
import { resolveFlowModelWorkContext } from './frontend-tools';
type MCPClientModel = Model<{ useUserContext?: boolean }>;
type TransactionOptions = {
@@ -358,9 +357,6 @@ export class PluginAIServer extends Plugin {
}
registerWorkContextResolveStrategy() {
this.workContextHandler.registerStrategy('flow-model', {
resolve: resolveFlowModelWorkContext,
});
this.workContextHandler.registerStrategy('datasource', {
resolve: this.aiContextDatasourceManager.provideWorkContextResolveStrategy(),
});