From ab657330ea6b794cbd3bc1ed4582fafe4110c45d Mon Sep 17 00:00:00 2001 From: 134 Date: Sun, 12 Jul 2026 10:32:20 +0800 Subject: [PATCH] fix: require Pascal design agents to mutate scenes --- .../apps/editor/app/api/agent/chat/route.ts | 11 +++- .../editor/lib/pascal-agent-config.test.ts | 10 ++- .../editor/lib/pascal-agent-intent.test.ts | 61 +++++++++++++++++++ .../apps/editor/lib/pascal-agent-intent.ts | 42 +++++++++++++ .../apps/editor/lib/pascal-agent-mcp.ts | 52 ++++++++++++++-- .../apps/editor/lib/pascal-agent-prompt.ts | 1 + 6 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 services/pascal-editor/apps/editor/lib/pascal-agent-intent.test.ts create mode 100644 services/pascal-editor/apps/editor/lib/pascal-agent-intent.ts 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 3e845be6f..61c9843fe 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 @@ -4,6 +4,7 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import { validatePascalAgentAccess } from '@/lib/pascal-agent-access' import { getPascalAgentConfig } from '@/lib/pascal-agent-config' +import { pascalAgentStepPolicy, requiresPascalMutation } from '@/lib/pascal-agent-intent' import { createPascalAgentMcpSession } from '@/lib/pascal-agent-mcp' import { buildPascalAgentPrompt } from '@/lib/pascal-agent-prompt' import { getPascalHostIdentity } from '@/lib/pascal-host-identity' @@ -82,6 +83,7 @@ export async function POST(request: Request) { { status: 400 }, ) } + const mutationRequired = requiresPascalMutation(parsed.data.messages) const operations = await getSceneOperations() const storedScene = await operations.loadStoredScene(parsed.data.sceneId) @@ -140,7 +142,14 @@ export async function POST(request: Request) { model: provider.chat(config.model), onAbort: async () => finalize(false), onError: async () => finalize(false), - onFinish: async () => finalize(true), + onFinish: async () => finalize(session.hasMutation()), + prepareStep: ({ stepNumber }) => + pascalAgentStepPolicy({ + hasMutation: session.hasMutation(), + mutationRequired, + mutationToolNames: session.mutationToolNames, + stepNumber, + }), stopWhen: stepCountIs(config.maxSteps), system: buildPascalAgentPrompt({ sceneId: parsed.data.sceneId, diff --git a/services/pascal-editor/apps/editor/lib/pascal-agent-config.test.ts b/services/pascal-editor/apps/editor/lib/pascal-agent-config.test.ts index 27c7d2cb8..1a0df1aac 100644 --- a/services/pascal-editor/apps/editor/lib/pascal-agent-config.test.ts +++ b/services/pascal-editor/apps/editor/lib/pascal-agent-config.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test' import { PASCAL_AGENT_PROXY_HEADER, validatePascalAgentAccess } from './pascal-agent-access' import { getPascalAgentConfig } from './pascal-agent-config' -import { isPascalAgentToolAllowed } from './pascal-agent-mcp' +import { isPascalAgentMutationTool, isPascalAgentToolAllowed } from './pascal-agent-mcp' import { buildPascalAgentPrompt } from './pascal-agent-prompt' import { getPascalHostIdentity } from './pascal-host-identity' @@ -81,6 +81,14 @@ describe('Pascal agent scene isolation', () => { expect(prompt).toContain('validate_scene') expect(prompt).toContain('check_collisions') }) + + test('distinguishes scene writes from inspection tools', () => { + expect(isPascalAgentMutationTool('create_room')).toBe(true) + expect(isPascalAgentMutationTool('add_door')).toBe(true) + expect(isPascalAgentMutationTool('get_scene')).toBe(false) + expect(isPascalAgentMutationTool('list_levels')).toBe(false) + expect(isPascalAgentMutationTool('save_scene')).toBe(false) + }) }) describe('Pascal host identity', () => { 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 new file mode 100644 index 000000000..57b99c7ca --- /dev/null +++ b/services/pascal-editor/apps/editor/lib/pascal-agent-intent.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'bun:test' +import { pascalAgentStepPolicy, requiresPascalMutation } from './pascal-agent-intent' + +describe('Pascal agent intent', () => { + test('requires a write operation for design requests', () => { + expect( + requiresPascalMutation([ + { + role: 'user', + parts: [{ type: 'text', text: '设计一个紧凑的两室一厅住宅' }], + }, + ]), + ).toBe(true) + }) + + test('allows read-only inspection requests to finish without a write', () => { + expect( + requiresPascalMutation([ + { + role: 'user', + parts: [{ type: 'text', text: '列出当前场景的所有楼层' }], + }, + ]), + ).toBe(false) + }) + + test('uses the latest user message', () => { + expect( + requiresPascalMutation([ + { role: 'user', parts: [{ type: 'text', text: '创建一个房间' }] }, + { role: 'assistant', parts: [{ type: 'text', text: '已完成' }] }, + { role: 'user', parts: [{ type: 'text', text: '现在只检查楼层' }] }, + ]), + ).toBe(false) + }) + + test('forces a mutation after two inspection steps', () => { + expect( + pascalAgentStepPolicy({ + hasMutation: false, + mutationRequired: true, + mutationToolNames: ['create_room', 'add_door'], + stepNumber: 2, + }), + ).toEqual({ + activeTools: ['create_room', 'add_door'], + toolChoice: 'required', + }) + }) + + test('returns to automatic tool selection after a write succeeds', () => { + expect( + pascalAgentStepPolicy({ + hasMutation: true, + mutationRequired: true, + mutationToolNames: ['create_room'], + stepNumber: 3, + }), + ).toEqual({ 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 new file mode 100644 index 000000000..ab3b53227 --- /dev/null +++ b/services/pascal-editor/apps/editor/lib/pascal-agent-intent.ts @@ -0,0 +1,42 @@ +const MUTATION_INTENT = + /(?:设计|创建|生成|添加|增加|新增|修改|调整|编辑|删除|移动|旋转|缩放|布置|装修|建造|搭建|修复|design|create|generate|add|modify|update|edit|delete|remove|move|rotate|scale|furnish|build|repair|fix)/i + +export function requiresPascalMutation(messages: unknown[]): boolean { + const latestUserMessage = [...messages] + .reverse() + .find( + (message) => + Boolean(message) && + typeof message === 'object' && + (message as { role?: unknown }).role === 'user', + ) as { parts?: unknown } | undefined + + if (!Array.isArray(latestUserMessage?.parts)) return false + const text = latestUserMessage.parts + .filter((part): part is { text: string; type: 'text' } => + Boolean( + part && + typeof part === 'object' && + (part as { type?: unknown }).type === 'text' && + typeof (part as { text?: unknown }).text === 'string', + ), + ) + .map((part) => part.text) + .join(' ') + + return MUTATION_INTENT.test(text) +} + +export function pascalAgentStepPolicy(params: { + hasMutation: boolean + mutationRequired: boolean + mutationToolNames: string[] + stepNumber: number +}): { activeTools?: string[]; toolChoice: 'auto' | 'required' } { + if (!(params.mutationRequired && !params.hasMutation)) return { toolChoice: 'auto' } + if (params.stepNumber < 2) return { toolChoice: 'required' } + return { + activeTools: params.mutationToolNames, + toolChoice: 'required', + } +} 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 0b478c8c6..7d7b47b71 100644 --- a/services/pascal-editor/apps/editor/lib/pascal-agent-mcp.ts +++ b/services/pascal-editor/apps/editor/lib/pascal-agent-mcp.ts @@ -17,12 +17,34 @@ const BLOCKED_TOOL_NAMES = new Set([ 'rename_scene', ]) +const MUTATION_TOOL_NAMES = new Set([ + 'add_door', + 'add_window', + 'apply_patch', + 'create_level', + 'create_roof', + 'create_room', + 'create_stair_between_levels', + 'create_story_shell', + 'create_wall', + 'cut_opening', + 'delete_node', + 'duplicate_level', + 'furnish_room', + 'place_item', + 'redo', + 'set_zone', + 'undo', +]) + type RequiredTool = ToolSet[string] & { execute: NonNullable } export interface PascalAgentMcpSession { close: () => Promise + hasMutation: () => boolean + mutationToolNames: string[] saveDraft: (abortSignal?: AbortSignal) => Promise sceneName: string tools: ToolSet @@ -91,6 +113,10 @@ export function isPascalAgentToolAllowed(name: string): boolean { return !BLOCKED_TOOL_NAMES.has(name) } +export function isPascalAgentMutationTool(name: string): boolean { + return MUTATION_TOOL_NAMES.has(name) +} + function readSceneName(result: unknown, fallback: string): string { const structured = (result as { structuredContent?: { name?: unknown } })?.structuredContent return typeof structured?.name === 'string' && structured.name.trim() @@ -151,6 +177,7 @@ export async function createPascalAgentMcpSession(params: { assertToolSucceeded(loadResult, 'load_scene') const sceneName = readSceneName(loadResult, params.sceneId) const saveScene = withStepTimeout(requireTool(allTools, 'save_scene'), params.stepTimeoutMs) + let mutationCount = 0 const scopedSaveScene: RequiredTool = { ...saveScene, @@ -175,12 +202,23 @@ export async function createPascalAgentMcpSession(params: { const tools = Object.fromEntries( Object.entries(allTools) .filter(([name]) => isPascalAgentToolAllowed(name)) - .map(([name, tool]) => [ - name, - name === 'save_scene' - ? scopedSaveScene - : withStepTimeout(tool as RequiredTool, params.stepTimeoutMs), - ]), + .map(([name, tool]) => { + const timedTool = + name === 'save_scene' + ? scopedSaveScene + : withStepTimeout(tool as RequiredTool, params.stepTimeoutMs) + if (!isPascalAgentMutationTool(name)) return [name, timedTool] + + const trackedTool: RequiredTool = { + ...timedTool, + execute: async (input, options) => { + const result = await timedTool.execute(input, options) + if ((result as { isError?: unknown })?.isError !== true) mutationCount++ + return result + }, + } + return [name, trackedTool] + }), ) as ToolSet let closed = false @@ -192,6 +230,8 @@ export async function createPascalAgentMcpSession(params: { return { close, + hasMutation: () => mutationCount > 0, + mutationToolNames: Object.keys(tools).filter(isPascalAgentMutationTool), sceneName, tools, saveDraft: async (abortSignal) => { diff --git a/services/pascal-editor/apps/editor/lib/pascal-agent-prompt.ts b/services/pascal-editor/apps/editor/lib/pascal-agent-prompt.ts index 94b222065..fa075663e 100644 --- a/services/pascal-editor/apps/editor/lib/pascal-agent-prompt.ts +++ b/services/pascal-editor/apps/editor/lib/pascal-agent-prompt.ts @@ -9,6 +9,7 @@ Operating rules: - Inspect the current scene before changing an existing design. Preserve unrelated work. - Pascal uses metres, with X/Z on the ground plane and Y pointing up. - Build in coherent groups. Call save_scene with saveMode "draft" after each meaningful group so the open editor updates while you work. +- For create, design, or modification requests, never finish after inspection alone. You must successfully call at least one scene mutation tool and verify its result before responding. - Before finishing, call validate_scene and check_collisions. Repair structural errors and serious collisions, then save_scene with saveMode "checkpoint". - Never claim a change succeeded unless the corresponding tool succeeded. - Keep destructive edits narrowly scoped. Prefer update or targeted delete_node operations and preserve the ability to undo.