fix: require Pascal design agents to mutate scenes

This commit is contained in:
134 2026-07-12 10:32:20 +08:00
parent 3d35858c05
commit ab657330ea
6 changed files with 169 additions and 8 deletions

View File

@ -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,

View File

@ -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', () => {

View File

@ -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' })
})
})

View File

@ -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',
}
}

View File

@ -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<ToolSet[string]['execute']>
}
export interface PascalAgentMcpSession {
close: () => Promise<void>
hasMutation: () => boolean
mutationToolNames: string[]
saveDraft: (abortSignal?: AbortSignal) => Promise<void>
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) => {

View File

@ -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.