From a52cac00b1a0dbb6969da5bd634e22c72cf02c1b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 3 Jul 2026 11:56:58 +0800 Subject: [PATCH] fix(project-instructions): load files through fs service --- docs/architecture.md | 6 +- docs/module-graph.md | 3 +- examples/echo-agent/cordis.yml | 7 + packages/README.md | 2 +- packages/core/agent-core/package.json | 1 + .../core/agent-core/tests/agent-core.spec.ts | 2 + .../prompt/project-instructions/README.md | 6 +- .../prompt/project-instructions/package.json | 3 + .../prompt/project-instructions/src/index.ts | 111 ++++++-- .../tests/project-instructions.e2e.ts | 8 +- .../tests/project-instructions.spec.ts | 236 +++++++++++++++++- .../prompt/project-instructions/tsconfig.json | 3 + packages/ui/acp-agent/tsconfig.json | 3 + packages/ui/stdio-agent/tsconfig.json | 3 + pnpm-lock.yaml | 9 + 15 files changed, 358 insertions(+), 45 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 234abdde93..bc39dfbeed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) | diff --git a/docs/module-graph.md b/docs/module-graph.md index ec5355694e..db45c18140 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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` | diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml index 9eef3d1a1b..a918dbbefb 100644 --- a/examples/echo-agent/cordis.yml +++ b/examples/echo-agent/cordis.yml @@ -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 diff --git a/packages/README.md b/packages/README.md index 2020c384bb..d655d92ed6 100644 --- a/packages/README.md +++ b/packages/README.md @@ -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) diff --git a/packages/core/agent-core/package.json b/packages/core/agent-core/package.json index dda3c6eb80..166e40acaf 100644 --- a/packages/core/agent-core/package.json +++ b/packages/core/agent-core/package.json @@ -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:^", diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index faff57a863..ed0b25b11d 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -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'), diff --git a/packages/prompt/project-instructions/README.md b/packages/prompt/project-instructions/README.md index cca2afb656..d2b17857ef 100644 --- a/packages/prompt/project-instructions/README.md +++ b/packages/prompt/project-instructions/README.md @@ -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 diff --git a/packages/prompt/project-instructions/package.json b/packages/prompt/project-instructions/package.json index 8ec27f2f1d..aa3db5d112 100644 --- a/packages/prompt/project-instructions/package.json +++ b/packages/prompt/project-instructions/package.json @@ -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:^", diff --git a/packages/prompt/project-instructions/src/index.ts b/packages/prompt/project-instructions/src/index.ts index 336bda2324..e6e6e63aac 100644 --- a/packages/prompt/project-instructions/src/index.ts +++ b/packages/prompt/project-instructions/src/index.ts @@ -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 { +async function nodeStatFile(path: string): Promise { 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 { } } -async function existsAsMarker(path: string): Promise { +async function fsStatFile(path: string, fileSystem: FileSystem): Promise { + 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 { + 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 { } } -async function findProjectRoot(cwd: string, markers: readonly string[]): Promise { +async function findProjectRoot(cwd: string, markers: readonly string[], fileSystem?: FileSystem): Promise { 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 { 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 { +async function discoverInstructionFiles(options: DiscoverOptions, fileSystem?: FileSystem): Promise { const config = resolveConfig(options) const files: DiscoveredInstructionFile[] = [] const seen = new Set() @@ -200,17 +244,23 @@ async function discoverInstructionFiles(options: DiscoverOptions): Promise ({ absolutePath, displayPath })) } -async function readCached(path: string, signature: FileSignature, cache: InstructionContentCache): Promise { +async function readCached( + file: DiscoveredInstructionFile, + cache: InstructionContentCache, + fileSystem?: FileSystem, +): Promise { + 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 { +export async function loadBaselineInstructions( + options: LoadOptions, + fileSystem?: FileSystem, +): Promise { const config = resolveConfig(options) if (config.baselineMaxBytes <= 0 || !Number.isFinite(config.baselineMaxBytes)) return undefined const cache = options.cache ?? new Map() - 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] } diff --git a/packages/prompt/project-instructions/tests/project-instructions.e2e.ts b/packages/prompt/project-instructions/tests/project-instructions.e2e.ts index f1ac503d29..3d54a4e5f2 100644 --- a/packages/prompt/project-instructions/tests/project-instructions.e2e.ts +++ b/packages/prompt/project-instructions/tests/project-instructions.e2e.ts @@ -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) diff --git a/packages/prompt/project-instructions/tests/project-instructions.spec.ts b/packages/prompt/project-instructions/tests/project-instructions.spec.ts index 79f1ccb293..0680350c25 100644 --- a/packages/prompt/project-instructions/tests/project-instructions.spec.ts +++ b/packages/prompt/project-instructions/tests/project-instructions.spec.ts @@ -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 { await writeFile(path, content) } +class RecordingFileSystem extends FileSystem { + entries = new Map() + throwOnStat = new Set() + readTargets: string[] = [] + + override async resolve(path: string, opts?: { cwd?: string }): Promise { + const absolute = join(opts?.cwd ?? '/', path) + return { inputPath: path, targetKey: FsTargetKey(absolute), displayPath: absolute } + } + + override async stat(target: FsTarget): Promise { + 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 { + this.readTargets.push(target.targetKey) + return this.entries.get(target.targetKey)?.content ?? '' + } + + override async streamText(target: FsTarget): Promise> { + const content = await this.readText(target) + return (async function* () { yield content })() + } + + override async writeText(_target: FsTarget, _content: string, _expected?: FsWriteIntent): Promise { + return { operation: 'update', version: FsVersion('unused') } + } + + override async editText(_target: FsTarget, _edit: FsEditRequest): Promise { + return { replacements: 0, replaceAll: false, version: FsVersion('unused') } + } +} + +async function mountProjectInstructions(ctx: Context, config: projectInstructions.Config): Promise>> { + 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', diff --git a/packages/prompt/project-instructions/tsconfig.json b/packages/prompt/project-instructions/tsconfig.json index 5ba191afce..f4a8565b6f 100644 --- a/packages/prompt/project-instructions/tsconfig.json +++ b/packages/prompt/project-instructions/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/agent" }, + { + "path": "../../fs/fs" + }, { "path": "../../util/paths" } diff --git a/packages/ui/acp-agent/tsconfig.json b/packages/ui/acp-agent/tsconfig.json index ffea8ec6f6..7091438adf 100644 --- a/packages/ui/acp-agent/tsconfig.json +++ b/packages/ui/acp-agent/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/agent-core" }, + { + "path": "../../prompt/project-instructions" + }, { "path": "../../session-persistence/session-persistence-jsonl" } diff --git a/packages/ui/stdio-agent/tsconfig.json b/packages/ui/stdio-agent/tsconfig.json index 58b492a549..c4c55f1541 100644 --- a/packages/ui/stdio-agent/tsconfig.json +++ b/packages/ui/stdio-agent/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../core/agent-core" }, + { + "path": "../../prompt/project-instructions" + }, { "path": "../../session-persistence/session-persistence-jsonl" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18f111405f..cbffbc23d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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