fix: tighten project instruction path handling

This commit is contained in:
Yichen Jiang
2026-06-29 21:42:23 +08:00
parent 234ffe4604
commit 21ae734839
20 changed files with 247 additions and 30 deletions

View File

@@ -6,7 +6,7 @@ Project instruction file loader for the harness. It discovers `AGENTS.md` with `
The plugin listens on the `agent/request` waterfall. For each request it derives the workspace from `agent.session.header.cwd`; if the session has no cwd, it falls back to `process.cwd()` for single-session local/stdio runs. It then finds the project root by walking upward until it sees `.git` as either a directory or a file, considers the ancestor chain from project root to cwd, and loads at most one instruction file per directory: `AGENTS.md` wins, `CLAUDE.md` is a compatibility fallback.
User-global instructions live at `$DSH_HOME/AGENTS.md`; `$DSH_HOME` defaults to `~/.dsh`. The user-global file renders before project files, so deeper project files appear later in the context and can override broader guidance.
User-global instructions live at `$DSH_HOME/AGENTS.md`; `$DSH_HOME` defaults to `~/.dsh`. A configured `~`, `~/...`, or Windows-style `~\...` prefix is expanded against the operating-system home directory before resolution. The user-global file renders before project files, so deeper project files appear later in the context and can override broader guidance.
The loaded files are inserted as a synthetic user-role workspace-context message, not as provider system text and not as persisted session events. The rendered envelope states that these files are workspace-provided guidance, lower authority than system/developer/direct user instructions, and must not override safety, permission, or secret-handling rules.
@@ -27,7 +27,7 @@ export interface Config {
The renderer keeps full text until the configured byte budget is exceeded. When it must trim, it preserves more-specific files first, drops whole less-specific files before truncating a more-specific file, and emits an HTML comment naming omitted and truncated files with byte counts.
Discovery re-walks the applicable ancestor chain on every request so newly created baseline files are noticed. File content is cached by normalized absolute path plus `mtimeMs` and `size`; a changed signature causes a re-read.
Discovery re-walks the applicable ancestor chain on every request so newly created baseline files are noticed. File content is cached by normalized absolute path plus `mtimeMs` and `size`; a changed signature causes a re-read. The discovery pass carries the file signature forward to the read pass, so a cache hit does not stat the same instruction file twice in one request.
## Non-goals

View File

@@ -24,6 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
@@ -34,6 +35,7 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",

View File

@@ -7,12 +7,12 @@
*/
import { readFile, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join, relative, resolve } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome, expandHomePath } from '@deepseek-ai/dsh-paths'
export const name = 'project-instructions'
@@ -35,7 +35,7 @@ export interface Config {
}
export const Config: z<Config> = z.object({
dshHome: z.string().default(join(homedir(), '.dsh')),
dshHome: z.string().default(defaultDshHome()),
projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]),
baselineMaxBytes: z.number().default(DEFAULT_BASELINE_MAX_BYTES),
enableClaudeFallback: z.boolean().default(true),
@@ -46,6 +46,10 @@ export interface InstructionFile {
displayPath: string
}
interface DiscoveredInstructionFile extends InstructionFile {
signature: FileSignature
}
export interface LoadedInstructionFile extends InstructionFile {
content: string
}
@@ -94,7 +98,7 @@ interface LoadOptions extends DiscoverOptions {
function resolveConfig(config: Config): ResolvedConfig {
return {
dshHome: resolve(config.dshHome ?? join(homedir(), '.dsh')),
dshHome: resolve(expandHomePath(config.dshHome ?? defaultDshHome())),
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
baselineMaxBytes: config.baselineMaxBytes ?? DEFAULT_BASELINE_MAX_BYTES,
enableClaudeFallback: config.enableClaudeFallback ?? true,
@@ -162,15 +166,17 @@ async function firstExistingInstructionFile(
dir: string,
root: string,
enableClaudeFallback: boolean,
): Promise<InstructionFile | undefined> {
): Promise<DiscoveredInstructionFile | undefined> {
const agentsPath = join(dir, 'AGENTS.md')
if (await statFile(agentsPath)) {
return { absolutePath: agentsPath, displayPath: relativeDisplay(root, agentsPath) }
const agentsSignature = await statFile(agentsPath)
if (agentsSignature !== undefined) {
return { absolutePath: agentsPath, displayPath: relativeDisplay(root, agentsPath), signature: agentsSignature }
}
if (!enableClaudeFallback) return undefined
const claudePath = join(dir, 'CLAUDE.md')
if (await statFile(claudePath)) {
return { absolutePath: claudePath, displayPath: relativeDisplay(root, claudePath) }
const claudeSignature = await statFile(claudePath)
if (claudeSignature !== undefined) {
return { absolutePath: claudePath, displayPath: relativeDisplay(root, claudePath), signature: claudeSignature }
}
return undefined
}
@@ -179,29 +185,38 @@ function relativeDisplay(root: string, path: string): string {
return relative(root, path)
}
export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise<InstructionFile[]> {
async function discoverInstructionFiles(options: DiscoverOptions): Promise<DiscoveredInstructionFile[]> {
const config = resolveConfig(options)
const files: InstructionFile[] = []
const files: DiscoveredInstructionFile[] = []
const seen = new Set<string>()
const addFile = (file: DiscoveredInstructionFile): void => {
if (seen.has(file.absolutePath)) return
seen.add(file.absolutePath)
files.push(file)
}
const userGlobal = join(config.dshHome, 'AGENTS.md')
if (await statFile(userGlobal)) {
const defaultDshHome = resolve(join(homedir(), '.dsh'))
const displayPath = config.dshHome === defaultDshHome ? '~/.dsh/AGENTS.md' : '$DSH_HOME/AGENTS.md'
files.push({ absolutePath: userGlobal, displayPath })
const userGlobalSignature = await statFile(userGlobal)
if (userGlobalSignature !== undefined) {
const defaultHome = resolve(defaultDshHome())
const displayPath = config.dshHome === defaultHome ? `${DEFAULT_DSH_HOME_DISPLAY}/AGENTS.md` : '$DSH_HOME/AGENTS.md'
addFile({ absolutePath: userGlobal, displayPath, signature: userGlobalSignature })
}
const cwd = resolve(options.cwd)
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers)
for (const dir of ancestorChain(projectRoot, cwd)) {
const file = await firstExistingInstructionFile(dir, projectRoot, config.enableClaudeFallback)
if (file !== undefined) files.push(file)
if (file !== undefined) addFile(file)
}
return files
}
async function readCached(path: string, cache: InstructionContentCache): Promise<string | undefined> {
const signature = await statFile(path)
/* v8 ignore next -- race-only path: file existed during discovery but vanished before the read-side stat. */
if (signature === undefined) return undefined
export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise<InstructionFile[]> {
return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath }))
}
async function readCached(path: string, signature: FileSignature, cache: InstructionContentCache): Promise<string | undefined> {
const cached = cache.get(path)
if (cached !== undefined && cached.mtimeMs === signature.mtimeMs && cached.size === signature.size) {
return cached.content
@@ -221,11 +236,11 @@ export async function loadBaselineInstructions(options: LoadOptions): Promise<Re
const config = resolveConfig(options)
if (config.baselineMaxBytes === 0) return undefined
const cache = options.cache ?? new Map<string, CachedContent>()
const discovered = await discoverBaselineInstructionFiles(options)
const discovered = await discoverInstructionFiles(options)
const loaded: LoadedInstructionFile[] = []
for (const file of discovered) {
const content = await readCached(file.absolutePath, cache)
if (content !== undefined) loaded.push({ ...file, content })
const content = await readCached(file.absolutePath, file.signature, cache)
if (content !== undefined) loaded.push({ absolutePath: file.absolutePath, displayPath: file.displayPath, content })
}
if (loaded.length === 0) return undefined
return renderProjectInstructions(loaded, { maxBytes: config.baselineMaxBytes })

View File

@@ -215,6 +215,40 @@ describe('project instruction discovery', () => {
}
})
it('expands a configured ~/.dsh home to the operating-system home directory', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await write(join(home, '.dsh/AGENTS.md'), 'global tilde rule')
vi.resetModules()
vi.doMock('node:os', () => ({ homedir: () => home }))
const isolated = await import('@deepseek-ai/dsh-project-instructions')
const files = await isolated.discoverBaselineInstructionFiles({ cwd: root, dshHome: '~/.dsh' })
expect(files).toEqual([{ absolutePath: join(home, '.dsh/AGENTS.md'), displayPath: '~/.dsh/AGENTS.md' }])
} finally {
vi.doUnmock('node:os')
vi.resetModules()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('deduplicates user-global instructions when dshHome points at the project root', async () => {
const root = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'same file')
const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: root })
expect(files).toEqual([{ absolutePath: join(root, 'AGENTS.md'), displayPath: '$DSH_HOME/AGENTS.md' }])
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('ignores instruction candidates that are directories', async () => {
const root = await tempRepo()
const home = await tempRepo()
@@ -492,4 +526,39 @@ describe('project instruction request injection', () => {
await rm(home, { recursive: true, force: true })
}
})
it('reuses the discovery stat signature when reading cached content', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const observedStats = new Map<string, number>()
vi.resetModules()
vi.doMock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
stat: async (path: string) => {
observedStats.set(path, (observedStats.get(path) ?? 0) + 1)
return actual.stat(path)
},
}
})
const isolated = await import('@deepseek-ai/dsh-project-instructions')
const cache: InstructionContentCache = new Map()
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, cache })
observedStats.clear()
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, cache })
expect(observedStats.get(join(root, 'AGENTS.md'))).toBe(1)
} finally {
vi.doUnmock('node:fs/promises')
vi.resetModules()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
})

View File

@@ -19,6 +19,9 @@
},
{
"path": "../../core/agent"
},
{
"path": "../../util/paths"
}
]
}