fix(project-instructions): load files through fs service

This commit is contained in:
Yichen Jiang
2026-07-03 11:56:58 +08:00
parent 19abd0a357
commit a52cac00b1
15 changed files with 358 additions and 45 deletions

View File

@@ -108,7 +108,7 @@ Replay/fork = `ctx.sessions.create(id, { seed: seedEvents })`. Trace/telemetry =
Plugins contribute `PromptSection`s (named, ordered, static or computed) and tool-schema providers. `assemble()` returns a `PromptAssembly { sections, tools }` through the `system-prompt/assemble` waterfall.
Prompt/context extension plugins that shape model inputs without owning a core service live under `packages/prompt/`. `dsh-project-instructions` is the reference case: it is semantically prompt/context assembly, but it uses the per-agent `agent/request` seam instead of a global `ctx.systemPrompt.section()` so concurrent sessions with different cwd values stay isolated. Shared filesystem path conventions such as the default DSH home live in the low-level `dsh-paths` utility package rather than in the prompt plugin.
Prompt/context extension plugins that shape model inputs without owning a core service live under `packages/prompt/`. `dsh-project-instructions` is the reference case: it is semantically prompt/context assembly, but it uses the per-agent `agent/request` seam instead of a global `ctx.systemPrompt.section()` so concurrent sessions with different cwd values stay isolated, and it reads instruction content through the `ctx.fs` provider seam. Shared filesystem path conventions such as the default DSH home live in the low-level `dsh-paths` utility package rather than in the prompt plugin.
Tool schemas are deliberately **part of the assembly**: "what the model is told it can do" is one coherent thing managed here, even though adapters transmit schemas as the wire-level `tools` field rather than prompt text.
@@ -210,8 +210,8 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl
| Queued + steering messages | core `Agent.send()` / `Agent.steer()` |
| Context compaction (auto + manual) | the `dsh-compact` seam (`ctx.compact`) + a backend (`dsh-compact-basic`) on the serial `agent/pre-step` seam: a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure before each step — runaway-turn survival, manual = a (deferred) `/compact` tool invoking the same `ctx.compact` routine. See the [compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) |
| System prompt configurability | `ctx.systemPrompt.section()` with ordering |
| AGENTS.md (baseline) | `dsh-project-instructions` wraps `agent/request`, discovers `$DSH_HOME/AGENTS.md` plus the project-root→cwd ancestor chain, and prepends fenced workspace context |
| AGENTS.md (subdir, on-touch) + file-change notices | deferred until structured file tools can report touched paths; late context should use `agent.inject()` |
| AGENTS.md (baseline) | `dsh-project-instructions` wraps `agent/request`, discovers `$DSH_HOME/AGENTS.md` plus the project-root→cwd ancestor chain, reads them through `ctx.fs`, and prepends fenced workspace context |
| AGENTS.md (subdir, on-touch) + file-change notices | deferred until structured file tools define the touched-path reporting semantics; late context should use `agent.inject()` |
| Built-in tools (Read/Write/Edit/Bash/…) | `ctx.tools.register()`; schemas flow into the assembly automatically. **Bash: implemented**`dsh-bash` (seam) + `dsh-bash-local` (subprocesses) + `dsh-tool-bash` (`bash`/`bash_output`/`bash_kill`, incl. background tasks). **`todo_write`: implemented** — `dsh-tool-todo` writes the whole task list to the session log (`todo/write`), rendered as a stdio checklist / ACP `plan` |
| ToolSearch / progressive disclosure | wrap `agent/request`, filter `req.tools` |
| Tool sandbox (landlock / sandbox-exec) | wrap `tools/execute`, or implement a sandboxing `BashExecutor` (the dsh-bash seam) |

View File

@@ -35,6 +35,7 @@ graph TD
invariants --> llm
invariants --> session
project-instructions --> agent
project-instructions --> fs
project-instructions --> llm
project-instructions --> paths
session-persistence-jsonl --> session
@@ -133,7 +134,7 @@ graph TD
| `session-persistence` | `session` |
| `compact-basic` | `agent`, `compact`, `llm`, `session` |
| `invariants` | `agent`, `llm`, `session` |
| `project-instructions` | `agent`, `llm`, `paths` |
| `project-instructions` | `agent`, `fs`, `llm`, `paths` |
| `session-persistence-jsonl` | `session`, `session-persistence` |
| `session-persistence-sqlite` | `session`, `session-persistence` |
| `tools` | `agent`, `llm`, `system-prompt` |

