diff --git a/services/pascal-editor/apps/editor/app/api/agent/chat/route.ts b/services/pascal-editor/apps/editor/app/api/agent/chat/route.ts index 597ced10d..8d3dfe525 100644 --- a/services/pascal-editor/apps/editor/app/api/agent/chat/route.ts +++ b/services/pascal-editor/apps/editor/app/api/agent/chat/route.ts @@ -256,6 +256,7 @@ export async function POST(request: Request) { hasMutation: session.hasMutation(), mutationRequired, mutationToolNames: session.mutationToolNames, + readOnlyToolNames: session.readOnlyToolNames, stepNumber, }), }), diff --git a/services/pascal-editor/apps/editor/lib/pascal-agent-intent.test.ts b/services/pascal-editor/apps/editor/lib/pascal-agent-intent.test.ts index cdf722d2c..f3fe11e0b 100644 --- a/services/pascal-editor/apps/editor/lib/pascal-agent-intent.test.ts +++ b/services/pascal-editor/apps/editor/lib/pascal-agent-intent.test.ts @@ -24,6 +24,42 @@ describe('Pascal agent intent', () => { ).toBe(false) }) + test('does not treat a negated mutation as permission to write', () => { + expect( + requiresPascalMutation([ + { + role: 'user', + parts: [ + { + type: 'text', + text: '请只读取当前场景并汇总元素数量,不要修改任何模型。', + }, + ], + }, + ]), + ).toBe(false) + + expect( + requiresPascalMutation([ + { + role: 'user', + parts: [{ type: 'text', text: 'Inspect this scene, but do not modify the model.' }], + }, + ]), + ).toBe(false) + }) + + test('keeps other requested mutations after removing a negated action', () => { + expect( + requiresPascalMutation([ + { + role: 'user', + parts: [{ type: 'text', text: '不要删除现有墙体,只添加一扇窗。' }], + }, + ]), + ).toBe(true) + }) + test('uses the latest user message', () => { expect( requiresPascalMutation([ @@ -40,6 +76,7 @@ describe('Pascal agent intent', () => { hasMutation: false, mutationRequired: true, mutationToolNames: ['create_room', 'add_door'], + readOnlyToolNames: ['read_scene'], stepNumber: 1, }), ).toEqual({ @@ -54,8 +91,24 @@ describe('Pascal agent intent', () => { hasMutation: true, mutationRequired: true, mutationToolNames: ['create_room'], + readOnlyToolNames: ['read_scene'], stepNumber: 3, }), ).toEqual({ toolChoice: 'auto' }) }) + + test('only exposes inspection tools for read-only requests', () => { + expect( + pascalAgentStepPolicy({ + hasMutation: false, + mutationRequired: false, + mutationToolNames: ['apply_patch', 'save_scene'], + readOnlyToolNames: ['read_scene', 'list_levels'], + stepNumber: 0, + }), + ).toEqual({ + activeTools: ['read_scene', 'list_levels'], + toolChoice: 'auto', + }) + }) }) diff --git a/services/pascal-editor/apps/editor/lib/pascal-agent-intent.ts b/services/pascal-editor/apps/editor/lib/pascal-agent-intent.ts index 33b96dc3c..817d093ae 100644 --- a/services/pascal-editor/apps/editor/lib/pascal-agent-intent.ts +++ b/services/pascal-editor/apps/editor/lib/pascal-agent-intent.ts @@ -1,5 +1,25 @@ -const MUTATION_INTENT = - /(?:设计|创建|生成|添加|增加|新增|修改|调整|编辑|删除|移动|旋转|缩放|布置|装修|建造|搭建|修复|design|create|generate|add|modify|update|edit|delete|remove|move|rotate|scale|furnish|build|repair|fix)/i +const CHINESE_MUTATION_ACTIONS = + '设计|创建|生成|添加|增加|新增|修改|调整|编辑|删除|移动|旋转|缩放|布置|装修|建造|搭建|修复' +const ENGLISH_MUTATION_ACTIONS = + 'design|create|generate|add|modify|change|update|edit|delete|remove|move|rotate|scale|furnish|build|repair|fix' + +const MUTATION_INTENT = new RegExp( + `(?:${CHINESE_MUTATION_ACTIONS}|${ENGLISH_MUTATION_ACTIONS})`, + 'i', +) + +const NEGATED_CHINESE_MUTATION = new RegExp( + `(?:不要|请勿|勿|无需|不需要|不用|禁止|避免|不(?:再)?)[^,。;;,.!?!?]{0,32}(?:${CHINESE_MUTATION_ACTIONS})`, + 'gi', +) +const NEGATED_ENGLISH_MUTATION = new RegExp( + `(?:do\\s+not|don't|dont|please\\s+do\\s+not|please\\s+don't|without|avoid)\\s+[^,.!?;]{0,48}(?:${ENGLISH_MUTATION_ACTIONS})`, + 'gi', +) + +function actionableIntentText(text: string): string { + return text.replace(NEGATED_CHINESE_MUTATION, ' ').replace(NEGATED_ENGLISH_MUTATION, ' ') +} export function requiresPascalMutation(messages: unknown[]): boolean { const latestUserMessage = [...messages] @@ -24,15 +44,19 @@ export function requiresPascalMutation(messages: unknown[]): boolean { .map((part) => part.text) .join(' ') - return MUTATION_INTENT.test(text) + return MUTATION_INTENT.test(actionableIntentText(text)) } export function pascalAgentStepPolicy(params: { hasMutation: boolean mutationRequired: boolean mutationToolNames: string[] + readOnlyToolNames: string[] stepNumber: number }): { activeTools?: string[]; toolChoice: 'auto' | 'required' } { + if (!params.mutationRequired) { + return { activeTools: params.readOnlyToolNames, toolChoice: 'auto' } + } if (!(params.mutationRequired && !params.hasMutation)) return { toolChoice: 'auto' } if (params.stepNumber < 1) return { toolChoice: 'required' } return { diff --git a/services/pascal-editor/apps/editor/lib/pascal-agent-mcp.ts b/services/pascal-editor/apps/editor/lib/pascal-agent-mcp.ts index 7d7b47b71..efa768511 100644 --- a/services/pascal-editor/apps/editor/lib/pascal-agent-mcp.ts +++ b/services/pascal-editor/apps/editor/lib/pascal-agent-mcp.ts @@ -45,6 +45,7 @@ export interface PascalAgentMcpSession { close: () => Promise hasMutation: () => boolean mutationToolNames: string[] + readOnlyToolNames: string[] saveDraft: (abortSignal?: AbortSignal) => Promise sceneName: string tools: ToolSet @@ -117,6 +118,10 @@ export function isPascalAgentMutationTool(name: string): boolean { return MUTATION_TOOL_NAMES.has(name) } +function isPascalAgentWriteTool(name: string): boolean { + return name === 'save_scene' || isPascalAgentMutationTool(name) +} + function readSceneName(result: unknown, fallback: string): string { const structured = (result as { structuredContent?: { name?: unknown } })?.structuredContent return typeof structured?.name === 'string' && structured.name.trim() @@ -228,10 +233,12 @@ export async function createPascalAgentMcpSession(params: { await client?.close() } + const toolNames = Object.keys(tools) return { close, hasMutation: () => mutationCount > 0, - mutationToolNames: Object.keys(tools).filter(isPascalAgentMutationTool), + mutationToolNames: toolNames.filter(isPascalAgentMutationTool), + readOnlyToolNames: toolNames.filter((name) => !isPascalAgentWriteTool(name)), sceneName, tools, saveDraft: async (abortSignal) => {