DeepSeek Harness · 工具系統

DSH 獨有的工具面:terminal / lsp / jobs

別家沒有的幾個工具各解決什麼問題。核心源碼:packages/terminal/packages/lsp/packages/jobs/,工具描述全文見 docs/tool-catalog.zh.md

課程目標bash 加 read/write 已經做得成嘢,點解 DSH 仲要再配三套一等公民能力?讀完你能説清三件事:terminal 怎麼讓狀態活過一次調用,lsp 為什麼只給四類語義查詢,jobs 怎麼把互不相干的後台任務收進一張列表。還有三者共同的設計紀律:閉合詞彙、結構化降級、按所有者授權。
互動演示 · 工具面對照間

同一個任務:起一個 Python REPL,分三步調試一段程式碼。情景 A 左邊只給 bash 單工具,右邊給 terminal 六件套,直接看輪數和重複勞動的差距。情景 B 演示 jobs 面板:三種完全不同的後台任務,怎麼被同一張列表管起來。

左 · 只有一次性 bash每次調用狀態清零
工具調用 0重複執行 0 行舊程式碼
右 · terminal 六件套持久 PTY,狀態保持
工具調用 0重複執行 0 行舊程式碼
job_list 視圖(當前 agent 名下的後台任務)
點「播放」開始對照,或滾動到此處自動播放。
演示為教學化模擬。左右輸出與工具名對應 docs/tool-catalog.zh.mdbashterminal_* 的真實 schema;情景 B 的任務形態對應 @deepseek-ai/dsh-tool-jobs 的三個工具。SEND_ACTIVE 報錯行為對應 packages/terminal/terminal/src/index.ts 第 246 行。
三套能力各治一種病
terminal · 治狀態失憶
  • 六個工具:terminal_open / send / read / signal / close / list,背後是持久 PTY 會話。
  • REPL、gdb、ssh 這類有狀態程式,跨調用活着。
  • 每個會話歸屬確切的 Agent 實例,別的 agent 拿到 id 也操作不了。
lsp · 治文本搜索沒有語義
  • 恰好四類查詢:跳定義、找引用、跳實現、hover,閉合聯合,加一類就是編譯期大改。
  • 故意不開通用 JSON-RPC 逃生口,模型玩不出協議花活。
  • 沒有 provider 時 schema 不變,返回結構化 LSP_UNAVAILABLE
jobs · 治後台任務沒人收屍
  • 後台 bash、後台 PTY 發送、後台 subagent,全部註冊成 <kind>-N 任務。
  • 統一 job_list / job_output / job_kill 三個工具管一切。
  • 授權看所有者會話,id 可預測也無所謂;每個 owner 預設最多 10 個併發。
terminal:讓狀態活過一次調用

先看最容易被低估的 terminal。一次性 bash 的問題演示裏已經看到了:REPL 的變數活不過一次調用,模型只能把舊程式碼全部重發一遍,輪數和 token 雙倍燒。DSH 的解法是把 PTY 會話做成一等資源:terminal_open 建會話拿 id,之後的 send、read、signal、close 全按 id 操作,會話在後端和工具插件熱重載期間照樣活着(docs/subsystems/terminal.zh.md「歸屬與持久性」一節)。

工具描述本身就是提示詞工程的範本,1878 行的工具目錄裏 terminal_send 這條把等待語義一句話説清:

docs/tool-catalog.zh.md · 第 878 行(terminal_send 工具描述)
「向持久終端發送文本。預設會提交 Enter,並等待提示符、stdin 等待、輸出靜默、超時或會話退出。後台模式會返回供 job_output/job_kill 使用的 job id。」

然後是併發紀律:一個 PTY 會話同一時刻只接受一個活動 send。兩個調用搶同一個終端,第二個直接吃結構化報錯,誰都別想把字符插進別人的命令中間。

startSend() 的把關順序,三道檢查一道都省不掉。進門第一件事是 expectOwned(owner, id):授權比較的是擁有會話的確切 Agent 實例,id 不是秘密,邊界靠歸屬,別的 agent 就算猜中了 id 也過不了這一關。第二道看會話是否正在關閉,關到一半的會話拒收任何新輸入。第三道看 record.active:上一條 send 還沒結算,新來的這條直接拋帶 SEND_ACTIVE 錯誤碼的 TerminalError,模型看到的是一條結構化失敗,等上一條結算完再來就行。檢查全過之後,本次操作被登記為這個會話唯一的活動 send,等它的 done 承諾結算(成功失敗都算),登記才清空,下一條 send 才有資格進門。歸屬即邊界這條紀律,待會在 jobs 裏還會再見一次。

出處:packages/terminal/terminal/src/index.ts 第 243 至 254 行(startSend()),核對日期 2026-08-13。

lsp:四個操作,一個不多

