mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-25 08:34:39 +00:00
feat(agent-core-v2): load AGENTS.md hierarchy and surface size warning
- add profile/context.ts to load the user- and project-level AGENTS.md hierarchy with a 32KB soft budget - add AgentProfileService.applyProfile as the production entry point that assembles SystemPromptContext and renders the profile - add SessionWarningService as the getSessionWarnings producer, surfacing agents-md-oversized instead of silently truncating - cover loading, applyProfile, and the warning surface with tests
This commit is contained in:
parent
8fbd378277
commit
bfaa75d320
9 changed files with 1055 additions and 0 deletions
330
packages/agent-core-v2/src/profile/context.ts
Normal file
330
packages/agent-core-v2/src/profile/context.ts
Normal file
|
|
@ -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<PreparedSystemPromptContext> {
|
||||
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<string> {
|
||||
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<LoadedAgentsMd> {
|
||||
const discovered: AgentFile[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const collect = async (path: string): Promise<boolean> => {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<AgentFile | undefined> {
|
||||
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<boolean> {
|
||||
try {
|
||||
await kaos.backend.stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isFile(kaos: IKaos, path: string): Promise<boolean> {
|
||||
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 `<!-- From: ${path} -->\n`;
|
||||
}
|
||||
|
||||
function dedupeDirs(dirs: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
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<string> {
|
||||
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)';
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
/**
|
||||
* 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;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -6,3 +6,5 @@
|
|||
|
||||
export * from './session';
|
||||
export * from './sessionService';
|
||||
export * from './sessionWarning';
|
||||
export * from './sessionWarningService';
|
||||
|
|
|
|||
25
packages/agent-core-v2/src/session/sessionWarning.ts
Normal file
25
packages/agent-core-v2/src/session/sessionWarning.ts
Normal file
|
|
@ -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<readonly SessionWarning[]>;
|
||||
}
|
||||
|
||||
export const ISessionWarningService: ServiceIdentifier<ISessionWarningService> =
|
||||
createDecorator<ISessionWarningService>('sessionWarningService');
|
||||
78
packages/agent-core-v2/src/session/sessionWarningService.ts
Normal file
78
packages/agent-core-v2/src/session/sessionWarningService.ts
Normal file
|
|
@ -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<readonly SessionWarning[]> {
|
||||
const agentsMdWarning = await this.resolveAgentsMdWarning();
|
||||
if (agentsMdWarning === undefined) return [];
|
||||
return [
|
||||
{
|
||||
code: AGENTS_MD_OVERSIZED_CODE,
|
||||
message: agentsMdWarning,
|
||||
severity: 'warning',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async resolveAgentsMdWarning(): Promise<string | undefined> {
|
||||
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',
|
||||
);
|
||||
103
packages/agent-core-v2/test/profile/apply-profile.test.ts
Normal file
103
packages/agent-core-v2/test/profile/apply-profile.test.ts
Normal file
|
|
@ -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(`<!-- From: ${join(workDir, 'AGENTS.md')} -->`);
|
||||
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();
|
||||
});
|
||||
});
|
||||
271
packages/agent-core-v2/test/profile/context.test.ts
Normal file
271
packages/agent-core-v2/test/profile/context.test.ts
Normal file
|
|
@ -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('<!-- From:').length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it('loads user-level AGENTS.md once and skips additional directory AGENTS.md', async () => {
|
||||
const brandHome = await mkdtemp(join(tmpdir(), 'kimi-agents-empty-brand-'));
|
||||
extraDirs.push(brandHome);
|
||||
const extraDirA = await mkdtemp(join(tmpdir(), 'kimi-agents-extra-a-'));
|
||||
const extraDirB = await mkdtemp(join(tmpdir(), 'kimi-agents-extra-b-'));
|
||||
extraDirs.push(extraDirA, extraDirB);
|
||||
|
||||
await mkdir(join(homeDir, '.agents'), { recursive: true });
|
||||
await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'shared user instructions', 'utf-8');
|
||||
await writeFile(join(extraDirA, 'AGENTS.md'), 'extra A instructions', 'utf-8');
|
||||
await writeFile(join(extraDirB, 'AGENTS.md'), 'extra B instructions', 'utf-8');
|
||||
|
||||
const result = await prepareSystemPromptContext(kaos, brandHome, {
|
||||
additionalDirs: [extraDirA, extraDirB],
|
||||
});
|
||||
|
||||
const agentsMd = result.agentsMd ?? '';
|
||||
|
||||
expect(result.additionalDirsInfo).toContain(`### ${extraDirA}`);
|
||||
expect(result.additionalDirsInfo).toContain(`### ${extraDirB}`);
|
||||
expect(agentsMd.split('shared user instructions').length - 1).toBe(1);
|
||||
expect(agentsMd).not.toContain('extra A instructions');
|
||||
expect(agentsMd).not.toContain('extra B instructions');
|
||||
});
|
||||
});
|
||||
184
packages/agent-core-v2/test/session/session-warning.test.ts
Normal file
184
packages/agent-core-v2/test/session/session-warning.test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { 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 { InstantiationType } from '#/_base/di/extensions';
|
||||
import {
|
||||
LifecycleScope,
|
||||
_clearScopedRegistryForTests,
|
||||
registerScopedService,
|
||||
} from '#/_base/di/scope';
|
||||
import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test';
|
||||
import { IAgentLifecycleService } from '#/agent-lifecycle';
|
||||
import { IBootstrapService } from '#/bootstrap';
|
||||
import { IKaos, type IKaos as IKaosType, type PathClass } from '#/kaos';
|
||||
import { IAgentProfileService } from '#/profile';
|
||||
import { ISessionWarningService, SessionWarningService } from '#/session';
|
||||
import { ISessionWorkspaceContext } from '#/workspaceContext';
|
||||
|
||||
const TEST_OS_ENV: Environment = {
|
||||
osKind: 'Linux',
|
||||
osArch: 'x86_64',
|
||||
osVersion: 'test',
|
||||
shellName: 'bash',
|
||||
shellPath: '/bin/bash',
|
||||
};
|
||||
|
||||
type LocalKaosCtor = new (osEnv: Environment) => LocalKaos;
|
||||
|
||||
function realIKaos(cwd: string): IKaosType {
|
||||
const backend: Kaos = new (LocalKaos as unknown as LocalKaosCtor)(TEST_OS_ENV);
|
||||
return wrapKaos(backend.withCwd(cwd));
|
||||
}
|
||||
|
||||
function wrapKaos(backend: Kaos): IKaosType {
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceStub(additionalDirs: readonly string[] = []): ISessionWorkspaceContext {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
workDir: '/tmp/proj',
|
||||
additionalDirs,
|
||||
setWorkDir: () => {},
|
||||
resolve: (p) => p,
|
||||
isWithin: () => true,
|
||||
assertAllowed: (p) => p,
|
||||
addAdditionalDir: () => {},
|
||||
removeAdditionalDir: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function bootstrapStub(homeDir: string): IBootstrapService {
|
||||
return { homeDir } as unknown as IBootstrapService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Session-scoped host with `SessionWarningService` registered and its
|
||||
* collaborators stubbed. `agentLifecycle` defaults to "no live main agent" so
|
||||
* the service exercises the on-demand recompute path.
|
||||
*/
|
||||
function build(args: {
|
||||
kaos: IKaosType;
|
||||
homeDir: string;
|
||||
additionalDirs?: readonly string[];
|
||||
agentLifecycle?: IAgentLifecycleService;
|
||||
}): { host: ScopedTestHost; service: ISessionWarningService } {
|
||||
const host = createScopedTestHost([stubPair(IBootstrapService, bootstrapStub(args.homeDir))]);
|
||||
const session = host.child(LifecycleScope.Session, 's1', [
|
||||
stubPair(IKaos, args.kaos),
|
||||
stubPair(ISessionWorkspaceContext, workspaceStub(args.additionalDirs ?? [])),
|
||||
stubPair(
|
||||
IAgentLifecycleService,
|
||||
args.agentLifecycle ??
|
||||
({
|
||||
_serviceBrand: undefined,
|
||||
getHandle: () => undefined,
|
||||
} as unknown as IAgentLifecycleService),
|
||||
),
|
||||
]);
|
||||
return { host, service: session.accessor.get(ISessionWarningService) };
|
||||
}
|
||||
|
||||
describe('SessionWarningService.getSessionWarnings', () => {
|
||||
let host: ScopedTestHost | undefined;
|
||||
let homeDir: string;
|
||||
let workDir: string;
|
||||
let kaos: IKaosType;
|
||||
|
||||
beforeEach(async () => {
|
||||
_clearScopedRegistryForTests();
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
ISessionWarningService,
|
||||
SessionWarningService,
|
||||
InstantiationType.Delayed,
|
||||
'sessionWarning',
|
||||
);
|
||||
homeDir = await mkdtemp(join(tmpdir(), 'kimi-warn-home-'));
|
||||
workDir = await mkdtemp(join(tmpdir(), 'kimi-warn-work-'));
|
||||
kaos = realIKaos(workDir);
|
||||
// Keep user-level discovery hermetic.
|
||||
vi.spyOn(kaos, 'gethome').mockReturnValue(homeDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
host?.dispose();
|
||||
host = undefined;
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns an agents-md-oversized warning when AGENTS.md exceeds the 32 KB budget', async () => {
|
||||
await writeFile(join(workDir, 'AGENTS.md'), 'x'.repeat(40 * 1024), 'utf-8');
|
||||
const built = build({ kaos, homeDir });
|
||||
host = built.host;
|
||||
|
||||
const warnings = await built.service.getSessionWarnings();
|
||||
|
||||
expect(warnings).toEqual([
|
||||
expect.objectContaining({
|
||||
code: 'agents-md-oversized',
|
||||
severity: 'warning',
|
||||
message: expect.stringContaining('exceeds the recommended'),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no warnings when AGENTS.md is within the budget', async () => {
|
||||
await writeFile(join(workDir, 'AGENTS.md'), 'small instructions', 'utf-8');
|
||||
const built = build({ kaos, homeDir });
|
||||
host = built.host;
|
||||
|
||||
const warnings = await built.service.getSessionWarnings();
|
||||
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('prefers the main agent cached warning when the agent is live', async () => {
|
||||
// No AGENTS.md on disk — the recompute path would yield nothing — but the
|
||||
// live main agent reports a cached warning, which must win.
|
||||
const cached = 'AGENTS.md total 40 KB exceeds the recommended 32 KB.';
|
||||
const profileStub = {
|
||||
getAgentsMdWarning: () => cached,
|
||||
} as unknown as IAgentProfileService;
|
||||
const agentLifecycle = {
|
||||
_serviceBrand: undefined,
|
||||
getHandle: (id: string) =>
|
||||
id === 'main'
|
||||
? { accessor: { get: (token: unknown) => (token === IAgentProfileService ? profileStub : undefined) } }
|
||||
: undefined,
|
||||
} as unknown as IAgentLifecycleService;
|
||||
|
||||
const built = build({ kaos, homeDir, agentLifecycle });
|
||||
host = built.host;
|
||||
|
||||
const warnings = await built.service.getSessionWarnings();
|
||||
|
||||
expect(warnings).toEqual([
|
||||
{ code: 'agents-md-oversized', severity: 'warning', message: cached },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue