# Remote-Task dsh 插件设计方案 > 面向 [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)(dsh,基于 [Cordis](https://cordis.js.org))的 **HTTP 会话插件**:通过 HTTP 创建会话并**立即返回 `sessionId`**,把 prompt 送入大模型对话;模型可通过参数指定,Skills 通过 **id 集合**指定;通过 `sessionId` 查询会话状态,并支持会话的**暂停、停止、恢复**。 > > 参考实现: > - dsh 端能力:`E:\htmlProjects\deepseek-harness-master`(重点参考 `packages/acp`、`packages/webhook`、`packages/core/agent*`、`packages/skill`) > - 插件开发流程与代码规范:`E:\htmlProjects\MCP-Manger` --- ## 一、需求与决策 ### 1.1 核心需求 1. 通过 HTTP 创建会话,携带 prompt,向大模型发起对话,并返回 `sessionId`。 2. 模型可通过参数指定(provider / model / reasoningEffort / maxTokens)。 3. Skills 可通过 **id 集合** 指定;id 由后续创建的 **skills 插件** 提供,本插件按 id 解析并注入。 4. 通过 `sessionId` 查询当前会话状态。 5. 会话可**恢复**(跨进程重启)。 6. 支持会话**暂停**与**停止**。 ### 1.2 已确认的传输 / 交互决策 | 项 | 决策 | |---|---| | 传输语义 | **异步**:`POST /sessions` 校验并投递首个 prompt 后**立即返回 `sessionId`**,客户端通过状态接口轮询进度 | | Skill 注入 | 传入 **id 集合**;id 从 skills 插件(后续创建)中获取,经 `ctx.skills.get(id)` 解析后注入会话 | | 流式 | **暂不提供** SSE / 增量流;仅通过状态快照与转录接口读取结果 | | 会话恢复 | **需要**:组合挂载 `dsh-session-persistence`,提供 restore 接口 | | 暂停 / 停止 | **需要**:暂停 = 中止当前轮次并保留待处理输入(可续跑);停止 = 取消并关闭会话(保留持久化日志以便恢复) | --- ## 二、需求到 dsh 能力的映射 在 `deepseek-harness-master` 中,每项需求都有对应的官方扩展点,这是本设计的基石: | 需求 | dsh 能力 | 来源包 / API | |---|---|---| | HTTP 入口 | `ctx.webServer.register({ kind, path, handler })` | `@deepseek-ai/dsh-host-webserver`(`webhook-github` 的挂载方式)| | 创建会话 + 驱动对话 | `ctx.agents.create({ sessionId, meta:{cwd}, agentOptions, setup })` → `AgentHandle` | `@deepseek-ai/dsh-agent` + `dsh-agent-loop` | | 送入 prompt | `agent.followup(createUserMessage({ content, source }))` | `@deepseek-ai/dsh-llm` | | 指定模型 | `agentOptions: { provider, model, reasoningEffort, maxTokens }`;动态切换用 `installModelSelection` | `@deepseek-ai/dsh-agent` / `dsh-llm` | | 指定 Skill(by id)| `ctx.skills.get(id, { scope, cwd })` + scope 内 `register` 或 `agent.inject()` | `@deepseek-ai/dsh-skill` / `dsh-tool-skill` | | 查询状态 | `agent.status`(`'idle'|'running'`)+ `session/event`(`turn/start`、`turn/end`、`assistant/message`)+ `agent/status`、`agent/error` | `@deepseek-ai/dsh-agent` / `dsh-session` | | 暂停 | `agent.cancel(cause, { keepInbox: true })` —— 仅中止当前轮次,保留 pending inbox | `@deepseek-ai/dsh-agent` | | 停止 | `agent.cancel(...)` → `agent.whenIdle()` → `ctx.sessions.flush()` → `handle.dispose()` | `@deepseek-ai/dsh-agent` / `dsh-session` | | 恢复 | `ctx.agents.resume({ resumeSessionId, agentOptions, setup })` + `ctx.sessionPersistence.stat/open` | `@deepseek-ai/dsh-session-persistence` | | 生命周期 / 清理 | `ctx.effect()`、`ctx.on()`、幂等 teardown | Cordis + agent | **关键结论**:官方 `@deepseek-ai/dsh-acp` 已实现"创建/恢复会话、选模型、发 prompt、收更新、取消、关闭"的完整会话内核,只是走 **JSON-RPC over stdio**。本插件本质是把同一套内核换成 **HTTP 传输**。因此: - `packages/acp/acp/src/session.ts`(`AcpSession`)是**会话内核**的直接模板(准入槽、精确所有权、幂等 teardown、结算竞态)。 - `packages/webhook/webhook-github`(`handler.ts` / `body.ts` / `index.ts`)是 **HTTP 层**的直接模板(prefix/exact 路由注册、有界 body 读取、错误码约定、`ctx.effect` 注册)。 --- ## 三、整体架构 插件为 **Host 单面插件**(无 client UI),挂载在 dsh 的 **web profile**(该 profile 才拥有 `ctx.webServer`)。 ```mermaid graph TD Client["HTTP 客户端 (curl / 前端 / 脚本)"] -->|POST /remote-task/sessions| WS["ctx.webServer (dsh-host-webserver)"] WS --> Router["RemoteTaskRouter (prefix 路由解析)"] Router --> Registry["SessionRegistry (Map)"] Registry --> RTS["RemoteTaskSession (每会话模块)"] RTS -->|create / resume| Agents["ctx.agents (dsh-agent + dsh-agent-loop)"] RTS -->|followup(prompt)| Agents RTS -->|cancel / whenIdle / dispose| Agents RTS -->|resolve skill by id| Skills["ctx.skills (dsh-skill + skills 插件)"] RTS -->|model route| LLM["ctx.llm (dsh-llm)"] RTS -->|persist / stat / open| Persist["ctx.sessionPersistence"] Agents -->|session/event, agent/status, agent/error, agent/inbox/claimed| RTS RTS -->|status snapshot| Router Registry -.->|effect teardown| WS ``` 设计要点: - **一个会话一个 `RemoteTaskSession` 实例**,独占其 `AgentHandle`、prompt 准入槽、状态投影、幂等 teardown —— 对齐 `AcpSession` 的所有权模型。 - **HTTP 层与会话内核解耦**:Router 只做协议解析 / 校验 / 错误码;会话内核不感知 HTTP。 - **状态是投影而非轮询**:订阅 `session/event` 与 `agent/*` 事件,实时维护每会话状态快照,`GET status` 直接读快照。 - **注册即副作用**:路由注册、事件订阅、会话创建 / 恢复全部走 `ctx.effect()` / `ctx.on()`,符合 dsh "Registrations are effects" 规范。 - **异步优先**:创建与追加 prompt 都在准入并入队后立即返回,结算状态通过 status 接口观测。 --- ## 四、HTTP API 设计 采用一个 **prefix 路由** `/remote-task`(webServer 不支持路径参数,需在 handler 内解析子路径),REST 风格: | 方法 | 路径 | 作用 | 请求体 | 响应 | |---|---|---|---|---| | `POST` | `/remote-task/sessions` | 创建会话并投递首个 prompt(异步)| `CreateSessionRequest` | `201 { sessionId }` | | `GET` | `/remote-task/sessions/:id/status` | 查询会话状态 | — | `200 SessionStatus` | | `POST` | `/remote-task/sessions/:id/prompt` | 追加对话轮次(异步)| `PromptRequest` | `202 { accepted: true }` | | `POST` | `/remote-task/sessions/:id/pause` | **暂停**:中止当前轮次,保留待处理输入 | — | `200 SessionStatus` | | `POST` | `/remote-task/sessions/:id/resume-turn` | **续跑**:暂停后重新唤醒驱动处理保留的 inbox | — | `200 SessionStatus` | | `POST` | `/remote-task/sessions/:id/stop` | **停止**:取消当前活动并关闭会话(保留持久化日志)| — | `200 { stopped: true }` | | `POST` | `/remote-task/sessions/:id/restore` | **恢复**:从持久化日志重新激活一个已停止的会话 | `RestoreRequest?` | `200 SessionStatus` | | `GET` | `/remote-task/sessions/:id/messages` | 拉取转录(可选)| — | `200 { messages }` | | `GET` | `/remote-task/sessions` | 列出活跃会话(可选)| — | `200 { sessions }` | | `GET` | `/remote-task/workspaces` | 列出工作区(含记忆绑定用的 id / path)| — | `200 { workspaces }` | | `POST` | `/remote-task/workspaces` | 创建/复用工作区,可带 `goal`(写入 sidecar,保留既有记忆)| `CreateWorkspaceRequest` | `201 { workspace }` | | `DELETE` | `/remote-task/workspaces/:id` | 删除工作区注册并移除其 sidecar 记忆文件 | — | `200 { deleted: true }` | | `GET` | `/remote-task/health` | 健康检查 | — | `200 { ok: true }` | > **暂停 vs 停止 vs 恢复 语义** > - **暂停(pause)**:`agent.cancel(cause, { keepInbox: true })` —— 中止在途轮次,**保留** 排队与 steering 输入;会话仍存活、仍在内存中,可用 `resume-turn` 续跑。 > - **停止(stop)**:取消当前活动 → `whenIdle` → `flush` 持久化 → `dispose` Agent;会话从内存移除,但**持久化日志保留**,可后续 `restore`。 > - **恢复(restore)**:`ctx.agents.resume({ resumeSessionId })` 从持久化日志重建 Agent,恢复后可继续投递 prompt。 ### 4.1 请求 / 响应类型 ```typescript /** 创建会话请求体。 */ interface CreateSessionRequest { /** 首个用户 prompt(纯文本;后续可扩展多模态)。 */ prompt: string /** 模型路由;缺省回落到插件 Config 的部署默认值。 */ model?: { provider: string; model: string; reasoningEffort?: string; maxTokens?: number } /** 要注入的 skill id 集合(由 skills 插件提供,本插件按 id 解析)。 */ skills?: string[] /** * 工作区 id(来自 `GET /workspaces`):其规范目录作为 SessionHeader.cwd,并 * 把会话绑定到该工作区,使其能注入/总结工作区记忆。缺省会话运行于 * Config.defaultCwd,并尝试用该 cwd 反查已注册工作区自动绑定。 */ workspace?: string } /** 追加对话请求体。 */ interface PromptRequest { prompt: string /** 本轮额外注入的 skill id 集合(可选)。 */ skills?: string[] } /** 恢复会话请求体(可选覆盖模型 / skills / 工作区)。 */ interface RestoreRequest { model?: { provider: string; model: string; reasoningEffort?: string; maxTokens?: number } skills?: string[] /** 可选:显式重绑的工作区 id;缺省用会话规范 cwd 反查,使恢复的会话仍能总结记忆。 */ workspace?: string } /** 会话状态快照。 */ interface SessionStatus { sessionId: string /** 生命周期相位。 */ phase: 'creating' | 'idle' | 'running' | 'paused' | 'stopping' | 'stopped' | 'failed' /** 当前 / 最近轮次号。 */ currentTurn?: number /** 最近一轮结束原因(映射自 turn/end 的 reason)。 */ lastStopReason?: 'end_turn' | 'max_tokens' | 'cancelled' | 'error' | string /** 已生效的模型路由。 */ model?: { provider: string; model: string } /** 已注入的 skill id 集合。 */ skills?: string[] /** 最近一条 assistant 文本(预览)。 */ lastReply?: string /** 待处理输入计数(暂停后用于确认可续跑)。 */ pending?: number /** 失败诊断(phase=failed 时)。 */ error?: string createdAt: number updatedAt: number } ``` ### 4.2 HTTP 错误码约定(对齐 `webhook-github`) `400` 参数 / body 非法 · `401` 鉴权失败 · `404` 会话不存在 · `405` 方法不允许 · `409` 已有 prompt 在途 / 状态冲突 · `413` body 超限 · `415` 非 JSON · `503` 依赖服务不可用。 body 读取使用**有界 UTF-8**(复用 `webhook-github/src/body.ts` 的 `readBoundedUtf8Body` 模式);日志中永不输出鉴权凭据,prompt 仅在必要时脱敏记录。 --- ## 五、核心模块设计(含代码骨架) ### 5.1 工程结构 ``` Remote-Task/ ├── package.json ├── tsconfig.json # host 面:emit lib + lib/types ├── tsdown.config.ts # 可选:打包 runtime ├── cordis.patch.yml # 把插件插入 web profile 的层栈 ├── src/ │ ├── index.ts # 插件入口:name / inject / Config / apply │ ├── config.ts # Config schema + 校验 │ ├── types.ts # 对外 wire 类型 + Cordis 事件声明合并 │ ├── http/ │ │ ├── router.ts # prefix 路由解析 → 分发到方法处理器 │ │ ├── body.ts # 有界 body 读取 + HttpError(移植自 webhook-github) │ │ └── respond.ts # 统一 JSON 响应 │ ├── registry.ts # SessionRegistry:Map + 事件路由 + 生命周期 │ ├── session.ts # RemoteTaskSession:Agent 所有权 + prompt 准入 + 暂停/停止/恢复 + 状态投影 │ ├── model.ts # 模型路由解析 / 校验(基于 ctx.llm) │ └── skills.ts # skill by id 解析与注入 └── tests/ ``` ### 5.2 插件入口 `src/index.ts` ```typescript /** * Remote-Task:通过 HTTP 暴露 dsh 会话的创建、对话、状态查询、暂停/停止/恢复。 * 在 web profile 的 ctx.webServer 上注册一个 prefix 路由,内部维护每会话一个 * RemoteTaskSession(独占 AgentHandle 与状态投影)。 * * @module @remote-task/remote-task */ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-host-webserver' // 声明合并 ctx.webServer import type {} from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-session-persistence' import { Config, type RemoteTaskConfig } from './config.ts' import { SessionRegistry } from './registry.ts' import { createRouter } from './http/router.ts' export const name = 'remote-task' /** 路由、会话内核与状态投影所需的核心宿主服务。 */ export const inject = ['webServer', 'agents', 'llm', 'sessions', 'skills', 'sessionPersistence', 'workspaceRegistry'] export { Config } export type { RemoteTaskConfig } /** * 挂载 Remote-Task HTTP 面。 * @param ctx - 携带 webServer / agents / llm / sessions / skills / sessionPersistence 的宿主上下文。 * @param config - 部署配置:默认模型、路由前缀、鉴权、body 上限、默认 cwd。 */ export function apply(ctx: Context, config: RemoteTaskConfig): void { const registry = new SessionRegistry(ctx, config) const handler = createRouter(ctx, config, registry) // 注册即副作用:卸载时自动摘除路由并优雅停止所有会话。 ctx.effect(() => { const disposeRoute = ctx.webServer.register({ kind: 'prefix', path: config.routePrefix, // 例如 '/remote-task' handler, }) return async () => { disposeRoute() await registry.disposeAll() } }, 'remote-task.http') ctx.logger.info(`remote-task: listening under ${config.routePrefix}`) } ``` > 与 MCP-Manger 的差异:MCP-Manger 用 `TypertRemoteService` + `@Remote` 暴露给浏览器 client 面;本插件无 client UI,改用 `ctx.webServer` 直接暴露 HTTP,因此采用**函数式插件**(`apply` + `name` + `inject` + `Config`),与 `webhook-github` 形态一致。 ### 5.3 会话内核 `src/session.ts`(对齐 `AcpSession` 的所有权 / 准入 / 结算模型) ```typescript /** 一个 Remote-Task 会话的 Agent、prompt 准入、暂停/停止/恢复、状态投影与幂等 teardown。 */ import type { Context } from '@deepseek-ai/cordis' import { randomUUID } from 'node:crypto' import { brandString } from '@deepseek-ai/dsh-brand' import { createUserMessage, errorChain, type UserMessage } from '@deepseek-ai/dsh-llm' import type { Agent, AgentHandle, ModelSelection } from '@deepseek-ai/dsh-agent' import type { SessionId, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' import { resolveModelSelection } from './model.ts' import { injectSkills } from './skills.ts' import { HttpError } from './http/body.ts' import type { SessionStatus, CreateInput, PromptInput } from './types.ts' export class RemoteTaskSession { readonly id: SessionId private readonly ctx: Context private readonly agent: Agent private readonly disposeAgent: () => Promise private inflight: { turn?: number; endReason?: TurnEndReason; messageId?: string } | undefined private status: SessionStatus private closing?: Promise private constructor(ctx: Context, id: SessionId, handle: AgentHandle, model: ModelSelection, skills: string[]) { this.ctx = ctx this.id = id this.agent = handle.agent this.disposeAgent = () => handle.dispose() this.status = { sessionId: id, phase: 'idle', model: { provider: model.provider, model: model.model }, skills, createdAt: Date.now(), updatedAt: Date.now(), } } /** 组合一个全新 Agent(含模型路由与 skill 注入)后再发布。 */ static async create(ctx: Context, input: CreateInput): Promise { const sessionId = brandString(randomUUID()) const selection = await resolveModelSelection(ctx, input.model) // 校验 provider/model 可用 const skills = input.skills ?? [] const handle = await ctx.agents.create({ sessionId, meta: { cwd: input.cwd }, agentOptions: { provider: selection.provider, model: selection.model, ...(selection.reasoningEffort ? { reasoningEffort: selection.reasoningEffort } : {}), }, setup: async (agentCtx) => { // 发布前把 skill(by id)组合进该 agent 的 scope;未知 id 直接失败(fails loud)。 await injectSkills(ctx, agentCtx, skills, input.cwd) }, }) return new RemoteTaskSession(ctx, sessionId, handle, selection, skills) } /** 从持久化日志恢复一个已停止的会话。 */ static async restore(ctx: Context, id: SessionId, input: CreateInput): Promise { const persisted = (await ctx.sessionPersistence.stat(id, {}))?.header if (persisted === undefined) throw new HttpError(404, `session is not resumable: ${id}`) const selection = await resolveModelSelection(ctx, input.model) const skills = input.skills ?? [] const handle = await ctx.agents.resume({ resumeSessionId: id, agentOptions: { provider: selection.provider, model: selection.model }, setup: async (agentCtx) => { await injectSkills(ctx, agentCtx, skills, persisted.cwd) }, }) return new RemoteTaskSession(ctx, id, handle, selection, skills) } /** 投递一轮 prompt(异步):准入并入队后立即返回;结算通过状态投影观测。 */ prompt(input: PromptInput): void { if (this.status.phase === 'stopped' || this.status.phase === 'stopping') { throw new HttpError(409, 'session is stopped') } if (this.inflight !== undefined) throw new HttpError(409, 'a prompt is already in flight') const message: UserMessage = createUserMessage({ content: [{ type: 'text', text: input.prompt }], source: { kind: 'user' }, }) this.inflight = { messageId: message.id } this.setPhase('running') this.agent.followup(message) } /** 暂停:中止当前轮次,保留待处理 inbox(可续跑)。 */ pause(): void { this.agent.cancel({ kind: 'user' }, { keepInbox: true }) this.setPhase('paused') } /** 续跑:暂停后重新唤醒驱动处理保留的 inbox。 */ resumeTurn(): void { if (this.status.phase !== 'paused') throw new HttpError(409, 'session is not paused') this.setPhase('running') // 空内容的 followup 仅用于唤醒驱动去认领保留的 inbox。 this.agent.followup(createUserMessage({ content: [], source: { kind: 'plugin', plugin: 'remote-task' } })) } /** 停止:取消 → drain → flush → dispose(保留持久化日志)。幂等。 */ stop(detail: string): Promise { return this.closing ??= (async () => { this.setPhase('stopping') this.agent.cancel({ kind: 'user' }) await this.agent.whenIdle() try { await this.ctx.sessions.flush(this.agent.session) } catch (error) { this.ctx.logger.warn(`remote-task: flush failed: ${errorChain(error)}`) } await this.disposeAgent() this.setPhase('stopped') })() } /** 由 registry 转发的 durable 事件 → 更新状态投影并结算在途 prompt。 */ onSessionEvent(_session: unknown, event: SessionEvent): void { if (event.type === 'assistant/message') { this.status.lastReply = extractText(event) // 文本预览 } else if (event.type === 'turn/end') { if (this.inflight?.turn === event.data.turn) this.inflight.endReason = event.data.reason this.status.lastStopReason = mapStopReason(event.data.reason) if (this.status.phase === 'running') this.setPhase('idle') this.inflight = undefined } this.touch() } onInboxClaimed(message: UserMessage, turn: number): void { if (this.inflight && this.inflight.messageId === message.id) { this.inflight.turn = turn this.status.currentTurn = turn } } onAgentError(turn: number, error: unknown): void { this.status.error = errorChain(error) if (this.inflight?.turn === turn) this.setPhase('failed') this.touch() } snapshot(): SessionStatus { return { ...this.status, pending: this.agent.inbox.nextTurn.length + this.agent.inbox.nextStep.length } } owns(agent: Agent): boolean { return this.agent === agent } ownsSession(session: unknown): boolean { return this.agent.session === session } // setPhase / touch / extractText / mapStopReason 为实现细节,见 §5.7 } ``` > **实现说明**:完整的准入 / 结算 / 取消竞态处理(准入 AbortController、`settleAfterQuiescence`、`agent/error` 与 turn 关联、输出投递失败降级)建议直接照搬 `AcpSession` 的实现细节——它已处理好"取消赢得准入就不入队""turn/end 关联 stopReason""同 id 冒名拒绝"等边界。上面骨架为突出结构做了简化。 ### 5.4 会话注册表 `src/registry.ts` ```typescript /** 会话注册表:id → RemoteTaskSession,并把宿主事件按精确所有权路由到会话。 */ export class SessionRegistry { private readonly sessions = new Map() constructor(private readonly ctx: Context, private readonly config: RemoteTaskConfig) { // 事件订阅即副作用:把 durable / 运行时事件转发给精确拥有的会话。 ctx.on('session/event', (session, event) => { this.bySession(session)?.onSessionEvent(session, event) }) ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => this.byAgent(agent)?.onInboxClaimed(message, turn)) ctx.on('agent/error', ({ agent, turn, error }) => this.byAgent(agent)?.onAgentError(turn, error)) } async create(input: CreateInput): Promise { const session = await RemoteTaskSession.create(this.ctx, withDefaults(input, this.config)) this.sessions.set(session.id, session) return session.id } async restore(id: SessionId, input: CreateInput): Promise { const session = await RemoteTaskSession.restore(this.ctx, id, withDefaults(input, this.config)) this.sessions.set(session.id, session) return session.snapshot() } get(id: SessionId): RemoteTaskSession { const s = this.sessions.get(id) if (s === undefined) throw new HttpError(404, `unknown session: ${id}`) return s } async stop(id: SessionId): Promise { const s = this.get(id) await s.stop('stopped via HTTP') if (this.sessions.get(id) === s) this.sessions.delete(id) // 停止后移出内存,日志已持久化 } async disposeAll(): Promise { /* 并发 stop 所有会话,聚合失败 */ } private byAgent(agent: Agent) { /* ownedRecord:精确所有权,拒绝同 id 冒名 */ } private bySession(session: unknown) { /* 同上 */ } } ``` ### 5.5 模型指定 `src/model.ts` ```typescript /** 解析并校验一次模型路由;缺省回落到部署默认。 */ export async function resolveModelSelection(ctx: Context, req?: ModelRequest): Promise { const provider = req?.provider ?? ctx.remoteTask.config.defaultProvider const model = req?.model ?? ctx.remoteTask.config.defaultModel // resolveCallConfig 会校验 provider/model 是否注册、解析 reasoning 默认值。 const resolved = await ctx.llm.resolveCallConfig({ provider, model }, undefined) return { provider: resolved.provider, model: resolved.model, ...(resolved.reasoningEffort ? { reasoningEffort: resolved.reasoningEffort } : {}), } } ``` - **会话级固定模型**:创建时通过 `agentOptions` 传入即满足"模型可通过参数指定"。 - **可选(v2)每轮覆盖模型**:参照 `AcpModelControl` 用 `installModelSelection(agentCtx, ref)`,在 prompt 准入时 `snapshot()` 并 `pinTurn()`。v1 仅做会话级。 ### 5.6 Skills by id `src/skills.ts` Skill 的 "id" 即其 **kebab-case name**,由后续的 **skills 插件** 作为 provider 注册进 `ctx.skills`。本插件按 id 集合解析并注入: ```typescript /** 把请求的 skill id 集合组合进 agent scope,使其对该会话可见/生效。 */ export async function injectSkills( ctx: Context, agentCtx: Context, ids: string[], cwd?: string, ): Promise { for (const id of ids) { // 通过注册表按 id 解析完整定义(scope=agentCtx 命中该 agent 层级 + skills 插件 provider)。 const def = await ctx.skills.get(id, { scope: agentCtx, cwd }) if (def === undefined) { // Misconfiguration fails loud:请求了不存在的 skill id → 拒绝创建/恢复会话。 throw new HttpError(400, `unknown skill id: ${id}`) } // 方案 A(推荐):在该 agent scope 内注册 runtime skill, // 交由 dsh-tool-skill 的会话目录收录,模型按需用 `skill` 工具加载。 agentCtx.skills.register({ name: def.name, description: def.description, content: def.content }) } } ``` **注入方案对比**: | 方案 | 机制 | 适用 | |---|---|---| | **A. scope 内注册**(推荐)| `agentCtx.skills.register(...)`,由 `dsh-tool-skill` 渲染会话目录,模型自行 `skill({name})` 加载 | 需组合挂载 `dsh-tool-skill`;语义最正统,token 高效 | | **B. `/id` 手势** | 在 prompt 前拼接 `/skill-id`,由 tool-skill 的用户显式调用注入 `` | 简单;要求 skill `userInvocable` 且挂了 tool-skill | | **C. 直接 inject** | `ctx.skills.get(id)` 取 content,`renderSkillContent` 后 `agent.inject()` | 不依赖 tool-skill;强制加载,最可控 | > **默认采用 A**;若部署未挂载 `dsh-tool-skill`,回退 **C**。因为 id 由后续 skills 插件提供,本插件只依赖 `ctx.skills` 注册表接口,与具体 provider 解耦。 ### 5.7 路由 `src/http/router.ts` ```typescript /** 把 prefix 路由下的请求解析为 方法 + 子资源,分发到 registry / session。 */ export function createRouter(ctx: Context, config: RemoteTaskConfig, registry: SessionRegistry) { return async (req: IncomingMessage, res: ServerResponse): Promise => { try { authorize(req, config) // 可选 Bearer token(credentialRef) const url = new URL(req.url ?? '/', 'http://local') const sub = url.pathname.slice(config.routePrefix.length) // '/sessions/xxx/status' const segments = sub.split('/').filter(Boolean) // ['sessions','xxx','status'] await dispatch(ctx, registry, req, res, req.method ?? 'GET', segments) } catch (error) { respondError(res, error) // HttpError → 其状态码;其余 → 503,且不泄露内部细节 } } } ``` 分发要点: - `POST /sessions` → `registry.create(body)`,随后 `session.prompt({ prompt: body.prompt, skills })`,返回 `201 { sessionId }`。 - `GET /sessions/:id/status` → `registry.get(id).snapshot()`。 - `POST /sessions/:id/prompt` → `session.prompt(body)`,返回 `202`。 - `POST /sessions/:id/pause | resume-turn | stop | restore` → 对应内核方法。 状态投影辅助(`extractText` / `mapStopReason` / `setPhase` / `touch`)为会话内核私有实现:`extractText` 从 `assistant/message` 事件提取文本;`mapStopReason` 把 `TurnEndReason` 映射为对外稳定字符串(参照 `acp/src/codec.ts`);`setPhase`/`touch` 更新 `phase` 与 `updatedAt`。 --- ## 六、会话状态机 ```mermaid stateDiagram-v2 [*] --> creating: POST /sessions creating --> idle: agent 组合完成并发布 creating --> failed: 模型/skill 校验失败或组合回滚 idle --> running: followup(prompt) 被认领 (turn/start) running --> idle: turn/end (end_turn / max_tokens) running --> paused: pause() → cancel(keepInbox) paused --> running: resume-turn() 重新唤醒驱动 running --> failed: agent/error 且不可重试 idle --> stopping: stop() running --> stopping: stop() (先 cancel) paused --> stopping: stop() stopping --> stopped: whenIdle + flush + dispose stopped --> idle: restore() 从持久化日志重建 stopped --> [*] failed --> idle: 允许再次 prompt(非致命) ``` - `phase` 由 `agent.status`(idle/running)+ 插件自维护的 creating/paused/stopping/stopped/failed 合成。 - `lastStopReason` 来自 `turn/end` 事件的 `reason`(映射为对外稳定字符串)。 - `lastReply` 来自 `assistant/message` 事件的文本投影。 - 状态更新通过 `ctx.on('session/event' | 'agent/status' | 'agent/error' | 'agent/inbox/claimed')` 订阅,由 `SessionRegistry` 按 sessionId 路由到对应 `RemoteTaskSession`(照搬 ACP 的 `ownedRecord` 精确所有权判定,避免同 id 冒名)。 --- ## 七、工程与开发流程(对齐 MCP-Manger 规范) ### 7.1 `package.json`(关键字段) ```json { "name": "@remote-task/remote-task", "description": "HTTP session plugin for deepseek-harness: create sessions, drive LLM dialogue with a chosen model and skills, query status, and pause/stop/restore.", "version": "0.2.0", "type": "module", "keywords": ["deepseek", "harness", "dsh", "dsh-plugin", "remote-task", "http"], "engines": { "node": "^22.19.0 || >=24.0.0" }, "main": "lib/index.js", "types": "lib/types/index.d.ts", "exports": { ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./cordis.patch.yml": "./cordis.patch.yml", "./package.json": "./package.json" }, "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }, "files": ["lib", "src", "cordis.patch.yml"], "scripts": { "build": "node scripts/clean.mjs && tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", "clean": "node scripts/clean.mjs", "prepack": "npm run build && node scripts/preflight.mjs", "prepare": "npm run build" }, "peerDependencies": { "@deepseek-ai/cordis": "^4.0.1", "@deepseek-ai/schemastery": "^3.18.1", "@deepseek-ai/dsh-agent": "^0.1.5-rc.2", "@deepseek-ai/dsh-agent-loop": "^0.1.5-rc.2", "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2", "@deepseek-ai/dsh-llm": "^0.1.5-rc.2", "@deepseek-ai/dsh-session": "^0.1.5-rc.2", "@deepseek-ai/dsh-session-persistence": "^0.1.5-rc.2", "@deepseek-ai/dsh-skill": "^0.1.5-rc.2", "@deepseek-ai/dsh-brand": "^0.1.5-rc.2", "@deepseek-ai/dsh-credentials": "^0.1.5-rc.2", "@deepseek-ai/dsh-workspace": "^0.1.5-rc.2" } } ``` > `dsh-*` 全部作为 **peerDependencies**(宿主提供),与 MCP-Manger 一致;版本走 `0.1.5-rc.2` lockstep 线。 ### 7.2 `cordis.patch.yml` ```yaml # dsh bundle patch:把插件插入 web profile 的层栈(在 agents/llm/skills/webServer/sessionPersistence 之后)。 - insert: - id: remote-task name: '@remote-task/remote-task' config: routePrefix: '/remote-task' defaultProvider: 'deepseek' defaultModel: 'deepseek-chat' # 宿主无「会话结束」事件:开启后每轮回到 idle 即增量总结工作区记忆 # (per-session cursor 幂等,与后续 stop 不重复)。 autoDistillOnIdle: true ``` ### 7.3 `tsconfig.json` 直接复用 MCP-Manger 的 host 面配置(`strict`、`exactOptionalPropertyTypes`、`noUncheckedIndexedAccess`、`verbatimModuleSyntax`、`allowImportingTsExtensions`、`rewriteRelativeImportExtensions`、`declaration → lib/types`),排除 `tests`。 ### 7.4 组合依赖(web profile 需已挂载) - `@deepseek-ai/dsh-agent` + `@deepseek-ai/dsh-agent-loop`(会话与驱动) - `@deepseek-ai/dsh-host-webserver`(HTTP 承载,仅 web profile) - `@deepseek-ai/dsh-session-persistence`(**会话恢复必需**) - `@deepseek-ai/dsh-skill`(+ 后续 skills 插件作为 provider);采用方案 A 时还需 `@deepseek-ai/dsh-tool-skill` - `@deepseek-ai/dsh-llm`(模型路由)、`@deepseek-ai/dsh-credentials`(可选鉴权) - `@deepseek-ai/dsh-workspace`(`ctx.workspaceRegistry`:工作区列举/创建,与记忆 sidecar 绑定) ### 7.5 构建 / 打包 / 安装 / 运行 **① 构建与打包**(工作区根目录;PowerShell 用 `npm.cmd` / `pnpm.cmd`): ```sh pnpm install npm run build # = node scripts/clean.mjs && tsc -p tsconfig.json(出 lib + lib/types) npm pack # prepack 自动 build + preflight;产出 remote-task-remote-task-.tgz ``` **② 安装进 web profile 并生效**(关键:dsh 每个 profile 是独立解析根): `dsh web` 实际从 `~/.dsh/profiles/web/`(自带独立 `package.json` + `node_modules`)加载插件,与宿主 monorepo 的 `node_modules/.pnpm`、`packages/bundle/web-app`、`apps/desktop/.desktop-build` 是**完全分离的多套解析根**。让新版本生效必须在 profile 目录操作: 1. 编辑 `~/.dsh/profiles/web/package.json`,把 `@remote-task/remote-task` 的 `file:` 指针指向新 tgz,并确保 `dsh.profile.bundles` 含 `@remote-task/remote-task`: `"@remote-task/remote-task": "file:<绝对路径>/remote-task-remote-task-.tgz"` 2. 在 profile 目录重装:`pnpm -C ~/.dsh/profiles/web install` 3. 重启 `pnpm dsh web` —— `patchReload: live` 只热更配置,**不热更 node_modules**,模块变更必须重启进程。 > **头号陷阱**:只 `npm pack`、或只改宿主仓库的 junction / `.pnpm` 副本,都**不会**让 web 端生效——运行时仍跑 profile `node_modules` 里的旧副本;运行中的 live host 还会自行改写 profile 的 `package.json`(“package transactions own this file”)。 **③ 生效判据**(反证运行的是哪份代码):`~/.dsh/profiles/web/node_modules/@remote-task/remote-task/package.json` 的 `version` = 新版本;新建带 `goal` 的工作区后 `~/.dsh/remote-task/workspaces/.json` 为 `{ goal, entries:[], cursors:{}, updatedAt }`(旧版为 `{ goal, memory:"", updatedAt }`);会话 `stop`/idle 后日志出现 `remote-task: distill start … / distill saved …`。 **④ 运行与调用**(web profile 才有 `ctx.webServer`,默认 `127.0.0.1:3080`): ```sh # 1) 创建会话(异步,立即返回 sessionId) curl -X POST http://127.0.0.1:3080/remote-task/sessions \ -H 'content-type: application/json' \ -d '{"prompt":"总结这个仓库","model":{"provider":"deepseek","model":"deepseek-chat"},"skills":["commit"]}' # → 201 {"sessionId":"..."} # 2) 查询状态 curl http://127.0.0.1:3080/remote-task/sessions//status # 3) 暂停 / 续跑 / 停止 / 恢复 curl -X POST http://127.0.0.1:3080/remote-task/sessions//pause curl -X POST http://127.0.0.1:3080/remote-task/sessions//resume-turn curl -X POST http://127.0.0.1:3080/remote-task/sessions//stop curl -X POST http://127.0.0.1:3080/remote-task/sessions//restore ``` --- ## 八、配置与安全(遵循 dsh "No hardcoded tunables / Misconfiguration fails loud") 插件 `Config`(schemastery)字段: | 字段 | 说明 | |---|---| | `routePrefix` | HTTP 路由前缀(默认 `/remote-task`),非根、无尾斜杠,加载时校验 | | `defaultProvider` / `defaultModel` | 请求未指定模型时的部署默认路由 | | `authTokenEnv` | `credential-ref`,指向 Bearer token 凭据;`ctx.credentials.resolve()` 每请求解析,支持轮换 | | `maxBodyBytes` | 正整数 body 上限 | | `defaultCwd` | 未传 workspace 时的工作目录(绝对路径)| | `autoDistillOnIdle` | 布尔,默认 `false`。开启后每轮 `turn/end` 回到 idle 即增量总结记忆(宿主无「会话结束」事件时的兜底);靠 per-session cursor 幂等,不会与后续 `stop()` 重复 | | `memoryInjectionBudget` | 非负整数,默认 `2000`。每轮注入的工作区记忆字符预算,配合按优先级/新近度的检索式选择,token 有界 | 安全:webServer **无内置 TLS / 鉴权**,默认 `host: 127.0.0.1`(loopback),生产置于 TLS 反向代理之后(与 `webhook-github` 的部署建议一致);插件自身通过 `authTokenEnv` 强制 Bearer 鉴权。 --- ## 九、测试策略(对齐 dsh testing policy) - **单元**:Router 子路径解析、body 有界读取、错误码映射、`resolveModelSelection` 校验、`injectSkills` 未知 id 失败、状态投影(喂 `session/event` fixture 断言 `SessionStatus`)、`mapStopReason`。 - **集成**:用内存 fake `ctx.agents` / `ctx.llm` / `ctx.skills` / `ctx.sessionPersistence`,覆盖 create → prompt → status → **pause → resume-turn → stop → restore** 全生命周期与并发 / 取消竞态。 - **契约**:`409` 在途冲突、`404` 未知会话、`409` 非暂停态续跑、停止后 restore、teardown 幂等。 - 复用 `AcpSession` 已有的竞态测试用例结构,替换传输层断言。 --- ## 十、分阶段实施计划 | 阶段 | 交付 | |---|---| | **P0 脚手架** | package.json / tsconfig / tsdown / cordis.patch.yml / 空 `apply` + 健康路由 `GET /remote-task/health` | | **P1 创建 + 对话(异步)** | `SessionRegistry` + `RemoteTaskSession.create/prompt`,`POST /sessions` 立即返回 sessionId,模型 by 参数 | | **P2 状态查询** | 事件订阅 + 状态投影,`GET /sessions/:id/status` | | **P3 Skills by id** | `injectSkills`(方案 A,回退 C),创建 / prompt 接受 `skills` id 集合 | | **P4 暂停 / 停止** | `pause`(keepInbox)/ `resume-turn` / `stop`,相位与 pending 投影 | | **P5 会话恢复** | 挂载 `dsh-session-persistence`,`POST /sessions/:id/restore`(resume + stat 校验)| | **P6 可选增强** | messages 转录、list 会话、每轮模型覆盖(`installModelSelection`)、SSE 流式(当前决策暂不做)| --- ## 十一、与参考项目的对应关系速查 | 本插件模块 | 参考来源 | |---|---| | `session.ts`(所有权 / 准入 / 结算 / teardown)| `deepseek-harness-master/packages/acp/acp/src/session.ts`(`AcpSession`)| | `model.ts`(模型路由解析)| `packages/acp/acp/src/model-control.ts`(`AcpModelControl`)| | `http/router.ts`、`http/body.ts`(路由注册 / 有界 body / 错误码)| `packages/webhook/webhook-github/src/{index,handler,body}.ts` | | `skills.ts`(skill by id)| `packages/skill/skill`(`ctx.skills`)+ `packages/skill/tool-skill`(会话目录)| | 插件封装 / package.json / tsconfig / tsdown / cordis.patch.yml | `E:\htmlProjects\MCP-Manger`(`src/index.ts`、根配置文件)| | 状态投影事件词汇 | `packages/core/agent`(`agent/status`、`agent/error`、`agent/inbox/claimed`)+ `packages/core/session`(`session/event`、`turn/end`)| | 工作区记忆(结构化条目 / 增量总结 / 原生注入)| `packages/context/agent-instructions`(`agent/pre-step` + `agent.inject()` 注入范式)、`packages/compaction`(会话内摘要范式)| --- ## 十二、工作区记忆架构(结构化 · 增量 · 原生注入) 记忆的目标是:**一次会话产出的关键信息,能自动影响同一工作区后续会话的任务**。为此插件维护一份 **工作区级(跨会话)** 的记忆,落在宿主 Workspace 实体之外的 sidecar 文件(Workspace 实体不可变):`~/.dsh/remote-task/workspaces/{workspaceId}.json`。 ### 12.1 数据模型:结构化条目而非单块文本 记忆由一组带稳定身份的原子条目构成,而非一个不断追加、再整体重写的字符串: ```typescript type MemoryKind = 'decision' | 'fact' | 'todo' | 'insight' interface MemoryEntry { id: string // sha1(sessionId, kind, content) 前 16 位:内容寻址,天然去重 kind: MemoryKind // 决策 / 事实 / 待办 / 洞见,驱动注入优先级 content: string // 独立、可复用、去会话指代的简洁陈述 tags: string[] // 关键词,供相关性排序 sessionId: string // 溯源:产出该条目的会话 createdAt: string // ISO-8601,首次提炼时刻 } interface WorkspaceSidecar { goal: string // 工作区任务架构目标(长期常驻) entries: MemoryEntry[] // 去重后的记忆,按时间升序 cursors: Record // 每会话的总结 checkpoint(已提炼的转录行数) updatedAt: string } ``` 关键性质: - **内容寻址 id**:同一会话重复提炼同样内容 → 同 id → 合并时去重,不会复利式堆积。 - **append-only**:旧条目永不被重写;不存在“把整块记忆再喂给 LLM 重总结”的有损步骤。 - **有界**:条目数超过 `MAX_ENTRIES=400` 时丢弃最旧者;注入侧再按预算裁剪。 - **向后兼容**:`loadWorkspaceSidecar` 会把旧的 `{ memory: string }` 迁移为单条 `insight`,历史记忆不丢。 ### 12.2 增量总结(per-session cursor) `distillToWorkspaceMemory` 只处理“上次 checkpoint 之后的新转录”: 1. 读转录(优先持久日志 `readTranscript`,失败/空回退内存 `transcriptLines`),得到 `{ lines, cursor }`,`cursor = lines.length`。 2. 从 sidecar 取本会话 `cursors[sessionId] ?? 0` 作为 `start`;`cursor <= start` 则跳过(幂等)。 3. 只对 `lines.slice(start)` 调 LLM,要求其**输出结构化 JSON 数组**(`kind/content/tags`);解析防御式,畸形响应回退为单条 `insight`,绝不丢总结。 4. `mergeEntries` 按 id 去重合并,`withWorkspaceSidecarLock` 内读改写并推进 `cursors[sessionId] = cursor`,原子落盘。 慢速 LLM 调用在**锁外**并行(同工作区多会话各自总结互不阻塞),只有 `load → merge → save` 在锁内串行,消除 lost update。cursor 是成本优化,**内容寻址去重才是幂等的最终保障**——即便 cursor 因回退源不一致而偏差,也不会产生重复记忆。 ### 12.3 原生注入:`agent.inject()`,而非拼接用户 prompt 记忆通过宿主的 **model-facing context** 通道注入:`agent.inject(createUserMessage({ content, source: { kind: 'plugin', plugin: 'remote-task' } }))`。这与 `dsh-agent-instructions` 的注入范式一致(它在 `agent/pre-step` 里把 AGENTS.md 作为独立上下文消息折叠进请求)。 这样做的决定性收益: - **注入的记忆不是 `source.kind === 'user'` 消息**,因此被 `transcriptFromEvents` 与 `onSessionEvent` 的用户分支自动排除——**旧记忆永不进入再总结**。据此彻底删除了旧实现里脆弱的 `injectedPrefixes / noteInjectedPrefix / stripInjectedContext / pendingInitialPrefix` 全套前缀记账与剥离逻辑。 - **不污染用户 prompt**:用户消息保持原样。 - **时序天然**:`inject()` 排入 next-step 上下文,idle 时挂起直到下一次 `followup` 唤醒驱动认领,正好匹配 create(先注入后首个 prompt)与 restore(无首 prompt,挂起待下轮)两条路径。 注入时机: | 时机 | 注入内容 | |---|---| | `composeCreate` 绑定工作区后、首个 prompt 前 | goal + 预算内选择的记忆快照 | | `composeRestore` 绑定工作区后 | 同上(挂起待下一轮 prompt 认领)| | 每次 `promptWithRefresh`(`POST /prompt`)先于 prompt | **两类增量**:goal 若被改/重贴则标注「[工作区任务架构目标已更新]」,记忆则仅注入 `createdAt > lastInjectedAt` 的条目并标注「[工作区记忆更新]」| `lastInjectedAt`(记忆,以 sidecar `updatedAt` 为水位线)与 `injectedGoal`(goal 上次注入值)**双水位线**各自至多触发一次注入。goal 变更只改 `updatedAt` 而不新增条目,故必须单独比对 `injectedGoal`——否则长会话将永远看不到运行中被 `POST /workspaces` 更新的架构目标。`promptWithRefresh` 先 `assertAdmittable()` 校验准入再注入,避免被拒的 prompt(409)把上下文塞进无关的在途轮次。 ### 12.4 检索式注入(预算 + 优先级) `selectEntriesForInjection(entries, budgetChars)` 按 `decision > todo > fact > insight` 的优先级、同级内新近度排序,在字符预算内贪心选取,最后按时间升序输出以便阅读。因此记忆可长期累积,而每轮注入永远是一小撮最相关内容——取代了旧的“全量注入 + 超 3000 字再整体压缩”的有损路径。 ### 12.5 触发时机 宿主 **没有「会话结束」事件**(一轮自然结束只发 `step/end → turn/end + agent/status: idle`,会话仍停在 idle)。因此总结触发点: 1. **`stop()`**(`POST /sessions/:id/stop` 或插件卸载 `disposeAll`):flush 后必做最后一次增量总结。 2. **`autoDistillOnIdle`(可选,默认关)**:`turn/end` 回到 idle 即触发增量总结,解决“从不调 stop 就永不落盘”。因增量 + cursor 幂等,与后续 `stop()` 不重复。 ### 12.6 落盘安全 `saveWorkspaceSidecar` 采用 **临时文件 + `rename`** 原子替换,崩溃不会留下半写 JSON。进程内 `withWorkspaceSidecarLock` 仅适用单宿主进程;多进程部署需改文件锁。 --- ## 十三、与 dsh 原生记忆设施的关系与冲突分析 核查 `E:\htmlProjects\deepseek-harness-master` 后确认:**dsh 没有一个“自动跨会话长期记忆库”服务**,但提供四个相邻设施。逐一比对: | dsh 原生设施 | 作用域 | 与本插件记忆的关系 | |---|---|---| | `ctx.goals`(`dsh-goal`)| **单会话**,事件溯源,backed by 会话日志;跟踪 objective/phase/rounds | **不冲突**。它是会话内任务目标的生命周期跟踪;本插件的 `goal` 是**工作区级长期架构纲要**,作为常驻上下文注入。二者作用域不同,可共存 | | `ctx.compaction`(`dsh-compaction`)| **单会话上下文窗口**,把历史区间替换为一个 summary 节点写回会话日志 | **不冲突**。它解决单会话 token 压力;本插件解决跨会话知识沉淀。且 compaction 的 summary 节点用 `compactCheckpointSource`(非 `source.kind==='user'`),被本插件转录读取自动排除,**不会被二次总结** | | `ctx.sessionReferenceResolver`(`dsh-session-reference`)| **显式跨会话**:`@提及` 另一会话,把其快照作为 durable context 注入 | **互补**。它是用户手动、按需的跨会话引用;本插件是自动、无感的工作区记忆沉淀 | | `dsh-agent-instructions` | **工作区级常驻上下文**:把 AGENTS.md 在 `agent/pre-step` 注入 durable context | **最接近的重叠**,但机制不同:它读仓库内 AGENTS.md 文件(会被 git 跟踪、对用户可见)。本插件记忆存在 `~/.dsh` 下的 sidecar(插件私有、不污染仓库),并**复用同一套 `agent.inject()`/pre-step 注入范式** | ### 结论 1. **不存在硬冲突**:本插件的工作区记忆占据的是 dsh 未覆盖的生态位——**自动、跨会话、工作区级、私有的长期记忆**。 2. **命名上的重叠需澄清**:sidecar 的 `goal`(工作区架构纲要,静态、注入用)≠ `ctx.goals`(单会话目标跟踪,动态、事件溯源)。DESIGN 与代码注释均以“工作区 goal”限定,避免混淆。 3. **回应“是否只保留工作区全局记忆摘要”**:**是,且应当如此**。会话内的目标跟踪交给 `ctx.goals`、会话内的上下文压缩交给 `ctx.compaction`、显式跨会话引用交给 `ctx.sessionReferenceResolver`;本插件**只负责工作区全局的结构化记忆摘要**,不重复实现任何单会话设施。这既消除职责重叠,也让插件记忆成为纯粹的“跨会话知识层”。 4. **注入采用宿主原生范式**:用 `agent.inject()` 走 model-facing context 通道(对齐 `agent-instructions`),而非拼接用户 prompt——这是本次重构在“优雅”与“正确性”上的核心改进。