diff --git a/packages/agent-core-v2/src/profile/context.ts b/packages/agent-core-v2/src/profile/context.ts new file mode 100644 index 000000000..224b97105 --- /dev/null +++ b/packages/agent-core-v2/src/profile/context.ts @@ -0,0 +1,330 @@ +/** + * `profile` domain (L4) — system-prompt context assembly. + * + * Loads the AGENTS.md instruction hierarchy (user-level brand + generic files, + * then project-level files from the project root down to the cwd) and assembles + * the {@link SystemPromptContext} bag consumed by `IAgentProfileService.useProfile`. + * + * Port of v1 `packages/agent-core/src/profile/context.ts`. The combined + * AGENTS.md content is injected in full; when it exceeds the soft + * {@link AGENTS_MD_RECOMMENDED_MAX_BYTES} budget a visible `agentsMdWarning` + * is produced (surfaced through `getSessionWarnings`) instead of silently + * truncating. + */ + +import { basename, dirname, join } from 'pathe'; + +import type { IKaos } from '#/kaos'; + +import type { SystemPromptContext } from './profile'; + +// Soft budget for the combined AGENTS.md content injected into the system +// prompt. ~32 KB is roughly 8K–20K tokens (≈1.5–3% of a 262144-token context), +// large enough to leave the bulk of the context window to the conversation +// while still catching accidental oversized instruction files. Exceeding it no +// longer truncates content; it only surfaces a user-visible warning so the user +// can trim oversized instruction files. +export const AGENTS_MD_RECOMMENDED_MAX_BYTES = 32 * 1024; + +const S_IFMT = 0o170000; +const S_IFREG = 0o100000; +const S_IFDIR = 0o040000; + +export const LIST_DIR_ROOT_WIDTH = 30; +export const LIST_DIR_CHILD_WIDTH = 10; + +export interface PreparedSystemPromptContext extends SystemPromptContext { + readonly cwdListing?: string; + readonly agentsMd?: string; + readonly additionalDirsInfo?: string; + /** Present when the combined AGENTS.md content exceeds the recommended size. */ + readonly agentsMdWarning?: string; +} + +export interface PrepareSystemPromptContextOptions { + readonly additionalDirs?: readonly string[]; +} + +export async function prepareSystemPromptContext( + kaos: IKaos, + brandHome?: string, + options?: PrepareSystemPromptContextOptions, +): Promise { + const additionalDirs = dedupeDirs(options?.additionalDirs ?? []); + const [cwdListing, agentsMdResult, additionalDirsInfo] = await Promise.all([ + listDirectory(kaos, undefined, { collapseHiddenDirs: true }), + loadAgentsMdForRoots(kaos, brandHome, [kaos.getcwd()]), + loadAdditionalDirsInfo(kaos, additionalDirs), + ]); + return { + cwdListing, + agentsMd: agentsMdResult.content, + additionalDirsInfo, + agentsMdWarning: agentsMdResult.warning, + }; +} + +export async function loadAgentsMd(kaos: IKaos, brandHome?: string): Promise { + const result = await loadAgentsMdForRoots(kaos, brandHome, [kaos.getcwd()]); + return result.content; +} + +interface LoadedAgentsMd { + readonly content: string; + readonly warning: string | undefined; +} + +async function loadAgentsMdForRoots( + kaos: IKaos, + brandHome: string | undefined, + workDirs: readonly string[], +): Promise { + const discovered: AgentFile[] = []; + const seen = new Set(); + + const collect = async (path: string): Promise => { + const file = await readAgentFile(kaos, path); + if (file === undefined) return false; + const key = kaos.normpath(file.path); + if (seen.has(key)) return false; + seen.add(key); + discovered.push(file); + return true; + }; + + // User-level files come first so any project-level AGENTS.md overrides them. + // The brand dir follows KIMI_CODE_HOME (default ~/.kimi-code); the generic + // .agents dir stays under the real OS home so it can be shared across tools. + const realHome = kaos.gethome(); + const brandDir = brandHome ?? join(realHome, '.kimi-code'); + await collect(join(brandDir, 'AGENTS.md')); + + // Generic user-level dir (.agents) matches skill discovery. + const genericDirs = [join(realHome, '.agents')]; + const genericFiles = genericDirs.flatMap((dir) => + ['AGENTS.md', 'agents.md'].map((name) => join(dir, name)), + ); + for (const file of genericFiles) { + if (await collect(file)) break; + } + + for (const workDir of workDirs) { + const rootKaos = kaos.withCwd(workDir); + const rootWorkDir = rootKaos.getcwd(); + const projectRoot = await findProjectRoot(rootKaos, rootWorkDir); + const dirs = dirsRootToLeaf(rootKaos, rootWorkDir, projectRoot); + + for (const dir of dirs) { + await collect(join(dir, '.kimi-code', 'AGENTS.md')); + for (const fileName of ['AGENTS.md', 'agents.md']) { + if (await collect(join(dir, fileName))) break; + } + } + } + + const content = renderAgentFiles(discovered); + const totalBytes = byteLength(content); + const warning = + totalBytes > AGENTS_MD_RECOMMENDED_MAX_BYTES + ? `AGENTS.md total ${formatKB(totalBytes)} KB exceeds the recommended ` + + `${formatKB(AGENTS_MD_RECOMMENDED_MAX_BYTES)} KB. Large instruction files ` + + `increase cost and may impact performance; consider trimming.` + : undefined; + return { content, warning }; +} + +async function loadAdditionalDirsInfo(kaos: IKaos, additionalDirs: readonly string[]): Promise { + const sections = await Promise.all( + additionalDirs.map(async (dir) => { + const listing = await listDirectory(kaos.withCwd(dir)); + return `### ${dir}\n${listing}`; + }), + ); + return sections.join('\n\n'); +} + +async function findProjectRoot(kaos: IKaos, workDir: string): Promise { + const initial = kaos.normpath(workDir); + let current = initial; + + while (true) { + if (await pathExists(kaos, join(current, '.git'))) return current; + const parent = dirname(current); + if (parent === current) return initial; + current = parent; + } +} + +function dirsRootToLeaf(kaos: IKaos, workDir: string, projectRoot: string): string[] { + const dirs: string[] = []; + let current = kaos.normpath(workDir); + + while (true) { + dirs.push(current); + if (current === projectRoot) break; + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + + return dirs.toReversed(); +} + +interface AgentFile { + readonly path: string; + readonly content: string; +} + +async function readAgentFile(kaos: IKaos, path: string): Promise { + if (!(await isFile(kaos, path))) return undefined; + const content = (await kaos.backend.readText(path, { errors: 'ignore' })).trim(); + if (content.length === 0) return undefined; + return { path, content }; +} + +async function pathExists(kaos: IKaos, path: string): Promise { + try { + await kaos.backend.stat(path); + return true; + } catch { + return false; + } +} + +async function isFile(kaos: IKaos, path: string): Promise { + try { + const stat = await kaos.backend.stat(path); + return (stat.stMode & S_IFMT) === S_IFREG; + } catch { + return false; + } +} + +function renderAgentFiles(files: readonly AgentFile[]): string { + if (files.length === 0) return ''; + return files.map((file) => `${annotationFor(file.path)}${file.content}`).join('\n\n'); +} + +function byteLength(text: string): number { + return Buffer.byteLength(text, 'utf8'); +} + +function formatKB(bytes: number): string { + const kb = bytes / 1024; + return Number.isInteger(kb) ? String(kb) : kb.toFixed(1); +} + +function annotationFor(path: string): string { + return `\n`; +} + +function dedupeDirs(dirs: readonly string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const dir of dirs) { + if (typeof dir !== 'string') continue; + const trimmed = dir.trim(); + if (trimmed.length === 0 || seen.has(trimmed)) continue; + seen.add(trimmed); + result.push(trimmed); + } + return result; +} + +// --------------------------------------------------------------------------- +// listDirectory — compact 2-level directory tree for LLM context. +// Port of v1 `packages/agent-core/src/tools/support/list-directory.ts`, driven +// through the v2 `IKaos` backend (`iterdir` + `stat`). +// --------------------------------------------------------------------------- + +interface ListDirectoryOptions { + readonly collapseHiddenDirs?: boolean; +} + +interface Entry { + readonly name: string; + readonly isDir: boolean; +} + +async function collectEntries( + kaos: IKaos, + dirPath: string, + maxWidth: number, +): Promise<{ entries: Entry[]; total: number; readable: boolean }> { + const all: Entry[] = []; + try { + for await (const fullPath of kaos.backend.iterdir(dirPath)) { + const name = basename(fullPath); + let isDir = false; + try { + const st = await kaos.backend.stat(fullPath); + isDir = (st.stMode & S_IFMT) === S_IFDIR; + } catch { + // Unreadable entries keep isDir=false; still list the name. + } + all.push({ name, isDir }); + } + } catch { + return { entries: [], total: 0, readable: false }; + } + all.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return { entries: all.slice(0, maxWidth), total: all.length, readable: true }; +} + +function shouldCollapseDirectory(entry: Entry, options: ListDirectoryOptions): boolean { + return options.collapseHiddenDirs === true && entry.isDir && entry.name.startsWith('.'); +} + +async function listDirectory( + kaos: IKaos, + workDir: string = kaos.getcwd(), + options: ListDirectoryOptions = {}, +): Promise { + const lines: string[] = []; + const { entries, total, readable } = await collectEntries(kaos, workDir, LIST_DIR_ROOT_WIDTH); + if (!readable) return '[not readable]'; + const remaining = total - entries.length; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry === undefined) continue; + const { name, isDir } = entry; + const isLast = i === entries.length - 1 && remaining === 0; + const connector = isLast ? '└── ' : '├── '; + + if (isDir) { + lines.push(`${connector}${name}/`); + if (shouldCollapseDirectory(entry, options)) continue; + const childPrefix = isLast ? ' ' : '│ '; + const childDir = join(workDir, name); + const child = await collectEntries(kaos, childDir, LIST_DIR_CHILD_WIDTH); + if (!child.readable) { + lines.push(`${childPrefix}└── [not readable]`); + continue; + } + const childRemaining = child.total - child.entries.length; + for (let j = 0; j < child.entries.length; j++) { + const ce = child.entries[j]; + if (ce === undefined) continue; + const cIsLast = j === child.entries.length - 1 && childRemaining === 0; + const cConnector = cIsLast ? '└── ' : '├── '; + const suffix = ce.isDir ? '/' : ''; + lines.push(`${childPrefix}${cConnector}${ce.name}${suffix}`); + } + if (childRemaining > 0) { + lines.push(`${childPrefix}└── ... and ${String(childRemaining)} more`); + } + } else { + lines.push(`${connector}${name}`); + } + } + + if (remaining > 0) { + lines.push(`└── ... and ${String(remaining)} more entries`); + } + + return lines.length > 0 ? lines.join('\n') : '(empty directory)'; +} diff --git a/packages/agent-core-v2/src/profile/profile.ts b/packages/agent-core-v2/src/profile/profile.ts index 6c18510d4..ce7739f12 100644 --- a/packages/agent-core-v2/src/profile/profile.ts +++ b/packages/agent-core-v2/src/profile/profile.ts @@ -33,6 +33,17 @@ export type AgentConfigUpdateData = Partial<{ export interface SystemPromptContext { readonly cwd?: string; + /** 2-level tree listing of the working directory, for LLM orientation. */ + readonly cwdListing?: string; + /** Concatenated AGENTS.md instruction hierarchy (user-level + project-level). */ + readonly agentsMd?: string; + /** Rendered listings of additional workspace directories. */ + readonly additionalDirsInfo?: string; + /** + * Present when the combined AGENTS.md content exceeds the recommended soft + * budget. Surfaced through `getSessionWarnings` instead of truncating. + */ + readonly agentsMdWarning?: string; readonly [key: string]: unknown; } @@ -62,6 +73,14 @@ export interface ProfileServiceOptions { readonly emitStatusUpdated?: () => void; } +export interface ApplyProfileOptions { + /** + * Additional workspace directories whose listings are appended to the system + * prompt context. Defaults to the session workspace's additional dirs. + */ + readonly additionalDirs?: readonly string[]; +} + export interface ProfileModelContext { readonly provider: ProviderConfig; readonly modelAlias: string; @@ -86,6 +105,20 @@ export interface IAgentProfileService { setThinking(level: string): void; getModel(): string; useProfile(profile: ResolvedAgentProfile, context: SystemPromptContext): void; + /** + * Production entry point for applying a profile: assembles the + * {@link SystemPromptContext} (loading the AGENTS.md hierarchy, cwd listing, + * and additional-dir listings), renders the profile's system prompt via + * {@link useProfile}, and caches any AGENTS.md size warning for + * {@link getAgentsMdWarning} / `getSessionWarnings`. + */ + applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise; + /** + * The AGENTS.md size warning produced by the most recent {@link applyProfile}, + * if the combined AGENTS.md content exceeded the recommended soft budget. + * `undefined` when no oversized content has been observed. + */ + getAgentsMdWarning(): string | undefined; data(): ProfileData; resolveModelContext(): ProfileModelContext; getProvider(): ChatProvider; diff --git a/packages/agent-core-v2/src/profile/profileService.ts b/packages/agent-core-v2/src/profile/profileService.ts index c43fc968b..2aa3f7aeb 100644 --- a/packages/agent-core-v2/src/profile/profileService.ts +++ b/packages/agent-core-v2/src/profile/profileService.ts @@ -20,12 +20,15 @@ import { import picomatch from 'picomatch'; import { ErrorCodes, KimiError } from "#/errors"; +import { IBootstrapService } from '#/bootstrap'; import { IConfigRegistry, IConfigService } from '#/config'; import { resolveThinkingEffort, type ThinkingEffort } from '#/config/thinking'; import { applyKimiModelOverrides, IChatProviderFactory, type KimiModelOverrides } from '#/chatProvider'; import type { LoopControl } from '#/loop/configSection'; +import { IKaos } from '#/kaos'; import { isMcpToolName } from '#/tool'; import { ISessionModelResolver, type ResolvedModel } from '#/modelRuntime'; +import { ISessionWorkspaceContext } from '#/workspaceContext'; import type { ResolvedAgentProfile, SystemPromptContext } from '#/profile'; import { IAgentEventSinkService } from '../eventSink'; @@ -33,7 +36,9 @@ import { IAgentReplayBuilderService } from '#/replayBuilder'; import { ITelemetryService } from '#/telemetry'; import type { ToolSource } from '#/tool'; import { IAgentWireRecordService } from '#/wireRecord'; +import { prepareSystemPromptContext } from './context'; import type { + ApplyProfileOptions, ProfileData, ProfileModelContext, ProfileServiceOptions, @@ -69,6 +74,7 @@ export class AgentProfileService implements IAgentProfileService { private thinkingLevelValue: ThinkingEffort = 'off'; private systemPrompt = ''; private activeToolNames: readonly string[] | undefined; + private agentsMdWarning: string | undefined; constructor( @IAgentWireRecordService private readonly wireRecord: IAgentWireRecordService, @@ -79,6 +85,9 @@ export class AgentProfileService implements IAgentProfileService { @IConfigService private readonly config: IConfigService, @ISessionModelResolver private readonly modelResolver: ISessionModelResolver, @IChatProviderFactory private readonly chatProviders: IChatProviderFactory, + @IKaos private readonly kaos: IKaos, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, ) { configRegistry.registerSection(THINKING_SECTION, ThinkingConfigSchema, { env: thinkingEnvBindings, @@ -157,6 +166,26 @@ export class AgentProfileService implements IAgentProfileService { this.setActiveTools(profile.tools); } + async applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise { + const context = await prepareSystemPromptContext(this.kaos, this.bootstrap.homeDir, { + additionalDirs: options?.additionalDirs ?? this.workspace.additionalDirs, + }); + this.useProfile(profile, context); + const { agentsMdWarning } = context; + this.agentsMdWarning = agentsMdWarning; + if (agentsMdWarning !== undefined) { + this.events.emit({ + type: 'warning', + message: agentsMdWarning, + code: 'agents-md-oversized', + }); + } + } + + getAgentsMdWarning(): string | undefined { + return this.agentsMdWarning; + } + data(): ProfileData { const resolved = this.tryResolvedProviderConfig(); return { diff --git a/packages/agent-core-v2/src/session/index.ts b/packages/agent-core-v2/src/session/index.ts index 0e261f943..f8f0772ee 100644 --- a/packages/agent-core-v2/src/session/index.ts +++ b/packages/agent-core-v2/src/session/index.ts @@ -6,3 +6,5 @@ export * from './session'; export * from './sessionService'; +export * from './sessionWarning'; +export * from './sessionWarningService'; diff --git a/packages/agent-core-v2/src/session/sessionWarning.ts b/packages/agent-core-v2/src/session/sessionWarning.ts new file mode 100644 index 000000000..bac12ec72 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionWarning.ts @@ -0,0 +1,25 @@ +/** + * `session` domain (L6) — session-warning contract. + * + * Produces the session-level warnings surfaced through the `getSessionWarnings` + * RPC (e.g. the `agents-md-oversized` warning). Backed by {@link ISessionWarningService}. + */ + +import type { SessionWarning } from '@moonshot-ai/protocol'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export type { SessionWarning }; + +export interface ISessionWarningService { + readonly _serviceBrand: undefined; + /** + * Compute the current session-level warnings. Recomputes the AGENTS.md size + * warning on demand (preferring the main agent's cached value when the agent + * is live) so the warning surfaces even for long-lived / resumed sessions. + */ + getSessionWarnings(): Promise; +} + +export const ISessionWarningService: ServiceIdentifier = + createDecorator('sessionWarningService'); diff --git a/packages/agent-core-v2/src/session/sessionWarningService.ts b/packages/agent-core-v2/src/session/sessionWarningService.ts new file mode 100644 index 000000000..bbd6a6fef --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionWarningService.ts @@ -0,0 +1,78 @@ +/** + * `session` domain (L6) — `ISessionWarningService` implementation. + * + * Aggregates session-level warnings. Today the only source is the + * `agents-md-oversized` warning, computed from the AGENTS.md hierarchy via + * `prepareSystemPromptContext` (the same soft budget used when the system + * prompt is assembled). The main agent's cached value (populated by + * `IAgentProfileService.applyProfile`) is preferred when the agent is live; + * otherwise the warning is recomputed on demand. Bound at Session scope. + */ + +import type { SessionWarning } from '@moonshot-ai/protocol'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentLifecycleService } from '#/agent-lifecycle'; +import { IBootstrapService } from '#/bootstrap'; +import { IKaos } from '#/kaos'; +import { IAgentProfileService, prepareSystemPromptContext } from '#/profile'; +import { ISessionWorkspaceContext } from '#/workspaceContext'; + +import { ISessionWarningService } from './sessionWarning'; + +const MAIN_AGENT_ID = 'main'; +const AGENTS_MD_OVERSIZED_CODE = 'agents-md-oversized'; + +export class SessionWarningService implements ISessionWarningService { + declare readonly _serviceBrand: undefined; + + constructor( + @IKaos private readonly kaos: IKaos, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, + ) {} + + async getSessionWarnings(): Promise { + const agentsMdWarning = await this.resolveAgentsMdWarning(); + if (agentsMdWarning === undefined) return []; + return [ + { + code: AGENTS_MD_OVERSIZED_CODE, + message: agentsMdWarning, + severity: 'warning', + }, + ]; + } + + private async resolveAgentsMdWarning(): Promise { + const cached = this.readMainAgentWarning(); + if (cached !== undefined) return cached; + // No live main agent (or it has not applied a profile yet): recompute on + // demand so the warning still surfaces for long-lived / resumed sessions. + try { + const context = await prepareSystemPromptContext(this.kaos, this.bootstrap.homeDir, { + additionalDirs: this.workspace.additionalDirs, + }); + return context.agentsMdWarning; + } catch { + // Best-effort: warning retrieval must not throw to the caller. + return undefined; + } + } + + private readMainAgentWarning(): string | undefined { + const main = this.agentLifecycle.getHandle(MAIN_AGENT_ID); + if (main === undefined) return undefined; + return main.accessor.get(IAgentProfileService).getAgentsMdWarning(); + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionWarningService, + SessionWarningService, + InstantiationType.Delayed, + 'sessionWarning', +); diff --git a/packages/agent-core-v2/test/profile/apply-profile.test.ts b/packages/agent-core-v2/test/profile/apply-profile.test.ts new file mode 100644 index 000000000..68eb9367d --- /dev/null +++ b/packages/agent-core-v2/test/profile/apply-profile.test.ts @@ -0,0 +1,103 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { LocalKaos, type Environment } from '@moonshot-ai/kaos'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { IKaos } from '#/kaos'; +import { IAgentProfileService, type ResolvedAgentProfile } from '#/profile'; + +import { createTestAgent, kaosServices, type TestAgentContext } from '../harness'; + +const TEST_OS_ENV: Environment = { + osKind: 'Linux', + osArch: 'x86_64', + osVersion: 'test', + shellName: 'bash', + shellPath: '/bin/bash', +}; + +type LocalKaosCtor = new (osEnv: Environment) => LocalKaos; + +function createRealKaos(cwd: string): LocalKaos { + const base = new (LocalKaos as unknown as LocalKaosCtor)(TEST_OS_ENV); + return base.withCwd(cwd) as LocalKaos; +} + +const profile: ResolvedAgentProfile = { + name: 'agents-profile', + systemPrompt: (context) => + typeof context['agentsMd'] === 'string' ? (context['agentsMd'] as string) : '', + tools: [], +}; + +describe('AgentProfileService.applyProfile', () => { + let ctx: TestAgentContext; + let homeDir: string; + let workDir: string; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'kimi-apply-home-')); + workDir = await mkdtemp(join(tmpdir(), 'kimi-apply-work-')); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await ctx?.dispose(); + await rm(homeDir, { recursive: true, force: true }); + await rm(workDir, { recursive: true, force: true }); + }); + + function buildContext(): { ctx: TestAgentContext; profile: IAgentProfileService } { + ctx = createTestAgent(kaosServices(createRealKaos(workDir))); + // Keep the user-level AGENTS.md discovery hermetic: point the OS home at an + // empty temp dir so a developer's real ~/.kimi-code / ~/.agents files never + // leak into the assertions. + vi.spyOn(ctx.get(IKaos), 'gethome').mockReturnValue(homeDir); + return { ctx, profile: ctx.get(IAgentProfileService) }; + } + + it('loads AGENTS.md into the rendered system prompt', async () => { + await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf-8'); + const { profile: svc } = buildContext(); + + await svc.applyProfile(profile); + + expect(svc.data().systemPrompt).toContain('project instructions'); + expect(svc.data().systemPrompt).toContain(``); + expect(svc.getAgentsMdWarning()).toBeUndefined(); + }); + + it('caches an agents-md warning when the content exceeds the 32 KB soft budget', async () => { + const largeContent = 'x'.repeat(40 * 1024); + await writeFile(join(workDir, 'AGENTS.md'), largeContent, 'utf-8'); + const { ctx: context, profile: svc } = buildContext(); + + await svc.applyProfile(profile); + + expect(svc.data().systemPrompt).toContain(largeContent); + const warning = svc.getAgentsMdWarning(); + expect(warning).toBeDefined(); + expect(warning).toContain('exceeds the recommended'); + + const events = context.newEvents() as readonly { + event: string; + args?: { code?: string }; + }[]; + expect( + events.some( + (entry) => entry.event === 'warning' && entry.args?.code === 'agents-md-oversized', + ), + ).toBe(true); + }); + + it('does not cache a warning when the content is within the budget', async () => { + await writeFile(join(workDir, 'AGENTS.md'), 'small instructions', 'utf-8'); + const { profile: svc } = buildContext(); + + await svc.applyProfile(profile); + + expect(svc.getAgentsMdWarning()).toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/profile/context.test.ts b/packages/agent-core-v2/test/profile/context.test.ts new file mode 100644 index 000000000..d2bc8f20d --- /dev/null +++ b/packages/agent-core-v2/test/profile/context.test.ts @@ -0,0 +1,271 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { LocalKaos, type Environment, type Kaos } from '@moonshot-ai/kaos'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { IKaos, PathClass } from '#/kaos'; +import { loadAgentsMd, prepareSystemPromptContext } from '#/profile'; + +const TEST_OS_ENV: Environment = { + osKind: 'Linux', + osArch: 'x86_64', + osVersion: 'test', + shellName: 'bash', + shellPath: '/bin/bash', +}; + +// `LocalKaos`'s constructor is `private` at the TS level only — at runtime it's +// just a function. Skip the singleton/async detection path and build a fresh +// instance with a stub `osEnv` so tests can hand a real IKaos directly to the +// profile context loaders (mirrors v1's `testKaos` fixture). +type LocalKaosCtor = new (osEnv: Environment) => LocalKaos; + +function createTestKaos(): IKaos { + const backend: Kaos = new (LocalKaos as unknown as LocalKaosCtor)(TEST_OS_ENV); + return wrapKaos(backend); +} + +function wrapKaos(backend: Kaos): IKaos { + return { + _serviceBrand: undefined, + get name() { + return backend.name; + }, + get cwd() { + return backend.getcwd(); + }, + get osEnv() { + return backend.osEnv; + }, + backend, + pathClass: (): PathClass => backend.pathClass(), + normpath: (path) => backend.normpath(path), + gethome: () => backend.gethome(), + getcwd: () => backend.getcwd(), + withCwd: (cwd) => wrapKaos(backend.withCwd(cwd)), + withEnv: (env) => wrapKaos(backend.withEnv(env)), + }; +} + +let kaos: IKaos; +let homeDir: string; +let workDir: string; +let extraDirs: string[]; + +beforeEach(async () => { + kaos = createTestKaos(); + homeDir = await mkdtemp(join(tmpdir(), 'kimi-agents-home-')); + workDir = await mkdtemp(join(tmpdir(), 'kimi-agents-work-')); + extraDirs = []; + vi.spyOn(kaos, 'gethome').mockReturnValue(homeDir); + vi.spyOn(kaos, 'getcwd').mockReturnValue(workDir); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await rm(homeDir, { recursive: true, force: true }); + await rm(workDir, { recursive: true, force: true }); + await Promise.all(extraDirs.map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('loadAgentsMd user-level discovery', () => { + it('loads user-level branded and generic files before project-level', async () => { + await mkdir(join(homeDir, '.kimi-code'), { recursive: true }); + await writeFile(join(homeDir, '.kimi-code', 'AGENTS.md'), 'user branded', 'utf-8'); + await mkdir(join(homeDir, '.agents'), { recursive: true }); + await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'user generic', 'utf-8'); + await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf-8'); + + const result = await loadAgentsMd(kaos); + + expect(result).toContain('user branded'); + expect(result).toContain('user generic'); + expect(result).toContain('project instructions'); + expect(result.indexOf('user branded')).toBeLessThan(result.indexOf('user generic')); + expect(result.indexOf('user generic')).toBeLessThan(result.indexOf('project instructions')); + }); + + it('loads generic user-level .agents/AGENTS.md', async () => { + await mkdir(join(homeDir, '.agents'), { recursive: true }); + await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'dot-agents generic', 'utf-8'); + + const result = await loadAgentsMd(kaos); + + expect(result).toContain('dot-agents generic'); + }); + + it('falls back to project-level only when no user-level files exist', async () => { + await writeFile(join(workDir, 'AGENTS.md'), 'project only', 'utf-8'); + + const result = await loadAgentsMd(kaos); + + expect(result).toContain('project only'); + expect(result).not.toContain(homeDir); + }); + + it('does not load the same file twice when the work dir is the home dir', async () => { + vi.spyOn(kaos, 'getcwd').mockReturnValue(homeDir); + await mkdir(join(homeDir, '.kimi-code'), { recursive: true }); + await writeFile(join(homeDir, '.kimi-code', 'AGENTS.md'), 'home branded', 'utf-8'); + + const result = await loadAgentsMd(kaos); + + expect(result.split('home branded').length - 1).toBe(1); + }); +}); + +describe('loadAgentsMd brand home (KIMI_CODE_HOME)', () => { + let brandHome: string; + + beforeEach(async () => { + brandHome = await mkdtemp(join(tmpdir(), 'kimi-agents-brand-')); + }); + + afterEach(async () => { + await rm(brandHome, { recursive: true, force: true }); + }); + + it('loads the branded AGENTS.md from the brand home and generic from the real home', async () => { + await writeFile(join(brandHome, 'AGENTS.md'), 'brand home instructions', 'utf-8'); + await mkdir(join(homeDir, '.agents'), { recursive: true }); + await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'real home generic', 'utf-8'); + + const result = await loadAgentsMd(kaos, brandHome); + + expect(result).toContain('brand home instructions'); + expect(result).toContain('real home generic'); + }); + + it('ignores the real-home .kimi-code/AGENTS.md when the brand home is elsewhere', async () => { + await writeFile(join(brandHome, 'AGENTS.md'), 'brand wins', 'utf-8'); + await mkdir(join(homeDir, '.kimi-code'), { recursive: true }); + await writeFile(join(homeDir, '.kimi-code', 'AGENTS.md'), 'stale real-home brand', 'utf-8'); + + const result = await loadAgentsMd(kaos, brandHome); + + expect(result).toContain('brand wins'); + expect(result).not.toContain('stale real-home brand'); + }); + + it('falls back to the real-home .kimi-code/AGENTS.md when no brand home is given', async () => { + await mkdir(join(homeDir, '.kimi-code'), { recursive: true }); + await writeFile(join(homeDir, '.kimi-code', 'AGENTS.md'), 'fallback branded', 'utf-8'); + + const result = await loadAgentsMd(kaos); + + expect(result).toContain('fallback branded'); + }); +}); + +describe('loadAgentsMd nested project hierarchy', () => { + it('loads AGENTS.md from the project root down to the cwd in root→leaf order', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'kimi-agents-project-')); + extraDirs.push(projectRoot); + const leaf = join(projectRoot, 'packages', 'app'); + await mkdir(leaf, { recursive: true }); + // Mark the project root so findProjectRoot stops here. + await mkdir(join(projectRoot, '.git')); + await writeFile(join(projectRoot, 'AGENTS.md'), 'root instructions', 'utf-8'); + await writeFile(join(projectRoot, 'packages', 'AGENTS.md'), 'packages instructions', 'utf-8'); + await writeFile(join(leaf, 'AGENTS.md'), 'leaf instructions', 'utf-8'); + vi.spyOn(kaos, 'getcwd').mockReturnValue(leaf); + + const result = await loadAgentsMd(kaos); + + expect(result).toContain('root instructions'); + expect(result).toContain('packages instructions'); + expect(result).toContain('leaf instructions'); + expect(result.indexOf('root instructions')).toBeLessThan(result.indexOf('packages instructions')); + expect(result.indexOf('packages instructions')).toBeLessThan(result.indexOf('leaf instructions')); + }); +}); + +describe('loadAgentsMd oversized content', () => { + it('keeps the full content when AGENTS.md exceeds the recommended size', async () => { + const largeContent = 'x'.repeat(40 * 1024); + await writeFile(join(workDir, 'AGENTS.md'), largeContent, 'utf-8'); + + const result = await loadAgentsMd(kaos); + + expect(result).toContain(largeContent); + expect(result).not.toContain('truncated or omitted'); + }); +}); + +describe('prepareSystemPromptContext AGENTS.md size warning', () => { + it('returns agentsMdWarning and keeps full content when oversized', async () => { + const brandHome = await mkdtemp(join(tmpdir(), 'kimi-agents-brand-')); + extraDirs.push(brandHome); + const largeContent = 'x'.repeat(40 * 1024); + await writeFile(join(workDir, 'AGENTS.md'), largeContent, 'utf-8'); + + const result = await prepareSystemPromptContext(kaos, brandHome); + + expect(result.agentsMd).toContain(largeContent); + expect(result.agentsMdWarning).toBeDefined(); + expect(result.agentsMdWarning).toContain('exceeds the recommended'); + }); + + it('does not return agentsMdWarning when within the recommended size', async () => { + const brandHome = await mkdtemp(join(tmpdir(), 'kimi-agents-brand-')); + extraDirs.push(brandHome); + await writeFile(join(workDir, 'AGENTS.md'), 'small instructions', 'utf-8'); + + const result = await prepareSystemPromptContext(kaos, brandHome); + + expect(result.agentsMdWarning).toBeUndefined(); + }); +}); + +describe('prepareSystemPromptContext additional directories', () => { + it('includes additional directory listings without loading their AGENTS.md', async () => { + const brandHome = await mkdtemp(join(tmpdir(), 'kimi-agents-empty-brand-')); + extraDirs.push(brandHome); + const extraDir = await mkdtemp(join(tmpdir(), 'kimi-agents-extra-')); + extraDirs.push(extraDir); + + await writeFile(join(workDir, 'AGENTS.md'), 'repo project instructions', 'utf-8'); + await writeFile(join(extraDir, 'AGENTS.md'), 'extra project instructions', 'utf-8'); + await writeFile(join(extraDir, 'extra-file.txt'), 'extra listing entry', 'utf-8'); + + const result = await prepareSystemPromptContext(kaos, brandHome, { + additionalDirs: [extraDir], + }); + + const agentsMd = result.agentsMd ?? ''; + + expect(result.cwdListing).toBeTypeOf('string'); + expect(result.additionalDirsInfo).toContain(`### ${extraDir}`); + expect(result.additionalDirsInfo).toContain('extra-file.txt'); + expect(agentsMd).toContain('repo project instructions'); + expect(agentsMd).not.toContain('extra project instructions'); + expect(agentsMd.split('