From fe67e63e7a2b918adf68af82aecf4a5b7698fbd4 Mon Sep 17 00:00:00 2001 From: BillyOutlast <172061051+BillyOutlast@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:39:08 -0400 Subject: [PATCH] T04: No-Project Fix (#21) - deterministic initialize_project self-remedy - CustomAIClient: NO_PROJECT helpers + system-prompt remedy when no project JSON - Roles: initialize_project first in manager manifest + first-action prompt rule - Patch 14: null-project pre-pass failing project-dependent tools closed with remedy - Tests: 3 new null-project assertions + MANIFEST recompute --- packages/ai-studio/AI/CustomAIClient.js | 40 ++++++++ .../ai-studio/AiGeneration/Studio/Roles.js | 3 + packages/ai-studio/MANIFEST.json | 8 +- patches/14-ai-utils.patch | 90 ++++++++++++----- tests/integration/ai-byok-flow.test.mjs | 97 +++++++++++++++++++ 5 files changed, 211 insertions(+), 27 deletions(-) diff --git a/packages/ai-studio/AI/CustomAIClient.js b/packages/ai-studio/AI/CustomAIClient.js index 401569b..96a622c 100644 --- a/packages/ai-studio/AI/CustomAIClient.js +++ b/packages/ai-studio/AI/CustomAIClient.js @@ -3090,6 +3090,13 @@ Guidelines: `; + // T04 (#21): when no project JSON is present, no project is open. State the + // self-remedy explicitly so the model's first project-dependent call is + // `initialize_project` instead of a dead-end null failure. + if (!gameProjectJson) { + prompt += `\nNo project is currently open. You must call initialize_project(template_slug:'empty') to create one before using project tools.\n`; + } + if (gameProjectJson) { prompt += `\nCurrent Project Structure:\n${gameProjectJson}\n`; } @@ -4014,6 +4021,39 @@ export const LOCAL_PLAN_TOOL_NAMES: Array = ['create_or_update_plan']; export const isLocalPlanTool = (toolName: string): boolean => LOCAL_PLAN_TOOL_NAMES.includes(toolName); +/** + * T04 (#21) no-project self-remedy: project-dependent tools called with + * `project === null` (no project open) fail deterministically with a + * remedy the model can act on — calling `initialize_project` — instead of + * a dead-end null crash or a silent no-op. Routed by the Utils.js pre-pass + * before the editor dispatch; also surfaced in the system prompt when no + * project JSON is present. + */ +export const NO_PROJECT_MESSAGE: string = + "No project is currently open. You must call initialize_project(template_slug:'empty') to create one before using project tools."; + +export const PROJECT_FREE_TOOL_NAMES: Array = [ + 'initialize_project', + 'get_game_starter_summary', + 'search_docs', + 'read_full_docs', + 'search_object_asset_store', + 'search_resource_store', + 'create_or_update_plan', + 'spawn_agent', +]; + +export const isProjectDependentTool = (toolName: string): boolean => + typeof toolName === 'string' && + toolName.length > 0 && + !PROJECT_FREE_TOOL_NAMES.includes(toolName); + +export const buildNoProjectResult = (toolName: string): Object => ({ + success: false, + noProject: true, + message: `${NO_PROJECT_MESSAGE} (tool: ${toolName})`, +}); + export const withoutBackendOnlyTools = (tools: Array): Array => tools.filter( tool => diff --git a/packages/ai-studio/AiGeneration/Studio/Roles.js b/packages/ai-studio/AiGeneration/Studio/Roles.js index 5c6ed97..fc73a58 100644 --- a/packages/ai-studio/AiGeneration/Studio/Roles.js +++ b/packages/ai-studio/AiGeneration/Studio/Roles.js @@ -94,6 +94,7 @@ export const STUDIO_ROLES: { [StudioRoleId]: StudioRole } = { readOnly: false, maxTurns: 60, allowedToolNames: [ + 'initialize_project', 'create_or_update_plan', 'spawn_agent', 'read_game_project_json', @@ -110,6 +111,8 @@ export const STUDIO_ROLES: { [StudioRoleId]: StudioRole } = { ], systemPrompt: `You are the studio lead of a small game studio building a game in GDevelop. +If no project is currently open, your first action must be initialize_project with template_slug 'empty' (or the requested template) before planning or delegating. A plan or spawn without a project cannot succeed. + Your job is to plan and delegate. You never edit the project yourself - you have no tool to do so. Follow this loop, every time: diff --git a/packages/ai-studio/MANIFEST.json b/packages/ai-studio/MANIFEST.json index 0597b57..c8a689b 100644 --- a/packages/ai-studio/MANIFEST.json +++ b/packages/ai-studio/MANIFEST.json @@ -1,9 +1,9 @@ { "files": { "AI/CustomAIClient.js": { - "bytes": 210819, + "bytes": 212395, "origin": "newIDE/app/src/AI/CustomAIClient.js", - "sha256": "0934ccb0060cf2c5607ecb192d855189d3fdc679545ee41ad748cd81f642a64b" + "sha256": "28e24dde3cde3941f6ae514d415fcc5069ce2229f3d888e95aa767ebef7e22f1" }, "AI/CustomAIClient.spec.js": { "bytes": 257794, @@ -51,9 +51,9 @@ "sha256": "66a7d577f12fccaa1b23d89bc7ba02c9bca07058358cc53d3ecfe50d604ef1b8" }, "AiGeneration/Studio/Roles.js": { - "bytes": 12493, + "bytes": 12736, "origin": "newIDE/app/src/AiGeneration/Studio/Roles.js", - "sha256": "dac71987229c63fcea55fabd049b43ab48beff68cb19813de9d1c77090c6798f" + "sha256": "1ebb998a8038a964151d28b5af6c4d4fdd4dffee2d563d0b83e9964ba13f8457" }, "AiGeneration/Studio/SafeTruncation.js": { "bytes": 2743, diff --git a/patches/14-ai-utils.patch b/patches/14-ai-utils.patch index 1fd654d..96ed480 100644 --- a/patches/14-ai-utils.patch +++ b/patches/14-ai-utils.patch @@ -17,7 +17,7 @@ } from './AiRequestUtils'; import { useEnsureExtensionInstalled } from './UseEnsureExtensionInstalled'; import { useGenerateEvents } from './UseGenerateEvents'; -@@ -62,6 +63,37 @@ +@@ -62,6 +63,39 @@ import { makeSimplifiedProjectBuilder } from '../EditorFunctions/SimplifiedProject/SimplifiedProject'; import { prepareAiUserContent } from './PrepareAiUserContent'; import { extractGDevelopApiErrorStatusAndCode } from '../Utils/GDevelopServices/Errors'; @@ -27,6 +27,8 @@ + isOfflineDocsTool, + OFFLINE_DOCS_MESSAGE, + isLocalPlanTool, ++ isProjectDependentTool, ++ NO_PROJECT_MESSAGE, +} from '../AI/CustomAIClient'; +import { + isSpawnAgentCall, @@ -55,7 +57,7 @@ import UnsavedChangesContext from '../MainFrame/UnsavedChangesContext'; import { type FileMetadata, -@@ -213,12 +245,10 @@ +@@ -213,12 +247,10 @@ editorCallbacks: EditorCallbacks, |}): React.Node => { if (aiRequest.parentAiRequestId) { @@ -72,7 +74,7 @@ if (launchingCall) { return renderFunctionCallLabel({ functionCall: launchingCall, -@@ -244,6 +274,7 @@ +@@ -244,6 +276,7 @@ editorCallbacks, aiRequestsToProcess, onSendEditorFunctionCallResults, @@ -80,7 +82,7 @@ getEditorFunctionCallResults, addEditorFunctionCallResults, onSceneEventsModifiedOutsideEditor, -@@ -265,6 +296,9 @@ +@@ -265,6 +298,9 @@ suspendAiRequest, requestEditApproval, onSaveProjectAsWithStorageProvider, @@ -90,7 +92,7 @@ }: {| i18n: I18nType, project: ?gdProject, -@@ -280,7 +314,7 @@ +@@ -280,7 +316,7 @@ createdExternalLayoutNames?: Array, createdProject?: ?gdProject, |} @@ -99,7 +101,7 @@ getEditorFunctionCallResults: string => Array | null, addEditorFunctionCallResults: ( string, -@@ -319,6 +353,9 @@ +@@ -319,6 +355,9 @@ onWillInstallExtension: (extensionNames: Array) => void, onExtensionInstalled: (extensionNames: Array) => void, isReadyToProcessFunctionCalls: boolean, @@ -109,7 +111,7 @@ getIsAutoEditEnabled: () => boolean, suspendAiRequest: (aiRequestId: string) => Promise, requestEditApproval: (request: EditApprovalRequest) => Promise, -@@ -331,12 +368,30 @@ +@@ -331,12 +370,30 @@ createdProject?: gdProject, |} ) => Promise, @@ -140,7 +142,7 @@ } => { const { ensureExtensionInstalled } = useEnsureExtensionInstalled({ project, -@@ -424,6 +479,14 @@ +@@ -424,6 +481,14 @@ // `call:` (for a single direct modifying call like generate_events). const approvedEditBatchKeysRef = React.useRef>(new Set()); @@ -155,7 +157,7 @@ // Forget all previously-granted edit approvals so the next modifying call // asks again. Called when the user toggles auto-edit: turning it on then off // again means they want to review the upcoming edits, even within a sub-agent -@@ -440,6 +503,16 @@ +@@ -440,6 +505,16 @@ if (!isReadyToProcessFunctionCalls) return; if (aiRequest.status === 'suspended') return; @@ -172,7 +174,7 @@ const functionCallsToProcess = functionCalls.filter( functionCall => !inFlightFunctionCallIdsRef.current.has( -@@ -460,14 +533,60 @@ +@@ -460,14 +535,60 @@ ); }); @@ -235,7 +237,7 @@ // Gate project-modifying calls behind a user confirmation when auto-edit // is off. Read-only calls (exploration, inspection) always run. The first -@@ -493,10 +612,16 @@ +@@ -493,10 +614,16 @@ const modifyingFunctionCalls = functionCallsToProcess.filter( functionCall => doesFunctionCallModifyProject(functionCall) && @@ -252,7 +254,7 @@ !isCallApproved(functionCall) ); -@@ -554,13 +679,365 @@ +@@ -554,13 +681,406 @@ } } @@ -363,6 +365,44 @@ + } + } + ++ // T04 (#21): no-project self-remedy. With no project open every ++ // project-dependent tool fails deterministically with the ++ // initialize_project remedy instead of a null crash. Checked before ++ // the editor dispatch; initialize_project itself is never blocked. ++ const noProjectHandledCallIds = new Set(); ++ const noProjectResults: Array = []; ++ if (isCustomEndpointEnabled() && !project) { ++ for (const functionCall of functionCallsToProcess) { ++ if (studioDeniedCallIds.has(functionCall.call_id)) continue; ++ if (loopGuardHandledCallIds.has(functionCall.call_id)) continue; ++ const needsProject = ++ typeof isProjectDependentTool === 'function' ++ ? isProjectDependentTool(functionCall.name) ++ : functionCall.name !== 'initialize_project' && ++ functionCall.name !== 'get_game_starter_summary' && ++ functionCall.name !== 'search_docs' && ++ functionCall.name !== 'read_full_docs' && ++ functionCall.name !== 'search_object_asset_store' && ++ functionCall.name !== 'search_resource_store' && ++ functionCall.name !== 'create_or_update_plan' && ++ functionCall.name !== 'spawn_agent'; ++ if (needsProject) { ++ noProjectHandledCallIds.add(functionCall.call_id); ++ const noProjectDenial = { ++ status: 'finished', ++ call_id: functionCall.call_id, ++ success: false, ++ output: { ++ message: `${NO_PROJECT_MESSAGE} (tool: ${functionCall.name})`, ++ noProject: true, ++ }, ++ }; ++ noProjectResults.push(noProjectDenial); ++ addEditorFunctionCallResults(aiRequest.id, [noProjectDenial]); ++ } ++ } ++ } ++ + // T03 (#24): offline docs + local plan pre-pass. Both are dead upstream + // (EditorFunctions fails them as "handled server-side"), so they are + // routed locally with zero network I/O before the editor dispatch. A @@ -376,6 +416,7 @@ + for (const functionCall of functionCallsToProcess) { + if (studioDeniedCallIds.has(functionCall.call_id)) continue; + if (loopGuardHandledCallIds.has(functionCall.call_id)) continue; ++ if (noProjectHandledCallIds.has(functionCall.call_id)) continue; + const isDocs = + functionCall.name === 'search_docs' || + functionCall.name === 'read_full_docs' || @@ -578,7 +619,8 @@ + !loopGuardHandledCallIds.has(functionCall.call_id) && + !studioDeniedCallIds.has(functionCall.call_id) && + !offlineDocsHandledCallIds.has(functionCall.call_id) && -+ !localPlanHandledCallIds.has(functionCall.call_id) ++ !localPlanHandledCallIds.has(functionCall.call_id) && ++ !noProjectHandledCallIds.has(functionCall.call_id) + ); + // Mark only the editor calls as "working": a spawn (or a loop-guard hit) + // already has a terminal result, and a permanent "working" entry would @@ -601,6 +643,7 @@ + ...spawnFailureResults, + ...offlineDocsResults, + ...localPlanResults, ++ ...noProjectResults, + ]; + if (outcomeResults.length > 0) { + await onSendEditorFunctionCallResults( @@ -619,7 +662,7 @@ // The "modified outside editor" callbacks each refresh the editor and can // trigger an in-game editor hot reload. Firing them once per function -@@ -669,7 +1146,7 @@ +@@ -669,7 +1189,7 @@ // Explorer sub-agent scripts are read-only: restrict their // `run_script` to non-mutating functions (defense in depth). runScriptReadOnly: isReadOnlyScriptContext, @@ -628,7 +671,7 @@ name: functionCall.name, arguments: functionCall.arguments, call_id: functionCall.call_id, -@@ -761,13 +1238,54 @@ +@@ -761,13 +1281,55 @@ return; } @@ -674,6 +717,7 @@ + ...spawnFailureResults, + ...offlineDocsResults, + ...localPlanResults, ++ ...noProjectResults, + ...mergedResults, + ], + { @@ -688,7 +732,7 @@ } finally { // Flush the coalesced editor notifications for everything modified in // this batch (one hot reload instead of one per call). In `finally` so -@@ -787,7 +1305,9 @@ +@@ -787,7 +1349,9 @@ } // Release the lock so these calls can be retried if needed @@ -699,7 +743,7 @@ functionCallsToProcess.forEach(functionCall => { inFlightFunctionCallIdsRef.current.delete( `${aiRequest.id}:${functionCall.call_id}` -@@ -824,9 +1344,12 @@ +@@ -824,9 +1388,12 @@ getAssetStoreTagForNewObject, generateEvents, onSendEditorFunctionCallResults, @@ -712,7 +756,7 @@ ] ); -@@ -870,9 +1393,50 @@ +@@ -870,9 +1437,50 @@ [onProcessFunctionCalls, allFunctionCallsToProcess] ); @@ -763,7 +807,7 @@ }; }; -@@ -977,7 +1541,6 @@ +@@ -977,7 +1585,6 @@ editorFunctionCallResultsStorage, isFetchingSuggestions, setIsFetchingSuggestions, @@ -771,7 +815,7 @@ setSelectedAiRequestId, selectedAiRequest, } = React.useContext(AiRequestContext); -@@ -1036,20 +1599,19 @@ +@@ -1036,20 +1643,19 @@ // Then ask for some. if ( !selectedAiRequest || @@ -803,7 +847,7 @@ // Check if there are tools being run. If so, no suggestions at this time. const hasFunctionsCallsToProcess = -@@ -1139,7 +1701,7 @@ +@@ -1139,7 +1745,7 @@ : null; const preparedAiUserContent = await prepareAiUserContent({ getAuthorizationHeader, @@ -812,7 +856,7 @@ simplifiedProjectJson, projectSpecificExtensionsSummaryJson, eventsJson: null, -@@ -1153,7 +1715,7 @@ +@@ -1153,7 +1759,7 @@ const aiRequestWorkingForSuggestions = await getAiRequestSuggestions( getAuthorizationHeader, { @@ -821,7 +865,7 @@ aiRequestId: selectedAiRequest.id, suggestionsType: isLastMessageFunctionCallOutputProjectInitialization ? 'list-with-explanations' -@@ -1525,39 +2087,12 @@ +@@ -1525,39 +2131,12 @@ [profile, setSelectedAiRequestId] ); diff --git a/tests/integration/ai-byok-flow.test.mjs b/tests/integration/ai-byok-flow.test.mjs index fcdcfe6..d80b141 100644 --- a/tests/integration/ai-byok-flow.test.mjs +++ b/tests/integration/ai-byok-flow.test.mjs @@ -488,3 +488,100 @@ describe('integration: T03 tool manifest pruning (no dead tool calls)', () => { ); }); }); + +describe('integration: T04 no-project self-remedy (null project)', () => { + it('project-dependent tools fail closed with the initialize_project remedy', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, resolve } = await import('node:path'); + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const src = readFileSync( + resolve(root, 'packages/ai-studio/AI/CustomAIClient.js'), + 'utf8' + ); + // Deterministic self-remedying error text (exact match on the contract). + assert.ok(src.includes('NO_PROJECT_MESSAGE'), 'NO_PROJECT_MESSAGE export missing'); + assert.ok( + src.includes("No project is currently open. You must call initialize_project(template_slug:'empty')"), + 'no-project remedy text missing or reworded' + ); + assert.ok(src.includes('isProjectDependentTool'), 'isProjectDependentTool helper missing'); + assert.ok(src.includes('buildNoProjectResult'), 'buildNoProjectResult helper missing'); + assert.ok(src.includes('PROJECT_FREE_TOOL_NAMES'), 'PROJECT_FREE_TOOL_NAMES missing'); + const helperAt = src.indexOf('export const buildNoProjectResult'); + assert.ok(helperAt !== -1, 'buildNoProjectResult export missing'); + const helperSlice = src.slice(helperAt, helperAt + 2000); + assert.ok(helperSlice.includes('success: false'), 'no-project result must be success:false'); + assert.ok(helperSlice.includes('noProject: true'), 'no-project result must set noProject:true'); + assert.ok(!helperSlice.includes('fetch('), 'no-project result must not fetch'); + // initialize_project itself is never project-dependent (it IS the remedy). + const freeAt = src.indexOf('PROJECT_FREE_TOOL_NAMES'); + const freeSlice = src.slice(freeAt, freeAt + 1200); + assert.ok(freeSlice.includes("'initialize_project'"), 'initialize_project must be project-free'); + // System prompt states the remedy when no project JSON is present. + assert.ok( + src.includes("No project is currently open. You must call initialize_project(template_slug:'empty') to create one before using project tools."), + 'buildSystemPrompt must state the no-project remedy' + ); + }); + + it('manager role leads with initialize_project when no project is open', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, resolve } = await import('node:path'); + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const roles = readFileSync( + resolve(root, 'packages/ai-studio/AiGeneration/Studio/Roles.js'), + 'utf8' + ); + const managerAt = roles.indexOf('manager: {'); + assert.ok(managerAt !== -1, 'manager role missing'); + const managerSlice = roles.slice(managerAt, managerAt + 4000); + // initialize_project prominent: first in the manager tool manifest. + const initAt = managerSlice.indexOf("'initialize_project'"); + assert.ok(initAt !== -1, 'initialize_project missing from manager allowedToolNames'); + const planAt = managerSlice.indexOf("'create_or_update_plan'"); + assert.ok(planAt !== -1, 'manager plan tool missing'); + assert.ok( + initAt < planAt, + 'initialize_project must precede create_or_update_plan in the manager manifest (prominent)' + ); + // First-action rule in the manager prompt. + assert.ok( + managerSlice.includes('your first action must be initialize_project'), + 'manager prompt must order initialize_project first when no project is open' + ); + }); + + it('patch 14 routes null-project calls to the self-remedy before the editor', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, resolve } = await import('node:path'); + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const patch14 = readFileSync(resolve(root, 'patches/14-ai-utils.patch'), 'utf8'); + for (const marker of [ + 'noProjectHandledCallIds', + 'noProjectResults', + 'isProjectDependentTool', + 'NO_PROJECT_MESSAGE', + ]) { + assert.ok(patch14.includes(marker), `patch 14 missing T04 marker: ${marker}`); + } + assert.ok( + patch14.includes('isCustomEndpointEnabled() && !project'), + 'no-project pre-pass must gate on !project under BYOK' + ); + assert.ok( + patch14.includes('!noProjectHandledCallIds.has(functionCall.call_id)'), + 'no-project calls must be excluded from the editor dispatch' + ); + assert.ok( + patch14.includes('...noProjectResults,'), + 'no-project results must be sent to the model' + ); + const preAt = patch14.indexOf('T04 (#21)'); + assert.ok(preAt !== -1, 'T04 pre-pass comment missing'); + const preSlice = patch14.slice(preAt, preAt + 4000); + assert.ok(!preSlice.includes('fetch('), 'no-project pre-pass must not fetch'); + }); +});