feat(cli): tell the agent where its own source lives and invite it to extend dsh

Squashes feat/dsh-system-prompt-source-path and feat/dsh-source-extend-hint.
This commit is contained in:
Turtle
2026-07-22 10:55:18 +08:00
parent 0e06d0691d
commit 19f9a509aa
7 changed files with 155 additions and 5 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-21-dsh-system-prompt-source-path.md: b54d01488fd7c0b49e06200c93af2b056c9fd00b
2026-07-21-dsh-system-prompt-source-path.zh.md: 208e3dce072f63c280999e15276dce62ff4e5c43

View File

@@ -0,0 +1,37 @@
# Agent Note: dsh tells the agent where its own source lives
Status: implemented
English | [中文](2026-07-21-dsh-system-prompt-source-path.zh.md)
## Problem
The `dsh` CLI is the self-referential surface: its `cordis` toolset lets the agent inspect and modify the very harness runtime it runs in. But the agent had no way to learn where that source lives on disk. `dsh` is normally symlinked onto PATH and launched from an arbitrary working directory — the project under work — so neither the cwd nor `argv` reliably points at the harness checkout. Without the path, "read your own source" is guesswork.
## Decision
The `dsh` launcher (`apps/cli/src/tui.ts`) computes the harness checkout root from its own module URL — `fileURLToPath(new URL('../../..', import.meta.url))`, three hops up from `apps/cli/{src,lib}` — so it resolves to the real source location however `dsh` is launched (a PATH symlink, an arbitrary cwd). After `boot()` settles the tree, the launcher calls the new `addHarnessSourceSection(ctx, sourceRoot)` helper from `dsh-app-boot`, which registers a global `harness:source` prompt section reading `Your own source code is the checkout at <path>; you can read it there to learn how dsh works and how to extend it.` The section orders at `-99`, just after the harness identity opener (`-100`) and before the deployment persona (`0`).
The testable logic lives in `dsh-app-boot`, not in `apps/cli`, because `apps/*` are not coverage-gated and `packages/*` are. Resolving the optional `systemPrompt` service, registering the section, and returning the disposer belong where per-file 100% coverage applies; the launcher keeps only the thin glue — compute the path, call the helper — covered by the CLI's PTY e2e. When the booted tree has no `systemPrompt` service the helper is a no-op returning `undefined`.
## Scope
Only the `dsh` CLI adds this. The demo bins (`dsh-tui-demo`, `dsh-acp-demo`) boot their committed trees verbatim and gain no source section: they are not the self-modification surface, and their checkout root is not a fact the model needs.
## HMR
The section is registered against the booted `systemPrompt` service's own fiber (through `ctx.get('systemPrompt')`), so a dev HMR reload of the system-prompt plugin drops it until the next boot. Production HMR watches the config, not the built lib, so this is a dev-only wrinkle and acceptable.
## Alternatives considered
**Register the section inside the system-prompt service constructor.** It would then appear in every deployment, not just the self-referential CLI, and the source root would have to be threaded through config to reach the constructor. The path is a launcher fact, so the launcher owns injecting it.
**Keep the whole thing in `apps/cli/src/tui.ts`.** Apps are not coverage-gated, so the registration and absent-service branches would ship untested. Extracting the tested helper into `dsh-app-boot` keeps the gate meaningful; the launcher glue is exercised by the CLI's keyless PTY smoke.
**Add a cordis.yml config field for the path.** The path is not a deployment choice — it is mechanically the launcher's own location. A config field invites a stale hand-entered path and adds a knob with no legitimate variation.
**Resolve from `process.cwd()` or `process.argv[1]`.** The cwd is the user's project, and a PATH symlink makes `argv[1]` the symlink path; `import.meta.url` is the only handle on the real source location.
## Consequences
The agent's system prompt now names its own checkout, so the `cordis` toolset can read and edit harness source with no discovery step. `dsh-app-boot` gains a type-only dependency on `dsh-system-prompt` (peer + dev, matching the acp package's side-effect type import) for the `ctx.get('systemPrompt')` declaration merge; there is no runtime dependency. The section is model-visible text, pinned verbatim in an app-boot unit test and asserted end to end through the CLI's keyless PTY smoke — which boots `dsh` against the scripted config, runs a turn, and reads the path back out of the persisted `request/header` system prompt. The line sits before per-request content, so it does not perturb the KV cache across turns.

