Files
deepseek-harness/packages/context/agent-instructions/src/config.ts
T
ZiyaandZiyaZhang 114da79e42 fix(agent-instructions): budget the user-global and project chains separately (#6400)
* fix(agent-instructions): budget the user-global and project chains separately

The user-global and project instruction files shared one maxBytes and the user-global files were the broadest, so a large project chain omitted them first. Each chain now renders under maxBytes on its own and the results share one system-reminder frame: unchanged output when everything fits or one chain is empty, at most twice maxBytes otherwise. The project chain keeps its omit-broadest-first, truncate-last rule; reconciliation batches follow the same split; the maxBytes key and the baseline identity are unchanged.

Tests cover the fast paths, both chains fitting only separately, each chain over its own budget, the 2 × maxBytes boundary at exact and tiny limits, multibyte content, reconciliation batches, and a resumed single-budget baseline receiving the user-global file as one transition. The agent-instructions-budget snapshot is refreshed and agent-instructions-budget-chains is added. README, Config.maxBytes JSDoc, the configuration catalog, a new Agent Note, and an upgrade guide state the per-chain bound.

* fix(agent-instructions): keep loaded order in merged frames and note the unshared chain budget

A merged frame now lists sections, omissions and truncations in the loaded order, so a reconciliation batch renders its items in one order whether or not it fits a single budget. The guard comment states only the invariant and points to the Agent Note; the intro selection states its rule. The README names the unshared budget between chains.

---------

Co-authored-by: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
2026-10-18 00:45:39 -07:00

141 lines
5.8 KiB
TypeScript

/**
* Configuration normalization for workspace instruction discovery and rendering.
*
* @module @deepseek-ai/dsh-agent-instructions/config
*/
import { relative } from 'node:path'
import z from '@deepseek-ai/schemastery'
import { resolveAgentsHome, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const
const DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.local.md', 'CLAUDE.local.md'] as const
const DEFAULT_MAX_SOURCE_BYTES = 1_048_576
const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..'])
/** User-facing workspace instruction loader configuration. */
export interface Config {
/** Directory entries that identify the project root while walking upward from the current working directory. */
projectRootMarkers?: string[]
/**
* UTF-8 byte cap for each instruction chain (user-global, project) within one
* rendered baseline or dynamic batch, so a baseline or batch holding both
* chains is at most twice this size; non-positive or non-finite disables loading.
*/
maxBytes: number
/** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */
maxSourceBytes?: number
/**
* Ordered same-directory project candidates; every existing file loads, with
* per-directory trimmed-content duplicates collapsed to the earliest candidate.
*/
instructionFileCandidates?: string[]
/**
* Ordered same-directory local-overlay candidates loaded after the base files
* under the same per-directory trimmed-content dedup; empty disables the overlay.
*/
localInstructionFileCandidates?: string[]
}
export const Config: z<Config> = z.object({
projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]),
maxBytes: z.number().required(),
maxSourceBytes: z.number().step(1).min(1).default(DEFAULT_MAX_SOURCE_BYTES),
instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
localInstructionFileCandidates: z.array(z.string()).default([...DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES]),
})
/** Normalized instruction discovery configuration. */
export interface ResolvedDiscoveryConfig {
dshHome: string
agentsHome: string
projectRootMarkers: string[]
instructionFileCandidates: string[]
localInstructionFileCandidates: string[]
}
/** Normalized configuration used by discovery and reconciliation. */
export interface ResolvedConfig extends ResolvedDiscoveryConfig {
maxBytes: number
maxSourceBytes: number
}
/**
* Identify the discovery, precedence, and budget semantics of one baseline.
* @param config - normalized plugin configuration.
* @param cwd - absolute session working directory.
* @param projectRoot - project root selected for the current baseline.
* @returns stable serialized identity for compatibility checks on resume.
*/
export function workspaceBaselineIdentity(
config: ResolvedConfig,
cwd: string,
projectRoot: string,
): string {
return JSON.stringify({
projectRoot: relative(cwd, projectRoot),
projectRootMarkers: config.projectRootMarkers,
maxBytes: config.maxBytes,
maxSourceBytes: config.maxSourceBytes,
instructionFileCandidates: config.instructionFileCandidates,
localInstructionFileCandidates: config.localInstructionFileCandidates,
})
}
/**
* Resolve row defaults, the process harness home, and valid same-directory candidates.
* Undeclared row fields cannot override the process home.
* @param config - user-facing plugin configuration.
* @returns normalized runtime configuration.
*/
export function resolveConfig(config: Config): ResolvedConfig {
return {
...resolveDiscoveryConfig({
...config.projectRootMarkers === undefined ? {} : { projectRootMarkers: config.projectRootMarkers },
...config.instructionFileCandidates === undefined ? {} : { instructionFileCandidates: config.instructionFileCandidates },
...config.localInstructionFileCandidates === undefined
? {}
: { localInstructionFileCandidates: config.localInstructionFileCandidates },
}),
maxBytes: config.maxBytes,
maxSourceBytes: config.maxSourceBytes ?? DEFAULT_MAX_SOURCE_BYTES,
}
}
/**
* Resolve the subset of configuration used before instruction content is rendered.
*
* The harness home comes from the process environment through
* {@link resolveDshHome}; the row cannot point instruction loading at a second
* root that other harness consumers would not share. An owner that already
* resolved the homes passes them as `dshHome` and `agentsHome`, which are
* normalized here so discovery and reconciliation use the same values.
* @param config - optional discovery controls plus the already-resolved homes.
* @returns normalized home, root markers, and instruction candidates.
*/
export function resolveDiscoveryConfig(
config: Pick<Config, 'projectRootMarkers' | 'instructionFileCandidates' | 'localInstructionFileCandidates'>
& { dshHome?: string; agentsHome?: string },
): ResolvedDiscoveryConfig {
return {
dshHome: resolveDshHome(config.dshHome),
agentsHome: resolveAgentsHome(config.agentsHome),
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
instructionFileCandidates: resolveInstructionFileCandidates(
config.instructionFileCandidates,
DEFAULT_INSTRUCTION_FILE_CANDIDATES,
),
localInstructionFileCandidates: resolveInstructionFileCandidates(
config.localInstructionFileCandidates,
DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES,
),
}
}
function resolveInstructionFileCandidates(candidates: string[] | undefined, fallback: readonly string[]): string[] {
return (candidates ?? [...fallback]).filter(candidate => (
!RESERVED_PATH_SEGMENTS.has(candidate) && !/[\\/]/.test(candidate)
))
}