mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-19 10:54:38 +08:00
fix(plugin-ai): enforce frontend tool approval at runtime (#10166)
This commit is contained in:
@@ -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(
|
||||
|
||||
+66
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user