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. 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. 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()` | | 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) | | 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 | | 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 (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 can report touched paths; late context should use `agent.inject()` | | 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` | | 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` | | 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) | | 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 --> llm
invariants --> session invariants --> session
project-instructions --> agent project-instructions --> agent
project-instructions --> fs
project-instructions --> llm project-instructions --> llm
project-instructions --> paths project-instructions --> paths
session-persistence-jsonl --> session session-persistence-jsonl --> session
@@ -133,7 +134,7 @@ graph TD
| `session-persistence` | `session` | | `session-persistence` | `session` |
| `compact-basic` | `agent`, `compact`, `llm`, `session` | | `compact-basic` | `agent`, `compact`, `llm`, `session` |
| `invariants` | `agent`, `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-jsonl` | `session`, `session-persistence` |
| `session-persistence-sqlite` | `session`, `session-persistence` | | `session-persistence-sqlite` | `session`, `session-persistence` |
| `tools` | `agent`, `llm`, `system-prompt` | | `tools` | `agent`, `llm`, `system-prompt` |

View File

@@ -27,6 +27,13 @@
- id: bash - id: bash
name: '@deepseek-ai/dsh-bash-local' 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 # The stdio chat app: console logger + the agent-core spine (pre-creating the
# `main` agent on the mock model) + JSONL persistence + the readline UI. # `main` agent on the mock model) + JSONL persistence + the readline UI.
- id: stdio-agent - 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 ← 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-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-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-bash-local ← dsh-bash (BashExecutor impl)
dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas)
dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam + fs/* events) dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam + fs/* events)

View File

@@ -38,6 +38,7 @@
"@cordisjs/plugin-timer": "workspace:^", "@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-project-instructions": "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 * as agentCore from '../src/index.ts'
import { AgentId } from '@deepseek-ai/dsh-agent' import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session' 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 { MockAdapter, textResponse } from '../../agent-loop/tests/mock-adapter.ts'
import type { Message } from '@deepseek-ai/dsh-llm' 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') await writeFile(join(root, 'AGENTS.md'), 'bundled project rule')
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await mount() const ctx = await mount()
await ctx.plugin(LocalFileSystem, { cwd: '/' })
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const handle = ctx.agents.create({ const handle = ctx.agents.create({
agentId: AgentId('main'), agentId: AgentId('main'),

View File

@@ -4,7 +4,7 @@ Project instruction file loader for the harness. It discovers `AGENTS.md` with `
## Behavior ## 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. 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 ## 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. 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 ## Non-goals

View File

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

View File

