mirror of
https://github.com/Heretek-AI/GDevelop-Unlocked.git
synced 2026-11-07 10:55:58 -10:00
Merge ticket-T06 (T06 Storm Guard Isolation #23) into integration/v0.2
This commit is contained in:
commit
e862a0087f
4 files changed
+137
-13
No files matched your search
@@ -6,6 +6,15 @@ import { emitStudioDebugEvent } from './DebugEvents';
|
||||
/**
|
||||
* The local loop guard: a circuit breaker for a BYOK agent.
|
||||
*
|
||||
* Scope is strictly single-request (T06 #23): one guard instance per
|
||||
* `aiRequest.id` (see `loopGuardsRef` in Utils.js), counting only that
|
||||
* request's own turns — consecutive identical calls (repeat), consecutive
|
||||
* failures (error storm), turns without user input, and turns without
|
||||
* project progress (repeat-without-progress, sub-agents only). A child's
|
||||
* startup failure never touches its parent's streak and never constrains
|
||||
* the parent: `spawnSubAgent` failures fail only their own `spawn_agent`
|
||||
* tool call (`success:false`) so the parent stays alive for serial fallback.
|
||||
*
|
||||
* Ported from munder-difflin's `src/main/breaker.ts`, narrowed to what this
|
||||
* editor can actually observe. Under BYOK there is no server, so nothing counts
|
||||
* a runaway local agent: the hosted `repeated-tool-call-loop` error can never
|
||||
@@ -44,6 +53,13 @@ export type LoopGuardDecision = {|
|
||||
changed: boolean,
|
||||
|};
|
||||
|
||||
/**
|
||||
* T06 (#23) isolation scope marker: guards are per-request only. A test
|
||||
* asserts this constant exists so the single-request + repeat-without-
|
||||
* progress contract cannot silently regress to a shared/global guard.
|
||||
*/
|
||||
export const LOOP_GUARD_SCOPE: string = 'single-request';
|
||||
|
||||
export const DEFAULT_LOOP_GUARD_CONFIG = {
|
||||
enabled: true,
|
||||
/** Trips on the Nth consecutive identical call; N-1 are tolerated. */
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
"sha256": "708de36210d000af56e795280eaa5dd450e32cedb4a54b7b031353a6cc4a679f"
|
||||
},
|
||||
"AiGeneration/Studio/LoopGuard.js": {
|
||||
"bytes": 10420,
|
||||
"bytes": 11296,
|
||||
"origin": "newIDE/app/src/AiGeneration/Studio/LoopGuard.js",
|
||||
"sha256": "09bde299559edc5b9c5f74d7605c7c4ef8ba0eeb4f044168171183f51676b86d"
|
||||
"sha256": "8fd346b75556051e7f03a15066ab562168f2028d5fca028583d9e7449be9b1ee"
|
||||
},
|
||||
"AiGeneration/Studio/NudgePolicy.js": {
|
||||
"bytes": 3821,
|
||||
|
||||
+24
-11
@@ -254,7 +254,7 @@
|
||||
!isCallApproved(functionCall)
|
||||
);
|
||||
|
||||
@@ -554,13 +681,406 @@
|
||||
@@ -554,13 +681,409 @@
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,6 +567,9 @@
|
||||
+ });
|
||||
+ } catch (error) {
|
||||
+ console.error('Error while spawning a studio sub-agent:', error);
|
||||
+ // T06 (#23): a child startup failure fails ONLY this tool call.
|
||||
+ // It never touches the parent guard streak and never suspends or
|
||||
+ // constrains the parent: the parent stays alive for serial fallback.
|
||||
+ spawnResult = { error: 'The sub-agent could not be started.' };
|
||||
+ }
|
||||
+
|
||||
@@ -662,7 +665,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 +1189,7 @@
|
||||
@@ -669,7 +1192,7 @@
|
||||
// Explorer sub-agent scripts are read-only: restrict their
|
||||
// `run_script` to non-mutating functions (defense in depth).
|
||||
runScriptReadOnly: isReadOnlyScriptContext,
|
||||
@@ -671,15 +674,25 @@
|
||||
name: functionCall.name,
|
||||
arguments: functionCall.arguments,
|
||||
call_id: functionCall.call_id,
|
||||
@@ -761,13 +1281,55 @@
|
||||
@@ -761,13 +1284,65 @@
|
||||
return;
|
||||
}
|
||||
|
||||
+ // Record outcomes for the loop guard's error-storm trip (a real success
|
||||
+ // clears it; a failure advances it).
|
||||
+ // T06 (#23) isolation: ONLY editor-dispatched calls feed the parent
|
||||
+ // guard. Spawn failures, offline-docs, local-plan, no-project, role
|
||||
+ // denials, and loop-guard warnings already have terminal results and
|
||||
+ // must never advance the parent error streak nor constrain it — a
|
||||
+ // child that fails to start fails its own call; the parent retries
|
||||
+ // serially instead of suspending.
|
||||
+ if (guard) {
|
||||
+ const editorCallIds = new Set(
|
||||
+ callsForEditor.map(functionCall => functionCall.call_id)
|
||||
+ );
|
||||
+ results.forEach(result => {
|
||||
+ if (result.status !== 'finished') return;
|
||||
+ if (!editorCallIds.has(result.call_id)) return;
|
||||
+ if (result.success) guard.recordSuccess();
|
||||
+ else guard.recordError();
|
||||
+ });
|
||||
@@ -732,7 +745,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 +1349,9 @@
|
||||
@@ -787,7 +1362,9 @@
|
||||
}
|
||||
|
||||
// Release the lock so these calls can be retried if needed
|
||||
@@ -743,7 +756,7 @@
|
||||
functionCallsToProcess.forEach(functionCall => {
|
||||
inFlightFunctionCallIdsRef.current.delete(
|
||||
`${aiRequest.id}:${functionCall.call_id}`
|
||||
@@ -824,9 +1388,12 @@
|
||||
@@ -824,9 +1401,12 @@
|
||||
getAssetStoreTagForNewObject,
|
||||
generateEvents,
|
||||
onSendEditorFunctionCallResults,
|
||||
@@ -756,7 +769,7 @@
|
||||
]
|
||||
);
|
||||
|
||||
@@ -870,9 +1437,50 @@
|
||||
@@ -870,9 +1450,50 @@
|
||||
[onProcessFunctionCalls, allFunctionCallsToProcess]
|
||||
);
|
||||
|
||||
@@ -807,7 +820,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
@@ -977,7 +1585,6 @@
|
||||
@@ -977,7 +1598,6 @@
|
||||
editorFunctionCallResultsStorage,
|
||||
isFetchingSuggestions,
|
||||
setIsFetchingSuggestions,
|
||||
@@ -815,7 +828,7 @@
|
||||
setSelectedAiRequestId,
|
||||
selectedAiRequest,
|
||||
} = React.useContext(AiRequestContext);
|
||||
@@ -1036,20 +1643,19 @@
|
||||
@@ -1036,20 +1656,19 @@
|
||||
// Then ask for some.
|
||||
if (
|
||||
!selectedAiRequest ||
|
||||
@@ -847,7 +860,7 @@
|
||||
|
||||
// Check if there are tools being run. If so, no suggestions at this time.
|
||||
const hasFunctionsCallsToProcess =
|
||||
@@ -1139,7 +1745,7 @@
|
||||
@@ -1139,7 +1758,7 @@
|
||||
: null;
|
||||
const preparedAiUserContent = await prepareAiUserContent({
|
||||
getAuthorizationHeader,
|
||||
@@ -856,7 +869,7 @@
|
||||
simplifiedProjectJson,
|
||||
projectSpecificExtensionsSummaryJson,
|
||||
eventsJson: null,
|
||||
@@ -1153,7 +1759,7 @@
|
||||
@@ -1153,7 +1772,7 @@
|
||||
const aiRequestWorkingForSuggestions = await getAiRequestSuggestions(
|
||||
getAuthorizationHeader,
|
||||
{
|
||||
@@ -865,7 +878,7 @@
|
||||
aiRequestId: selectedAiRequest.id,
|
||||
suggestionsType: isLastMessageFunctionCallOutputProjectInitialization
|
||||
? 'list-with-explanations'
|
||||
@@ -1525,39 +2131,12 @@
|
||||
@@ -1525,39 +2144,12 @@
|
||||
[profile, setSelectedAiRequestId]
|
||||
);
|
||||
|
||||
|
||||
@@ -690,3 +690,98 @@ describe('integration: T05 suspended UI and resume (terminal status card)', () =
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration: T06 storm guard isolation (child failure never suspends parent)', () => {
|
||||
it('LoopGuard is single-request with repeat-without-progress isolation', 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/AiGeneration/Studio/LoopGuard.js'),
|
||||
'utf8'
|
||||
);
|
||||
assert.ok(src.includes('LOOP_GUARD_SCOPE'), 'LOOP_GUARD_SCOPE marker missing');
|
||||
assert.ok(src.includes("'single-request'"), 'guard scope must be single-request');
|
||||
assert.ok(src.includes('maxTurnsWithoutProgress'), 'repeat-without-progress trip missing');
|
||||
assert.ok(
|
||||
src.includes("child") || src.includes('parent'),
|
||||
'isolation note must name child/parent separation'
|
||||
);
|
||||
});
|
||||
|
||||
it('child spawn startup failure fails only its tool call (success:false, no parent suspend)', 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');
|
||||
// Failure shape: failed tool call the model sees.
|
||||
assert.ok(
|
||||
patch14.includes('The sub-agent could not be started.'),
|
||||
'spawn startup failure message missing'
|
||||
);
|
||||
// Isolation markers.
|
||||
for (const marker of ['T06 (#23)', 'editorCallIds', 'spawnFailureResults']) {
|
||||
assert.ok(patch14.includes(marker), `patch 14 missing T06 marker: ${marker}`);
|
||||
}
|
||||
// Parent guard feeds ONLY editor-dispatched calls — never spawn/local failures.
|
||||
assert.ok(
|
||||
patch14.includes('if (!editorCallIds.has(result.call_id)) return;'),
|
||||
'parent guard must skip non-editor (child/local) results'
|
||||
);
|
||||
// The spawn failure path never suspends or constrains the parent.
|
||||
const spawnAt = patch14.indexOf('The sub-agent could not be started.');
|
||||
assert.ok(spawnAt !== -1, 'spawn failure site missing');
|
||||
const spawnSlice = patch14.slice(Math.max(0, spawnAt - 2000), spawnAt + 2000);
|
||||
assert.ok(!spawnSlice.includes('suspendAiRequest'), 'spawn failure must not suspend the parent');
|
||||
assert.ok(!spawnSlice.includes("status: 'error'"), 'spawn failure must not mark the parent error');
|
||||
assert.ok(!spawnSlice.includes('guard.recordError'), 'spawn failure must not touch the parent streak');
|
||||
// Parent stays alive: the failure is recorded AND sent as a normal tool error.
|
||||
assert.ok(
|
||||
patch14.includes('spawnFailureResults.push(failure)'),
|
||||
'spawn failure must be pushed as a tool result'
|
||||
);
|
||||
assert.ok(
|
||||
patch14.includes('...spawnFailureResults,'),
|
||||
'spawn failures must be sent to the model for serial fallback'
|
||||
);
|
||||
});
|
||||
|
||||
it('patch 14 still dry-runs clean with isolation (real I/O, anti-revert intact)', async () => {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const fs = await import('node:fs');
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
const root = path.dirname(
|
||||
path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
||||
);
|
||||
const upstream =
|
||||
process.env.UPSTREAM_DIR || path.join(root, 'review', 'GDevelop');
|
||||
const rel = 'newIDE/app/src/AiGeneration/Utils.js';
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(upstream, rel)),
|
||||
`missing fixture ${rel} (set UPSTREAM_DIR)`
|
||||
);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 't06-p14-'));
|
||||
try {
|
||||
const dest = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.copyFileSync(path.join(upstream, rel), dest);
|
||||
const r = spawnSync(
|
||||
'patch',
|
||||
['-p1', '-s', '--dry-run', '-i', path.join(root, 'patches/14-ai-utils.patch')],
|
||||
{ cwd: dir, encoding: 'utf8' }
|
||||
);
|
||||
assert.equal(r.status, 0, `patch 14 dry-run failed:\n${r.stdout}\n${r.stderr}`);
|
||||
const patchText = fs.readFileSync(path.join(root, 'patches/14-ai-utils.patch'), 'utf8');
|
||||
assert.ok(
|
||||
!/^-[^-].*onSaveProjectAsWithStorageProvider/m.test(patchText),
|
||||
'patch 14 deletes upstream-new symbol onSaveProjectAsWithStorageProvider'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user