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 { z } from 'zod'
|
||||||
import { validatePascalAgentAccess } from '@/lib/pascal-agent-access'
|
import { validatePascalAgentAccess } from '@/lib/pascal-agent-access'
|
||||||
import { getPascalAgentConfig } from '@/lib/pascal-agent-config'
|
import { getPascalAgentConfig } from '@/lib/pascal-agent-config'
|
||||||
|
import { pascalAgentStepPolicy, requiresPascalMutation } from '@/lib/pascal-agent-intent'
|
||||||
import { createPascalAgentMcpSession } from '@/lib/pascal-agent-mcp'
|
import { createPascalAgentMcpSession } from '@/lib/pascal-agent-mcp'
|
||||||
import { buildPascalAgentPrompt } from '@/lib/pascal-agent-prompt'
|
import { buildPascalAgentPrompt } from '@/lib/pascal-agent-prompt'
|
||||||
import { getPascalHostIdentity } from '@/lib/pascal-host-identity'
|
import { getPascalHostIdentity } from '@/lib/pascal-host-identity'
|
||||||
@ -82,6 +83,7 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const mutationRequired = requiresPascalMutation(parsed.data.messages)
|
||||||
|
|
||||||
const operations = await getSceneOperations()
|
const operations = await getSceneOperations()
|
||||||
const storedScene = await operations.loadStoredScene(parsed.data.sceneId)
|
const storedScene = await operations.loadStoredScene(parsed.data.sceneId)
|
||||||
@ -140,7 +142,14 @@ export async function POST(request: Request) {
|
|||||||
model: provider.chat(config.model),
|
model: provider.chat(config.model),
|
||||||
onAbort: async () => finalize(false),
|
onAbort: async () => finalize(false),
|
||||||
onError: 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),
|
stopWhen: stepCountIs(config.maxSteps),
|
||||||
system: buildPascalAgentPrompt({
|
system: buildPascalAgentPrompt({
|
||||||
sceneId: parsed.data.sceneId,
|
sceneId: parsed.data.sceneId,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import { PASCAL_AGENT_PROXY_HEADER, validatePascalAgentAccess } from './pascal-agent-access'
|
import { PASCAL_AGENT_PROXY_HEADER, validatePascalAgentAccess } from './pascal-agent-access'
|
||||||
import { getPascalAgentConfig } from './pascal-agent-config'
|
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 { buildPascalAgentPrompt } from './pascal-agent-prompt'
|
||||||
import { getPascalHostIdentity } from './pascal-host-identity'
|
import { getPascalHostIdentity } from './pascal-host-identity'
|
||||||
|
|
||||||
@ -81,6 +81,14 @@ describe('Pascal agent scene isolation', () => {
|
|||||||
expect(prompt).toContain('validate_scene')
|
expect(prompt).toContain('validate_scene')
|
||||||
expect(prompt).toContain('check_collisions')
|
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', () => {
|
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',
|
'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] & {
|
type RequiredTool = ToolSet[string] & {
|
||||||
execute: NonNullable<ToolSet[string]['execute']>
|
execute: NonNullable<ToolSet[string]['execute']>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PascalAgentMcpSession {
|
export interface PascalAgentMcpSession {
|
||||||
close: () => Promise<void>
|
close: () => Promise<void>
|
||||||
|
hasMutation: () => boolean
|
||||||
|
mutationToolNames: string[]
|
||||||
saveDraft: (abortSignal?: AbortSignal) => Promise<void>
|
saveDraft: (abortSignal?: AbortSignal) => Promise<void>
|
||||||
sceneName: string
|
sceneName: string
|
||||||
tools: ToolSet
|
tools: ToolSet
|
||||||
@ -91,6 +113,10 @@ export function isPascalAgentToolAllowed(name: string): boolean {
|
|||||||
return !BLOCKED_TOOL_NAMES.has(name)
|
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 {
|
function readSceneName(result: unknown, fallback: string): string {
|
||||||
const structured = (result as { structuredContent?: { name?: unknown } })?.structuredContent
|
const structured = (result as { structuredContent?: { name?: unknown } })?.structuredContent
|
||||||
return typeof structured?.name === 'string' && structured.name.trim()
|
return typeof structured?.name === 'string' && structured.name.trim()
|
||||||
@ -151,6 +177,7 @@ export async function createPascalAgentMcpSession(params: {
|
|||||||
assertToolSucceeded(loadResult, 'load_scene')
|
assertToolSucceeded(loadResult, 'load_scene')
|
||||||
const sceneName = readSceneName(loadResult, params.sceneId)
|
const sceneName = readSceneName(loadResult, params.sceneId)
|
||||||
const saveScene = withStepTimeout(requireTool(allTools, 'save_scene'), params.stepTimeoutMs)
|
const saveScene = withStepTimeout(requireTool(allTools, 'save_scene'), params.stepTimeoutMs)
|
||||||
|
let mutationCount = 0
|
||||||
|
|
||||||
const scopedSaveScene: RequiredTool = {
|
const scopedSaveScene: RequiredTool = {
|
||||||
...saveScene,
|
...saveScene,
|
||||||
@ -175,12 +202,23 @@ export async function createPascalAgentMcpSession(params: {
|
|||||||
const tools = Object.fromEntries(
|
const tools = Object.fromEntries(
|
||||||
Object.entries(allTools)
|
Object.entries(allTools)
|
||||||
.filter(([name]) => isPascalAgentToolAllowed(name))
|
.filter(([name]) => isPascalAgentToolAllowed(name))
|
||||||
.map(([name, tool]) => [
|
.map(([name, tool]) => {
|
||||||
name,
|
const timedTool =
|
||||||
name === 'save_scene'
|
name === 'save_scene'
|
||||||
? scopedSaveScene
|
? scopedSaveScene
|
||||||
: withStepTimeout(tool as RequiredTool, params.stepTimeoutMs),
|
: 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
|
) as ToolSet
|
||||||
|
|
||||||
let closed = false
|
let closed = false
|
||||||
@ -192,6 +230,8 @@ export async function createPascalAgentMcpSession(params: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
close,
|
close,
|
||||||
|
hasMutation: () => mutationCount > 0,
|
||||||
|
mutationToolNames: Object.keys(tools).filter(isPascalAgentMutationTool),
|
||||||
sceneName,
|
sceneName,
|
||||||
tools,
|
tools,
|
||||||
saveDraft: async (abortSignal) => {
|
saveDraft: async (abortSignal) => {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ Operating rules:
|
|||||||
- Inspect the current scene before changing an existing design. Preserve unrelated work.
|
- 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.
|
- 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.
|
- 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".
|
- 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.
|
- 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.
|
- 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