lsp 工具把語言伺服器的能力收窄到四類語義查詢。點解唔直接將 LSP 協議成個透出去?因為那樣模型面對的是一個無底洞 schema,provider 換一家行為就漂一次。DSH 的做法是閉合:seam、提供方、工具三層共享同一個四操作聯合,加第五個操作會讓編譯失敗,直到三層都改完。選路也簡單粗暴,按文件擴展名找 provider,一個擴展名只屬於一家:

packages/lsp/lsp/src/index.ts第 143 至 149 行節選
  async query(request: LspQueryRequest, signal?: AbortSignal): Promise<LspQueryResult> {
    const route = this.routes.get(finalExtension(request.filePath))
    if (route === undefined) {
      throw new LspError(`no LSP provider handles "${request.filePath}"`, 'LSP_UNAVAILABLE')
    }
    return route.provider.query({ ...request, languageId: route.languageId }, signal)
  }
源碼快照説明:依據本地倉庫 deepseek-harness-master,核對文件 packages/lsp/lsp/src/index.ts,核對日期 2026-08-13。程式碼塊保留源碼原文。

重點在沒有 provider 的時候:lsp 工具照常掛在目錄裏,schema 一個字不變,調用返回帶 LSP_UNAVAILABLE 錯誤碼的結構化失敗。模型學到的是這個項目沒配語言伺服器,不用去猜工具怎麼消失了。降級要結構化、詞彙表要穩定,這個思路和 bash 報 [sandbox: …] 一脈相承。

jobs:三種後台任務,一張列表

最後是 jobs。後台 bash 命令、terminal_send 的後台模式、後台 subagent,三種生產方形態完全不同,DSH 讓它們全部註冊進同一個 ctx.jobs 註冊表,領到 bash-1subagent-2 這種按種類加序號發的 id,然後模型用同一組 job_list / job_output / job_kill 通吃。生產方擁有執行資源,註冊表擁有身份、存取權限和生命週期狀態(docs/subsystems/jobs.zh.md)。

授權不靠 id 保密

id 按 <kind>-N 順序發放,完全可預測。防綫是所有者授權:讀、殺、等,全都校驗調用方的會話與任務 owner 是否一致,別人的任務連 label 都看不到。

done 等的是資源釋放

生產方的 done 承諾在資源釋放後才 resolve,工作幹完了還不算數。owner 被銷燬時註冊表取消並等待任務,不留孤兒進程。

完成通知不重複打擾

某個接口已經交付過終止狀態時,reported 標記會抑制重複的完成通知,避免一次任務結束讓模型收兩遍消息、開兩個 turn 白燒請求。

橫向對比 · 產品可以不做,運行時值得做
Grok Build:ptyctl 獨立 crate

Grok Build 在 PTY 這件事上和 DSH 想到了一塊:倉庫裏有獨立的 ptyctl crate(crates/codegen/ptyctl/,內含 pty、session、server、term、wait 等模組,另配 ptyctl-cli),把終端控制做成了可複用的基礎設施。兩家都認為有狀態終端值得一等公民待遇,差異在組合方式:Grok 是編譯期連結的 Rust crate,DSH 是運行時掛載的插件加六個面向模型的工具。

Claude Code:沒有一等 PTY 和 LSP

Claude Code 的還原源碼工具清單裏(書稿第 2 章)沒有一等的 PTY 會話工具,也沒有 LSP 工具,這一條基於已公開的還原源碼證據。後台能力它有:bash 帶 run_in_background,agent 任務還能按耗時自動轉後台(tengu_auto_background_agents 開關,書稿第 2 章第 448 至 458 行引文),但那是圍繞 bash 和 subagent 各自做的,沒有跨種類的統一任務註冊表。這個差距不是誰偷懶:CC 係產品,互動式除錯有 IDE 兜底,語義導航有編輯器兜底;DSH 是運行時,要在無頭環境裏獨自把這些能力供給模型。產品可以不做的事,運行時值得做。順帶點破一句:這三套能力都不在 agent loop 主幹上,全是可選插件,minimal 預設一個都不掛,照樣是完整的 coding agent。

課堂練習
01

推演兩個邊界場景

場景一:agent 在同一個 terminal 會話上先發了一條 run_in_background: true 的 send,緊接着又發一條前台 send。第二條嘅下場係咩,錯誤碼係邊個?job 面板呢陣睇到咩?場景二:項目沒配任何語言伺服器,模型調了一次 lsp 工具查 a.py 的定義。寫出模型收到的結果形態,並説明為什麼這比直接把 lsp 工具從目錄裏摘掉對模型更友好。

Takeaway:terminal 讓狀態活過調用,lsp 給四類語義查詢且拒絕協議逃生口,jobs 把三種後台任務收進一張按 owner 授權的列表。三者共同的紀律:閉合詞彙、結構化降級、歸屬即邊界;而且全是可選插件,主幹不揹包袱。