From ac63822fbc7dfcb03f4e4e17ca5dce48ee15693f Mon Sep 17 00:00:00 2001 From: improdead Date: Thu, 2 Oct 2025 22:25:49 -0400 Subject: [PATCH 1/7] feat: manual-mode gating, asset filtering, search limit 60, hospital e2e example; plugin fallback+filters --- .../apps/web/app/api/assets/search/route.ts | 3 +- .../web/app/api/proposals/[id]/apply/route.ts | 24 + vector/apps/web/lib/catalog/search.ts | 42 +- vector/apps/web/lib/orchestrator/README.md | 4 + vector/apps/web/lib/orchestrator/index.ts | 441 ++++++++++++++-- .../web/lib/orchestrator/prompts/examples.ts | 21 +- vector/apps/web/lib/orchestrator/taskState.ts | 10 +- vector/apps/web/package.json | 3 +- .../web/scripts/test-workflow-hospital.ts | 61 +++ vector/plugin/Vector.rbxmx | 480 ++++++++++++++++-- vector/plugin/src/main.server.lua | 470 +++++++++++++++-- vector/plugin/src/tools/create_instance.lua | 8 + 12 files changed, 1398 insertions(+), 169 deletions(-) create mode 100644 vector/apps/web/scripts/test-workflow-hospital.ts diff --git a/vector/apps/web/app/api/assets/search/route.ts b/vector/apps/web/app/api/assets/search/route.ts index 080519d..58e195f 100644 --- a/vector/apps/web/app/api/assets/search/route.ts +++ b/vector/apps/web/app/api/assets/search/route.ts @@ -25,7 +25,7 @@ export async function GET(req: Request) { const tags = parseTags(searchParams) const logQuery = query.replace(/[\r\n\t]+/g, ' ').trim().slice(0, 80) const logTags = tags.map((t) => t.slice(0, 24)).join('|') - const limit = Math.max(1, Math.min(50, Number(searchParams.get('limit') || '8'))) + const limit = Math.max(1, Math.min(60, Number(searchParams.get('limit') || '8'))) const override = process.env.CATALOG_API_URL?.trim() const provider = !override || override.toLowerCase() === 'roblox' ? 'roblox' : 'proxy' const t0 = Date.now() @@ -48,4 +48,3 @@ export async function GET(req: Request) { }) } } - diff --git a/vector/apps/web/app/api/proposals/[id]/apply/route.ts b/vector/apps/web/app/api/proposals/[id]/apply/route.ts index 197a9fb..0a148a0 100644 --- a/vector/apps/web/app/api/proposals/[id]/apply/route.ts +++ b/vector/apps/web/app/api/proposals/[id]/apply/route.ts @@ -45,6 +45,11 @@ export async function POST(req: Request, ctx: { params: { id: string } }) { const fallback = 'CATALOG_UNAVAILABLE Asset search/insert failed. Create the requested objects manually using create_instance/set_properties or Luau edits.' try { pushChunk(wf, 'fallback.asset manual_required') } catch (e) { console.error('Failed to push chunk for asset fallback', e) } updateTaskState(wf, (state) => { + if (!state.policy || typeof state.policy !== 'object') { + state.policy = { geometryOps: 0, luauEdits: 0, manualMode: true, assetSearches: 0, assetInserts: 0 } + } else { + state.policy.manualMode = true + } state.history.push({ role: 'system', content: fallback + (opKind ? ` op=${opKind}` : ''), at: Date.now() }) }) } @@ -66,6 +71,25 @@ export async function POST(req: Request, ctx: { params: { id: string } }) { ) } } + // Log asset operations verbosely for terminal diagnostics + if (typeof (body as any)?.type === 'string' && (body as any).type === 'asset_op') { + const opKind = typeof (body as any)?.op === 'string' ? String((body as any).op) : 'unknown' + const ok = (body as any)?.ok === true + const assetId = (body as any)?.assetId + const parentPath = typeof (body as any)?.parentPath === 'string' ? String((body as any)?.parentPath) : undefined + const insertedPath = typeof (body as any)?.insertedPath === 'string' ? String((body as any)?.insertedPath) : undefined + const query = typeof (body as any)?.query === 'string' ? String((body as any)?.query) : undefined + const error = typeof (body as any)?.error === 'string' ? String((body as any)?.error) : undefined + const base = `[proposals.apply] asset_op op=${opKind}` + + (assetId != null ? ` id=${assetId}` : '') + + (parentPath ? ` parent=${parentPath}` : '') + + (query ? ` query="${query}"` : '') + if (ok) { + console.log(`${base} ok inserted=${insertedPath || 'n/a'}`) + } else { + console.warn(`${base} failed error=${error || 'unknown'}`) + } + } console.log( `[proposals.apply] id=${id} ok=${!!after} workflowId=${after?.workflowId || 'n/a'} payloadKeys=${Object.keys(body || {}).length}`, ) diff --git a/vector/apps/web/lib/catalog/search.ts b/vector/apps/web/lib/catalog/search.ts index 235a9de..bc1e3c3 100644 --- a/vector/apps/web/lib/catalog/search.ts +++ b/vector/apps/web/lib/catalog/search.ts @@ -1,4 +1,13 @@ -export type CatalogItem = { id: number; name: string; creator: string; type: string; thumbnailUrl?: string } +export type CatalogItem = { + id: number + name: string + creator: string + type: string + thumbnailUrl?: string + creatorId?: number + creatorType?: string + isVerified?: boolean +} type CatalogSearchOptions = { tags?: string[] @@ -6,7 +15,7 @@ type CatalogSearchOptions = { const ROBLOX_CATALOG_URL = 'https://catalog.roblox.com/v1/search/items/details' const ROBLOX_THUMBNAIL_URL = 'https://thumbnails.roblox.com/v1/assets' -const ROBLOX_ALLOWED_LIMITS = [10, 28, 30] as const +const ROBLOX_ALLOWED_LIMITS = [10, 28, 30, 60] as const const TAG_CATEGORY_MAP: Record = { audio: 'Audio', @@ -52,7 +61,7 @@ function normalizeString(value?: string | null): string | undefined { } function pickRobloxLimit(limit: number): number { - const safe = Math.max(1, Math.min(50, Number.isFinite(limit) ? limit : 8)) + const safe = Math.max(1, Math.min(60, Number.isFinite(limit) ? limit : 8)) for (const allowed of ROBLOX_ALLOWED_LIMITS) { if (safe <= allowed) return allowed } @@ -98,7 +107,7 @@ async function fetchRobloxThumbnails(ids: number[]): Promise async function fetchFromRoblox(query: string, limit: number, opts?: CatalogSearchOptions): Promise { const trimmedQuery = query?.trim() ?? '' - const desiredLimit = Math.max(1, Math.min(50, Number.isFinite(limit) ? limit : 8)) + const desiredLimit = Math.max(1, Math.min(60, Number.isFinite(limit) ? limit : 8)) const requestLimit = pickRobloxLimit(desiredLimit) const category = deriveCategory(opts?.tags) const params = new URLSearchParams({ @@ -109,6 +118,7 @@ async function fetchFromRoblox(query: string, limit: number, opts?: CatalogSearc SortType: '3', }) // Optional: restrict to free assets only when explicitly requested + // Default free-only OFF to broaden results; env can enable explicitly const freeOnly = String(process.env.CATALOG_FREE_ONLY || '0') === '1' if (freeOnly) { // Prefer Roblox sales filter for free items; explicit price filters often 0-out results @@ -156,12 +166,19 @@ async function fetchFromRoblox(query: string, limit: number, opts?: CatalogSearc for (const entry of sliced) { const id = Number(entry?.id) if (!Number.isFinite(id)) continue + const creatorIdRaw = entry?.creatorTargetId ?? entry?.creatorId + const creatorId = typeof creatorIdRaw === 'number' ? creatorIdRaw : Number(creatorIdRaw) + const creatorType = typeof entry?.creatorType === 'string' ? entry.creatorType : undefined + const isVerified = Boolean(entry?.creatorHasVerifiedBadge ?? entry?.hasVerifiedBadge) items.push({ id, name: typeof entry?.name === 'string' ? entry.name : `Asset ${id}`, creator: typeof entry?.creatorName === 'string' ? entry.creatorName : 'Unknown', type: assetTypeLabel(entry?.assetType), thumbnailUrl: thumbMap.get(id), + creatorId: Number.isFinite(creatorId) ? Number(creatorId) : undefined, + creatorType, + isVerified, }) } return items @@ -207,7 +224,7 @@ async function fetchFromCreatorStoreToolbox(query: string, limit: number, opts?: // Broaden results by allowing all creators (verified and non-verified) url.searchParams.set('includeOnlyVerifiedCreators', 'false') // Free-only via price caps in cents - if (String(process.env.CATALOG_FREE_ONLY || '0') === '1') { + if (String(process.env.CATALOG_FREE_ONLY || '1') === '1') { url.searchParams.set('minPriceCents', '0') url.searchParams.set('maxPriceCents', '0') } @@ -244,7 +261,20 @@ async function fetchFromCreatorStoreToolbox(query: string, limit: number, opts?: // Toolbox imagePreviewAssets are asset ids; thumbnails route might be needed separately. // Use undefined here; downstream can fetch thumbnails by id if needed. } - items.push({ id, name, creator, type, thumbnailUrl }) + const creatorIdRaw = entry?.creator?.id ?? entry?.creatorId + const creatorId = typeof creatorIdRaw === 'number' ? creatorIdRaw : Number(creatorIdRaw) + const creatorType = typeof entry?.creator?.type === 'string' ? entry.creator.type : undefined + const isVerified = Boolean(entry?.creator?.hasVerifiedBadge ?? entry?.creatorHasVerifiedBadge) + items.push({ + id, + name, + creator, + type, + thumbnailUrl, + creatorId: Number.isFinite(creatorId) ? Number(creatorId) : undefined, + creatorType, + isVerified, + }) } return items.slice(0, Math.max(1, Math.min(50, limit || 8))) } finally { diff --git a/vector/apps/web/lib/orchestrator/README.md b/vector/apps/web/lib/orchestrator/README.md index d48377a..f5986c9 100644 --- a/vector/apps/web/lib/orchestrator/README.md +++ b/vector/apps/web/lib/orchestrator/README.md @@ -10,5 +10,9 @@ Coordinates multi-step tasks, provider execution, prompt assembly, and proposal - `taskState.ts` – State tracking for tasks. - `proposals.ts` / `autoApprove.ts` – Proposal generation and auto-approval logic. +## Behaviour Highlights +- Catalog fallback switches the task into **manual mode**. Once enabled, asset search/insert tools are blocked until manual geometry (Parts + Luau) is produced or the user explicitly re-enables catalog usage. +- When a plan already exists, additional `` calls must reuse the same steps; changes require ``. + ## Extending Providers Add a new file under `providers/` exporting a standardized interface (see existing providers for shape). diff --git a/vector/apps/web/lib/orchestrator/index.ts b/vector/apps/web/lib/orchestrator/index.ts index 308e243..da2ca95 100644 --- a/vector/apps/web/lib/orchestrator/index.ts +++ b/vector/apps/web/lib/orchestrator/index.ts @@ -132,6 +132,7 @@ import { getSceneProperties, applyObjectOpsPreview, hydrateSceneSnapshot, + normalizeInstancePath, } from './sceneGraph' type ProviderMode = 'openrouter' | 'gemini' | 'bedrock' | 'nvidia' @@ -372,9 +373,10 @@ const PROMPT_SECTIONS = [ - Attributes use "@Name" keys. - Edits are 0-based, non-overlapping, ≤20 edits and ≤2000 inserted chars.`, `Assets & 3D - - Prefer search_assets → insert_asset for props/models. Use create_instance only for simple primitive geometry or when catalog search fails/disabled. + - Manual-first when the user provides explicit dimensions/materials or forbids catalog usage. In these cases, do NOT use search_assets/insert_asset — build with create_instance/set_properties and author idempotent Luau. + - Use asset tools only when the user explicitly asks for catalog assets and no precise geometry was requested. If catalog search fails or is disabled, immediately fall back to manual geometry; do not retry search in a loop. - Composite scenes (e.g., park, plaza, town square): it is acceptable to add multiple distinct assets (e.g., trees, benches, fountain, lights) as separate search_assets → insert_asset steps in the plan. Keep it concise: 2–4 categories initially, each with limit ≤ 6. - - Single‑subject builds (e.g., hospital, watch tower): avoid adding unrelated props unless requested. If a container model (e.g., game.Workspace.Hospital) already exists, do not recreate it; prefer inserting exactly one primary asset (e.g., a hospital building) under a sensible child like "Imported", or add obviously missing interior kits/furnishings. + - Single‑subject builds (e.g., hospital, watch tower): avoid adding unrelated props unless requested. If a container model (e.g., game.Workspace.Hospital) already exists, do not recreate it; prefer manual parts under that model, or insert a single primary asset when the user asked for it. - In Auto mode (autoApply=true), prefer choosing a single best match and inserting it directly without listing. Do not insert multiples unless the user explicitly asks for more than one. - search_assets limit ≤ 6 unless the user asks. Include helpful tags. - insert_asset defaults parentPath to game.Workspace if unknown. Before creating or inserting, inspect existing children (list_children) and skip duplicates when names/roles already exist.`, @@ -419,6 +421,99 @@ Insert an asset (preferred) game.Workspace +End-to-end: Hospital (asset-first → manual fallback) + + [ + "List children of game.Workspace to inventory scene", + "Search assets query='hospital building' tags=['model','building','hospital'] limit=6", + "Insert best match under game.Workspace", + "If insert fails or unauthorized, create Model 'Hospital' and build geometry manually", + "Create 'Floor' 40x1x40 at y=0.5, Material Concrete", + "Create four brick walls: front/back 40x10x1 at z=±19.5, left/right 1x10x38 at x=±19.5", + "Create 'Roof' 42x1x42 at y=11, Material Slate", + "Open or create Script 'HospitalBuilder' in game.ServerScriptService", + "Show diff to add idempotent Luau that rebuilds the hospital" + ] + + + + game.Workspace + 1 + +TOOL_RESULT list_children +{"children":[{"path":"game.Workspace.Terrain","name":"Terrain","className":"Terrain"}]} + + + hospital building + ["model","building","hospital"] + 6 + +TOOL_RESULT search_assets +{"results":[{"id":123456,"name":"City Hospital","creator":"BuilderX","type":"Model"},{"id":234567,"name":"Hospital LowPoly","creator":"DevY","type":"Model"}]} + + + 123456 + game.Workspace + +TOOL_RESULT insert_asset +{"ok":false,"op":"insert_asset","assetId":123456,"error":"User is not authorized to access Asset."} + + + Model + game.Workspace + {"Name":"Hospital"} + + + + Part + game.Workspace.Hospital + {"Name":"Floor","Anchored":true,"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"Concrete"},"Size":{"__t":"Vector3","x":40,"y":1,"z":40},"CFrame":{"__t":"CFrame","comps":[0,0.5,0, 1,0,0, 0,1,0, 0,0,1]}} + + + + Part + game.Workspace.Hospital + {"Name":"WallFront","Anchored":true,"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"Brick"},"Size":{"__t":"Vector3","x":40,"y":10,"z":1},"CFrame":{"__t":"CFrame","comps":[0,5,-19.5, 1,0,0, 0,1,0, 0,0,1]}} + + + + Part + game.Workspace.Hospital + {"Name":"WallBack","Anchored":true,"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"Brick"},"Size":{"__t":"Vector3","x":40,"y":10,"z":1},"CFrame":{"__t":"CFrame","comps":[0,5,19.5, 1,0,0, 0,1,0, 0,0,1]}} + + + + Part + game.Workspace.Hospital + {"Name":"WallLeft","Anchored":true,"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"Brick"},"Size":{"__t":"Vector3","x":1,"y":10,"z":38},"CFrame":{"__t":"CFrame","comps":[-19.5,5,0, 1,0,0, 0,1,0, 0,0,1]}} + + + + Part + game.Workspace.Hospital + {"Name":"WallRight","Anchored":true,"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"Brick"},"Size":{"__t":"Vector3","x":1,"y":10,"z":38},"CFrame":{"__t":"CFrame","comps":[19.5,5,0, 1,0,0, 0,1,0, 0,0,1]}} + + + + Part + game.Workspace.Hospital + {"Name":"Roof","Anchored":true,"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"Slate"},"Size":{"__t":"Vector3","x":42,"y":1,"z":42},"CFrame":{"__t":"CFrame","comps":[0,11,0, 1,0,0, 0,1,0, 0,0,1]}} + + + + game.ServerScriptService + HospitalBuilder + + + + game.ServerScriptService.HospitalBuilder + [{"start":{"line":0,"character":0},"end":{"line":0,"character":0},"text":"local Workspace = game:GetService('Workspace')\nlocal function ensureModel(name)\n\tlocal m = Workspace:FindFirstChild(name)\n\tif not m then m = Instance.new('Model'); m.Name = name; m.Parent = Workspace end\n\treturn m\nend\nlocal function ensurePart(parent, name, size, cf, material)\n\tlocal p = parent:FindFirstChild(name)\n\tif not p then p = Instance.new('Part'); p.Name = name; p.Anchored = true; p.Parent = parent end\n\tp.Size = size; p.CFrame = cf; if material then p.Material = material end\n\treturn p\nend\nlocal hospital = ensureModel('Hospital')\nensurePart(hospital, 'Floor', Vector3.new(40,1,40), CFrame.new(0,0.5,0), Enum.Material.Concrete)\nensurePart(hospital, 'WallFront', Vector3.new(40,10,1), CFrame.new(0,5,-19.5), Enum.Material.Brick)\nensurePart(hospital, 'WallBack', Vector3.new(40,10,1), CFrame.new(0,5,19.5), Enum.Material.Brick)\nensurePart(hospital, 'WallLeft', Vector3.new(1,10,38), CFrame.new(-19.5,5,0), Enum.Material.Brick)\nensurePart(hospital, 'WallRight', Vector3.new(1,10,38), CFrame.new(19.5,5,0), Enum.Material.Brick)\nensurePart(hospital, 'Roof', Vector3.new(42,1,42), CFrame.new(0,11,0), Enum.Material.Slate)\n"}] + + + + Placed Hospital geometry (floor, 4 walls, roof) and added HospitalBuilder Luau for idempotent rebuild. Asset insert failed; manual fallback used. + + Build simple geometry (generic) ["Create 'Structure' model under Workspace","Create 'Floor' Part with size 16x1x16 at y=0.5 Anchored","Create 'WallFront' Part 16x8x1 at z=-7.5 Anchored","Create 'Roof' Part 16x1x16 with slight tilt Anchored"] @@ -712,6 +807,10 @@ type MapToolExtras = { recordPlanUpdate?: (update: { completedStep?: string; nextStep?: string; notes?: string }) => void userOptedOut?: boolean geometryTracker?: { sawCreate: boolean; sawParts: boolean } + subjectNouns?: string[] + manualMode?: boolean + geometryCount?: number + luauCount?: number } type MapResult = { proposals: Proposal[]; missingContext?: string; contextResult?: any } @@ -824,8 +923,42 @@ function mapToolToProposals( if (typeof (a as any).className === 'string' && parentPath) { const childClass = (a as any).className as string const childProps = (a as any).props as Record | undefined + const manualMode = !!extras?.manualMode + const manualProgress = ((extras?.geometryCount ?? 0) > 0) || ((extras?.luauCount ?? 0) > 0) const ops: ObjectOp[] = [] + if (manualMode && !manualProgress && childClass === 'Model') { + return { proposals, missingContext: 'Manual mode: create visible Parts (Floor/Wall/Roof) or add the builder script before making additional container Models.' } + } + + if (manualMode && childClass === 'Model') { + const nameProp = childProps && typeof childProps.Name === 'string' ? String(childProps.Name).trim() : '' + const subjects = Array.isArray(extras?.subjectNouns) ? extras!.subjectNouns : [] + const matchesSubject = nameProp && subjects.some((noun) => noun && nameProp.toLowerCase().includes(noun.toLowerCase())) + if (!matchesSubject) { + return { proposals, missingContext: 'Manual mode active: create anchored Parts or update existing geometry instead of generic container Models.' } + } + } + + // Guard: keep names aligned to user nouns. Common pitfall: "House"/"SimpleHouse" for hospital tasks. + const wantsHospital = /\bhospital\b/i.test(msg) || (extras?.subjectNouns || []).some((s) => /\bhospital\b/i.test(s)) + if (wantsHospital && childClass === 'Model' && childProps && typeof childProps.Name === 'string') { + const nm = String(childProps.Name) + if (/^simple\s*house$/i.test(nm) || /^house$/i.test(nm) || /^structure$/i.test(nm)) { + (a as any).props = { ...childProps, Name: 'Hospital' } + } + } + let normalizedParentPath = parentPath + if (wantsHospital && typeof normalizedParentPath === 'string') { + normalizedParentPath = normalizedParentPath + .replace(/(game\.)?Workspace\.SimpleHouse\b/i, 'game.Workspace.Hospital') + .replace(/(game\.)?Workspace\.House\b/i, 'game.Workspace.Hospital') + .replace(/(game\.)?Workspace\.Structure\b/i, 'game.Workspace.Hospital') + .replace(/^Workspace\.SimpleHouse\b/i, 'Workspace.Hospital') + .replace(/^Workspace\.House\b/i, 'Workspace.Hospital') + .replace(/^Workspace\.Structure\b/i, 'Workspace.Hospital') + } + // Build known scene paths, with and without 'game.' prefix for services const nodes = Array.isArray(input.context.scene?.nodes) ? input.context.scene!.nodes! : [] const known = new Set() @@ -838,35 +971,41 @@ function mapToolToProposals( if (SERVICE_PREFIXES.includes(head)) known.add(`game.${p}`) } - const looksLikeWorkspace = /^game\.Workspace(?:\.|$)/i.test(parentPath) || /^Workspace(?:\.|$)/.test(parentPath) + const looksLikeWorkspace = /^game\.Workspace(?:\.|$)/i.test(normalizedParentPath) || /^Workspace(?:\.|$)/.test(normalizedParentPath) if (looksLikeWorkspace) { - // Walk up and create missing ancestors as Models under Workspace - const chain: { parent: string; name: string }[] = [] - let cur = parentPath - let guard = 0 - while (cur && !known.has(cur) && guard++ < 10) { - const noGame = cur.replace(/^game\./, '') - if (known.has(noGame)) break - const split = splitInstancePath(cur) - const inferredParent = split.parentPath || 'game.Workspace' - const inferredName = split.name || 'Model' - if (/^game\.Workspace$/i.test(inferredParent) || /^Workspace$/i.test(inferredParent)) { - const normalizedParent = /^Workspace$/i.test(inferredParent) ? 'game.Workspace' : inferredParent - chain.push({ parent: normalizedParent, name: inferredName }) + // Skip ancestor creation when targeting the root Workspace directly + if (!/^game\.Workspace$/i.test(normalizedParentPath) && !/^Workspace$/i.test(normalizedParentPath)) { + // Walk up and create missing ancestors (beyond Workspace) as Models under Workspace + const chain: { parent: string; name: string }[] = [] + let cur = normalizedParentPath + let guard = 0 + while (cur && !known.has(cur) && guard++ < 10) { + const noGame = cur.replace(/^game\./, '') + if (known.has(noGame)) break + const split = splitInstancePath(cur) + const inferredParent = split.parentPath || 'game.Workspace' + const inferredName = split.name || 'Model' + // Avoid generating bogus entries for the Workspace or 'game' segment + const isRootParent = /^game\.Workspace$/i.test(inferredParent) || /^Workspace$/i.test(inferredParent) + const isRootName = /^Workspace$/i.test(inferredName) || /^game$/i.test(inferredName) + if (isRootParent && !isRootName) { + const normalizedParent = /^Workspace$/i.test(inferredParent) ? 'game.Workspace' : inferredParent + chain.push({ parent: normalizedParent, name: inferredName }) + } + if (!split.parentPath || /^game\.Workspace$/i.test(split.parentPath) || /^Workspace$/i.test(split.parentPath)) break + cur = split.parentPath + } + for (let i = chain.length - 1; i >= 0; i--) { + const seg = chain[i] + ops.push({ op: 'create_instance', className: 'Model', parentPath: seg.parent, props: { Name: seg.name } }) + const createdPath = buildInstancePath(seg.parent.replace(/^game\./, ''), seg.name).replace(/^Workspace\./, 'Workspace.') + known.add(createdPath) + known.add(`game.${createdPath}`) } - if (!split.parentPath || /^game\.Workspace$/i.test(split.parentPath) || /^Workspace$/i.test(split.parentPath)) break - cur = split.parentPath - } - for (let i = chain.length - 1; i >= 0; i--) { - const seg = chain[i] - ops.push({ op: 'create_instance', className: 'Model', parentPath: seg.parent, props: { Name: seg.name } }) - const createdPath = buildInstancePath(seg.parent.replace(/^game\./, ''), seg.name).replace(/^Workspace\./, 'Workspace.') - known.add(createdPath) - known.add(`game.${createdPath}`) } } - ops.push({ op: 'create_instance', className: childClass, parentPath, props: childProps }) + ops.push({ op: 'create_instance', className: childClass, parentPath: normalizedParentPath, props: (a as any).props as any }) proposals.push({ id: id('obj'), type: 'object_op', ops, notes: 'Parsed from create_instance' }) if (extras?.geometryTracker) { extras.geometryTracker.sawCreate = true @@ -980,11 +1119,37 @@ function mapToolToProposals( targetPath = buildInstancePath(parentPath, scriptName) } + const normalizePath = (value?: string) => normalizeInstancePath(value) || undefined + const pathsEqual = (left?: string, right?: string): boolean => { + const normLeft = normalizePath(left) + const normRight = normalizePath(right) + if (!normLeft || !normRight) return false + if (normLeft === normRight) return true + const withGameLeft = normLeft.startsWith('game.') ? normLeft : `game.${normLeft}` + const withGameRight = normRight.startsWith('game.') ? normRight : `game.${normRight}` + if (withGameLeft === withGameRight) return true + const withoutGameLeft = normLeft.replace(/^game\./, '') + const withoutGameRight = normRight.replace(/^game\./, '') + return withoutGameLeft === withoutGameRight + } + + const activeScript = input.context.activeScript const knownSource = targetPath ? extras?.getScriptSource?.(targetPath) : undefined - let created = false - let text = typeof knownSource === 'string' ? knownSource : '' + let text: string | undefined = typeof knownSource === 'string' ? knownSource : undefined + + if (text === undefined && activeScript && pathsEqual(activeScript.path, targetPath)) { + text = typeof activeScript.text === 'string' ? activeScript.text : '' + } - if (targetPath && typeof knownSource !== 'string') { + const sceneNodes = Array.isArray(input.context.scene?.nodes) ? input.context.scene!.nodes! : [] + const scriptExistsInScene = !!targetPath && sceneNodes.some((node) => pathsEqual(node?.path, targetPath)) + + if (text === undefined && scriptExistsInScene) { + return { proposals, missingContext: `Need script Source for ${targetPath}. Open the script or call .` } + } + + let created = false + if (targetPath && text === undefined) { created = true text = '' const op: ObjectOp = { @@ -999,10 +1164,13 @@ function mapToolToProposals( notes: 'Ensure script exists before editing.', ops: [op], }) + } + + if (targetPath && text !== undefined) { extras?.recordScriptSource?.(targetPath, text) } - return { proposals, contextResult: { path: targetPath, text, created } } + return { proposals, contextResult: { path: targetPath, text: text ?? '', created } } } if (name === 'complete') { const summary = typeof (a as any).summary === 'string' ? (a as any).summary : undefined @@ -1100,17 +1268,14 @@ const SCRIPT_OPT_IN_PATTERNS = [ /\bprovide\s+(?:the\s+)?script\b/i, ] -const GEOMETRY_INTENT_PATTERNS = [ - /\b(build|create|make|spawn|place|add|put|drop)\b[^.!?]{0,120}\b(house|building|structure|scene|environment|world|level|map|terrain|tower|bridge|park|city|village|room|base|farm|garden|landscape)\b/i, - /\bpopulate\b[^.!?]{0,120}\b(scene|world|environment)\b/i, - /\bdecorate\b[^.!?]{0,120}\b(scene|area|room)\b/i, -] +// Removed implicit geometry-intent opt-out: natural phrases like "build a hospital" should not disable scripting +const GEOMETRY_INTENT_PATTERNS: RegExp[] = [] function detectScriptOptPreference(text: string): boolean | null { if (!text) return null if (SCRIPT_OPT_OUT_PATTERNS.some((pat) => pat.test(text))) return true if (SCRIPT_OPT_IN_PATTERNS.some((pat) => pat.test(text))) return false - if (GEOMETRY_INTENT_PATTERNS.some((pat) => pat.test(text))) return true + // Do not infer opt-out from generic build verbs; prefer explicit user opt-in/opt-out return null } @@ -1158,6 +1323,29 @@ function proposalTouchesGeometry(proposal: Proposal): boolean { return false } +function proposalCreatesVisibleGeometry(proposal: Proposal): boolean { + if (!proposal) return false + if (proposal.type === 'object_op') { + return proposal.ops.some((op) => { + if (op.op === 'create_instance') { + return PART_CLASS_NAMES.has(op.className) + } + if (op.op === 'set_properties') { + if (isLuauScriptPath(op.path) && hasLuauSource(op.props as Record | undefined)) { + return false + } + const props = (op.props || {}) as Record + return ['Size', 'CFrame', 'Anchored', 'Material', 'Position', 'Orientation'].some((key) => Object.prototype.hasOwnProperty.call(props, key)) + } + return false + }) + } + if (proposal.type === 'asset_op') { + return !!proposal.insert || !!proposal.generate3d + } + return false +} + export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; taskState: TaskState; tokenTotals: { in: number; out: number } }> { const rawMessage = input.message.trim() const { cleaned, attachments } = await extractMentions(rawMessage) @@ -1218,6 +1406,20 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; } const scriptSources: Record = { ...(taskState.scriptSources || {}) } + // Infer stable subject nouns (e.g., "hospital") from prior user messages or plan steps + const subjectNouns: string[] = (() => { + const nouns = new Set() + try { + const hist = Array.isArray(taskState.history) ? taskState.history : [] + const histText = hist.map((h) => (h?.content || '')).join(' \n ') + if (/\bhospital\b/i.test(histText)) nouns.add('hospital') + } catch {} + try { + const steps = Array.isArray(taskState.plan?.steps) ? taskState.plan!.steps : [] + if (steps.some((s) => /\bHospital\b/i.test(String(s)))) nouns.add('hospital') + } catch {} + return Array.from(nouns) + })() const normalizeScriptPath = (path?: string) => (path || '').trim() const getScriptSource = (path: string): string | undefined => { const key = normalizeScriptPath(path) @@ -1237,7 +1439,24 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; } const recordPlanStart = (steps: string[]) => { - const normalized = steps.map((s) => (typeof s === 'string' ? s.trim() : '')).filter((s) => s.length > 0) + const raw = steps.map((s) => (typeof s === 'string' ? s.trim() : '')).filter((s) => s.length > 0) + // Sanitize step names to avoid subject drift (e.g., "SimpleHouse" instead of requested "Hospital") + const normalized = raw.map((s) => { + let out = s + if ((subjectNouns || []).some((n) => /\bhospital\b/i.test(n))) { + out = out + .replace(/\bSimpleHouse\b/g, 'Hospital') + .replace(/\bHouse\b/g, 'Hospital') + .replace(/\bStructure\b/g, 'Hospital') + } + return out + }) + const current = Array.isArray(taskState.plan?.steps) ? taskState.plan!.steps : [] + const changed = JSON.stringify(current) !== JSON.stringify(normalized) + if (!changed) { + pushChunk(streamKey, 'plan.keep (no change)') + return + } updateState((state) => { state.plan = { steps: normalized, completed: [], currentIndex: 0 } }) @@ -1245,6 +1464,7 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; } const recordPlanUpdate = (update: { completedStep?: string; nextStep?: string; notes?: string }) => { + let changed = false updateState((state) => { if (!state.plan) { state.plan = { steps: [], completed: [], currentIndex: 0 } @@ -1255,19 +1475,22 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; if (idx >= 0 && !plan.completed.includes(update.completedStep)) { plan.completed.push(update.completedStep) plan.currentIndex = Math.min(idx + 1, plan.steps.length - 1) + changed = true } } if (update.nextStep) { const idx = plan.steps.findIndex((step) => step === update.nextStep) - if (idx >= 0) { + if (idx >= 0 && plan.currentIndex !== idx) { plan.currentIndex = idx + changed = true } } - if (typeof update.notes === 'string' && update.notes.trim().length > 0) { + if (typeof update.notes === 'string' && update.notes.trim().length > 0 && plan.notes !== update.notes.trim()) { plan.notes = update.notes.trim() + changed = true } }) - pushChunk(streamKey, 'plan.update ' + JSON.stringify(update)) + pushChunk(streamKey, changed ? ('plan.update ' + JSON.stringify(update)) : 'plan.keep (no material change)') } let userOptedOut = !!taskState.policy?.userOptedOut @@ -1324,6 +1547,49 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; : '' const providerFirstMessage = attachments.length ? `${msg}\n\n[ATTACHMENTS]\n${attachmentSummary}` : msg + // Detect user or system instructions that force manual geometry mode + const saysNoAssets = (t?: string) => { + if (!t) return false + return /\b(?:do\s*not\s*(?:search|use|insert)[^\n]*?(?:assets|catalog)|no\s+assets|manual\s+geometry|primitives\s+only|no\s+catalog|avoid\s+assets|build\s+manually)\b/i.test(t) + } + const allowsAssets = (t?: string) => { + if (!t) return false + return /\b(?:allow|re\s*enable|use)\s+(?:the\s+)?(?:catalog|assets)|\bsearch\s+(?:the\s+)?catalog\b|\bresume\s+asset\s+search\b/i.test(t) + } + const userHistoryText = Array.isArray(taskState.history) + ? taskState.history.filter((h) => h.role === 'user').map((h) => h.content || '').join(' \n ') + : '' + const recentSystemText = Array.isArray(taskState.history) + ? taskState.history + .slice(Math.max(0, taskState.history.length - 12)) + .filter((h) => h.role === 'system') + .map((h) => h.content || '') + .join(' \n ') + : '' + const fallbackManual = /CATALOG_UNAVAILABLE|manual\s+using\s+create_instance|manual_required/i.test(recentSystemText) + const currentPolicy = ensureScriptPolicy(taskState) + let manualModeActive = !!currentPolicy.manualMode + if (fallbackManual) manualModeActive = true + if (manualModeActive && allowsAssets(msg)) { + updateState((state) => { + const policy = ensureScriptPolicy(state) + policy.manualMode = false + }) + manualModeActive = false + } + const manualDirect = saysNoAssets(msg) || saysNoAssets(userHistoryText) + const manualWanted = manualModeActive || manualDirect + + if (manualWanted && !manualModeActive) { + updateState((state) => { + const policy = ensureScriptPolicy(state) + policy.manualMode = true + }) + manualModeActive = true + } + let geometryCount = currentPolicy.geometryOps || 0 + let luauCount = currentPolicy.luauEdits || 0 + const contextRequestLimit = 1 let contextRequestsThisCall = 0 let requestAdditionalContext: (reason: string) => boolean = () => false @@ -1347,7 +1613,8 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; let messages: { role: 'user' | 'assistant' | 'system'; content: string }[] | null = null let assetFallbackWarningSent = false - const catalogSearchAvailable = (process.env.CATALOG_DISABLE_SEARCH || '0') !== '1' + let catalogSearchAvailable = (process.env.CATALOG_DISABLE_SEARCH || '0') !== '1' + if (manualWanted) catalogSearchAvailable = false let scriptWarnings = 0 const finalize = (list: Proposal[]): { proposals: Proposal[]; taskState: TaskState; tokenTotals: { in: number; out: number } } => { @@ -1595,7 +1862,9 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; const planReady = Array.isArray(taskState.plan?.steps) && (taskState.plan?.steps.length || 0) > 0 const askMode = (input.mode || 'agent') === 'ask' - const requirePlan = (process.env.VECTOR_REQUIRE_PLAN || '0') === '1' + const envRequirePlan = process.env.VECTOR_REQUIRE_PLAN + // Default OFF: only require a plan when explicitly enabled via env. + const requirePlan = (typeof envRequirePlan === 'string' && envRequirePlan.trim().length > 0) ? (envRequirePlan === '1') : false const isContextOrNonActionTool = ( toolName === 'start_plan' || toolName === 'update_plan' || @@ -1613,6 +1882,38 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; toolName === 'search_files' ) const isActionTool = !isContextOrNonActionTool + + if (manualWanted && (toolName === 'search_assets' || toolName === 'insert_asset' || toolName === 'generate_asset_3d')) { + consecutiveValidationErrors++ + const errMsg = 'MANUAL_MODE Active: use create_instance/set_properties or Luau; catalog tools are disabled until manual build is complete.' + pushChunk(streamKey, `error.validation ${toolName} manual_mode`) + console.warn(`[orch] manual_mode tool=${toolName}`) + convo.push({ role: 'assistant', content: toolXml }) + convo.push({ role: 'user', content: errMsg }) + appendHistory('system', errMsg) + continue + } + + // Allow duplicate start_plan: if steps are unchanged, no-op; otherwise replace (handled by recordPlanStart) + if (toolName === 'start_plan' && planReady) { + const incomingSteps = Array.isArray((a as any).steps) + ? (a as any).steps.map((s: any) => String(s || '').trim()) + : [] + const currentSteps = Array.isArray(taskState.plan?.steps) + ? taskState.plan!.steps.map((s) => String(s || '').trim()) + : [] + if (JSON.stringify(incomingSteps) !== JSON.stringify(currentSteps)) { + consecutiveValidationErrors++ + const errMsg = 'PLAN_ACTIVE Use to adjust the existing plan instead of starting a new one.' + pushChunk(streamKey, `error.validation ${toolName} plan_active`) + console.warn(`[orch] plan.active tool=${toolName}`) + convo.push({ role: 'assistant', content: toolXml }) + convo.push({ role: 'user', content: errMsg }) + appendHistory('system', errMsg) + continue + } + pushChunk(streamKey, 'plan.duplicate allowed') + } if (!planReady && isActionTool && !askMode && requirePlan) { const errMsg = 'PLAN_REQUIRED Call with a step-by-step outline before taking actions.' pushChunk(streamKey, `error.validation ${String(name)} plan_required`) @@ -1623,6 +1924,37 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; continue } + // Record asset usage counters + if (toolName === 'search_assets') { + updateState((state) => { const p = ensureScriptPolicy(state); p.assetSearches = (p.assetSearches || 0) + 1 }) + } else if (toolName === 'insert_asset') { + updateState((state) => { const p = ensureScriptPolicy(state); p.assetInserts = (p.assetInserts || 0) + 1 }) + } + + // Enforce asset-first: require at least one search_assets attempt before manual geometry (agent mode) + let assetFirst = (process.env.VECTOR_ASSET_FIRST || '1') === '1' + if (manualWanted) assetFirst = false + const isGeometryTool = (toolName === 'create_instance') || (toolName === 'set_properties' && (!isLuauScriptPath((a as any).path) || !hasLuauSource((a as any).props))) + // Detect recent catalog failures to allow manual fallback without re-searching + const recentHistory = taskState.history.slice(-12).map((h) => h.content || '') + const assetBlocked = recentHistory.some((line) => + /CATALOG_UNAVAILABLE|Asset .* failed|User is not authorized to access Asset|Failed to find \".*\" in the marketplace!|search_insert_fallback|no_results/i.test(line) + ) + if (assetFirst && !askMode && catalogSearchAvailable && isActionTool && isGeometryTool) { + const searches = (taskState.policy?.assetSearches || 0) + const userRequestedManual = /\b(?:manual\s+geometry|primitives\s+only|no\s+assets)\b/i.test(msg) + if (searches === 0 && !userRequestedManual && !assetBlocked) { + const errMsg = 'ASSET_FIRST Please search the Creator Store first using before using manual geometry.' + pushChunk(streamKey, 'error.validation asset_first_required') + console.warn(`[orch] asset.first.require tool=${toolName}`) + // Echo tool for visibility then nudge + convo.push({ role: 'assistant', content: toolXml }) + convo.push({ role: 'user', content: errMsg }) + appendHistory('system', errMsg) + continue + } + } + if ((name === 'show_diff' || name === 'apply_edit') && !a.path && input.context.activeScript?.path) { a = { ...a, path: input.context.activeScript.path } } @@ -1780,6 +2112,10 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; recordPlanUpdate, userOptedOut, geometryTracker, + subjectNouns, + manualMode: manualWanted, + geometryCount, + luauCount, }) if (mapped.contextResult !== undefined) { @@ -1809,6 +2145,17 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; name === 'attempt_completion' || (name === 'message' && typeof (a as any).phase === 'string' && (a as any).phase.toLowerCase() === 'final') + if (manualWanted && !askMode && isFinalPhase && geometryCount === 0 && luauCount === 0) { + consecutiveValidationErrors++ + const warn = 'MANUAL_MODE Manual mode is active. Create at least one anchored Part (e.g., floor/walls/roof) or add the HospitalBuilder Luau before finishing.' + pushChunk(streamKey, 'error.validation manual_mode_incomplete') + console.warn(`[orch] manual_mode.incomplete tool=${String(name)}`) + convo.push({ role: 'assistant', content: toolXml }) + convo.push({ role: 'user', content: warn }) + appendHistory('system', warn) + continue + } + const scriptRequired = geometryWorkObserved && !scriptWorkObserved && !userOptedOut if (isFinalPhase && scriptRequired) { scriptWarnings += 1 @@ -1828,7 +2175,10 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; updateState((state) => { const policy = ensureScriptPolicy(state) if (touchesGeometry) { - policy.geometryOps += 1 + const addsVisibleGeometry = mapped.proposals.some(proposalCreatesVisibleGeometry) + if (addsVisibleGeometry) { + policy.geometryOps += 1 + } } if (touchesLuau) { policy.luauEdits += 1 @@ -1839,6 +2189,9 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; } } }) + const refreshedPolicy = ensureScriptPolicy(taskState) + geometryCount = refreshedPolicy.geometryOps || 0 + luauCount = refreshedPolicy.luauEdits || 0 pushChunk(streamKey, `proposals.mapped ${String(name)} count=${mapped.proposals.length}`) console.log(`[orch] proposals.mapped tool=${String(name)} count=${mapped.proposals.length}`) // Stream assistant text for UI transcript @@ -1962,7 +2315,7 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; ]) } - if (!fallbacksDisabled) { + if (fallbacksDisabled) { const errMsg = 'ASSET_FALLBACK_DISABLED Asset catalog fallback is disabled. Create the requested objects manually using create_instance or Luau edits.' pushChunk(streamKey, 'fallback.asset disabled') console.warn('[orch] fallback.asset disabled; no proposals after provider warning') diff --git a/vector/apps/web/lib/orchestrator/prompts/examples.ts b/vector/apps/web/lib/orchestrator/prompts/examples.ts index f62c3ef..28c034f 100644 --- a/vector/apps/web/lib/orchestrator/prompts/examples.ts +++ b/vector/apps/web/lib/orchestrator/prompts/examples.ts @@ -3,6 +3,7 @@ export const PLANNER_GUIDE = ` Planning +- Always begin by returning exactly one and then stop. Do not perform any action tools until the user approves (e.g., they say "proceed" or "next step"). - Translate the user's goal into specific placements and code changes. - When planning, produce a DETAILED, TOOL-ORIENTED step list via . - 8–15 concise steps typical for multi‑object builds; more if needed. @@ -10,10 +11,18 @@ Planning - The tool name (create_instance, set_properties, search_assets, insert_asset, open_or_create_script, show_diff, ...) - The exact target (className, path/parentPath, and Name) - The intention (e.g., size/position, purpose like "outer fence", or code outcome) - - Examples of good step text: "Create Model 'MilitaryBase' under game.Workspace", "Search assets: query='watch tower' tags=['model'] limit=6", "Insert asset 123456789 under game.Workspace.MilitaryBase", "Set CFrame/Anchored for 'Gate' at (0,0,50)", "Open or create Script 'BaseBuilder' in ServerScriptService", "Show diff to insert idempotent Luau that rebuilds placed structures". -- Prefer assets first: plan searches and inserts before manual geometry. Use create_instance for primitives or when search/insert is unavailable. + - Examples of good step text: "Search assets: query='hospital building' tags=['model','building','hospital'] limit=6", "Insert asset under game.Workspace", "Set CFrame/Anchored for placement". +Manual-first when the user provides explicit dimensions/materials or forbids catalog usage. In these cases, do NOT use search_assets/insert_asset — build with create_instance/set_properties and author idempotent Luau. +- When catalog access is denied or fails, immediately switch to manual geometry; do not keep calling search_assets or insert_asset. +- Do NOT introduce generic example containers like "House" or "SimpleHouse". If the user asks for a "Hospital", keep all names and steps aligned to "Hospital". +- By default, insert assets under game.Workspace directly. Do not create a container Model first unless the user explicitly requested one or multiple inserts require grouping. +- Before searching, quickly inventory context: + - list_code_definition_names (brief) to understand existing code modules + - optionally list_children on game.Workspace to avoid duplicating obvious containers +Use create_instance/set_properties for primitives whenever assets are unavailable/disabled OR the user specifies exact sizes/positions/materials. Only use asset tools if the user explicitly asks for catalog assets. +- After the plan is approved, execute exactly one step per assistant turn. - Use as you progress (mark completed, set next, add notes). Keep steps small and verifiable. -- Always add a Luau step (unless user opted out) to rebuild what was created. +- Add a Luau step only when the user asks for code, or when assets are unavailable and you fall back to manual geometry (to make the build reproducible). `; export const COMPLEXITY_DECISION_GUIDE = ` @@ -62,8 +71,10 @@ Examples policy - Choose an example pattern only if it matches the current user goal; otherwise proceed without one. - Never introduce unrelated names or content from examples (e.g., do not create \"Farm\" or \"FarmBuilder\" when the user asked for a house). - Keep examples as text-only guidance. Always prioritize the user's request and the current scene/context. - - Strictly follow the user's requested subject and nouns. Do not pivot to different subjects or example names. If the user asks for a "hospital", do not create a "house", "base", or any unrelated structure. - - When continuing a plan, prefer steps that explicitly progress the user's requested build; avoid switching to generic examples. + - STRICTLY FOLLOW the user's requested subject and nouns. NEVER pivot to different subjects or example names. If the user asks for a "hospital", do NOT create a "house", "base", "SimpleHouse", or any unrelated structure. + - When continuing a plan, prefer steps that explicitly progress the user's requested build; avoid switching to generic examples or renaming containers. + - If the user forbids catalog assets (e.g., "do not search or insert catalog assets", "no assets", "manual geometry"), asset tools are disabled for this task. Build manually and write idempotent Luau. + - After an asset search/insert failure, assume manual geometry mode until the user explicitly allows assets again. `; export const WORKFLOW_EXAMPLES = ` diff --git a/vector/apps/web/lib/orchestrator/taskState.ts b/vector/apps/web/lib/orchestrator/taskState.ts index bd84e0a..8499ae5 100644 --- a/vector/apps/web/lib/orchestrator/taskState.ts +++ b/vector/apps/web/lib/orchestrator/taskState.ts @@ -39,6 +39,9 @@ export type ScriptPolicyState = { userOptedOut?: boolean lastOptOutAt?: number nudgedAt?: number + assetSearches?: number + assetInserts?: number + manualMode?: boolean } export interface TaskState { @@ -108,10 +111,13 @@ function ensureCheckpointFields(state: TaskState): TaskState { state.plan = { steps: [], completed: [], currentIndex: 0 } } if (!state.policy || typeof state.policy !== 'object') { - state.policy = { geometryOps: 0, luauEdits: 0 } + state.policy = { geometryOps: 0, luauEdits: 0, assetSearches: 0, assetInserts: 0, manualMode: false } } else { state.policy.geometryOps = Math.max(0, Number(state.policy.geometryOps) || 0) state.policy.luauEdits = Math.max(0, Number(state.policy.luauEdits) || 0) + state.policy.assetSearches = Math.max(0, Number(state.policy.assetSearches) || 0) + state.policy.assetInserts = Math.max(0, Number(state.policy.assetInserts) || 0) + state.policy.manualMode = !!state.policy.manualMode } return state } @@ -129,7 +135,7 @@ function defaultState(taskId: string): TaskState { codeDefinitions: [], scene: { nodes: {} }, plan: { steps: [], completed: [], currentIndex: 0 }, - policy: { geometryOps: 0, luauEdits: 0 }, + policy: { geometryOps: 0, luauEdits: 0, manualMode: false, assetSearches: 0, assetInserts: 0 }, checkpoints: { count: 0 }, updatedAt: now, } diff --git a/vector/apps/web/package.json b/vector/apps/web/package.json index 2552038..1616dc7 100644 --- a/vector/apps/web/package.json +++ b/vector/apps/web/package.json @@ -11,7 +11,8 @@ "test:orchestrator": "TS_NODE_COMPILER_OPTIONS=\"{\\\"module\\\":\\\"commonjs\\\",\\\"moduleResolution\\\":\\\"node\\\"}\" ts-node lib/orchestrator/index.test.ts", "test:providers": "TS_NODE_COMPILER_OPTIONS=\"{\\\"module\\\":\\\"commonjs\\\",\\\"moduleResolution\\\":\\\"node\\\"}\" ts-node scripts/test-providers.ts", "test:select": "TS_NODE_COMPILER_OPTIONS=\"{\\\"module\\\":\\\"commonjs\\\",\\\"moduleResolution\\\":\\\"node\\\"}\" ts-node scripts/test-select.ts", - "test:catalog": "TS_NODE_COMPILER_OPTIONS=\"{\\\"module\\\":\\\"commonjs\\\",\\\"moduleResolution\\\":\\\"node\\\"}\" ts-node scripts/test-catalog.ts" + "test:catalog": "TS_NODE_COMPILER_OPTIONS=\"{\\\"module\\\":\\\"commonjs\\\",\\\"moduleResolution\\\":\\\"node\\\"}\" ts-node scripts/test-catalog.ts", + "test:workflow:hospital": "TS_NODE_COMPILER_OPTIONS=\"{\\\"module\\\":\\\"commonjs\\\",\\\"moduleResolution\\\":\\\"node\\\"}\" ts-node scripts/test-workflow-hospital.ts" }, "dependencies": { "adm-zip": "^0.5.12", diff --git a/vector/apps/web/scripts/test-workflow-hospital.ts b/vector/apps/web/scripts/test-workflow-hospital.ts new file mode 100644 index 0000000..564f79f --- /dev/null +++ b/vector/apps/web/scripts/test-workflow-hospital.ts @@ -0,0 +1,61 @@ +/* Workflow smoke test: plan-first + asset-first for hospital */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +function loadDotEnvLocal() { + try { + const p = resolve(__dirname, '..', '.env.local') + const text = readFileSync(p, 'utf8') + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eq = trimmed.indexOf('=') + if (eq <= 0) continue + const key = trimmed.slice(0, eq).trim() + const val = trimmed.slice(eq + 1).trim() + if (!(key in process.env)) process.env[key] = val + } + } catch {} +} + +async function main() { + loadDotEnvLocal() + const { runLLM } = await import('../lib/orchestrator') + const workflowId = `wf_test_${Date.now().toString(36)}` + console.log(`[workflow.test] using workflowId=${workflowId}`) + + // Step 1: request a plan only + const msg1 = 'Build a hospital into the scene. Return exactly one and then stop.' + const r1 = await runLLM({ + projectId: 'local', + workflowId, + message: msg1, + context: { activeScript: null, selection: [], scene: {} }, + mode: 'agent', + maxTurns: 1, + modelOverride: process.env.BEDROCK_MODEL || process.env.AWS_BEDROCK_MODEL || 'qwen.qwen3-coder-30b-a3b-v1:0', + } as any) + const steps = r1.taskState?.plan?.steps || [] + console.log(`[workflow.test] plan.steps=${steps.length}`) + for (const s of steps) console.log(' -', s) + + // Step 2: approve and expect asset-first search + const r2 = await runLLM({ + projectId: 'local', + workflowId, + message: 'proceed', + context: { activeScript: null, selection: [], scene: {} }, + mode: 'agent', + maxTurns: 1, + modelOverride: process.env.BEDROCK_MODEL || process.env.AWS_BEDROCK_MODEL || 'qwen.qwen3-coder-30b-a3b-v1:0', + } as any) + const types = (r2.proposals || []).map((p: any) => p?.type) + const gotSearch = (r2.proposals || []).some((p: any) => p?.type === 'asset_op' && p?.search) + console.log(`[workflow.test] second.turn proposals=${types.join(',') || 'none'} asset_search=${gotSearch}`) + if (!gotSearch) { + console.warn('[workflow.test] WARN: did not see asset search on second turn — check provider output and policy') + } +} + +main().catch((e) => { console.error(e); process.exit(1) }) + diff --git a/vector/plugin/Vector.rbxmx b/vector/plugin/Vector.rbxmx index 10e4640..5481975 100644 --- a/vector/plugin/Vector.rbxmx +++ b/vector/plugin/Vector.rbxmx @@ -40,6 +40,18 @@ _G.__VECTOR_PROGRESS = _G.__VECTOR_PROGRESS or 0 _G.__VECTOR_RUNS = _G.__VECTOR_RUNS or {} _G.__VECTOR_LAST_WORKFLOW_ID = _G.__VECTOR_LAST_WORKFLOW_ID or nil +local FAILED_ASSET_IDS = _G.__VECTOR_FAILED_ASSET_IDS +if type(FAILED_ASSET_IDS) ~= "table" then + FAILED_ASSET_IDS = {} + _G.__VECTOR_FAILED_ASSET_IDS = FAILED_ASSET_IDS +end + +local GAME_CREATOR_ID = tonumber(game.CreatorId) or 0 +local GAME_CREATOR_TYPE = tostring(game.CreatorType) + +-- TEMP: Restrict to Roblox-owned assets only +local ROBLOX_ONLY = false + -- UI state (shared across UI and actions) local CURRENT_MODE = "agent" -- or "ask" @@ -600,6 +612,15 @@ local function renderUnifiedDiff(container, oldText, newText) container.CanvasSize = UDim2.new(0, 0, 0, container.UIListLayout.AbsoluteContentSize.Y + 8) end +local function openScriptByPath(path) + local inst = resolveByFullName(path) + if inst then + pcall(function() + ScriptEditorService:OpenScript(inst) + end) + end +end + local function renderConflictDetails(container, files) container:ClearAllChildren() local layout = Instance.new("UIListLayout") @@ -710,15 +731,6 @@ local function applyEditProposal(proposal) return true, nil, results end -local function openScriptByPath(path) - local inst = resolveByFullName(path) - if inst then - pcall(function() - ScriptEditorService:OpenScript(inst) - end) - end -end - local function getBackendBaseUrl() -- Prefer plugin setting if present, fallback to local dev server local val @@ -780,7 +792,29 @@ local function fetchAssets(query, limit, tags) if not ok then return false, "Invalid JSON" end - return true, json.results or {} + local raw = json.results or {} + local filtered = {} + for _, entry in ipairs(raw) do + local assetId = assetIdKey(entry and (entry.id or entry.assetId or entry.AssetId)) + if assetId and assetId > 0 and assetId < 100000000000 then + local typeName = nil + if typeof(entry.type) == "string" then + typeName = string.lower(entry.type) + elseif typeof(entry.AssetType) == "string" then + typeName = string.lower(entry.AssetType) + elseif typeof(entry.assetType) == "string" then + typeName = string.lower(entry.assetType) + elseif typeof(entry.assetType) == "number" then + typeName = tostring(entry.assetType) + end + local isModelLike = typeName == nil or string.find(typeName, "model", 1, true) ~= nil or typeName == "8" + if isModelLike then + entry.id = assetId + table.insert(filtered, entry) + end + end + end + return true, filtered end local function checkpointsBaseUrl() @@ -823,6 +857,123 @@ local function restoreCheckpointRequest(checkpointId, mode) return true, parsed.checkpoint end +local function assetIdKey(assetId) + if typeof(assetId) == "number" then + return assetId + end + local asNumber = tonumber(assetId) + if typeof(asNumber) == "number" and asNumber == asNumber then + return asNumber + end + return nil +end + +local function recordAssetFailure(assetId, message) + local key = assetIdKey(assetId) + if not key then return end + FAILED_ASSET_IDS[key] = message or true +end + +local function clearAssetFailure(assetId) + local key = assetIdKey(assetId) + if not key then return end + FAILED_ASSET_IDS[key] = nil +end + +local function isRobloxCreatorName(name) + if typeof(name) ~= "string" then return false end + local lower = string.lower(name) + return lower == "roblox" or lower == "roblox team" or lower == "roblox official" or string.sub(lower, 1, 7) == "roblox " +end + +local function isRobloxEntry(entry) + local creatorName = (typeof(entry.creator) == "string" and entry.creator) + or (typeof(entry.CreatorName) == "string" and entry.CreatorName) or "" + local creatorId = assetIdKey(entry.creatorId or entry.CreatorId) + local creatorType + if typeof(entry.creatorType) == "string" then + creatorType = string.lower(entry.creatorType) + elseif typeof(entry.CreatorType) == "string" then + creatorType = string.lower(entry.CreatorType) + elseif typeof(entry.CreatorType) == "EnumItem" then + creatorType = string.lower(tostring(entry.CreatorType)) + end + if creatorType then + local trimmed = string.match(creatorType, "([^.]+)$") + if trimmed then creatorType = trimmed end + end + if isRobloxCreatorName(creatorName) then return true end + if creatorId == 1 then return true end + if creatorType == "roblox" then return true end + return false +end + +local function computeCatalogScore(entry, index) + local score = (index or 0) * 10 + local creatorName = (typeof(entry.creator) == "string" and entry.creator) or (typeof(entry.CreatorName) == "string" and entry.CreatorName) or "" + local creatorId = assetIdKey(entry.creatorId or entry.CreatorId) + local creatorType + if typeof(entry.creatorType) == "string" then + creatorType = string.lower(entry.creatorType) + elseif typeof(entry.CreatorType) == "string" then + creatorType = string.lower(entry.CreatorType) + elseif typeof(entry.CreatorType) == "EnumItem" then + creatorType = string.lower(tostring(entry.CreatorType)) + end + if creatorType then + local trimmed = string.match(creatorType, "([^.]+)$") + if trimmed then + creatorType = trimmed + end + end + local isVerified = entry.isVerified == true or entry.IsVerified == true or entry.creatorHasVerifiedBadge == true + + local assetId = assetIdKey(entry.id or entry.AssetId) + if assetId and FAILED_ASSET_IDS[assetId] then + score += 100000 + end + + if isRobloxCreatorName(creatorName) or (creatorType == "roblox") or (creatorId == 1) then + score = score - 8000 + end + + if creatorId and creatorId == GAME_CREATOR_ID then + score = score - 6000 + elseif GAME_CREATOR_TYPE == tostring(Enum.CreatorType.Group) and creatorType == "group" and creatorId == GAME_CREATOR_ID then + score = score - 4000 + end + + if isVerified then + score = score - 400 + end + + return score +end + +local function sortCatalogResults(results) + local scored = {} + -- Optional strict Roblox-only filter + if ROBLOX_ONLY then + local filtered = {} + for _, e in ipairs(results) do + if isRobloxEntry(e) then table.insert(filtered, e) end + end + results = filtered + end + for index, entry in ipairs(results) do + table.insert(scored, { entry = entry, score = computeCatalogScore(entry, index) }) + end + table.sort(scored, function(a, b) + if a.score == b.score then return (a.entry.id or a.entry.AssetId or 0) < (b.entry.id or b.entry.AssetId or 0) end + return a.score < b.score + end) + local ordered = {} + for _, info in ipairs(scored) do + table.insert(ordered, info.entry) + end + return ordered +end + local function insertAsset(assetId, parentPath) local parent = workspace if parentPath then @@ -831,21 +982,170 @@ local function insertAsset(assetId, parentPath) parent = resolved end end + local key = assetIdKey(assetId) + if not key then + return false, "invalid_asset_id" + end + if FAILED_ASSET_IDS[key] then + return false, FAILED_ASSET_IDS[key] + end local ok, modelOrErr = pcall(function() local container = InsertService:LoadAsset(assetId) if not container then error("LoadAsset returned nil") end local model = container:FindFirstChildOfClass("Model") or container model.Parent = parent - -- If the container is different from the inserted model, clean it up if container ~= model then pcall(function() container:Destroy() end) end return model end) if not ok then + recordAssetFailure(assetId, tostring(modelOrErr)) return false, modelOrErr end + clearAssetFailure(assetId) return true, modelOrErr end +local function insertAssetFromFreeModels(query, parentPath, relativePosition, opts) + if typeof(query) ~= "string" or #query == 0 then + return false, "invalid_query" + end + local parent = workspace + if parentPath then + local resolved = resolveByFullName(parentPath) + if resolved and resolved:IsA("Instance") then + parent = resolved + end + end + local cam = workspace.CurrentCamera + local origin = cam and cam.CFrame and cam.CFrame.Position or Vector3.new() + local offset + if typeof(relativePosition) == "Vector3" then + offset = relativePosition + elseif cam and cam.CFrame then + offset = cam.CFrame.LookVector * 16 + else + offset = Vector3.new() + end + + local maxPages = 5 -- broaden search depth to improve odds + if opts and typeof(opts.maxPages) == "number" then + maxPages = math.clamp(math.floor(opts.maxPages), 1, 10) + end + + local candidates = {} + local seenAsset = {} + for pageIndex = 0, maxPages - 1 do + local okPage, pages = pcall(function() + return InsertService:GetFreeModels(query, pageIndex) + end) + if okPage then + local page = (pages or {})[1] + if page and typeof(page) == "table" and typeof(page.Results) == "table" then + for resultIndex, entry in ipairs(page.Results) do + local assetId = entry and assetIdKey(entry.AssetId) + if assetId and not seenAsset[assetId] then + if ROBLOX_ONLY and not isRobloxEntry(entry) then + -- skip non-Roblox entries when filter is active + else + seenAsset[assetId] = true + entry.__score = computeCatalogScore(entry, pageIndex * 100 + resultIndex) + entry.__pageIndex = pageIndex + entry.__resultIndex = resultIndex + table.insert(candidates, entry) + end + end + end + end + else + warn(string.format("[Vector] GetFreeModels failed page=%d err=%s", pageIndex, tostring(pages))) + end + end + + if #candidates == 0 then + local trimmed = tostring(query or ""):gsub("^%s+", ""):gsub("%s+$", "") + local first = string.match(trimmed, "^([^%s]+)") + if first and string.lower(first) ~= string.lower(trimmed) then + -- Broaden by retrying with the first word (e.g., "hospital" from "hospital building") + for pageIndex = 0, maxPages - 1 do + local okPage2, pages2 = pcall(function() + return InsertService:GetFreeModels(first, pageIndex) + end) + if okPage2 then + local page2 = (pages2 or {})[1] + if page2 and typeof(page2) == "table" and typeof(page2.Results) == "table" then + for resultIndex, entry in ipairs(page2.Results) do + local assetId = entry and assetIdKey(entry.AssetId) + if assetId and not seenAsset[assetId] then + if ROBLOX_ONLY and not isRobloxEntry(entry) then + -- skip + else + seenAsset[assetId] = true + entry.__score = computeCatalogScore(entry, pageIndex * 100 + resultIndex) + entry.__pageIndex = pageIndex + entry.__resultIndex = resultIndex + table.insert(candidates, entry) + end + end + end + end + end + end + end + end + + if #candidates == 0 then + local msg = string.format("Failed to find \"%s\" in the marketplace!", query) + warn(msg) + return false, msg + end + + table.sort(candidates, function(a, b) + local scoreA = a.__score or 0 + local scoreB = b.__score or 0 + if scoreA == scoreB then + return (assetIdKey(a.AssetId) or 0) < (assetIdKey(b.AssetId) or 0) + end + return scoreA < scoreB + end) + + local lastErr = nil + for _, entry in ipairs(candidates) do + local assetId = entry and assetIdKey(entry.AssetId) + if assetId and not FAILED_ASSET_IDS[assetId] then + local okLoad, container = pcall(function() + return InsertService:LoadAsset(assetId) + end) + if okLoad and container then + local model = container:FindFirstChildWhichIsA("Model") or container + pcall(function() + if typeof(entry.Name) == "string" and #entry.Name > 0 then + model.Name = entry.Name + else + model.Name = query + end + end) + model.Parent = parent + pcall(function() + local target = origin + offset + model:PivotTo(CFrame.new(target)) + end) + if container ~= model then pcall(function() container:Destroy() end) end + clearAssetFailure(assetId) + return true, model, assetId + else + local errText = okLoad and "load_failed" or container + lastErr = errText + recordAssetFailure(assetId, tostring(errText)) + end + else + lastErr = lastErr or "cached_failure" + end + end + local errMsg = lastErr and tostring(lastErr) or string.format("No insertable free model found for \"%s\"", query) + warn(errMsg) + return false, errMsg +end + local function buildUI(gui) print("[Vector] building UI") gui:ClearAllChildren() @@ -1700,13 +2000,19 @@ local function buildUI(gui) return ui end -local function renderAssetResults(container, p, results) + local function renderAssetResults(container, p, results) container:ClearAllChildren() + local ordered = {} + if type(results) == "table" then + ordered = sortCatalogResults(results) + else + ordered = results + end local layout = Instance.new("UIListLayout") layout.SortOrder = Enum.SortOrder.LayoutOrder layout.Padding = UDim.new(0, 4) layout.Parent = container - for i, r in ipairs(results) do + for i, r in ipairs(ordered) do local row = Instance.new("Frame") row.Size = UDim2.new(1, -8, 0, 28) row.BackgroundTransparency = 0.4 @@ -1740,18 +2046,41 @@ local function renderAssetResults(container, p, results) insertBtn.ZIndex = 2 insertBtn.Parent = row - insertBtn.MouseButton1Click:Connect(function() - local ok, modelOrErr = insertAsset(r.id, p.insert and p.insert.parentPath or nil) - if ok then - row.BackgroundColor3 = Color3.fromRGB(32, 64, 32) - row.BackgroundTransparency = 0.2 - reportApply(p.id, { ok = true, type = p.type, op = "insert_asset", assetId = r.id, insertedPath = modelOrErr:GetFullName() }) - else - row.BackgroundColor3 = Color3.fromRGB(64, 32, 32) - row.BackgroundTransparency = 0.2 - reportApply(p.id, { ok = false, type = p.type, op = "insert_asset", assetId = r.id, error = tostring(modelOrErr) }) - end - end) + insertBtn.MouseButton1Click:Connect(function() + -- Try this result first, then fall back to trying others in order until one inserts + local function tryInsert(id) + local ok, modelOrErr = insertAsset(id, p.insert and p.insert.parentPath or nil) + if ok then + local insertedPath = nil + if modelOrErr and typeof(modelOrErr) == "Instance" and modelOrErr.GetFullName then + local success, value = pcall(function() return modelOrErr:GetFullName() end) + insertedPath = success and value or nil + end + return true, insertedPath + end + return false, tostring(modelOrErr) + end + + local ok, insertedPath = tryInsert(r.id) + if not ok then + for _, alt in ipairs(ordered) do + if alt.id ~= r.id then + ok, insertedPath = tryInsert(alt.id) + if ok then break end + end + end + end + + if ok then + row.BackgroundColor3 = Color3.fromRGB(32, 64, 32) + row.BackgroundTransparency = 0.2 + reportApply(p.id, { ok = true, type = p.type, op = "insert_asset", assetId = r.id, insertedPath = insertedPath }) + else + row.BackgroundColor3 = Color3.fromRGB(64, 32, 32) + row.BackgroundTransparency = 0.2 + reportApply(p.id, { ok = false, type = p.type, op = "insert_asset", assetId = r.id, error = insertedPath }) + end + end) end end @@ -2302,31 +2631,42 @@ local function toggleDock() end elseif p.search then -- Auto-mode: run a single search, insert the best match under a sensible parent - if _G.__VECTOR_AUTO then - local query = p.search.query or "" - ui.addStatus("auto.asset_search " .. tostring(query)) - local okFetch, resultsOrErr = fetchAssets(query, p.search.limit or 6, p.search.tags) - if okFetch and type(resultsOrErr) == "table" then - if #resultsOrErr > 0 then - local first = resultsOrErr[1] - local assetId = tonumber(first and first.id) + if _G.__VECTOR_AUTO then + local query = p.search.query or "" + ui.addStatus("auto.asset_search " .. tostring(query)) + if ROBLOX_ONLY then ui.addStatus("asset.filter roblox_only") end + -- Fetch a broader candidate set from backend; we will filter/order locally + local desiredLimit = tonumber(p.search.limit) or 0 + local fetchLimit = math.max(60, desiredLimit, 0) + local okFetch, resultsOrErr = fetchAssets(query, fetchLimit, p.search.tags) + if okFetch and type(resultsOrErr) == "table" then + local orderedResults = sortCatalogResults(resultsOrErr) + local desiredParentPath = nil + local sel = Selection:Get() + if type(sel) == "table" and #sel == 1 and sel[1] and sel[1].GetFullName then + local okPath, pathOrErr = pcall(function() return sel[1]:GetFullName() end) + if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end + end + if not desiredParentPath then + local hosp = workspace:FindFirstChild("Hospital") + if hosp and hosp.GetFullName then + local okPath, pathOrErr = pcall(function() return hosp:GetFullName() end) + if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end + end + end + + ensurePermissionWithStatus() + local lastErr = nil + local inserted = false + for _, entry in ipairs(orderedResults) do + local assetId = tonumber(entry and entry.id) if assetId then - -- Choose a default parent: selected container → Workspace.Hospital → Workspace - local desiredParentPath = nil - local sel = Selection:Get() - if type(sel) == "table" and #sel == 1 and sel[1] and sel[1].GetFullName then - local okPath, pathOrErr = pcall(function() return sel[1]:GetFullName() end) - if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end - end - if not desiredParentPath then - local hosp = workspace:FindFirstChild("Hospital") - if hosp and hosp.GetFullName then - local okPath, pathOrErr = pcall(function() return hosp:GetFullName() end) - if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end - end + if FAILED_ASSET_IDS[assetId] then + ui.addStatus("auto.asset_search skip cached " .. tostring(assetId)) + lastErr = FAILED_ASSET_IDS[assetId] + continue end ui.addStatus("auto.asset_search insert " .. tostring(assetId) .. (desiredParentPath and (" → " .. desiredParentPath) or "")) - ensurePermissionWithStatus() local okInsert, modelOrErr = insertAsset(assetId, desiredParentPath) if okInsert then _G.__VECTOR_LAST_ASSET_ERROR = nil @@ -2340,20 +2680,42 @@ local function toggleDock() return true else local errMsg = tostring(modelOrErr) + lastErr = errMsg _G.__VECTOR_LAST_ASSET_ERROR = errMsg or "insert_failed" ui.addStatus("auto.err asset " .. errMsg) reportApply(p.id, { ok = false, type = p.type, op = "search_insert", assetId = assetId, query = query, parentPath = desiredParentPath, error = errMsg }) end + end + end + + if not inserted and typeof(query) == "string" and #query > 0 then + ui.addStatus("auto.asset_search fallback free model") + local okFallback, modelOrErr, fallbackAssetId = insertAssetFromFreeModels(query, desiredParentPath, nil, { maxPages = 3 }) + if okFallback then + _G.__VECTOR_LAST_ASSET_ERROR = nil + local insertedPath = nil + if modelOrErr and typeof(modelOrErr) == "Instance" and modelOrErr.GetFullName then + local success, value = pcall(function() return modelOrErr:GetFullName() end) + insertedPath = success and value or nil + end + ui.addStatus("auto.ok asset (fallback)") + reportApply(p.id, { ok = true, type = p.type, op = "search_insert_fallback", assetId = fallbackAssetId, insertedPath = insertedPath, query = query, parentPath = desiredParentPath }) + return true else - ui.addStatus("auto.err asset invalid id") - _G.__VECTOR_LAST_ASSET_ERROR = "invalid_asset_id" - reportApply(p.id, { ok = false, type = p.type, op = "search_insert", query = query, error = "invalid_asset_id" }) + local errMsg = tostring(modelOrErr) + lastErr = lastErr or errMsg + _G.__VECTOR_LAST_ASSET_ERROR = errMsg or "insert_failed" + ui.addStatus("auto.err asset " .. errMsg) + reportApply(p.id, { ok = false, type = p.type, op = "search_insert_fallback", query = query, parentPath = desiredParentPath, error = errMsg }) end - else - ui.addStatus("auto.asset_search none") - _G.__VECTOR_LAST_ASSET_ERROR = "no_results" - reportApply(p.id, { ok = false, type = p.type, op = "search", query = query, error = "no_results" }) end + + if lastErr then + return false + end + ui.addStatus("auto.asset_search none") + _G.__VECTOR_LAST_ASSET_ERROR = "no_results" + reportApply(p.id, { ok = false, type = p.type, op = "search", query = query, error = "no_results" }) else local errMsg = typeof(resultsOrErr) == "string" and resultsOrErr or "fetch_failed" _G.__VECTOR_LAST_ASSET_ERROR = errMsg or "fetch_failed" @@ -2982,6 +3344,14 @@ return function(className, parentPath, props) end local startedRecording = ChangeHistoryService:TryBeginRecording("Vector Create", "Vector Create") local ok, res = pcall(function() + -- Idempotency: if a child with the target Name and class already exists, reuse it + local desiredName = type(props) == "table" and props.Name + if type(desiredName) == "string" and #desiredName > 0 then + local existing = parent:FindFirstChild(desiredName) + if existing and existing.ClassName == className then + return existing:GetFullName() + end + end local inst = Instance.new(className) if type(props) == "table" then for k, v in pairs(props) do @@ -3915,4 +4285,4 @@ end - \ No newline at end of file + diff --git a/vector/plugin/src/main.server.lua b/vector/plugin/src/main.server.lua index 6957e15..a751881 100644 --- a/vector/plugin/src/main.server.lua +++ b/vector/plugin/src/main.server.lua @@ -30,6 +30,18 @@ _G.__VECTOR_PROGRESS = _G.__VECTOR_PROGRESS or 0 _G.__VECTOR_RUNS = _G.__VECTOR_RUNS or {} _G.__VECTOR_LAST_WORKFLOW_ID = _G.__VECTOR_LAST_WORKFLOW_ID or nil +local FAILED_ASSET_IDS = _G.__VECTOR_FAILED_ASSET_IDS +if type(FAILED_ASSET_IDS) ~= "table" then + FAILED_ASSET_IDS = {} + _G.__VECTOR_FAILED_ASSET_IDS = FAILED_ASSET_IDS +end + +local GAME_CREATOR_ID = tonumber(game.CreatorId) or 0 +local GAME_CREATOR_TYPE = tostring(game.CreatorType) + +-- TEMP: Restrict to Roblox-owned assets only +local ROBLOX_ONLY = false + -- UI state (shared across UI and actions) local CURRENT_MODE = "agent" -- or "ask" @@ -590,6 +602,15 @@ local function renderUnifiedDiff(container, oldText, newText) container.CanvasSize = UDim2.new(0, 0, 0, container.UIListLayout.AbsoluteContentSize.Y + 8) end +local function openScriptByPath(path) + local inst = resolveByFullName(path) + if inst then + pcall(function() + ScriptEditorService:OpenScript(inst) + end) + end +end + local function renderConflictDetails(container, files) container:ClearAllChildren() local layout = Instance.new("UIListLayout") @@ -700,15 +721,6 @@ local function applyEditProposal(proposal) return true, nil, results end -local function openScriptByPath(path) - local inst = resolveByFullName(path) - if inst then - pcall(function() - ScriptEditorService:OpenScript(inst) - end) - end -end - local function getBackendBaseUrl() -- Prefer plugin setting if present, fallback to local dev server local val @@ -770,7 +782,29 @@ local function fetchAssets(query, limit, tags) if not ok then return false, "Invalid JSON" end - return true, json.results or {} + local raw = json.results or {} + local filtered = {} + for _, entry in ipairs(raw) do + local assetId = assetIdKey(entry and (entry.id or entry.assetId or entry.AssetId)) + if assetId and assetId > 0 and assetId < 100000000000 then + local typeName = nil + if typeof(entry.type) == "string" then + typeName = string.lower(entry.type) + elseif typeof(entry.AssetType) == "string" then + typeName = string.lower(entry.AssetType) + elseif typeof(entry.assetType) == "string" then + typeName = string.lower(entry.assetType) + elseif typeof(entry.assetType) == "number" then + typeName = tostring(entry.assetType) + end + local isModelLike = typeName == nil or string.find(typeName, "model", 1, true) ~= nil or typeName == "8" + if isModelLike then + entry.id = assetId + table.insert(filtered, entry) + end + end + end + return true, filtered end local function checkpointsBaseUrl() @@ -813,6 +847,123 @@ local function restoreCheckpointRequest(checkpointId, mode) return true, parsed.checkpoint end +local function assetIdKey(assetId) + if typeof(assetId) == "number" then + return assetId + end + local asNumber = tonumber(assetId) + if typeof(asNumber) == "number" and asNumber == asNumber then + return asNumber + end + return nil +end + +local function recordAssetFailure(assetId, message) + local key = assetIdKey(assetId) + if not key then return end + FAILED_ASSET_IDS[key] = message or true +end + +local function clearAssetFailure(assetId) + local key = assetIdKey(assetId) + if not key then return end + FAILED_ASSET_IDS[key] = nil +end + +local function isRobloxCreatorName(name) + if typeof(name) ~= "string" then return false end + local lower = string.lower(name) + return lower == "roblox" or lower == "roblox team" or lower == "roblox official" or string.sub(lower, 1, 7) == "roblox " +end + +local function isRobloxEntry(entry) + local creatorName = (typeof(entry.creator) == "string" and entry.creator) + or (typeof(entry.CreatorName) == "string" and entry.CreatorName) or "" + local creatorId = assetIdKey(entry.creatorId or entry.CreatorId) + local creatorType + if typeof(entry.creatorType) == "string" then + creatorType = string.lower(entry.creatorType) + elseif typeof(entry.CreatorType) == "string" then + creatorType = string.lower(entry.CreatorType) + elseif typeof(entry.CreatorType) == "EnumItem" then + creatorType = string.lower(tostring(entry.CreatorType)) + end + if creatorType then + local trimmed = string.match(creatorType, "([^.]+)$") + if trimmed then creatorType = trimmed end + end + if isRobloxCreatorName(creatorName) then return true end + if creatorId == 1 then return true end + if creatorType == "roblox" then return true end + return false +end + +local function computeCatalogScore(entry, index) + local score = (index or 0) * 10 + local creatorName = (typeof(entry.creator) == "string" and entry.creator) or (typeof(entry.CreatorName) == "string" and entry.CreatorName) or "" + local creatorId = assetIdKey(entry.creatorId or entry.CreatorId) + local creatorType + if typeof(entry.creatorType) == "string" then + creatorType = string.lower(entry.creatorType) + elseif typeof(entry.CreatorType) == "string" then + creatorType = string.lower(entry.CreatorType) + elseif typeof(entry.CreatorType) == "EnumItem" then + creatorType = string.lower(tostring(entry.CreatorType)) + end + if creatorType then + local trimmed = string.match(creatorType, "([^.]+)$") + if trimmed then + creatorType = trimmed + end + end + local isVerified = entry.isVerified == true or entry.IsVerified == true or entry.creatorHasVerifiedBadge == true + + local assetId = assetIdKey(entry.id or entry.AssetId) + if assetId and FAILED_ASSET_IDS[assetId] then + score += 100000 + end + + if isRobloxCreatorName(creatorName) or (creatorType == "roblox") or (creatorId == 1) then + score = score - 8000 + end + + if creatorId and creatorId == GAME_CREATOR_ID then + score = score - 6000 + elseif GAME_CREATOR_TYPE == tostring(Enum.CreatorType.Group) and creatorType == "group" and creatorId == GAME_CREATOR_ID then + score = score - 4000 + end + + if isVerified then + score = score - 400 + end + + return score +end + +local function sortCatalogResults(results) + local scored = {} + -- Optional strict Roblox-only filter + if ROBLOX_ONLY then + local filtered = {} + for _, e in ipairs(results) do + if isRobloxEntry(e) then table.insert(filtered, e) end + end + results = filtered + end + for index, entry in ipairs(results) do + table.insert(scored, { entry = entry, score = computeCatalogScore(entry, index) }) + end + table.sort(scored, function(a, b) + if a.score == b.score then return (a.entry.id or a.entry.AssetId or 0) < (b.entry.id or b.entry.AssetId or 0) end + return a.score < b.score + end) + local ordered = {} + for _, info in ipairs(scored) do + table.insert(ordered, info.entry) + end + return ordered +end + local function insertAsset(assetId, parentPath) local parent = workspace if parentPath then @@ -821,21 +972,170 @@ local function insertAsset(assetId, parentPath) parent = resolved end end + local key = assetIdKey(assetId) + if not key then + return false, "invalid_asset_id" + end + if FAILED_ASSET_IDS[key] then + return false, FAILED_ASSET_IDS[key] + end local ok, modelOrErr = pcall(function() local container = InsertService:LoadAsset(assetId) if not container then error("LoadAsset returned nil") end local model = container:FindFirstChildOfClass("Model") or container model.Parent = parent - -- If the container is different from the inserted model, clean it up if container ~= model then pcall(function() container:Destroy() end) end return model end) if not ok then + recordAssetFailure(assetId, tostring(modelOrErr)) return false, modelOrErr end + clearAssetFailure(assetId) return true, modelOrErr end +local function insertAssetFromFreeModels(query, parentPath, relativePosition, opts) + if typeof(query) ~= "string" or #query == 0 then + return false, "invalid_query" + end + local parent = workspace + if parentPath then + local resolved = resolveByFullName(parentPath) + if resolved and resolved:IsA("Instance") then + parent = resolved + end + end + local cam = workspace.CurrentCamera + local origin = cam and cam.CFrame and cam.CFrame.Position or Vector3.new() + local offset + if typeof(relativePosition) == "Vector3" then + offset = relativePosition + elseif cam and cam.CFrame then + offset = cam.CFrame.LookVector * 16 + else + offset = Vector3.new() + end + + local maxPages = 5 -- broaden search depth to improve odds + if opts and typeof(opts.maxPages) == "number" then + maxPages = math.clamp(math.floor(opts.maxPages), 1, 10) + end + + local candidates = {} + local seenAsset = {} + for pageIndex = 0, maxPages - 1 do + local okPage, pages = pcall(function() + return InsertService:GetFreeModels(query, pageIndex) + end) + if okPage then + local page = (pages or {})[1] + if page and typeof(page) == "table" and typeof(page.Results) == "table" then + for resultIndex, entry in ipairs(page.Results) do + local assetId = entry and assetIdKey(entry.AssetId) + if assetId and not seenAsset[assetId] then + if ROBLOX_ONLY and not isRobloxEntry(entry) then + -- skip non-Roblox entries when filter is active + else + seenAsset[assetId] = true + entry.__score = computeCatalogScore(entry, pageIndex * 100 + resultIndex) + entry.__pageIndex = pageIndex + entry.__resultIndex = resultIndex + table.insert(candidates, entry) + end + end + end + end + else + warn(string.format("[Vector] GetFreeModels failed page=%d err=%s", pageIndex, tostring(pages))) + end + end + + if #candidates == 0 then + local trimmed = tostring(query or ""):gsub("^%s+", ""):gsub("%s+$", "") + local first = string.match(trimmed, "^([^%s]+)") + if first and string.lower(first) ~= string.lower(trimmed) then + -- Broaden by retrying with the first word (e.g., "hospital" from "hospital building") + for pageIndex = 0, maxPages - 1 do + local okPage2, pages2 = pcall(function() + return InsertService:GetFreeModels(first, pageIndex) + end) + if okPage2 then + local page2 = (pages2 or {})[1] + if page2 and typeof(page2) == "table" and typeof(page2.Results) == "table" then + for resultIndex, entry in ipairs(page2.Results) do + local assetId = entry and assetIdKey(entry.AssetId) + if assetId and not seenAsset[assetId] then + if ROBLOX_ONLY and not isRobloxEntry(entry) then + -- skip + else + seenAsset[assetId] = true + entry.__score = computeCatalogScore(entry, pageIndex * 100 + resultIndex) + entry.__pageIndex = pageIndex + entry.__resultIndex = resultIndex + table.insert(candidates, entry) + end + end + end + end + end + end + end + end + + if #candidates == 0 then + local msg = string.format("Failed to find \"%s\" in the marketplace!", query) + warn(msg) + return false, msg + end + + table.sort(candidates, function(a, b) + local scoreA = a.__score or 0 + local scoreB = b.__score or 0 + if scoreA == scoreB then + return (assetIdKey(a.AssetId) or 0) < (assetIdKey(b.AssetId) or 0) + end + return scoreA < scoreB + end) + + local lastErr = nil + for _, entry in ipairs(candidates) do + local assetId = entry and assetIdKey(entry.AssetId) + if assetId and not FAILED_ASSET_IDS[assetId] then + local okLoad, container = pcall(function() + return InsertService:LoadAsset(assetId) + end) + if okLoad and container then + local model = container:FindFirstChildWhichIsA("Model") or container + pcall(function() + if typeof(entry.Name) == "string" and #entry.Name > 0 then + model.Name = entry.Name + else + model.Name = query + end + end) + model.Parent = parent + pcall(function() + local target = origin + offset + model:PivotTo(CFrame.new(target)) + end) + if container ~= model then pcall(function() container:Destroy() end) end + clearAssetFailure(assetId) + return true, model, assetId + else + local errText = okLoad and "load_failed" or container + lastErr = errText + recordAssetFailure(assetId, tostring(errText)) + end + else + lastErr = lastErr or "cached_failure" + end + end + local errMsg = lastErr and tostring(lastErr) or string.format("No insertable free model found for \"%s\"", query) + warn(errMsg) + return false, errMsg +end + local function buildUI(gui) print("[Vector] building UI") gui:ClearAllChildren() @@ -1690,13 +1990,19 @@ local function buildUI(gui) return ui end -local function renderAssetResults(container, p, results) + local function renderAssetResults(container, p, results) container:ClearAllChildren() + local ordered = {} + if type(results) == "table" then + ordered = sortCatalogResults(results) + else + ordered = results + end local layout = Instance.new("UIListLayout") layout.SortOrder = Enum.SortOrder.LayoutOrder layout.Padding = UDim.new(0, 4) layout.Parent = container - for i, r in ipairs(results) do + for i, r in ipairs(ordered) do local row = Instance.new("Frame") row.Size = UDim2.new(1, -8, 0, 28) row.BackgroundTransparency = 0.4 @@ -1730,18 +2036,41 @@ local function renderAssetResults(container, p, results) insertBtn.ZIndex = 2 insertBtn.Parent = row - insertBtn.MouseButton1Click:Connect(function() - local ok, modelOrErr = insertAsset(r.id, p.insert and p.insert.parentPath or nil) - if ok then - row.BackgroundColor3 = Color3.fromRGB(32, 64, 32) - row.BackgroundTransparency = 0.2 - reportApply(p.id, { ok = true, type = p.type, op = "insert_asset", assetId = r.id, insertedPath = modelOrErr:GetFullName() }) - else - row.BackgroundColor3 = Color3.fromRGB(64, 32, 32) - row.BackgroundTransparency = 0.2 - reportApply(p.id, { ok = false, type = p.type, op = "insert_asset", assetId = r.id, error = tostring(modelOrErr) }) - end - end) + insertBtn.MouseButton1Click:Connect(function() + -- Try this result first, then fall back to trying others in order until one inserts + local function tryInsert(id) + local ok, modelOrErr = insertAsset(id, p.insert and p.insert.parentPath or nil) + if ok then + local insertedPath = nil + if modelOrErr and typeof(modelOrErr) == "Instance" and modelOrErr.GetFullName then + local success, value = pcall(function() return modelOrErr:GetFullName() end) + insertedPath = success and value or nil + end + return true, insertedPath + end + return false, tostring(modelOrErr) + end + + local ok, insertedPath = tryInsert(r.id) + if not ok then + for _, alt in ipairs(ordered) do + if alt.id ~= r.id then + ok, insertedPath = tryInsert(alt.id) + if ok then break end + end + end + end + + if ok then + row.BackgroundColor3 = Color3.fromRGB(32, 64, 32) + row.BackgroundTransparency = 0.2 + reportApply(p.id, { ok = true, type = p.type, op = "insert_asset", assetId = r.id, insertedPath = insertedPath }) + else + row.BackgroundColor3 = Color3.fromRGB(64, 32, 32) + row.BackgroundTransparency = 0.2 + reportApply(p.id, { ok = false, type = p.type, op = "insert_asset", assetId = r.id, error = insertedPath }) + end + end) end end @@ -2292,31 +2621,42 @@ local function toggleDock() end elseif p.search then -- Auto-mode: run a single search, insert the best match under a sensible parent - if _G.__VECTOR_AUTO then - local query = p.search.query or "" - ui.addStatus("auto.asset_search " .. tostring(query)) - local okFetch, resultsOrErr = fetchAssets(query, p.search.limit or 6, p.search.tags) - if okFetch and type(resultsOrErr) == "table" then - if #resultsOrErr > 0 then - local first = resultsOrErr[1] - local assetId = tonumber(first and first.id) + if _G.__VECTOR_AUTO then + local query = p.search.query or "" + ui.addStatus("auto.asset_search " .. tostring(query)) + if ROBLOX_ONLY then ui.addStatus("asset.filter roblox_only") end + -- Fetch a broader candidate set from backend; we will filter/order locally + local desiredLimit = tonumber(p.search.limit) or 0 + local fetchLimit = math.max(60, desiredLimit, 0) + local okFetch, resultsOrErr = fetchAssets(query, fetchLimit, p.search.tags) + if okFetch and type(resultsOrErr) == "table" then + local orderedResults = sortCatalogResults(resultsOrErr) + local desiredParentPath = nil + local sel = Selection:Get() + if type(sel) == "table" and #sel == 1 and sel[1] and sel[1].GetFullName then + local okPath, pathOrErr = pcall(function() return sel[1]:GetFullName() end) + if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end + end + if not desiredParentPath then + local hosp = workspace:FindFirstChild("Hospital") + if hosp and hosp.GetFullName then + local okPath, pathOrErr = pcall(function() return hosp:GetFullName() end) + if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end + end + end + + ensurePermissionWithStatus() + local lastErr = nil + local inserted = false + for _, entry in ipairs(orderedResults) do + local assetId = tonumber(entry and entry.id) if assetId then - -- Choose a default parent: selected container → Workspace.Hospital → Workspace - local desiredParentPath = nil - local sel = Selection:Get() - if type(sel) == "table" and #sel == 1 and sel[1] and sel[1].GetFullName then - local okPath, pathOrErr = pcall(function() return sel[1]:GetFullName() end) - if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end - end - if not desiredParentPath then - local hosp = workspace:FindFirstChild("Hospital") - if hosp and hosp.GetFullName then - local okPath, pathOrErr = pcall(function() return hosp:GetFullName() end) - if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end - end + if FAILED_ASSET_IDS[assetId] then + ui.addStatus("auto.asset_search skip cached " .. tostring(assetId)) + lastErr = FAILED_ASSET_IDS[assetId] + continue end ui.addStatus("auto.asset_search insert " .. tostring(assetId) .. (desiredParentPath and (" → " .. desiredParentPath) or "")) - ensurePermissionWithStatus() local okInsert, modelOrErr = insertAsset(assetId, desiredParentPath) if okInsert then _G.__VECTOR_LAST_ASSET_ERROR = nil @@ -2330,20 +2670,42 @@ local function toggleDock() return true else local errMsg = tostring(modelOrErr) + lastErr = errMsg _G.__VECTOR_LAST_ASSET_ERROR = errMsg or "insert_failed" ui.addStatus("auto.err asset " .. errMsg) reportApply(p.id, { ok = false, type = p.type, op = "search_insert", assetId = assetId, query = query, parentPath = desiredParentPath, error = errMsg }) end + end + end + + if not inserted and typeof(query) == "string" and #query > 0 then + ui.addStatus("auto.asset_search fallback free model") + local okFallback, modelOrErr, fallbackAssetId = insertAssetFromFreeModels(query, desiredParentPath, nil, { maxPages = 3 }) + if okFallback then + _G.__VECTOR_LAST_ASSET_ERROR = nil + local insertedPath = nil + if modelOrErr and typeof(modelOrErr) == "Instance" and modelOrErr.GetFullName then + local success, value = pcall(function() return modelOrErr:GetFullName() end) + insertedPath = success and value or nil + end + ui.addStatus("auto.ok asset (fallback)") + reportApply(p.id, { ok = true, type = p.type, op = "search_insert_fallback", assetId = fallbackAssetId, insertedPath = insertedPath, query = query, parentPath = desiredParentPath }) + return true else - ui.addStatus("auto.err asset invalid id") - _G.__VECTOR_LAST_ASSET_ERROR = "invalid_asset_id" - reportApply(p.id, { ok = false, type = p.type, op = "search_insert", query = query, error = "invalid_asset_id" }) + local errMsg = tostring(modelOrErr) + lastErr = lastErr or errMsg + _G.__VECTOR_LAST_ASSET_ERROR = errMsg or "insert_failed" + ui.addStatus("auto.err asset " .. errMsg) + reportApply(p.id, { ok = false, type = p.type, op = "search_insert_fallback", query = query, parentPath = desiredParentPath, error = errMsg }) end - else - ui.addStatus("auto.asset_search none") - _G.__VECTOR_LAST_ASSET_ERROR = "no_results" - reportApply(p.id, { ok = false, type = p.type, op = "search", query = query, error = "no_results" }) end + + if lastErr then + return false + end + ui.addStatus("auto.asset_search none") + _G.__VECTOR_LAST_ASSET_ERROR = "no_results" + reportApply(p.id, { ok = false, type = p.type, op = "search", query = query, error = "no_results" }) else local errMsg = typeof(resultsOrErr) == "string" and resultsOrErr or "fetch_failed" _G.__VECTOR_LAST_ASSET_ERROR = errMsg or "fetch_failed" diff --git a/vector/plugin/src/tools/create_instance.lua b/vector/plugin/src/tools/create_instance.lua index ef598a8..e728946 100644 --- a/vector/plugin/src/tools/create_instance.lua +++ b/vector/plugin/src/tools/create_instance.lua @@ -114,6 +114,14 @@ return function(className, parentPath, props) end local startedRecording = ChangeHistoryService:TryBeginRecording("Vector Create", "Vector Create") local ok, res = pcall(function() + -- Idempotency: if a child with the target Name and class already exists, reuse it + local desiredName = type(props) == "table" and props.Name + if type(desiredName) == "string" and #desiredName > 0 then + local existing = parent:FindFirstChild(desiredName) + if existing and existing.ClassName == className then + return existing:GetFullName() + end + end local inst = Instance.new(className) if type(props) == "table" then for k, v in pairs(props) do From 154c2f6ec3cc4caa2cd5c215f6264c4f37e6e0f5 Mon Sep 17 00:00:00 2001 From: improdead Date: Thu, 2 Oct 2025 22:28:56 -0400 Subject: [PATCH 2/7] feat(command-mode): add run_command tool; map to object/asset ops; update system prompt and tool reference --- vector/apps/web/lib/orchestrator/index.ts | 136 ++++++++++++++++++ .../web/lib/orchestrator/prompts/examples.ts | 1 + vector/apps/web/lib/tools/schemas.ts | 3 + 3 files changed, 140 insertions(+) diff --git a/vector/apps/web/lib/orchestrator/index.ts b/vector/apps/web/lib/orchestrator/index.ts index da2ca95..bb9c275 100644 --- a/vector/apps/web/lib/orchestrator/index.ts +++ b/vector/apps/web/lib/orchestrator/index.ts @@ -350,6 +350,7 @@ const PROMPT_SECTIONS = [ `You are Vector, a Roblox Studio copilot.`, `Core rules - One tool per turn: emit EXACTLY ONE tool tag. It must be the last output (ignoring trailing whitespace). Wait for the tool result before continuing. +- Prefer run_command for actions (create/modify/insert). Keep list_children for context and start_plan/update_plan to outline work. - Proposal-first and undoable: never change code/Instances outside a tool; keep each step small and reviewable. - Plan when work spans multiple steps. For a single obvious action you may act without . - Keep responses to that single tool tag (no markdown or invented tags). Optional brief explanatory text may appear before the tag when allowed.` , @@ -367,6 +368,14 @@ const PROMPT_SECTIONS = [ - JSON goes inside the tag as strict JSON (double quotes, no trailing commas). - For show_diff/apply_edit the tag MUST contain a JSON array of edit objects (example: [{"start":{...},"end":{...},"text":"..."}]); never pass a plain string. - For show_diff/apply_edit when using , every files[i].edits must also be that same JSON array (no strings, no code fences).`, + `Command mode +- Use ... for actions. +- Examples: + - create_model parent="game.Workspace" name="Hospital" + - create_part parent="game.Workspace.Hospital" name="Floor" size=40,1,40 cframe=0,0.5,0 material=Concrete anchored=1 + - set_props path="game.Workspace.Hospital.Floor" Anchored=1 size=40,1,40 + - insert_asset assetId=123456 parent="game.Workspace" +- Keep , , and for context and planning.`, `Encoding & hygiene - Strings/numbers are literal. JSON must not be wrapped in quotes or code fences. - Paths use GetFullName() with brackets for special characters. @@ -827,6 +836,133 @@ function mapToolToProposals( const p = typeof a.path === 'string' ? a.path : undefined return p || (fallback || undefined) } + if (name === 'run_command') { + const cmdRaw = typeof (a as any).command === 'string' ? (a as any).command.trim() : '' + if (!cmdRaw) return { proposals, missingContext: 'Provide a command string.' } + const parseKV = (s: string): Record => { + const out: Record = {} + const re = /(\w+)=(("[^"]*")|('[^']*')|([^\s]+))/g + let m: RegExpExecArray | null + while ((m = re.exec(s))) { + const key = String(m[1]) + let val = String(m[2]) + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1) + } + out[key] = val + } + return out + } + const toBool = (v?: string) => { + if (!v) return undefined + const t = v.toLowerCase() + if (t === '1' || t === 'true' || t === 'yes' || t === 'on') return true + if (t === '0' || t === 'false' || t === 'no' || t === 'off') return false + return undefined + } + const parseVec3 = (v?: string) => { + if (!v) return undefined + const parts = v.replace(/[xX]/g, ',').split(/[,\s]+/).filter(Boolean) + if (parts.length < 3) return undefined + const nums = parts.slice(0, 3).map((p) => Number(p)) + if (nums.some((n) => !Number.isFinite(n))) return undefined + return { __t: 'Vector3', x: nums[0], y: nums[1], z: nums[2] } + } + const parseColor3 = (v?: string) => { + if (!v) return undefined + const parts = v.split(/[,\s]+/).filter(Boolean) + if (parts.length < 3) return undefined + const nums = parts.slice(0, 3).map((p) => Number(p)) + if (nums.some((n) => !Number.isFinite(n))) return undefined + return { __t: 'Color3', r: nums[0], g: nums[1], b: nums[2] } + } + const parseCFrame = (v?: string) => { + if (!v) return undefined + const parts = v.split(/[,\s]+/).filter(Boolean).map((p) => Number(p)) + if (parts.some((n) => !Number.isFinite(n))) return undefined + if (parts.length >= 12) return { __t: 'CFrame', comps: parts.slice(0, 12) } + if (parts.length >= 3) return { __t: 'CFrame', comps: [parts[0], parts[1], parts[2], 1,0,0, 0,1,0, 0,0,1] } + return undefined + } + const parseMaterial = (v?: string) => { + if (!v) return undefined + const name = v.trim() + if (!name) return undefined + return { __t: 'EnumItem', enum: 'Enum.Material', name } + } + const [verbRaw, ...rest] = cmdRaw.split(/\s+/) + const verb = (verbRaw || '').toLowerCase() + const argsLine = rest.join(' ') + const kv = parseKV(argsLine) + const addObjectOp = (op: ObjectOp) => { + proposals.push({ id: id('obj'), type: 'object_op', ops: [op], notes: `Parsed from run_command: ${verb}` }) + } + const addAssetOp = (insert?: { assetId: number; parentPath?: string }) => { + if (!insert) return + if (extras?.manualMode) return + proposals.push({ id: id('asset'), type: 'asset_op', insert, search: undefined, generate3d: undefined, meta: {} as any }) + } + if (verb === 'create_model') { + const parentPath = kv.parent || kv.parentPath || 'game.Workspace' + const name = kv.name || 'Model' + addObjectOp({ op: 'create_instance', className: 'Model', parentPath, props: { Name: name } }) + return { proposals } + } + if (verb === 'create_part') { + const parentPath = kv.parent || kv.parentPath || 'game.Workspace' + const name = kv.name || 'Part' + const size = parseVec3(kv.size) + const cf = parseCFrame(kv.cframe || kv.cf || kv.position) + const mat = parseMaterial(kv.material) + const anchored = toBool(kv.anchored) + const color = parseColor3(kv.color) + const props: Record = { Name: name } + if (size) props.Size = size + if (cf) props.CFrame = cf + if (mat) props.Material = mat + if (typeof anchored === 'boolean') props.Anchored = anchored + if (color) props.Color = color + addObjectOp({ op: 'create_instance', className: 'Part', parentPath, props }) + return { proposals } + } + if (verb === 'set_props' || verb === 'set_properties') { + const path = kv.path + if (!path) return { proposals, missingContext: 'set_props requires path=...' } + const props: Record = {} + if (kv.size) props.Size = parseVec3(kv.size) + if (kv.cframe || kv.cf || kv.position) props.CFrame = parseCFrame(kv.cframe || kv.cf || kv.position) + if (kv.material) props.Material = parseMaterial(kv.material) + if (kv.color) props.Color = parseColor3(kv.color) + const anchored = toBool(kv.anchored) + if (typeof anchored === 'boolean') props.Anchored = anchored + addObjectOp({ op: 'set_properties', path, props }) + return { proposals } + } + if (verb === 'rename') { + const path = kv.path + const newName = kv.newName || kv.name + if (!path || !newName) return { proposals, missingContext: 'rename requires path= and newName=' } + addObjectOp({ op: 'rename_instance', path, newName }) + return { proposals } + } + if (verb === 'delete') { + const path = kv.path + if (!path) return { proposals, missingContext: 'delete requires path=' } + addObjectOp({ op: 'delete_instance', path }) + return { proposals } + } + if (verb === 'insert_asset' || verb === 'insert') { + const assetId = Number(kv.assetId || kv.id) + if (!Number.isFinite(assetId) || assetId <= 0) return { proposals, missingContext: 'insert_asset requires assetId=' } + const parentPath = kv.parent || kv.parentPath + if (extras?.manualMode) { + return { proposals, missingContext: 'Manual mode active: asset commands are disabled. Use create_part/set_props or Luau.' } + } + addAssetOp({ assetId, parentPath }) + return { proposals } + } + return { proposals, missingContext: `Unknown command verb: ${verb}` } + } if (name === 'start_plan') { const steps = Array.isArray((a as any).steps) ? (a as any).steps.map(String) : [] if (!steps.length) { diff --git a/vector/apps/web/lib/orchestrator/prompts/examples.ts b/vector/apps/web/lib/orchestrator/prompts/examples.ts index 28c034f..8c9c5bd 100644 --- a/vector/apps/web/lib/orchestrator/prompts/examples.ts +++ b/vector/apps/web/lib/orchestrator/prompts/examples.ts @@ -40,6 +40,7 @@ When to plan export const TOOL_REFERENCE = ` Tool reference (purpose and tips) +- run_command: Execute a single action via a compact command string. Use for creating models/parts, setting properties, or inserting assets. - start_plan: Begin an ordered list of steps. Use for multi-step work. - update_plan: Mark a step done, set next step, or add notes. - get_active_script: Return the currently open script (path, text) if any. diff --git a/vector/apps/web/lib/tools/schemas.ts b/vector/apps/web/lib/tools/schemas.ts index 07a14e4..57b2ebb 100644 --- a/vector/apps/web/lib/tools/schemas.ts +++ b/vector/apps/web/lib/tools/schemas.ts @@ -54,6 +54,9 @@ const ParentEither = z .transform((v) => ('parentPath' in v ? v : { parentPath: (v as any).parent })) export const Tools = { + run_command: z.object({ + command: z.string().min(1), + }), get_active_script: z.object({}), list_selection: z.object({}), list_open_documents: z.object({ maxCount: z.number().min(1).max(100).optional() }), From af212b90b22ef593bf7054fa4c45b2e02e41bea8 Mon Sep 17 00:00:00 2001 From: improdead Date: Thu, 2 Oct 2025 22:33:50 -0400 Subject: [PATCH 3/7] chore(prompt): simplify system prompt and examples; default to run_command; concise tool overview and guidance-only notes --- vector/apps/web/lib/orchestrator/index.ts | 219 +++--------------- .../web/lib/orchestrator/prompts/examples.ts | 80 ++----- 2 files changed, 40 insertions(+), 259 deletions(-) diff --git a/vector/apps/web/lib/orchestrator/index.ts b/vector/apps/web/lib/orchestrator/index.ts index bb9c275..e6eed7a 100644 --- a/vector/apps/web/lib/orchestrator/index.ts +++ b/vector/apps/web/lib/orchestrator/index.ts @@ -348,209 +348,42 @@ function determineProvider(opts: { input: ChatInput; modelOverride?: string | nu const PROMPT_SECTIONS = [ `You are Vector, a Roblox Studio copilot.`, - `Core rules + `Core rules (guidance only) - One tool per turn: emit EXACTLY ONE tool tag. It must be the last output (ignoring trailing whitespace). Wait for the tool result before continuing. -- Prefer run_command for actions (create/modify/insert). Keep list_children for context and start_plan/update_plan to outline work. -- Proposal-first and undoable: never change code/Instances outside a tool; keep each step small and reviewable. -- Plan when work spans multiple steps. For a single obvious action you may act without . -- Keep responses to that single tool tag (no markdown or invented tags). Optional brief explanatory text may appear before the tag when allowed.` , - `Planning details -- For non-trivial tasks, your MUST list detailed, tool-specific steps (8–15 typical): include the tool name, exact target (class/path/name), and the intended outcome. Example: "Create Model 'Base' under game.Workspace", "Search assets query='barracks'", "Insert asset 12345 under game.Workspace.Base", "Set CFrame for 'Gate' to (0,0,50)", "Open or create Script 'BaseBuilder'", "Show diff to add idempotent Luau".`, - `Default Script Policy -- Whenever you create, modify, or insert Instances (create_instance/set_properties/rename_instance/delete_instance/insert_asset/generate_asset_3d), you must author Luau that rebuilds the result before completing. -- Preferred flow: open_or_create_script → show_diff (or apply_edit when already previewed). Scripts must be valid, idempotent, and set Anchored/props explicitly. -- Skip Luau only when the user explicitly opts out (e.g., "geometry only", "no script", "no code"). Otherwise completion is blocked.`, - `Tool calls - - ... - -- Omit optional params you don’t know. -- JSON goes inside the tag as strict JSON (double quotes, no trailing commas). -- For show_diff/apply_edit the tag MUST contain a JSON array of edit objects (example: [{"start":{...},"end":{...},"text":"..."}]); never pass a plain string. -- For show_diff/apply_edit when using , every files[i].edits must also be that same JSON array (no strings, no code fences).`, - `Command mode -- Use ... for actions. -- Examples: - - create_model parent="game.Workspace" name="Hospital" - - create_part parent="game.Workspace.Hospital" name="Floor" size=40,1,40 cframe=0,0.5,0 material=Concrete anchored=1 - - set_props path="game.Workspace.Hospital.Floor" Anchored=1 size=40,1,40 - - insert_asset assetId=123456 parent="game.Workspace" -- Keep , , and for context and planning.`, - `Encoding & hygiene -- Strings/numbers are literal. JSON must not be wrapped in quotes or code fences. -- Paths use GetFullName() with brackets for special characters. -- Attributes use "@Name" keys. -- Edits are 0-based, non-overlapping, ≤20 edits and ≤2000 inserted chars.`, - `Assets & 3D - - Manual-first when the user provides explicit dimensions/materials or forbids catalog usage. In these cases, do NOT use search_assets/insert_asset — build with create_instance/set_properties and author idempotent Luau. - - Use asset tools only when the user explicitly asks for catalog assets and no precise geometry was requested. If catalog search fails or is disabled, immediately fall back to manual geometry; do not retry search in a loop. - - Composite scenes (e.g., park, plaza, town square): it is acceptable to add multiple distinct assets (e.g., trees, benches, fountain, lights) as separate search_assets → insert_asset steps in the plan. Keep it concise: 2–4 categories initially, each with limit ≤ 6. - - Single‑subject builds (e.g., hospital, watch tower): avoid adding unrelated props unless requested. If a container model (e.g., game.Workspace.Hospital) already exists, do not recreate it; prefer manual parts under that model, or insert a single primary asset when the user asked for it. - - In Auto mode (autoApply=true), prefer choosing a single best match and inserting it directly without listing. Do not insert multiples unless the user explicitly asks for more than one. - - search_assets limit ≤ 6 unless the user asks. Include helpful tags. - - insert_asset defaults parentPath to game.Workspace if unknown. Before creating or inserting, inspect existing children (list_children) and skip duplicates when names/roles already exist.`, - `Scene building - - Always think through the layout before acting: use to outline the main structures, then execute steps one tool at a time. - - Inspect what already exists. If nothing is selected, call on game.Workspace (depth 1–2) to inventory the scene; also use and . Reuse or extend Models instead of duplicating them. - - Build geometry iteratively with create_instance/set_properties, anchoring parts and setting Size/CFrame so progress is visible in Workspace. - - Only switch to scripting when the user explicitly wants reusable code or behaviour. Otherwise stay in direct manipulation mode.`, - `Quality checks -- Derive a short checklist from the user prompt and track progress (optionally via ). -- Do not call until every checklist item exists and the matching Luau is written (unless the user opted out). -- Created Models must contain anchored, visible parts or code that produces them.`, - `Validation & recovery -- On VALIDATION_ERROR, retry the SAME tool once with corrected args (no commentary or tool switching). -- If you would otherwise reply with no tool, either choose exactly one tool or finish with .`, -String.raw`Quick examples -Detailed plan (assets-first) +- Default to run_command for actions (create/modify/insert). Keep list_children for context and start_plan/update_plan to outline work. +- Keep each step small and reviewable; never modify outside a tool.`, + `Commands and tools (concise) +- run_command (default for actions). Verbs: + • create_model parent="..." name="..." + • create_part parent="..." name="..." size=40,1,40 cframe=0,0.5,0 material=Concrete anchored=1 + • set_props path="..." Anchored=1 size=... cframe=... + • rename path="..." newName="..." | delete path="..." + • insert_asset assetId=123456 parent="..." (disabled in manual mode) +- list_children: inventory scene; include parentPath and depth when helpful. +- start_plan / update_plan: create and maintain a single plan; use update_plan to adjust. +- open_or_create_script / show_diff: author idempotent Luau when needed. +- complete / final_message / message: summaries and updates.`, + `Manual mode +- If the user forbids assets or catalog fails, manual mode is active: asset tools/commands are disabled. +- In manual mode, do not create new container Models until a Part exists or builder Luau is written. +- Do not complete while manual mode has zero geometry and zero Luau edits.`, + String.raw`Examples (guidance only) - [ - "Create Model 'MilitaryBase' under game.Workspace", - "Search assets query='watch tower' tags=['model'] limit=6", - "Insert asset under game.Workspace.MilitaryBase", - "Search assets query='barracks' tags=['model'] limit=6", - "Insert asset under game.Workspace.MilitaryBase", - "Search assets query='fence' tags=['model'] limit=6", - "Insert asset under game.Workspace.MilitaryBase", - "Set properties (Anchored, CFrame) to arrange towers, barracks, fence perimeter", - "Open or create Script 'BaseBuilder' in game.ServerScriptService", - "Show diff to add idempotent Luau that rebuilds the base" - ] - - -Insert an asset (preferred) - - oak tree - ["tree","nature"] - 6 - - - - 123456789 - game.Workspace - - -End-to-end: Hospital (asset-first → manual fallback) - - [ - "List children of game.Workspace to inventory scene", - "Search assets query='hospital building' tags=['model','building','hospital'] limit=6", - "Insert best match under game.Workspace", - "If insert fails or unauthorized, create Model 'Hospital' and build geometry manually", - "Create 'Floor' 40x1x40 at y=0.5, Material Concrete", - "Create four brick walls: front/back 40x10x1 at z=±19.5, left/right 1x10x38 at x=±19.5", - "Create 'Roof' 42x1x42 at y=11, Material Slate", - "Open or create Script 'HospitalBuilder' in game.ServerScriptService", - "Show diff to add idempotent Luau that rebuilds the hospital" - ] + ["Create Hospital model","Add floor and four walls","Add roof","Write HospitalBuilder","Summarize"] game.Workspace 1 -TOOL_RESULT list_children -{"children":[{"path":"game.Workspace.Terrain","name":"Terrain","className":"Terrain"}]} - - - hospital building - ["model","building","hospital"] - 6 - -TOOL_RESULT search_assets -{"results":[{"id":123456,"name":"City Hospital","creator":"BuilderX","type":"Model"},{"id":234567,"name":"Hospital LowPoly","creator":"DevY","type":"Model"}]} - - - 123456 - game.Workspace - -TOOL_RESULT insert_asset -{"ok":false,"op":"insert_asset","assetId":123456,"error":"User is not authorized to access Asset."} - - Model - game.Workspace - {"Name":"Hospital"} - - - - Part - game.Workspace.Hospital - {"Name":"Floor","Anchored":true,"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"Concrete"},"Size":{"__t":"Vector3","x":40,"y":1,"z":40},"CFrame":{"__t":"CFrame","comps":[0,0.5,0, 1,0,0, 0,1,0, 0,0,1]}} - - - - Part - game.Workspace.Hospital - {"Name":"WallFront","Anchored":true,"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"Brick"},"Size":{"__t":"Vector3","x":40,"y":10,"z":1},"CFrame":{"__t":"CFrame","comps":[0,5,-19.5, 1,0,0, 0,1,0, 0,0,1]}} - - - - Part - game.Workspace.Hospital - {"Name":"WallBack","Anchored":true,"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"Brick"},"Size":{"__t":"Vector3","x":40,"y":10,"z":1},"CFrame":{"__t":"CFrame","comps":[0,5,19.5, 1,0,0, 0,1,0, 0,0,1]}} - - - - Part - game.Workspace.Hospital - {"Name":"WallLeft","Anchored":true,"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"Brick"},"Size":{"__t":"Vector3","x":1,"y":10,"z":38},"CFrame":{"__t":"CFrame","comps":[-19.5,5,0, 1,0,0, 0,1,0, 0,0,1]}} - - - - Part - game.Workspace.Hospital - {"Name":"WallRight","Anchored":true,"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"Brick"},"Size":{"__t":"Vector3","x":1,"y":10,"z":38},"CFrame":{"__t":"CFrame","comps":[19.5,5,0, 1,0,0, 0,1,0, 0,0,1]}} - - - - Part - game.Workspace.Hospital - {"Name":"Roof","Anchored":true,"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"Slate"},"Size":{"__t":"Vector3","x":42,"y":1,"z":42},"CFrame":{"__t":"CFrame","comps":[0,11,0, 1,0,0, 0,1,0, 0,0,1]}} - - - - game.ServerScriptService - HospitalBuilder - - - - game.ServerScriptService.HospitalBuilder - [{"start":{"line":0,"character":0},"end":{"line":0,"character":0},"text":"local Workspace = game:GetService('Workspace')\nlocal function ensureModel(name)\n\tlocal m = Workspace:FindFirstChild(name)\n\tif not m then m = Instance.new('Model'); m.Name = name; m.Parent = Workspace end\n\treturn m\nend\nlocal function ensurePart(parent, name, size, cf, material)\n\tlocal p = parent:FindFirstChild(name)\n\tif not p then p = Instance.new('Part'); p.Name = name; p.Anchored = true; p.Parent = parent end\n\tp.Size = size; p.CFrame = cf; if material then p.Material = material end\n\treturn p\nend\nlocal hospital = ensureModel('Hospital')\nensurePart(hospital, 'Floor', Vector3.new(40,1,40), CFrame.new(0,0.5,0), Enum.Material.Concrete)\nensurePart(hospital, 'WallFront', Vector3.new(40,10,1), CFrame.new(0,5,-19.5), Enum.Material.Brick)\nensurePart(hospital, 'WallBack', Vector3.new(40,10,1), CFrame.new(0,5,19.5), Enum.Material.Brick)\nensurePart(hospital, 'WallLeft', Vector3.new(1,10,38), CFrame.new(-19.5,5,0), Enum.Material.Brick)\nensurePart(hospital, 'WallRight', Vector3.new(1,10,38), CFrame.new(19.5,5,0), Enum.Material.Brick)\nensurePart(hospital, 'Roof', Vector3.new(42,1,42), CFrame.new(0,11,0), Enum.Material.Slate)\n"}] - - - - Placed Hospital geometry (floor, 4 walls, roof) and added HospitalBuilder Luau for idempotent rebuild. Asset insert failed; manual fallback used. - - -Build simple geometry (generic) - - ["Create 'Structure' model under Workspace","Create 'Floor' Part with size 16x1x16 at y=0.5 Anchored","Create 'WallFront' Part 16x8x1 at z=-7.5 Anchored","Create 'Roof' Part 16x1x16 with slight tilt Anchored"] - + + create_model parent="game.Workspace" name="Hospital" + - - Model - game.Workspace - {"Name":"Structure"} - - - - Part - game.Workspace.Structure - {"Name":"Floor","Anchored":true,"Size":{"__t":"Vector3","x":16,"y":1,"z":16},"CFrame":{"__t":"CFrame","comps":[0,0.5,0, 1,0,0, 0,1,0, 0,0,1]},"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"WoodPlanks"}} - - - - Part - game.Workspace.Structure - {"Name":"WallFront","Anchored":true,"Size":{"__t":"Vector3","x":16,"y":8,"z":1},"CFrame":{"__t":"CFrame","comps":[0,4.5,-7.5, 1,0,0, 0,1,0, 0,0,1]},"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"Brick"}} - - - - Part - game.Workspace.Structure - {"Name":"Roof","Anchored":true,"Size":{"__t":"Vector3","x":16,"y":1,"z":16},"CFrame":{"__t":"CFrame","comps":[0,8.6,0, 1,0,0, 0,0.5,-0.8660254, 0,0.8660254,0.5]},"Color":{"__t":"Color3","r":0.6,"g":0.2,"b":0.2}} -` + + create_part parent="game.Workspace.Hospital" name="Floor" size=40,1,40 cframe=0,0.5,0 material=Concrete anchored=1 +` ] const SYSTEM_PROMPT = PROMPT_SECTIONS.join('\n\n') diff --git a/vector/apps/web/lib/orchestrator/prompts/examples.ts b/vector/apps/web/lib/orchestrator/prompts/examples.ts index 8c9c5bd..151f5ed 100644 --- a/vector/apps/web/lib/orchestrator/prompts/examples.ts +++ b/vector/apps/web/lib/orchestrator/prompts/examples.ts @@ -2,27 +2,10 @@ // Keep these short, deterministic, and copy-ready for the model. export const PLANNER_GUIDE = ` -Planning -- Always begin by returning exactly one and then stop. Do not perform any action tools until the user approves (e.g., they say "proceed" or "next step"). -- Translate the user's goal into specific placements and code changes. -- When planning, produce a DETAILED, TOOL-ORIENTED step list via . - - 8–15 concise steps typical for multi‑object builds; more if needed. - - One tool per step and name the exact action: - - The tool name (create_instance, set_properties, search_assets, insert_asset, open_or_create_script, show_diff, ...) - - The exact target (className, path/parentPath, and Name) - - The intention (e.g., size/position, purpose like "outer fence", or code outcome) - - Examples of good step text: "Search assets: query='hospital building' tags=['model','building','hospital'] limit=6", "Insert asset under game.Workspace", "Set CFrame/Anchored for placement". -Manual-first when the user provides explicit dimensions/materials or forbids catalog usage. In these cases, do NOT use search_assets/insert_asset — build with create_instance/set_properties and author idempotent Luau. -- When catalog access is denied or fails, immediately switch to manual geometry; do not keep calling search_assets or insert_asset. -- Do NOT introduce generic example containers like "House" or "SimpleHouse". If the user asks for a "Hospital", keep all names and steps aligned to "Hospital". -- By default, insert assets under game.Workspace directly. Do not create a container Model first unless the user explicitly requested one or multiple inserts require grouping. -- Before searching, quickly inventory context: - - list_code_definition_names (brief) to understand existing code modules - - optionally list_children on game.Workspace to avoid duplicating obvious containers -Use create_instance/set_properties for primitives whenever assets are unavailable/disabled OR the user specifies exact sizes/positions/materials. Only use asset tools if the user explicitly asks for catalog assets. -- After the plan is approved, execute exactly one step per assistant turn. -- Use as you progress (mark completed, set next, add notes). Keep steps small and verifiable. -- Add a Luau step only when the user asks for code, or when assets are unavailable and you fall back to manual geometry (to make the build reproducible). +Planning (guidance only) +- Use to outline multi-step work and to mark progress or adjust. Keep one active plan; reuse it. +- Steps should be concrete and tool-oriented (e.g., run_command to create parts, open_or_create_script + show_diff to write Luau). +- Keep plans short and actionable; do not introduce unrelated content or rename the user’s subjects. `; export const COMPLEXITY_DECISION_GUIDE = ` @@ -39,43 +22,18 @@ When to plan `; export const TOOL_REFERENCE = ` -Tool reference (purpose and tips) -- run_command: Execute a single action via a compact command string. Use for creating models/parts, setting properties, or inserting assets. -- start_plan: Begin an ordered list of steps. Use for multi-step work. -- update_plan: Mark a step done, set next step, or add notes. -- get_active_script: Return the currently open script (path, text) if any. -- list_selection: Return the current Studio selection (array of paths/classes). -- list_open_documents: Return open documents; useful to infer likely targets. -- open_or_create_script: Ensure a Script/LocalScript/ModuleScript exists; returns {path,text,created}. -- list_children: Inspect scene tree under a path. Add classWhitelist to filter. -- get_properties: Read properties/attributes for a path; set includeAllAttributes for attributes. -- list_code_definition_names: Enumerate known code symbol names for navigation. -- search_files: Grep-like substring search across files (case-insensitive by default). -- show_diff: Propose edits to a file. Prefer first before apply_edit. Supports […] for multi-file. -- apply_edit: Apply edits directly (use sparingly; prefer show_diff previews first). -- create_instance: Create a Roblox instance at parentPath with optional props. -- set_properties: Update properties on an existing instance. -- rename_instance: Rename an instance at a path. -- delete_instance: Delete an instance. -- search_assets: Search catalog for assets (limit ≤ 6 unless asked otherwise). -- insert_asset: Insert an assetId into the scene (defaults to game.Workspace). -- generate_asset_3d: Request a generated asset; include tags/style/budget if helpful. -- complete: Mark the task complete with a succinct summary. -- message: Stream a short text update with phase=start|update|final. -- final_message: Send the final summary (Ask-friendly) and end the turn. -- attempt_completion: Alias for completion; include result and optional confidence. +Tools (concise) +- run_command (default for actions): create_model, create_part, set_props, rename, delete, insert_asset. +- list_children: read scene tree (parentPath, depth, classWhitelist). +- start_plan / update_plan: outline and track steps. +- open_or_create_script / show_diff: write idempotent Luau when needed. +- complete / final_message / message: summaries and updates. `; export const EXAMPLES_POLICY = ` Examples policy -- Examples shown in this prompt are illustrative guidance only. They are not commands. -- Choose an example pattern only if it matches the current user goal; otherwise proceed without one. -- Never introduce unrelated names or content from examples (e.g., do not create \"Farm\" or \"FarmBuilder\" when the user asked for a house). -- Keep examples as text-only guidance. Always prioritize the user's request and the current scene/context. - - STRICTLY FOLLOW the user's requested subject and nouns. NEVER pivot to different subjects or example names. If the user asks for a "hospital", do NOT create a "house", "base", "SimpleHouse", or any unrelated structure. - - When continuing a plan, prefer steps that explicitly progress the user's requested build; avoid switching to generic examples or renaming containers. - - If the user forbids catalog assets (e.g., "do not search or insert catalog assets", "no assets", "manual geometry"), asset tools are disabled for this task. Build manually and write idempotent Luau. - - After an asset search/insert failure, assume manual geometry mode until the user explicitly allows assets again. +- Examples are for guidance only. Do not treat them as commands. +- Always follow the user’s request and current context. Keep subject names aligned with the user’s nouns. `; export const WORKFLOW_EXAMPLES = ` @@ -136,22 +94,12 @@ Asset search and insert (planned) export const ROLE_SCOPE_GUIDE = ` Role and approach -- Be a precise, safety-first Roblox Studio copilot. -- Favor minimal, reviewable steps with previews (show_diff/object ops). -- Use the user's existing names/styles when extending code or scenes. -- Prefer reading context (selection, open docs, search) before guessing paths. -- Avoid destructive changes; never delete or overwrite large files blindly. -- Summarize outcomes with complete/attempt_completion or final_message when done. +- Be precise. Keep steps minimal and safe. Use the user’s names. Prefer reading context before acting. Summarize when done. `; export const QUALITY_CHECK_GUIDE = ` Quality check -- Derive a checklist of deliverables straight from the prompt (e.g., floor, four walls, roof). -- Track completion of each checklist item as you work; optionally stream progress updates. -- Only call once every checklist item is satisfied with visible, anchored geometry or verified code. -- Placeholder Models or unattached Parts never count toward completion. -- Ensure reproducible Luau exists: update a Script/ModuleScript (or repo .lua/.luau file) so the build can be rebuilt from code before completing. -- Use open_or_create_script(path,parentPath?,name?) to guarantee a script container exists before diffing or editing its Source. +- Build from the user’s checklist. Only complete after visible anchored Parts or idempotent Luau exist. Placeholder Models don’t count. `; export const EXAMPLE_HOUSE_SMALL = ` From 12497e6cd8ed92a71706031afbd5c270570a2edd Mon Sep 17 00:00:00 2001 From: improdead Date: Thu, 2 Oct 2025 22:37:58 -0400 Subject: [PATCH 4/7] chore: relax hard gates; remove manual-mode enforcement and plan/complete blocks; keep run_command flow --- .../web/app/api/proposals/[id]/apply/route.ts | 9 +- vector/apps/web/lib/orchestrator/index.ts | 176 +----------------- vector/plugin/src/main.server.lua | 5 +- 3 files changed, 10 insertions(+), 180 deletions(-) diff --git a/vector/apps/web/app/api/proposals/[id]/apply/route.ts b/vector/apps/web/app/api/proposals/[id]/apply/route.ts index 0a148a0..16bfd5d 100644 --- a/vector/apps/web/app/api/proposals/[id]/apply/route.ts +++ b/vector/apps/web/app/api/proposals/[id]/apply/route.ts @@ -42,14 +42,9 @@ export async function POST(req: Request, ctx: { params: { id: string } }) { const opKind = typeof (body as any)?.op === 'string' ? String((body as any).op) : undefined const failed = (body as any)?.ok === false if (wf && isAsset && failed) { - const fallback = 'CATALOG_UNAVAILABLE Asset search/insert failed. Create the requested objects manually using create_instance/set_properties or Luau edits.' - try { pushChunk(wf, 'fallback.asset manual_required') } catch (e) { console.error('Failed to push chunk for asset fallback', e) } + const fallback = 'CATALOG_UNAVAILABLE Asset search/insert failed. Consider manual geometry or alternative assets.' + try { pushChunk(wf, 'fallback.asset manual_suggest') } catch (e) { console.error('Failed to push chunk for asset fallback', e) } updateTaskState(wf, (state) => { - if (!state.policy || typeof state.policy !== 'object') { - state.policy = { geometryOps: 0, luauEdits: 0, manualMode: true, assetSearches: 0, assetInserts: 0 } - } else { - state.policy.manualMode = true - } state.history.push({ role: 'system', content: fallback + (opKind ? ` op=${opKind}` : ''), at: Date.now() }) }) } diff --git a/vector/apps/web/lib/orchestrator/index.ts b/vector/apps/web/lib/orchestrator/index.ts index e6eed7a..b1720c1 100644 --- a/vector/apps/web/lib/orchestrator/index.ts +++ b/vector/apps/web/lib/orchestrator/index.ts @@ -650,9 +650,6 @@ type MapToolExtras = { userOptedOut?: boolean geometryTracker?: { sawCreate: boolean; sawParts: boolean } subjectNouns?: string[] - manualMode?: boolean - geometryCount?: number - luauCount?: number } type MapResult = { proposals: Proposal[]; missingContext?: string; contextResult?: any } @@ -892,23 +889,8 @@ function mapToolToProposals( if (typeof (a as any).className === 'string' && parentPath) { const childClass = (a as any).className as string const childProps = (a as any).props as Record | undefined - const manualMode = !!extras?.manualMode - const manualProgress = ((extras?.geometryCount ?? 0) > 0) || ((extras?.luauCount ?? 0) > 0) const ops: ObjectOp[] = [] - if (manualMode && !manualProgress && childClass === 'Model') { - return { proposals, missingContext: 'Manual mode: create visible Parts (Floor/Wall/Roof) or add the builder script before making additional container Models.' } - } - - if (manualMode && childClass === 'Model') { - const nameProp = childProps && typeof childProps.Name === 'string' ? String(childProps.Name).trim() : '' - const subjects = Array.isArray(extras?.subjectNouns) ? extras!.subjectNouns : [] - const matchesSubject = nameProp && subjects.some((noun) => noun && nameProp.toLowerCase().includes(noun.toLowerCase())) - if (!matchesSubject) { - return { proposals, missingContext: 'Manual mode active: create anchored Parts or update existing geometry instead of generic container Models.' } - } - } - // Guard: keep names aligned to user nouns. Common pitfall: "House"/"SimpleHouse" for hospital tasks. const wantsHospital = /\bhospital\b/i.test(msg) || (extras?.subjectNouns || []).some((s) => /\bhospital\b/i.test(s)) if (wantsHospital && childClass === 'Model' && childProps && typeof childProps.Name === 'string') { @@ -1292,28 +1274,7 @@ function proposalTouchesGeometry(proposal: Proposal): boolean { return false } -function proposalCreatesVisibleGeometry(proposal: Proposal): boolean { - if (!proposal) return false - if (proposal.type === 'object_op') { - return proposal.ops.some((op) => { - if (op.op === 'create_instance') { - return PART_CLASS_NAMES.has(op.className) - } - if (op.op === 'set_properties') { - if (isLuauScriptPath(op.path) && hasLuauSource(op.props as Record | undefined)) { - return false - } - const props = (op.props || {}) as Record - return ['Size', 'CFrame', 'Anchored', 'Material', 'Position', 'Orientation'].some((key) => Object.prototype.hasOwnProperty.call(props, key)) - } - return false - }) - } - if (proposal.type === 'asset_op') { - return !!proposal.insert || !!proposal.generate3d - } - return false -} +// export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; taskState: TaskState; tokenTotals: { in: number; out: number } }> { const rawMessage = input.message.trim() @@ -1516,48 +1477,7 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; : '' const providerFirstMessage = attachments.length ? `${msg}\n\n[ATTACHMENTS]\n${attachmentSummary}` : msg - // Detect user or system instructions that force manual geometry mode - const saysNoAssets = (t?: string) => { - if (!t) return false - return /\b(?:do\s*not\s*(?:search|use|insert)[^\n]*?(?:assets|catalog)|no\s+assets|manual\s+geometry|primitives\s+only|no\s+catalog|avoid\s+assets|build\s+manually)\b/i.test(t) - } - const allowsAssets = (t?: string) => { - if (!t) return false - return /\b(?:allow|re\s*enable|use)\s+(?:the\s+)?(?:catalog|assets)|\bsearch\s+(?:the\s+)?catalog\b|\bresume\s+asset\s+search\b/i.test(t) - } - const userHistoryText = Array.isArray(taskState.history) - ? taskState.history.filter((h) => h.role === 'user').map((h) => h.content || '').join(' \n ') - : '' - const recentSystemText = Array.isArray(taskState.history) - ? taskState.history - .slice(Math.max(0, taskState.history.length - 12)) - .filter((h) => h.role === 'system') - .map((h) => h.content || '') - .join(' \n ') - : '' - const fallbackManual = /CATALOG_UNAVAILABLE|manual\s+using\s+create_instance|manual_required/i.test(recentSystemText) const currentPolicy = ensureScriptPolicy(taskState) - let manualModeActive = !!currentPolicy.manualMode - if (fallbackManual) manualModeActive = true - if (manualModeActive && allowsAssets(msg)) { - updateState((state) => { - const policy = ensureScriptPolicy(state) - policy.manualMode = false - }) - manualModeActive = false - } - const manualDirect = saysNoAssets(msg) || saysNoAssets(userHistoryText) - const manualWanted = manualModeActive || manualDirect - - if (manualWanted && !manualModeActive) { - updateState((state) => { - const policy = ensureScriptPolicy(state) - policy.manualMode = true - }) - manualModeActive = true - } - let geometryCount = currentPolicy.geometryOps || 0 - let luauCount = currentPolicy.luauEdits || 0 const contextRequestLimit = 1 let contextRequestsThisCall = 0 @@ -1583,7 +1503,6 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; let messages: { role: 'user' | 'assistant' | 'system'; content: string }[] | null = null let assetFallbackWarningSent = false let catalogSearchAvailable = (process.env.CATALOG_DISABLE_SEARCH || '0') !== '1' - if (manualWanted) catalogSearchAvailable = false let scriptWarnings = 0 const finalize = (list: Proposal[]): { proposals: Proposal[]; taskState: TaskState; tokenTotals: { in: number; out: number } } => { @@ -1852,35 +1771,8 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; ) const isActionTool = !isContextOrNonActionTool - if (manualWanted && (toolName === 'search_assets' || toolName === 'insert_asset' || toolName === 'generate_asset_3d')) { - consecutiveValidationErrors++ - const errMsg = 'MANUAL_MODE Active: use create_instance/set_properties or Luau; catalog tools are disabled until manual build is complete.' - pushChunk(streamKey, `error.validation ${toolName} manual_mode`) - console.warn(`[orch] manual_mode tool=${toolName}`) - convo.push({ role: 'assistant', content: toolXml }) - convo.push({ role: 'user', content: errMsg }) - appendHistory('system', errMsg) - continue - } - // Allow duplicate start_plan: if steps are unchanged, no-op; otherwise replace (handled by recordPlanStart) if (toolName === 'start_plan' && planReady) { - const incomingSteps = Array.isArray((a as any).steps) - ? (a as any).steps.map((s: any) => String(s || '').trim()) - : [] - const currentSteps = Array.isArray(taskState.plan?.steps) - ? taskState.plan!.steps.map((s) => String(s || '').trim()) - : [] - if (JSON.stringify(incomingSteps) !== JSON.stringify(currentSteps)) { - consecutiveValidationErrors++ - const errMsg = 'PLAN_ACTIVE Use to adjust the existing plan instead of starting a new one.' - pushChunk(streamKey, `error.validation ${toolName} plan_active`) - console.warn(`[orch] plan.active tool=${toolName}`) - convo.push({ role: 'assistant', content: toolXml }) - convo.push({ role: 'user', content: errMsg }) - appendHistory('system', errMsg) - continue - } pushChunk(streamKey, 'plan.duplicate allowed') } if (!planReady && isActionTool && !askMode && requirePlan) { @@ -1900,29 +1792,7 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; updateState((state) => { const p = ensureScriptPolicy(state); p.assetInserts = (p.assetInserts || 0) + 1 }) } - // Enforce asset-first: require at least one search_assets attempt before manual geometry (agent mode) - let assetFirst = (process.env.VECTOR_ASSET_FIRST || '1') === '1' - if (manualWanted) assetFirst = false - const isGeometryTool = (toolName === 'create_instance') || (toolName === 'set_properties' && (!isLuauScriptPath((a as any).path) || !hasLuauSource((a as any).props))) - // Detect recent catalog failures to allow manual fallback without re-searching - const recentHistory = taskState.history.slice(-12).map((h) => h.content || '') - const assetBlocked = recentHistory.some((line) => - /CATALOG_UNAVAILABLE|Asset .* failed|User is not authorized to access Asset|Failed to find \".*\" in the marketplace!|search_insert_fallback|no_results/i.test(line) - ) - if (assetFirst && !askMode && catalogSearchAvailable && isActionTool && isGeometryTool) { - const searches = (taskState.policy?.assetSearches || 0) - const userRequestedManual = /\b(?:manual\s+geometry|primitives\s+only|no\s+assets)\b/i.test(msg) - if (searches === 0 && !userRequestedManual && !assetBlocked) { - const errMsg = 'ASSET_FIRST Please search the Creator Store first using before using manual geometry.' - pushChunk(streamKey, 'error.validation asset_first_required') - console.warn(`[orch] asset.first.require tool=${toolName}`) - // Echo tool for visibility then nudge - convo.push({ role: 'assistant', content: toolXml }) - convo.push({ role: 'user', content: errMsg }) - appendHistory('system', errMsg) - continue - } - } + // No asset-first enforcement; allow either assets or direct geometry per user intent if ((name === 'show_diff' || name === 'apply_edit') && !a.path && input.context.activeScript?.path) { a = { ...a, path: input.context.activeScript.path } @@ -2082,9 +1952,6 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; userOptedOut, geometryTracker, subjectNouns, - manualMode: manualWanted, - geometryCount, - luauCount, }) if (mapped.contextResult !== undefined) { @@ -2114,40 +1981,13 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; name === 'attempt_completion' || (name === 'message' && typeof (a as any).phase === 'string' && (a as any).phase.toLowerCase() === 'final') - if (manualWanted && !askMode && isFinalPhase && geometryCount === 0 && luauCount === 0) { - consecutiveValidationErrors++ - const warn = 'MANUAL_MODE Manual mode is active. Create at least one anchored Part (e.g., floor/walls/roof) or add the HospitalBuilder Luau before finishing.' - pushChunk(streamKey, 'error.validation manual_mode_incomplete') - console.warn(`[orch] manual_mode.incomplete tool=${String(name)}`) - convo.push({ role: 'assistant', content: toolXml }) - convo.push({ role: 'user', content: warn }) - appendHistory('system', warn) - continue - } - - const scriptRequired = geometryWorkObserved && !scriptWorkObserved && !userOptedOut - if (isFinalPhase && scriptRequired) { - scriptWarnings += 1 - consecutiveValidationErrors += 1 - const warn = - 'SCRIPT_REQUIRED Default Script Policy: add Luau in a Script/ModuleScript (open_or_create_script → show_diff) that rebuilds the created Instances before completing. Say "geometry only" if you really need to skip.' - pushChunk(streamKey, 'error.validation script_required') - console.warn(`[orch] validation.script_missing tool=${String(name)}`) - convo.push({ role: 'assistant', content: toolXml }) - convo.push({ role: 'user', content: warn }) - appendHistory('system', warn) - if (consecutiveValidationErrors > validationRetryLimit || scriptWarnings > validationRetryLimit) break - continue - } + // No script-required gate: allow completion when user decides if (mapped.proposals.length) { updateState((state) => { const policy = ensureScriptPolicy(state) if (touchesGeometry) { - const addsVisibleGeometry = mapped.proposals.some(proposalCreatesVisibleGeometry) - if (addsVisibleGeometry) { - policy.geometryOps += 1 - } + policy.geometryOps += 1 } if (touchesLuau) { policy.luauEdits += 1 @@ -2158,9 +1998,7 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; } } }) - const refreshedPolicy = ensureScriptPolicy(taskState) - geometryCount = refreshedPolicy.geometryOps || 0 - luauCount = refreshedPolicy.luauEdits || 0 + // counters updated pushChunk(streamKey, `proposals.mapped ${String(name)} count=${mapped.proposals.length}`) console.log(`[orch] proposals.mapped tool=${String(name)} count=${mapped.proposals.length}`) // Stream assistant text for UI transcript @@ -2226,8 +2064,8 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; } if (!fallbacksDisabled && !assetFallbackWarningSent) { - const warn = 'CATALOG_UNAVAILABLE Asset catalog lookup failed. Create the requested objects manually using create_instance or Luau edits.' - pushChunk(streamKey, 'fallback.asset manual_required') + const warn = 'CATALOG_UNAVAILABLE Asset catalog lookup failed. Consider manual geometry or alternative assets.' + pushChunk(streamKey, 'fallback.asset manual_suggest') console.log('[orch] fallback.asset manual_required; instructing provider to create manually') appendHistory('assistant', 'fallback: asset search disabled (request manual creation)') convo.push({ role: 'user', content: warn }) diff --git a/vector/plugin/src/main.server.lua b/vector/plugin/src/main.server.lua index a751881..2d8e9ec 100644 --- a/vector/plugin/src/main.server.lua +++ b/vector/plugin/src/main.server.lua @@ -2734,10 +2734,7 @@ local function maybeAutoContinue(workflowId) steps += 1 ui.addStatus("auto.next step " .. tostring(steps)) local followup = "Next step: propose exactly one small, safe action." - if _G.__VECTOR_LAST_ASSET_ERROR then - followup = followup .. " Asset search/insert failed (" .. tostring(_G.__VECTOR_LAST_ASSET_ERROR) .. "). Please build manually using create_instance/set_properties, or write Luau to rebuild the scene." - _G.__VECTOR_LAST_ASSET_ERROR = nil - end + _G.__VECTOR_LAST_ASSET_ERROR = nil local mode = CURRENT_MODE local opts = { mode = mode, maxTurns = (mode == "ask") and 1 or nil, enableFallbacks = true, modelOverride = getModelOverride(), autoApply = _G.__VECTOR_AUTO } local resp = sendChat("local", followup, buildContextSnapshot(), workflowId, opts) From 00db1bff292ad1e06c1fba67d1dc7100d229347d Mon Sep 17 00:00:00 2001 From: improdead Date: Thu, 2 Oct 2025 22:46:44 -0400 Subject: [PATCH 5/7] docs(prompt): add run_command cheat-sheet guidance; clarify manual fallback --- vector/apps/web/lib/orchestrator/index.ts | 67 +++++++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/vector/apps/web/lib/orchestrator/index.ts b/vector/apps/web/lib/orchestrator/index.ts index b1720c1..0ebe3c1 100644 --- a/vector/apps/web/lib/orchestrator/index.ts +++ b/vector/apps/web/lib/orchestrator/index.ts @@ -363,10 +363,69 @@ const PROMPT_SECTIONS = [ - start_plan / update_plan: create and maintain a single plan; use update_plan to adjust. - open_or_create_script / show_diff: author idempotent Luau when needed. - complete / final_message / message: summaries and updates.`, - `Manual mode -- If the user forbids assets or catalog fails, manual mode is active: asset tools/commands are disabled. -- In manual mode, do not create new container Models until a Part exists or builder Luau is written. -- Do not complete while manual mode has zero geometry and zero Luau edits.`, +String.raw`Command cheat-sheet (guidance only) +Inspect workspace (depth=2) + + game.Workspace + 2 + + +Create model + floor/roof via run_command + + create_model parent="game.Workspace" name="Hospital" + + + create_part parent="game.Workspace.Hospital" name="Floor" size=40,1,40 cframe=0,0.5,0 material=Concrete anchored=1 + + + create_part parent="game.Workspace.Hospital" name="Roof" size=42,1,42 cframe=0,11,0 material=Slate anchored=1 + + +Walls (repeat with adjusted CFrame and Size) + + create_part parent="game.Workspace.Hospital" name="WallFront" size=40,10,1 cframe=0,5.5,-19.5 material=Brick anchored=1 + + +Builder script (idempotent) + + game.ServerScriptService + HospitalBuilder + + + game.ServerScriptService.HospitalBuilder + [{"start":{"line":0,"character":0},"end":{"line":0,"character":0},"text":"local Workspace = game:GetService('Workspace')\nlocal function ensureModel(name)\n\tlocal m = Workspace:FindFirstChild(name)\n\tif not m then\n\tm = Instance.new('Model')\n\tm.Name = name\n\tm.Parent = Workspace\n\tend\n\treturn m\nend\nlocal function ensurePart(parent, name, size, cf, mat)\n\tlocal p = parent:FindFirstChild(name)\n\tif not p then\n\tp = Instance.new('Part')\n\tp.Anchored = true\n\tp.Name = name\n\tp.Parent = parent\n\tend\n\tp.Size = size\n\tp.CFrame = cf\n\tif mat then p.Material = mat end\n\treturn p\nend\nlocal hospital = ensureModel('Hospital')\nensurePart(hospital, 'Floor', Vector3.new(40,1,40), CFrame.new(0,0.5,0), Enum.Material.Concrete)\nensurePart(hospital, 'WallFront', Vector3.new(40,10,1), CFrame.new(0,5.5,-19.5), Enum.Material.Brick)\nensurePart(hospital, 'WallBack', Vector3.new(40,10,1), CFrame.new(0,5.5,19.5), Enum.Material.Brick)\nensurePart(hospital, 'WallLeft', Vector3.new(1,10,40), CFrame.new(-19.5,5.5,0), Enum.Material.Brick)\nensurePart(hospital, 'WallRight', Vector3.new(1,10,40), CFrame.new(19.5,5.5,0), Enum.Material.Brick)\nensurePart(hospital, 'Roof', Vector3.new(42,1,42), CFrame.new(0,11,0), Enum.Material.Slate)\n"}] + + +Catalog search + insert + + hospital bed + ["model","bed","hospital"] + 6 + + + insert_asset assetId=125013769 parent="game.Workspace.Hospital" + + +Manual shell (copy block for Floor + 4 walls + roof) + + create_part parent="game.Workspace.Hospital" name="WallBack" size=40,10,1 cframe=0,5.5,19.5 material=Brick anchored=1 + + +Helpers (lights, spawn, cleanup) + + create_model parent="game.Workspace" name="LightingHelper" + + + game.ServerScriptService + ImportedCleanup + + + game.ServerScriptService.ImportedCleanup + [{"start":{"line":0,"character":0},"end":{"line":0,"character":0},"text":"local container = workspace:FindFirstChild('Hospital')\nif container then\n\tfor _, inst in ipairs(container:GetDescendants()) do\n\t\tif inst:IsA('Script') or inst:IsA('LocalScript') then\n\t\t\tinst:Destroy()\n\t\tend\n\tend\nend\n"}] +`, + `Manual fallback (guidance only) +- If the user asks for primitives or catalog searches fail, switch to run_command create_part + set_props and add an optional builder script. +- You may still use catalog assets when the user permits or provides an assetId.`, String.raw`Examples (guidance only) ["Create Hospital model","Add floor and four walls","Add roof","Write HospitalBuilder","Summarize"] From 608a515db9095c044ea2665a9fab02ac3136a022 Mon Sep 17 00:00:00 2001 From: improdead Date: Fri, 3 Oct 2025 08:59:48 -0400 Subject: [PATCH 6/7] feat(plugin): auto-create missing parents for create_instance commands --- vector/plugin/Vector.rbxmx | 72 +++++++++++++++++++-- vector/plugin/src/tools/create_instance.lua | 65 ++++++++++++++++++- 2 files changed, 130 insertions(+), 7 deletions(-) diff --git a/vector/plugin/Vector.rbxmx b/vector/plugin/Vector.rbxmx index 5481975..4990b99 100644 --- a/vector/plugin/Vector.rbxmx +++ b/vector/plugin/Vector.rbxmx @@ -2744,10 +2744,7 @@ local function maybeAutoContinue(workflowId) steps += 1 ui.addStatus("auto.next step " .. tostring(steps)) local followup = "Next step: propose exactly one small, safe action." - if _G.__VECTOR_LAST_ASSET_ERROR then - followup = followup .. " Asset search/insert failed (" .. tostring(_G.__VECTOR_LAST_ASSET_ERROR) .. "). Please build manually using create_instance/set_properties, or write Luau to rebuild the scene." - _G.__VECTOR_LAST_ASSET_ERROR = nil - end + _G.__VECTOR_LAST_ASSET_ERROR = nil local mode = CURRENT_MODE local opts = { mode = mode, maxTurns = (mode == "ask") and 1 or nil, enableFallbacks = true, modelOverride = getModelOverride(), autoApply = _G.__VECTOR_AUTO } local resp = sendChat("local", followup, buildContextSnapshot(), workflowId, opts) @@ -3277,6 +3274,69 @@ local function resolveByFullName(path) return cur end +local function ensureParentExists(path) + if type(path) ~= "string" or #path == 0 then return nil end + + local function tokenize(full) + local tokens = {} + local buf, inBr = {}, false + local function flush() + if #buf > 0 then + table.insert(tokens, table.concat(buf)) + buf = {} + end + end + for i = 1, #full do + local ch = string.sub(full, i, i) + if ch == "[" then + inBr = true + elseif ch == "]" then + inBr = false + elseif ch == "." and not inBr then + flush() + else + table.insert(buf, ch) + end + end + flush() + return tokens + end + + local tokens = tokenize(path) + if tokens[1] == "game" then + table.remove(tokens, 1) + end + if #tokens == 0 then return nil end + + local rootName = tokens[1] + local okService, current = pcall(function() + return game:GetService(rootName) + end) + if not okService or not current then + current = game:FindFirstChild(rootName) + if not current then + return nil + end + end + + for i = 2, #tokens do + local name = tokens[i] + local child = current:FindFirstChild(name) + if not child then + if current == workspace or current:IsDescendantOf(workspace) then + child = Instance.new("Model") + child.Name = name + child.Parent = current + else + return nil + end + end + current = child + end + + return current +end + local function deserialize(v) if type(v) ~= "table" or v.__t == nil then return v end local t = v.__t @@ -3338,7 +3398,7 @@ return function(className, parentPath, props) if type(className) ~= "string" then return { ok = false, error = "Missing className" } end - local parent = resolveByFullName(parentPath) + local parent = resolveByFullName(parentPath) or ensureParentExists(parentPath) or resolveByFullName(parentPath) if not parent then return { ok = false, error = "Parent not found: " .. tostring(parentPath) } end @@ -4285,4 +4345,4 @@ end - + \ No newline at end of file diff --git a/vector/plugin/src/tools/create_instance.lua b/vector/plugin/src/tools/create_instance.lua index e728946..06f36ac 100644 --- a/vector/plugin/src/tools/create_instance.lua +++ b/vector/plugin/src/tools/create_instance.lua @@ -47,6 +47,69 @@ local function resolveByFullName(path) return cur end +local function ensureParentExists(path) + if type(path) ~= "string" or #path == 0 then return nil end + + local function tokenize(full) + local tokens = {} + local buf, inBr = {}, false + local function flush() + if #buf > 0 then + table.insert(tokens, table.concat(buf)) + buf = {} + end + end + for i = 1, #full do + local ch = string.sub(full, i, i) + if ch == "[" then + inBr = true + elseif ch == "]" then + inBr = false + elseif ch == "." and not inBr then + flush() + else + table.insert(buf, ch) + end + end + flush() + return tokens + end + + local tokens = tokenize(path) + if tokens[1] == "game" then + table.remove(tokens, 1) + end + if #tokens == 0 then return nil end + + local rootName = tokens[1] + local okService, current = pcall(function() + return game:GetService(rootName) + end) + if not okService or not current then + current = game:FindFirstChild(rootName) + if not current then + return nil + end + end + + for i = 2, #tokens do + local name = tokens[i] + local child = current:FindFirstChild(name) + if not child then + if current == workspace or current:IsDescendantOf(workspace) then + child = Instance.new("Model") + child.Name = name + child.Parent = current + else + return nil + end + end + current = child + end + + return current +end + local function deserialize(v) if type(v) ~= "table" or v.__t == nil then return v end local t = v.__t @@ -108,7 +171,7 @@ return function(className, parentPath, props) if type(className) ~= "string" then return { ok = false, error = "Missing className" } end - local parent = resolveByFullName(parentPath) + local parent = resolveByFullName(parentPath) or ensureParentExists(parentPath) or resolveByFullName(parentPath) if not parent then return { ok = false, error = "Parent not found: " .. tostring(parentPath) } end From b1a079f9132e24890afecf79fc32e14320661d00 Mon Sep 17 00:00:00 2001 From: improdead Date: Fri, 3 Oct 2025 11:58:24 -0400 Subject: [PATCH 7/7] fix(scene): canonicalize instance paths to game.Service.* to align list_children with applied ops --- .../apps/web/lib/orchestrator/sceneGraph.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/vector/apps/web/lib/orchestrator/sceneGraph.ts b/vector/apps/web/lib/orchestrator/sceneGraph.ts index e18d55b..90b66bc 100644 --- a/vector/apps/web/lib/orchestrator/sceneGraph.ts +++ b/vector/apps/web/lib/orchestrator/sceneGraph.ts @@ -45,10 +45,32 @@ function ensureScene(state: TaskState): SceneGraph { return state.scene } +const SERVICE_HEADS = new Set([ + 'Workspace', + 'ReplicatedStorage', + 'ServerStorage', + 'StarterGui', + 'StarterPack', + 'StarterPlayer', + 'Lighting', + 'Teams', + 'SoundService', + 'TextService', + 'CollectionService', +]) + export function normalizeInstancePath(path?: string): string | undefined { if (typeof path !== 'string') return undefined - const trimmed = path.trim() - return trimmed.length > 0 ? trimmed : undefined + let trimmed = path.trim() + if (!trimmed) return undefined + // Canonicalize service heads to include 'game.' prefix for consistency + // Examples: 'Workspace', 'Workspace.Hospital' -> 'game.Workspace', 'game.Workspace.Hospital' + const startsWithGame = trimmed.startsWith('game.') + const head = trimmed.split('.')[0] + if (!startsWithGame && SERVICE_HEADS.has(head)) { + trimmed = `game.${trimmed}` + } + return trimmed } type SnapshotNode = { path: string; className: string; name: string; parentPath?: string; props?: Record }