mirror of
https://github.com/Heretek-AI/GDevelop-Heretek.git
synced 2026-10-11 21:09:52 +00:00
feat(ai): steer a sub-agent that stops making progress
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
"asset-curator",
|
||||
"qa-playtester"
|
||||
],
|
||||
"last_cycle": 262,
|
||||
"last_cycle": 263,
|
||||
"completed_targets": [
|
||||
{
|
||||
"cycle": 75,
|
||||
@@ -1546,6 +1546,12 @@
|
||||
"target": "Tier 2 fallback routing: a secondary model used when the primary exhausts its provider retries",
|
||||
"metric_delta": "Implemented the objective's Tier 2 'fallback routing'. (1) CustomAIConfig gains fallbackModel (sanitized); (2) a 'Fallback model (optional)' field in the AI Settings Preferences, wired through PreferencesContext/Provider/Dialog exactly like aiCustomModel (PreferencesStorage strips only apiKey, so it persists); (3) the three turn paths (create/continue/sub-agent) now accept a per-attempt model override, and runTurnWithProviderRetry makes one final attempt with the fallback model after the 2 provider retries fail on a retryable 408/429/502/503/504. +1 spec (3x503 then a 4th call with the fallback model => ready, 4 posts, 4th uses 'fallback-model'). Full editor suite 187 suites / 2438 passed; build green; ESLint/Prettier/divergence clean. Live-verified with a model-aware provider: primary-model -> 503 x3 then fallback-model -> 200, request ended ready, console logged 'Provider still failing (503); falling back to model \"fallback-model\"', and the Preferences field renders.",
|
||||
"commit": "feat(ai): fallback to a secondary model when the provider keeps failing"
|
||||
},
|
||||
{
|
||||
"cycle": 263,
|
||||
"target": "Tier 4 progress-stall steer (the objective's 'zero project mods over N turns' signature) + mock parent/child detection fix",
|
||||
"metric_delta": "Added a fourth loop-guard trip: a sub-agent that changes nothing for maxTurnsWithoutProgress (default 5) consecutive turns is steered. Progress is recorded per dispatched batch (any MUTATING_TOOL_NAMES call = progress) and applied ONLY when aiRequest.studioRoleId is set (sub-agents), never the manager (delegating without editing is its job), so the manager is not falsely flagged. +3 LoopGuard specs (trips at N, a project-changing turn resets, reset clears) and the defaults assertion updated. Full editor suite 187 suites / 2441 passed; build green; ESLint/Prettier/divergence clean. Live-verified with the mock: a developer scripted to only read_game_project_json was steered after its 5th no-progress turn ('stuck (no progress: 5 turns without changing the project)'). Also fixed scripts/dev/mock-ai-provider.js: after cycle 258 the manager prompt also contains 'small game studio', so child detection now requires a detected studio ROLE (designer/developer/tester); otherwise the manager was misclassified as a child and scripted sessions could not run.",
|
||||
"commit": "feat(ai): steer a sub-agent that stops making progress"
|
||||
}
|
||||
],
|
||||
"backlog": [
|
||||
@@ -1768,6 +1774,7 @@
|
||||
"A fresh @feedback-loop run is the right validation for an orchestration fix: the deterministic spec pinned the tool subset, but only the real run proved the manager delegates (4 spawns, 0 direct edits) instead of building the project itself.",
|
||||
"Sub-agent failure recovery works (feedback-loop Run 6): an errored child is treated as finished, its failed report is delivered to the manager, and the manager spawned a replacement. The underlying 503 is now retried client-side, so a transient provider blip no longer kills a sub-agent turn. Keep 500 non-retryable (a genuine provider error should surface as-is).",
|
||||
"Periodically run the FULL newIDE/app suite (187 suites), not only the AI-surface subset: the fast per-cycle gate is a subset and cannot prove the wider app still passes after many edits. The full suite is ~10s here.",
|
||||
"Fallback routing belongs after the provider retries, not instead of them: retry the same model (transient blips often clear), then switch models once. Keep the model list user-configurable (AI Settings) and persisted via the preferences store; only the apiKey is stripped before persistence."
|
||||
"Fallback routing belongs after the provider retries, not instead of them: retry the same model (transient blips often clear), then switch models once. Keep the model list user-configurable (AI Settings) and persisted via the preferences store; only the apiKey is stripped before persistence.",
|
||||
"The objective's thrash signature 'zero project mods across 5 turns' maps to a guard trip only for roles that CAN modify. Record progress per batch from the dispatched calls (a MUTATING_TOOL_NAMES call = progress) and exempt the manager, whose no-edit behaviour is correct. Also: the committed mock's child detection must key on a studio ROLE, not the 'small game studio' phrase, because the manager prompt now contains it too."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -55,6 +55,13 @@ export const DEFAULT_LOOP_GUARD_CONFIG = {
|
||||
* of calls, a gameplay test) is not a loop.
|
||||
*/
|
||||
maxTurnsWithoutUserInput: 60,
|
||||
/**
|
||||
* Trips on the Nth consecutive turn with no project-modifying tool call; N-1
|
||||
* are tolerated. A sub-agent that only inspects is stuck (this is the
|
||||
* objective's "zero project mods over N turns" thrash signature). Never
|
||||
* applied to the manager, whose job is to delegate without editing.
|
||||
*/
|
||||
maxTurnsWithoutProgress: 5,
|
||||
};
|
||||
|
||||
export type LoopGuardConfig = {
|
||||
@@ -62,6 +69,7 @@ export type LoopGuardConfig = {
|
||||
repeatedToolLimit: number,
|
||||
errorStormLimit: number,
|
||||
maxTurnsWithoutUserInput: number,
|
||||
maxTurnsWithoutProgress: number,
|
||||
};
|
||||
|
||||
const LEVELS: Array<LoopGuardLevel> = [
|
||||
@@ -129,6 +137,8 @@ export type LoopGuard = {|
|
||||
recordSuccess: () => void,
|
||||
recordError: () => void,
|
||||
recordTurn: () => void,
|
||||
/** Record whether the last turn changed the project (progress). */
|
||||
recordProgress: (madeProgress: boolean) => void,
|
||||
reset: () => void,
|
||||
evaluate: () => LoopGuardDecision,
|
||||
|};
|
||||
@@ -147,6 +157,7 @@ export const createLoopGuard = (
|
||||
let repeatCount = 0;
|
||||
let errorCount = 0;
|
||||
let turnCount = 0;
|
||||
let noProgressTurns = 0;
|
||||
let actionEmittedForTrip = false;
|
||||
|
||||
const evaluateTrips = (): {| tripping: boolean, reason: string |} => {
|
||||
@@ -164,6 +175,12 @@ export const createLoopGuard = (
|
||||
reason: `error storm: ${errorCount} consecutive failures`,
|
||||
};
|
||||
}
|
||||
if (noProgressTurns >= effectiveConfig.maxTurnsWithoutProgress) {
|
||||
return {
|
||||
tripping: true,
|
||||
reason: `no progress: ${noProgressTurns} turns without changing the project`,
|
||||
};
|
||||
}
|
||||
if (turnCount >= effectiveConfig.maxTurnsWithoutUserInput) {
|
||||
return {
|
||||
tripping: true,
|
||||
@@ -193,6 +210,11 @@ export const createLoopGuard = (
|
||||
recordTurn: () => {
|
||||
turnCount++;
|
||||
},
|
||||
recordProgress: madeProgress => {
|
||||
// Any project change resets the streak: a task that reads for a while,
|
||||
// then writes, is not stuck. Only a stretch of pure inspection trips.
|
||||
noProgressTurns = madeProgress ? 0 : noProgressTurns + 1;
|
||||
},
|
||||
reset: () => {
|
||||
// A new user input: the counters start over, but the ladder is NOT
|
||||
// jumped back to healthy - it de-escalates one level per `evaluate`, so
|
||||
@@ -202,6 +224,7 @@ export const createLoopGuard = (
|
||||
repeatCount = 0;
|
||||
errorCount = 0;
|
||||
turnCount = 0;
|
||||
noProgressTurns = 0;
|
||||
actionEmittedForTrip = false;
|
||||
},
|
||||
evaluate: () => {
|
||||
|
||||
@@ -259,12 +259,46 @@ describe('LoopGuard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('progress trip', () => {
|
||||
it('trips after N consecutive turns with no project change', () => {
|
||||
const guard = createLoopGuard({ maxTurnsWithoutProgress: 3 });
|
||||
guard.recordProgress(false);
|
||||
guard.recordProgress(false);
|
||||
expect(guard.evaluate().level).toBe('healthy');
|
||||
guard.recordProgress(false);
|
||||
const decision = guard.evaluate();
|
||||
expect(decision.level).toBe('steering');
|
||||
expect(decision.reason).toContain('no progress');
|
||||
});
|
||||
|
||||
it('a project-changing turn resets the streak', () => {
|
||||
const guard = createLoopGuard({ maxTurnsWithoutProgress: 3 });
|
||||
guard.recordProgress(false);
|
||||
guard.recordProgress(false);
|
||||
// The task wrote something: it is not stuck.
|
||||
guard.recordProgress(true);
|
||||
guard.recordProgress(false);
|
||||
guard.recordProgress(false);
|
||||
expect(guard.evaluate().level).toBe('healthy');
|
||||
});
|
||||
|
||||
it('reset clears the progress streak', () => {
|
||||
const guard = createLoopGuard({ maxTurnsWithoutProgress: 2 });
|
||||
guard.recordProgress(false);
|
||||
guard.recordProgress(false);
|
||||
expect(guard.evaluate().level).toBe('steering');
|
||||
guard.reset();
|
||||
expect(guard.evaluate().level).toBe('healthy');
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults are the documented ones', () => {
|
||||
expect(DEFAULT_LOOP_GUARD_CONFIG).toEqual({
|
||||
enabled: true,
|
||||
repeatedToolLimit: 8,
|
||||
errorStormLimit: 5,
|
||||
maxTurnsWithoutUserInput: 60,
|
||||
maxTurnsWithoutProgress: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
} from './Studio/PlanStore';
|
||||
import { isSubAgentAtTurnCap } from './Studio/FinalizeSubAgents';
|
||||
import { getRoleToolPolicy, isRoleReadOnly } from './Studio/RoleToolPolicy';
|
||||
import { MUTATING_TOOL_NAMES } from './Studio/Roles';
|
||||
import { getEditApprovalLaunchingCall } from './Studio/EditApprovalLabel';
|
||||
import {
|
||||
createRequestWriteQueue,
|
||||
@@ -692,6 +693,15 @@ export const useProcessFunctionCalls = ({
|
||||
});
|
||||
});
|
||||
guard.recordTurn();
|
||||
// A sub-agent that only inspects turn after turn is stuck (the
|
||||
// objective's "no project mods over N turns" signature). The manager is
|
||||
// exempt: delegating without editing is exactly its job.
|
||||
if (aiRequest.studioRoleId) {
|
||||
const madeProgress = functionCallsToProcess.some(functionCall =>
|
||||
MUTATING_TOOL_NAMES.includes(functionCall.name)
|
||||
);
|
||||
guard.recordProgress(madeProgress);
|
||||
}
|
||||
|
||||
const decision = guard.evaluate();
|
||||
if (decision.action === 'steer' || decision.action === 'constrain') {
|
||||
|
||||
@@ -98,6 +98,19 @@ work; `endpoint` is the AI endpoint under test for that cycle.
|
||||
looping until the guard tripped (cycles 239-241)..
|
||||
|
||||
## Proven workflows
|
||||
- Tier 4 progress-stall steer (cycle 263): the loop guard gains a fourth trip -
|
||||
a sub-agent that changes nothing for `maxTurnsWithoutProgress` (5) consecutive
|
||||
turns. It is recorded per turn from the dispatched batch (any
|
||||
`MUTATING_TOOL_NAMES` call = progress) and is applied only to sub-agents
|
||||
(`aiRequest.studioRoleId`), never the manager (delegating without editing is its
|
||||
job). This is the objective's "zero project mods over N turns" thrash
|
||||
signature. Verified live with the mock: a developer scripted to only
|
||||
`read_game_project_json` was steered after its 5th no-progress turn
|
||||
(`stuck (no progress: 5 turns without changing the project)`). +3 specs.
|
||||
Also fixed the committed mock provider: after cycle 258 the manager prompt also
|
||||
says "small game studio", so child detection now requires a detected studio
|
||||
ROLE, not the shared phrase - otherwise the manager was misclassified and the
|
||||
scripted session could not run.
|
||||
- Tier 2 fallback routing (cycle 262): a configurable secondary model used when
|
||||
the primary model exhausts its provider retries. `CustomAIConfig.fallbackModel`
|
||||
(sanitized), a "Fallback model (optional)" field in the AI Settings
|
||||
|
||||
@@ -124,19 +124,11 @@ const json = (res, status, body, extraHeaders) => {
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
// A sub-agent request carries its role prompt ("... of a small game studio");
|
||||
// the top-level orchestrator does not. Distinguishing them lets a scripted run
|
||||
// give the PARENT the scripted turns (plan, spawn, spawn, ...) while each child
|
||||
// simply returns a report, so the interleaved parent/child request order does
|
||||
// not consume the script out of sequence.
|
||||
const isSubAgentRequest = messages =>
|
||||
messages.some(
|
||||
message =>
|
||||
message &&
|
||||
message.role === 'system' &&
|
||||
typeof message.content === 'string' &&
|
||||
message.content.includes('small game studio')
|
||||
);
|
||||
// A sub-agent request carries a studio ROLE prompt ("... the developer of a
|
||||
// small game studio"). The top-level manager ALSO names "small game studio" (its
|
||||
// role prompt became the manager's in the editor's role fix), so the reliable
|
||||
// discriminator is a detected ROLE, not the shared phrase.
|
||||
const isSubAgentRequest = messages => detectRole(messages) !== null;
|
||||
|
||||
const buildReply = (messages, rawBody) => {
|
||||
const lastUser = [...messages].reverse().find(m => m && m.role === 'user');
|
||||
|
||||
Reference in New Issue
Block a user