@@ -1,7 +1,7 @@
/** /**
* Project instruction file loader: discovers `AGENTS.md` with `CLAUDE.md` * Project instruction file loader: discovers `AGENTS.md` with `CLAUDE.md`
* fallback on the per-session workspace path and injects it as fenced * fallback on the per-session workspace path, reads them through `ctx.fs`, and
* workspace context for each model request. * injects them as fenced workspace context for each model request.
* *
* @module @deepseek-ai/dsh-project-instructions * @module @deepseek-ai/dsh-project-instructions
*/ */
@@ -12,9 +12,11 @@ import type { Context } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent' 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' import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome, resolveDshHome } from '@deepseek-ai/dsh-paths'
export const name = 'project-instructions' export const name = 'project-instructions'
export const inject = ['fs']
const DEFAULT_BASELINE_MAX_BYTES = 64 * 1024 const DEFAULT_BASELINE_MAX_BYTES = 64 * 1024
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
@@ -48,6 +50,7 @@ export interface InstructionFile {
interface DiscoveredInstructionFile extends InstructionFile { interface DiscoveredInstructionFile extends InstructionFile {
signature: FileSignature signature: FileSignature
target?: FsTarget
} }
export interface LoadedInstructionFile extends InstructionFile { export interface LoadedInstructionFile extends InstructionFile {
@@ -74,8 +77,8 @@ interface ResolvedConfig {
} }
interface FileSignature { interface FileSignature {
mtimeMs: number version: string
size: number size: number | undefined
} }
interface CachedContent extends FileSignature { interface CachedContent extends FileSignature {
@@ -117,11 +120,11 @@ function truncateUtf8(value: string, maxBytes: number): string {
return truncated return truncated
} }
async function statFile(path: string): Promise<FileSignature | undefined> { async function nodeStatFile(path: string): Promise<FileSignature | undefined> {
try { try {
const info = await lstat(path) const info = await lstat(path)
if (!info.isFile()) return undefined if (!info.isFile()) return undefined
return { mtimeMs: info.mtimeMs, size: info.size } return { version: `${info.mtimeMs}:${info.size}`, size: info.size }
} catch { } catch {
// Expected race/absence: a candidate file may not exist, or may disappear // Expected race/absence: a candidate file may not exist, or may disappear
// between directory discovery and stat. Treat it as not loadable. // 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 { try {
await stat(path) await stat(path)
return true 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) let current = resolve(cwd)
for (;;) { for (;;) {
for (const marker of markers) { 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) const parent = dirname(current)
if (parent === current) return resolve(cwd) if (parent === current) return resolve(cwd)
@@ -170,17 +201,30 @@ async function firstExistingInstructionFile(
dir: string, dir: string,
root: string, root: string,
enableClaudeFallback: boolean, enableClaudeFallback: boolean,
fileSystem?: FileSystem,
): Promise<DiscoveredInstructionFile | undefined> { ): Promise<DiscoveredInstructionFile | undefined> {
const agentsPath = join(dir, 'AGENTS.md') const agentsPath = join(dir, 'AGENTS.md')
const agentsSignature = await statFile(agentsPath) const agentsSignature = await statFile(agentsPath, fileSystem)
if (agentsSignature !== undefined) { 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 if (!enableClaudeFallback) return undefined
const claudePath = join(dir, 'CLAUDE.md') const claudePath = join(dir, 'CLAUDE.md')
const claudeSignature = await statFile(claudePath) const claudeSignature = await statFile(claudePath, fileSystem)
if (claudeSignature !== undefined) { 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 return undefined
} }
@@ -189,7 +233,7 @@ function relativeDisplay(root: string, path: string): string {
return relative(root, path) 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 config = resolveConfig(options)
const files: DiscoveredInstructionFile[] = [] const files: DiscoveredInstructionFile[] = []
const seen = new Set<string>() const seen = new Set<string>()
@@ -200,17 +244,23 @@ async function discoverInstructionFiles(options: DiscoverOptions): Promise<Disco
} }
const userGlobal = join(config.dshHome, 'AGENTS.md') const userGlobal = join(config.dshHome, 'AGENTS.md')
const userGlobalSignature = await statFile(userGlobal) const userGlobalSignature = await statFile(userGlobal, fileSystem)
if (userGlobalSignature !== undefined) { if (userGlobalSignature !== undefined) {
const { target, ...signature } = userGlobalSignature
const defaultHome = resolve(defaultDshHome()) const defaultHome = resolve(defaultDshHome())
const displayPath = config.dshHome === defaultHome ? `${DEFAULT_DSH_HOME_DISPLAY}/AGENTS.md` : '$DSH_HOME/AGENTS.md' 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 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)) { 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) if (file !== undefined) addFile(file)
} }
return files return files
@@ -220,13 +270,21 @@ export async function discoverBaselineInstructionFiles(options: DiscoverOptions)
return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath })) 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) 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 return cached.content
} }
try { 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 }) cache.set(path, { ...signature, content })
return content return content
} catch { } 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) const config = resolveConfig(options)
if (config.baselineMaxBytes <= 0 || !Number.isFinite(config.baselineMaxBytes)) return undefined if (config.baselineMaxBytes <= 0 || !Number.isFinite(config.baselineMaxBytes)) return undefined
const cache = options.cache ?? new Map<string, CachedContent>() const cache = options.cache ?? new Map<string, CachedContent>()
const discovered = await discoverInstructionFiles(options) const discovered = await discoverInstructionFiles(options, fileSystem)
const loaded: LoadedInstructionFile[] = [] const loaded: LoadedInstructionFile[] = []
for (const file of discovered) { 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 (content !== undefined) loaded.push({ absolutePath: file.absolutePath, displayPath: file.displayPath, content })
} }
if (loaded.length === 0) return undefined if (loaded.length === 0) return undefined
@@ -379,7 +440,7 @@ export function apply(ctx: Context, config: Config): void {
baselineMaxBytes: resolved.baselineMaxBytes, baselineMaxBytes: resolved.baselineMaxBytes,
enableClaudeFallback: resolved.enableClaudeFallback, enableClaudeFallback: resolved.enableClaudeFallback,
cache, cache,
}) }, ctx.fs)
if (instructions !== undefined) { if (instructions !== undefined) {
request.messages = [workspaceContextMessage(instructions.text), ...request.messages] 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 AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import * as ProjectInstructions from '@deepseek-ai/dsh-project-instructions' 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' import type { SessionEvent } from '@deepseek-ai/dsh-session'
const PROBE = 'DSH_PROJECT_INSTRUCTIONS_PROBE_BANANA' const PROBE = 'banana-271828'
let ctx: Context | undefined let ctx: Context | undefined
let workdir: string | undefined let workdir: string | undefined
@@ -29,13 +30,14 @@ afterEach(async () => {
async function harness(): Promise<{ ctx: Context; agent: Agent }> { async function harness(): Promise<{ ctx: Context; agent: Agent }> {
workdir = await mkdtemp(join(tmpdir(), 'dsh-project-instructions-e2e-')) workdir = await mkdtemp(join(tmpdir(), 'dsh-project-instructions-e2e-'))
await mkdir(join(workdir, '.git'), { recursive: true }) 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() ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
await ctx.plugin(SessionStore) await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt) await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry) await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ProjectInstructions) await ctx.plugin(ProjectInstructions)
await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) 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 () => { it('obeys a probe instruction loaded from the workspace', async () => {
const live = await harness() 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) await waitForIdle(live.ctx, live.agent)
expect(finalText([...live.agent.session.events])).toContain(PROBE) 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 { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent'
import { AgentId } 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 { import {
apply,
Config as ProjectInstructionsConfig,
discoverBaselineInstructionFiles, discoverBaselineInstructionFiles,
loadBaselineInstructions, loadBaselineInstructions,
renderProjectInstructions, renderProjectInstructions,
@@ -27,6 +35,52 @@ async function write(path: string, content: string): Promise<void> {
await writeFile(path, content) 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 { function stubAgent(cwd?: string): Agent {
const id = SessionId('s1') const id = SessionId('s1')
const session = new Session(id, [], cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd }) 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 mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule') await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context() const ctx = new Context()
await ctx.plugin({ name: 'project-instructions', apply }, { dshHome: home }) await mountProjectInstructions(ctx, { dshHome: home })
const request: GenerateOptions = { const request: GenerateOptions = {
model: 'mock', 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 () => { it('keeps different session cwd instruction files isolated in one context', async () => {
const repoA = await tempRepo() const repoA = await tempRepo()
const repoB = 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(repoA, 'AGENTS.md'), 'repo A only')
await write(join(repoB, 'AGENTS.md'), 'repo B only') await write(join(repoB, 'AGENTS.md'), 'repo B only')
const ctx = new Context() 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 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' }] }] } 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(root, 'AGENTS.md'), 'root schema default rule')
await write(join(cwd, 'AGENTS.md'), 'child schema default rule') await write(join(cwd, 'AGENTS.md'), 'child schema default rule')
const ctx = new Context() 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 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) 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 mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule') await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context() 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() await fiber.dispose()
const request: GenerateOptions = { const request: GenerateOptions = {
@@ -540,7 +758,7 @@ describe('project instruction request injection', () => {
await mkdir(join(root, '.git'), { recursive: true }) await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule') await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context() 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 = { const request: GenerateOptions = {
model: 'mock', model: 'mock',
@@ -562,7 +780,7 @@ describe('project instruction request injection', () => {
await mkdir(join(root, '.git'), { recursive: true }) await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule') await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context() 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 = { const request: GenerateOptions = {
model: 'mock', model: 'mock',
@@ -583,7 +801,7 @@ describe('project instruction request injection', () => {
try { try {
await mkdir(join(root, '.git'), { recursive: true }) await mkdir(join(root, '.git'), { recursive: true })
const ctx = new Context() const ctx = new Context()
await ctx.plugin({ name: 'project-instructions', apply }, { dshHome: home }) await mountProjectInstructions(ctx, { dshHome: home })
const request: GenerateOptions = { const request: GenerateOptions = {
model: 'mock', model: 'mock',

View File

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

View File

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

View File

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

9
pnpm-lock.yaml generated
View File

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