feat(ai): steer a sub-agent that stops making progress

This commit is contained in:
John Smith
2026-09-24 05:52:30 -04:00
parent a9ab2861d8
commit 22349d89c9
6 changed files with 94 additions and 15 deletions
+9 -2
View File
@@ -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,
});
});
});
+10
View File
@@ -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') {
+13
View File
@@ -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
+5 -13
View File
@@ -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');