mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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。
|
||||
@@ -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)
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user