diff --git a/packages/plugins/@nocobase/plugin-ai/src/ai/tools/executeFrontendTool.ts b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/executeFrontendTool.ts index 81e8c24e271..75845a23106 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/ai/tools/executeFrontendTool.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/executeFrontendTool.ts @@ -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 diff --git a/packages/plugins/@nocobase/plugin-ai/src/ai/tools/loadFrontendTool.ts b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/loadFrontendTool.ts index ed95f153366..afdefc76547 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/ai/tools/loadFrontendTool.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/loadFrontendTool.ts @@ -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, }, }; }, diff --git a/packages/plugins/@nocobase/plugin-ai/src/common/frontend-tools.ts b/packages/plugins/@nocobase/plugin-ai/src/common/frontend-tools.ts index 16cbed54214..76d971e6df9 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/common/frontend-tools.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/common/frontend-tools.ts @@ -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'; diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/__tests__/frontend-tools.test.ts b/packages/plugins/@nocobase/plugin-ai/src/server/__tests__/frontend-tools.test.ts index c8afdd9d307..b41a0bc95e7 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/__tests__/frontend-tools.test.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/__tests__/frontend-tools.test.ts @@ -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( diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/__tests__/work-context-handler.test.ts b/packages/plugins/@nocobase/plugin-ai/src/server/__tests__/work-context-handler.test.ts new file mode 100644 index 00000000000..a88e66a0a12 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/server/__tests__/work-context-handler.test.ts @@ -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'); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/frontend-tools.ts b/packages/plugins/@nocobase/plugin-ai/src/server/frontend-tools.ts index e5e454dd1bc..55e17f92a65 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/frontend-tools.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/frontend-tools.ts @@ -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 = => { - const { frontendTools: _definitions, ...context } = contextItem; - return JSON.stringify(context); -}; - export const readFrontendToolResult = ( ctx: Context, toolCallId: string, diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/manager/work-context-handler.ts b/packages/plugins/@nocobase/plugin-ai/src/server/manager/work-context-handler.ts index 1ce98e1389e..777e8f62ccd 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/manager/work-context-handler.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/manager/work-context-handler.ts @@ -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; } } diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts index ad9d5c4dcd2..72f88e82765 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts @@ -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(), });