Merge ticket-T04 (T04 No-Project Fix #21) into integration/v0.2

This commit is contained in:
BillyOutlast committed 2026-10-12 00:39:10 -04:00
commit 1d884d12c2
5 files changed
+211 -27

No files matched your search

+40
View File
@@ -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<string> = ['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<string> = [
'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<Object>): Array<Object> =>
tools.filter(
tool =>
@@ -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:
+4 -4
View File
@@ -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,
+67 -23
View File
@@ -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<string>,
createdProject?: ?gdProject,
|}
@@ -99,7 +101,7 @@
getEditorFunctionCallResults: string => Array<EditorFunctionCallResult> | null,
addEditorFunctionCallResults: (
string,
@@ -319,6 +353,9 @@
@@ -319,6 +355,9 @@
onWillInstallExtension: (extensionNames: Array<string>) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
isReadyToProcessFunctionCalls: boolean,
@@ -109,7 +111,7 @@
getIsAutoEditEnabled: () => boolean,
suspendAiRequest: (aiRequestId: string) => Promise<void>,
requestEditApproval: (request: EditApprovalRequest) => Promise<boolean>,
@@ -331,12 +368,30 @@
@@ -331,12 +370,30 @@
createdProject?: gdProject,
|}
) => Promise<?FileMetadata>,
@@ -140,7 +142,7 @@
} => {
const { ensureExtensionInstalled } = useEnsureExtensionInstalled({
project,
@@ -424,6 +479,14 @@
@@ -424,6 +481,14 @@
// `call:<callId>` (for a single direct modifying call like generate_events).
const approvedEditBatchKeysRef = React.useRef<Set<string>>(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<string>();
+ const noProjectResults: Array<EditorFunctionCallResult> = [];
+ 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]
);
+97
View File
@@ -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');
});
});