From 19f9a509aa1c82540792288cf305fc3ac598ea21 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 10:55:18 +0800 Subject: [PATCH] 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. --- ...21-dsh-system-prompt-source-path.i18n.yaml | 6 ++ ...026-07-21-dsh-system-prompt-source-path.md | 37 ++++++++++++ ...-07-21-dsh-system-prompt-source-path.zh.md | 37 ++++++++++++ apps/cli/README.md | 1 + apps/cli/src/tui.ts | 13 +++- packages/ui/app-boot/tests/app-boot.spec.ts | 60 ++++++++++++++++++- packages/ui/app-boot/tsconfig.json | 6 ++ 7 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md create mode 100644 .agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml new file mode 100644 index 0000000000..f1b9829b73 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md new file mode 100644 index 0000000000..b54d01488f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md @@ -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 ; 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. diff --git a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md new file mode 100644 index 0000000000..208e3dce07 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md @@ -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 ; 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。 diff --git a/apps/cli/README.md b/apps/cli/README.md index 21479e4b3f..eb3eb4efc3 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -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) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index ef9ff91fb2..2ffc26081f 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -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 { // 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 */ diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 510186ebb0..d9934cb8bb 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -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() + } + }) +}) diff --git a/packages/ui/app-boot/tsconfig.json b/packages/ui/app-boot/tsconfig.json index b85dc7f6a2..23f83dda51 100644 --- a/packages/ui/app-boot/tsconfig.json +++ b/packages/ui/app-boot/tsconfig.json @@ -19,6 +19,12 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../util/paths" } ] }