View File

@@ -27,6 +27,13 @@
- id: bash
name: '@deepseek-ai/dsh-bash-local'
# Local filesystem provider for agent-core's project-instructions loader. This
# does not expose model-facing read/write/edit tools in the echo demo.
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
config:
cwd: !!js process.cwd()
# The stdio chat app: console logger + the agent-core spine (pre-creating the
# `main` agent on the mock model) + JSONL persistence + the readline UI.
- id: stdio-agent

View File

@@ -36,7 +36,7 @@ dsh-agent ← dsh-llm, dsh-session, dsh-brand
dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; tool deferred)
dsh-compact-basic ← dsh-compact, dsh-session, dsh-llm, dsh-agent (char/4 + token-budget retention backend)
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
dsh-project-instructions ← dsh-agent, dsh-llm, dsh-paths (AGENTS.md/CLAUDE.md workspace context loader)
dsh-project-instructions ← dsh-agent, dsh-fs, dsh-llm, dsh-paths (AGENTS.md/CLAUDE.md workspace context loader)
dsh-bash-local ← dsh-bash (BashExecutor impl)
dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas)
dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam + fs/* events)

View File

@@ -38,6 +38,7 @@
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-project-instructions": "workspace:^",

View File

@@ -7,6 +7,7 @@ import Loader from '@cordisjs/plugin-loader'
import * as agentCore from '../src/index.ts'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { MockAdapter, textResponse } from '../../agent-loop/tests/mock-adapter.ts'
import type { Message } from '@deepseek-ai/dsh-llm'
@@ -80,6 +81,7 @@ describe('dsh-agent-core bundle', () => {
await writeFile(join(root, 'AGENTS.md'), 'bundled project rule')
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await mount()
await ctx.plugin(LocalFileSystem, { cwd: '/' })
ctx.llm.registerAdapter(['mock'], adapter)
const handle = ctx.agents.create({
agentId: AgentId('main'),

View File

@@ -4,7 +4,7 @@ Project instruction file loader for the harness. It discovers `AGENTS.md` with `
## Behavior
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.
The plugin listens on the `agent/request` waterfall and depends on the `ctx.fs` provider seam to read instruction file content. 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`. 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.
@@ -21,13 +21,13 @@ export interface Config {
}
```
`projectRootMarkers` defaults to `['.git']`, `baselineMaxBytes` defaults to `65536`, and `enableClaudeFallback` defaults to `true`. Setting `baselineMaxBytes` to `0` disables instruction injection.
`projectRootMarkers` defaults to `['.git']`, `baselineMaxBytes` defaults to `65536`, and `enableClaudeFallback` defaults to `true`. Setting `baselineMaxBytes` to `0` or another non-positive value disables instruction injection.
## Budgeting and cache
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. 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.
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 the provider's opaque file version 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

@@ -23,6 +23,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"cordis": "^4.0.0-rc.6"
@@ -34,6 +35,8 @@
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",

View File

@@ -1,7 +1,7 @@
/**
* Project instruction file loader: discovers `AGENTS.md` with `CLAUDE.md`
* fallback on the per-session workspace path and injects it as fenced
* workspace context for each model request.
* fallback on the per-session workspace path, reads them through `ctx.fs`, and
* injects them as fenced workspace context for each model request.
*
* @module @deepseek-ai/dsh-project-instructions
*/
@@ -12,9 +12,11 @@ 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 type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome, resolveDshHome } from '@deepseek-ai/dsh-paths'
export const name = 'project-instructions'
export const inject = ['fs']
const DEFAULT_BASELINE_MAX_BYTES = 64 * 1024
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
@@ -48,6 +50,7 @@ export interface InstructionFile {
interface DiscoveredInstructionFile extends InstructionFile {
signature: FileSignature
target?: FsTarget
}
export interface LoadedInstructionFile extends InstructionFile {
@@ -74,8 +77,8 @@ interface ResolvedConfig {
}
interface FileSignature {
mtimeMs: number
size: number
version: string
size: number | undefined
}
interface CachedContent extends FileSignature {
@@ -117,11 +120,11 @@ function truncateUtf8(value: string, maxBytes: number): string {
return truncated
}
async function statFile(path: string): Promise<FileSignature | undefined> {
async function nodeStatFile(path: string): Promise<FileSignature | undefined> {
try {
const info = await lstat(path)
if (!info.isFile()) return undefined
return { mtimeMs: info.mtimeMs, size: info.size }
return { version: `${info.mtimeMs}:${info.size}`, size: info.size }
} catch {
// Expected race/absence: a candidate file may not exist, or may disappear
// between directory discovery and stat. Treat it as not loadable.
@@ -129,7 +132,35 @@ async function statFile(path: string): Promise<FileSignature | undefined> {
}
}
async function existsAsMarker(path: string): Promise<boolean> {
async function fsStatFile(path: string, fileSystem: FileSystem): Promise<DiscoveredInstructionFile['signature'] & { target: FsTarget } | undefined> {
const noFollow = await nodeStatFile(path)
if (noFollow === undefined) return undefined
try {
const target = await fileSystem.resolve(path)
const info = await fileSystem.stat(target)
if (info?.type !== 'file') return undefined
return { version: info.version, size: info.size ?? noFollow.size, target }
} catch {
// Expected race/absence: the no-follow check passed, but the backing fs
// provider could no longer resolve/stat the target. Treat it as not loadable.
return undefined
}
}
async function statFile(path: string, fileSystem?: FileSystem): Promise<(DiscoveredInstructionFile['signature'] & { target?: FsTarget }) | undefined> {
return fileSystem === undefined ? nodeStatFile(path) : fsStatFile(path, fileSystem)
}
async function existsAsMarker(path: string, fileSystem?: FileSystem): Promise<boolean> {
if (fileSystem !== undefined) {
try {
const target = await fileSystem.resolve(path)
return await fileSystem.stat(target) !== undefined
} catch {
// Expected absence while walking ancestors.
return false
}
}
try {
await stat(path)
return true
@@ -139,11 +170,11 @@ async function existsAsMarker(path: string): Promise<boolean> {
}
}
async function findProjectRoot(cwd: string, markers: readonly string[]): Promise<string> {
async function findProjectRoot(cwd: string, markers: readonly string[], fileSystem?: FileSystem): Promise<string> {
let current = resolve(cwd)
for (;;) {
for (const marker of markers) {
if (await existsAsMarker(join(current, marker))) return current
if (await existsAsMarker(join(current, marker), fileSystem)) return current
}
const parent = dirname(current)
if (parent === current) return resolve(cwd)
@@ -170,17 +201,30 @@ async function firstExistingInstructionFile(
dir: string,
root: string,
enableClaudeFallback: boolean,
fileSystem?: FileSystem,
): Promise<DiscoveredInstructionFile | undefined> {
const agentsPath = join(dir, 'AGENTS.md')
const agentsSignature = await statFile(agentsPath)
const agentsSignature = await statFile(agentsPath, fileSystem)
if (agentsSignature !== undefined) {
return { absolutePath: agentsPath, displayPath: relativeDisplay(root, agentsPath), signature: agentsSignature }
const { target, ...signature } = agentsSignature
return {
absolutePath: agentsPath,
displayPath: relativeDisplay(root, agentsPath),
signature,
...target === undefined ? {} : { target },
}
}
if (!enableClaudeFallback) return undefined
const claudePath = join(dir, 'CLAUDE.md')
const claudeSignature = await statFile(claudePath)
const claudeSignature = await statFile(claudePath, fileSystem)
if (claudeSignature !== undefined) {
return { absolutePath: claudePath, displayPath: relativeDisplay(root, claudePath), signature: claudeSignature }
const { target, ...signature } = claudeSignature
return {
absolutePath: claudePath,
displayPath: relativeDisplay(root, claudePath),
signature,
...target === undefined ? {} : { target },
}
}
return undefined
}
@@ -189,7 +233,7 @@ function relativeDisplay(root: string, path: string): string {
return relative(root, path)
}
async function discoverInstructionFiles(options: DiscoverOptions): Promise<DiscoveredInstructionFile[]> {
async function discoverInstructionFiles(options: DiscoverOptions, fileSystem?: FileSystem): Promise<DiscoveredInstructionFile[]> {
const config = resolveConfig(options)
const files: DiscoveredInstructionFile[] = []
const seen = new Set<string>()
@@ -200,17 +244,23 @@ async function discoverInstructionFiles(options: DiscoverOptions): Promise<Disco
}
const userGlobal = join(config.dshHome, 'AGENTS.md')
const userGlobalSignature = await statFile(userGlobal)
const userGlobalSignature = await statFile(userGlobal, fileSystem)
if (userGlobalSignature !== undefined) {
const { target, ...signature } = userGlobalSignature
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 })
addFile({
absolutePath: userGlobal,
displayPath,
signature,
...target === undefined ? {} : { target },
})
}
const cwd = resolve(options.cwd)
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers)
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem)
for (const dir of ancestorChain(projectRoot, cwd)) {
const file = await firstExistingInstructionFile(dir, projectRoot, config.enableClaudeFallback)
const file = await firstExistingInstructionFile(dir, projectRoot, config.enableClaudeFallback, fileSystem)
if (file !== undefined) addFile(file)
}
return files
@@ -220,13 +270,21 @@ export async function discoverBaselineInstructionFiles(options: DiscoverOptions)
return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath }))
}
async function readCached(path: string, signature: FileSignature, cache: InstructionContentCache): Promise<string | undefined> {
async function readCached(
file: DiscoveredInstructionFile,
cache: InstructionContentCache,
fileSystem?: FileSystem,
): Promise<string | undefined> {
const path = file.absolutePath
const { signature } = file
const cached = cache.get(path)
if (cached !== undefined && cached.mtimeMs === signature.mtimeMs && cached.size === signature.size) {
if (cached !== undefined && cached.version === signature.version && cached.size === signature.size) {
return cached.content
}
try {
const content = await readFile(path, 'utf8')
const content = fileSystem === undefined || file.target === undefined
? await readFile(path, 'utf8')
: await fileSystem.readText(file.target)
cache.set(path, { ...signature, content })
return content
} catch {
@@ -236,14 +294,17 @@ async function readCached(path: string, signature: FileSignature, cache: Instruc
}
}
export async function loadBaselineInstructions(options: LoadOptions): Promise<RenderedProjectInstructions | undefined> {
export async function loadBaselineInstructions(
options: LoadOptions,
fileSystem?: FileSystem,
): Promise<RenderedProjectInstructions | undefined> {
const config = resolveConfig(options)
if (config.baselineMaxBytes <= 0 || !Number.isFinite(config.baselineMaxBytes)) return undefined
const cache = options.cache ?? new Map<string, CachedContent>()
const discovered = await discoverInstructionFiles(options)
const discovered = await discoverInstructionFiles(options, fileSystem)
const loaded: LoadedInstructionFile[] = []
for (const file of discovered) {
const content = await readCached(file.absolutePath, file.signature, cache)
const content = await readCached(file, cache, fileSystem)
if (content !== undefined) loaded.push({ absolutePath: file.absolutePath, displayPath: file.displayPath, content })
}
if (loaded.length === 0) return undefined
@@ -379,7 +440,7 @@ export function apply(ctx: Context, config: Config): void {
baselineMaxBytes: resolved.baselineMaxBytes,
enableClaudeFallback: resolved.enableClaudeFallback,
cache,
})
}, ctx.fs)
if (instructions !== undefined) {
request.messages = [workspaceContextMessage(instructions.text), ...request.messages]
}

View File

@@ -12,9 +12,10 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import * as ProjectInstructions from '@deepseek-ai/dsh-project-instructions'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
const PROBE = 'DSH_PROJECT_INSTRUCTIONS_PROBE_BANANA'
const PROBE = 'banana-271828'
let ctx: Context | undefined
let workdir: string | undefined
@@ -29,13 +30,14 @@ afterEach(async () => {
async function harness(): Promise<{ ctx: Context; agent: Agent }> {
workdir = await mkdtemp(join(tmpdir(), 'dsh-project-instructions-e2e-'))
await mkdir(join(workdir, '.git'), { recursive: true })
await writeFile(join(workdir, 'AGENTS.md'), `For this repository, every assistant response must include exactly this probe token: ${PROBE}.\n`)
await writeFile(join(workdir, 'AGENTS.md'), `If the user asks for the project instruction handshake, reply with exactly this string and nothing else: ${PROBE}.\n`)
ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ProjectInstructions)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
@@ -75,7 +77,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('project instructions e2e: real m
it('obeys a probe instruction loaded from the workspace', async () => {
const live = await harness()
live.agent.send([{ type: 'text', text: 'Reply with the repository probe token only.' }])
live.agent.send([{ type: 'text', text: 'Project instruction handshake?' }])
await waitForIdle(live.ctx, live.agent)
expect(finalText([...live.agent.session.events])).toContain(PROBE)

View File

@@ -9,9 +9,17 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsEditOutcome,
FsEditRequest,
FsInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import {
apply,
Config as ProjectInstructionsConfig,
discoverBaselineInstructionFiles,
loadBaselineInstructions,
renderProjectInstructions,
@@ -27,6 +35,52 @@ async function write(path: string, content: string): Promise<void> {
await writeFile(path, content)
}
class RecordingFileSystem extends FileSystem {
entries = new Map<string, { type: FsInfo['type']; content?: string }>()
throwOnStat = new Set<string>()
readTargets: string[] = []
override async resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> {
const absolute = join(opts?.cwd ?? '/', path)
return { inputPath: path, targetKey: FsTargetKey(absolute), displayPath: absolute }
}
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
if (this.throwOnStat.has(target.targetKey)) throw new Error(`stat failed: ${target.displayPath}`)
const entry = this.entries.get(target.targetKey)
if (entry === undefined) return undefined
const info: FsInfo = {
version: FsVersion(`v:${target.targetKey}`),
type: entry.type,
}
if (entry.content !== undefined) info.size = Buffer.byteLength(entry.content, 'utf8')
return info
}
override async readText(target: FsTarget): Promise<string> {
this.readTargets.push(target.targetKey)
return this.entries.get(target.targetKey)?.content ?? ''
}
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
const content = await this.readText(target)
return (async function* () { yield content })()
}
override async writeText(_target: FsTarget, _content: string, _expected?: FsWriteIntent): Promise<FsWriteOutcome> {
return { operation: 'update', version: FsVersion('unused') }
}
override async editText(_target: FsTarget, _edit: FsEditRequest): Promise<FsEditOutcome> {
return { replacements: 0, replaceAll: false, version: FsVersion('unused') }
}
}
async function mountProjectInstructions(ctx: Context, config: projectInstructions.Config): Promise<Awaited<ReturnType<Context['plugin']>>> {
await ctx.plugin(LocalFileSystem, { cwd: '/' })
return ctx.plugin(projectInstructions, config)
}
function stubAgent(cwd?: string): Agent {
const id = SessionId('s1')
const session = new Session(id, [], cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd })
@@ -439,7 +493,7 @@ describe('project instruction request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await ctx.plugin({ name: 'project-instructions', apply }, { dshHome: home })
await mountProjectInstructions(ctx, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
@@ -460,6 +514,169 @@ describe('project instruction request injection', () => {
}
})
it('loads instruction file content through ctx.fs instead of direct node reads', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'node fs rule')
const ctx = new Context()
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'ctx.fs rule' })
await ctx.plugin(projectInstructions, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
}
const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request)
expect(firstText(result.messages[0])).toContain('ctx.fs rule')
expect(firstText(result.messages[0])).not.toContain('node fs rule')
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')])
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('loads user-global and CLAUDE fallback content through ctx.fs', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(home, 'AGENTS.md'), 'node global rule')
await write(join(root, 'CLAUDE.md'), 'node claude rule')
const ctx = new Context()
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(home, 'AGENTS.md'), { type: 'file', content: 'ctx global rule' })
fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'ctx claude rule' })
await ctx.plugin(projectInstructions, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
}
const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request)
expect(firstText(result.messages[0])).toContain('ctx global rule')
expect(firstText(result.messages[0])).toContain('ctx claude rule')
expect(firstText(result.messages[0])).not.toContain('node global rule')
expect(firstText(result.messages[0])).not.toContain('node claude rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('skips lstat-visible instruction files when ctx.fs reports a non-file target', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'node fs rule')
const ctx = new Context()
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' })
await ctx.plugin(projectInstructions, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
}
const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request)
expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }])
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('loads instruction files when ctx.fs omits the metadata size', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'node fs rule')
const ctx = new Context()
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file' })
await ctx.plugin(projectInstructions, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
}
const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request)
expect(firstText(result.messages[0])).toContain('## AGENTS.md')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('skips lstat-visible instruction files when ctx.fs cannot stat them', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'node fs rule')
const ctx = new Context()
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.throwOnStat.add(join(root, 'AGENTS.md'))
await ctx.plugin(projectInstructions, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
}
const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request)
expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }])
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('treats ctx.fs marker lookup failures as absent root markers', 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 ctx = new Context()
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.throwOnStat.add(join(root, '.git'))
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' })
await ctx.plugin(projectInstructions, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
}
const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request)
expect(firstText(result.messages[0])).toContain('repo rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('keeps different session cwd instruction files isolated in one context', async () => {
const repoA = await tempRepo()
const repoB = await tempRepo()
@@ -470,7 +687,7 @@ describe('project instruction request injection', () => {
await write(join(repoA, 'AGENTS.md'), 'repo A only')
await write(join(repoB, 'AGENTS.md'), 'repo B only')
const ctx = new Context()
await ctx.plugin({ name: 'project-instructions', apply }, { dshHome: home })
await mountProjectInstructions(ctx, { dshHome: home })
const requestA: GenerateOptions = { model: 'mock', messages: [{ role: 'user', content: [{ type: 'text', text: 'A' }] }] }
const requestB: GenerateOptions = { model: 'mock', messages: [{ role: 'user', content: [{ type: 'text', text: 'B' }] }] }
@@ -497,7 +714,8 @@ describe('project instruction request injection', () => {
await write(join(root, 'AGENTS.md'), 'root schema default rule')
await write(join(cwd, 'AGENTS.md'), 'child schema default rule')
const ctx = new Context()
await ctx.plugin({ name: 'project-instructions', Config: ProjectInstructionsConfig, apply }, {})
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(projectInstructions, {})
const request: GenerateOptions = { model: 'mock', messages: [{ role: 'user', content: [{ type: 'text', text: 'prompt' }] }] }
const result = await ctx.waterfall('agent/request', stubAgent(cwd), 1, 1, request, async () => request)
@@ -517,7 +735,7 @@ describe('project instruction request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
const fiber = await ctx.plugin({ name: 'project-instructions', apply }, { dshHome: home })
const fiber = await mountProjectInstructions(ctx, { dshHome: home })
await fiber.dispose()
const request: GenerateOptions = {
@@ -540,7 +758,7 @@ describe('project instruction request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await ctx.plugin({ name: 'project-instructions', apply }, { dshHome: home, baselineMaxBytes: 0 })
await mountProjectInstructions(ctx, { dshHome: home, baselineMaxBytes: 0 })
const request: GenerateOptions = {
model: 'mock',
@@ -562,7 +780,7 @@ describe('project instruction request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await ctx.plugin({ name: 'project-instructions', apply }, { dshHome: home, baselineMaxBytes: -1 })
await mountProjectInstructions(ctx, { dshHome: home, baselineMaxBytes: -1 })
const request: GenerateOptions = {
model: 'mock',
@@ -583,7 +801,7 @@ describe('project instruction request injection', () => {
try {
await mkdir(join(root, '.git'), { recursive: true })
const ctx = new Context()
await ctx.plugin({ name: 'project-instructions', apply }, { dshHome: home })
await mountProjectInstructions(ctx, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',

View File

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

View File

@@ -23,6 +23,9 @@
{
"path": "../../core/agent-core"
},
{
"path": "../../prompt/project-instructions"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
}

View File

@@ -29,6 +29,9 @@
{
"path": "../../core/agent-core"
},
{
"path": "../../prompt/project-instructions"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
},

9
pnpm-lock.yaml generated
View File

@@ -186,6 +186,9 @@ importers:
'@deepseek-ai/dsh-agent-loop':
specifier: workspace:^
version: link:../agent-loop
'@deepseek-ai/dsh-fs-local':
specifier: workspace:^
version: link:../../fs/fs-local
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
@@ -413,6 +416,12 @@ importers:
'@deepseek-ai/dsh-agent-loop':
specifier: workspace:^
version: link:../../core/agent-loop
'@deepseek-ai/dsh-fs':
specifier: workspace:^
version: link:../../fs/fs
'@deepseek-ai/dsh-fs-local':
specifier: workspace:^
version: link:../../fs/fs-local
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm