mirror of
https://github.com/Heretek-AI/GDevelop-Heretek.git
synced 2026-10-11 21:09:52 +00:00
feat(ai): resume a stalled studio manager with a bounded nudge
This commit is contained in:
+11
-3
@@ -7,7 +7,7 @@
|
||||
"asset-curator",
|
||||
"qa-playtester"
|
||||
],
|
||||
"last_cycle": 249,
|
||||
"last_cycle": 250,
|
||||
"completed_targets": [
|
||||
{
|
||||
"cycle": 75,
|
||||
@@ -1468,10 +1468,16 @@
|
||||
"target": "Tier 1/2 multi-agent UI: reveal each finished sub-agent's report inline from the dashboard",
|
||||
"metric_delta": "A finished row in the per-agent dashboard is now clickable and expands to that sub-agent's own report - the 'what did each agent do' half of a multi-agent panel, using data already in the parent transcript (no child persistence). Added the pure, tested extractFunctionCallReport (reads the studio {message} object, a JSON-string payload's message/report, and a plain-text report; returns null for empty values and for machine JSON/scalar payloads with no message - so a generic tool blob is never shown as prose), added `report` to SubAgentActivityRow/listSubAgentActivity (first-output-wins, matching call de-duplication), and made the panel render a truncated (<4000 chars) report. +8 specs (report shapes, still-working null, extract edge cases). Live-verified: the real benchmark chat's finished designer row expanded to its report text; the working developer row stayed non-clickable. Tests 1670 AI-surface (up from 1662); build green; ESLint/Prettier/divergence clean.",
|
||||
"commit": "feat(ai): reveal each finished sub-agent's report inline in the dashboard"
|
||||
},
|
||||
{
|
||||
"cycle": 250,
|
||||
"target": "Tier 1 orchestrator reliability: resume a studio manager that stalls after planning (bounded, tested)",
|
||||
"metric_delta": "Under BYOK nothing resumed a manager that produced a plan, then ended its turn without delegating - the multi-agent loop idled forever (the recurring 'local model plans then stops' gap). Added the fix end-to-end: (1) pure, tested NudgePolicy (shouldNudgeStudioManager + getActionablePlanSignature + buildStudioNudgeMessage, 13 specs) decides when to nudge; (2) useStudioNudge hook (8 specs with fake timers + react-test-renderer) arms a 5s grace timer, sends once per plan state, caps at 5 per request, only for the top-level non-role manager, only a recent (<=10min) request, skips suspended/error/sending/in-progress/live-or-pending-sub-agent; (3) wired beside useStudioRuntime in AskAiEditorContainer. Live end-to-end proof against the scriptable mock: transcript user -> plan output -> user[NUDGE] -> assistant create_or_update_plan -> assistant spawn_agent (the stalled manager resumed and delegated; exactly one nudge). Also deduplicated two merge-artifact comment blocks (CustomAIClient.js, AiRequestUtils.js). Tests 1691 AI-surface / 83 suites (up from 1670/81); build green; ESLint/Prettier/divergence clean (4 new forkOnly files recorded).",
|
||||
"commit": "feat(ai): resume a stalled studio manager with a bounded nudge"
|
||||
}
|
||||
],
|
||||
"backlog": [
|
||||
"Model-compliance (Tier 1): the local deepseek-v4.1-flash:cloud often plans/inspects then stops without calling spawn_agent; a stronger model or an automatic continuation nudge (design as a small scheduler outside AI/CustomAIClient, which cannot import Studio/) would improve real-model completion.",
|
||||
"Tier 1 orchestration (follow-up to cycle 250): the nudge resumes a stalled manager, but the manager prompt itself could be tightened and a stronger local model still delegates more reliably; consider measuring the nudge's real-model effect with the prompt-city benchmark.",
|
||||
"Harness: the committed scripts/dev/mock-ai-provider.js lets @feedback-loop run offline; use it to script deterministic tool-call sequences and assert the studio loop without a real model.",
|
||||
"Watch for the ubuntu-latest -> Ubuntu 26 runner migration notice (dated 2026-10-19); workflows are restored from local master by upstream-sync.",
|
||||
"Upstream community 3D PRs (#8343-#8349): cherry-pick only if upstream stalls (roadmap direction B). Strategic, not queued.",
|
||||
@@ -1677,6 +1683,8 @@
|
||||
"The agent loop's client guard (LoopGuard, repeatedToolLimit 8) and the harness's thrash signature (>=3 identical probes) are different things by design: the guard must avoid constraining legitimate long tasks, the audit flags early for human review. Do not 'align' them by lowering the guard.",
|
||||
"The sub-agent audit line is now an expandable dashboard; keep listSubAgentActivity the single source of truth for per-agent rows and derive summarizeSubAgentActivity/sumSubAgentTokens from it rather than re-walking the output.",
|
||||
"Run Jest through `npm test` only: the package's test script is `react-app-rewired test --env=node`, and invoking `npx react-app-rewired test` without `--env=node` makes 21 CustomAIClient streaming specs fail with 'TextEncoder is not defined'. A bare-invocation failure is environmental, not a regression.",
|
||||
"For a multi-agent UI, the sub-agent's report is already in the parent's output as a function_call_output (written by useStudioRuntime); surface that instead of persisting child transcripts. extractFunctionCallReport handles both the {message} object and JSON-string shapes."
|
||||
"For a multi-agent UI, the sub-agent's report is already in the parent's output as a function_call_output (written by useStudioRuntime); surface that instead of persisting child transcripts. extractFunctionCallReport handles both the {message} object and JSON-string shapes.",
|
||||
"The CRA dev server may hold a stale bundle after long edit sessions; verify a new feature is actually served by fetching the loaded app chunk (the big src_ExportAndShare_* chunk contains the app under test) and grepping for a unique symbol, not the small bundle.js. To restart it, kill the `node .../react-app-rewired/scripts/start.js` PID (pkill on 'react-app-rewired start' misses it); wait for 'webpack compiled successfully' in its log.",
|
||||
"A hook that sends through a timer must flush microtasks in its spec: after jest.advanceTimersByTime, `await TestRenderer.act(async () => { await Promise.resolve(); })`, or the deferred send has not run when the assertions execute."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -166,6 +166,8 @@
|
||||
"newIDE/app/src/AiGeneration/Studio/FinalizeSubAgents.spec.js",
|
||||
"newIDE/app/src/AiGeneration/Studio/LoopGuard.js",
|
||||
"newIDE/app/src/AiGeneration/Studio/LoopGuard.spec.js",
|
||||
"newIDE/app/src/AiGeneration/Studio/NudgePolicy.js",
|
||||
"newIDE/app/src/AiGeneration/Studio/NudgePolicy.spec.js",
|
||||
"newIDE/app/src/AiGeneration/Studio/PlanStore.js",
|
||||
"newIDE/app/src/AiGeneration/Studio/PlanStore.spec.js",
|
||||
"newIDE/app/src/AiGeneration/Studio/RequestWriteGate.js",
|
||||
@@ -178,6 +180,8 @@
|
||||
"newIDE/app/src/AiGeneration/Studio/SafeTruncation.spec.js",
|
||||
"newIDE/app/src/AiGeneration/Studio/SpawnSubAgents.js",
|
||||
"newIDE/app/src/AiGeneration/Studio/SpawnSubAgents.spec.js",
|
||||
"newIDE/app/src/AiGeneration/Studio/UseStudioNudge.js",
|
||||
"newIDE/app/src/AiGeneration/Studio/UseStudioNudge.spec.js",
|
||||
"newIDE/app/src/AiGeneration/Studio/UseStudioRuntime.js",
|
||||
"newIDE/app/src/AiGeneration/Studio/UseStudioRuntime.spec.js",
|
||||
"newIDE/app/src/MainFrame/Preferences/PreferencesStorage.js",
|
||||
|
||||
@@ -4272,7 +4272,6 @@ export const customAddMessageToAiRequest = async ({
|
||||
|
||||
if (functionCallOutputs && functionCallOutputs.length > 0) {
|
||||
for (const fcOutput of functionCallOutputs) {
|
||||
// Network-supplied: a hole would throw on the property read.
|
||||
// Network-supplied: a hole would throw on the property read.
|
||||
if (!fcOutput || typeof fcOutput !== 'object') continue;
|
||||
output.push({
|
||||
|
||||
@@ -862,10 +862,6 @@ export const getFunctionCallOutputsFromEditorFunctionCallResults = (
|
||||
return null;
|
||||
}
|
||||
if (functionCallOutput.status === 'finished') {
|
||||
// `output: any`: spreading a non-object (a string becomes {0:'a',...},
|
||||
// an array becomes {0:...}) corrupts the JSON the model receives, so
|
||||
// only a plain object is spread; anything else is nested under
|
||||
// `output` so its value survives without numeric keys.
|
||||
// `output: any`: spreading a non-object (a string becomes {0:'a',...},
|
||||
// an array becomes {0:...}) corrupts the JSON the model receives, so
|
||||
// only a plain object is spread; anything else is nested under
|
||||
|
||||
@@ -85,6 +85,7 @@ import UrlStorageProvider from '../ProjectsStorage/UrlStorageProvider';
|
||||
import { prepareAiUserContent } from './PrepareAiUserContent';
|
||||
import { AiRequestContext } from './AiRequestContext';
|
||||
import { useStudioRuntime } from './Studio/UseStudioRuntime';
|
||||
import { useStudioNudge } from './Studio/UseStudioNudge';
|
||||
import { getAiConfigurationPresetsWithAvailability } from './AiConfiguration';
|
||||
import {
|
||||
setEditorHotReloadNeeded,
|
||||
@@ -1204,6 +1205,18 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
|
||||
enqueueRequestWrite,
|
||||
});
|
||||
|
||||
// Keeps the local manager moving when it plans, then ends its turn without
|
||||
// delegating (BYOK-only, bounded, one nudge per plan state). A no-op for
|
||||
// the hosted path and for sub-agent requests.
|
||||
useStudioNudge({
|
||||
selectedAiRequest,
|
||||
aiRequests,
|
||||
activeSubAgents,
|
||||
getEditorFunctionCallResults,
|
||||
isSendingAiRequest,
|
||||
onSendMessage,
|
||||
});
|
||||
|
||||
// Wrap onProcessFunctionCalls to bind the selected AI request for the chat UI.
|
||||
const onProcessSelectedAiRequestFunctionCalls = React.useCallback(
|
||||
async (functionCalls: Array<AiRequestMessageAssistantFunctionCall>) => {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// @flow
|
||||
|
||||
import { type StudioPlanTask } from './PlanStore';
|
||||
|
||||
/**
|
||||
* When the local studio's manager has stalled: it produced a plan, then ended
|
||||
* its turn with an actionable task still undelegated (and no sub-agent running),
|
||||
* so the multi-agent loop would sit idle forever under BYOK.
|
||||
*
|
||||
* This is the decision half of `useStudioNudge`, kept pure and dependency-light
|
||||
* (`PlanStore`'s types only) so it can be unit-tested. The caller supplies the
|
||||
* facts it already has: whether the request is the top-level manager, whether a
|
||||
* turn is in flight, how many sub-agents are live and how many spawn calls are
|
||||
* still unanswered.
|
||||
*
|
||||
* The nudge is deliberately conservative:
|
||||
* - only the top-level manager (never a sub-agent);
|
||||
* - never while a turn is in flight or work is pending;
|
||||
* - never with a live or unanswered sub-agent;
|
||||
* - never twice for the same plan state (the caller passes the signature it
|
||||
* already nudged), so a stalled plan is nudged once, not every render;
|
||||
* - a suspended/errored request is left alone (the user may have stopped it).
|
||||
*/
|
||||
export type StudioNudgeInput = {|
|
||||
/** True for the top-level manager request (no parent, no studio role). */
|
||||
isTopLevel: boolean,
|
||||
status: string,
|
||||
hasWorkInProgress: boolean,
|
||||
isSending: boolean,
|
||||
liveSubAgentCount: number,
|
||||
pendingSubAgentCallCount: number,
|
||||
/** Non-null only when an actionable (pending/in_progress) task exists. */
|
||||
planSignature: string | null,
|
||||
previouslyNudgedSignature: string | null,
|
||||
/**
|
||||
* True only for a request touched recently. Opening an old, abandoned chat
|
||||
* must not resurrect its plan and spend tokens.
|
||||
*/
|
||||
isRecent: boolean,
|
||||
|};
|
||||
|
||||
export const shouldNudgeStudioManager = (input: StudioNudgeInput): boolean => {
|
||||
if (!input.isTopLevel) return false;
|
||||
if (!input.isRecent) return false;
|
||||
if (input.status === 'suspended' || input.status === 'error') return false;
|
||||
if (input.isSending) return false;
|
||||
if (input.hasWorkInProgress) return false;
|
||||
if (input.liveSubAgentCount > 0) return false;
|
||||
if (input.pendingSubAgentCallCount > 0) return false;
|
||||
if (!input.planSignature) return false;
|
||||
return input.planSignature !== input.previouslyNudgedSignature;
|
||||
};
|
||||
|
||||
/**
|
||||
* A stable signature of a plan's actionable state: the ids and statuses of the
|
||||
* tasks that still need attention, in order. Null when there is no plan or
|
||||
* nothing is actionable (every task is done or voided).
|
||||
*
|
||||
* `agentCallId` is included so a task that was delegated (but whose status has
|
||||
* not flipped yet) is still distinguishable; a status-only signature would
|
||||
* miss it only in the window before the spawn is stamped.
|
||||
*/
|
||||
export const getActionablePlanSignature = (
|
||||
tasks: Array<StudioPlanTask> | null | void
|
||||
): string | null => {
|
||||
if (!Array.isArray(tasks) || tasks.length === 0) return null;
|
||||
const actionable = tasks.filter(
|
||||
task =>
|
||||
task &&
|
||||
typeof task === 'object' &&
|
||||
(task.status === 'pending' || task.status === 'in_progress')
|
||||
);
|
||||
if (actionable.length === 0) return null;
|
||||
return actionable
|
||||
.map(task => `${task.id}:${task.status}:${task.agentCallId || ''}`)
|
||||
.join('|');
|
||||
};
|
||||
|
||||
/**
|
||||
* The follow-up message the nudge sends to the stalled manager. Written as the
|
||||
* user so it flows through the existing send path unchanged, and prefixed so
|
||||
* the transcript makes clear it was the studio, not the person.
|
||||
*/
|
||||
export const buildStudioNudgeMessage = (
|
||||
nextReadyTask: StudioPlanTask | null
|
||||
): string => {
|
||||
const taskHint = nextReadyTask
|
||||
? ` Next ready task: ${nextReadyTask.id} (${nextReadyTask.title}).`
|
||||
: '';
|
||||
return (
|
||||
'[Automatic reminder from the studio] Your plan still has a task that is ' +
|
||||
`not delegated.${taskHint} Call spawn_agent for it now, or report plainly ` +
|
||||
'why it cannot be done. Do not answer with prose only.'
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
// @flow
|
||||
import {
|
||||
shouldNudgeStudioManager,
|
||||
getActionablePlanSignature,
|
||||
buildStudioNudgeMessage,
|
||||
} from './NudgePolicy';
|
||||
|
||||
const baseInput = {
|
||||
isTopLevel: true,
|
||||
status: 'ready',
|
||||
hasWorkInProgress: false,
|
||||
isSending: false,
|
||||
liveSubAgentCount: 0,
|
||||
pendingSubAgentCallCount: 0,
|
||||
planSignature: 'task_1:pending:',
|
||||
previouslyNudgedSignature: null,
|
||||
isRecent: true,
|
||||
};
|
||||
|
||||
describe('shouldNudgeStudioManager', () => {
|
||||
it('nudges a stalled top-level manager with an actionable task', () => {
|
||||
expect(shouldNudgeStudioManager((baseInput: any))).toBe(true);
|
||||
});
|
||||
|
||||
it('never nudges a sub-agent (not top-level)', () => {
|
||||
expect(
|
||||
shouldNudgeStudioManager(({ ...baseInput, isTopLevel: false }: any))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('never nudges an old, abandoned request', () => {
|
||||
expect(
|
||||
shouldNudgeStudioManager(({ ...baseInput, isRecent: false }: any))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('never nudges while a turn is in flight or work is pending', () => {
|
||||
expect(
|
||||
shouldNudgeStudioManager(({ ...baseInput, isSending: true }: any))
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldNudgeStudioManager(({ ...baseInput, hasWorkInProgress: true }: any))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('never nudges while a sub-agent is live or its call is unanswered', () => {
|
||||
expect(
|
||||
shouldNudgeStudioManager(({ ...baseInput, liveSubAgentCount: 1 }: any))
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldNudgeStudioManager(
|
||||
({
|
||||
...baseInput,
|
||||
pendingSubAgentCallCount: 1,
|
||||
}: any)
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not nudge a plan with nothing actionable', () => {
|
||||
expect(
|
||||
shouldNudgeStudioManager(({ ...baseInput, planSignature: null }: any))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a suspended or errored request alone', () => {
|
||||
expect(
|
||||
shouldNudgeStudioManager(({ ...baseInput, status: 'suspended' }: any))
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldNudgeStudioManager(({ ...baseInput, status: 'error' }: any))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not re-nudge the same plan state, but does nudge a new one', () => {
|
||||
expect(
|
||||
shouldNudgeStudioManager(
|
||||
({
|
||||
...baseInput,
|
||||
previouslyNudgedSignature: 'task_1:pending:',
|
||||
}: any)
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldNudgeStudioManager(
|
||||
({
|
||||
...baseInput,
|
||||
planSignature: 'task_2:in_progress:',
|
||||
previouslyNudgedSignature: 'task_1:pending:',
|
||||
}: any)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActionablePlanSignature', () => {
|
||||
it('is null for no plan or nothing actionable', () => {
|
||||
expect(getActionablePlanSignature(null)).toBeNull();
|
||||
expect(getActionablePlanSignature([])).toBeNull();
|
||||
expect(
|
||||
getActionablePlanSignature([
|
||||
({ id: 'a', title: 'A', description: '', status: 'done' }: any),
|
||||
({ id: 'b', title: 'B', description: '', status: 'voided' }: any),
|
||||
])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('signs the actionable tasks with their status and delegated call', () => {
|
||||
const signature = getActionablePlanSignature([
|
||||
({ id: 'a', title: 'A', description: '', status: 'done' }: any),
|
||||
({
|
||||
id: 'b',
|
||||
title: 'B',
|
||||
description: '',
|
||||
status: 'in_progress',
|
||||
agentCallId: 'call_1',
|
||||
}: any),
|
||||
({ id: 'c', title: 'C', description: '', status: 'pending' }: any),
|
||||
]);
|
||||
expect(signature).toBe('b:in_progress:call_1|c:pending:');
|
||||
});
|
||||
|
||||
it('tolerates null holes in the task list', () => {
|
||||
const signature = getActionablePlanSignature([
|
||||
(null: any),
|
||||
({ id: 'c', title: 'C', description: '', status: 'pending' }: any),
|
||||
]);
|
||||
expect(signature).toBe('c:pending:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildStudioNudgeMessage', () => {
|
||||
it('names the next ready task and demands delegation', () => {
|
||||
const message = buildStudioNudgeMessage(
|
||||
({
|
||||
id: 'task_2',
|
||||
title: 'Build the scene',
|
||||
description: '',
|
||||
status: 'pending',
|
||||
}: any)
|
||||
);
|
||||
expect(message).toContain('task_2');
|
||||
expect(message).toContain('Build the scene');
|
||||
expect(message).toContain('spawn_agent');
|
||||
});
|
||||
|
||||
it('still demands delegation with no ready task', () => {
|
||||
const message = buildStudioNudgeMessage(null);
|
||||
expect(message).toContain('spawn_agent');
|
||||
expect(message).not.toContain('Next ready task');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
// @flow
|
||||
|
||||
import * as React from 'react';
|
||||
import { type AiRequest } from '../../Utils/GDevelopServices/Generation';
|
||||
import { type EditorFunctionCallResult } from '../../EditorFunctions';
|
||||
import {
|
||||
aiRequestHasWorkInProgress,
|
||||
getLatestActivePlan,
|
||||
getPendingSubAgentFunctionCalls,
|
||||
} from '../AiRequestUtils';
|
||||
import { getNextReadyTask, type StudioPlanTask } from './PlanStore';
|
||||
import {
|
||||
shouldNudgeStudioManager,
|
||||
getActionablePlanSignature,
|
||||
buildStudioNudgeMessage,
|
||||
} from './NudgePolicy';
|
||||
|
||||
type ActiveSubAgent = {|
|
||||
parentAiRequestId: string,
|
||||
callId: string,
|
||||
|};
|
||||
|
||||
/**
|
||||
* How long the manager must stay stalled before the nudge is sent. A grace
|
||||
* period, not a poll interval: the effect is re-run (and the timer cleared) on
|
||||
* every relevant state change, so this only has to outlast the sub-second gap
|
||||
* between writing a tool result and the follow-up turn starting.
|
||||
*/
|
||||
export const STUDIO_NUDGE_GRACE_MS = 5000;
|
||||
|
||||
/** Hard cap on nudges for one request, so a pathological model cannot loop. */
|
||||
export const MAX_STUDIO_NUDGES_PER_REQUEST = 5;
|
||||
|
||||
/**
|
||||
* Only a request touched within this window may be nudged. Opening an old,
|
||||
* abandoned chat must not resurrect its plan and spend tokens; the window is
|
||||
* generous enough that a genuinely-stalled live run is always in range.
|
||||
*/
|
||||
export const STUDIO_NUDGE_MAX_AGE_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Keep the local studio's manager moving.
|
||||
*
|
||||
* Under BYOK nothing resumes a manager that produced a plan and then ended its
|
||||
* turn without delegating: the multi-agent loop waits forever. This hook sends
|
||||
* at most one continuation per stalled plan state (`NudgePolicy` decides when),
|
||||
* with a short grace period so it never races a turn already in flight.
|
||||
*
|
||||
* It is a no-op for the hosted path and for sub-agent requests (only the
|
||||
* top-level, non-role request is nudged) and for a user-stopped request.
|
||||
*/
|
||||
export const useStudioNudge = ({
|
||||
selectedAiRequest,
|
||||
aiRequests,
|
||||
activeSubAgents,
|
||||
getEditorFunctionCallResults,
|
||||
isSendingAiRequest,
|
||||
onSendMessage,
|
||||
}: {|
|
||||
selectedAiRequest: ?AiRequest,
|
||||
aiRequests: { [string]: AiRequest },
|
||||
activeSubAgents: { [string]: ActiveSubAgent },
|
||||
getEditorFunctionCallResults: string => Array<EditorFunctionCallResult> | null,
|
||||
isSendingAiRequest: (aiRequestId: string | null) => boolean,
|
||||
onSendMessage: (options: Object) => Promise<boolean>,
|
||||
|}): void => {
|
||||
// Per-request: the plan state already nudged, and how many nudges were sent.
|
||||
const nudgedSignatureByRequestRef = React.useRef<Map<string, string>>(
|
||||
new Map()
|
||||
);
|
||||
const nudgedCountByRequestRef = React.useRef<Map<string, number>>(new Map());
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (!selectedAiRequest) return;
|
||||
const aiRequestId = selectedAiRequest.id;
|
||||
|
||||
const isTopLevel =
|
||||
!selectedAiRequest.parentAiRequestId && !selectedAiRequest.studioRoleId;
|
||||
if (!isTopLevel) return;
|
||||
|
||||
const plan = getLatestActivePlan(selectedAiRequest);
|
||||
const tasks: Array<StudioPlanTask> = plan && plan.tasks ? plan.tasks : [];
|
||||
const planSignature = getActionablePlanSignature(tasks);
|
||||
const liveSubAgentCount = Object.keys(activeSubAgents).filter(
|
||||
subAgentId =>
|
||||
activeSubAgents[subAgentId] &&
|
||||
activeSubAgents[subAgentId].parentAiRequestId === aiRequestId
|
||||
).length;
|
||||
|
||||
const updatedAtMs = Date.parse(selectedAiRequest.updatedAt || '');
|
||||
const isRecent =
|
||||
Number.isFinite(updatedAtMs) &&
|
||||
Date.now() - updatedAtMs <= STUDIO_NUDGE_MAX_AGE_MS;
|
||||
|
||||
const shouldNudge = shouldNudgeStudioManager({
|
||||
isTopLevel,
|
||||
isRecent,
|
||||
status: selectedAiRequest.status,
|
||||
hasWorkInProgress: aiRequestHasWorkInProgress(
|
||||
selectedAiRequest,
|
||||
getEditorFunctionCallResults(aiRequestId)
|
||||
),
|
||||
isSending: isSendingAiRequest(aiRequestId),
|
||||
liveSubAgentCount,
|
||||
pendingSubAgentCallCount: getPendingSubAgentFunctionCalls({
|
||||
aiRequest: selectedAiRequest,
|
||||
}).length,
|
||||
planSignature,
|
||||
previouslyNudgedSignature:
|
||||
nudgedSignatureByRequestRef.current.get(aiRequestId) || null,
|
||||
});
|
||||
if (!shouldNudge || !planSignature) return;
|
||||
|
||||
const nudgedCount = nudgedCountByRequestRef.current.get(aiRequestId) || 0;
|
||||
if (nudgedCount >= MAX_STUDIO_NUDGES_PER_REQUEST) return;
|
||||
|
||||
const nextReadyTask = getNextReadyTask(tasks);
|
||||
const timer = setTimeout(() => {
|
||||
// Mark before the send: a re-render landing during the await must not
|
||||
// arm a second nudge for the same plan state.
|
||||
nudgedSignatureByRequestRef.current.set(aiRequestId, planSignature);
|
||||
nudgedCountByRequestRef.current.set(aiRequestId, nudgedCount + 1);
|
||||
console.info(
|
||||
`[studio] nudging the stalled manager #${aiRequestId} for plan ${planSignature}.`
|
||||
);
|
||||
// `onSendMessage` can return undefined (its own early-return path), so it
|
||||
// is not necessarily a promise and may also throw synchronously.
|
||||
Promise.resolve()
|
||||
.then(() =>
|
||||
onSendMessage({
|
||||
aiRequestId,
|
||||
userMessage: buildStudioNudgeMessage(nextReadyTask),
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
console.error('[studio] error while nudging the manager:', error);
|
||||
// Let the next pass retry the same state.
|
||||
if (
|
||||
nudgedSignatureByRequestRef.current.get(aiRequestId) ===
|
||||
planSignature
|
||||
) {
|
||||
nudgedSignatureByRequestRef.current.delete(aiRequestId);
|
||||
}
|
||||
});
|
||||
}, STUDIO_NUDGE_GRACE_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
},
|
||||
[
|
||||
selectedAiRequest,
|
||||
aiRequests,
|
||||
activeSubAgents,
|
||||
getEditorFunctionCallResults,
|
||||
isSendingAiRequest,
|
||||
onSendMessage,
|
||||
]
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
import { useStudioNudge, STUDIO_NUDGE_GRACE_MS } from './UseStudioNudge';
|
||||
|
||||
/**
|
||||
* The hook is mounted through a tiny harness so its timer/effect behavior can be
|
||||
* driven deterministically with fake timers - the mock provider cannot prove
|
||||
* this half (it depends on the client's real turn loop), and a real model's
|
||||
* compliance is variable.
|
||||
*/
|
||||
const Harness = (props: any) => {
|
||||
useStudioNudge(props);
|
||||
return null;
|
||||
};
|
||||
|
||||
const PLAN_OUTPUT = JSON.stringify({
|
||||
success: true,
|
||||
plan: {
|
||||
tasks: [
|
||||
{
|
||||
id: 'task_1',
|
||||
title: 'Design',
|
||||
description: 'Write the design.',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const makeRequest = (overrides: Object = {}) => ({
|
||||
id: 'req-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
userId: 'local-byok-user',
|
||||
status: 'ready',
|
||||
error: null,
|
||||
output: [
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'plan_call',
|
||||
output: PLAN_OUTPUT,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeProps = (overrides: Object = {}) => {
|
||||
const request = makeRequest();
|
||||
return {
|
||||
selectedAiRequest: request,
|
||||
aiRequests: { 'req-1': request },
|
||||
activeSubAgents: {},
|
||||
getEditorFunctionCallResults: () => null,
|
||||
isSendingAiRequest: () => false,
|
||||
onSendMessage: jest.fn(async () => true),
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
describe('useStudioNudge', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const render = (props: Object) => {
|
||||
let renderer;
|
||||
TestRenderer.act(() => {
|
||||
renderer = TestRenderer.create(React.createElement(Harness, props));
|
||||
});
|
||||
return renderer;
|
||||
};
|
||||
|
||||
const advancePastGrace = async () => {
|
||||
// The timer callback defers the send through a microtask, so flush it.
|
||||
await TestRenderer.act(async () => {
|
||||
jest.advanceTimersByTime(STUDIO_NUDGE_GRACE_MS + 10);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
it('nudges a stalled manager once after the grace period', async () => {
|
||||
const onSendMessage = jest.fn(async () => true);
|
||||
render(makeProps({ onSendMessage }));
|
||||
await advancePastGrace();
|
||||
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onSendMessage.mock.calls[0][0].aiRequestId).toBe('req-1');
|
||||
expect(String(onSendMessage.mock.calls[0][0].userMessage)).toContain(
|
||||
'spawn_agent'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not nudge while a sub-agent is live', async () => {
|
||||
const onSendMessage = jest.fn(async () => true);
|
||||
render(
|
||||
makeProps({
|
||||
onSendMessage,
|
||||
activeSubAgents: {
|
||||
'sub-1': { parentAiRequestId: 'req-1', callId: 'call_1' },
|
||||
},
|
||||
})
|
||||
);
|
||||
await advancePastGrace();
|
||||
expect(onSendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not nudge while a turn is in flight', async () => {
|
||||
const onSendMessage = jest.fn(async () => true);
|
||||
render(makeProps({ onSendMessage, isSendingAiRequest: () => true }));
|
||||
await advancePastGrace();
|
||||
expect(onSendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not nudge a sub-agent request', async () => {
|
||||
const onSendMessage = jest.fn(async () => true);
|
||||
const request = makeRequest({
|
||||
parentAiRequestId: 'parent-1',
|
||||
studioRoleId: 'developer',
|
||||
});
|
||||
render(
|
||||
makeProps({
|
||||
onSendMessage,
|
||||
selectedAiRequest: request,
|
||||
aiRequests: { 'req-1': request },
|
||||
})
|
||||
);
|
||||
await advancePastGrace();
|
||||
expect(onSendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not nudge a suspended request', async () => {
|
||||
const onSendMessage = jest.fn(async () => true);
|
||||
const request = makeRequest({ status: 'suspended' });
|
||||
render(
|
||||
makeProps({
|
||||
onSendMessage,
|
||||
selectedAiRequest: request,
|
||||
aiRequests: { 'req-1': request },
|
||||
})
|
||||
);
|
||||
await advancePastGrace();
|
||||
expect(onSendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not nudge an old, abandoned request', async () => {
|
||||
const onSendMessage = jest.fn(async () => true);
|
||||
const request = makeRequest({
|
||||
updatedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
render(
|
||||
makeProps({
|
||||
onSendMessage,
|
||||
selectedAiRequest: request,
|
||||
aiRequests: { 'req-1': request },
|
||||
})
|
||||
);
|
||||
await advancePastGrace();
|
||||
expect(onSendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not nudge a plan whose tasks are all done', async () => {
|
||||
const onSendMessage = jest.fn(async () => true);
|
||||
const request = makeRequest({
|
||||
output: [
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'plan_call',
|
||||
output: JSON.stringify({
|
||||
success: true,
|
||||
plan: {
|
||||
tasks: [
|
||||
{
|
||||
id: 'task_1',
|
||||
title: 'Design',
|
||||
description: 'Done.',
|
||||
status: 'done',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
makeProps({
|
||||
onSendMessage,
|
||||
selectedAiRequest: request,
|
||||
aiRequests: { 'req-1': request },
|
||||
})
|
||||
);
|
||||
await advancePastGrace();
|
||||
expect(onSendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-nudge the same plan state on a re-render', async () => {
|
||||
const onSendMessage = jest.fn(async () => true);
|
||||
const renderer = render(makeProps({ onSendMessage }));
|
||||
await advancePastGrace();
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A re-render (new props, same stalled plan) must not arm a second nudge.
|
||||
TestRenderer.act(() => {
|
||||
renderer.update(
|
||||
React.createElement(Harness, makeProps({ onSendMessage }))
|
||||
);
|
||||
});
|
||||
await advancePastGrace();
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,13 @@ work; `endpoint` is the AI endpoint under test for that cycle.
|
||||
looping until the guard tripped (cycles 239-241)..
|
||||
|
||||
## Proven workflows
|
||||
- Orchestrator stall recovery, live end-to-end (cycle 250): a manager that
|
||||
plans and then ends its turn without delegating is now nudged automatically
|
||||
(bounded, once per plan state). Proven against the scriptable mock provider:
|
||||
the request transcript went `user → plan output → user[NUDGE] → assistant
|
||||
create_or_update_plan → assistant spawn_agent` - the nudge resumed the stalled
|
||||
manager, which then delegated. Only one nudge was sent (no loop); screenshot
|
||||
/tmp/opencode/cycle250-nudge.png.
|
||||
- Per-agent report on demand (cycle 249): a finished row in the per-agent
|
||||
dashboard is now clickable and reveals that sub-agent's own report (read by the
|
||||
pure, tested `extractFunctionCallReport`, handling the studio `{message}`
|
||||
|
||||
Reference in New Issue
Block a user