View File

@@ -0,0 +1,37 @@
# Agent Note: dsh 告知 agent 其自身源码所在位置
Status: implemented
[English](2026-07-21-dsh-system-prompt-source-path.md) | 中文
## Problem
`dsh` CLI 是自我引用的接口:其 `cordis` 工具包让 agent智能体得以查看并修改它自身运行其上的 harness智能体框架运行时。但 agent 此前无从得知这份源码在磁盘上的位置。`dsh` 通常以符号链接的形式挂到 PATH 上,并从任意工作目录(正在处理的项目)启动,因此无论是 cwd 还是 `argv` 都无法可靠地指向 harness 检出目录。缺了这个路径,"读取你自己的源码"便只能靠猜。
## Decision
`dsh` 启动器(`apps/cli/src/tui.ts`)从它自身的模块 URL 计算 harness 检出根目录——`fileURLToPath(new URL('../../..', import.meta.url))`,从 `apps/cli/{src,lib}` 向上三级——因此无论 `dsh` 以何种方式启动PATH 符号链接、任意 cwd它都能解析到真实的源码位置。在 `boot()` 使插件树就位之后,启动器调用来自 `dsh-app-boot` 的新辅助函数 `addHarnessSourceSection(ctx, sourceRoot)`,它注册一个全局 `harness:source` 提示词段,内容为 `Your own source code is the checkout at <path>; you can read it there to learn how dsh works and how to extend it.`。该段的 order 为 `-99`,恰在 harness 身份开场(`-100`)之后、部署 persona`0`)之前。
可测试的逻辑放在 `dsh-app-boot` 而非 `apps/cli` 中,因为 `apps/*` 不受覆盖率门禁约束,而 `packages/*` 受约束。解析可选的 `systemPrompt` 服务、注册该段、返回 dispose资源释放这些都属于按文件 100% 覆盖率生效的地方;启动器只保留那层薄薄的黏合——计算路径、调用辅助函数——由 CLI 的 PTY e2e 覆盖。当就位的插件树没有 `systemPrompt` 服务时,该辅助函数是一个返回 `undefined` 的空操作。
## Scope
只有 `dsh` CLI 会加入这一段。demo bin`dsh-tui-demo``dsh-acp-demo`)原样引导它们已提交的插件树,不会获得 source 段:它们不是自我修改的接口,其检出根目录也不是模型需要知道的事实。
## HMR
该段是针对就位后的 `systemPrompt` 服务自身的 fiber 注册的(通过 `ctx.get('systemPrompt')`),因此对 system-prompt 插件做一次开发态 HMR热模块替换重载会丢弃它直到下一次引导为止。生产环境的 HMR 监视的是配置而非构建产物 lib所以这只是一个仅限开发态的小瑕疵可以接受。
## Alternatives considered
**在 system-prompt 服务的构造函数内注册该段。** 那样它会出现在每一个部署中,而不只是自我引用的 CLI而且源码根目录还得穿过配置才能到达构造函数。这个路径是启动器的事实所以由启动器负责注入它。
**把整件事都留在 `apps/cli/src/tui.ts` 里。** apps 不受覆盖率门禁约束,因此注册逻辑与服务缺失分支会以未受测的形式发布。把受测的辅助函数抽取到 `dsh-app-boot` 让门禁保持有效;启动器的黏合部分由 CLI 的无密钥 PTY 冒烟测试演练。
**为该路径新增一个 cordis.yml 配置键。** 这个路径不是一项部署选择——它在机制上就是启动器自身的位置。配置键会招致手工填入的路径变陈旧,并新增一个没有合理变化空间的旋钮。
**从 `process.cwd()` 或 `process.argv[1]` 解析。** cwd 是用户的项目,而 PATH 符号链接会使 `argv[1]` 成为符号链接自身的路径;`import.meta.url` 是唯一能抓住真实源码位置的把手。
## Consequences
agent 的系统提示词现在会写明它自己的检出目录,因此 `cordis` 工具包无需一个发现步骤就能读取并编辑 harness 源码。`dsh-app-boot``ctx.get('systemPrompt')` 的声明合并新增了一个对 `dsh-system-prompt` 的仅类型依赖peer dependency对等依赖+ dev与 acp 包的副作用型类型 import 模式一致);不存在运行时依赖。该段是模型可见文本,在 app-boot 单元测试中逐字锁定,并通过 CLI 的无密钥 PTY 冒烟测试端到端断言——该测试以脚本化配置引导 `dsh`、运行一个轮次,再从持久化的 `request/header` 系统提示词中把路径读回来。这一行位于按请求变化的内容之前,所以它不会在多个轮次间扰动 KV Cache。

