deepseek-harness
deepseek-ai
DeepSeek Harness: Everything is a Plugin.
PROJECT TOPICS
INSTALL REFERENCE
dsh plugin --profile web add github:zdjmrq/dsh-better-chat
该命令指向仓库当前默认分支;尚无绑定当前 commit 的完整验证结果。
PROJECT README
给 DeepSeek Harness 的「更好的对话模式」模式:像 DeepSeek 网页版一样聊天,但会自己多轮思考。
一个 DSH 本地 bundle,向 Web 端注册名为「更好的对话模式」的 agent preset,并附带一个 keep_thinking 工具。
keep_thinking:纯文字的控制流工具,不碰文件、不联网、无任何副作用DSH 的 agent 循环规则是:让轮次继续的唯一燃料是「工具调用」或「steering」。没有工具调用,模型答完就结束。
所以这里给模型一个只用来表达"我还没想完"的空工具:
| 模型的行为 | 循环的行为 |
|---|---|
调用 keep_thinking |
执行工具 → 再跑一步(又一次模型请求) |
| 不调用、直接输出答案 | 轮次结束 |
这就是 agent loop 原生的控制流,不需要监听轮次边界、不需要往对话正文里塞协议标记。「停止」就等于「不再调用」。
| DeepSeek 网页版 | 本模式 |
|---|---|
| 联网搜索 | ✅ web_search + web_fetch |
| 文件上传 / 文本提取 | ✅ 全局附件能力(DSH 自带) |
| 图片识图 | ✅ 全局多模态输入 + read_image |
| 语音输入 | ✅ 取决于 profile 是否装了语音 bundle |
| 深度思考 | ✅ reasoningEffort(profile 级配置) |
| 快速 / 专家模式 | ✅ 就是 DSH 的 preset 概念,本模式是其中一个 |
| —— | ➕ 多轮纯文字思考(本模式独有) |
全部内容就是 cordis.patch.yml 里的一个 preset 声明。
空的。 本 bundle 以前在这里放一行 think-better-tool,现在没有了——原因见 「为什么工具行在 preset 里面」。
5 个官方工具行 + 本仓库自己的工具行,全部挂在 preset-chat 的 config.plugins 里:
| 行 id | 包 | 配置 | 提供的工具 |
|---|---|---|---|
think-better-tool |
think_better(本仓库,按包名引入) |
maxRounds: 10 |
keep_thinking |
persona |
@deepseek-ai/dsh-persona |
见 persona | —(系统提示词) |
tool-web |
@deepseek-ai/dsh-tool-web |
fetch: truesearchTimeoutMs: 60000 |
web_search web_fetch |
tool-ask-user |
@deepseek-ai/dsh-tool-ask-user |
— | ask_user_question |
tool-fs |
@deepseek-ai/dsh-tool-fs |
— | read write edit read_image |
tool-fs-search |
@deepseek-ai/dsh-tool-fs-search |
sampleOverCapGlobResults: false |
glob grep |
present |
@deepseek-ai/dsh-tool-present |
— | present |
合计 11 个工具。 实测「更好的对话模式」一轮实际下发 25 个工具——多出来的 14 个来自宿主层的其它 bundle,见 已知限制。
因为工具行就在 preset 里,
ctx.tools.register()走的是 preset 的作用域:只有「更好的对话模式」的 agent 看得见它,其它模式不需要被摘掉任何东西。
| 没挂 | 原因 |
|---|---|
dsh-tool-goal + dsh-command-goal |
goal-round-driver 在 base 中默认启用,模型一旦能建 goal 就会自动续轮,直接破坏"模型自己决定何时停" |
dsh-tool-ralph |
天生自我循环 |
dsh-tool-subagent / dsh-tool-workflow / agent-team |
请求数成倍放大 |
dsh-tool-todo |
会把人格拉向"干活" |
dsh-tool-pwsh / dsh-tool-bash / dsh-tool-jobs / dsh-tool-terminal |
重型,与"更好的对话模式"定位冲突 |
dsh-agent-instructions |
必须不挂,否则会去读 AGENTS.md,模型一上来就进入编码 agent 人格 |
dsh-plan-mode |
与更好的对话模式无关 |
dsh-compaction-basic(长对话自动压缩)、dsh-fs-observation-policy(先读后写门禁)、dsh-repeat-tool-reminder、dsh-tool-call-timeout-policy、dsh-session-title 系列。
maxRounds —— 唯一需要理解的参数- id: preset-chat
name: '@deepseek-ai/dsh-agent-preset'
config:
id: chat
plugins:
- id: think-better-tool
name: 'think_better'
config:
maxRounds: 10 # ← 改这里,必须是 ≥1 的整数
maxRounds 是每个用户轮次允许的思考轮数上限(每个 turn/start 清零)。它的行为:
| 第几次调用 | 工具返回 | 循环行为 |
|---|---|---|
1 … maxRounds-1 |
继续。(n/10,还剩 m 轮) |
再跑一步 |
maxRounds |
已达思考上限(10 轮)。现在必须直接给出最终答案,不要再调用本工具。 |
再跑一步,模型在这一步作答 |
> maxRounds |
思考已达 10 轮上限,本次回答到此结束。 + concludeTurn() |
本轮立即强制结束 |
净效果:最多 maxRounds 轮思考 + 1 轮最终回答,绝对收敛。
maxRounds 的 schema 是 z.natural().min(1).default(10):非整数、0、负数都在加载时直接校验失败(ValidationError),不会静默退化成一个"一调用就结束本轮"的工具。
| 行 | 参数 | 默认 | 说明 |
|---|---|---|---|
think-better-tool |
maxRounds |
10 |
见上 |
persona |
prefix |
见下 | 人设文本,可自由替换 |
persona |
suffix |
'当前工作目录是 {{cwd}}。' |
见 为什么 suffix 不能留空 |
persona |
includeRuntimeContext |
true |
是否注入运行时上下文(时间等)。设 false 提示词更干净,但模型不知道当前日期 |
tool-web |
fetch |
true |
是否启用 web_fetch |
tool-web |
searchTimeoutMs |
60000 |
搜索超时 |
tool-fs-search |
sampleOverCapGlobResults |
false |
必填(该插件的 Config schema 要求)。控制超出结果上限时是"跨顶层条目抽样"还是"保留前 N 条" |
改完
cordis.patch.yml需要重启 DSH:HMR 只监听 profile 自己的cordis.patch.yml和package.json,不监听 bundle 内部的 patch。
当前 persona.prefix 内容:
你是一位专业、耐心的对话助手,既能深入讨论专业问题,也能自然闲聊。
专业问题上:结论先行、依据清楚、不确定处直言不讳。
日常对话上:自然、简洁、不端着。
需要查证时使用联网搜索或文件工具,不做无意义的工具调用;简单问题直接回答。
推演不充分时调用 keep_thinking 继续思考,足够时立即作答。
这段文字可以按需要随意替换。 它就是 cordis.patch.yml 里 persona 行的 prefix,改完重启即可。
几点说明:
prefix 会遮蔽部署级人设(Web 端默认是 You are a coding agent powered by the {{model}} model.),所以人格是被替换而不是被追加。complete: true。complete 控制的是"除人设以外的东西还要不要":设了它,工具引导段落会被一并抹掉,模型更容易用错工具。这里要的是"换人格、留工具提示"。prefix 支持模板变量,例如 {{model}}、{{cwd}}。suffix 不能留空suffix 默认是空字符串,而空后缀会遮蔽掉部署级的 Your working directory is {{cwd}}.。
运行时上下文里没有任何 cwd 提供方(packages/context/ 下只有 agent-instructions、file-reference、session-reference、time-context、tmux-context),也就是说 {{cwd}} 是工作目录进入模型视野的唯一通道。而本模式挂了 read / glob / grep / write 四个按会话 cwd 解析相对路径的工具——不知道 cwd,模型就只能等你给绝对路径。
所以这里要显式写一句:suffix: '当前工作目录是 {{cwd}}。'
dsh plugin --profile <你的profile> add https://github.com/zdjmrq/dsh-better-chat
DSH 的 install_bundle 原生支持 git 仓库 URL,pnpm 会克隆仓库并把它装进 profile,不需要先 clone、也不需要发到 npm。
想锁定版本就加 commitish:
dsh plugin --profile <你的profile> add "https://github.com/zdjmrq/dsh-better-chat#v0.1.0"
git clone https://github.com/zdjmrq/dsh-better-chat.git
dsh plugin --profile <你的profile> add "<clone 出来的绝对路径>"
适合要自己改代码的场景——就地改,重装一次即生效。
侧边栏 插件 页 → 安装组合包 → 填上面任意一种 spec(git URL 或绝对路径)。
三种方式装完都需要重启 DSH 才会看到新模式。
本地路径方式会创建目录链接(junction / symlink),那个目录不要删除或移动,否则 profile 启动时会跳过这个 bundle。
本插件没有发布到 npm:git 安装方式已经覆盖同样的效果,没必要多维护一个发布渠道。
cordis.patch.yml 里只有一行(preset-chat);keep_thinking 是它 config.plugins 里的一个子行,按包名引入:
- id: think-better-tool
name: 'think_better'
config:
maxRounds: 10
0.1.x 用裸包名时失败过:
think-better-tool (think_better): never started
(never started 在 packages/preset/agent-preset-registry/src/mount.ts 中等价于「模块根本没解析成功」。)
当时的解释是「preset 的行挂在注册表拥有的内存子树里,profile 本地包名那条规则不适用」。这个解释站不住——把 packages/boot/app-boot 的解析器读完,四个条件现在全部成立:
| 条件 | 源码位置 | 本机取值 |
|---|---|---|
| 导入方 baseURL 落在 profile 目录里 | mountPreset 用 prepareProfileEntries(ctx, plugins, ctx.baseUrl)——注释原文是 "scope context inheriting the declaring Loader's resolution base" |
探针实测 file:///…/profiles/desktop/ ✅ |
| 拦截层由导入方路径决定,不由"谁建了这棵子树"决定 | findInterceptionLayer() → computeProfileLayer() |
路径在 active profile 之下 → { kind: 'profile', active: true } ✅ |
该层 active |
同上 | ✅ |
包名在 localPackageNames 里 |
profile manifest 的 dependencies |
think_better: link:… ✅ |
四条齐了,routeLocalPackage 就返回 route: { kind: 'native' },交给 Node 按普通 node_modules 解析。
所以当初那次失败最可能就是:think_better 还没写进 profile 的 dependencies,localPackageNames 里没有它,路由被拒——跟"preset 子树"没关系。
绝对 file: URL 在本机能跑,但发布包不能带它。指南 R22 明确:「发布包不得含作者机器的绝对路径」。原因不只是"换台机器路径就没了"——preset 里任何一行失败 = 整棵子树失败:
// agent-preset-registry/src/mount.ts
const audit = await auditRows(tree)
if (audit.failed.length > 0) throw new Error(audit.failed.join("\n"))
也就是说别人装完拿到的不是「少一个工具」,而是一个挂不上的模式,报错还是不指向根因的 never started。这就是 0.4.0 改回包名的唯一理由。
万一它哪天又不解析了:本机临时把这一行改回绝对
file://URL 去定位,但不要把那个改动提交/发布。
工具行挂在 preset 里,ctx.tools.register() 就落在 preset 的作用域上:只有从「更好的对话模式」组合出来的 agent 看得见 keep_thinking,其它模式什么都不用摘。插件因此没有任何可见性逻辑——不监听 agent/created、不推断当前预设、不调用 tools.restrict()。
实测记录(把没验证的结论跟验证过的分开写)
- 用一个一次性的探针预设实测:preset 子树里按 file URL 挂一行,
IMPORTED和APPLIED都会发生 ✅(file URL 这条路本身是通的,只是不能发布。)- 探针读到的
ctx.baseUrl是 profile 目录(file:///…/profiles/<name>/),不是app.asar——这正是上面那张表第一行的依据。- 未实测:0.4.0 换成包名之后的解析结果是从源码规则推出来的(四个条件逐条核过),不是重新探针测出来的。首次重启后请按本地自测那条清单确认一遍。
agentPreset 是创建时的冻结事实,不是当前值。agent-preset-registry/src/session.ts 的原文是:"reads the agentPreset Session projection, never the header alone"。select() 会拒绝已经开过轮的会话(抛 agent-preset/locked: This session has already started)。预设只能在第一次对话之前选,一旦跑过一轮就冻结——所以在旧会话里换不到「更好的对话模式」,只能新建一个。这两条在旧版本里曾经是个坑(那时可见性靠「按会话头 deny、切预设再解除」,还会踩到 select() 的时序)。现在工具跟着配方走,可见性已经与预设身份无关。
插件页显示的不是包名,而是一份可本地化的显示元数据。取法(packages/boot/app-boot/src/package-meta.ts 的 readPluginMeta):
<包名>/locale/en.json —— 这个文件是入口,没有它,其它语言文件根本不会被扫描;<语言id>.json,文件名就是查表用的键(小写化:zh-CN.json → zh-cn);meta.title / meta.description 取到就用;取不到则回落到 package.json 的 name / description;presentation.ts 是 title: pkg.meta?.title ?? pkg.name —— 所以包名和显示名是两回事。这一步很容易错。 DSH 内置的语言 id 是 zh 和 en(packages/client/locale/src/locale-settings.ts:LOCALE_IDS = ['zh', 'en']),不是 zh-CN。
查表是按当前语言 id 做的(locale/src/client/index.ts):
resolveText(text) {
return this.fallbackChain(this.snapshot.active).reduceRight(
(resolved, locale) => text[localeKey(locale)] ?? resolved, // localeKey = toLowerCase()
text.en,
)
}
所以文件名写成 zh-CN.json 时,键是 zh-cn,而当前语言是 zh → 查不到 → 静默回落到英文。页面就会显示英文标题和英文描述,而且不报任何错。
本仓库因此同时放两个,覆盖 zh 和可能的 zh-CN 语言包:
// locale/zh.json ← 内置语言,主要就靠这个
// locale/zh-CN.json ← 兼容自定义 zh-CN 语言包
{ "meta": { "title": "更好的对话模式", "description": "…" } }
// locale/en.json ← 入口文件 + 英文兜底
{ "meta": { "title": "Better Chat Mode", "description": "…" } }
| 页面元素 | 来源 | 能不能中文化 |
|---|---|---|
| 标题 | meta.title |
✅ |
| 描述 | meta.description |
✅ |
| 版本号 | package.json 的 version |
❌ 本来就不该翻译 |
| 包名(等宽字体那一行) | package.json 的 name |
❌ 原样显示(PluginManagerPage.tsx 里是 <code>{pkg.name}</code>),而且 npm 包名不允许中文,它同时是 dsh plugin remove <name> 要用的句柄 |
两个前提,缺一不可:
package.json 的 exports 必须放行这个路径("./locale/*": "./locale/*")——解析走的是 Node 的 ESM resolver,exports 不放行就等于没有这个文件(报 ERR_PACKAGE_PATH_NOT_EXPORTED,被当成"没配元数据"静默回落);files 里要有 locale,否则打包/安装时不会带上。包名 think_better、行 id think-better-tool、模块名都不用动——显示名和它们是分开的两层。另外 package.json 顶层可以放 icon(相对路径,SVG/PNG/JPEG/WebP,≤256 KiB),会被内联成 data URL 显示在插件页。
元数据在启动时读取,改完要重启。
dsh plugin --profile <你的profile> remove think_better
lib/index.js 的两个 harness import 被 test/stub-loader.mjs 顶替成桩,所以测试直接跑线上那个文件,不需要装 DSH:
pnpm test # = node --import ./test/register.mjs test/behaviour.test.mjs
覆盖:只注册一个工具、从不调用 tools.restrict()(可见性归作用域管,插件不该有可见性逻辑)、轮数计数与 turn/start/agent/disposed 的重置、每会话独立计数、maxRounds 行为、三档返回文案、空 thought 被拒且不计轮、dispose 之后工具与监听器全部注销(HMR 安全)。
0.2.x 那版测的是「从会话日志推断当前预设 + 解除 deny」;那套逻辑已经删掉了,相应的断言也一起删了。
桩的边界(必须知道):stub-dsh-tools.mjs 的 defineTool 是原样返回,stub-schemastery.mjs 什么都接受。所以测试只能断言本模块声明出来的对象形状,不能证明真 defineTool 会接受它、也不能证明真 schemastery 会拒绝坏的 maxRounds。
这不是偷懒:本机普通 Node 解析不到 @deepseek-ai/*(ERR_MODULE_NOT_FOUND),连 import('think_better') 从 profile 目录都失败——宿主里能跑,是因为 DSH 注入了宿主基准解析。所以「真实入口冒烟」在这台机器上只能手动做:
手动冒烟清单(改完 cordis.patch.yml / parameters / output / Config 之后跑一遍)
重启 DSH,打开 插件 页 → 「更好的对话模式」的内置插件列表里,这一行应该显示「更好的对话模式」/「Better Chat Mode」(因为 readPluginMeta 现在解析得到包名,能读到 locale/*.json),而不是一条 file:///… 路径。显示路径 = 解析或元数据出了问题。
新建一个会话,模式选「更好的对话模式」,问一个需要多步推演的问题,确认模型会调用 keep_thinking,并且返回文案是「继续。(1/10,还剩 9 轮)」这一档。
想要硬证据就看会话日志里的 request/header——那是实际发给模型的 tools 数组:
node tools/session-tools.mjs <会话 id 前缀> keep_thinking
列表里有 keep_thinking,说明注册成功且作用域正确。
keep_thinking 的可见性由作用域决定,不由插件决定:工具行挂在 preset-chat 里,所以只有「更好的对话模式」的 agent 看得见它。插件里没有任何可见性逻辑,也不需要。think_better)引入,所以仓库放哪、装到谁的机器上都一样。历史上用绝对 file:// URL 的那几版换机器就装不上,详见为什么不能发布绝对 file URL。persona.prefix 只遮蔽部署级人设、没设 complete: true,harness 的工具引导段落(read/grep/glob/web_search/present 的用法)会照常注入;Agent Teams 那一大段 POLICY 文字也会进来。实测一轮下发 25 个工具:本 preset 挂的 11 个,加上 agent-team 的 9 个(spawn_teammate/send_message/list_agents/wait_agent/interrupt_agent/team_task_*)、schedule_* 4 个、load_workspace_dependencies 1 个。tool-agent-team 和 schedule 都在 agent/created 时把工具注册进 agent 自己的作用域(tool-agent-team/src/index.ts:const scoped = agent.ctx;schedule/src/index.ts:registerScheduleTools(ctx, agent.ctx, agent)),而 tools.restrict() 只能遮蔽全局工具——拿作用域内的名字去 restrict 会直接抛 unknown global tool。所以「刻意没有挂的」那张表管不到它们,"把 deny 列表做成配置项"也解决不了;要让「更好的对话模式」真的干净,只能在整个 profile 层面不挂 dsh-experimental-agent-team-profile / dsh-experimental-schedule-bundle(所有模式一起去掉)。这 14 个里只有 load_workspace_dependencies 是全局注册。(本插件现在不做任何 restrict,所以这条只是说明"为什么连做成配置项也收不掉"。)tool-fs 带写权限。想要只读,把会话权限切到 profile 里的 read-only 预设。keep_thinking 在对话记录里渲染为通用工具卡片。想做成定制的"思考"卡片,需要另写一个 Client 插件在 tool.call.toolview 槽位注册组件。@deepseek-ai/dsh-*)。DSH 升级后若某个 id 改名,需要同步更新 cordis.patch.yml。MIT
CLASSIFICATION EVIDENCE
系统优先读取 GitHub Topics,再与站内分类词典和词根规则比对。当前命中: 无有效分类标签。