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
5 changes: 5 additions & 0 deletions .changeset/skill-watch-fd-exhaustion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix all tool calls failing with spawn EBADF on macOS when a skill folder contains a very large file tree.
74 changes: 72 additions & 2 deletions packages/agent-core-v2/src/_base/utils/paths.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,94 @@
/**
* Path-filter helpers — pure string predicates, no IO.
* `_base/utils/paths` (cross-cutting) — pure path-filter predicates.
*
* Constrains filesystem watches to selected subtrees and scanner-visible
* entries.
*/

function normalizeSlashes(p: string): string {
return p.replaceAll('\\', '/');
}

export interface SubtreeWatchFilterOptions {
readonly maxDepth?: number;
readonly skipEntry?: (entryName: string) => boolean;
readonly keepEntryFile?: string;
readonly scannedDirectories?: readonly string[];
}

export function subtreeWatchFilter(
root: string,
candidates: readonly string[],
options?: SubtreeWatchFilterOptions,
): (path: string) => boolean {
const normRoot = normalizeSlashes(root);
const normCandidates = candidates.map(normalizeSlashes);
const normScannedDirectories =
options?.scannedDirectories === undefined
? undefined
: new Set([...normCandidates, ...options.scannedDirectories.map(normalizeSlashes)]);
return (p: string): boolean => {
const norm = normalizeSlashes(p);
if (norm === normRoot) return false;
for (const candidate of normCandidates) {
if (norm === candidate || norm.startsWith(`${candidate}/`)) return false;
if (norm === candidate) return false;
if (norm.startsWith(`${candidate}/`)) {
return isPrunedBelowCandidate(
norm,
norm.slice(candidate.length + 1),
options,
normScannedDirectories,
);
}
if (candidate.startsWith(`${norm}/`)) return false;
}
return true;
};
}

function isPrunedBelowCandidate(
normPath: string,
rel: string,
options: SubtreeWatchFilterOptions | undefined,
scannedDirectories: ReadonlySet<string> | undefined,
): boolean {
if (options === undefined) return false;
const segments = rel.split('/');
if (options.maxDepth !== undefined && segments.length > options.maxDepth) return true;
if (options.skipEntry !== undefined) {
const excludedAt = segments.findIndex(options.skipEntry);
if (excludedAt !== -1) {
if (segments.length <= excludedAt + 1) return false;
return !(
options.keepEntryFile !== undefined &&
segments.length === excludedAt + 2 &&
segments.at(-1) === options.keepEntryFile
);
}
}
if (scannedDirectories !== undefined) {
return !isScannerVisiblePath(normPath, scannedDirectories, options.keepEntryFile);
}
return false;
}

function isScannerVisiblePath(
normPath: string,
scannedDirectories: ReadonlySet<string>,
keepEntryFile: string | undefined,
): boolean {
if (scannedDirectories.has(normPath)) return true;
const separatorAt = normPath.lastIndexOf('/');
if (separatorAt === -1) return false;
const parent = normPath.slice(0, separatorAt);
if (scannedDirectories.has(parent)) return true;
if (
keepEntryFile === undefined ||
normPath.slice(separatorAt + 1) !== keepEntryFile
) {
return false;
}
const parentSeparatorAt = parent.lastIndexOf('/');
if (parentSeparatorAt === -1) return false;
return scannedDirectories.has(parent.slice(0, parentSeparatorAt));
}
15 changes: 11 additions & 4 deletions packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* `skillCatalog` domain — filesystem `ISkillDiscovery` backend.
*
* Discovers skill bundles by walking caller-supplied roots and parsing each
* SKILL.md. Exposes both the App-scoped `ISkillDiscovery` service and a
* stateless standalone function.
* SKILL.md. Exposes discovery through the App-scoped service and a stateless
* filesystem entry point.
*/

import { promises as fs } from 'node:fs';
Expand All @@ -16,7 +16,11 @@ import type { SkillDiscoveryResult, ISkillDiscovery } from './skillDiscovery';
import type { SkillDefinition, SkillRoot, SkippedSkill } from './types';
import { normalizeSkillName } from './types';

const MAX_SKILL_SCAN_DEPTH = 8;
export const MAX_SKILL_SCAN_DEPTH = 8;

export function isSkillScanExcludedEntry(entryName: string): boolean {
return entryName === 'node_modules' || entryName.startsWith('.');
}

export class FileSkillDiscovery implements ISkillDiscovery {
declare readonly _serviceBrand: undefined;
Expand All @@ -36,6 +40,7 @@ export async function discoverFileSkills(
): Promise<SkillDiscoveryResult> {
const byDiscoveryKey = new Map<string, SkillDefinition>();
const skipped: SkippedSkill[] = [];
const scannedDirectories: string[] = [];

async function walkSkillDir(
dirPath: string,
Expand All @@ -52,6 +57,7 @@ export async function discoverFileSkills(
} catch {
return;
}
scannedDirectories.push(dirPath);

const directorySkills = new Set<string>();
const subdirs: string[] = [];
Expand All @@ -60,7 +66,7 @@ export async function discoverFileSkills(
if (await isFile(path.join(entryPath, 'SKILL.md'))) {
directorySkills.add(entry);
}
if (entry === 'node_modules' || entry.startsWith('.')) continue;
if (isSkillScanExcludedEntry(entry)) continue;
if (await isDir(entryPath)) subdirs.push(entry);
}

Expand Down Expand Up @@ -134,6 +140,7 @@ export async function discoverFileSkills(
skills: sortSkills([...byDiscoveryKey.values()]),
skipped,
scannedRoots: roots.map((root) => root.path),
scannedDirectories,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export class InMemorySkillDiscovery implements ISkillDiscovery {
if (roots.some((root) => root.source === 'user')) skills.push(...this.userSkills);
if (roots.some((root) => root.source === 'project')) skills.push(...this.projectSkills);
}
return { skills, skipped: [], scannedRoots: [] };
return { skills, skipped: [], scannedRoots: [], scannedDirectories: [] };
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface SkillDiscoveryResult {
readonly skills: readonly SkillDefinition[];
readonly skipped: readonly SkippedSkill[];
readonly scannedRoots: readonly string[];
readonly scannedDirectories: readonly string[];
}

export interface ISkillDiscovery {
Expand Down
10 changes: 9 additions & 1 deletion packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export interface ProjectSkillRootCandidates {
export async function projectSkillRootCandidates(
workDir: string,
): Promise<ProjectSkillRootCandidates> {
const projectRoot = await findProjectRoot(workDir);
const projectRoot = await realpathOrSelf(await findProjectRoot(workDir));
return {
projectRoot,
candidates: [...PROJECT_BRAND_DIRS, ...PROJECT_GENERIC_DIRS].map((dir) =>
Expand Down Expand Up @@ -145,6 +145,14 @@ async function realpath(p: string): Promise<string> {
return (await fs.realpath(p)).replaceAll('\\', '/');
}

async function realpathOrSelf(p: string): Promise<string> {
try {
return await realpath(p);
} catch {
return p.replaceAll('\\', '/');
}
}

async function exists(p: string): Promise<boolean> {
try {
await fs.stat(p);
Expand Down
Loading
Loading