diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b9b3142a..d60eab05 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,7 +3,7 @@ * Usage: node cli.cjs [args] */ -import { loadConfig, saveConfig, resolveModel, AgentType, type ModelProfile } from './core/index.js'; +import { loadConfig, saveConfig, resolveModel, resolveMaxAgents, AgentType, TaskComplexity, type ModelProfile } from './core/index.js'; const args = process.argv.slice(2); const command = args[0]; @@ -24,6 +24,32 @@ const COMMANDS: Record void> = { console.log(model); } }, + 'resolve-max-agents': () => { + const projectDir = process.cwd(); + const config = loadConfig(projectDir); + const profile = (args[1] as ModelProfile) || config.execution.model_profile as ModelProfile; + + const fileCountIdx = args.indexOf('--file-count'); + const fileCount = fileCountIdx >= 0 ? parseInt(args[fileCountIdx + 1], 10) : 0; + if (fileCountIdx >= 0 && (isNaN(fileCount) || fileCount < 0)) { + console.error('--file-count must be a non-negative integer'); + process.exit(1); + } + + const complexityIdx = args.indexOf('--complexity'); + const complexityArg = complexityIdx >= 0 ? args[complexityIdx + 1] : TaskComplexity.MEDIUM; + if (!Object.values(TaskComplexity).includes(complexityArg as TaskComplexity)) { + console.error(`Invalid complexity: ${complexityArg}. Must be: simple, medium, complex`); + process.exit(1); + } + + const result = resolveMaxAgents(profile, fileCount, complexityArg as TaskComplexity); + if (args.includes('--raw')) { + process.stdout.write(String(result)); + } else { + console.log(result); + } + }, 'config-get': () => { const key = args[1]; if (!key) { console.error('Usage: config-get '); process.exit(1); } diff --git a/packages/cli/src/core/config.ts b/packages/cli/src/core/config.ts index e9439429..e5e83f18 100644 --- a/packages/cli/src/core/config.ts +++ b/packages/cli/src/core/config.ts @@ -9,6 +9,7 @@ import { type Model, type ModelProfile, type AgentType, + TaskComplexity, DEFAULT_CONFIG, MODEL_PROFILES, PARALLELISM_LIMITS, @@ -84,12 +85,17 @@ export function saveConfig(projectDir: string, config: MaxsimConfig): void { /** Resolve max agents for a profile, applying the small-project scaling rule from PROJECT.md ยง7.4. */ export function resolveMaxAgents( profile: ModelProfile, - projectFileCount: number + projectFileCount: number, + complexity: TaskComplexity = TaskComplexity.MEDIUM, ): number { const limits = PARALLELISM_LIMITS[profile]; - if (projectFileCount < 10) return Math.min(5, limits.max_agents); - if (projectFileCount < 25) return Math.min(Math.floor(limits.max_agents / 2), limits.typical_range[1]); - return limits.max_agents; + let cap: number; + if (projectFileCount < 10) cap = Math.min(5, limits.max_agents); + else if (projectFileCount < 25) cap = Math.min(Math.floor(limits.max_agents / 2), limits.typical_range[1]); + else cap = limits.max_agents; + + if (complexity === TaskComplexity.SIMPLE) return Math.max(1, Math.floor(cap / 2)); + return cap; } /** Resolve the model for a given profile and agent type, with optional per-agent overrides. */ diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts index 30f0cdba..eaa4d127 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -9,6 +9,7 @@ export { AgentType, Model, ModelProfile, + TaskComplexity, VerificationGate, VerificationResult, TaskState, diff --git a/packages/cli/src/core/types.ts b/packages/cli/src/core/types.ts index 22030f59..b2a447db 100644 --- a/packages/cli/src/core/types.ts +++ b/packages/cli/src/core/types.ts @@ -81,6 +81,14 @@ export const ModelProfile = { } as const; export type ModelProfile = (typeof ModelProfile)[keyof typeof ModelProfile]; +/** Task complexity levels for agent parallelism scaling. */ +export const TaskComplexity = { + SIMPLE: 'simple', + MEDIUM: 'medium', + COMPLEX: 'complex', +} as const; +export type TaskComplexity = (typeof TaskComplexity)[keyof typeof TaskComplexity]; + /** Verification gate types. */ export const VerificationGate = { TESTS_PASS: 'tests_pass', diff --git a/packages/cli/tests/unit/cli.test.ts b/packages/cli/tests/unit/cli.test.ts index 5ecf2165..00ae80a6 100644 --- a/packages/cli/tests/unit/cli.test.ts +++ b/packages/cli/tests/unit/cli.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { loadConfig, saveConfig, resolveModel, getConfigPath } from '../../src/core/config.js'; -import { AgentType, Model, ModelProfile, DEFAULT_CONFIG } from '../../src/core/types.js'; +import { loadConfig, saveConfig, resolveModel, resolveMaxAgents, getConfigPath } from '../../src/core/config.js'; +import { AgentType, Model, ModelProfile, TaskComplexity, DEFAULT_CONFIG, PARALLELISM_LIMITS } from '../../src/core/types.js'; let tmpDir: string; @@ -174,3 +174,23 @@ describe('config-ensure-section command logic', () => { expect(JSON.parse(raw)).toHaveProperty('fresh_section'); }); }); + +describe('resolve-max-agents command logic', () => { + it('returns max agents for a large project when --file-count is provided', () => { + expect(resolveMaxAgents(ModelProfile.BALANCED, 50)).toBe(PARALLELISM_LIMITS[ModelProfile.BALANCED].max_agents); + }); + + it('defaults to file count 0 when --file-count flag is absent (small project cap applies)', () => { + expect(resolveMaxAgents(ModelProfile.BALANCED, 0)).toBe(Math.min(5, PARALLELISM_LIMITS[ModelProfile.BALANCED].max_agents)); + }); + + it('invalid complexity value is not a member of TaskComplexity', () => { + expect(Object.values(TaskComplexity).includes('bogus' as TaskComplexity)).toBe(false); + }); + + it('all TaskComplexity values are accepted by resolveMaxAgents without throwing', () => { + for (const complexity of Object.values(TaskComplexity)) { + expect(() => resolveMaxAgents(ModelProfile.BALANCED, 50, complexity)).not.toThrow(); + } + }); +}); diff --git a/packages/cli/tests/unit/config.test.ts b/packages/cli/tests/unit/config.test.ts index 687ed11f..b1aad91b 100644 --- a/packages/cli/tests/unit/config.test.ts +++ b/packages/cli/tests/unit/config.test.ts @@ -9,7 +9,7 @@ import { resolveMaxAgents, getConfigPath, } from '../../src/core/config.js'; -import { Model, ModelProfile, AgentType, DEFAULT_CONFIG, PARALLELISM_LIMITS } from '../../src/core/types.js'; +import { Model, ModelProfile, AgentType, TaskComplexity, DEFAULT_CONFIG, PARALLELISM_LIMITS } from '../../src/core/types.js'; let tmpDir: string; @@ -157,4 +157,38 @@ describe('resolveMaxAgents', () => { it('large project (>=25 files) uses full profile max for budget profile', () => { expect(resolveMaxAgents(ModelProfile.BUDGET, 50)).toBe(PARALLELISM_LIMITS[ModelProfile.BUDGET].max_agents); }); + + it('backward compatibility: calling without complexity parameter defaults to medium', () => { + const withDefault = resolveMaxAgents(ModelProfile.BALANCED, 50); + const withMedium = resolveMaxAgents(ModelProfile.BALANCED, 50, TaskComplexity.MEDIUM); + expect(withDefault).toBe(withMedium); + }); + + it('complexity=simple halves the cap (rounded down, min 1)', () => { + const limits = PARALLELISM_LIMITS[ModelProfile.BALANCED]; + const cap = limits.max_agents; + const expected = Math.max(1, Math.floor(cap / 2)); + expect(resolveMaxAgents(ModelProfile.BALANCED, 50, TaskComplexity.SIMPLE)).toBe(expected); + }); + + it('complexity=complex uses the full cap', () => { + const limits = PARALLELISM_LIMITS[ModelProfile.BALANCED]; + expect(resolveMaxAgents(ModelProfile.BALANCED, 50, TaskComplexity.COMPLEX)).toBe(limits.max_agents); + }); + + it('complexity=medium uses the same result as no complexity argument', () => { + expect(resolveMaxAgents(ModelProfile.QUALITY, 30, TaskComplexity.MEDIUM)).toBe( + resolveMaxAgents(ModelProfile.QUALITY, 30), + ); + }); + + it('complexity=simple with small project caps at minimum 1', () => { + expect(resolveMaxAgents(ModelProfile.BALANCED, 5, TaskComplexity.SIMPLE)).toBe(2); + }); + + it('complexity=simple with budget profile large project halves max_agents', () => { + const limits = PARALLELISM_LIMITS[ModelProfile.BUDGET]; + const expected = Math.max(1, Math.floor(limits.max_agents / 2)); + expect(resolveMaxAgents(ModelProfile.BUDGET, 50, TaskComplexity.SIMPLE)).toBe(expected); + }); });