Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,19 @@ describe('loadCliConfig', () => {
expect(config.getAgentsSettings().maxParallelAgents).toBe(2);
});

it('passes agents.maxParallelAgentsByModel from settings to core config', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig(
{ agents: { maxParallelAgentsByModel: { 'weak-model': 1 } } },
argv,
);

expect(config.getAgentsSettings().maxParallelAgentsByModel).toEqual({
'weak-model': 1,
});
});

it('passes tools.shell.defaultTimeoutMs from settings to core config', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2255,6 +2255,7 @@ export async function loadCliConfig(
}
: undefined,
maxParallelAgents: settings.agents.maxParallelAgents,
maxParallelAgentsByModel: settings.agents.maxParallelAgentsByModel,
displayMode: settings.agents.displayMode,
arena: settings.agents.arena
? {
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2822,13 +2822,31 @@ const SETTINGS_SCHEMA = {
default: undefined as number | undefined,
minimum: 1,
description:
'Global maximum number of background sub-agents that can run concurrently. Additional background agents wait in a queue until a slot is available. Per-model limits are not supported yet.',
'Global maximum number of background sub-agents that can run concurrently. Additional background agents wait in a queue until a slot is available. Use maxParallelAgentsByModel to cap a specific model below this global limit.',
showInDialog: false,
jsonSchemaOverride: {
type: 'integer',
minimum: 1,
},
},
maxParallelAgentsByModel: {
type: 'object',
label: 'Max Parallel Agents Per Model',
category: 'Advanced',
requiresRestart: true,
default: undefined as Record<string, number> | undefined,
description:
'Per-model maximum number of background sub-agents that can run concurrently, keyed by model ID (e.g. { "qwen3-max": 2 }). Useful when a model has a lower concurrency capacity. Takes precedence over the global maxParallelAgents for the matched model; models not listed here fall back to the global limit.',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The description says "Takes precedence over the global maxParallelAgents" but the implementation enforces min(global, perModel) — the global cap always wins. The test "enforces the global cap even when the per-model cap has room" explicitly proves this: a per-model cap of 5 with a global cap of 1 results in an effective cap of 1. The same inaccurate phrasing ("Overrides the global maxParallelAgents") also appears in the JSDoc at packages/core/src/config/config.ts:837.

Failure scenario: A user sets maxParallelAgentsByModel: { "weak-model": 5 } with global maxParallelAgents: 2, expecting weak-model to run up to 5 agents. The actual cap is 2 — the description misleads.

Suggested change
'Per-model maximum number of background sub-agents that can run concurrently, keyed by model ID (e.g. { "qwen3-max": 2 }). Useful when a model has a lower concurrency capacity. Takes precedence over the global maxParallelAgents for the matched model; models not listed here fall back to the global limit.',
'Per-model maximum number of background sub-agents that can run concurrently, keyed by model ID (e.g. { "qwen3-max": 2 }). Useful when a model has a lower concurrency capacity. The effective cap for a listed model is the lower of this value and the global maxParallelAgents; models not listed here use the global limit.',

— qwen3.7-max via Qwen Code /review

showInDialog: false,
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
jsonSchemaOverride: {
type: 'object',
additionalProperties: {
type: 'integer',
minimum: 1,
},
},
},
displayMode: {
type: 'enum',
label: 'Display Mode',
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2970,7 +2970,7 @@ export const AppContainer = (props: AppContainerProps) => {
stickyTodos !== null &&
!dialogsVisible &&
!isFeedbackDialogOpen &&
streamingState !== StreamingState.WaitingForConfirmation;
streamingState === StreamingState.Responding;
const stickyTodoWidth = Math.min(mainAreaWidth, 64);
const stickyTodoMaxVisibleItems =
getStickyTodoMaxVisibleItems(terminalHeight);
Expand Down
18 changes: 17 additions & 1 deletion packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const baseUIState: Partial<UIState> = {
terminalHeight: 24,
staticExtraHeight: 0,
constrainHeight: true,
streamingState: StreamingState.Idle,
streamingState: StreamingState.Responding,
historyManager: {
addItem: vi.fn(),
history: [],
Expand Down Expand Up @@ -212,6 +212,22 @@ describe('DefaultAppLayout', () => {
expect(output).toContain('Composer');
});

it('does not render sticky todo list when agent is idle', () => {
mockedUseAgentViewState.mockReturnValue({
activeView: 'main',
agents: new Map(),
});

const { lastFrame } = renderLayout({
...baseUIState,
streamingState: StreamingState.Idle,
});

const output = lastFrame() ?? '';
expect(output).not.toContain('StickyTodoList');
expect(output).toContain('Composer');
});

it('does not render sticky todo list when feedback dialog is open', () => {
mockedUseAgentViewState.mockReturnValue({
activeView: 'main',
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/layouts/DefaultAppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const DefaultAppLayout: React.FC = () => {
uiState.stickyTodos !== null &&
!uiState.dialogsVisible &&
!uiState.isFeedbackDialogOpen &&
uiState.streamingState !== StreamingState.WaitingForConfirmation;
uiState.streamingState === StreamingState.Responding;

// Clear terminal on view switch so previous view's <Static> output
// is removed. refreshStatic clears the terminal and bumps the
Expand Down
13 changes: 12 additions & 1 deletion packages/cli/src/ui/layouts/ScreenReaderAppLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const baseUIState: Partial<UIState> = {
terminalHeight: 24,
staticExtraHeight: 0,
constrainHeight: true,
streamingState: StreamingState.Idle,
streamingState: StreamingState.Responding,
historyManager: {
addItem: vi.fn(),
history: [],
Expand Down Expand Up @@ -164,6 +164,17 @@ describe('ScreenReaderAppLayout', () => {
expect(output).toContain('Composer');
});

it('does not render sticky todo list when agent is idle', () => {
const { lastFrame } = renderLayout({
...baseUIState,
streamingState: StreamingState.Idle,
});

const output = lastFrame() ?? '';
expect(output).not.toContain('StickyTodoList');
expect(output).toContain('Composer');
});

it('does not render sticky todo list when feedback dialog is open', () => {
const { lastFrame } = renderLayout({
...baseUIState,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const ScreenReaderAppLayout: React.FC = () => {
uiState.stickyTodos !== null &&
!uiState.dialogsVisible &&
!uiState.isFeedbackDialogOpen &&
uiState.streamingState !== StreamingState.WaitingForConfirmation;
uiState.streamingState === StreamingState.Responding;

return (
<Box flexDirection="column" width="90%" height="100%">
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/agents/agent-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ export interface AgentMeta {
* via {@link normalizeResumedAgentDepth} — never trust the raw value.
*/
depth?: number;
/**
* Concrete model ID this agent runs with. Persisted so a process-restart
* recovery can enforce per-model concurrency caps on the revive path.
*/
model?: string;
/** Last terminal error, if any. */
lastError?: string;
}
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/agents/background-agent-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,45 @@ describe('BackgroundAgentResumeService', () => {
expect(subagentManager.loadSubagent).not.toHaveBeenCalled();
});

it('restores the model from the meta sidecar for per-model cap accounting', async () => {
const sessionId = 'session-model-resume';
const agentId = 'agent-model-resume';
const metaPath = getAgentMetaPath(tempDir, sessionId, agentId);

writeAgentMeta(metaPath, {
agentId,
agentType: 'researcher',
description: 'Model-capped background task',
parentSessionId: sessionId,
parentAgentId: null,
createdAt: '2026-04-20T00:00:00.000Z',
status: 'running',
subagentName: 'researcher',
resolvedApprovalMode: 'default',
model: 'gemini-2.5-pro',
});
fs.writeFileSync(
getAgentJsonlPath(tempDir, sessionId, agentId),
JSON.stringify({
uuid: 'u1',
parentUuid: null,
sessionId,
timestamp: '2026-04-20T00:00:00.000Z',
type: 'user',
message: {
role: 'user',
parts: [{ text: 'Model-capped background task' }],
},
}) + '\n',
'utf8',
);

const { service } = createService();
await service.loadPausedBackgroundAgents(sessionId);

expect(registry.get(agentId)?.model).toBe('gemini-2.5-pro');
});

it('keeps missing subagents visible so they can be abandoned later', async () => {
const sessionId = 'session-missing';
const agentId = 'agent-missing';
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/agents/background-agent-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ export class BackgroundAgentResumeService {
// UI falls back to its generic orphan annotation.
parentAgentId: meta.parentAgentId,
depth: meta.depth,
model: meta.model,
};
const entry = registry.register(registration);
recovered.push(entry);
Expand Down Expand Up @@ -532,7 +533,7 @@ export class BackgroundAgentResumeService {
// entry back to paused, so an at-capacity revive fails cleanly instead of
// stranding the entry as paused.
try {
registry.assertCanStartBackgroundAgent();
registry.assertCanStartBackgroundAgent(entry.model);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The resume recovery registration at line ~425 never sets model on the AgentTaskRegistration, so this assertCanStartBackgroundAgent(entry.model) call always receives undefined and the per-model cap check is skipped entirely on the revive path. — Failure scenario: user configures maxParallelAgentsByModel: { 'gemini-2.5-pro': 1 }, process crashes, on restart all recovered agents on that model are revived without the per-model cap being enforced.

Consider persisting the resolved model ID in AgentMeta (add model?: string) and reading it back in the recovery registration.

— qwen3.7-max via Qwen Code /review

} catch (error) {
debugLogger.warn(
`[BackgroundAgentResume] Cannot revive "${agentId}": ` +
Expand Down Expand Up @@ -579,7 +580,7 @@ export class BackgroundAgentResumeService {
);
}
try {
registry.assertCanStartBackgroundAgent();
registry.assertCanStartBackgroundAgent(entry.model);
} catch (error) {
debugLogger.warn(
`[BackgroundAgentResume] Cannot revive "${agentId}": ` +
Expand Down
Loading
Loading