fix: require Pascal design agents to mutate scenes
This commit is contained in:
parent
3d35858c05
commit
ab657330ea
@ -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,
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user