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
28 changes: 27 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Usage: node cli.cjs <command> [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];
Expand All @@ -24,6 +24,32 @@ const COMMANDS: Record<string, () => 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;

Comment on lines +27 to +31
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

resolve-max-agents treats args[1] as the optional profile, but the documented usage passes flags immediately after the command (e.g. resolve-max-agents --file-count 42 ...). In that case args[1] becomes --file-count, PARALLELISM_LIMITS[profile] is undefined, and the command will crash when resolving limits. Parse the profile as an actual positional arg (skip --* tokens), and/or introduce a --profile flag with validation against known ModelProfile values (fall back to config when absent).

Copilot uses AI. Check for mistakes.
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 <key>'); process.exit(1); }
Expand Down
14 changes: 10 additions & 4 deletions packages/cli/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type Model,
type ModelProfile,
type AgentType,
TaskComplexity,
DEFAULT_CONFIG,
MODEL_PROFILES,
PARALLELISM_LIMITS,
Expand Down Expand Up @@ -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. */
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
AgentType,
Model,
ModelProfile,
TaskComplexity,
VerificationGate,
VerificationResult,
TaskState,
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
24 changes: 22 additions & 2 deletions packages/cli/tests/unit/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
}
});
});
Comment on lines +178 to +196
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

The new tests under “resolve-max-agents command logic” only exercise resolveMaxAgents() directly and don’t cover the actual CLI argument parsing/validation added in src/cli.ts (e.g. flag handling, defaulting, error paths). Given the new command, add coverage that executes the command handler (or factors the parsing into a testable function) so regressions like resolve-max-agents --file-count 42 are caught.

Copilot uses AI. Check for mistakes.
36 changes: 35 additions & 1 deletion packages/cli/tests/unit/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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', () => {
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

This test name says it “caps at minimum 1”, but the assertion expects 2 (and with the current scaling rules a small-project cap of 5 halves to 2). Update the test description to match the expected behavior (or adjust the expectation if the intended behavior is actually to cap at 1).

Suggested change
it('complexity=simple with small project caps at minimum 1', () => {
it('complexity=simple with small project halves the cap (result 2 for cap 5)', () => {

Copilot uses AI. Check for mistakes.
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);
});
});
Loading