View File

@@ -6,6 +6,7 @@ The TUI surface:
- boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd;
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.config/dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
## Install (developer machine)

View File

@@ -5,12 +5,15 @@
* environment, then the invoking directory's `.env`, then the personal one)
* and its `config.yaml` patches the booted tree. The workspace is the invoking
* directory: sessions, relative paths, and workspace instructions resolve from
* the cwd, so `dsh` acts on whatever project it is launched in.
* the cwd, so `dsh` acts on whatever project it is launched in. After boot, the
* agent's system prompt is told the path to this harness checkout so it can find
* its own source.
* @module @deepseek-ai/dsh/tui
*/
import { fileURLToPath } from 'node:url'
import {
addHarnessSourceSection,
boot,
installFailLoud,
loadEnv,
@@ -26,6 +29,11 @@ const NAME = 'dsh'
// the same relative hop from either artifact.
const DEFAULT_CONFIG = fileURLToPath(new URL('../../../examples/tui-agent/cordis.yml', import.meta.url))
// The harness checkout root: three hops up from apps/cli/{src,lib}, resolved
// from this bin's location so it holds however `dsh` is launched (a PATH
// symlink, an arbitrary cwd). The agent is told where its own source lives.
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers;
the tui-agent PTY smoke drives this path end to end, personal overlay included */
/**
@@ -45,6 +53,7 @@ export async function runTui(argv: string[]): Promise<void> {
// The bin already loaded the invoking directory's .env; the personal .env
// only fills what is still unset (process.loadEnvFile never overrides).
loadEnv(NAME, resolvePersonalConfigDir())
await boot(NAME, resolveConfigPath(argv[0] ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME))
const ctx = await boot(NAME, resolveConfigPath(argv[0] ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME))
addHarnessSourceSection(ctx, SOURCE_ROOT)
}
/* v8 ignore stop */

View File

@@ -2,10 +2,11 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
assertEntriesLoaded, boot, installFailLoud, loadEnv, resolveConfigPath,
type FailLoudProcess,
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
@@ -176,3 +177,56 @@ describe('boot', () => {
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
})
})
describe('addHarnessSourceSection', () => {
const SOURCE_ROOT = `${sep}opt${sep}harness-src`
const EXPECTED = `Your own source code is the checkout at ${SOURCE_ROOT}; you can read it there to learn how dsh works and how to extend it.`
it('adds the source path between the harness identity and the deployment persona', async () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)
expect(dispose).toBeTypeOf('function')
const systemPrompt = ctx.get('systemPrompt')!
const rendered = renderPrompt(await systemPrompt.assemble())
expect(rendered).toContain(EXPECTED)
// Harness-owned opener (-100) → source (-99) → persona (0). The >= 0 guards
// keep a drifted opener/persona string from a false pass through `-1 < n`.
const identityAt = rendered.indexOf('You are an AI agent powered by the DeepSeek Harness SDK.')
const sourceAt = rendered.indexOf(EXPECTED)
const personaAt = rendered.indexOf('You are a coding agent.')
expect(identityAt).toBeGreaterThanOrEqual(0)
expect(personaAt).toBeGreaterThanOrEqual(0)
expect(identityAt).toBeLessThan(sourceAt)
expect(sourceAt).toBeLessThan(personaAt)
} finally {
await ctx.fiber.dispose()
}
})
it('is a no-op returning undefined when no systemPrompt service is mounted', async () => {
const ctx = new Context()
try {
expect(addHarnessSourceSection(ctx, SOURCE_ROOT)).toBeUndefined()
} finally {
await ctx.fiber.dispose()
}
})
it('disposes the section it added, so a systemPrompt reload leaves no residue', async () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt, {})
const systemPrompt = ctx.get('systemPrompt')!
const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)!
const present = await systemPrompt.assemble()
expect(present.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(true)
dispose()
const gone = await systemPrompt.assemble()
expect(gone.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(false)
} finally {
await ctx.fiber.dispose()
}
})
})

View File

@@ -19,6 +19,12 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../util/paths"
}
]
}