T03: Tool Manifest Pruning (#24) - offline docs fail-closed + PlanStore plan routing

- CustomAIClient: search_docs/read_full_docs offline suffix + OFFLINE_DOCS helpers
- Patch 14: local pre-pass for docs (offline:true, no network) + create_or_update_plan via PlanStore validate/merge
- Tests: 3 new assertions in ai-byok-flow.test.mjs proving no dead tool calls
- MANIFEST: recompute CustomAIClient hash/bytes
This commit is contained in:
BillyOutlast committed 2026-10-12 00:37:45 -04:00
1 parent 67a4be1b29
commit 0276353b64
4 files changed
+312 -28

No files matched your search

+46 -2
View File
@@ -2086,7 +2086,7 @@ export const GDEVELOP_OPENAI_TOOLS: Array<{|
function: {
name: 'search_docs',
description:
'Search GDevelop documentation for functions, behaviors, and instructions.',
'Search GDevelop documentation for functions, behaviors, and instructions. [Offline in local BYOK mode: rely on internal GDevelop knowledge and on-project verification]',
parameters: {
type: 'object',
properties: {
@@ -2101,7 +2101,7 @@ export const GDEVELOP_OPENAI_TOOLS: Array<{|
function: {
name: 'read_full_docs',
description:
'Read full documentation for specific GDevelop extensions or features.',
'Read full documentation for specific GDevelop extensions or features. [Offline in local BYOK mode: rely on internal GDevelop knowledge and on-project verification]',
parameters: {
type: 'object',
properties: {
@@ -3970,6 +3970,50 @@ export const BACKEND_ONLY_TOOL_NAMES: Array<string> = [
'get_game_starter_summary',
];
/**
* T03 (#24) offline docs tools: the hosted docs backend is unreachable in
* local BYOK mode. Their descriptions carry the offline suffix (above) and
* every local call fails closed immediately with no network lag — see
* `buildOfflineDocsResult` and the Utils.js pre-pass that routes them
* before the editor dispatch.
*/
export const OFFLINE_DOCS_SUFFIX: string =
' [Offline in local BYOK mode: rely on internal GDevelop knowledge and on-project verification]';
export const OFFLINE_DOCS_MESSAGE: string =
'Documentation backend is offline in local mode. Rely on internal GDevelop knowledge and verify against the open project.';
export const OFFLINE_DOCS_TOOL_NAMES: Array<string> = [
'search_docs',
'read_full_docs',
];
export const isOfflineDocsTool = (toolName: string): boolean =>
OFFLINE_DOCS_TOOL_NAMES.includes(toolName);
/**
* Fail-closed result for an offline docs call. Synchronous with no network
* I/O so a docs lookup can never stall a local turn.
*/
export const buildOfflineDocsResult = (toolName: string): Object => ({
success: false,
offline: true,
message: `${OFFLINE_DOCS_MESSAGE} (tool: ${toolName})`,
});
/**
* T03 (#24) local plan tool: `create_or_update_plan` is uncallable upstream
* (`EditorFunctions/index.js` fails it as "handled server-side"). Under BYOK
* it is routed to `Studio/PlanStore.js` (validate + merge over the request's
* existing plan) by the Utils.js pre-pass — never to the editor dispatch.
* Kept in `GDEVELOP_OPENAI_TOOLS` so the manager keeps planning; the pre-pass
* is what makes it a live tool instead of a dead one.
*/
export const LOCAL_PLAN_TOOL_NAMES: Array<string> = ['create_or_update_plan'];
export const isLocalPlanTool = (toolName: string): boolean =>
LOCAL_PLAN_TOOL_NAMES.includes(toolName);
export const withoutBackendOnlyTools = (tools: Array<Object>): Array<Object> =>
tools.filter(
tool =>
+3 -3
View File
@@ -1,9 +1,9 @@
{
"files": {
"AI/CustomAIClient.js": {
"bytes": 208828,
"bytes": 210819,
"origin": "newIDE/app/src/AI/CustomAIClient.js",
"sha256": "2dd976b936908115d08374851121a077d2b2b6c314a029a4bc52ab7125f51723"
"sha256": "0934ccb0060cf2c5607ecb192d855189d3fdc679545ee41ad748cd81f642a64b"
},
"AI/CustomAIClient.spec.js": {
"bytes": 257794,
@@ -102,5 +102,5 @@
"sha256": "45c2915841b0a6c0f17db7a297a39ca1dfb8b889d74f018f87d1815cadf17c26"
}
},
"provenance": "Historical origin only: extracted in 2026-10 from the Heretek fork (now retired). This repo never reads the donor \u2014 integrity is self-contained via the pinned bytes/sha256 below."
"provenance": "Historical origin only: extracted in 2026-10 from the Heretek fork (now retired). This repo never reads the donor — integrity is self-contained via the pinned bytes/sha256 below."
}
+118 -23
View File
@@ -17,13 +17,16 @@
} from './AiRequestUtils';
import { useEnsureExtensionInstalled } from './UseEnsureExtensionInstalled';
import { useGenerateEvents } from './UseGenerateEvents';
@@ -62,6 +63,31 @@
@@ -62,6 +63,37 @@
import { makeSimplifiedProjectBuilder } from '../EditorFunctions/SimplifiedProject/SimplifiedProject';
import { prepareAiUserContent } from './PrepareAiUserContent';
import { extractGDevelopApiErrorStatusAndCode } from '../Utils/GDevelopServices/Errors';
+import {
+ isCustomEndpointEnabled,
+ LOCAL_BYOK_USER_ID,
+ isOfflineDocsTool,
+ OFFLINE_DOCS_MESSAGE,
+ isLocalPlanTool,
+} from '../AI/CustomAIClient';
+import {
+ isSpawnAgentCall,
@@ -35,6 +38,9 @@
+import { createLoopGuard } from './Studio/LoopGuard';
+import {
+ mergePlanResultOutput,
+ mergePlanTasks,
+ validatePlanTasks,
+ buildPlanOutput,
+ patchPlanTaskAsDelegated,
+ replacePlanMessage,
+} from './Studio/PlanStore';
@@ -49,7 +55,7 @@
import UnsavedChangesContext from '../MainFrame/UnsavedChangesContext';
import {
type FileMetadata,
@@ -213,12 +239,10 @@
@@ -213,12 +245,10 @@
editorCallbacks: EditorCallbacks,
|}): React.Node => {
if (aiRequest.parentAiRequestId) {
@@ -66,7 +72,7 @@
if (launchingCall) {
return renderFunctionCallLabel({
functionCall: launchingCall,
@@ -244,6 +268,7 @@
@@ -244,6 +274,7 @@
editorCallbacks,
aiRequestsToProcess,
onSendEditorFunctionCallResults,
@@ -74,7 +80,7 @@
getEditorFunctionCallResults,
addEditorFunctionCallResults,
onSceneEventsModifiedOutsideEditor,
@@ -265,6 +290,9 @@
@@ -265,6 +296,9 @@
suspendAiRequest,
requestEditApproval,
onSaveProjectAsWithStorageProvider,
@@ -84,7 +90,7 @@
}: {|
i18n: I18nType,
project: ?gdProject,
@@ -280,7 +308,7 @@
@@ -280,7 +314,7 @@
createdExternalLayoutNames?: Array<string>,
createdProject?: ?gdProject,
|}
@@ -93,7 +99,7 @@
getEditorFunctionCallResults: string => Array<EditorFunctionCallResult> | null,
addEditorFunctionCallResults: (
string,
@@ -319,6 +347,9 @@
@@ -319,6 +353,9 @@
onWillInstallExtension: (extensionNames: Array<string>) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
isReadyToProcessFunctionCalls: boolean,
@@ -103,7 +109,7 @@
getIsAutoEditEnabled: () => boolean,
suspendAiRequest: (aiRequestId: string) => Promise<void>,
requestEditApproval: (request: EditApprovalRequest) => Promise<boolean>,
@@ -331,12 +362,30 @@
@@ -331,12 +368,30 @@
createdProject?: gdProject,
|}
) => Promise<?FileMetadata>,
@@ -134,7 +140,7 @@
} => {
const { ensureExtensionInstalled } = useEnsureExtensionInstalled({
project,
@@ -424,6 +473,14 @@
@@ -424,6 +479,14 @@
// `call:<callId>` (for a single direct modifying call like generate_events).
const approvedEditBatchKeysRef = React.useRef<Set<string>>(new Set());
@@ -149,7 +155,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 +497,16 @@
@@ -440,6 +503,16 @@
if (!isReadyToProcessFunctionCalls) return;
if (aiRequest.status === 'suspended') return;
@@ -166,7 +172,7 @@
const functionCallsToProcess = functionCalls.filter(
functionCall =>
!inFlightFunctionCallIdsRef.current.has(
@@ -460,14 +527,60 @@
@@ -460,14 +533,60 @@
);
});
@@ -229,7 +235,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 +606,16 @@
@@ -493,10 +612,16 @@
const modifyingFunctionCalls = functionCallsToProcess.filter(
functionCall =>
doesFunctionCallModifyProject(functionCall) &&
@@ -246,7 +252,7 @@
!isCallApproved(functionCall)
);
@@ -554,13 +673,278 @@
@@ -554,13 +679,365 @@
}
}
@@ -357,6 +363,89 @@
+ }
+ }
+
+ // 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
+ // docs call fails closed with offline:true; a plan call validates via
+ // PlanStore and folds over the existing plan (preserving agentCallId).
+ const offlineDocsHandledCallIds = new Set<string>();
+ const offlineDocsResults: Array<EditorFunctionCallResult> = [];
+ const localPlanHandledCallIds = new Set<string>();
+ const localPlanResults: Array<EditorFunctionCallResult> = [];
+ if (isCustomEndpointEnabled()) {
+ for (const functionCall of functionCallsToProcess) {
+ if (studioDeniedCallIds.has(functionCall.call_id)) continue;
+ if (loopGuardHandledCallIds.has(functionCall.call_id)) continue;
+ const isDocs =
+ functionCall.name === 'search_docs' ||
+ functionCall.name === 'read_full_docs' ||
+ (typeof isOfflineDocsTool === 'function' &&
+ isOfflineDocsTool(functionCall.name));
+ if (isDocs) {
+ offlineDocsHandledCallIds.add(functionCall.call_id);
+ const docsDenial = {
+ status: 'finished',
+ call_id: functionCall.call_id,
+ success: false,
+ output: {
+ message: `${OFFLINE_DOCS_MESSAGE} (tool: ${functionCall.name})`,
+ offline: true,
+ },
+ };
+ offlineDocsResults.push(docsDenial);
+ addEditorFunctionCallResults(aiRequest.id, [docsDenial]);
+ continue;
+ }
+ const isPlan =
+ functionCall.name === 'create_or_update_plan' ||
+ (typeof isLocalPlanTool === 'function' &&
+ isLocalPlanTool(functionCall.name));
+ if (isPlan) {
+ localPlanHandledCallIds.add(functionCall.call_id);
+ let planResult;
+ try {
+ const parsedPlanArgs = JSON.parse(functionCall.arguments || '{}');
+ const validation = validatePlanTasks(
+ parsedPlanArgs ? parsedPlanArgs.tasks : null
+ );
+ if (!validation.success) {
+ planResult = {
+ status: 'finished',
+ call_id: functionCall.call_id,
+ success: false,
+ output: { message: validation.message },
+ };
+ } else {
+ const currentPlan = getLatestActivePlan(aiRequest);
+ const currentTasks =
+ currentPlan && Array.isArray(currentPlan.tasks)
+ ? currentPlan.tasks
+ : [];
+ const merged =
+ currentTasks.length > 0
+ ? mergePlanTasks(currentTasks, validation.tasks)
+ : validation.tasks;
+ planResult = {
+ status: 'finished',
+ call_id: functionCall.call_id,
+ success: true,
+ output: buildPlanOutput(merged),
+ };
+ }
+ } catch (error) {
+ planResult = {
+ status: 'finished',
+ call_id: functionCall.call_id,
+ success: false,
+ output: { message: 'Invalid plan arguments: not valid JSON.' },
+ };
+ }
+ localPlanResults.push(planResult);
+ addEditorFunctionCallResults(aiRequest.id, [planResult]);
+ }
+ }
+ }
+
+ // The local studio's delegation tool is not an editor function: it creates
+ // a child AI request and stamps the call so the shared loop stops treating
+ // it as a call for the editor to execute (`getFunctionCallsToProcess`
@@ -487,7 +576,9 @@
+ functionCall =>
+ !spawnHandledCallIds.has(functionCall.call_id) &&
+ !loopGuardHandledCallIds.has(functionCall.call_id) &&
+ !studioDeniedCallIds.has(functionCall.call_id)
+ !studioDeniedCallIds.has(functionCall.call_id) &&
+ !offlineDocsHandledCallIds.has(functionCall.call_id) &&
+ !localPlanHandledCallIds.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
@@ -508,6 +599,8 @@
+ ...studioDeniedResults,
+ ...loopGuardResults,
+ ...spawnFailureResults,
+ ...offlineDocsResults,
+ ...localPlanResults,
+ ];
+ if (outcomeResults.length > 0) {
+ await onSendEditorFunctionCallResults(
@@ -526,7 +619,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 +1053,7 @@
@@ -669,7 +1146,7 @@
// Explorer sub-agent scripts are read-only: restrict their
// `run_script` to non-mutating functions (defense in depth).
runScriptReadOnly: isReadOnlyScriptContext,
@@ -535,7 +628,7 @@
name: functionCall.name,
arguments: functionCall.arguments,
call_id: functionCall.call_id,
@@ -761,13 +1145,52 @@
@@ -761,13 +1238,54 @@
return;
}
@@ -579,6 +672,8 @@
+ ...studioDeniedResults,
+ ...loopGuardResults,
+ ...spawnFailureResults,
+ ...offlineDocsResults,
+ ...localPlanResults,
+ ...mergedResults,
+ ],
+ {
@@ -593,7 +688,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 +1210,9 @@
@@ -787,7 +1305,9 @@
}
// Release the lock so these calls can be retried if needed
@@ -604,7 +699,7 @@
functionCallsToProcess.forEach(functionCall => {
inFlightFunctionCallIdsRef.current.delete(
`${aiRequest.id}:${functionCall.call_id}`
@@ -824,9 +1249,12 @@
@@ -824,9 +1344,12 @@
getAssetStoreTagForNewObject,
generateEvents,
onSendEditorFunctionCallResults,
@@ -617,7 +712,7 @@
]
);
@@ -870,9 +1298,50 @@
@@ -870,9 +1393,50 @@
[onProcessFunctionCalls, allFunctionCallsToProcess]
);
@@ -668,7 +763,7 @@
};
};
@@ -977,7 +1446,6 @@
@@ -977,7 +1541,6 @@
editorFunctionCallResultsStorage,
isFetchingSuggestions,
setIsFetchingSuggestions,
@@ -676,7 +771,7 @@
setSelectedAiRequestId,
selectedAiRequest,
} = React.useContext(AiRequestContext);
@@ -1036,20 +1504,19 @@
@@ -1036,20 +1599,19 @@
// Then ask for some.
if (
!selectedAiRequest ||
@@ -708,7 +803,7 @@
// Check if there are tools being run. If so, no suggestions at this time.
const hasFunctionsCallsToProcess =
@@ -1139,7 +1606,7 @@
@@ -1139,7 +1701,7 @@
: null;
const preparedAiUserContent = await prepareAiUserContent({
getAuthorizationHeader,
@@ -717,7 +812,7 @@
simplifiedProjectJson,
projectSpecificExtensionsSummaryJson,
eventsJson: null,
@@ -1153,7 +1620,7 @@
@@ -1153,7 +1715,7 @@
const aiRequestWorkingForSuggestions = await getAiRequestSuggestions(
getAuthorizationHeader,
{
@@ -726,7 +821,7 @@
aiRequestId: selectedAiRequest.id,
suggestionsType: isLastMessageFunctionCallOutputProjectInitialization
? 'list-with-explanations'
@@ -1525,39 +1992,12 @@
@@ -1525,39 +2087,12 @@
[profile, setSelectedAiRequestId]
);
+145
View File
@@ -343,3 +343,148 @@ describe('integration: BYOK tool schemas, zero-egress routing, patch-level leak
}
});
});
describe('integration: T03 tool manifest pruning (no dead tool calls)', () => {
it('search_docs/read_full_docs carry the offline suffix and fail closed with no network', 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'
);
const suffix =
'[Offline in local BYOK mode: rely on internal GDevelop knowledge and on-project verification]';
assert.ok(
src.includes(suffix),
'offline suffix missing from CustomAIClient tool descriptions'
);
// Both docs tools must carry it.
const searchAt = src.indexOf("name: 'search_docs'");
const readAt = src.indexOf("name: 'read_full_docs'");
assert.ok(searchAt !== -1 && readAt !== -1, 'docs tools missing from schema');
assert.ok(
src.slice(searchAt, searchAt + 800).includes(suffix),
'search_docs description lacks offline suffix'
);
assert.ok(
src.slice(readAt, readAt + 800).includes(suffix),
'read_full_docs description lacks offline suffix'
);
// Fail-closed helper: synchronous, offline:true, exact message, no fetch.
assert.ok(
src.includes('OFFLINE_DOCS_MESSAGE'),
'OFFLINE_DOCS_MESSAGE export missing'
);
assert.ok(
src.includes('Documentation backend is offline in local mode'),
'offline docs message text missing'
);
assert.ok(
src.includes('buildOfflineDocsResult'),
'buildOfflineDocsResult helper missing'
);
assert.ok(
src.includes('isOfflineDocsTool'),
'isOfflineDocsTool helper missing'
);
const helperAt = src.indexOf('export const buildOfflineDocsResult');
assert.ok(helperAt !== -1, 'buildOfflineDocsResult export missing');
const helperSlice = src.slice(helperAt, helperAt + 2000);
assert.ok(helperSlice.includes('offline: true'), 'docs result must set offline:true');
assert.ok(helperSlice.includes('success: false'), 'docs result must be success:false');
assert.ok(!helperSlice.includes('fetch('), 'offline docs result must not fetch (no network lag)');
assert.ok(!helperSlice.includes('axios'), 'offline docs result must not use axios');
// Zero egress: the offline message path never mentions the hosted API.
assert.ok(
!src.includes('api.gdevelop.io'),
'BYOK client must not hardcode the hosted API'
);
});
it('create_or_update_plan routes to PlanStore locally, never to upstream EditorFunctions', 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 client = readFileSync(
resolve(root, 'packages/ai-studio/AI/CustomAIClient.js'),
'utf8'
);
const patch14 = readFileSync(resolve(root, 'patches/14-ai-utils.patch'), 'utf8');
// Schema keeps the tool so the manager keeps planning.
assert.ok(
client.includes("name: 'create_or_update_plan'"),
'plan tool missing from GDEVELOP_OPENAI_TOOLS'
);
assert.ok(
client.includes('isLocalPlanTool'),
'isLocalPlanTool marker missing (T03 plan routing)'
);
// Patch 14 pre-pass: local PlanStore routing before the editor dispatch.
for (const marker of [
'offlineDocsHandledCallIds',
'localPlanHandledCallIds',
'validatePlanTasks',
'buildPlanOutput',
'mergePlanTasks',
'OFFLINE_DOCS_MESSAGE',
]) {
assert.ok(patch14.includes(marker), `patch 14 missing T03 marker: ${marker}`);
}
// Both pre-pass results are excluded from the editor and surfaced to the model.
assert.ok(
patch14.includes('!offlineDocsHandledCallIds.has(functionCall.call_id)'),
'docs calls must be excluded from editor dispatch'
);
assert.ok(
patch14.includes('!localPlanHandledCallIds.has(functionCall.call_id)'),
'plan calls must be excluded from editor dispatch'
);
assert.ok(
patch14.includes('...offlineDocsResults,'),
'docs results must be sent to the model'
);
assert.ok(
patch14.includes('...localPlanResults,'),
'plan results must be sent to the model'
);
// No network in the T03 pre-pass: docs fail closed synchronously.
const preAt = patch14.indexOf('T03 (#24)');
assert.ok(preAt !== -1, 'T03 pre-pass comment missing in patch 14');
const preSlice = patch14.slice(preAt, preAt + 6000);
assert.ok(!preSlice.includes('fetch('), 'T03 pre-pass must not fetch');
});
it('every GDEVELOP_OPENAI_TOOLS entry is dispatchable locally (no dead tools)', 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'
);
const patch14 = readFileSync(resolve(root, 'patches/14-ai-utils.patch'), 'utf8');
const toolRe =
/\{\s*type:\s*'function',\s*function:\s*\{\s*name:\s*'([^']+)',\s*description:\s*('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")/g;
const names = [];
let m;
while ((m = toolRe.exec(src)) !== null) names.push(m[1]);
assert.equal(names.length, 39, `expected 39 tools, found ${names.length}`);
// The three historically dead tools now have explicit local handling.
for (const dead of ['search_docs', 'read_full_docs', 'create_or_update_plan']) {
assert.ok(names.includes(dead), `tool missing from schema: ${dead}`);
assert.ok(
patch14.includes(dead),
`patch 14 has no local route for historically dead tool: ${dead}`
);
}
// Backend-only tools stay in schema for hosted turns but are withheld locally.
assert.ok(
src.includes('BACKEND_ONLY_TOOL_NAMES'),
'backend-only filter missing'
);
});
});