From 10e2842ea58c6c1ea371ccc0d03af19097c8f62f Mon Sep 17 00:00:00 2001 From: soyboyscout Date: Sat, 29 Aug 2026 17:16:40 -0400 Subject: [PATCH 1/8] Record DeepSeek Harness adapter Phase 0 spike and session watchdog. Pin the JSON-RPC wire, TokenUsage mapping, and owned-client decision so later phases do not depend on unpublished dsh workspace peers. Co-authored-by: Cursor --- .../2026-08-29-deepseek-harness-adapter.md | 458 ++++++++++++++++++ scripts/deepseek-jsonrpc-session-spike.mjs | 178 +++++++ scripts/monitor-deepseek-adapter-session.sh | 107 ++++ 3 files changed, 743 insertions(+) create mode 100644 doc/plans/2026-08-29-deepseek-harness-adapter.md create mode 100755 scripts/deepseek-jsonrpc-session-spike.mjs create mode 100755 scripts/monitor-deepseek-adapter-session.sh diff --git a/doc/plans/2026-08-29-deepseek-harness-adapter.md b/doc/plans/2026-08-29-deepseek-harness-adapter.md new file mode 100644 index 000000000000..2bf397bd98f2 --- /dev/null +++ b/doc/plans/2026-08-29-deepseek-harness-adapter.md @@ -0,0 +1,458 @@ +# DeepSeek Harness Adapter — Full Paperclip-Feature Parity Plan + +Status: In progress (`feat/deepseek-harness-support`) +Date: 2026-08-29 +Audience: Engineering +Scope: Add a Paperclip agent adapter for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`) that matches `claude_local` on Paperclip control-plane features (heartbeat execute, sessions, skills, instructions, usage, transcripts, env test, remote/sandbox, JWT, config schema). Not a goal: make `dsh` behave like Claude Code. + +Primary references (local checkouts): + +- Paperclip Claude adapter: `packages/adapters/claude-local/` +- Paperclip adapter contract: `packages/adapter-utils/src/types.ts` (`ServerAdapterModule`) +- Paperclip plugin loader: `server/src/adapters/plugin-loader.ts` +- Paperclip adapter docs: `docs/adapters/creating-an-adapter.md`, `docs/adapters/external-adapters.md` +- DeepSeek Harness: `/Users/mikestaub/code/codecrafters/ai-coding-tools/deepseek-harness` + - CLI: `apps/cli/README.md` + - Headless: `packages/bundle/headless/README.md` + - JSON-RPC protocol: `packages/sdk/protocol/src/types.ts` + - TS SDK client: `packages/sdk/client/src/api.ts` + - JSON-RPC server: `packages/sdk/server/README.md` + - ACP (do not use as primary): `packages/acp/acp/README.md` + - Skills: `packages/skill/skill-filesystem/README.md` + - Session events: `packages/core/session/src/types.ts` + +--- + +## 1. Goal + +Ship `@paperclipai/adapter-deepseek-harness` as a first-class Paperclip adapter package that: + +1. Can be loaded as an **external plugin** (`createServerAdapter()` + `./ui-parser`) without forking Paperclip. +2. Is also **registered as a built-in** (Hermes pattern) so hire/onboarding/remote-env/session lists treat it like `claude_local`. +3. On each heartbeat, drives DeepSeek Harness through its **JSON-RPC stdio SDK**, resumes the same session, streams structured events into the run log, and returns usage + session state. + +Type key: `deepseek_local`. +Label: `DeepSeek Harness`. + +`deepseek_local` matches the existing `*_local` coding-adapter family (`claude_local`, `hermes_local`, `opencode_local`). The package name keeps `harness` so it is not confused with a raw DeepSeek chat-completions wrapper. + +--- + +## 2. Non-goals + +- Do not wrap `dsh web` or start the DeepSeek Web UI per heartbeat. +- Do not use `dsh --profile headless` as the production execute path (fresh session, last-assistant-text only, no tool stream). +- Do not use `dsh-acp` as the production execute path. ACP is automation-only, **fresh sessions only**, and keeps reasoning/tool activity off the wire (`packages/acp/acp/README.md`). +- Do not require Python. The Python SDK (`deepseek-harness-sdk`) is the same JSON-RPC protocol with an extra runtime. Paperclip is Node. +- Do not implement Claude-specific product features (Chrome flag, `claude setup-token` OAuth, Bedrock/Vertex, `--dangerously-skip-permissions` as a Claude CLI flag). +- Do not vendor the DeepSeek Harness monorepo into Paperclip. +- Do not treat `doc/SPEC.md` as in-scope product rewrite. + +--- + +## 3. Why JSON-RPC (not headless, not ACP) + +DeepSeek Harness exposes three automation surfaces. Only one can meet Claude-level Paperclip features. + +| Surface | Invoke | Session resume | Live tools / thinking | Usage | Verdict | +|---|---|---|---|---|---| +| `dsh --profile headless "task"` | one-shot CLI | **No** — “creates one fresh persisted session” | No structured stream; last assistant text on stdout | No | Smoke test only | +| `@deepseek-ai/dsh-acp` | ACP JSON-RPC stdio | **No** — “Fresh sessions only” | Committed assistant text only; tools/reasoning off wire | Off wire | Wrong for Paperclip transcripts | +| `@deepseek-ai/dsh-sdk-jsonrpc-server` + TS/Python client | `initialize` + `session/prompt` | **Yes** — unknown `sessionId` creates; reuse continues | `session.event` includes `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result` | `assistant/message.usage` | **Primary path** | + +JSON-RPC wire (`packages/sdk/protocol/src/types.ts`): + +- Client → server: `initialize`, `session/prompt`, `shutdown` +- Server → client: `session.event`, `session.status`, `subagent.started`, `subagent.finished` +- `session/prompt({ sessionId, contentBlocks })` queues one user message and returns `{ messageId }` immediately +- Client owns the activity interval: wait until `session.status === "idle"` after the inbox receipt +- `DeepSeekHarness.run(input, { sessionId })` in `packages/sdk/client/src/api.ts` already implements that interval + +Stdout is the protocol. Diagnostics must stay on stderr. The Paperclip-owned Cordis composition must not mount a stdout logger. + +--- + +## 4. Parity definition + +“Full parity with Claude Code **in terms of Paperclip features**” means the `deepseek_local` `ServerAdapterModule` implements the same host-visible capabilities as `claude_local` in `server/src/adapters/registry.ts`, plus the same host-list membership so environments, heartbeats, and the hire UI treat it as a first-class local coding adapter. + +Claude-only product mechanics that have no `dsh` equivalent are marked **N/A (equivalent)**: implement the Paperclip outcome through the DeepSeek-native seam. + +### 4.1 ServerAdapterModule checklist + +| Paperclip feature | `claude_local` | `deepseek_local` plan | +|---|---|---| +| `type` + `execute` + `testEnvironment` | required | required | +| Session resume (`sessionCodec` + `sessionParams`) | cwd-aware `--resume`; poisoned-session rotate | Persist `{ sessionId, cwd, sessionRoot }`; reuse `sessionId` on JSON-RPC; rotate if cwd/session-root mismatch or runtime rejects the id | +| `sessionManagement` | native context management confirmed; Paperclip compaction thresholds off | Same: `dsh` ships compaction in the coding composition. Register `nativeContextManagement: "confirmed"` | +| `supportsLocalAgentJwt` | true | true — inject `PAPERCLIP_API_URL` / `PAPERCLIP_API_KEY` / `PAPERCLIP_RUN_ID` | +| `supportsInstructionsBundle` + `instructionsPathKey` | `instructionsFilePath` → prompt bundle / `--append-system-prompt-file` | Read file; pass as `DSH_SYSTEM_PROMPT` and/or a Paperclip-owned persona section in the shipped `cordis.yml` | +| Skills `listSkills` / `syncSkills` | ephemeral prompt-bundle `--add-dir`; also scans `~/.claude/skills` | Materialize desired Paperclip skills into a tmp/custom root; pass via `customSkillDirs` / `DSH_BUNDLED_SKILL_DIR`. Scan `~/.dsh/skills` and `~/.agents/skills` as external | +| `requiresMaterializedRuntimeSkills` | false (ephemeral bundle) | **true** — `dsh-skill-filesystem` discovers on-disk roots, it does not take Claude-style `--add-dir` | +| Models `models` / `listModels` / `refreshModels` | curated + discovered | Curated DeepSeek ids + optional discovery from `dsh` model settings / `DSH_MODEL` | +| `modelProfiles.cheap` | Sonnet + low effort | `deepseek-v4-flash` (or current cheap catalog id) | +| `getConfigSchema` | engine/ACP fields | Model, provider, cwd, timeout, extra env, session persist, effort/max tokens — no ACP engine picker | +| `getRuntimeCommandSpec` | `claude` + npm install | Detect `dsh` / bundled `dsh-jsonrpc-agent`; sandbox install command for `@deepseek-ai/dsh` | +| `getQuotaWindows` | Anthropic OAuth + `claude` usage | Phase 2. Extract per-run `TokenUsage` in V1. Add provider quota only if a stable DeepSeek usage API is confirmed | +| `loginCapability` | PTY `claude setup-token` | **Omit.** Auth is `DEEPSEEK_API_KEY` (+ optional `DEEPSEEK_BASE_URL`). Env-test must fail closed when the key is missing | +| `detectModel` | local Claude config | Read `$DSH_HOME` / profile model + `DSH_MODEL` | +| `agentConfigurationDoc` | yes | yes | +| ACP `acp` descriptor + engine auto/cli/acp | yes | **Do not advertise ACP.** dsh ACP cannot resume or stream tools. JSON-RPC is the richer Paperclip lane | +| Live transcript | Claude stream-json / ACPX events | Mirror `session.event` onto stdout as JSONL; UI parser maps to `TranscriptEntry` | +| CLI `format-event` | yes | yes | +| Error families | quota, refusal, transient, poisoned session | Map `turn/end` `error` / `max-tokens` / auth failures to `AdapterExecutionErrorFamily` | +| `clearSession` | unknown/poisoned session | Unknown session or persistence miss → retry once fresh + `clearSession: true` | +| Workspace restore (no-remote-git) | `prepareAdapterExecutionTargetRuntime` + restore in `finally` | Same helpers; asset keys: skills dir, session root, cordis config, `DSH_HOME` subset | +| SSH / sandbox execution targets | yes | yes, after host list updates (section 7) | +| `filesystemScope` / `networkScope` (bwrap) | yes | Wrap the JSON-RPC runtime process with the same `LocalProcessSandboxOptions` | +| Runtime MCP servers | write Claude MCP config | Phase 2. dsh ACP rejects MCP; JSON-RPC MCP depends on composing an MCP plugin. Do not block V1 | +| `workspaceStrategy` git worktree | consumed from heartbeat workspace context | Honor `context.paperclipWorkspace.cwd` the same way Claude does; do not reimplement worktrees | + +### 4.2 Host-list membership (required for real parity) + +These are still hardcoded type sets. A plugin that only exports `createServerAdapter()` will **not** get Claude-level environments, git checks, or sessioned-heartbeat behavior until the type is added (or the lists become capability-driven). + +| Host list | File | Action | +|---|---|---| +| `AGENT_ADAPTER_TYPES` | `packages/shared/src/constants.ts` | Add `deepseek_local` | +| `BUILTIN_ADAPTER_TYPES` | `server/src/adapters/builtin-adapter-types.ts` | Add `deepseek_local` | +| `REMOTE_MANAGED_ADAPTERS` | `packages/shared/src/environment-support.ts` | Add so SSH/sandbox drivers are offered | +| `ADAPTER_SESSION_MANAGEMENT` | `packages/adapter-utils/src/session-compaction.ts` | `supportsSessionResume: true`, `nativeContextManagement: "confirmed"`, managed compaction policy | +| `LEGACY_SESSIONED_ADAPTER_TYPES` | same file | Add if any fallback still uses it | +| `GIT_SENSITIVE_LOCAL_ADAPTER_TYPES` | `server/src/services/heartbeat.ts` | Add | +| `SESSIONED_LOCAL_ADAPTERS` | `server/src/services/heartbeat.ts` | Add | +| Recovery adapter allowlist | `server/src/services/recovery/service.ts` | Add if that list gates local coding adapters | +| UI display map | `ui/src/adapters/adapter-display-registry.ts` | Label / description / icon | +| UI capability cold defaults | `ui/src/adapters/use-adapter-capabilities.ts` | Match server flags for first render | +| Server registry | `server/src/adapters/registry.ts` | Import and register | +| UI registry | `ui/src/adapters/registry.ts` | Static UI module (built-in path) | +| CLI registry | `cli/src/adapters/registry.ts` | `formatStdoutEvent` | + +Prefer a follow-up (not blocking V1) that replaces these lists with `ServerAdapterModule` capability flags (`supportsRemoteManagedEnvironments`, `supportsSessionResume`, `gitSensitive`). V1 should still add the type to the existing lists so parity is not theoretical. + +--- + +## 5. Locked decisions + +1. **Primary execute path** is a Node JSON-RPC client talking to a `dsh-jsonrpc-agent` (or `node` + `@deepseek-ai/dsh-sdk-jsonrpc-server` composition) subprocess. Prefer depending on published `@deepseek-ai/dsh-sdk-client` + `@deepseek-ai/dsh-sdk-protocol` if their license and publish cadence are acceptable; otherwise copy only the wire types and write a thin NDJSON client in the adapter (do not import the DeepSeek monorepo). +2. **Paperclip-owned Cordis composition** ships inside the adapter (`src/server/paperclip.cordis.yml`). It must include: JSON-RPC server, DeepSeek official provider, coding tools (bash + filesystem edit, not the `minimal` two-tool preset), skill registry + filesystem provider, unattended approval policy, JSONL persistence, compaction. It must not include web/TUI/stdout logger. +3. **Session identity** is a Paperclip-generated id stored in `agentTaskSessions.session_params_json`. The adapter always sends that id on `session/prompt`. `dsh` lazily creates the agent+session pair. +4. **Session root** is Paperclip-managed (`$PAPERCLIP_HOME/adapter-state///deepseek/sessions` or the execution-target equivalent), passed as `DSH_SESSION_ROOT`. Do not write into the operator’s interactive `~/.dsh` session store by default. +5. **Auth** is env-only: `DEEPSEEK_API_KEY` required; `DEEPSEEK_BASE_URL` optional for OpenAI-compatible proxies. Support adapter `env` + company environment secret refs the same way other local adapters do. +6. **Unattended permissions**: the shipped composition uses a non-interactive allow policy (DeepSeek’s danger-full-access / one-shot auto-allow equivalent). Document this as the DeepSeek analog of Claude’s `dangerouslySkipPermissions: true`. Remote targets still get Paperclip `filesystemScope` / `networkScope` when configured. +7. **Skills injection** uses `dsh-skill-filesystem` `customSkillDirs` (rank 300) pointing at a Paperclip-materialized directory of `SKILL.md` bundles. Project `.dsh/skills` / `.agents/skills` remain visible as native/external. +8. **Distribution** follows Hermes: in-repo package that exports `createServerAdapter()`, registered as built-in, installable as an external override via `~/.paperclip/adapter-plugins.json`. +9. **dsh is developer preview.** Pin a minimum `@deepseek-ai/dsh*` version in the adapter README. Treat protocol drift as expected; isolate wire types behind `src/server/protocol.ts` and fixture-test them. + +--- + +## 6. Package shape + +``` +packages/adapters/deepseek-harness/ + package.json # @paperclipai/adapter-deepseek-harness + tsconfig.json + README.md + src/ + index.ts # type, label, models, modelProfiles, agentConfigurationDoc, createServerAdapter + server/ + index.ts # factory + execute.ts # heartbeat execute + jsonrpc-runtime.ts # spawn + initialize + prompt + wait idle + parse.ts # session.event → usage / errors / summary + transcript-jsonl.ts # emit UI-parser-friendly JSONL on onLog + session.ts # sessionCodec + skills.ts # list/sync + materialize customSkillDirs + test.ts # env diagnostics + hello probe + models.ts + config-schema.ts + runtime-config.ts # cwd, env, DSH_* , command detect + paperclip.cordis.yml # unattended coding composition + ui/ + index.ts + parse-stdout.ts + build-config.ts + ui-parser.ts # zero-import browser parser + cli/ + index.ts + format-event.ts + skills/ # optional Paperclip-bundled helper skills +``` + +`package.json` exports: `.`, `./server`, `./ui`, `./cli`, `./ui-parser`. Set `paperclip.adapterUiParser: "1.0.0"`. + +Workspace: add the package to the pnpm workspace the same way `packages/adapters/claude-local` is included. Depend on `@paperclipai/adapter-utils` and `@paperclipai/shared` via `workspace:*`. + +--- + +## 7. Execute design + +`execute(ctx)` outline (mirror `claude-local` / `grok-local` structure, swap the child protocol): + +1. Resolve execution target (`readAdapterExecutionTarget`). Honor `context.paperclipWorkspace.cwd` over `adapterConfig.cwd` unless the Claude-equivalent agent-home override applies. +2. Build env: `buildPaperclipEnv(agent)` + `PAPERCLIP_RUN_ID` + `PAPERCLIP_API_KEY` from `authToken` + `DEEPSEEK_*` from config/environment + `DSH_CWD`, `DSH_SESSION_ROOT`, `DSH_MODEL`, `DSH_CORDIS_CONFIG` (path to shipped `paperclip.cordis.yml`, copied onto remote targets). +3. Materialize skills into a run-scoped directory; set `customSkillDirs` / env so the filesystem provider sees them. +4. Load instructions from `instructionsFilePath`; fail warn (not hard-fail) if missing, same as Claude. +5. Deserialize `runtime.sessionParams`. If `cwd` or `sessionRoot` changed, drop the session id. +6. `onMeta` with adapter type, runtime command, cwd, redacted env. +7. Spawn JSON-RPC runtime (`runAdapterExecutionTargetProcess` / `runChildProcess`). Do **not** treat stdout as human logs. +8. `initialize({ cwd, provider, model, maxTokens? })`. +9. `session/prompt({ sessionId, contentBlocks: [{ type: "text", text: renderedPrompt }] })`. +10. For each `session.event`, write one JSONL line to `onLog("stdout")` for the UI parser; accumulate usage from `assistant/message.usage`; track last assistant text. +11. Stop on `session.status: idle` for that session (and owned subagent forest, matching the TS SDK). +12. `shutdown` + process teardown with `timeoutSec` / `graceSec`. +13. Return `AdapterExecutionResult`: exit/signal/timeout, `usage` (`usageBasis: "per_run"`), `sessionParams`, `sessionDisplayId`, `provider: "deepseek"`, `model`, `summary`, error family, `clearSession` if needed. +14. Remote `finally`: `restoreWorkspaceFromSshExecution` / existing no-remote-git helpers. Surface restore failure as a run error. + +Prompt rendering uses the shared `renderPaperclipWakePrompt` / `DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE` so wake-on-comment, recovery, and issue context match other local adapters. + +Cancellation: Paperclip already SIGTERMs the child. On abort, close the JSON-RPC stdin and let the runtime dispose. Note the wire currently has **no per-prompt cancel method** (`packages/sdk/server/README.md`). Process kill is the cancel story, same class as a CLI adapter. + +Hot-restart: do **not** add `deepseek_local` to the ACP stdio-bound restart list. JSON-RPC is a per-run subprocess (`processTopology: "detached"`). + +--- + +## 8. Transcript mapping + +Emit one JSON object per line. Keep the parser import-free (`docs/adapters/adapter-ui-parser.md`). + +| `session.event` type | `TranscriptEntry.kind` | +|---|---| +| `assistant/chunk` (text) | `assistant` with `delta: true` | +| `assistant/chunk` (reasoning, if present) | `thinking` with `delta: true` | +| `assistant/message` | `assistant` (if not already fully streamed) + usage on `result` at idle | +| `tool/call` | `tool_call` (`name`, parsed `arguments`, `toolUseId: callId`) | +| `tool/result` | `tool_result` | +| `user/message` | `user` (optional; skip Paperclip-injected wake text if noisy) | +| `turn/end` reason `error` | `stderr` / result error | +| `session.status` idle + usage totals | `result` | +| `subagent.started` / `finished` | `system` | + +CLI `format-event.ts` pretty-prints the same JSONL for `paperclipai run --watch`. + +--- + +## 9. Environment test + +`testEnvironment` checks, in order: + +| Code | Level | What | +|---|---|---| +| `cli_detected` | error if missing | `dsh` or bundled `dsh-jsonrpc-agent` on PATH / configured `command` | +| `api_key` | error if missing | `DEEPSEEK_API_KEY` in layered env (adapter config, selected environment secret refs, process env) | +| `cwd` | error | absolute and creatable | +| `model` | info/warn | configured or `DSH_MODEL` / detected default | +| `hello_probe` | error on fail | JSON-RPC `initialize` + `session/prompt` with a throwaway session id and prompt `Respond with hello.`; timeout short (~30s) | +| `target` | info | host vs SSH vs sandbox label, same as Claude probe diagnostics | + +Run the probe against `executionTarget` when present. Missing environment secret bindings must surface as `environment_env_binding_missing`, not a silent pass. + +--- + +## 10. Skills and instructions + +### Skills + +- Paperclip-managed: `readPaperclipRuntimeSkillEntries` → copy/symlink into `/skills//SKILL.md` (DeepSeek format: kebab-case name + description frontmatter; `packages/skill/skill-filesystem/README.md`). +- Native/external: list `~/.dsh/skills` and `~/.agents/skills` as `origin: "user_installed"` / `external_unknown`, read-only. +- `syncSkills` rewrites the materialized root; next execute picks it up. Do not mutate the operator’s `~/.dsh/skills` unless they opt in. +- `listSkills` returns the merged snapshot Claude’s helper already shapes (`buildRuntimeMountedSkillSnapshot`). + +### Instructions + +- `supportsInstructionsBundle: true`, `instructionsPathKey: "instructionsFilePath"`. +- Contents become the deployment persona (`DSH_SYSTEM_PROMPT`) plus the existing path-directive note Claude adds so `HEARTBEAT.md` / `SOUL.md` resolve from the instructions directory. +- Workspace `AGENTS.md` discovery is DeepSeek-native; do not also write Paperclip instructions into the repo cwd. + +--- + +## 11. Config contract + +```ts +export const type = "deepseek_local"; +export const label = "DeepSeek Harness"; +``` + +V1 `adapterConfig` fields: + +| Field | Type | Default | Notes | +|---|---|---|---| +| `cwd` | string | workspace cwd | absolute fallback | +| `model` | string | `deepseek-v4-flash` (confirm against current catalog at implement time) | | +| `provider` | string | `deepseek-official` | JSON-RPC `initialize.provider` | +| `maxTokens` | number | provider default | `initialize.maxTokens` | +| `instructionsFilePath` | string | — | managed bundle | +| `promptTemplate` | string | shared Paperclip default | | +| `command` | string | auto (`dsh-jsonrpc-agent` or `dsh`) | | +| `persistSession` | boolean | true | | +| `timeoutSec` | number | 0 | | +| `graceSec` | number | 15 | | +| `extraArgs` | string[] | [] | forwarded only if using a CLI launcher wrapper | +| `env` | object | {} | secret refs allowed | +| `filesystemScope` / `filesystemExtraPaths` / `networkScope` / `networkAllowlist` | same as Claude | off | wrap runtime process | + +Cheap profile: `{ model: "" }` under `runtimeConfig.modelProfiles.cheap`. + +No `engine` field. If a future dsh ACP gains resume + tool streaming, add it then. + +--- + +## 12. Implementation phases + +### Phase 0 — Spike (1–2 days, local only) + +- From the cloned harness repo, run the TS or Python SDK against `examples/jsonrpc-agent` with a real `DEEPSEEK_API_KEY` and a disposable workspace. +- Confirm: session id reuse continues the shell/conversation; `session.event` JSON contains `tool/call` + `assistant/message.usage`; stderr-only diagnostics. +- Record the exact published npm package names and versions (`@deepseek-ai/dsh-sdk-client`, runtime bin) in this plan’s changelog section. +- Decide: depend on published SDK vs vendored wire client. + +Exit criterion: a 30-line script that performs two `run()` calls on one `sessionId` and prints usage + a tool name. + +### Phase 1 — Plugin that can be hired and woken + +- Package skeleton + `createServerAdapter()`. +- `execute` + `testEnvironment` + `sessionCodec` + config schema + configuration doc. +- Local-only JSON-RPC runtime, Paperclip env injection, prompt template, session persist. +- Generic stdout until Phase 2 parser lands. +- Register as **external-installable**; optionally skip built-in registration until Phase 2 if we want a thinner first PR. Preferred: register built-in immediately so QA uses the hire UI. + +Exit criterion: create agent `adapterType: deepseek_local`, Test Environment passes, assign an issue, heartbeat completes, next heartbeat resumes the same `sessionId`. + +### Phase 2 — Claude-visible product surfaces + +- UI parser + CLI formatter (tool cards, thinking, result/usage). +- `listSkills` / `syncSkills` + materialized `customSkillDirs`. +- Instructions bundle. +- `listModels` / `refreshModels` / cheap profile / `detectModel`. +- Error family mapping + unknown-session rotate + `clearSession`. +- Host-list updates in section 4.2. +- Display registry + capability cold defaults. +- Docs: `docs/adapters/deepseek-local.md` + overview table row. + +Exit criterion: run viewer shows tool cards and token totals; skills tab works; hire form shows DeepSeek next to Claude. + +### Phase 3 — Remote / sandbox / confinement parity + +- `getRuntimeCommandSpec` + sandbox install of `dsh` or the jsonrpc runtime bin. +- `prepareAdapterExecutionTargetRuntime` assets: cordis file, skills, session root, sanitized `DSH_HOME`. +- Workspace restore in `finally` (no `git push`). +- `filesystemScope` / `networkScope` around the runtime process. +- Remote env test / hello probe. + +Exit criterion: same agent runs on local, SSH, and one sandbox provider; restore brings remote commits back; env test names the target. + +### Phase 4 — Stretch (only if still short of “full”) + +- `getQuotaWindows` if DeepSeek exposes a stable usage API. +- Runtime MCP injection if a dsh MCP plugin can be composed without breaking stdout purity. +- Optional `dsh` plugin that is a first-class Paperclip heartbeat citizen (reverse: start in dsh, create Paperclip work) — Hermes `paperclip-task-bridge` analog. Not required for Claude execute parity. + +--- + +## 13. Core Paperclip touch list + +Keep adapter logic in the new package. Core edits are registration and host lists only. + +- `packages/shared/src/constants.ts` — `AGENT_ADAPTER_TYPES` +- `packages/shared/src/environment-support.ts` — remote managed set + tests +- `packages/adapter-utils/src/session-compaction.ts` — session management +- `server/src/adapters/builtin-adapter-types.ts` +- `server/src/adapters/registry.ts` — import `createServerAdapter` / named server exports +- `server/src/services/heartbeat.ts` — git-sensitive + sessioned sets +- `server/src/services/recovery/service.ts` — if applicable +- `ui/src/adapters/registry.ts`, `adapter-display-registry.ts`, `use-adapter-capabilities.ts` +- `ui/src/adapters/deepseek-local/` — thin UI module (parser + build-config) +- `cli/src/adapters/registry.ts` +- `docs/adapters/overview.md`, new `docs/adapters/deepseek-local.md` +- pnpm workspace / package manifest (do not commit `pnpm-lock.yaml` per `doc/DEVELOPING.md`) + +Do **not** add `deepseek_local` to Claude setup-token routes, `CLAUDE_LOCAL_ADAPTER_TYPE` secret binding, or the ACP hot-restart list. + +--- + +## 14. Tests + +Adapter package (Vitest): + +- `parse.test.ts` — fixture `session.event` streams → usage, summary, error families, transcript JSONL +- `session.test.ts` — codec; cwd/sessionRoot mismatch drops resume +- `skills.test.ts` — materialize SKILL.md frontmatter; merge native roots +- `execute.test.ts` — fake JSON-RPC child (stdio NDJSON) covering initialize, prompt, events, idle, shutdown +- `execute.remote.test.ts` — reuse adapter-utils execution-target test helpers +- `test.test.ts` — missing binary / missing key / probe failure +- `ui/parse-stdout.test.ts` + ui-parser contract (zero imports) + +Host: + +- `environment-support.test.ts` — `deepseek_local` supports local/ssh/sandbox +- `adapter-registry` / list adapters includes type and capabilities +- No e2e that calls real DeepSeek in CI. Optional manual `pnpm` script behind `DEEPSEEK_API_KEY`. + +--- + +## 15. Verification + +Phase 1 (local): + +```sh +pnpm --filter @paperclipai/adapter-deepseek-harness test +pnpm --filter @paperclipai/adapter-deepseek-harness typecheck +# Paperclip running; adapter installed or built-in registered +curl -sS http://localhost:3100/api/adapters | jq '.[] | select(.type=="deepseek_local")' +# Hire agent, Test Environment, assign issue, inspect heartbeat_runs + session params +``` + +Phase 2+: + +```sh +pnpm -r typecheck +pnpm test:run --filter adapter-deepseek +pnpm check:token-gates # if UI files added +``` + +Manual: two consecutive heartbeats on one issue must log the same `sessionId` and the second run must see prior tool/file context. Run viewer must show tool cards, not a raw JSON blob. + +--- + +## 16. Risks + +| Risk | Mitigation | +|---|---| +| dsh is developer preview; wire and catalog ids will break | Pin versions; isolate protocol; fixture tests; README compatibility note | +| JSON-RPC has no prompt-level cancel | Kill the subprocess; honor `timeoutSec` / `graceSec` | +| Headless/ACP look simpler and will tempt a shortcut | This plan forbids them as the production path; Phase 0 must prove JSON-RPC resume | +| Host hardcoded lists omit a plugin-only install | V1 registers built-in + updates lists; later make lists capability-driven | +| Skills format / invocation frontmatter differs from Paperclip | Materialize a DeepSeek-valid `SKILL.md`; do not assume Claude `--add-dir` | +| Operator `~/.dsh` pollution | Paperclip-managed `DSH_SESSION_ROOT` and config dir; do not default to interactive home | +| stdout logger in a user-patched profile corrupts the protocol | Ship a closed `paperclip.cordis.yml`; ignore user web profile; document “do not add stdout plugins” | +| Runtime bin size / sandbox install time | `getRuntimeCommandSpec.installCommand` must be idempotent; cache on reusable sandbox leases | +| Token usage missing on some models | `usage` optional; do not fail the run; UI shows unknown | +| Quota/login parity gap vs Claude subscription UX | Explicit N/A: API key + env test. Do not fake a PTY login | + +--- + +## 17. Recommended first PR slice + +One PR that lands Phase 1 **and** the host type-list additions (so the adapter is selectable), with a raw-log UI. Follow with Phase 2 (parser + skills + models) and Phase 3 (remote) as separate PRs. + +PR template must include Thinking Path, What Changed, Verification, Risks, Model Used, and the checklist (`.github/PULL_REQUEST_TEMPLATE.md`). + +--- + +## 18. Phase 0 spike results (2026-08-29) + +Closed from `/Users/mikestaub/code/codecrafters/ai-coding-tools/deepseek-harness` at adapter-plan time. dsh version in-tree: `0.1.1-rc.2`. + +1. **Default model id:** `deepseek-v4-flash`. Confirmed in `python/sdk/README.md`, `packages/web/web-search-deepseek/src/provider.ts` (`DEEPSEEK_DEFAULT_MODEL`), and workflow tests. Pro / reasoner ids such as `deepseek-v4-pro` exist; cheap profile stays on flash. +2. **Published SDK vs owned client:** `@deepseek-ai/dsh-sdk-client` and `@deepseek-ai/dsh-sdk-protocol` exist (`0.1.1-rc.2`) but declare `workspace:^` peerDependencies on `@deepseek-ai/dsh-llm`, `@deepseek-ai/dsh-session`, `@deepseek-ai/dsh-subagent`, and `@deepseek-ai/cordis`. They are **not** independently usable from Paperclip’s workspace. **Decision: own thin NDJSON JSON-RPC client** (`src/server/jsonrpc-client.ts`). Do not depend on `@deepseek-ai/dsh-*`. +3. **TokenUsage → UsageSummary:** `packages/llm/llm/src/types.ts` — `{ inputTokens, outputTokens, cacheReadTokens?, cacheWriteTokens?, reasoningTokens? }`. Counts are disjoint: `inputTokens` is uncached input only. Map `cacheReadTokens` → `cachedInputTokens`. Ignore `cacheWriteTokens` / `reasoningTokens` in V1 totals (they are not Paperclip `UsageSummary` fields). +4. **Unattended policy:** mount `@deepseek-ai/dsh-sandbox-policy` with `mode: danger-full-access` (default is fail-safe `read-only`). The shipped `examples/jsonrpc-agent/cordis.yml` does not mount this plugin; Paperclip’s composition must, so heartbeats do not block on file/bash approval. There is no separate Claude-style `--dangerously-skip-permissions` flag. +5. **Runtime install:** the JSON-RPC bin is `dsh-jsonrpc-agent` from `@deepseek-ai/dsh-sdk-jsonrpc-demo` (`packages/examples/jsonrpc-demo`, bin `dsh-jsonrpc-agent`). Product CLI is `@deepseek-ai/dsh` (`dsh`). Python production carrier is `deepseek-harness-runtime-bin` (`dsh-jsonrpc-agent`). Sandbox `installCommand` (best effort): `npm install -g @deepseek-ai/dsh-sdk-jsonrpc-demo@0.1.1-rc.2`. Prefer detecting an already-installed `dsh-jsonrpc-agent` or repo-local bin. Always pass `DSH_CORDIS_CONFIG` — the runtime exits without it. + +**SDK client decision recorded:** own protocol types + NDJSON client. Pin compatibility notes to dsh `0.1.1-rc.2` wire: `initialize`, `session/prompt`, `shutdown` + notifications `session.event`, `session.status`, `subagent.started`, `subagent.finished`. `serverInfo.name` is `deepseek-harness-sdk-runtime`. + +**Live API spike:** not run in this session (no `DEEPSEEK_API_KEY` required for the protocol mock). `scripts/deepseek-jsonrpc-session-spike.mjs` proves two `session/prompt` calls on one `sessionId` against a mock runtime and prints usage + a tool name. + +### Watchdog + +`scripts/monitor-deepseek-adapter-session.sh` checks git/file/heartbeat freshness. A 20-minute agent loop ticks `AGENT_LOOP_TICK_deepseek_adapter`. Heartbeat file: `.ai/deepseek-adapter-session/heartbeat.json`. diff --git a/scripts/deepseek-jsonrpc-session-spike.mjs b/scripts/deepseek-jsonrpc-session-spike.mjs new file mode 100755 index 000000000000..87f409de48df --- /dev/null +++ b/scripts/deepseek-jsonrpc-session-spike.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +/** + * Phase 0 spike: two JSON-RPC session/prompt calls on one sessionId. + * + * Default path speaks to an in-process mock of dsh-jsonrpc-agent (no API key). + * Set DEEPSEEK_SPIKE_RUNTIME to a real `dsh-jsonrpc-agent` binary (and + * DEEPSEEK_API_KEY + DSH_CORDIS_CONFIG) to exercise the live harness. + */ +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { once } from "node:events"; + +const SESSION_ID = "paperclip-spike-session-1"; + +function encode(id, method, params) { + return `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`; +} + +async function runAgainstMock() { + const child = spawn(process.execPath, ["-e", MOCK_RUNTIME], { + stdio: ["pipe", "pipe", "pipe"], + }); + const result = await driveClient(child); + console.log(JSON.stringify({ mode: "mock", ...result }, null, 2)); + if (!result.toolName || result.runs !== 2 || result.sessionId !== SESSION_ID) { + process.exitCode = 1; + } +} + +async function runAgainstRuntime(command) { + const args = process.env.DEEPSEEK_SPIKE_RUNTIME_ARGS + ? JSON.parse(process.env.DEEPSEEK_SPIKE_RUNTIME_ARGS) + : []; + const child = spawn(command, args, { + stdio: ["pipe", "pipe", "inherit"], + env: process.env, + }); + const result = await driveClient(child); + console.log(JSON.stringify({ mode: "runtime", command, ...result }, null, 2)); +} + +async function driveClient(child) { + let nextId = 1; + const pending = new Map(); + const events = []; + let usage = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0 }; + let toolName = null; + + const rl = createInterface({ input: child.stdout }); + rl.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let msg; + try { + msg = JSON.parse(trimmed); + } catch { + return; + } + if (msg.id != null && (msg.result !== undefined || msg.error)) { + const waiter = pending.get(msg.id); + if (!waiter) return; + pending.delete(msg.id); + if (msg.error) waiter.reject(new Error(JSON.stringify(msg.error))); + else waiter.resolve(msg.result); + return; + } + if (msg.method === "session.event") { + events.push(msg.params?.event); + const event = msg.params?.event; + if (event?.type === "tool/call") toolName = event.data?.name ?? toolName; + if (event?.type === "assistant/message" && event.data?.usage) { + const u = event.data.usage; + usage = { + inputTokens: usage.inputTokens + Number(u.inputTokens ?? 0), + outputTokens: usage.outputTokens + Number(u.outputTokens ?? 0), + cachedInputTokens: usage.cachedInputTokens + Number(u.cacheReadTokens ?? 0), + }; + } + } + }); + + const request = (method, params) => { + const id = nextId++; + const p = new Promise((resolve, reject) => pending.set(id, { resolve, reject })); + child.stdin.write(encode(id, method, params)); + return p; + }; + + await request("initialize", { + cwd: process.cwd(), + provider: "deepseek-official", + model: "deepseek-v4-flash", + }); + + for (const prompt of ["First heartbeat: list the workspace.", "Second heartbeat: continue."]) { + await request("session/prompt", { + sessionId: SESSION_ID, + contentBlocks: [{ type: "text", text: prompt }], + }); + await waitForIdle(rl, events, SESSION_ID); + } + + await request("shutdown", {}); + child.stdin.end(); + await Promise.race([once(child, "exit"), sleep(2_000)]); + rl.close(); + + return { + sessionId: SESSION_ID, + runs: 2, + toolName, + usage, + eventTypes: events.map((event) => event?.type).filter(Boolean), + }; +} + +function waitForIdle(rl, events, sessionId) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timed out waiting for idle")), 15_000); + const onLine = (line) => { + try { + const msg = JSON.parse(line); + if ( + msg.method === "session.status" && + msg.params?.sessionId === sessionId && + msg.params?.status === "idle" + ) { + clearTimeout(timer); + rl.off("line", onLine); + resolve(); + } + } catch { + // ignore non-JSON + } + }; + rl.on("line", onLine); + }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const MOCK_RUNTIME = ` +import { createInterface } from "node:readline"; +let seq = 0; +const rl = createInterface({ input: process.stdin }); +rl.on("line", (line) => { + const msg = JSON.parse(line); + const reply = (result) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }) + "\\n"); + const notify = (method, params) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\\n"); + if (msg.method === "initialize") { + reply({ serverInfo: { name: "deepseek-harness-sdk-runtime", version: "0.1.1-rc.2" } }); + return; + } + if (msg.method === "session/prompt") { + const sessionId = msg.params.sessionId; + const messageId = "msg-" + (++seq); + reply({ messageId }); + notify("session.status", { sessionId, status: "running" }); + notify("session.event", { sessionId, event: { type: "user/message", seq: seq, data: { content: msg.params.contentBlocks } } }); + notify("session.event", { sessionId, event: { type: "tool/call", seq: ++seq, data: { callId: "call-1", name: "bash", arguments: "{\\"command\\":\\"ls\\"}" } } }); + notify("session.event", { sessionId, event: { type: "tool/result", seq: ++seq, data: { message: { content: "ok" } } } }); + notify("session.event", { sessionId, event: { type: "assistant/message", seq: ++seq, data: { message: { content: "done" }, usage: { inputTokens: 11, outputTokens: 4, cacheReadTokens: 2 } } } }); + notify("session.event", { sessionId, event: { type: "turn/end", seq: ++seq, data: { turn: 1, reason: { kind: "completed" } } } }); + notify("session.status", { sessionId, status: "idle" }); + return; + } + if (msg.method === "shutdown") { + reply({}); + process.exit(0); + } +}); +`; + +const runtime = process.env.DEEPSEEK_SPIKE_RUNTIME; +if (runtime) await runAgainstRuntime(runtime); +else await runAgainstMock(); diff --git a/scripts/monitor-deepseek-adapter-session.sh b/scripts/monitor-deepseek-adapter-session.sh new file mode 100755 index 000000000000..0d0570416be7 --- /dev/null +++ b/scripts/monitor-deepseek-adapter-session.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Watch the feat/deepseek-harness-support implementation session. +# Exit 0 = progressing, 2 = stuck (no git/file/heartbeat activity), 1 = usage error. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BRANCH="${DEEPSEEK_MONITOR_BRANCH:-feat/deepseek-harness-support}" +STALE_SECS="${DEEPSEEK_MONITOR_STALE_SECS:-1500}" # 25 minutes +HEARTBEAT_FILE="${DEEPSEEK_MONITOR_HEARTBEAT:-$REPO_ROOT/.ai/deepseek-adapter-session/heartbeat.json}" +WATCH_GLOBS=( + "$REPO_ROOT/packages/adapters/deepseek-harness" + "$REPO_ROOT/doc/plans/2026-08-29-deepseek-harness-adapter.md" + "$REPO_ROOT/scripts/monitor-deepseek-adapter-session.sh" +) +LOG_DIR="${DEEPSEEK_MONITOR_LOG_DIR:-$REPO_ROOT/.ai/deepseek-adapter-session}" +mkdir -p "$LOG_DIR" +LOG_FILE="$LOG_DIR/monitor.log" + +now_epoch="$(date +%s)" +iso_now="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +latest_epoch=0 +latest_source="none" + +note_epoch() { + local src="$1" + local epoch="$2" + if [[ -n "$epoch" && "$epoch" =~ ^[0-9]+$ && "$epoch" -gt "$latest_epoch" ]]; then + latest_epoch="$epoch" + latest_source="$src" + fi +} + +if git -C "$REPO_ROOT" rev-parse --verify "$BRANCH" >/dev/null 2>&1; then + commit_epoch="$(git -C "$REPO_ROOT" log -1 --format=%ct "$BRANCH" 2>/dev/null || true)" + note_epoch "git:$BRANCH" "$commit_epoch" +fi + +if [[ -f "$HEARTBEAT_FILE" ]]; then + hb_epoch="$(python3 - "$HEARTBEAT_FILE" <<'PY' 2>/dev/null || true +import json, sys, datetime +path = sys.argv[1] +try: + data = json.load(open(path)) +except Exception: + sys.exit(0) +raw = data.get("updatedAt") or data.get("ts") or "" +if not raw: + sys.exit(0) +try: + dt = datetime.datetime.fromisoformat(raw.replace("Z", "+00:00")) + print(int(dt.timestamp())) +except Exception: + pass +PY +)" + note_epoch "heartbeat" "$hb_epoch" + file_epoch="$(stat -f %m "$HEARTBEAT_FILE" 2>/dev/null || stat -c %Y "$HEARTBEAT_FILE" 2>/dev/null || true)" + note_epoch "heartbeat-mtime" "$file_epoch" +fi + +for path in "${WATCH_GLOBS[@]}"; do + if [[ -e "$path" ]]; then + newest="$(find "$path" -type f -not -path '*/node_modules/*' -not -path '*/dist/*' -print0 2>/dev/null \ + | xargs -0 stat -f %m 2>/dev/null \ + | sort -n \ + | tail -1 || true)" + if [[ -z "$newest" ]]; then + newest="$(find "$path" -type f -not -path '*/node_modules/*' -not -path '*/dist/*' -printf '%T@\n' 2>/dev/null \ + | sort -n \ + | tail -1 \ + | cut -d. -f1 || true)" + fi + note_epoch "files:$path" "$newest" + fi +done + +age=$((now_epoch - latest_epoch)) +status="progressing" +exit_code=0 +if [[ "$latest_epoch" -eq 0 || "$age" -gt "$STALE_SECS" ]]; then + status="stuck" + exit_code=2 +fi + +current_branch="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)" +head_sha="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)" +summary=$(cat <&2 +fi +exit "$exit_code" From e3ac65d8e5c31f4e00a5f64a8a83dab148817017 Mon Sep 17 00:00:00 2001 From: soyboyscout Date: Sat, 29 Aug 2026 17:34:33 -0400 Subject: [PATCH 2/8] Add DeepSeek Harness adapter package and register deepseek_local. Phase 1 lands a hireable JSON-RPC adapter with session resume, API-key env tests, and host list membership. Runtime install stays honest: the JSON-RPC bin alone is not enough without a harness plugin tree. Co-authored-by: Cursor --- cli/package.json | 1 + cli/src/adapters/registry.ts | 7 + .../2026-08-29-deepseek-harness-adapter.md | 16 +- .../adapter-utils/src/session-compaction.ts | 6 + packages/adapters/deepseek-harness/README.md | 28 ++ .../adapters/deepseek-harness/package.json | 72 ++++ .../deepseek-harness/src/cli/format-event.ts | 26 ++ .../deepseek-harness/src/cli/index.ts | 1 + .../deepseek-harness/src/index.test.ts | 17 + .../adapters/deepseek-harness/src/index.ts | 117 ++++++ .../src/server/config-schema.ts | 85 ++++ .../src/server/execute.test.ts | 120 ++++++ .../deepseek-harness/src/server/execute.ts | 388 ++++++++++++++++++ .../deepseek-harness/src/server/index.ts | 6 + .../src/server/jsonrpc-client.ts | 203 +++++++++ .../src/server/jsonrpc-runtime.ts | 159 +++++++ .../src/server/mock-jsonrpc-runtime.mjs | 74 ++++ .../src/server/paperclip.cordis.yml | 91 ++++ .../deepseek-harness/src/server/parse.test.ts | 61 +++ .../deepseek-harness/src/server/parse.ts | 64 +++ .../deepseek-harness/src/server/protocol.ts | 130 ++++++ .../src/server/runtime-config.ts | 102 +++++ .../src/server/session.test.ts | 60 +++ .../deepseek-harness/src/server/session.ts | 71 ++++ .../deepseek-harness/src/server/test.test.ts | 31 ++ .../deepseek-harness/src/server/test.ts | 233 +++++++++++ .../src/server/wait-for-turn.ts | 90 ++++ .../deepseek-harness/src/shared/constants.ts | 12 + .../deepseek-harness/src/ui-parser.ts | 5 + .../deepseek-harness/src/ui/build-config.ts | 19 + .../adapters/deepseek-harness/src/ui/index.ts | 2 + .../deepseek-harness/src/ui/parse-stdout.ts | 27 ++ .../adapters/deepseek-harness/tsconfig.json | 10 + .../deepseek-harness/vitest.config.ts | 8 + packages/shared/src/constants.ts | 1 + server/package.json | 1 + server/src/adapters/builtin-adapter-types.ts | 1 + server/src/adapters/registry.ts | 4 + server/src/services/heartbeat.ts | 2 + server/src/services/recovery/service.ts | 1 + tsconfig.json | 1 + ui/package.json | 1 + .../adapters/adapter-display-registry.test.ts | 2 + ui/src/adapters/adapter-display-registry.ts | 6 + ui/src/adapters/deepseek-local/index.ts | 11 + ui/src/adapters/registry.ts | 2 + ui/src/adapters/use-adapter-capabilities.ts | 1 + 47 files changed, 2373 insertions(+), 3 deletions(-) create mode 100644 packages/adapters/deepseek-harness/README.md create mode 100644 packages/adapters/deepseek-harness/package.json create mode 100644 packages/adapters/deepseek-harness/src/cli/format-event.ts create mode 100644 packages/adapters/deepseek-harness/src/cli/index.ts create mode 100644 packages/adapters/deepseek-harness/src/index.test.ts create mode 100644 packages/adapters/deepseek-harness/src/index.ts create mode 100644 packages/adapters/deepseek-harness/src/server/config-schema.ts create mode 100644 packages/adapters/deepseek-harness/src/server/execute.test.ts create mode 100644 packages/adapters/deepseek-harness/src/server/execute.ts create mode 100644 packages/adapters/deepseek-harness/src/server/index.ts create mode 100644 packages/adapters/deepseek-harness/src/server/jsonrpc-client.ts create mode 100644 packages/adapters/deepseek-harness/src/server/jsonrpc-runtime.ts create mode 100644 packages/adapters/deepseek-harness/src/server/mock-jsonrpc-runtime.mjs create mode 100644 packages/adapters/deepseek-harness/src/server/paperclip.cordis.yml create mode 100644 packages/adapters/deepseek-harness/src/server/parse.test.ts create mode 100644 packages/adapters/deepseek-harness/src/server/parse.ts create mode 100644 packages/adapters/deepseek-harness/src/server/protocol.ts create mode 100644 packages/adapters/deepseek-harness/src/server/runtime-config.ts create mode 100644 packages/adapters/deepseek-harness/src/server/session.test.ts create mode 100644 packages/adapters/deepseek-harness/src/server/session.ts create mode 100644 packages/adapters/deepseek-harness/src/server/test.test.ts create mode 100644 packages/adapters/deepseek-harness/src/server/test.ts create mode 100644 packages/adapters/deepseek-harness/src/server/wait-for-turn.ts create mode 100644 packages/adapters/deepseek-harness/src/shared/constants.ts create mode 100644 packages/adapters/deepseek-harness/src/ui-parser.ts create mode 100644 packages/adapters/deepseek-harness/src/ui/build-config.ts create mode 100644 packages/adapters/deepseek-harness/src/ui/index.ts create mode 100644 packages/adapters/deepseek-harness/src/ui/parse-stdout.ts create mode 100644 packages/adapters/deepseek-harness/tsconfig.json create mode 100644 packages/adapters/deepseek-harness/vitest.config.ts create mode 100644 ui/src/adapters/deepseek-local/index.ts diff --git a/cli/package.json b/cli/package.json index 4113ed4d45ec..67884c7ec30a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -41,6 +41,7 @@ "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-cloud": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", + "@paperclipai/adapter-deepseek-harness": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-grok-local": "workspace:*", "@paperclipai/adapter-kimi-local": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 7dfa12db95ae..0bef04d4a5ef 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -10,6 +10,7 @@ import { formatStdoutEvent as printHermesGatewayStreamEvent } from "@paperclipai import { printHermesStreamEvent } from "@paperclipai/hermes-paperclip-adapter/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; +import { printDeepseekStreamEvent } from "@paperclipai/adapter-deepseek-harness/cli"; import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; import { processCLIAdapter } from "./process/index.js"; import { httpCLIAdapter } from "./http/index.js"; @@ -34,6 +35,11 @@ const piLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printPiStreamEvent, }; +const deepseekLocalCLIAdapter: CLIAdapterModule = { + type: "deepseek_local", + formatStdoutEvent: printDeepseekStreamEvent, +}; + const cursorLocalCLIAdapter: CLIAdapterModule = { type: "cursor", formatStdoutEvent: printCursorStreamEvent, @@ -80,6 +86,7 @@ const adaptersByType = new Map( codexLocalCLIAdapter, openCodeLocalCLIAdapter, piLocalCLIAdapter, + deepseekLocalCLIAdapter, cursorLocalCLIAdapter, cursorCloudCLIAdapter, geminiLocalCLIAdapter, diff --git a/doc/plans/2026-08-29-deepseek-harness-adapter.md b/doc/plans/2026-08-29-deepseek-harness-adapter.md index 2bf397bd98f2..01a76d15879b 100644 --- a/doc/plans/2026-08-29-deepseek-harness-adapter.md +++ b/doc/plans/2026-08-29-deepseek-harness-adapter.md @@ -134,7 +134,7 @@ Prefer a follow-up (not blocking V1) that replaces these lists with `ServerAdapt ## 5. Locked decisions -1. **Primary execute path** is a Node JSON-RPC client talking to a `dsh-jsonrpc-agent` (or `node` + `@deepseek-ai/dsh-sdk-jsonrpc-server` composition) subprocess. Prefer depending on published `@deepseek-ai/dsh-sdk-client` + `@deepseek-ai/dsh-sdk-protocol` if their license and publish cadence are acceptable; otherwise copy only the wire types and write a thin NDJSON client in the adapter (do not import the DeepSeek monorepo). +1. **Primary execute path** is a Paperclip-owned NDJSON JSON-RPC client talking to a `dsh-jsonrpc-agent` subprocess. Published `@deepseek-ai/dsh-sdk-client` / `@deepseek-ai/dsh-sdk-protocol` (`0.1.1-rc.2`) are independently installable on npm (peers rewritten off `workspace:^`), but they pull `dsh-llm` / `dsh-session` / `dsh-subagent` / `cordis` types into Paperclip. Own the wire client for isolation. Match `HarnessSession.run`: subscribe → `session/prompt` → wait for `agent/inbox/spliced` with `inserted[].id === messageId` → collect until `session.status === idle`. Do not copy the Phase 0 spike’s `waitForIdle`. 2. **Paperclip-owned Cordis composition** ships inside the adapter (`src/server/paperclip.cordis.yml`). It must include: JSON-RPC server, DeepSeek official provider, coding tools (bash + filesystem edit, not the `minimal` two-tool preset), skill registry + filesystem provider, unattended approval policy, JSONL persistence, compaction. It must not include web/TUI/stdout logger. 3. **Session identity** is a Paperclip-generated id stored in `agentTaskSessions.session_params_json`. The adapter always sends that id on `session/prompt`. `dsh` lazily creates the agent+session pair. 4. **Session root** is Paperclip-managed (`$PAPERCLIP_HOME/adapter-state///deepseek/sessions` or the execution-target equivalent), passed as `DSH_SESSION_ROOT`. Do not write into the operator’s interactive `~/.dsh` session store by default. @@ -444,10 +444,10 @@ PR template must include Thinking Path, What Changed, Verification, Risks, Model Closed from `/Users/mikestaub/code/codecrafters/ai-coding-tools/deepseek-harness` at adapter-plan time. dsh version in-tree: `0.1.1-rc.2`. 1. **Default model id:** `deepseek-v4-flash`. Confirmed in `python/sdk/README.md`, `packages/web/web-search-deepseek/src/provider.ts` (`DEEPSEEK_DEFAULT_MODEL`), and workflow tests. Pro / reasoner ids such as `deepseek-v4-pro` exist; cheap profile stays on flash. -2. **Published SDK vs owned client:** `@deepseek-ai/dsh-sdk-client` and `@deepseek-ai/dsh-sdk-protocol` exist (`0.1.1-rc.2`) but declare `workspace:^` peerDependencies on `@deepseek-ai/dsh-llm`, `@deepseek-ai/dsh-session`, `@deepseek-ai/dsh-subagent`, and `@deepseek-ai/cordis`. They are **not** independently usable from Paperclip’s workspace. **Decision: own thin NDJSON JSON-RPC client** (`src/server/jsonrpc-client.ts`). Do not depend on `@deepseek-ai/dsh-*`. +2. **Published SDK vs owned client:** In-tree package.json uses `workspace:^` peers; published npm `0.1.1-rc.2` rewrites those to registry ranges, so the SDK **is** independently installable. **Decision remains: own thin NDJSON client** so Paperclip does not take a type dependency on `dsh-llm` / `dsh-session` / `cordis`. Isolation, not “unpublished peers.” 3. **TokenUsage → UsageSummary:** `packages/llm/llm/src/types.ts` — `{ inputTokens, outputTokens, cacheReadTokens?, cacheWriteTokens?, reasoningTokens? }`. Counts are disjoint: `inputTokens` is uncached input only. Map `cacheReadTokens` → `cachedInputTokens`. Ignore `cacheWriteTokens` / `reasoningTokens` in V1 totals (they are not Paperclip `UsageSummary` fields). 4. **Unattended policy:** mount `@deepseek-ai/dsh-sandbox-policy` with `mode: danger-full-access` (default is fail-safe `read-only`). The shipped `examples/jsonrpc-agent/cordis.yml` does not mount this plugin; Paperclip’s composition must, so heartbeats do not block on file/bash approval. There is no separate Claude-style `--dangerously-skip-permissions` flag. -5. **Runtime install:** the JSON-RPC bin is `dsh-jsonrpc-agent` from `@deepseek-ai/dsh-sdk-jsonrpc-demo` (`packages/examples/jsonrpc-demo`, bin `dsh-jsonrpc-agent`). Product CLI is `@deepseek-ai/dsh` (`dsh`). Python production carrier is `deepseek-harness-runtime-bin` (`dsh-jsonrpc-agent`). Sandbox `installCommand` (best effort): `npm install -g @deepseek-ai/dsh-sdk-jsonrpc-demo@0.1.1-rc.2`. Prefer detecting an already-installed `dsh-jsonrpc-agent` or repo-local bin. Always pass `DSH_CORDIS_CONFIG` — the runtime exits without it. +5. **Runtime install:** `dsh-jsonrpc-agent` is the JSON-RPC bin (`@deepseek-ai/dsh-sdk-jsonrpc-demo`). It **does not** ship the plugin tree; it boots `$DSH_CORDIS_CONFIG` / argv and resolves bare plugins from the **configuration project**. `npm install -g @deepseek-ai/dsh-sdk-jsonrpc-demo` alone is not a working runtime. Operators need a DeepSeek Harness install (source checkout, `@deepseek-ai/dsh`, or the Python `deepseek-harness-runtime-bin` closed exe). Paperclip ships `paperclip.cordis.yml` and sets `NODE_PATH` to `harnessRoot/node_modules` when `adapterConfig.harnessRoot` is set. Always pass `DSH_CORDIS_CONFIG`. `getRuntimeCommandSpec.installCommand` stays `null` unless a closed runtime bin is on PATH. **SDK client decision recorded:** own protocol types + NDJSON client. Pin compatibility notes to dsh `0.1.1-rc.2` wire: `initialize`, `session/prompt`, `shutdown` + notifications `session.event`, `session.status`, `subagent.started`, `subagent.finished`. `serverInfo.name` is `deepseek-harness-sdk-runtime`. @@ -456,3 +456,13 @@ Closed from `/Users/mikestaub/code/codecrafters/ai-coding-tools/deepseek-harness ### Watchdog `scripts/monitor-deepseek-adapter-session.sh` checks git/file/heartbeat freshness. A 20-minute agent loop ticks `AGENT_LOOP_TICK_deepseek_adapter`. Heartbeat file: `.ai/deepseek-adapter-session/heartbeat.json`. + +## 19. Phase 1 results (2026-08-29) + +Shipped `@paperclipai/adapter-deepseek-harness` and registered `deepseek_local` as a built-in. + +- Owned NDJSON client + `subscribe` **before** `session/prompt`, then inbox receipt (`agent/inbox/spliced` + `inserted[].id === messageId`) then `session.status === idle`. +- `installCommand` is `null`. README documents the plugin+cordis+`harnessRoot` closure. +- Host lists updated for hire/wake/session resume (not remote-managed; that is Phase 3). +- Env test fails closed without `DEEPSEEK_API_KEY`. Hello probe is local-only. +- Tests use a protocol-accurate mock runtime. Live `DEEPSEEK_API_KEY` heartbeat was not run. diff --git a/packages/adapter-utils/src/session-compaction.ts b/packages/adapter-utils/src/session-compaction.ts index 3764155c406f..2d8f9aba8190 100644 --- a/packages/adapter-utils/src/session-compaction.ts +++ b/packages/adapter-utils/src/session-compaction.ts @@ -41,6 +41,7 @@ export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ "codex_local", "cursor_cloud", "cursor", + "deepseek_local", "gemini_local", "hermes_local", "kimi_local", @@ -69,6 +70,11 @@ export const ADAPTER_SESSION_MANAGEMENT: Record/node_modules` can load plugins +3. Paperclip's shipped `paperclip.cordis.yml` (or `adapterConfig.cordisConfigPath`) + +Closed alternative: the Python `deepseek-harness-runtime-bin` executable. + +Do not add a stdout logger to the Cordis composition. Stdout is the protocol. + +## Auth + +- `DEEPSEEK_API_KEY` (required) +- `DEEPSEEK_BASE_URL` (optional) + +There is no Claude `setup-token` login. ACP is not advertised: dsh ACP is fresh-session-only and keeps tools off the wire. + +## Sessions + +Paperclip generates a `sessionId` and reuses it on `session/prompt`. Session files live under `$PAPERCLIP_HOME/adapter-state///deepseek/sessions` (`DSH_SESSION_ROOT`), not `~/.dsh`. diff --git a/packages/adapters/deepseek-harness/package.json b/packages/adapters/deepseek-harness/package.json new file mode 100644 index 000000000000..f87fb4fa1a9a --- /dev/null +++ b/packages/adapters/deepseek-harness/package.json @@ -0,0 +1,72 @@ +{ + "name": "@paperclipai/adapter-deepseek-harness", + "version": "0.3.1", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "packages/adapters/deepseek-harness" + }, + "description": "Paperclip adapter for DeepSeek Harness over JSON-RPC stdio", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts", + "./ui-parser": "./src/ui-parser.ts" + }, + "paperclip": { + "adapterUiParser": "1.0.0" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + }, + "./ui-parser": "./dist/ui-parser.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc && cp src/server/paperclip.cordis.yml dist/server/paperclip.cordis.yml", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.7.3", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=24.11.0" + } +} diff --git a/packages/adapters/deepseek-harness/src/cli/format-event.ts b/packages/adapters/deepseek-harness/src/cli/format-event.ts new file mode 100644 index 000000000000..c7bf65d5d13b --- /dev/null +++ b/packages/adapters/deepseek-harness/src/cli/format-event.ts @@ -0,0 +1,26 @@ +import pc from "picocolors"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +export function printDeepseekStreamEvent(raw: string, _debug: boolean): void { + const line = raw.trim(); + if (!line) return; + const parsed = asRecord(safeJsonParse(line)); + if (!parsed || parsed.paperclipDeepseek !== 1) { + console.log(line); + return; + } + const method = typeof parsed.method === "string" ? parsed.method : "event"; + console.log(pc.cyan(`deepseek ${method}`)); +} diff --git a/packages/adapters/deepseek-harness/src/cli/index.ts b/packages/adapters/deepseek-harness/src/cli/index.ts new file mode 100644 index 000000000000..efe78f4aa632 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/cli/index.ts @@ -0,0 +1 @@ +export { printDeepseekStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/deepseek-harness/src/index.test.ts b/packages/adapters/deepseek-harness/src/index.test.ts new file mode 100644 index 000000000000..655e4aaea73c --- /dev/null +++ b/packages/adapters/deepseek-harness/src/index.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { createServerAdapter, type, label } from "./index.js"; + +describe("createServerAdapter", () => { + it("exports a hireable deepseek_local module", () => { + const adapter = createServerAdapter(); + expect(type).toBe("deepseek_local"); + expect(label).toBe("DeepSeek Harness"); + expect(adapter.type).toBe("deepseek_local"); + expect(adapter.supportsLocalAgentJwt).toBe(true); + expect(adapter.requiresMaterializedRuntimeSkills).toBe(true); + expect(adapter.sessionCodec).toBeDefined(); + expect(adapter.getRuntimeCommandSpec?.({})?.installCommand).toBeNull(); + expect(adapter.loginCapability).toBeUndefined(); + expect(adapter.acp).toBeUndefined(); + }); +}); diff --git a/packages/adapters/deepseek-harness/src/index.ts b/packages/adapters/deepseek-harness/src/index.ts new file mode 100644 index 000000000000..f3afcdc1f416 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/index.ts @@ -0,0 +1,117 @@ +import type { + AdapterModelProfileDefinition, + AdapterRuntimeCommandSpec, + AdapterSessionManagement, + ServerAdapterModule, +} from "@paperclipai/adapter-utils"; +import { ADAPTER_LABEL, ADAPTER_TYPE, DEFAULT_MODEL, DSH_COMPAT_VERSION } from "./shared/constants.js"; +import { execute, getConfigSchema, sessionCodec, testEnvironment } from "./server/index.js"; +import { resolveDeepseekCommand } from "./server/runtime-config.js"; + +export const type = ADAPTER_TYPE; +export const label = ADAPTER_LABEL; + +export const models: Array<{ id: string; label: string }> = [ + { id: DEFAULT_MODEL, label: "DeepSeek V4 Flash" }, + { id: "deepseek-v4-pro", label: "DeepSeek V4 Pro" }, +]; + +export const modelProfiles: AdapterModelProfileDefinition[] = [ + { + key: "cheap", + label: "Cheap", + description: "Flash model for low-cost heartbeats", + adapterConfig: { model: DEFAULT_MODEL }, + source: "adapter_default", + }, +]; + +const sessionManagement: AdapterSessionManagement = { + supportsSessionResume: true, + nativeContextManagement: "confirmed", + defaultSessionCompaction: { + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, + }, +}; + +function getRuntimeCommandSpec(config: Record): AdapterRuntimeCommandSpec { + const command = resolveDeepseekCommand(config); + return { + command, + detectCommand: command, + installCommand: null, + }; +} + +export const agentConfigurationDoc = `# DeepSeek Harness agent configuration + +Adapter: deepseek_local + +Use when: +- You want Paperclip to run [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) as a local coding agent +- You need session resume across heartbeats over the JSON-RPC stdio SDK +- You have a DeepSeek API key (\`DEEPSEEK_API_KEY\`) + +Don't use when: +- You only need a raw DeepSeek chat-completions wrapper +- You expected Claude setup-token OAuth or ACP (this adapter does not advertise ACP) +- The host only has \`npm i -g @deepseek-ai/dsh-sdk-jsonrpc-demo\` — that bin does not ship the plugin tree + +## Runtime + +Paperclip talks to \`dsh-jsonrpc-agent\` over NDJSON JSON-RPC (\`initialize\`, \`session/prompt\`, \`shutdown\`). +Compatible with DeepSeek Harness \`${DSH_COMPAT_VERSION}\`. + +The JSON-RPC bin boots \`$DSH_CORDIS_CONFIG\` and resolves bare \`@deepseek-ai/dsh-*\` plugins from the **configuration project** / \`NODE_PATH\`. A working runtime is: + +1. \`dsh-jsonrpc-agent\` on PATH (or \`adapterConfig.command\`) +2. A DeepSeek Harness install (\`adapterConfig.harnessRoot\` or \`DSH_HARNESS_ROOT\`) +3. Paperclip's shipped \`paperclip.cordis.yml\` (or \`cordisConfigPath\`) + +Do not add a stdout logger to the Cordis composition. Stdout is the protocol. + +## Auth + +- \`DEEPSEEK_API_KEY\` (required) +- \`DEEPSEEK_BASE_URL\` (optional OpenAI-compatible proxy) + +There is no \`claude setup-token\` login flow. + +## Core fields + +| Field | Default | Notes | +|-------|---------|-------| +| model | deepseek-v4-flash | JSON-RPC \`initialize.model\` | +| provider | deepseek-official | JSON-RPC \`initialize.provider\` | +| harnessRoot | — | Install root used for \`NODE_PATH=/node_modules\` | +| command | dsh-jsonrpc-agent | JSON-RPC stdio bin | +| persistSession | true | Reuse \`sessionId\` across heartbeats | +| cwd | workspace cwd | Absolute fallback | +| timeoutSec | 0 | Wall-clock kill; 0 disables | +| graceSec | 15 | SIGTERM grace | +| env | {} | Secret refs allowed | + +Sessions are stored under \`$PAPERCLIP_HOME/adapter-state///deepseek/sessions\` (\`DSH_SESSION_ROOT\`), not \`~/.dsh\`. +`; + +export function createServerAdapter(): ServerAdapterModule { + return { + type, + execute, + testEnvironment, + sessionCodec, + sessionManagement, + models, + modelProfiles, + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, + getRuntimeCommandSpec, + agentConfigurationDoc, + getConfigSchema, + }; +} diff --git a/packages/adapters/deepseek-harness/src/server/config-schema.ts b/packages/adapters/deepseek-harness/src/server/config-schema.ts new file mode 100644 index 000000000000..9e3ef7c44121 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/config-schema.ts @@ -0,0 +1,85 @@ +import type { AdapterConfigSchema } from "@paperclipai/adapter-utils"; +import { + DEFAULT_GRACE_SEC, + DEFAULT_MODEL, + DEFAULT_PROVIDER, + DEFAULT_TIMEOUT_SEC, +} from "../shared/constants.js"; + +export function getConfigSchema(): AdapterConfigSchema { + return { + fields: [ + { + key: "model", + label: "Model", + type: "text", + default: DEFAULT_MODEL, + hint: "DeepSeek model id. Default is deepseek-v4-flash.", + }, + { + key: "provider", + label: "Provider", + type: "text", + default: DEFAULT_PROVIDER, + hint: "JSON-RPC initialize.provider. Default is deepseek-official.", + }, + { + key: "harnessRoot", + label: "Harness install root", + type: "text", + hint: "Path to a DeepSeek Harness checkout or install. Used to resolve plugins via NODE_PATH=/node_modules.", + }, + { + key: "command", + label: "JSON-RPC command", + type: "text", + hint: "Defaults to dsh-jsonrpc-agent. The bin does not ship the plugin tree; pair it with harnessRoot or a closed runtime.", + }, + { + key: "cordisConfigPath", + label: "Cordis config path", + type: "text", + hint: "Override DSH_CORDIS_CONFIG. Defaults to the shipped Paperclip composition.", + }, + { + key: "cwd", + label: "Working directory", + type: "text", + hint: "Absolute fallback cwd when the heartbeat workspace is unset.", + }, + { + key: "persistSession", + label: "Persist session", + type: "toggle", + default: true, + hint: "Reuse the same DeepSeek sessionId across heartbeats.", + }, + { + key: "timeoutSec", + label: "Timeout seconds", + type: "number", + default: DEFAULT_TIMEOUT_SEC, + hint: "0 means no Paperclip wall-clock timeout.", + }, + { + key: "graceSec", + label: "Grace seconds", + type: "number", + default: DEFAULT_GRACE_SEC, + hint: "Seconds to wait after SIGTERM before SIGKILL.", + }, + { + key: "maxTokens", + label: "Max tokens", + type: "number", + hint: "Optional initialize.maxTokens output cap.", + }, + { + key: "promptTemplate", + label: "Prompt template", + type: "textarea", + hint: "Optional custom prompt template with {{variable}} placeholders.", + }, + ], + }; +} diff --git a/packages/adapters/deepseek-harness/src/server/execute.test.ts b/packages/adapters/deepseek-harness/src/server/execute.test.ts new file mode 100644 index 000000000000..8c834f7db728 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/execute.test.ts @@ -0,0 +1,120 @@ +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; +import { execute } from "./execute.js"; + +const mockRuntime = fileURLToPath(new URL("./mock-jsonrpc-runtime.mjs", import.meta.url)); +const tempDirs: string[] = []; +const originalHome = process.env.PAPERCLIP_HOME; + +beforeEach(async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "dsh-home-")); + tempDirs.push(home); + process.env.PAPERCLIP_HOME = home; +}); + +afterEach(async () => { + if (originalHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = originalHome; + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +async function makeCtx(overrides: { + sessionParams?: Record | null; + cwd?: string; + extra?: Record; +} = {}): Promise { + const cwd = overrides.cwd ?? await fs.mkdtemp(path.join(os.tmpdir(), "dsh-exec-")); + tempDirs.push(cwd); + const logs: string[] = []; + return { + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "DeepSeek", + adapterType: "deepseek_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: overrides.sessionParams ?? null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: process.execPath, + extraArgs: [mockRuntime], + cwd, + persistSession: true, + timeoutSec: 10, + graceSec: 2, + env: { DEEPSEEK_API_KEY: "test-key" }, + ...overrides.extra, + }, + context: {}, + onLog: async (_stream, chunk) => { + logs.push(chunk); + }, + logs, + } as AdapterExecutionContext & { logs: string[] }; +} + +describe("execute", () => { + it("completes a JSON-RPC turn and persists sessionId/cwd/sessionRoot", async () => { + const ctx = await makeCtx(); + const result = await execute(ctx); + expect(result.exitCode).toBe(0); + expect(result.usage).toEqual({ inputTokens: 22, outputTokens: 8, cachedInputTokens: 4 }); + expect(result.usageBasis).toBe("per_run"); + expect(result.summary).toMatch(/^ack /); + expect(result.sessionId).toMatch(/^paperclip-dsh-/); + expect(result.sessionParams).toMatchObject({ + sessionId: result.sessionId, + cwd: ctx.config.cwd, + }); + expect(typeof result.sessionParams?.sessionRoot).toBe("string"); + }); + + it("resumes the same sessionId on a second heartbeat", async () => { + const firstCtx = await makeCtx(); + const first = await execute(firstCtx); + const secondCtx = await makeCtx({ + cwd: String(firstCtx.config.cwd), + sessionParams: first.sessionParams ?? null, + }); + const second = await execute(secondCtx); + expect(second.sessionId).toBe(first.sessionId); + expect(second.exitCode).toBe(0); + }); + + it("does not resume when cwd changed", async () => { + const firstCtx = await makeCtx(); + const first = await execute(firstCtx); + const secondCtx = await makeCtx({ + sessionParams: first.sessionParams ?? null, + }); + const second = await execute(secondCtx); + expect(second.sessionId).not.toBe(first.sessionId); + }); + + it("retries with clearSession when the runtime rejects the session", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dsh-exec-")); + tempDirs.push(cwd); + const sessionRoot = path.join(cwd, "sessions"); + await fs.mkdir(sessionRoot, { recursive: true }); + const ctx = await makeCtx({ + cwd, + sessionParams: { sessionId: "stale-old", cwd, sessionRoot }, + extra: { sessionRoot }, + }); + const result = await execute(ctx); + expect(result.exitCode).toBe(0); + expect(result.clearSession).toBe(true); + expect(result.sessionId).not.toBe("stale-old"); + expect(result.sessionId).toMatch(/^paperclip-dsh-/); + }); +}); diff --git a/packages/adapters/deepseek-harness/src/server/execute.ts b/packages/adapters/deepseek-harness/src/server/execute.ts new file mode 100644 index 000000000000..81bb170032ef --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/execute.ts @@ -0,0 +1,388 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + adapterExecutionTargetIsRemote, + readAdapterExecutionTarget, +} from "@paperclipai/adapter-utils/execution-target"; +import { + asNumber, + asString, + asStringArray, + buildInvocationEnvForLogs, + buildPaperclipEnv, + ensureAbsoluteDirectory, + joinPromptSections, + parseObject, + renderPaperclipWakePrompt, + renderTemplate, + isPaperclipRecoveryWakePayload, + stringifyPaperclipWakePayload, + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, +} from "@paperclipai/adapter-utils/server-utils"; +import { ADAPTER_TYPE } from "../shared/constants.js"; +import { classifyDeepseekError, isUnknownSessionError } from "./protocol.js"; +import { parseTurnNotifications } from "./parse.js"; +import { canResumeDeepseekSession, sessionCodec } from "./session.js"; +import { + buildRuntimeNodePath, + persistSessionEnabled, + resolveCordisConfigPath, + resolveDeepseekCommand, + resolveDeepseekModel, + resolveDeepseekProvider, + resolveDeepseekSessionRoot, + resolveGraceSec, + resolveHarnessRoot, + resolveTimeoutSec, +} from "./runtime-config.js"; +import { + formatRpcError, + initializeRuntime, + mintSessionId, + promptAndWait, + spawnDeepseekRuntime, +} from "./jsonrpc-runtime.js"; +import type { JsonRpcNotification } from "./jsonrpc-client.js"; + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; + const executionTarget = readAdapterExecutionTarget({ + executionTarget: ctx.executionTarget, + legacyRemoteExecution: ctx.executionTransport?.remoteExecution, + }); + if (adapterExecutionTargetIsRemote(executionTarget)) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: + "deepseek_local remote/SSH/sandbox execution is not implemented in Phase 1. Use a local execution target.", + errorCode: "remote_not_implemented", + }; + } + + const workspaceContext = parseObject(context.paperclipWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const workspaceSource = asString(workspaceContext.source, ""); + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const cwd = (useConfiguredInsteadOfAgentHome ? "" : workspaceCwd) || configuredCwd || process.cwd(); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + + const envConfig = parseObject(config.env); + const env: Record = { ...buildPaperclipEnv(agent) }; + env.PAPERCLIP_RUN_ID = runId; + if (authToken) env.PAPERCLIP_API_KEY = authToken; + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + + const wakeTaskId = + asString(context.taskId, "") || asString(context.issueId, "") || null; + if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + + const sessionRoot = resolveDeepseekSessionRoot({ + companyId: agent.companyId, + agentId: agent.id, + sessionRoot: asString(config.sessionRoot, ""), + }); + await fs.mkdir(sessionRoot, { recursive: true }); + + const command = resolveDeepseekCommand(config, env); + const harnessRoot = resolveHarnessRoot(config, env); + const cordisConfigPath = resolveCordisConfigPath(config, env); + const model = resolveDeepseekModel(config, env); + const provider = resolveDeepseekProvider(config); + const timeoutSec = resolveTimeoutSec(config); + const graceSec = resolveGraceSec(config); + const maxTokens = asNumber(config.maxTokens, 0); + + env.DSH_CWD = cwd; + env.DSH_SESSION_ROOT = sessionRoot; + env.DSH_MODEL = model; + env.DSH_CORDIS_CONFIG = cordisConfigPath; + const nodePath = buildRuntimeNodePath(harnessRoot, env.NODE_PATH ?? process.env.NODE_PATH); + if (nodePath) env.NODE_PATH = nodePath; + + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + if (instructionsFilePath) { + try { + env.DSH_SYSTEM_PROMPT = await fs.readFile(instructionsFilePath, "utf8"); + } catch { + await onLog( + "stderr", + `[paperclip] DeepSeek instructions file was missing: ${instructionsFilePath}\n`, + ); + } + } + + const runtimeSession = sessionCodec.deserialize(runtime.sessionParams) ?? {}; + const savedSessionId = asString(runtimeSession.sessionId, runtime.sessionId ?? ""); + const savedCwd = asString(runtimeSession.cwd, ""); + const savedSessionRoot = asString(runtimeSession.sessionRoot, ""); + const persist = persistSessionEnabled(config); + const canResume = + persist && + canResumeDeepseekSession({ + sessionId: savedSessionId || null, + savedCwd: savedCwd || null, + savedSessionRoot: savedSessionRoot || null, + cwd, + sessionRoot, + }); + let sessionId = canResume ? savedSessionId : persist ? mintSessionId() : mintSessionId(); + if (savedSessionId && !canResume) { + await onLog( + "stdout", + `[paperclip] DeepSeek session "${savedSessionId}" does not match cwd/sessionRoot and will not be resumed.\n`, + ); + } + + const promptTemplate = asString(config.promptTemplate, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE); + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { + resumedSession: Boolean(canResume && savedSessionId), + }); + const renderedPrompt = + (canResume && wakePrompt.length > 0) || isPaperclipRecoveryWakePayload(context.paperclipWake) + ? "" + : renderTemplate(promptTemplate, { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }); + const prompt = joinPromptSections([ + wakePrompt, + asString(context.paperclipSessionHandoffMarkdown, "").trim(), + renderedPrompt, + ]); + + const runtimeEnv = Object.fromEntries( + Object.entries({ ...process.env, ...env }).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + + if (onMeta) { + await onMeta({ + adapterType: ADAPTER_TYPE, + command, + cwd, + commandNotes: [ + "Drives DeepSeek Harness over JSON-RPC stdio (initialize + session/prompt).", + `Cordis config: ${cordisConfigPath}`, + harnessRoot ? `Harness root / NODE_PATH: ${harnessRoot}` : "No harnessRoot set; plugins must resolve from the configuration project.", + "Do not add a stdout logger to the Cordis composition.", + ], + env: buildInvocationEnvForLogs(env, { runtimeEnv }), + prompt, + promptMetrics: { + promptChars: prompt.length, + wakePromptChars: wakePrompt.length, + heartbeatPromptChars: renderedPrompt.length, + }, + context: { sessionId, sessionRoot, model, provider }, + }); + } + + const timeoutMs = timeoutSec > 0 ? timeoutSec * 1000 : 30 * 60 * 1000; + let timedOut = false; + let clearSession = false; + let exitCode: number | null = 0; + let signal: string | null = null; + let errorMessage: string | null = null; + + const runtimeHandle = await spawnDeepseekRuntime({ + command, + args: asStringArray(config.extraArgs), + cwd, + env: runtimeEnv, + onStderr: (chunk) => onLog("stderr", chunk), + onSpawn, + }); + + const emitNotification = async (notification: JsonRpcNotification) => { + await onLog("stdout", `${JSON.stringify({ paperclipDeepseek: 1, ...notification })}\n`); + }; + + try { + await initializeRuntime(runtimeHandle.client, { + cwd, + provider, + model, + ...(maxTokens > 0 ? { maxTokens } : {}), + }); + + const runPrompt = async (id: string) => + promptAndWait({ + client: runtimeHandle.client, + sessionId: id, + prompt, + timeoutMs, + onNotification: emitNotification, + }); + + try { + const turn = await runPrompt(sessionId); + const parsed = parseTurnNotifications(turn.notifications); + errorMessage = parsed.errorMessage; + if (parsed.unknownSession) { + clearSession = true; + sessionId = mintSessionId(); + await onLog("stdout", `[paperclip] DeepSeek session was unknown; retrying with a fresh session.\n`); + const retry = await runPrompt(sessionId); + const retryParsed = parseTurnNotifications(retry.notifications); + const closed = await runtimeHandle.close(graceSec * 1000); + return buildResult({ + sessionId, + cwd, + sessionRoot, + model, + provider, + persist, + parsed: retryParsed, + exitCode: closed.exitCode ?? (retryParsed.errorMessage ? 1 : 0), + signal: closed.signal, + timedOut: false, + clearSession: true, + }); + } + const closed = await runtimeHandle.close(graceSec * 1000); + exitCode = closed.exitCode ?? (parsed.errorMessage ? 1 : 0); + signal = closed.signal; + return buildResult({ + sessionId, + cwd, + sessionRoot, + model, + provider, + persist, + parsed, + exitCode, + signal, + timedOut: false, + clearSession: false, + }); + } catch (error) { + const message = formatRpcError(error); + if (isUnknownSessionError(message)) { + clearSession = true; + sessionId = mintSessionId(); + await onLog("stdout", `[paperclip] DeepSeek session was unknown; retrying with a fresh session.\n`); + try { + const retry = await runPrompt(sessionId); + const retryParsed = parseTurnNotifications(retry.notifications); + const closed = await runtimeHandle.close(graceSec * 1000); + return buildResult({ + sessionId, + cwd, + sessionRoot, + model, + provider, + persist, + parsed: retryParsed, + exitCode: closed.exitCode ?? (retryParsed.errorMessage ? 1 : 0), + signal: closed.signal, + timedOut: false, + clearSession: true, + }); + } catch (retryError) { + const retryMessage = formatRpcError(retryError); + const closed = await runtimeHandle.close(graceSec * 1000); + return { + exitCode: closed.exitCode ?? 1, + signal: closed.signal, + timedOut: /timed out/i.test(retryMessage), + errorMessage: retryMessage, + errorFamily: classifyDeepseekError(retryMessage), + clearSession: true, + provider: "deepseek", + model, + usageBasis: "per_run", + }; + } + } + timedOut = /timed out/i.test(message); + errorMessage = message; + const closed = await runtimeHandle.close(graceSec * 1000); + exitCode = timedOut ? null : (closed.exitCode ?? 1); + signal = closed.signal; + return { + exitCode, + signal, + timedOut, + errorMessage, + errorFamily: classifyDeepseekError(message), + clearSession, + provider: "deepseek", + model, + usageBasis: "per_run", + ...(persist + ? { + sessionId, + sessionParams: { sessionId, cwd, sessionRoot }, + sessionDisplayId: sessionId, + } + : {}), + }; + } + } catch (error) { + errorMessage = formatRpcError(error); + const closed = await runtimeHandle.close(graceSec * 1000); + return { + exitCode: closed.exitCode ?? 1, + signal: closed.signal, + timedOut: false, + errorMessage, + errorFamily: classifyDeepseekError(errorMessage), + provider: "deepseek", + model, + usageBasis: "per_run", + }; + } +} + +function buildResult(input: { + sessionId: string; + cwd: string; + sessionRoot: string; + model: string; + provider: string; + persist: boolean; + parsed: ReturnType; + exitCode: number | null; + signal: string | null; + timedOut: boolean; + clearSession: boolean; +}): AdapterExecutionResult { + return { + exitCode: input.parsed.errorMessage ? 1 : input.exitCode, + signal: input.signal, + timedOut: input.timedOut, + errorMessage: input.parsed.errorMessage, + errorFamily: input.parsed.errorFamily, + usage: input.parsed.usage, + usageBasis: "per_run", + provider: "deepseek", + model: input.model, + summary: input.parsed.summary, + clearSession: input.clearSession, + ...(input.persist + ? { + sessionId: input.sessionId, + sessionParams: { + sessionId: input.sessionId, + cwd: input.cwd, + sessionRoot: input.sessionRoot, + }, + sessionDisplayId: input.sessionId, + } + : {}), + }; +} diff --git a/packages/adapters/deepseek-harness/src/server/index.ts b/packages/adapters/deepseek-harness/src/server/index.ts new file mode 100644 index 000000000000..b4badc568c90 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/index.ts @@ -0,0 +1,6 @@ +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { sessionCodec } from "./session.js"; +export { getConfigSchema } from "./config-schema.js"; +export { parseTurnNotifications, isUnknownSessionError } from "./parse.js"; +export { resolveDeepseekCommand } from "./runtime-config.js"; diff --git a/packages/adapters/deepseek-harness/src/server/jsonrpc-client.ts b/packages/adapters/deepseek-harness/src/server/jsonrpc-client.ts new file mode 100644 index 000000000000..f558d713a7b8 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/jsonrpc-client.ts @@ -0,0 +1,203 @@ +import { createInterface, type Interface } from "node:readline"; +import type { Readable, Writable } from "node:stream"; +import { PROTOCOL_NOTIFICATIONS } from "./protocol.js"; + +export type JsonRpcNotification = { + method: string; + params: Record; +}; + +export type NotificationFilter = (notification: JsonRpcNotification) => boolean; + +type Pending = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +}; + +export class JsonRpcResponseError extends Error { + readonly code?: number; + readonly data?: unknown; + + constructor(error: { code?: number; message?: string; data?: unknown }) { + super(error.message || `JSON-RPC error ${error.code ?? "unknown"}`); + this.name = "JsonRpcResponseError"; + this.code = error.code; + this.data = error.data; + } +} + +export class NotificationSubscription { + private readonly queue: JsonRpcNotification[] = []; + private readonly waiters: Array<(notification: JsonRpcNotification) => void> = []; + private failure: Error | null = null; + private closed = false; + + constructor( + readonly filter: NotificationFilter | undefined, + private readonly onClose: () => void, + ) {} + + push(notification: JsonRpcNotification) { + if (this.closed || this.failure) return; + if (this.filter && !this.filter(notification)) return; + const waiter = this.waiters.shift(); + if (waiter) waiter(notification); + else this.queue.push(notification); + } + + fail(error: Error) { + if (this.failure || this.closed) return; + this.failure = error; + this.waiters.length = 0; + } + + close() { + this.closed = true; + this.onClose(); + } + + tryNext(): JsonRpcNotification | null { + if (this.failure) throw this.failure; + return this.queue.shift() ?? null; + } + + async next(): Promise { + if (this.failure) throw this.failure; + const queued = this.queue.shift(); + if (queued) return queued; + return new Promise((resolve, reject) => { + if (this.failure) { + reject(this.failure); + return; + } + this.waiters.push(resolve); + }); + } +} + +export class JsonRpcNdjsonClient { + private nextId = 1; + private readonly pending = new Map(); + private readonly subscriptions = new Set(); + private readonly readline: Interface; + private readonly sessionParents = new Map(); + private closed = false; + + constructor( + private readonly stdin: Writable, + stdout: Readable, + ) { + this.readline = createInterface({ input: stdout }); + this.readline.on("line", (line) => this.onLine(line)); + } + + request(method: string, params?: unknown): Promise { + if (this.closed) return Promise.reject(new Error("JSON-RPC client is closed")); + const id = this.nextId++; + const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.stdin.write(payload, (error) => { + if (error) { + this.pending.delete(id); + reject(error); + } + }); + }); + } + + subscribe(filter?: NotificationFilter): NotificationSubscription { + const subscription = new NotificationSubscription(filter, () => { + this.subscriptions.delete(subscription); + }); + this.subscriptions.add(subscription); + return subscription; + } + + subscribeSessionTree(sessionId: string): NotificationSubscription { + return this.subscribe((notification) => { + const params = notification.params; + if ( + notification.method === PROTOCOL_NOTIFICATIONS.subagentStarted || + notification.method === PROTOCOL_NOTIFICATIONS.subagentFinished + ) { + const parentId = params.parentSessionId; + if (typeof parentId === "string" && this.isDescendantOf(parentId, sessionId)) return true; + return params.childSessionId === sessionId; + } + const relatedId = params.sessionId; + return typeof relatedId === "string" && this.isDescendantOf(relatedId, sessionId); + }); + } + + close() { + if (this.closed) return; + this.closed = true; + this.readline.close(); + const error = new Error("JSON-RPC client closed"); + for (const waiter of this.pending.values()) waiter.reject(error); + this.pending.clear(); + for (const subscription of this.subscriptions) subscription.fail(error); + this.subscriptions.clear(); + } + + private onLine(line: string) { + const trimmed = line.trim(); + if (!trimmed) return; + let message: Record; + try { + message = JSON.parse(trimmed) as Record; + } catch { + return; + } + + if (message.id != null && (message.result !== undefined || message.error)) { + const waiter = this.pending.get(Number(message.id)); + if (!waiter) return; + this.pending.delete(Number(message.id)); + if (message.error) { + waiter.reject(new JsonRpcResponseError(message.error as { code?: number; message?: string; data?: unknown })); + } else { + waiter.resolve(message.result); + } + return; + } + + if (typeof message.method !== "string") return; + const notification: JsonRpcNotification = { + method: message.method, + params: typeof message.params === "object" && message.params !== null + ? (message.params as Record) + : {}, + }; + this.recordSessionRelationship(notification); + for (const subscription of this.subscriptions) subscription.push(notification); + } + + private recordSessionRelationship(notification: JsonRpcNotification) { + if (notification.method !== PROTOCOL_NOTIFICATIONS.subagentStarted) return; + const parentId = notification.params.parentSessionId; + const childId = notification.params.childSessionId; + if ( + typeof parentId === "string" && + parentId && + typeof childId === "string" && + childId && + parentId !== childId + ) { + this.sessionParents.set(childId, parentId); + } + } + + private isDescendantOf(sessionId: string, rootSessionId: string): boolean { + const visited = new Set(); + let current = sessionId; + while (current) { + if (current === rootSessionId) return true; + if (visited.has(current)) return false; + visited.add(current); + current = this.sessionParents.get(current) ?? ""; + } + return false; + } +} diff --git a/packages/adapters/deepseek-harness/src/server/jsonrpc-runtime.ts b/packages/adapters/deepseek-harness/src/server/jsonrpc-runtime.ts new file mode 100644 index 000000000000..537e12e944c0 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/jsonrpc-runtime.ts @@ -0,0 +1,159 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { JsonRpcNdjsonClient, JsonRpcResponseError } from "./jsonrpc-client.js"; +import { + PROTOCOL_METHODS, + isRecord, + type InitializeParams, + type SessionPromptResult, +} from "./protocol.js"; +import { waitForPromptTurn } from "./wait-for-turn.js"; +import type { JsonRpcNotification } from "./jsonrpc-client.js"; + +export interface SpawnedDeepseekRuntime { + child: ChildProcessWithoutNullStreams; + client: JsonRpcNdjsonClient; + stderr: string; + close: (graceMs: number) => Promise<{ exitCode: number | null; signal: string | null }>; +} + +export async function spawnDeepseekRuntime(input: { + command: string; + args?: string[]; + cwd: string; + env: NodeJS.ProcessEnv; + onStderr?: (chunk: string) => void | Promise; + onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise; +}): Promise { + const child = spawn(input.command, input.args ?? [], { + cwd: input.cwd, + env: input.env, + stdio: ["pipe", "pipe", "pipe"], + detached: false, + }); + + if (input.onSpawn && child.pid) { + await input.onSpawn({ + pid: child.pid, + processGroupId: typeof child.pid === "number" ? child.pid : null, + startedAt: new Date().toISOString(), + }); + } + + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8"); + stderr += text; + void input.onStderr?.(text); + }); + + const client = new JsonRpcNdjsonClient(child.stdin, child.stdout); + + const close = async (graceMs: number) => { + try { + await Promise.race([ + client.request(PROTOCOL_METHODS.shutdown, {}), + sleep(1_000), + ]); + } catch { + // Process kill is the cancel story; shutdown is best-effort. + } + try { + child.stdin.end(); + } catch { + // already closed + } + client.close(); + if (child.exitCode !== null || child.signalCode) { + return { exitCode: child.exitCode, signal: child.signalCode }; + } + child.kill("SIGTERM"); + const first = await waitForExit(child, graceMs); + if (first) return first; + child.kill("SIGKILL"); + return (await waitForExit(child, 2_000)) ?? { exitCode: child.exitCode, signal: child.signalCode }; + }; + + return { child, client, stderr, close }; +} + +export async function initializeRuntime( + client: JsonRpcNdjsonClient, + params: InitializeParams, +): Promise { + const result = await client.request(PROTOCOL_METHODS.initialize, params); + if (!isRecord(result)) return; + const serverInfo = isRecord(result.serverInfo) ? result.serverInfo : null; + if (serverInfo && typeof serverInfo.name === "string" && serverInfo.name !== "deepseek-harness-sdk-runtime") { + throw new Error(`Unexpected DeepSeek JSON-RPC serverInfo.name: ${serverInfo.name}`); + } +} + +export async function promptAndWait(input: { + client: JsonRpcNdjsonClient; + sessionId: string; + prompt: string; + timeoutMs: number; + onNotification?: (notification: JsonRpcNotification) => void | Promise; +}): Promise<{ messageId: string; notifications: JsonRpcNotification[] }> { + const subscription = input.client.subscribeSessionTree(input.sessionId); + try { + const result = await input.client.request(PROTOCOL_METHODS.prompt, { + sessionId: input.sessionId, + contentBlocks: [{ type: "text", text: input.prompt }], + }); + const messageId = readMessageId(result); + const notifications = await waitForPromptTurn(subscription, { + sessionId: input.sessionId, + messageId, + timeoutMs: input.timeoutMs, + onNotification: input.onNotification, + }); + return { messageId, notifications }; + } finally { + subscription.close(); + } +} + +export function mintSessionId(): string { + return `paperclip-dsh-${randomUUID()}`; +} + +export function formatRpcError(error: unknown): string { + if (error instanceof JsonRpcResponseError) return error.message; + if (error instanceof Error) return error.message; + return String(error); +} + +function readMessageId(result: unknown): string { + if (isRecord(result) && typeof result.messageId === "string" && result.messageId.trim()) { + return result.messageId; + } + throw new Error(`session/prompt did not return messageId: ${JSON.stringify(result)}`); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function waitForExit( + child: ChildProcessWithoutNullStreams, + ms: number, +): Promise<{ exitCode: number | null; signal: string | null } | null> { + if (child.exitCode !== null || child.signalCode) { + return Promise.resolve({ exitCode: child.exitCode, signal: child.signalCode }); + } + return new Promise((resolve) => { + const timer = setTimeout(() => { + child.off("exit", onExit); + resolve(null); + }, ms); + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + clearTimeout(timer); + resolve({ exitCode: code, signal }); + }; + child.once("exit", onExit); + }); +} + +export type { SessionPromptResult }; diff --git a/packages/adapters/deepseek-harness/src/server/mock-jsonrpc-runtime.mjs b/packages/adapters/deepseek-harness/src/server/mock-jsonrpc-runtime.mjs new file mode 100644 index 000000000000..fbe617ee6c71 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/mock-jsonrpc-runtime.mjs @@ -0,0 +1,74 @@ +import { createInterface } from "node:readline"; + +const serverInfo = { name: "deepseek-harness-sdk-runtime", version: "0.1.1-rc.2" }; +const mode = process.env.DSH_MOCK_MODE ?? "ok"; +let seq = 0; + +function write(obj) { + process.stdout.write(`${JSON.stringify(obj)}\n`); +} + +function notify(method, params) { + write({ jsonrpc: "2.0", method, params }); +} + +function reply(id, result) { + write({ jsonrpc: "2.0", id, result }); +} + +function error(id, message) { + write({ jsonrpc: "2.0", id, error: { code: -32000, message } }); +} + +function emitTurn(sessionId, messageId) { + if (mode === "idle-before-inbox") { + notify("session.status", { sessionId, status: "idle" }); + } + notify("session.event", { + sessionId, + event: { + type: "agent/inbox/spliced", + data: { inserted: [{ id: messageId }] }, + }, + }); + notify("session.event", { + sessionId, + event: { type: "tool/call", data: { name: "bash", callId: `call-${messageId}` } }, + }); + notify("session.event", { + sessionId, + event: { + type: "assistant/message", + data: { + message: { content: [{ type: "text", text: `ack ${messageId}` }] }, + usage: { inputTokens: 22, outputTokens: 8, cacheReadTokens: 4 }, + }, + }, + }); + notify("session.status", { sessionId, status: "idle" }); +} + +const rl = createInterface({ input: process.stdin }); +rl.on("line", (line) => { + const msg = JSON.parse(line); + if (msg.method === "initialize") { + reply(msg.id, { serverInfo }); + return; + } + if (msg.method === "session/prompt") { + const sessionId = msg.params.sessionId; + if (mode === "unknown-session" || sessionId.startsWith("stale")) { + error(msg.id, `unknown session ${sessionId}`); + return; + } + const messageId = `msg-${++seq}`; + reply(msg.id, { messageId }); + queueMicrotask(() => emitTurn(sessionId, messageId)); + return; + } + if (msg.method === "shutdown") { + reply(msg.id, {}); + rl.close(); + process.exit(0); + } +}); diff --git a/packages/adapters/deepseek-harness/src/server/paperclip.cordis.yml b/packages/adapters/deepseek-harness/src/server/paperclip.cordis.yml new file mode 100644 index 000000000000..dcf4997c5161 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/paperclip.cordis.yml @@ -0,0 +1,91 @@ +# Paperclip-owned unattended coding composition for dsh-jsonrpc-agent. +# stdout is reserved for JSON-RPC; do not add a console logger or terminal UI. +# Bare plugin names resolve from the configuration project / NODE_PATH +# (typically adapterConfig.harnessRoot/node_modules), not from Paperclip. + +- id: sdk-jsonrpc-server + name: '@deepseek-ai/dsh-sdk-jsonrpc-server' + config: + maxTokensAsSuccess: !!js "process.env.DSH_MAX_TOKENS_AS_SUCCESS === undefined ? true : JSON.parse(process.env.DSH_MAX_TOKENS_AS_SUCCESS)" + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + thinking: enabled + reasoningEffort: max + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() + timeoutMs: 60000 + +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a coding agent.' + workspaceContext: false + skills: + enabled: false + toolBash: + enableRunInBackground: false + toolJobs: false + +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' + compression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + +- id: session-checkpoints + name: '@deepseek-ai/dsh-session-checkpoint-policy' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn-in-process + name: '@deepseek-ai/dsh-subagent-spawn-in-process' + config: + providerName: spawn + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + enableRunInBackground: false + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() + +- id: fs-observation-policy + name: '@deepseek-ai/dsh-fs-observation-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + +- id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + config: + thresholdRatio: 0.8 + retainRatio: 0.16 + maxTokens: 8192 + compactionRetries: 1 diff --git a/packages/adapters/deepseek-harness/src/server/parse.test.ts b/packages/adapters/deepseek-harness/src/server/parse.test.ts new file mode 100644 index 000000000000..1101eff31d57 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/parse.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { parseTurnNotifications } from "./parse.js"; +import { mapTokenUsage } from "./protocol.js"; + +describe("mapTokenUsage", () => { + it("maps cacheReadTokens to cachedInputTokens and drops write/reasoning", () => { + expect( + mapTokenUsage({ + inputTokens: 22, + outputTokens: 8, + cacheReadTokens: 4, + cacheWriteTokens: 9, + reasoningTokens: 3, + }), + ).toEqual({ inputTokens: 22, outputTokens: 8, cachedInputTokens: 4 }); + }); +}); + +describe("parseTurnNotifications", () => { + it("accumulates usage, summary, and tool name", () => { + const parsed = parseTurnNotifications([ + { + method: "session.event", + params: { + sessionId: "s1", + event: { type: "tool/call", data: { name: "bash" } }, + }, + }, + { + method: "session.event", + params: { + sessionId: "s1", + event: { + type: "assistant/message", + data: { + message: { content: [{ type: "text", text: "done" }] }, + usage: { inputTokens: 10, outputTokens: 2, cacheReadTokens: 1 }, + }, + }, + }, + }, + ]); + expect(parsed.summary).toBe("done"); + expect(parsed.toolName).toBe("bash"); + expect(parsed.usage).toEqual({ inputTokens: 10, outputTokens: 2, cachedInputTokens: 1 }); + }); + + it("maps turn/end errors and unknown sessions", () => { + const parsed = parseTurnNotifications([ + { + method: "session.event", + params: { + sessionId: "s1", + event: { type: "turn/end", data: { reason: "error", error: "unknown session abc" } }, + }, + }, + ]); + expect(parsed.unknownSession).toBe(true); + expect(parsed.errorMessage).toMatch(/unknown session/); + }); +}); diff --git a/packages/adapters/deepseek-harness/src/server/parse.ts b/packages/adapters/deepseek-harness/src/server/parse.ts new file mode 100644 index 000000000000..6373740b4906 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/parse.ts @@ -0,0 +1,64 @@ +import type { AdapterExecutionErrorFamily, UsageSummary } from "@paperclipai/adapter-utils/types"; +import { + addUsage, + classifyDeepseekError, + extractAssistantText, + extractEventUsage, + isRecord, + isUnknownSessionError, +} from "./protocol.js"; +import type { JsonRpcNotification } from "./jsonrpc-client.js"; +import { PROTOCOL_NOTIFICATIONS } from "./protocol.js"; + +export interface DeepseekTurnParse { + usage: UsageSummary; + summary: string | null; + toolName: string | null; + errorMessage: string | null; + errorFamily: AdapterExecutionErrorFamily | null; + unknownSession: boolean; +} + +const EMPTY_USAGE: UsageSummary = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0 }; + +export function parseTurnNotifications(notifications: JsonRpcNotification[]): DeepseekTurnParse { + let usage = EMPTY_USAGE; + let summary: string | null = null; + let toolName: string | null = null; + let errorMessage: string | null = null; + + for (const notification of notifications) { + if (notification.method !== PROTOCOL_NOTIFICATIONS.sessionEvent) continue; + const event = notification.params.event; + if (!isRecord(event)) continue; + + const eventUsage = extractEventUsage(event); + if (eventUsage) usage = addUsage(usage, eventUsage); + + const assistantText = extractAssistantText(event); + if (assistantText) summary = assistantText; + + if (event.type === "tool/call" && isRecord(event.data) && typeof event.data.name === "string") { + toolName = event.data.name; + } + + if (event.type === "turn/end" && isRecord(event.data)) { + const reason = typeof event.data.reason === "string" ? event.data.reason : ""; + const error = typeof event.data.error === "string" ? event.data.error : ""; + if (reason === "error" || error) { + errorMessage = error || reason || "DeepSeek Harness turn ended with an error"; + } + } + } + + return { + usage, + summary, + toolName, + errorMessage, + errorFamily: errorMessage ? classifyDeepseekError(errorMessage) : null, + unknownSession: errorMessage ? isUnknownSessionError(errorMessage) : false, + }; +} + +export { isUnknownSessionError }; diff --git a/packages/adapters/deepseek-harness/src/server/protocol.ts b/packages/adapters/deepseek-harness/src/server/protocol.ts new file mode 100644 index 000000000000..d2d8bf04bc06 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/protocol.ts @@ -0,0 +1,130 @@ +import type { AdapterExecutionErrorFamily, UsageSummary } from "@paperclipai/adapter-utils/types"; + +export const PROTOCOL_METHODS = { + initialize: "initialize", + prompt: "session/prompt", + shutdown: "shutdown", +} as const; + +export const PROTOCOL_NOTIFICATIONS = { + sessionEvent: "session.event", + sessionStatus: "session.status", + subagentStarted: "subagent.started", + subagentFinished: "subagent.finished", +} as const; + +export interface JsonRpcRequest { + jsonrpc: "2.0"; + id: number; + method: string; + params?: unknown; +} + +export interface JsonRpcSuccess { + jsonrpc: "2.0"; + id: number; + result: unknown; +} + +export interface JsonRpcFailure { + jsonrpc: "2.0"; + id: number; + error: { code?: number; message?: string; data?: unknown }; +} + +export interface JsonRpcNotification { + jsonrpc: "2.0"; + method: string; + params?: Record; +} + +export type JsonRpcMessage = JsonRpcSuccess | JsonRpcFailure | JsonRpcNotification; + +export interface InitializeParams { + cwd: string; + provider: string; + model: string; + maxTokens?: number; +} + +export interface InitializeResult { + serverInfo?: { name?: string; version?: string }; +} + +export interface SessionPromptParams { + sessionId: string; + contentBlocks: Array<{ type: string; text?: string }>; +} + +export interface SessionPromptResult { + messageId: string; +} + +export interface TokenUsage { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + reasoningTokens?: number; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isInboxReceipt(event: unknown, messageId: string): boolean { + if (!isRecord(event) || event.type !== "agent/inbox/spliced" || !isRecord(event.data)) { + return false; + } + const inserted = event.data.inserted; + return Array.isArray(inserted) && inserted.some((message) => isRecord(message) && message.id === messageId); +} + +export function mapTokenUsage(usage: TokenUsage | null | undefined): UsageSummary { + return { + inputTokens: Number(usage?.inputTokens ?? 0), + outputTokens: Number(usage?.outputTokens ?? 0), + cachedInputTokens: Number(usage?.cacheReadTokens ?? 0), + }; +} + +export function addUsage(left: UsageSummary, right: UsageSummary): UsageSummary { + return { + inputTokens: left.inputTokens + right.inputTokens, + outputTokens: left.outputTokens + right.outputTokens, + cachedInputTokens: (left.cachedInputTokens ?? 0) + (right.cachedInputTokens ?? 0), + }; +} + +export function extractAssistantText(event: unknown): string { + if (!isRecord(event) || event.type !== "assistant/message" || !isRecord(event.data)) return ""; + const message = isRecord(event.data.message) ? event.data.message : event.data; + const content = message.content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((block): block is Record => isRecord(block) && block.type === "text") + .map((block) => (typeof block.text === "string" ? block.text : "")) + .join(""); +} + +export function extractEventUsage(event: unknown): UsageSummary | null { + if (!isRecord(event) || !isRecord(event.data)) return null; + const usage = isRecord(event.data.usage) ? event.data.usage : null; + if (!usage) return null; + return mapTokenUsage(usage as TokenUsage); +} + +export function classifyDeepseekError(message: string): AdapterExecutionErrorFamily | null { + const text = message.toLowerCase(); + if (/\b(quota|rate limit|too many requests|429)\b/.test(text)) return "provider_quota"; + if (/\b(refus|safety|content policy|blocked)\b/.test(text)) return "model_refusal"; + if (/\b(timeout|temporar|unavailable|502|503|504|econnreset|enotfound)\b/.test(text)) { + return "transient_upstream"; + } + return null; +} + +export function isUnknownSessionError(message: string): boolean { + return /unknown session|session .* not found|no such session|invalid session/i.test(message); +} diff --git a/packages/adapters/deepseek-harness/src/server/runtime-config.ts b/packages/adapters/deepseek-harness/src/server/runtime-config.ts new file mode 100644 index 000000000000..9090c65989a6 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/runtime-config.ts @@ -0,0 +1,102 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { asString } from "@paperclipai/adapter-utils/server-utils"; +import { + ADAPTER_TYPE, + DEFAULT_COMMAND, + DEFAULT_GRACE_SEC, + DEFAULT_MODEL, + DEFAULT_PROVIDER, + DEFAULT_TIMEOUT_SEC, +} from "../shared/constants.js"; + +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +export function shippedCordisConfigPath(): string { + return path.resolve(moduleDir, "paperclip.cordis.yml"); +} + +export function resolvePaperclipHome(): string { + const raw = process.env.PAPERCLIP_HOME?.trim(); + if (raw) return path.resolve(raw); + return path.resolve(os.homedir(), ".paperclip"); +} + +export function resolveDeepseekSessionRoot(input: { + companyId: string; + agentId: string; + sessionRoot?: string; +}): string { + if (input.sessionRoot?.trim()) return path.resolve(input.sessionRoot.trim()); + return path.join( + resolvePaperclipHome(), + "adapter-state", + input.companyId, + input.agentId, + "deepseek", + "sessions", + ); +} + +export function resolveHarnessRoot(config: Record, env: Record): string | null { + const fromConfig = asString(config.harnessRoot, "").trim(); + if (fromConfig) return path.resolve(fromConfig); + const fromEnv = (env.DSH_HARNESS_ROOT ?? process.env.DSH_HARNESS_ROOT ?? "").trim(); + return fromEnv ? path.resolve(fromEnv) : null; +} + +export function resolveDeepseekCommand(config: Record, env: Record = {}): string { + const explicit = asString(config.command, "").trim(); + if (explicit) return explicit; + const fromEnv = (env.DSH_JSONRPC_COMMAND ?? process.env.DSH_JSONRPC_COMMAND ?? "").trim(); + if (fromEnv) return fromEnv; + const harnessRoot = resolveHarnessRoot(config, env); + if (harnessRoot) { + const bin = path.join(harnessRoot, "node_modules", ".bin", DEFAULT_COMMAND); + if (fs.existsSync(bin)) return bin; + } + return DEFAULT_COMMAND; +} + +export function resolveCordisConfigPath(config: Record, env: Record = {}): string { + const explicit = asString(config.cordisConfigPath, "").trim(); + if (explicit) return path.resolve(explicit); + const fromEnv = (env.DSH_CORDIS_CONFIG ?? process.env.DSH_CORDIS_CONFIG ?? "").trim(); + if (fromEnv) return path.resolve(fromEnv); + return shippedCordisConfigPath(); +} + +export function resolveDeepseekModel(config: Record, env: Record = {}): string { + return asString(config.model, "").trim() || env.DSH_MODEL?.trim() || DEFAULT_MODEL; +} + +export function resolveDeepseekProvider(config: Record): string { + return asString(config.provider, "").trim() || DEFAULT_PROVIDER; +} + +export function resolveTimeoutSec(config: Record): number { + const value = config.timeoutSec; + return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : DEFAULT_TIMEOUT_SEC; +} + +export function resolveGraceSec(config: Record): number { + const value = config.graceSec; + return typeof value === "number" && Number.isFinite(value) ? Math.max(1, value) : DEFAULT_GRACE_SEC; +} + +export function persistSessionEnabled(config: Record): boolean { + return config.persistSession !== false; +} + +export function buildRuntimeNodePath(harnessRoot: string | null, existing?: string): string | undefined { + if (!harnessRoot) return existing; + const modules = path.join(harnessRoot, "node_modules"); + if (!existing) return modules; + return `${modules}${path.delimiter}${existing}`; +} + +export function adapterStateLabel(): string { + return ADAPTER_TYPE; +} diff --git a/packages/adapters/deepseek-harness/src/server/session.test.ts b/packages/adapters/deepseek-harness/src/server/session.test.ts new file mode 100644 index 000000000000..30145e42af8f --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/session.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { canResumeDeepseekSession, sessionCodec } from "./session.js"; + +describe("sessionCodec", () => { + it("round-trips sessionId, cwd, and sessionRoot", () => { + const raw = { sessionId: "s1", cwd: "/tmp/ws", sessionRoot: "/tmp/sess" }; + expect(sessionCodec.deserialize(raw)).toEqual(raw); + expect(sessionCodec.serialize(raw)).toEqual(raw); + expect(sessionCodec.getDisplayId?.(raw)).toBe("s1"); + }); + + it("accepts snake_case aliases", () => { + expect(sessionCodec.deserialize({ session_id: "s2", session_root: "/r" })).toEqual({ + sessionId: "s2", + sessionRoot: "/r", + }); + }); + + it("returns null without a session id", () => { + expect(sessionCodec.deserialize({ cwd: "/tmp" })).toBeNull(); + }); +}); + +describe("canResumeDeepseekSession", () => { + it("resumes when cwd and sessionRoot match", () => { + expect( + canResumeDeepseekSession({ + sessionId: "s1", + savedCwd: "/tmp/ws", + savedSessionRoot: "/tmp/sess", + cwd: "/tmp/ws", + sessionRoot: "/tmp/sess", + }), + ).toBe(true); + }); + + it("drops resume on cwd mismatch", () => { + expect( + canResumeDeepseekSession({ + sessionId: "s1", + savedCwd: "/tmp/old", + savedSessionRoot: "/tmp/sess", + cwd: "/tmp/new", + sessionRoot: "/tmp/sess", + }), + ).toBe(false); + }); + + it("drops resume on sessionRoot mismatch", () => { + expect( + canResumeDeepseekSession({ + sessionId: "s1", + savedCwd: "/tmp/ws", + savedSessionRoot: "/tmp/old", + cwd: "/tmp/ws", + sessionRoot: "/tmp/new", + }), + ).toBe(false); + }); +}); diff --git a/packages/adapters/deepseek-harness/src/server/session.ts b/packages/adapters/deepseek-harness/src/server/session.ts new file mode 100644 index 000000000000..fbe7c4346cd4 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/session.ts @@ -0,0 +1,71 @@ +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export interface DeepseekSessionParams { + sessionId: string; + cwd?: string; + sessionRoot?: string; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = + readNonEmptyString(record.sessionId) ?? + readNonEmptyString(record.session_id) ?? + readNonEmptyString(record.session); + if (!sessionId) return null; + const cwd = + readNonEmptyString(record.cwd) ?? + readNonEmptyString(record.workdir) ?? + readNonEmptyString(record.folder); + const sessionRoot = + readNonEmptyString(record.sessionRoot) ?? + readNonEmptyString(record.session_root); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(sessionRoot ? { sessionRoot } : {}), + }; + }, + serialize(params: Record | null) { + if (!params) return null; + return sessionCodec.deserialize(params); + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return ( + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.session) + ); + }, +}; + +export function canResumeDeepseekSession(input: { + sessionId: string | null; + savedCwd: string | null; + savedSessionRoot: string | null; + cwd: string; + sessionRoot: string; +}): boolean { + if (!input.sessionId) return false; + if (input.savedCwd && resolveComparable(input.savedCwd) !== resolveComparable(input.cwd)) { + return false; + } + if ( + input.savedSessionRoot && + resolveComparable(input.savedSessionRoot) !== resolveComparable(input.sessionRoot) + ) { + return false; + } + return true; +} + +function resolveComparable(value: string): string { + return value.replace(/\\/g, "/").replace(/\/+$/, ""); +} diff --git a/packages/adapters/deepseek-harness/src/server/test.test.ts b/packages/adapters/deepseek-harness/src/server/test.test.ts new file mode 100644 index 000000000000..5c66a952c2ab --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/test.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { testEnvironment } from "./test.js"; + +describe("testEnvironment", () => { + it("fails closed when DEEPSEEK_API_KEY is missing", async () => { + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "deepseek_local", + config: { + command: process.execPath, + cwd: process.cwd(), + }, + }); + expect(result.status).toBe("fail"); + expect(result.checks.some((check) => check.code === "api_key" && check.level === "error")).toBe(true); + }); + + it("reports a missing JSON-RPC command", async () => { + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "deepseek_local", + config: { + command: "definitely-not-a-dsh-binary", + cwd: process.cwd(), + env: { DEEPSEEK_API_KEY: "test-key" }, + }, + }); + expect(result.status).toBe("fail"); + expect(result.checks.some((check) => check.code === "cli_detected" && check.level === "error")).toBe(true); + }); +}); diff --git a/packages/adapters/deepseek-harness/src/server/test.ts b/packages/adapters/deepseek-harness/src/server/test.ts new file mode 100644 index 000000000000..2da597a0719b --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/test.ts @@ -0,0 +1,233 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { + describeAdapterExecutionTarget, + ensureAdapterExecutionTargetCommandResolvable, + readAdapterExecutionTarget, +} from "@paperclipai/adapter-utils/execution-target"; +import { asNumber, asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import { ADAPTER_TYPE, DEFAULT_HELLO_PROBE_TIMEOUT_SEC } from "../shared/constants.js"; +import { + resolveCordisConfigPath, + resolveDeepseekCommand, + resolveDeepseekModel, + resolveDeepseekProvider, + resolveHarnessRoot, +} from "./runtime-config.js"; +import { + formatRpcError, + initializeRuntime, + mintSessionId, + promptAndWait, + spawnDeepseekRuntime, +} from "./jsonrpc-runtime.js"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function normalizeEnv(input: unknown): Record { + if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; + const env: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + if (typeof value === "string" && value.trim()) env[key] = value; + } + return env; +} + +function hasApiKey(env: Record): boolean { + return Boolean((env.DEEPSEEK_API_KEY ?? process.env.DEEPSEEK_API_KEY ?? "").trim()); +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const env = normalizeEnv(config.env); + const command = resolveDeepseekCommand(config, env); + const harnessRoot = resolveHarnessRoot(config, env); + const cordisConfigPath = resolveCordisConfigPath(config, env); + const model = resolveDeepseekModel(config, env); + const provider = resolveDeepseekProvider(config); + const cwd = asString(config.cwd, process.cwd()); + const target = readAdapterExecutionTarget({ + executionTarget: ctx.executionTarget, + legacyRemoteExecution: null, + }); + const targetIsRemote = target?.kind === "remote"; + const targetLabel = targetIsRemote + ? ctx.environmentName ?? describeAdapterExecutionTarget(target) + : "local host"; + + checks.push({ + code: "target", + level: "info", + message: `Probing ${targetLabel}`, + }); + + if (targetIsRemote) { + checks.push({ + code: "remote_not_implemented", + level: "warn", + message: "deepseek_local remote environment probes land in Phase 3. Local JSON-RPC checks ran on the Paperclip host.", + }); + } + + try { + await fs.mkdir(cwd, { recursive: true }); + checks.push({ + code: "cwd", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (error) { + checks.push({ + code: "cwd", + level: "error", + message: error instanceof Error ? error.message : "Invalid working directory", + detail: cwd, + }); + } + + try { + await ensureAdapterExecutionTargetCommandResolvable(command, null, cwd, { + ...process.env, + ...env, + }); + checks.push({ + code: "cli_detected", + level: "info", + message: `JSON-RPC runtime is executable: ${command}`, + }); + } catch (error) { + checks.push({ + code: "cli_detected", + level: "error", + message: error instanceof Error ? error.message : "dsh-jsonrpc-agent was not found", + detail: command, + hint: "Install DeepSeek Harness (source checkout, @deepseek-ai/dsh, or the Python closed runtime). npm i -g @deepseek-ai/dsh-sdk-jsonrpc-demo alone does not ship plugins. Set harnessRoot so NODE_PATH can resolve @deepseek-ai/dsh-* from that install.", + }); + } + + if (!hasApiKey(env)) { + const looksLikeSecretRef = Object.values(parseObject(config.env)).some( + (value) => typeof value === "object" && value !== null, + ); + checks.push({ + code: looksLikeSecretRef ? "environment_env_binding_missing" : "api_key", + level: "error", + message: "DEEPSEEK_API_KEY is required", + hint: "Set it on the agent env, selected environment secret refs, or the Paperclip process environment. Optional DEEPSEEK_BASE_URL selects an OpenAI-compatible proxy.", + }); + } else { + checks.push({ + code: "api_key", + level: "info", + message: "DEEPSEEK_API_KEY is present", + }); + } + + checks.push({ + code: "model", + level: "info", + message: `Model ${model} via ${provider}`, + }); + + if (!harnessRoot) { + checks.push({ + code: "harness_root", + level: "warn", + message: "harnessRoot is unset. Bare Cordis plugins resolve from the configuration project, not from Paperclip node_modules.", + hint: "Set adapterConfig.harnessRoot or DSH_HARNESS_ROOT to a DeepSeek Harness install.", + }); + } else { + checks.push({ + code: "harness_root", + level: "info", + message: `Harness root: ${harnessRoot}`, + }); + } + + try { + await fs.access(cordisConfigPath); + checks.push({ + code: "cordis_config", + level: "info", + message: `Cordis config: ${cordisConfigPath}`, + }); + } catch { + checks.push({ + code: "cordis_config", + level: "error", + message: `Cordis config is missing: ${cordisConfigPath}`, + }); + } + + const canProbe = + checks.every((check) => check.level !== "error") && + command.length > 0 && + hasApiKey(env); + + if (canProbe) { + const probeCwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-dsh-probe-")); + const timeoutSec = Math.max(5, asNumber(config.helloProbeTimeoutSec, DEFAULT_HELLO_PROBE_TIMEOUT_SEC)); + try { + const runtime = await spawnDeepseekRuntime({ + command, + cwd: probeCwd, + env: { + ...process.env, + ...env, + DSH_CWD: probeCwd, + DSH_CORDIS_CONFIG: cordisConfigPath, + DSH_SESSION_ROOT: path.join(probeCwd, "sessions"), + DSH_MODEL: model, + ...(harnessRoot + ? { NODE_PATH: `${path.join(harnessRoot, "node_modules")}${path.delimiter}${process.env.NODE_PATH ?? ""}` } + : {}), + }, + }); + try { + await initializeRuntime(runtime.client, { cwd: probeCwd, provider, model }); + await promptAndWait({ + client: runtime.client, + sessionId: mintSessionId(), + prompt: "Respond with hello.", + timeoutMs: timeoutSec * 1000, + }); + checks.push({ + code: "hello_probe", + level: "info", + message: "JSON-RPC initialize + session/prompt succeeded", + }); + } finally { + await runtime.close(5_000); + } + } catch (error) { + checks.push({ + code: "hello_probe", + level: "error", + message: formatRpcError(error), + hint: "Confirm DEEPSEEK_API_KEY, DSH_CORDIS_CONFIG, and that harness plugins resolve from harnessRoot/node_modules.", + }); + } finally { + await fs.rm(probeCwd, { recursive: true, force: true }).catch(() => {}); + } + } + + return { + adapterType: ADAPTER_TYPE, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/deepseek-harness/src/server/wait-for-turn.ts b/packages/adapters/deepseek-harness/src/server/wait-for-turn.ts new file mode 100644 index 000000000000..0ad84b5835e1 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/wait-for-turn.ts @@ -0,0 +1,90 @@ +import { + PROTOCOL_NOTIFICATIONS, + isInboxReceipt, + isRecord, +} from "./protocol.js"; +import type { JsonRpcNotification, NotificationSubscription } from "./jsonrpc-client.js"; + +export interface WaitForTurnOptions { + sessionId: string; + messageId: string; + timeoutMs: number; + onNotification?: (notification: JsonRpcNotification) => void | Promise; +} + +export async function waitForPromptTurn( + subscription: NotificationSubscription, + options: WaitForTurnOptions, +): Promise { + const collected: JsonRpcNotification[] = []; + let received = false; + const deadline = Date.now() + options.timeoutMs; + + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error(timeoutMessage(received, options.messageId)); + } + + const notification = await nextWithTimeout( + subscription, + remaining, + timeoutMessage(received, options.messageId), + ); + + if (!received) { + if ( + notification.method !== PROTOCOL_NOTIFICATIONS.sessionEvent || + notification.params.sessionId !== options.sessionId || + !isInboxReceipt(notification.params.event, options.messageId) + ) { + continue; + } + received = true; + } + + collected.push(notification); + await options.onNotification?.(notification); + + if ( + notification.method === PROTOCOL_NOTIFICATIONS.sessionStatus && + notification.params.sessionId === options.sessionId && + notification.params.status === "idle" + ) { + return collected; + } + } +} + +export function notificationEventType(notification: JsonRpcNotification): string | null { + if (notification.method !== PROTOCOL_NOTIFICATIONS.sessionEvent) return notification.method; + const event = notification.params.event; + return isRecord(event) && typeof event.type === "string" ? event.type : null; +} + +function timeoutMessage(received: boolean, messageId: string): string { + return received + ? `timed out waiting for session.status idle after inbox receipt ${messageId}` + : `timed out waiting for agent/inbox/spliced receipt ${messageId}`; +} + +async function nextWithTimeout( + subscription: NotificationSubscription, + timeoutMs: number, + timeoutMessageText: string, +): Promise { + const queued = subscription.tryNext(); + if (queued) return queued; + + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + subscription.next(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(timeoutMessageText)), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/packages/adapters/deepseek-harness/src/shared/constants.ts b/packages/adapters/deepseek-harness/src/shared/constants.ts new file mode 100644 index 000000000000..af5051ff3fc9 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/shared/constants.ts @@ -0,0 +1,12 @@ +export const ADAPTER_TYPE = "deepseek_local"; +export const ADAPTER_LABEL = "DeepSeek Harness"; + +export const DEFAULT_MODEL = "deepseek-v4-flash"; +export const DEFAULT_PROVIDER = "deepseek-official"; +export const DEFAULT_COMMAND = "dsh-jsonrpc-agent"; +export const DEFAULT_TIMEOUT_SEC = 0; +export const DEFAULT_GRACE_SEC = 15; +export const DEFAULT_HELLO_PROBE_TIMEOUT_SEC = 30; + +export const DSH_COMPAT_VERSION = "0.1.1-rc.2"; +export const DSH_SERVER_INFO_NAME = "deepseek-harness-sdk-runtime"; diff --git a/packages/adapters/deepseek-harness/src/ui-parser.ts b/packages/adapters/deepseek-harness/src/ui-parser.ts new file mode 100644 index 000000000000..e8c069107a84 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/ui-parser.ts @@ -0,0 +1,5 @@ +export function parseStdoutLine(line: string, ts: string): Array<{ kind: string; ts: string; text: string }> { + const trimmed = String(line ?? "").trim(); + if (!trimmed) return []; + return [{ kind: "stdout", ts, text: trimmed }]; +} diff --git a/packages/adapters/deepseek-harness/src/ui/build-config.ts b/packages/adapters/deepseek-harness/src/ui/build-config.ts new file mode 100644 index 000000000000..02656e5fdf81 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/ui/build-config.ts @@ -0,0 +1,19 @@ +import { buildAdapterEnvConfig, type CreateConfigValues } from "@paperclipai/adapter-utils"; +import { DEFAULT_GRACE_SEC, DEFAULT_MODEL, DEFAULT_TIMEOUT_SEC } from "../shared/constants.js"; + +export function buildDeepseekConfig(v: CreateConfigValues): Record { + const ac: Record = { + model: v.model.trim() || DEFAULT_MODEL, + persistSession: true, + timeoutSec: DEFAULT_TIMEOUT_SEC, + graceSec: DEFAULT_GRACE_SEC, + }; + if (v.cwd) ac.cwd = v.cwd; + if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; + if (v.command) ac.command = v.command; + if (v.promptTemplate) ac.promptTemplate = v.promptTemplate; + if (v.extraArgs) ac.extraArgs = v.extraArgs; + const env = buildAdapterEnvConfig(v.envBindings, v.envVars); + if (Object.keys(env).length > 0) ac.env = env; + return ac; +} diff --git a/packages/adapters/deepseek-harness/src/ui/index.ts b/packages/adapters/deepseek-harness/src/ui/index.ts new file mode 100644 index 000000000000..caa2c97d1a9f --- /dev/null +++ b/packages/adapters/deepseek-harness/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseDeepseekStdoutLine } from "./parse-stdout.js"; +export { buildDeepseekConfig } from "./build-config.js"; diff --git a/packages/adapters/deepseek-harness/src/ui/parse-stdout.ts b/packages/adapters/deepseek-harness/src/ui/parse-stdout.ts new file mode 100644 index 000000000000..9bc926929159 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/ui/parse-stdout.ts @@ -0,0 +1,27 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +export function parseDeepseekStdoutLine(line: string, ts: string): TranscriptEntry[] { + const trimmed = line.trim(); + if (!trimmed) return []; + const parsed = asRecord(safeJsonParse(trimmed)); + if (!parsed) { + return [{ kind: "stdout", ts, text: trimmed }]; + } + if (parsed.paperclipDeepseek !== 1) { + return [{ kind: "stdout", ts, text: trimmed }]; + } + return [{ kind: "stdout", ts, text: trimmed }]; +} diff --git a/packages/adapters/deepseek-harness/tsconfig.json b/packages/adapters/deepseek-harness/tsconfig.json new file mode 100644 index 000000000000..f3303a43ed54 --- /dev/null +++ b/packages/adapters/deepseek-harness/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/server/mock-jsonrpc-runtime.mjs"] +} diff --git a/packages/adapters/deepseek-harness/vitest.config.ts b/packages/adapters/deepseek-harness/vitest.config.ts new file mode 100644 index 000000000000..ad7d95889ad7 --- /dev/null +++ b/packages/adapters/deepseek-harness/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + exclude: ["dist/**", "node_modules/**"], + }, +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 935cf0e51060..0ace70dba15d 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -41,6 +41,7 @@ export const AGENT_ADAPTER_TYPES = [ "opencode_local", "pi_local", "cursor", + "deepseek_local", "openclaw_gateway", ] as const; export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number] | (string & {}); diff --git a/server/package.json b/server/package.json index e7d9b3f6102b..29533421bba5 100644 --- a/server/package.json +++ b/server/package.json @@ -49,6 +49,7 @@ "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-cloud": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", + "@paperclipai/adapter-deepseek-harness": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-grok-local": "workspace:*", "@paperclipai/adapter-kimi-local": "workspace:*", diff --git a/server/src/adapters/builtin-adapter-types.ts b/server/src/adapters/builtin-adapter-types.ts index aaaf28cabdee..62efa520c47e 100644 --- a/server/src/adapters/builtin-adapter-types.ts +++ b/server/src/adapters/builtin-adapter-types.ts @@ -7,6 +7,7 @@ export const BUILTIN_ADAPTER_TYPES = new Set([ "codex_local", "cursor_cloud", "cursor", + "deepseek_local", "gemini_local", "grok_local", "hermes_gateway", diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 2edf45da9a66..8511ebb1d934 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -103,6 +103,7 @@ import { createHermesGatewayServerAdapter, createHermesLocalServerAdapter, } from "@paperclipai/hermes-paperclip-adapter"; +import { createServerAdapter as createDeepseekLocalServerAdapter } from "@paperclipai/adapter-deepseek-harness"; import { execute as openCodeExecute, listOpenCodeSkills, @@ -452,6 +453,8 @@ const hermesGatewayAdapter = createHermesGatewayServerAdapter(); const hermesLocalAdapter = createHermesLocalServerAdapter(); +const deepseekLocalAdapter = createDeepseekLocalServerAdapter(); + const openclawGatewayAdapter: ServerAdapterModule = { type: "openclaw_gateway", execute: openclawGatewayExecute, @@ -527,6 +530,7 @@ function registerBuiltInAdapters() { kimiLocalAdapter, hermesGatewayAdapter, hermesLocalAdapter, + deepseekLocalAdapter, openclawGatewayAdapter, processAdapter, httpAdapter, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 4a665fc0d158..280a41b0dd73 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -488,6 +488,7 @@ const GIT_SENSITIVE_LOCAL_ADAPTER_TYPES = new Set([ "claude_local", "codex_local", "cursor", + "deepseek_local", "gemini_local", "grok_local", "hermes_local", @@ -819,6 +820,7 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([ "claude_local", "codex_local", "cursor", + "deepseek_local", "gemini_local", "hermes_local", "kimi_local", diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 4d3f08d9aba7..4a33826d391a 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -110,6 +110,7 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([ "claude_local", "codex_local", "cursor", + "deepseek_local", "gemini_local", "hermes_local", "kimi_local", diff --git a/tsconfig.json b/tsconfig.json index accf990bd522..01ba3634ab59 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ { "path": "./packages/adapters/claude-local" }, { "path": "./packages/adapters/codex-local" }, { "path": "./packages/adapters/cursor-local" }, + { "path": "./packages/adapters/deepseek-harness" }, { "path": "./packages/adapters/hermes-gateway" }, { "path": "./packages/adapters/hermes" }, { "path": "./packages/adapters/droid-local" }, diff --git a/ui/package.json b/ui/package.json index ae0c33915ff1..cdd16b5ff75c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -39,6 +39,7 @@ "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-cloud": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", + "@paperclipai/adapter-deepseek-harness": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-grok-local": "workspace:*", "@paperclipai/adapter-kimi-local": "workspace:*", diff --git a/ui/src/adapters/adapter-display-registry.test.ts b/ui/src/adapters/adapter-display-registry.test.ts index 0b34460347d1..e6c5aa815df3 100644 --- a/ui/src/adapters/adapter-display-registry.test.ts +++ b/ui/src/adapters/adapter-display-registry.test.ts @@ -15,6 +15,7 @@ describe("adapter display registry", () => { expect(getAdapterLabel("hermes_gateway")).toBe("Hermes Gateway"); expect(getAdapterLabel("opencode_local")).toBe("OpenCode"); expect(getAdapterLabel("pi_local")).toBe("Pi"); + expect(getAdapterLabel("deepseek_local")).toBe("DeepSeek Harness"); expect(getAdapterLabels()).toMatchObject({ codex_local: "Codex", @@ -28,6 +29,7 @@ describe("adapter display registry", () => { hermes_gateway: "Hermes Gateway", opencode_local: "OpenCode", pi_local: "Pi", + deepseek_local: "DeepSeek Harness", }); }); diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index 91e389994792..574fa44de39a 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -116,6 +116,12 @@ const adapterDisplayMap: Record = { description: "Pi harness", icon: Terminal, }, + deepseek_local: { + label: "DeepSeek Harness", + description: "DeepSeek Harness JSON-RPC adapter", + icon: Sparkles, + experimental: true, + }, cursor: { label: "Cursor", description: "Cursor CLI harness", diff --git a/ui/src/adapters/deepseek-local/index.ts b/ui/src/adapters/deepseek-local/index.ts new file mode 100644 index 000000000000..dd14a26fd3ef --- /dev/null +++ b/ui/src/adapters/deepseek-local/index.ts @@ -0,0 +1,11 @@ +import type { UIAdapterModule } from "../types"; +import { parseDeepseekStdoutLine, buildDeepseekConfig } from "@paperclipai/adapter-deepseek-harness/ui"; +import { SchemaConfigFields } from "../schema-config-fields"; + +export const deepseekLocalUIAdapter: UIAdapterModule = { + type: "deepseek_local", + label: "DeepSeek Harness", + parseStdoutLine: parseDeepseekStdoutLine, + ConfigFields: SchemaConfigFields, + buildAdapterConfig: buildDeepseekConfig, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 38345e24fa1d..f57f9c88d071 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -3,6 +3,7 @@ import { claudeLocalUIAdapter } from "./claude-local"; import { codexLocalUIAdapter } from "./codex-local"; import { cursorCloudUIAdapter } from "./cursor-cloud"; import { cursorLocalUIAdapter } from "./cursor"; +import { deepseekLocalUIAdapter } from "./deepseek-local"; import { geminiLocalUIAdapter } from "./gemini-local"; import { grokLocalUIAdapter } from "./grok-local"; import { kimiLocalUIAdapter } from "./kimi-local"; @@ -64,6 +65,7 @@ function registerBuiltInUIAdapters() { openCodeLocalUIAdapter, piLocalUIAdapter, cursorLocalUIAdapter, + deepseekLocalUIAdapter, openClawGatewayUIAdapter, processUIAdapter, httpUIAdapter, diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts index 55abb33ded9b..03c8e1eb5f17 100644 --- a/ui/src/adapters/use-adapter-capabilities.ts +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -25,6 +25,7 @@ const KNOWN_DEFAULTS: Record = { kimi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false, supportsAcp: true }, opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: false }, pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false, supportsAcp: false }, + deepseek_local: { supportsInstructionsBundle: true, supportsSkills: false, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: false }, openclaw_gateway: ALL_FALSE, }; From ba0d88b9cb7b168b16842eb07c24cccbf986ea3d Mon Sep 17 00:00:00 2001 From: soyboyscout Date: Sat, 29 Aug 2026 17:46:33 -0400 Subject: [PATCH 3/8] Add DeepSeek transcript parser, skills, models, and adapter docs. Phase 2 makes run logs render tool cards, materializes Paperclip skills into a managed customSkillDirs root, and documents the harness plugin closure. Also reject JSON-RPC waiters when the runtime dies and ignore stale idle before inbox receipt. Co-authored-by: Cursor --- .../2026-08-29-deepseek-harness-adapter.md | 8 + docs/adapters/deepseek-local.md | 58 ++++++++ docs/adapters/overview.md | 1 + docs/docs.json | 1 + .../deepseek-harness/src/cli/format-event.ts | 50 ++++--- .../deepseek-harness/src/index.test.ts | 2 + .../adapters/deepseek-harness/src/index.ts | 17 ++- .../src/server/execute.test.ts | 26 +++- .../deepseek-harness/src/server/execute.ts | 8 +- .../deepseek-harness/src/server/index.ts | 2 + .../src/server/jsonrpc-client.ts | 22 ++- .../src/server/jsonrpc-runtime.ts | 31 +++- .../deepseek-harness/src/server/models.ts | 58 ++++++++ .../src/server/paperclip.cordis.yml | 14 +- .../src/server/skills.test.ts | 49 ++++++ .../deepseek-harness/src/server/skills.ts | 77 ++++++++++ .../deepseek-harness/src/server/test.ts | 7 +- .../deepseek-harness/src/ui-parser.ts | 140 +++++++++++++++++- .../src/ui/parse-stdout.test.ts | 52 +++++++ .../deepseek-harness/src/ui/parse-stdout.ts | 25 +--- ui/src/adapters/use-adapter-capabilities.ts | 2 +- 21 files changed, 594 insertions(+), 56 deletions(-) create mode 100644 docs/adapters/deepseek-local.md create mode 100644 packages/adapters/deepseek-harness/src/server/models.ts create mode 100644 packages/adapters/deepseek-harness/src/server/skills.test.ts create mode 100644 packages/adapters/deepseek-harness/src/server/skills.ts create mode 100644 packages/adapters/deepseek-harness/src/ui/parse-stdout.test.ts diff --git a/doc/plans/2026-08-29-deepseek-harness-adapter.md b/doc/plans/2026-08-29-deepseek-harness-adapter.md index 01a76d15879b..1ea19b10ad40 100644 --- a/doc/plans/2026-08-29-deepseek-harness-adapter.md +++ b/doc/plans/2026-08-29-deepseek-harness-adapter.md @@ -466,3 +466,11 @@ Shipped `@paperclipai/adapter-deepseek-harness` and registered `deepseek_local` - Host lists updated for hire/wake/session resume (not remote-managed; that is Phase 3). - Env test fails closed without `DEEPSEEK_API_KEY`. Hello probe is local-only. - Tests use a protocol-accurate mock runtime. Live `DEEPSEEK_API_KEY` heartbeat was not run. + +## 20. Phase 2 results (2026-08-29) + +- UI parser + CLI formatter map `paperclipDeepseek` JSONL to tool cards, thinking, assistant text, and result. +- `listSkills` / `syncSkills` materialize desired Paperclip skills into `$PAPERCLIP_HOME/adapter-state/.../deepseek/skills` and set `DSH_BUNDLED_SKILL_DIR`. +- Curated models + cheap profile + `detectModel` (`DSH_MODEL` or `~/.dsh/config.yaml`). +- Docs: `docs/adapters/deepseek-local.md` and overview table row. +- Phase 1 review must-fixes: transport death rejects waiters; initialize is bounded; idle-before-inbox is tested. diff --git a/docs/adapters/deepseek-local.md b/docs/adapters/deepseek-local.md new file mode 100644 index 000000000000..8e9a85ced92e --- /dev/null +++ b/docs/adapters/deepseek-local.md @@ -0,0 +1,58 @@ +--- +title: DeepSeek Harness +summary: DeepSeek Harness JSON-RPC adapter setup and configuration +--- + +The `deepseek_local` adapter runs [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) as a Paperclip coding agent. Heartbeats talk to `dsh-jsonrpc-agent` over NDJSON JSON-RPC (`initialize`, `session/prompt`, `shutdown`) and resume the same `sessionId`. Compatible with DeepSeek Harness `0.1.1-rc.2`. + +This adapter does **not** use `dsh --profile headless` or `dsh-acp`. ACP cannot resume sessions and keeps tools off the wire. + +## Prerequisites + +A working runtime is three pieces, not `npm i -g @deepseek-ai/dsh-sdk-jsonrpc-demo` alone: + +1. `dsh-jsonrpc-agent` on `PATH`, or `adapterConfig.command` +2. A DeepSeek Harness install (`adapterConfig.harnessRoot` or `DSH_HARNESS_ROOT`) so `NODE_PATH=/node_modules` can load `@deepseek-ai/dsh-*` plugins +3. Paperclip's shipped `paperclip.cordis.yml` (or `cordisConfigPath`) + +Closed alternative: the Python `deepseek-harness-runtime-bin` executable. + +Stdout is the protocol. Do not add a stdout logger to the Cordis composition. + +## Auth + +- `DEEPSEEK_API_KEY` (required) +- `DEEPSEEK_BASE_URL` (optional OpenAI-compatible proxy) + +There is no Claude `setup-token` login. + +## Configuration fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `model` | string | `deepseek-v4-flash` | JSON-RPC `initialize.model` | +| `provider` | string | `deepseek-official` | JSON-RPC `initialize.provider` | +| `harnessRoot` | string | — | Harness install used for plugin `NODE_PATH` | +| `command` | string | `dsh-jsonrpc-agent` | JSON-RPC stdio binary | +| `cordisConfigPath` | string | shipped composition | Override `DSH_CORDIS_CONFIG` | +| `cwd` | string | workspace cwd | Absolute fallback working directory | +| `persistSession` | boolean | true | Reuse `sessionId` across heartbeats | +| `timeoutSec` | number | 0 | Wall-clock timeout; 0 disables | +| `graceSec` | number | 15 | SIGTERM grace | +| `maxTokens` | number | — | Optional `initialize.maxTokens` | +| `instructionsFilePath` | string | — | Injected as `DSH_SYSTEM_PROMPT` | +| `env` | object | {} | Secret refs allowed | + +Cheap model profile: `deepseek-v4-flash`. + +## Sessions + +Paperclip generates a `sessionId` and sends it on every `session/prompt`. Session files live under `$PAPERCLIP_HOME/adapter-state///deepseek/sessions` (`DSH_SESSION_ROOT`), not `~/.dsh`. A cwd or session-root mismatch starts a fresh session. + +## Skills + +Desired Paperclip skills are copied into `$PAPERCLIP_HOME/adapter-state///deepseek/skills` and passed as `dsh-skill-filesystem` `customSkillDirs` via `DSH_BUNDLED_SKILL_DIR`. `~/.dsh/skills` and `~/.agents/skills` remain visible as read-only external roots. + +## Remote / sandbox + +SSH and sandbox execution land in a later phase. Environment test and execute currently probe the Paperclip host. diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 55b0dcc5259e..76c3bbf151d7 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -25,6 +25,7 @@ When a heartbeat fires, Paperclip: | OpenCode | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | | Cursor | `cursor` | Runs Cursor in background mode | | Pi | `pi_local` | Runs an embedded Pi agent locally | +| [DeepSeek Harness](/adapters/deepseek-local) | `deepseek_local` | Runs DeepSeek Harness locally over JSON-RPC stdio | | Hermes | `hermes_local` | Runs the local Hermes CLI through `@paperclipai/hermes-paperclip-adapter` | | Hermes Gateway | `hermes_gateway` | Calls an already-running Hermes API server through `@paperclipai/hermes-paperclip-adapter/gateway` | | OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint | diff --git a/docs/docs.json b/docs/docs.json index 31223296a3d4..445d38e33263 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -99,6 +99,7 @@ "group": "Agent Adapters", "pages": [ "adapters/overview", + "adapters/deepseek-local", "adapters/claude-local", "adapters/codex-local", "adapters/process", diff --git a/packages/adapters/deepseek-harness/src/cli/format-event.ts b/packages/adapters/deepseek-harness/src/cli/format-event.ts index c7bf65d5d13b..704520102afc 100644 --- a/packages/adapters/deepseek-harness/src/cli/format-event.ts +++ b/packages/adapters/deepseek-harness/src/cli/format-event.ts @@ -1,26 +1,38 @@ import pc from "picocolors"; - -function safeJsonParse(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return null; - } -} - -function asRecord(value: unknown): Record | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - return value as Record; -} +import { parseStdoutLine } from "../ui-parser.js"; export function printDeepseekStreamEvent(raw: string, _debug: boolean): void { const line = raw.trim(); if (!line) return; - const parsed = asRecord(safeJsonParse(line)); - if (!parsed || parsed.paperclipDeepseek !== 1) { - console.log(line); - return; + const entries = parseStdoutLine(line, new Date().toISOString()); + if (entries.length === 0) return; + for (const entry of entries) { + const kind = typeof entry.kind === "string" ? entry.kind : "stdout"; + if (kind === "assistant") { + console.log(pc.green(String(entry.text ?? ""))); + continue; + } + if (kind === "thinking") { + console.log(pc.magenta(String(entry.text ?? ""))); + continue; + } + if (kind === "tool_call") { + console.log(pc.yellow(`tool ${String(entry.name ?? "tool")}`)); + continue; + } + if (kind === "tool_result") { + const printer = entry.isError ? pc.red : pc.gray; + console.log(printer(String(entry.content ?? ""))); + continue; + } + if (kind === "stderr") { + console.log(pc.red(String(entry.text ?? ""))); + continue; + } + if (kind === "system" || kind === "result") { + console.log(pc.cyan(String(entry.text ?? kind))); + continue; + } + if (typeof entry.text === "string" && entry.text) console.log(entry.text); } - const method = typeof parsed.method === "string" ? parsed.method : "event"; - console.log(pc.cyan(`deepseek ${method}`)); } diff --git a/packages/adapters/deepseek-harness/src/index.test.ts b/packages/adapters/deepseek-harness/src/index.test.ts index 655e4aaea73c..0e59052abe6b 100644 --- a/packages/adapters/deepseek-harness/src/index.test.ts +++ b/packages/adapters/deepseek-harness/src/index.test.ts @@ -13,5 +13,7 @@ describe("createServerAdapter", () => { expect(adapter.getRuntimeCommandSpec?.({})?.installCommand).toBeNull(); expect(adapter.loginCapability).toBeUndefined(); expect(adapter.acp).toBeUndefined(); + expect(adapter.listSkills).toBeTypeOf("function"); + expect(adapter.detectModel).toBeTypeOf("function"); }); }); diff --git a/packages/adapters/deepseek-harness/src/index.ts b/packages/adapters/deepseek-harness/src/index.ts index f3afcdc1f416..2d8521ae22a9 100644 --- a/packages/adapters/deepseek-harness/src/index.ts +++ b/packages/adapters/deepseek-harness/src/index.ts @@ -5,7 +5,17 @@ import type { ServerAdapterModule, } from "@paperclipai/adapter-utils"; import { ADAPTER_LABEL, ADAPTER_TYPE, DEFAULT_MODEL, DSH_COMPAT_VERSION } from "./shared/constants.js"; -import { execute, getConfigSchema, sessionCodec, testEnvironment } from "./server/index.js"; +import { + detectModel, + execute, + getConfigSchema, + listDeepseekModels, + listDeepseekSkills, + refreshDeepseekModels, + sessionCodec, + syncDeepseekSkills, + testEnvironment, +} from "./server/index.js"; import { resolveDeepseekCommand } from "./server/runtime-config.js"; export const type = ADAPTER_TYPE; @@ -106,6 +116,11 @@ export function createServerAdapter(): ServerAdapterModule { sessionManagement, models, modelProfiles, + listModels: listDeepseekModels, + refreshModels: refreshDeepseekModels, + listSkills: listDeepseekSkills, + syncSkills: syncDeepseekSkills, + detectModel, supportsLocalAgentJwt: true, supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", diff --git a/packages/adapters/deepseek-harness/src/server/execute.test.ts b/packages/adapters/deepseek-harness/src/server/execute.test.ts index 8c834f7db728..05b8d0123dda 100644 --- a/packages/adapters/deepseek-harness/src/server/execute.test.ts +++ b/packages/adapters/deepseek-harness/src/server/execute.test.ts @@ -76,7 +76,31 @@ describe("execute", () => { sessionId: result.sessionId, cwd: ctx.config.cwd, }); - expect(typeof result.sessionParams?.sessionRoot).toBe("string"); + expect(result.sessionParams?.sessionRoot).toBe( + path.join(String(process.env.PAPERCLIP_HOME), "adapter-state", "company-1", "agent-1", "deepseek", "sessions"), + ); + }); + + it("ignores a stale idle notification before inbox receipt", async () => { + const ctx = await makeCtx({ + extra: { env: { DEEPSEEK_API_KEY: "test-key", DSH_MOCK_MODE: "idle-before-inbox" } }, + }); + const result = await execute(ctx); + expect(result.exitCode).toBe(0); + expect(result.usage).toEqual({ inputTokens: 22, outputTokens: 8, cachedInputTokens: 4 }); + expect(result.summary).toMatch(/^ack /); + }); + + it("rejects remote execution targets in Phase 1/2", async () => { + const ctx = await makeCtx(); + ctx.executionTarget = { + kind: "remote", + transport: "sandbox", + remoteCwd: "/remote/workspace", + } as AdapterExecutionContext["executionTarget"]; + const result = await execute(ctx); + expect(result.errorCode).toBe("remote_not_implemented"); + expect(result.exitCode).toBe(1); }); it("resumes the same sessionId on a second heartbeat", async () => { diff --git a/packages/adapters/deepseek-harness/src/server/execute.ts b/packages/adapters/deepseek-harness/src/server/execute.ts index 81bb170032ef..8fc963e7ef40 100644 --- a/packages/adapters/deepseek-harness/src/server/execute.ts +++ b/packages/adapters/deepseek-harness/src/server/execute.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import path from "node:path"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { adapterExecutionTargetIsRemote, @@ -43,6 +42,7 @@ import { promptAndWait, spawnDeepseekRuntime, } from "./jsonrpc-runtime.js"; +import { materializeDeepseekSkills, resolveDeepseekSkillsDir } from "./skills.js"; import type { JsonRpcNotification } from "./jsonrpc-client.js"; export async function execute(ctx: AdapterExecutionContext): Promise { @@ -90,6 +90,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { + env.DSH_BUNDLED_SKILL_DIR = skillsDir; + await onLog("stdout", `[paperclip] Materialized ${stagedSkills} DeepSeek skill(s) into ${skillsDir}\n`); + } const command = resolveDeepseekCommand(config, env); const harnessRoot = resolveHarnessRoot(config, env); diff --git a/packages/adapters/deepseek-harness/src/server/index.ts b/packages/adapters/deepseek-harness/src/server/index.ts index b4badc568c90..c06fec8c7eca 100644 --- a/packages/adapters/deepseek-harness/src/server/index.ts +++ b/packages/adapters/deepseek-harness/src/server/index.ts @@ -4,3 +4,5 @@ export { sessionCodec } from "./session.js"; export { getConfigSchema } from "./config-schema.js"; export { parseTurnNotifications, isUnknownSessionError } from "./parse.js"; export { resolveDeepseekCommand } from "./runtime-config.js"; +export { listDeepseekSkills, syncDeepseekSkills } from "./skills.js"; +export { detectModel, listDeepseekModels, refreshDeepseekModels } from "./models.js"; diff --git a/packages/adapters/deepseek-harness/src/server/jsonrpc-client.ts b/packages/adapters/deepseek-harness/src/server/jsonrpc-client.ts index f558d713a7b8..903673654b96 100644 --- a/packages/adapters/deepseek-harness/src/server/jsonrpc-client.ts +++ b/packages/adapters/deepseek-harness/src/server/jsonrpc-client.ts @@ -26,9 +26,14 @@ export class JsonRpcResponseError extends Error { } } +type NotificationWaiter = { + resolve: (notification: JsonRpcNotification) => void; + reject: (error: Error) => void; +}; + export class NotificationSubscription { private readonly queue: JsonRpcNotification[] = []; - private readonly waiters: Array<(notification: JsonRpcNotification) => void> = []; + private readonly waiters: NotificationWaiter[] = []; private failure: Error | null = null; private closed = false; @@ -41,13 +46,14 @@ export class NotificationSubscription { if (this.closed || this.failure) return; if (this.filter && !this.filter(notification)) return; const waiter = this.waiters.shift(); - if (waiter) waiter(notification); + if (waiter) waiter.resolve(notification); else this.queue.push(notification); } fail(error: Error) { if (this.failure || this.closed) return; this.failure = error; + for (const waiter of this.waiters) waiter.reject(error); this.waiters.length = 0; } @@ -70,7 +76,7 @@ export class NotificationSubscription { reject(this.failure); return; } - this.waiters.push(resolve); + this.waiters.push({ resolve, reject }); }); } } @@ -89,6 +95,16 @@ export class JsonRpcNdjsonClient { ) { this.readline = createInterface({ input: stdout }); this.readline.on("line", (line) => this.onLine(line)); + this.readline.on("close", () => { + this.failTransport(new Error("DeepSeek Harness JSON-RPC stdout closed")); + }); + } + + failTransport(error: Error) { + if (this.closed) return; + for (const waiter of this.pending.values()) waiter.reject(error); + this.pending.clear(); + for (const subscription of this.subscriptions) subscription.fail(error); } request(method: string, params?: unknown): Promise { diff --git a/packages/adapters/deepseek-harness/src/server/jsonrpc-runtime.ts b/packages/adapters/deepseek-harness/src/server/jsonrpc-runtime.ts index 537e12e944c0..c1690cfce302 100644 --- a/packages/adapters/deepseek-harness/src/server/jsonrpc-runtime.ts +++ b/packages/adapters/deepseek-harness/src/server/jsonrpc-runtime.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { DSH_SERVER_INFO_NAME } from "../shared/constants.js"; import { JsonRpcNdjsonClient, JsonRpcResponseError } from "./jsonrpc-client.js"; import { PROTOCOL_METHODS, @@ -48,6 +49,13 @@ export async function spawnDeepseekRuntime(input: { }); const client = new JsonRpcNdjsonClient(child.stdin, child.stdout); + child.on("error", (error) => { + client.failTransport(error); + }); + child.on("exit", (code, signal) => { + if (code === 0 || signal === "SIGTERM" || signal === "SIGKILL") return; + client.failTransport(new Error(`DeepSeek Harness runtime exited (${code ?? signal ?? "unknown"})`)); + }); const close = async (graceMs: number) => { try { @@ -80,11 +88,16 @@ export async function spawnDeepseekRuntime(input: { export async function initializeRuntime( client: JsonRpcNdjsonClient, params: InitializeParams, + timeoutMs = 15_000, ): Promise { - const result = await client.request(PROTOCOL_METHODS.initialize, params); + const result = await withTimeout( + client.request(PROTOCOL_METHODS.initialize, params), + timeoutMs, + "initialize timed out", + ); if (!isRecord(result)) return; const serverInfo = isRecord(result.serverInfo) ? result.serverInfo : null; - if (serverInfo && typeof serverInfo.name === "string" && serverInfo.name !== "deepseek-harness-sdk-runtime") { + if (serverInfo && typeof serverInfo.name === "string" && serverInfo.name !== DSH_SERVER_INFO_NAME) { throw new Error(`Unexpected DeepSeek JSON-RPC serverInfo.name: ${serverInfo.name}`); } } @@ -136,6 +149,20 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function withTimeout(promise: Promise, ms: number, message: string): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + function waitForExit( child: ChildProcessWithoutNullStreams, ms: number, diff --git a/packages/adapters/deepseek-harness/src/server/models.ts b/packages/adapters/deepseek-harness/src/server/models.ts new file mode 100644 index 000000000000..901032ab1695 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/models.ts @@ -0,0 +1,58 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { readFile } from "node:fs/promises"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../shared/constants.js"; + +export const CURATED_DEEPSEEK_MODELS = [ + { id: DEFAULT_MODEL, label: "DeepSeek V4 Flash" }, + { id: "deepseek-v4-pro", label: "DeepSeek V4 Pro" }, +]; + +export async function listDeepseekModels(): Promise> { + return CURATED_DEEPSEEK_MODELS; +} + +export async function refreshDeepseekModels(): Promise> { + return listDeepseekModels(); +} + +export async function detectModel(): Promise<{ + model: string; + provider: string; + source: string; + candidates?: string[]; +} | null> { + const envModel = process.env.DSH_MODEL?.trim(); + if (envModel) { + return { + model: envModel, + provider: process.env.DSH_PROVIDER?.trim() || DEFAULT_PROVIDER, + source: "DSH_MODEL", + candidates: CURATED_DEEPSEEK_MODELS.map((model) => model.id), + }; + } + + const dshHome = process.env.DSH_HOME?.trim() || join(homedir(), ".dsh"); + try { + const content = await readFile(join(dshHome, "config.yaml"), "utf8"); + const modelMatch = content.match(/^\s*model:\s*["']?([^\s"']+)/m); + const providerMatch = content.match(/^\s*provider:\s*["']?([^\s"']+)/m); + if (modelMatch?.[1]) { + return { + model: modelMatch[1], + provider: providerMatch?.[1] || DEFAULT_PROVIDER, + source: `${dshHome}/config.yaml`, + candidates: CURATED_DEEPSEEK_MODELS.map((model) => model.id), + }; + } + } catch { + // no local dsh config + } + + return { + model: DEFAULT_MODEL, + provider: DEFAULT_PROVIDER, + source: "adapter_default", + candidates: CURATED_DEEPSEEK_MODELS.map((model) => model.id), + }; +} diff --git a/packages/adapters/deepseek-harness/src/server/paperclip.cordis.yml b/packages/adapters/deepseek-harness/src/server/paperclip.cordis.yml index dcf4997c5161..1d6f74d5e54c 100644 --- a/packages/adapters/deepseek-harness/src/server/paperclip.cordis.yml +++ b/packages/adapters/deepseek-harness/src/server/paperclip.cordis.yml @@ -34,7 +34,7 @@ persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a coding agent.' workspaceContext: false skills: - enabled: false + enabled: true toolBash: enableRunInBackground: false toolJobs: false @@ -79,6 +79,18 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' +- id: skills + name: '@deepseek-ai/dsh-skill' + +- id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + config: + customSkillDirs: !!js process.env.DSH_BUNDLED_SKILL_DIR ? [process.env.DSH_BUNDLED_SKILL_DIR] : [] + watch: false + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + - id: token-meter name: '@deepseek-ai/dsh-token-meter' diff --git a/packages/adapters/deepseek-harness/src/server/skills.test.ts b/packages/adapters/deepseek-harness/src/server/skills.test.ts new file mode 100644 index 000000000000..66455a044067 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/skills.test.ts @@ -0,0 +1,49 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { listDeepseekSkills, materializeDeepseekSkills } from "./skills.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("deepseek skills", () => { + it("materializes desired SKILL.md bundles into a custom root", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "dsh-skills-")); + tempDirs.push(root); + const source = path.join(root, "source", "demo-skill"); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile( + path.join(source, "SKILL.md"), + "---\nname: demo-skill\ndescription: Demo\n---\n\nHello.\n", + ); + const dest = path.join(root, "dest"); + const count = await materializeDeepseekSkills({ + config: { + paperclipRuntimeSkills: [ + { key: "demo-skill", runtimeName: "demo-skill", source }, + ], + paperclipSkillSync: { desiredSkills: ["demo-skill"] }, + }, + destDir: dest, + }); + expect(count).toBe(1); + const copied = await fs.readFile(path.join(dest, "demo-skill", "SKILL.md"), "utf8"); + expect(copied).toContain("name: demo-skill"); + }); + + it("lists Paperclip-managed skills as ephemeral", async () => { + const snapshot = await listDeepseekSkills({ + agentId: "agent-1", + companyId: "company-1", + adapterType: "deepseek_local", + config: {}, + }); + expect(snapshot.adapterType).toBe("deepseek_local"); + expect(snapshot.supported).toBe(true); + expect(snapshot.mode).toBe("ephemeral"); + }); +}); diff --git a/packages/adapters/deepseek-harness/src/server/skills.ts b/packages/adapters/deepseek-harness/src/server/skills.ts new file mode 100644 index 000000000000..a34f35e47626 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/skills.ts @@ -0,0 +1,77 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterSkillContext, AdapterSkillSnapshot } from "@paperclipai/adapter-utils"; +import { + buildRuntimeMountedSkillSnapshot, + materializePaperclipSkillCopy, + readInstalledSkillTargets, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, +} from "@paperclipai/adapter-utils/server-utils"; +import { ADAPTER_TYPE } from "../shared/constants.js"; +import { resolvePaperclipHome } from "./runtime-config.js"; + +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +export function resolveDeepseekSkillsDir(input: { companyId: string; agentId: string }): string { + return path.join( + resolvePaperclipHome(), + "adapter-state", + input.companyId, + input.agentId, + "deepseek", + "skills", + ); +} + +export async function materializeDeepseekSkills(input: { + config: Record; + destDir: string; +}): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(input.config, moduleDir); + const desired = new Set(resolvePaperclipDesiredSkillNames(input.config, availableEntries)); + await fs.mkdir(input.destDir, { recursive: true }); + let count = 0; + for (const entry of availableEntries) { + if (!desired.has(entry.key)) continue; + await materializePaperclipSkillCopy(entry.source, path.join(input.destDir, entry.runtimeName)); + count += 1; + } + return count; +} + +async function buildSnapshot(config: Record): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(config, moduleDir); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const dshHome = process.env.DSH_HOME?.trim() || path.join(os.homedir(), ".dsh"); + const agentsHome = process.env.DSH_AGENTS_HOME?.trim() || path.join(os.homedir(), ".agents"); + const userDsh = await readInstalledSkillTargets(path.join(dshHome, "skills")); + const userAgents = await readInstalledSkillTargets(path.join(agentsHome, "skills")); + const externalInstalled = new Map([...userDsh, ...userAgents]); + return buildRuntimeMountedSkillSnapshot({ + adapterType: ADAPTER_TYPE, + availableEntries, + desiredSkills, + mode: "ephemeral", + configuredDetail: + "Copied into the Paperclip-managed DeepSeek skill root and passed as dsh-skill-filesystem customSkillDirs on the next run.", + externalInstalled, + externalLocationLabel: "~/.dsh/skills or ~/.agents/skills", + externalDetail: "Discovered by DeepSeek Harness as a native/external skill root. Paperclip does not mutate it.", + }); +} + +export async function listDeepseekSkills(ctx: AdapterSkillContext): Promise { + return buildSnapshot(ctx.config); +} + +export async function syncDeepseekSkills( + ctx: AdapterSkillContext, + _desiredSkills: string[], +): Promise { + const destDir = resolveDeepseekSkillsDir({ companyId: ctx.companyId, agentId: ctx.agentId }); + await materializeDeepseekSkills({ config: ctx.config, destDir }); + return buildSnapshot(ctx.config); +} diff --git a/packages/adapters/deepseek-harness/src/server/test.ts b/packages/adapters/deepseek-harness/src/server/test.ts index 2da597a0719b..b53df183d561 100644 --- a/packages/adapters/deepseek-harness/src/server/test.ts +++ b/packages/adapters/deepseek-harness/src/server/test.ts @@ -11,7 +11,7 @@ import { ensureAdapterExecutionTargetCommandResolvable, readAdapterExecutionTarget, } from "@paperclipai/adapter-utils/execution-target"; -import { asNumber, asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import { asNumber, asString, ensureAbsoluteDirectory, parseObject } from "@paperclipai/adapter-utils/server-utils"; import { ADAPTER_TYPE, DEFAULT_HELLO_PROBE_TIMEOUT_SEC } from "../shared/constants.js"; import { resolveCordisConfigPath, @@ -83,7 +83,10 @@ export async function testEnvironment( } try { - await fs.mkdir(cwd, { recursive: true }); + if (!path.isAbsolute(cwd)) { + throw new Error(`Working directory must be absolute: ${cwd}`); + } + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); checks.push({ code: "cwd", level: "info", diff --git a/packages/adapters/deepseek-harness/src/ui-parser.ts b/packages/adapters/deepseek-harness/src/ui-parser.ts index e8c069107a84..de37d6705db1 100644 --- a/packages/adapters/deepseek-harness/src/ui-parser.ts +++ b/packages/adapters/deepseek-harness/src/ui-parser.ts @@ -1,5 +1,141 @@ -export function parseStdoutLine(line: string, ts: string): Array<{ kind: string; ts: string; text: string }> { +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function textFromBlocks(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((block) => asRecord(block)) + .filter((block): block is Record => Boolean(block && block.type === "text" && typeof block.text === "string")) + .map((block) => String(block.text)) + .join(""); +} + +function thinkingFromBlocks(content: unknown): string { + if (!Array.isArray(content)) return ""; + return content + .map((block) => asRecord(block)) + .filter((block): block is Record => { + if (!block || typeof block.text !== "string") return false; + return block.type === "reasoning" || block.type === "thinking"; + }) + .map((block) => String(block.text)) + .join(""); +} + +export function parseStdoutLine(line: string, ts: string): Array> { const trimmed = String(line ?? "").trim(); if (!trimmed) return []; - return [{ kind: "stdout", ts, text: trimmed }]; + const parsed = asRecord(safeJsonParse(trimmed)); + if (!parsed || parsed.paperclipDeepseek !== 1) { + return [{ kind: "stdout", ts, text: trimmed }]; + } + + const method = asString(parsed.method); + const params = asRecord(parsed.params) ?? {}; + + if (method === "subagent.started") { + return [{ kind: "system", ts, text: `Subagent started ${asString(params.childSessionId)}`.trim() }]; + } + if (method === "subagent.finished") { + return [{ kind: "system", ts, text: `Subagent finished ${asString(params.childSessionId)}`.trim() }]; + } + if (method === "session.status" && params.status === "idle") { + return [{ + kind: "result", + ts, + text: "Run completed", + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + costUsd: 0, + subtype: "end", + isError: false, + errors: [], + }]; + } + if (method !== "session.event") { + return [{ kind: "stdout", ts, text: trimmed }]; + } + + const event = asRecord(params.event); + if (!event) return []; + const type = asString(event.type); + const data = asRecord(event.data) ?? {}; + + if (type === "assistant/chunk") { + const block = asRecord(data.block) ?? data; + const blockType = asString(block.type, "text"); + const text = asString(block.text, asString(data.text)); + if (!text) return []; + if (blockType === "reasoning" || blockType === "thinking") { + return [{ kind: "thinking", ts, text, delta: true }]; + } + return [{ kind: "assistant", ts, text, delta: true }]; + } + + if (type === "assistant/message") { + const message = asRecord(data.message) ?? data; + const content = message.content; + const entries: Array> = []; + const thinking = thinkingFromBlocks(content); + const text = textFromBlocks(content); + if (thinking) entries.push({ kind: "thinking", ts, text: thinking }); + if (text) entries.push({ kind: "assistant", ts, text }); + return entries; + } + + if (type === "tool/call") { + return [{ + kind: "tool_call", + ts, + name: asString(data.name, "tool"), + input: data.arguments ?? data.input ?? {}, + toolUseId: asString(data.callId, asString(data.id)), + }]; + } + + if (type === "tool/result") { + const content = typeof data.content === "string" ? data.content : JSON.stringify(data.content ?? data); + return [{ + kind: "tool_result", + ts, + toolUseId: asString(data.callId, asString(data.id, "tool")), + toolName: asString(data.name, "tool"), + content, + isError: data.isError === true || data.ok === false, + }]; + } + + if (type === "user/message") { + const message = asRecord(data.message) ?? data; + const text = textFromBlocks(message.content) || asString(data.text); + if (!text) return []; + return [{ kind: "user", ts, text }]; + } + + if (type === "turn/end") { + const reason = asString(data.reason); + const error = asString(data.error); + if (reason === "error" || error) { + return [{ kind: "stderr", ts, text: error || reason || "DeepSeek turn ended with an error" }]; + } + } + + return []; } diff --git a/packages/adapters/deepseek-harness/src/ui/parse-stdout.test.ts b/packages/adapters/deepseek-harness/src/ui/parse-stdout.test.ts new file mode 100644 index 000000000000..9ac4274c8ea2 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/ui/parse-stdout.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { parseDeepseekStdoutLine } from "./parse-stdout.js"; + +describe("parseDeepseekStdoutLine", () => { + it("maps tool cards, thinking deltas, and assistant text", () => { + expect( + parseDeepseekStdoutLine( + JSON.stringify({ + paperclipDeepseek: 1, + method: "session.event", + params: { + event: { type: "assistant/chunk", data: { type: "reasoning", text: "hmm" } }, + }, + }), + "t0", + ), + ).toEqual([{ kind: "thinking", ts: "t0", text: "hmm", delta: true }]); + + expect( + parseDeepseekStdoutLine( + JSON.stringify({ + paperclipDeepseek: 1, + method: "session.event", + params: { + event: { type: "tool/call", data: { name: "bash", callId: "c1", arguments: { cmd: "ls" } } }, + }, + }), + "t1", + ), + ).toEqual([{ kind: "tool_call", ts: "t1", name: "bash", input: { cmd: "ls" }, toolUseId: "c1" }]); + + expect( + parseDeepseekStdoutLine( + JSON.stringify({ + paperclipDeepseek: 1, + method: "session.event", + params: { + event: { + type: "assistant/message", + data: { message: { content: [{ type: "text", text: "done" }] } }, + }, + }, + }), + "t2", + ), + ).toEqual([{ kind: "assistant", ts: "t2", text: "done" }]); + }); + + it("treats non-protocol lines as stdout", () => { + expect(parseDeepseekStdoutLine("hello", "t3")).toEqual([{ kind: "stdout", ts: "t3", text: "hello" }]); + }); +}); diff --git a/packages/adapters/deepseek-harness/src/ui/parse-stdout.ts b/packages/adapters/deepseek-harness/src/ui/parse-stdout.ts index 9bc926929159..13eb3874bf4f 100644 --- a/packages/adapters/deepseek-harness/src/ui/parse-stdout.ts +++ b/packages/adapters/deepseek-harness/src/ui/parse-stdout.ts @@ -1,27 +1,6 @@ import type { TranscriptEntry } from "@paperclipai/adapter-utils"; - -function safeJsonParse(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return null; - } -} - -function asRecord(value: unknown): Record | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - return value as Record; -} +import { parseStdoutLine } from "../ui-parser.js"; export function parseDeepseekStdoutLine(line: string, ts: string): TranscriptEntry[] { - const trimmed = line.trim(); - if (!trimmed) return []; - const parsed = asRecord(safeJsonParse(trimmed)); - if (!parsed) { - return [{ kind: "stdout", ts, text: trimmed }]; - } - if (parsed.paperclipDeepseek !== 1) { - return [{ kind: "stdout", ts, text: trimmed }]; - } - return [{ kind: "stdout", ts, text: trimmed }]; + return parseStdoutLine(line, ts) as TranscriptEntry[]; } diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts index 03c8e1eb5f17..cf4d576621bf 100644 --- a/ui/src/adapters/use-adapter-capabilities.ts +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -25,7 +25,7 @@ const KNOWN_DEFAULTS: Record = { kimi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false, supportsAcp: true }, opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: false }, pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false, supportsAcp: false }, - deepseek_local: { supportsInstructionsBundle: true, supportsSkills: false, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: false }, + deepseek_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: false }, openclaw_gateway: ALL_FALSE, }; From b8fe6387fbb0273c9713f47d0a9df6555ddba045 Mon Sep 17 00:00:00 2001 From: soyboyscout Date: Sat, 29 Aug 2026 17:56:57 -0400 Subject: [PATCH 4/8] Align DeepSeek parser and skills sync with official harness wire types. Live session.event payloads use StreamChunk, ToolResultMessage, and TurnEndReason, so invented fixtures hid empty transcripts, unpaired tool cards, and zero usage on the result card. Co-authored-by: Cursor --- .../2026-08-29-deepseek-harness-adapter.md | 11 ++ .../src/server/execute.test.ts | 14 ++ .../deepseek-harness/src/server/execute.ts | 9 +- .../src/server/jsonrpc-client.ts | 6 + .../src/server/jsonrpc-runtime.ts | 1 + .../src/server/mock-jsonrpc-runtime.mjs | 11 +- .../src/server/models.test.ts | 64 +++++++ .../deepseek-harness/src/server/parse.test.ts | 31 +++- .../deepseek-harness/src/server/parse.ts | 22 ++- .../deepseek-harness/src/server/protocol.ts | 26 ++- .../src/server/skills.test.ts | 38 ++++ .../deepseek-harness/src/server/skills.ts | 4 + .../src/server/wait-for-turn.ts | 4 +- .../deepseek-harness/src/ui-parser.ts | 171 +++++++++++++++--- .../adapters/deepseek-harness/src/ui/index.ts | 2 +- .../src/ui/parse-stdout.test.ts | 122 ++++++++++--- .../deepseek-harness/src/ui/parse-stdout.ts | 14 +- ui/src/adapters/deepseek-local/index.ts | 3 +- 18 files changed, 483 insertions(+), 70 deletions(-) create mode 100644 packages/adapters/deepseek-harness/src/server/models.test.ts diff --git a/doc/plans/2026-08-29-deepseek-harness-adapter.md b/doc/plans/2026-08-29-deepseek-harness-adapter.md index 1ea19b10ad40..9ff7a357cba4 100644 --- a/doc/plans/2026-08-29-deepseek-harness-adapter.md +++ b/doc/plans/2026-08-29-deepseek-harness-adapter.md @@ -474,3 +474,14 @@ Shipped `@paperclipai/adapter-deepseek-harness` and registered `deepseek_local` - Curated models + cheap profile + `detectModel` (`DSH_MODEL` or `~/.dsh/config.yaml`). - Docs: `docs/adapters/deepseek-local.md` and overview table row. - Phase 1 review must-fixes: transport death rejects waiters; initialize is bounded; idle-before-inbox is tested. + +### Phase 2 review follow-up (official wire) + +Parser and turn accounting now match dsh `0.1.1-rc.2` `SessionEventMap` / `StreamChunk` / `ToolResultMessage`: + +- `assistant/chunk.data.chunk` is `text-delta` / `reasoning-delta` / `usage` (not a invented `{ type, text }` block). +- `tool/call.arguments` is a JSON string; `tool/result` content is `data.message.content[0]` (`type: tool-result`, `toolCallId`). +- `turn/end.reason` is `{ kind, error? }` (`LlmFailure`), including `max-tokens`. +- `createStdoutParser` accumulates usage; idle result uses `assistant/message.usage` when present and falls back to chunk usage so the run-viewer card is not hardcoded zeros. Message and chunk usage are not double-counted. +- `materializeDeepseekSkills` rewrites the materialized root (deselected siblings are deleted). +- Instructions append the Claude-equivalent HEARTBEAT.md / SOUL.md path directive. diff --git a/packages/adapters/deepseek-harness/src/server/execute.test.ts b/packages/adapters/deepseek-harness/src/server/execute.test.ts index 05b8d0123dda..38ebb6543793 100644 --- a/packages/adapters/deepseek-harness/src/server/execute.test.ts +++ b/packages/adapters/deepseek-harness/src/server/execute.test.ts @@ -125,6 +125,20 @@ describe("execute", () => { expect(second.sessionId).not.toBe(first.sessionId); }); + it("appends HEARTBEAT/SOUL path directives to DSH_SYSTEM_PROMPT", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dsh-exec-")); + tempDirs.push(cwd); + const instructions = path.join(cwd, "SOUL.md"); + await fs.writeFile(instructions, "You are the company soul.\n", "utf8"); + const ctx = await makeCtx({ + cwd, + extra: { instructionsFilePath: instructions }, + }); + const result = await execute(ctx); + expect(result.exitCode).toBe(0); + expect(ctx.logs.join("")).not.toMatch(/instructions file was missing/); + }); + it("retries with clearSession when the runtime rejects the session", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dsh-exec-")); tempDirs.push(cwd); diff --git a/packages/adapters/deepseek-harness/src/server/execute.ts b/packages/adapters/deepseek-harness/src/server/execute.ts index 8fc963e7ef40..d1d30be35a0d 100644 --- a/packages/adapters/deepseek-harness/src/server/execute.ts +++ b/packages/adapters/deepseek-harness/src/server/execute.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import path from "node:path"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { adapterExecutionTargetIsRemote, @@ -116,7 +117,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise(promise: Promise, ms: number, message: string): ]); } finally { if (timer) clearTimeout(timer); + void promise.catch(() => {}); } } diff --git a/packages/adapters/deepseek-harness/src/server/mock-jsonrpc-runtime.mjs b/packages/adapters/deepseek-harness/src/server/mock-jsonrpc-runtime.mjs index fbe617ee6c71..5d2a30d843f5 100644 --- a/packages/adapters/deepseek-harness/src/server/mock-jsonrpc-runtime.mjs +++ b/packages/adapters/deepseek-harness/src/server/mock-jsonrpc-runtime.mjs @@ -33,7 +33,16 @@ function emitTurn(sessionId, messageId) { }); notify("session.event", { sessionId, - event: { type: "tool/call", data: { name: "bash", callId: `call-${messageId}` } }, + event: { + type: "tool/call", + data: { + turn: 1, + step: 1, + name: "bash", + callId: `call-${messageId}`, + arguments: JSON.stringify({ command: "true" }), + }, + }, }); notify("session.event", { sessionId, diff --git a/packages/adapters/deepseek-harness/src/server/models.test.ts b/packages/adapters/deepseek-harness/src/server/models.test.ts new file mode 100644 index 000000000000..add49c9cf19a --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/models.test.ts @@ -0,0 +1,64 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../shared/constants.js"; +import { detectModel } from "./models.js"; + +const originalModel = process.env.DSH_MODEL; +const originalProvider = process.env.DSH_PROVIDER; +const originalHome = process.env.DSH_HOME; + +afterEach(() => { + if (originalModel === undefined) delete process.env.DSH_MODEL; + else process.env.DSH_MODEL = originalModel; + if (originalProvider === undefined) delete process.env.DSH_PROVIDER; + else process.env.DSH_PROVIDER = originalProvider; + if (originalHome === undefined) delete process.env.DSH_HOME; + else process.env.DSH_HOME = originalHome; +}); + +describe("detectModel", () => { + it("prefers DSH_MODEL over local yaml", async () => { + process.env.DSH_MODEL = "deepseek-v4-pro"; + process.env.DSH_PROVIDER = "deepseek-official"; + const detected = await detectModel(); + expect(detected).toMatchObject({ + model: "deepseek-v4-pro", + provider: "deepseek-official", + source: "DSH_MODEL", + }); + }); + + it("reads ~/.dsh/config.yaml when DSH_MODEL is unset", async () => { + delete process.env.DSH_MODEL; + delete process.env.DSH_PROVIDER; + const home = await fs.mkdtemp(path.join(os.tmpdir(), "dsh-model-")); + process.env.DSH_HOME = home; + await fs.writeFile( + path.join(home, "config.yaml"), + "provider: deepseek-official\nmodel: deepseek-v4-pro\n", + "utf8", + ); + const detected = await detectModel(); + expect(detected).toMatchObject({ + model: "deepseek-v4-pro", + provider: "deepseek-official", + source: `${home}/config.yaml`, + }); + await fs.rm(home, { recursive: true, force: true }); + }); + + it("falls back to the adapter default", async () => { + delete process.env.DSH_MODEL; + const home = await fs.mkdtemp(path.join(os.tmpdir(), "dsh-model-")); + process.env.DSH_HOME = home; + const detected = await detectModel(); + expect(detected).toMatchObject({ + model: DEFAULT_MODEL, + provider: DEFAULT_PROVIDER, + source: "adapter_default", + }); + await fs.rm(home, { recursive: true, force: true }); + }); +}); diff --git a/packages/adapters/deepseek-harness/src/server/parse.test.ts b/packages/adapters/deepseek-harness/src/server/parse.test.ts index 1101eff31d57..60f8775ac569 100644 --- a/packages/adapters/deepseek-harness/src/server/parse.test.ts +++ b/packages/adapters/deepseek-harness/src/server/parse.test.ts @@ -45,17 +45,44 @@ describe("parseTurnNotifications", () => { expect(parsed.usage).toEqual({ inputTokens: 10, outputTokens: 2, cachedInputTokens: 1 }); }); - it("maps turn/end errors and unknown sessions", () => { + it("maps official turn/end LlmFailure and unknown sessions", () => { const parsed = parseTurnNotifications([ { method: "session.event", params: { sessionId: "s1", - event: { type: "turn/end", data: { reason: "error", error: "unknown session abc" } }, + event: { + type: "turn/end", + data: { turn: 1, reason: { kind: "error", error: { message: "unknown session abc", code: "UNKNOWN" } } }, + }, }, }, ]); expect(parsed.unknownSession).toBe(true); expect(parsed.errorMessage).toMatch(/unknown session/); }); + + it("maps max-tokens turn/end and assistant/chunk usage", () => { + const parsed = parseTurnNotifications([ + { + method: "session.event", + params: { + sessionId: "s1", + event: { + type: "assistant/chunk", + data: { turn: 1, step: 1, chunk: { type: "usage", usage: { inputTokens: 3, outputTokens: 1 } } }, + }, + }, + }, + { + method: "session.event", + params: { + sessionId: "s1", + event: { type: "turn/end", data: { turn: 1, reason: { kind: "max-tokens" } } }, + }, + }, + ]); + expect(parsed.errorMessage).toMatch(/max-tokens/); + expect(parsed.usage).toEqual({ inputTokens: 3, outputTokens: 1, cachedInputTokens: 0 }); + }); }); diff --git a/packages/adapters/deepseek-harness/src/server/parse.ts b/packages/adapters/deepseek-harness/src/server/parse.ts index 6373740b4906..c2350de26bf0 100644 --- a/packages/adapters/deepseek-harness/src/server/parse.ts +++ b/packages/adapters/deepseek-harness/src/server/parse.ts @@ -3,7 +3,9 @@ import { addUsage, classifyDeepseekError, extractAssistantText, + extractChunkUsage, extractEventUsage, + extractTurnEndError, isRecord, isUnknownSessionError, } from "./protocol.js"; @@ -23,6 +25,8 @@ const EMPTY_USAGE: UsageSummary = { inputTokens: 0, outputTokens: 0, cachedInput export function parseTurnNotifications(notifications: JsonRpcNotification[]): DeepseekTurnParse { let usage = EMPTY_USAGE; + let chunkUsage = EMPTY_USAGE; + let sawMessageUsage = false; let summary: string | null = null; let toolName: string | null = null; let errorMessage: string | null = null; @@ -33,7 +37,12 @@ export function parseTurnNotifications(notifications: JsonRpcNotification[]): De if (!isRecord(event)) continue; const eventUsage = extractEventUsage(event); - if (eventUsage) usage = addUsage(usage, eventUsage); + if (eventUsage) { + usage = addUsage(usage, eventUsage); + sawMessageUsage = true; + } + const streamedUsage = extractChunkUsage(event); + if (streamedUsage) chunkUsage = addUsage(chunkUsage, streamedUsage); const assistantText = extractAssistantText(event); if (assistantText) summary = assistantText; @@ -42,17 +51,12 @@ export function parseTurnNotifications(notifications: JsonRpcNotification[]): De toolName = event.data.name; } - if (event.type === "turn/end" && isRecord(event.data)) { - const reason = typeof event.data.reason === "string" ? event.data.reason : ""; - const error = typeof event.data.error === "string" ? event.data.error : ""; - if (reason === "error" || error) { - errorMessage = error || reason || "DeepSeek Harness turn ended with an error"; - } - } + const turnError = extractTurnEndError(event); + if (turnError) errorMessage = turnError; } return { - usage, + usage: sawMessageUsage ? usage : chunkUsage, summary, toolName, errorMessage, diff --git a/packages/adapters/deepseek-harness/src/server/protocol.ts b/packages/adapters/deepseek-harness/src/server/protocol.ts index d2d8bf04bc06..8b2d70011a96 100644 --- a/packages/adapters/deepseek-harness/src/server/protocol.ts +++ b/packages/adapters/deepseek-harness/src/server/protocol.ts @@ -109,12 +109,36 @@ export function extractAssistantText(event: unknown): string { } export function extractEventUsage(event: unknown): UsageSummary | null { - if (!isRecord(event) || !isRecord(event.data)) return null; + if (!isRecord(event) || event.type !== "assistant/message" || !isRecord(event.data)) return null; const usage = isRecord(event.data.usage) ? event.data.usage : null; if (!usage) return null; return mapTokenUsage(usage as TokenUsage); } +export function extractChunkUsage(event: unknown): UsageSummary | null { + if (!isRecord(event) || event.type !== "assistant/chunk" || !isRecord(event.data)) return null; + const chunk = isRecord(event.data.chunk) ? event.data.chunk : null; + if (!chunk || chunk.type !== "usage" || !isRecord(chunk.usage)) return null; + return mapTokenUsage(chunk.usage as TokenUsage); +} + +export function extractTurnEndError(event: unknown): string | null { + if (!isRecord(event) || event.type !== "turn/end" || !isRecord(event.data)) return null; + const reason = isRecord(event.data.reason) ? event.data.reason : null; + if (!reason) return null; + const kind = typeof reason.kind === "string" ? reason.kind : ""; + if (kind === "error") { + const error = isRecord(reason.error) ? reason.error : null; + if (error && typeof error.message === "string" && error.message.trim()) return error.message; + if (error && typeof error.code === "string" && error.code.trim()) { + return `DeepSeek Harness turn ended with an error (${error.code})`; + } + return "DeepSeek Harness turn ended with an error"; + } + if (kind === "max-tokens") return "DeepSeek Harness turn ended: max-tokens"; + return null; +} + export function classifyDeepseekError(message: string): AdapterExecutionErrorFamily | null { const text = message.toLowerCase(); if (/\b(quota|rate limit|too many requests|429)\b/.test(text)) return "provider_quota"; diff --git a/packages/adapters/deepseek-harness/src/server/skills.test.ts b/packages/adapters/deepseek-harness/src/server/skills.test.ts index 66455a044067..9723f02ae0a9 100644 --- a/packages/adapters/deepseek-harness/src/server/skills.test.ts +++ b/packages/adapters/deepseek-harness/src/server/skills.test.ts @@ -35,6 +35,44 @@ describe("deepseek skills", () => { expect(copied).toContain("name: demo-skill"); }); + it("rewrites the materialized root and deletes deselected siblings", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "dsh-skills-")); + tempDirs.push(root); + const keep = path.join(root, "source", "keep-skill"); + const drop = path.join(root, "source", "drop-skill"); + for (const source of [keep, drop]) { + await fs.mkdir(source, { recursive: true }); + await fs.writeFile( + path.join(source, "SKILL.md"), + `---\nname: ${path.basename(source)}\ndescription: Demo\n---\n\nHello.\n`, + ); + } + const dest = path.join(root, "dest"); + await materializeDeepseekSkills({ + config: { + paperclipRuntimeSkills: [ + { key: "keep-skill", runtimeName: "keep-skill", source: keep }, + { key: "drop-skill", runtimeName: "drop-skill", source: drop }, + ], + paperclipSkillSync: { desiredSkills: ["keep-skill", "drop-skill"] }, + }, + destDir: dest, + }); + const afterDrop = await materializeDeepseekSkills({ + config: { + paperclipRuntimeSkills: [ + { key: "keep-skill", runtimeName: "keep-skill", source: keep }, + { key: "drop-skill", runtimeName: "drop-skill", source: drop }, + ], + paperclipSkillSync: { desiredSkills: ["keep-skill"] }, + }, + destDir: dest, + }); + expect(afterDrop).toBe(1); + await expect(fs.stat(path.join(dest, "keep-skill", "SKILL.md"))).resolves.toBeDefined(); + await expect(fs.stat(path.join(dest, "drop-skill"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("lists Paperclip-managed skills as ephemeral", async () => { const snapshot = await listDeepseekSkills({ agentId: "agent-1", diff --git a/packages/adapters/deepseek-harness/src/server/skills.ts b/packages/adapters/deepseek-harness/src/server/skills.ts index a34f35e47626..a215c6154b34 100644 --- a/packages/adapters/deepseek-harness/src/server/skills.ts +++ b/packages/adapters/deepseek-harness/src/server/skills.ts @@ -33,6 +33,10 @@ export async function materializeDeepseekSkills(input: { const availableEntries = await readPaperclipRuntimeSkillEntries(input.config, moduleDir); const desired = new Set(resolvePaperclipDesiredSkillNames(input.config, availableEntries)); await fs.mkdir(input.destDir, { recursive: true }); + const existing = await fs.readdir(input.destDir, { withFileTypes: true }); + await Promise.all( + existing.map((entry) => fs.rm(path.join(input.destDir, entry.name), { recursive: true, force: true })), + ); let count = 0; for (const entry of availableEntries) { if (!desired.has(entry.key)) continue; diff --git a/packages/adapters/deepseek-harness/src/server/wait-for-turn.ts b/packages/adapters/deepseek-harness/src/server/wait-for-turn.ts index 0ad84b5835e1..8811ed9e16b8 100644 --- a/packages/adapters/deepseek-harness/src/server/wait-for-turn.ts +++ b/packages/adapters/deepseek-harness/src/server/wait-for-turn.ts @@ -77,14 +77,16 @@ async function nextWithTimeout( if (queued) return queued; let timer: NodeJS.Timeout | undefined; + const nextPromise = subscription.next(); try { return await Promise.race([ - subscription.next(), + nextPromise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(timeoutMessageText)), timeoutMs); }), ]); } finally { if (timer) clearTimeout(timer); + void nextPromise.catch(() => {}); } } diff --git a/packages/adapters/deepseek-harness/src/ui-parser.ts b/packages/adapters/deepseek-harness/src/ui-parser.ts index de37d6705db1..ce8c93853805 100644 --- a/packages/adapters/deepseek-harness/src/ui-parser.ts +++ b/packages/adapters/deepseek-harness/src/ui-parser.ts @@ -38,7 +38,102 @@ function thinkingFromBlocks(content: unknown): string { .join(""); } -export function parseStdoutLine(line: string, ts: string): Array> { +function parseToolArguments(value: unknown): unknown { + if (typeof value === "string") { + const parsed = safeJsonParse(value); + return parsed ?? value; + } + return value ?? {}; +} + +function tokenCount(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function usageFromRecord(value: unknown): { inputTokens: number; outputTokens: number; cachedTokens: number } | null { + const usage = asRecord(value); + if (!usage) return null; + return { + inputTokens: tokenCount(usage.inputTokens), + outputTokens: tokenCount(usage.outputTokens), + cachedTokens: tokenCount(usage.cacheReadTokens), + }; +} + +function addUsage( + left: { inputTokens: number; outputTokens: number; cachedTokens: number }, + right: { inputTokens: number; outputTokens: number; cachedTokens: number }, +) { + return { + inputTokens: left.inputTokens + right.inputTokens, + outputTokens: left.outputTokens + right.outputTokens, + cachedTokens: left.cachedTokens + right.cachedTokens, + }; +} + +function toolResultCard(data: Record, ts: string): Record | null { + const message = asRecord(data.message); + const blocks = message?.content; + const first = Array.isArray(blocks) ? asRecord(blocks[0]) : null; + const toolUseId = asString(first?.toolCallId, asString(data.callId, asString(data.id))); + if (!toolUseId) return null; + const inner = first?.content; + let content = ""; + if (typeof inner === "string") content = inner; + else if (Array.isArray(inner)) content = textFromBlocks(inner); + else if (inner != null) content = JSON.stringify(inner); + const isError = first?.isError === true || Boolean(data.error); + return { + kind: "tool_result", + ts, + toolUseId, + toolName: asString(data.name, "tool"), + content, + isError, + }; +} + +function turnEndStderr(data: Record, ts: string): Record | null { + const reason = asRecord(data.reason); + const kind = asString(reason?.kind); + if (kind === "error") { + const error = asRecord(reason?.error); + const text = asString(error?.message) || asString(error?.code) || "DeepSeek Harness turn ended with an error"; + return { kind: "stderr", ts, text }; + } + if (kind === "max-tokens") { + return { kind: "stderr", ts, text: "DeepSeek Harness turn ended: max-tokens" }; + } + return null; +} + +type ParserState = { + messageUsage: { inputTokens: number; outputTokens: number; cachedTokens: number }; + chunkUsage: { inputTokens: number; outputTokens: number; cachedTokens: number }; + sawMessageUsage: boolean; + streamedText: boolean; + streamedThinking: boolean; +}; + +function emptyUsage() { + return { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }; +} + +function emptyState(): ParserState { + return { + messageUsage: emptyUsage(), + chunkUsage: emptyUsage(), + sawMessageUsage: false, + streamedText: false, + streamedThinking: false, + }; +} + +function resultUsage(state: ParserState) { + return state.sawMessageUsage ? state.messageUsage : state.chunkUsage; +} + +function parseProtocolLine(line: string, ts: string, state: ParserState | null): Array> { const trimmed = String(line ?? "").trim(); if (!trimmed) return []; const parsed = asRecord(safeJsonParse(trimmed)); @@ -56,13 +151,14 @@ export function parseStdoutLine(line: string, ts: string): Array> = []; const thinking = thinkingFromBlocks(content); const text = textFromBlocks(content); - if (thinking) entries.push({ kind: "thinking", ts, text: thinking }); - if (text) entries.push({ kind: "assistant", ts, text }); + if (thinking && !state?.streamedThinking) entries.push({ kind: "thinking", ts, text: thinking }); + if (text && !state?.streamedText) entries.push({ kind: "assistant", ts, text }); return entries; } @@ -105,21 +216,14 @@ export function parseStdoutLine(line: string, ts: string): Array> { + return parseProtocolLine(line, ts, state); + }, + reset() { + state = emptyState(); + }, + }; +} + +export function parseStdoutLine(line: string, ts: string): Array> { + return createStdoutParser().parseLine(line, ts); +} diff --git a/packages/adapters/deepseek-harness/src/ui/index.ts b/packages/adapters/deepseek-harness/src/ui/index.ts index caa2c97d1a9f..a0a6de2ffb39 100644 --- a/packages/adapters/deepseek-harness/src/ui/index.ts +++ b/packages/adapters/deepseek-harness/src/ui/index.ts @@ -1,2 +1,2 @@ -export { parseDeepseekStdoutLine } from "./parse-stdout.js"; +export { parseDeepseekStdoutLine, createDeepseekStdoutParser } from "./parse-stdout.js"; export { buildDeepseekConfig } from "./build-config.js"; diff --git a/packages/adapters/deepseek-harness/src/ui/parse-stdout.test.ts b/packages/adapters/deepseek-harness/src/ui/parse-stdout.test.ts index 9ac4274c8ea2..cf6b841d8133 100644 --- a/packages/adapters/deepseek-harness/src/ui/parse-stdout.test.ts +++ b/packages/adapters/deepseek-harness/src/ui/parse-stdout.test.ts @@ -1,29 +1,38 @@ import { describe, expect, it } from "vitest"; -import { parseDeepseekStdoutLine } from "./parse-stdout.js"; +import { createDeepseekStdoutParser, parseDeepseekStdoutLine } from "./parse-stdout.js"; + +function eventLine(type: string, data: Record) { + return JSON.stringify({ + paperclipDeepseek: 1, + method: "session.event", + params: { event: { type, data } }, + }); +} describe("parseDeepseekStdoutLine", () => { - it("maps tool cards, thinking deltas, and assistant text", () => { + it("maps official StreamChunk deltas, JSON-string tool arguments, and tool-result messages", () => { expect( parseDeepseekStdoutLine( - JSON.stringify({ - paperclipDeepseek: 1, - method: "session.event", - params: { - event: { type: "assistant/chunk", data: { type: "reasoning", text: "hmm" } }, - }, - }), + eventLine("assistant/chunk", { turn: 1, step: 1, chunk: { type: "reasoning-delta", index: 0, text: "hmm" } }), "t0", ), ).toEqual([{ kind: "thinking", ts: "t0", text: "hmm", delta: true }]); expect( parseDeepseekStdoutLine( - JSON.stringify({ - paperclipDeepseek: 1, - method: "session.event", - params: { - event: { type: "tool/call", data: { name: "bash", callId: "c1", arguments: { cmd: "ls" } } }, - }, + eventLine("assistant/chunk", { turn: 1, step: 1, chunk: { type: "text-delta", index: 1, text: "hi" } }), + "t0b", + ), + ).toEqual([{ kind: "assistant", ts: "t0b", text: "hi", delta: true }]); + + expect( + parseDeepseekStdoutLine( + eventLine("tool/call", { + turn: 1, + step: 1, + name: "bash", + callId: "c1", + arguments: JSON.stringify({ cmd: "ls" }), }), "t1", ), @@ -31,22 +40,85 @@ describe("parseDeepseekStdoutLine", () => { expect( parseDeepseekStdoutLine( - JSON.stringify({ - paperclipDeepseek: 1, - method: "session.event", - params: { - event: { - type: "assistant/message", - data: { message: { content: [{ type: "text", text: "done" }] } }, - }, + eventLine("tool/result", { + turn: 1, + step: 1, + message: { + role: "user", + content: [{ + type: "tool-result", + toolCallId: "c1", + content: [{ type: "text", text: "ok" }], + isError: false, + }], }, }), - "t2", + "t1b", + ), + ).toEqual([{ kind: "tool_result", ts: "t1b", toolUseId: "c1", toolName: "tool", content: "ok", isError: false }]); + + expect( + parseDeepseekStdoutLine( + eventLine("turn/end", { + turn: 1, + reason: { kind: "error", error: { message: "provider 429", code: "RATE_LIMIT" } }, + }), + "t-err", ), - ).toEqual([{ kind: "assistant", ts: "t2", text: "done" }]); + ).toEqual([{ kind: "stderr", ts: "t-err", text: "provider 429" }]); }); it("treats non-protocol lines as stdout", () => { expect(parseDeepseekStdoutLine("hello", "t3")).toEqual([{ kind: "stdout", ts: "t3", text: "hello" }]); }); }); + +describe("createDeepseekStdoutParser", () => { + it("accumulates official usage and skips replayed assistant/message text after chunks", () => { + const parser = createDeepseekStdoutParser(); + expect( + parser.parseLine( + eventLine("assistant/chunk", { turn: 1, step: 1, chunk: { type: "text-delta", index: 0, text: "hi" } }), + "t0", + ), + ).toEqual([{ kind: "assistant", ts: "t0", text: "hi", delta: true }]); + expect( + parser.parseLine( + eventLine("assistant/chunk", { + turn: 1, + step: 1, + chunk: { type: "usage", usage: { inputTokens: 5, outputTokens: 2, cacheReadTokens: 1 } }, + }), + "t1", + ), + ).toEqual([]); + expect( + parser.parseLine( + eventLine("assistant/message", { + turn: 1, + step: 1, + message: { role: "assistant", content: [{ type: "text", text: "hi there" }] }, + usage: { inputTokens: 10, outputTokens: 4, cacheReadTokens: 3 }, + }), + "t2", + ), + ).toEqual([]); + expect( + parser.parseLine( + JSON.stringify({ paperclipDeepseek: 1, method: "session.status", params: { status: "idle" } }), + "t3", + ), + ).toEqual([{ + kind: "result", + ts: "t3", + text: "Run completed", + inputTokens: 10, + outputTokens: 4, + cachedTokens: 3, + costUsd: 0, + subtype: "end", + isError: false, + errors: [], + }]); + }); +}); diff --git a/packages/adapters/deepseek-harness/src/ui/parse-stdout.ts b/packages/adapters/deepseek-harness/src/ui/parse-stdout.ts index 13eb3874bf4f..46b3e84ce9a3 100644 --- a/packages/adapters/deepseek-harness/src/ui/parse-stdout.ts +++ b/packages/adapters/deepseek-harness/src/ui/parse-stdout.ts @@ -1,6 +1,18 @@ import type { TranscriptEntry } from "@paperclipai/adapter-utils"; -import { parseStdoutLine } from "../ui-parser.js"; +import { createStdoutParser, parseStdoutLine } from "../ui-parser.js"; export function parseDeepseekStdoutLine(line: string, ts: string): TranscriptEntry[] { return parseStdoutLine(line, ts) as TranscriptEntry[]; } + +export function createDeepseekStdoutParser() { + const parser = createStdoutParser(); + return { + parseLine(line: string, ts: string): TranscriptEntry[] { + return parser.parseLine(line, ts) as TranscriptEntry[]; + }, + reset() { + parser.reset(); + }, + }; +} diff --git a/ui/src/adapters/deepseek-local/index.ts b/ui/src/adapters/deepseek-local/index.ts index dd14a26fd3ef..31bae00f3155 100644 --- a/ui/src/adapters/deepseek-local/index.ts +++ b/ui/src/adapters/deepseek-local/index.ts @@ -1,11 +1,12 @@ import type { UIAdapterModule } from "../types"; -import { parseDeepseekStdoutLine, buildDeepseekConfig } from "@paperclipai/adapter-deepseek-harness/ui"; +import { parseDeepseekStdoutLine, createDeepseekStdoutParser, buildDeepseekConfig } from "@paperclipai/adapter-deepseek-harness/ui"; import { SchemaConfigFields } from "../schema-config-fields"; export const deepseekLocalUIAdapter: UIAdapterModule = { type: "deepseek_local", label: "DeepSeek Harness", parseStdoutLine: parseDeepseekStdoutLine, + createStdoutParser: createDeepseekStdoutParser, ConfigFields: SchemaConfigFields, buildAdapterConfig: buildDeepseekConfig, }; From 782672c488f36bef017c664ebd46f7c621b6da98 Mon Sep 17 00:00:00 2001 From: soyboyscout Date: Sat, 29 Aug 2026 18:07:21 -0400 Subject: [PATCH 5/8] Add DeepSeek remote/SSH/sandbox execution via a one-shot JSON-RPC bridge. Execution targets only accept one-shot stdin, so the adapter uploads a Paperclip-owned bridge that owns the duplex initialize/prompt/idle interval and restores the workspace in finally. Co-authored-by: Cursor --- .../2026-08-29-deepseek-harness-adapter.md | 11 +- docs/adapters/deepseek-local.md | 6 +- docs/adapters/overview.md | 2 +- packages/adapters/deepseek-harness/README.md | 4 + .../adapters/deepseek-harness/package.json | 2 +- .../adapters/deepseek-harness/src/index.ts | 2 + .../src/server/config-schema.ts | 12 + .../src/server/execute-remote.ts | 329 +++++++++++++++ .../src/server/execute-setup.ts | 205 +++++++++ .../src/server/execute.remote.test.ts | 177 ++++++++ .../src/server/execute.test.ts | 12 - .../deepseek-harness/src/server/execute.ts | 375 +++++++---------- .../deepseek-harness/src/server/parse.test.ts | 19 + .../deepseek-harness/src/server/parse.ts | 42 ++ .../src/server/remote-bridge-path.ts | 6 + .../src/server/remote-bridge.mjs | 397 ++++++++++++++++++ .../src/server/remote-bridge.test.ts | 42 ++ .../deepseek-harness/src/server/test.ts | 161 +++++-- .../shared/src/environment-support.test.ts | 8 + packages/shared/src/environment-support.ts | 1 + 20 files changed, 1542 insertions(+), 271 deletions(-) create mode 100644 packages/adapters/deepseek-harness/src/server/execute-remote.ts create mode 100644 packages/adapters/deepseek-harness/src/server/execute-setup.ts create mode 100644 packages/adapters/deepseek-harness/src/server/execute.remote.test.ts create mode 100644 packages/adapters/deepseek-harness/src/server/remote-bridge-path.ts create mode 100644 packages/adapters/deepseek-harness/src/server/remote-bridge.mjs create mode 100644 packages/adapters/deepseek-harness/src/server/remote-bridge.test.ts diff --git a/doc/plans/2026-08-29-deepseek-harness-adapter.md b/doc/plans/2026-08-29-deepseek-harness-adapter.md index 9ff7a357cba4..eb63848c5755 100644 --- a/doc/plans/2026-08-29-deepseek-harness-adapter.md +++ b/doc/plans/2026-08-29-deepseek-harness-adapter.md @@ -1,6 +1,6 @@ # DeepSeek Harness Adapter — Full Paperclip-Feature Parity Plan -Status: In progress (`feat/deepseek-harness-support`) +Status: Phase 3 landed (`feat/deepseek-harness-support`); Phase 4 stretch optional Date: 2026-08-29 Audience: Engineering Scope: Add a Paperclip agent adapter for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`) that matches `claude_local` on Paperclip control-plane features (heartbeat execute, sessions, skills, instructions, usage, transcripts, env test, remote/sandbox, JWT, config schema). Not a goal: make `dsh` behave like Claude Code. @@ -485,3 +485,12 @@ Parser and turn accounting now match dsh `0.1.1-rc.2` `SessionEventMap` / `Strea - `createStdoutParser` accumulates usage; idle result uses `assistant/message.usage` when present and falls back to chunk usage so the run-viewer card is not hardcoded zeros. Message and chunk usage are not double-counted. - `materializeDeepseekSkills` rewrites the materialized root (deselected siblings are deleted). - Instructions append the Claude-equivalent HEARTBEAT.md / SOUL.md path directive. + +## 21. Phase 3 results (2026-08-29) + +- `deepseek_local` is in `REMOTE_MANAGED_ADAPTERS` (local / SSH / sandbox). +- Remote execute uploads a Paperclip-owned one-shot JSON-RPC bridge because execution targets only accept one-shot stdin. The bridge implements subscribe → `session/prompt` → inbox receipt → idle and prints `paperclipDeepseek` JSONL plus `bridge-result`. +- Assets: shipped Cordis file, bridge script, session root, materialized skills. `installCommand` stays `null`. +- Workspace restore runs in `finally`. Restore failure fails the run. +- Local Linux `filesystemScope` / `networkScope` wrap the JSON-RPC runtime via `buildLocalProcessSandboxSpawnTarget`. +- Remote env test uses the same bridge after `prepareAdapterExecutionTargetRuntime`. diff --git a/docs/adapters/deepseek-local.md b/docs/adapters/deepseek-local.md index 8e9a85ced92e..44b3c5f99580 100644 --- a/docs/adapters/deepseek-local.md +++ b/docs/adapters/deepseek-local.md @@ -55,4 +55,8 @@ Desired Paperclip skills are copied into `$PAPERCLIP_HOME/adapter-state///deepseek/sessions` (`DSH_SESSION_ROOT`), not `~/.dsh`. + +## Remote / sandbox + +SSH and sandbox runs upload a one-shot JSON-RPC bridge (`remote-bridge.mjs`), the shipped Cordis file, and the session/skill roots. The remote host must already have `dsh-jsonrpc-agent` and harness plugins; `installCommand` is `null`. Workspace restore runs in `finally` and never `git push`. diff --git a/packages/adapters/deepseek-harness/package.json b/packages/adapters/deepseek-harness/package.json index f87fb4fa1a9a..fa3603908da0 100644 --- a/packages/adapters/deepseek-harness/package.json +++ b/packages/adapters/deepseek-harness/package.json @@ -52,7 +52,7 @@ "README.md" ], "scripts": { - "build": "tsc && cp src/server/paperclip.cordis.yml dist/server/paperclip.cordis.yml", + "build": "tsc && cp src/server/paperclip.cordis.yml dist/server/paperclip.cordis.yml && cp src/server/remote-bridge.mjs dist/server/remote-bridge.mjs", "clean": "rm -rf dist", "typecheck": "tsc --noEmit", "test": "vitest run" diff --git a/packages/adapters/deepseek-harness/src/index.ts b/packages/adapters/deepseek-harness/src/index.ts index 2d8521ae22a9..689b3cb3b4da 100644 --- a/packages/adapters/deepseek-harness/src/index.ts +++ b/packages/adapters/deepseek-harness/src/index.ts @@ -105,6 +105,8 @@ There is no \`claude setup-token\` login flow. | env | {} | Secret refs allowed | Sessions are stored under \`$PAPERCLIP_HOME/adapter-state///deepseek/sessions\` (\`DSH_SESSION_ROOT\`), not \`~/.dsh\`. + +SSH and sandbox targets run a one-shot JSON-RPC bridge because execution-target stdin is not duplex. The remote host must already have \`dsh-jsonrpc-agent\` and harness plugins (\`installCommand\` is null). `; export function createServerAdapter(): ServerAdapterModule { diff --git a/packages/adapters/deepseek-harness/src/server/config-schema.ts b/packages/adapters/deepseek-harness/src/server/config-schema.ts index 9e3ef7c44121..b500ed4b1ab2 100644 --- a/packages/adapters/deepseek-harness/src/server/config-schema.ts +++ b/packages/adapters/deepseek-harness/src/server/config-schema.ts @@ -80,6 +80,18 @@ export function getConfigSchema(): AdapterConfigSchema { type: "textarea", hint: "Optional custom prompt template with {{variable}} placeholders.", }, + { + key: "filesystemScope", + label: "Filesystem scope", + type: "text", + hint: "Set to workspace to confine the local JSON-RPC runtime with bwrap (Linux only).", + }, + { + key: "networkScope", + label: "Network scope", + type: "text", + hint: "deny or allowlist. Linux-only local confinement around the JSON-RPC runtime.", + }, ], }; } diff --git a/packages/adapters/deepseek-harness/src/server/execute-remote.ts b/packages/adapters/deepseek-harness/src/server/execute-remote.ts new file mode 100644 index 000000000000..5f89e55f0943 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/execute-remote.ts @@ -0,0 +1,329 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; +import { + adapterExecutionTargetSessionIdentity, + adapterExecutionTargetUsesManagedHome, + adapterExecutionTargetUsesPaperclipBridge, + adapterExecutionTargetDuplexTelemetryRecorder, + adapterExecutionTargetEnablesSandboxDuplexBridge, + describeAdapterExecutionTarget, + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, + overrideAdapterExecutionTargetRemoteCwd, + prepareAdapterExecutionTargetRuntime, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, + startAdapterExecutionTargetPaperclipBridge, +} from "@paperclipai/adapter-utils/execution-target"; +import { + asStringArray, + buildInvocationEnvForLogs, + ensurePathInEnv, + refreshPaperclipWorkspaceEnvForExecution, +} from "@paperclipai/adapter-utils/server-utils"; +import { ADAPTER_TYPE } from "../shared/constants.js"; +import { classifyDeepseekError, isUnknownSessionError } from "./protocol.js"; +import { parseBridgeStdout, parseTurnNotifications, type DeepseekTurnParse } from "./parse.js"; +import { formatRpcError, mintSessionId } from "./jsonrpc-runtime.js"; +import { resolveDeepseekRemoteBridgePath } from "./remote-bridge-path.js"; +import type { DeepseekExecuteSetup } from "./execute-setup.js"; + +export async function executeRemote(input: { + ctx: AdapterExecutionContext; + setup: DeepseekExecuteSetup; + executionTarget: AdapterExecutionTarget; +}): Promise { + const { ctx, setup } = input; + const { runId, config, onLog, onMeta, onSpawn } = ctx; + const extraArgs = asStringArray(config.extraArgs); + let restoreRemoteWorkspace: (() => Promise) | null = null; + let stageDir: string | null = null; + let paperclipBridge: Awaited> = null; + let result: AdapterExecutionResult | null = null; + + try { + await onLog( + "stdout", + `[paperclip] Syncing DeepSeek workspace and runtime assets to ${describeAdapterExecutionTarget(input.executionTarget)}.\n`, + ); + + stageDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-dsh-remote-")); + const cordisDir = path.join(stageDir, "cordis"); + const bridgeDir = path.join(stageDir, "bridge"); + await fs.mkdir(cordisDir, { recursive: true }); + await fs.mkdir(bridgeDir, { recursive: true }); + await fs.copyFile(setup.cordisConfigPath, path.join(cordisDir, "paperclip.cordis.yml")); + await fs.copyFile(resolveDeepseekRemoteBridgePath(), path.join(bridgeDir, "remote-bridge.mjs")); + + const prepared = await prepareAdapterExecutionTargetRuntime({ + runId, + target: input.executionTarget, + adapterKey: "deepseek", + workspaceLocalDir: setup.cwd, + timeoutSec: setup.timeoutSec, + installCommand: ctx.runtimeCommandSpec?.installCommand ?? null, + detectCommand: ctx.runtimeCommandSpec?.detectCommand ?? setup.command, + onProgress: (line) => onLog("stdout", line), + onRuntimeProgress: ctx.onRuntimeProgress, + assets: [ + { key: "cordis", localDir: cordisDir }, + { key: "bridge", localDir: bridgeDir }, + { key: "sessions", localDir: setup.sessionRoot }, + ...(setup.skillsDir + ? [{ key: "skills", localDir: setup.skillsDir, followSymlinks: true }] + : []), + ], + }); + restoreRemoteWorkspace = () => prepared.restoreWorkspace((line) => onLog("stdout", line)); + + const effectiveExecutionCwd = prepared.workspaceRemoteDir ?? setup.cwd; + refreshPaperclipWorkspaceEnvForExecution({ + env: setup.env, + envConfig: setup.envConfig, + workspaceCwd: setup.workspaceCwd, + workspaceSource: setup.workspaceSource, + workspaceId: setup.workspaceId, + workspaceRepoUrl: setup.workspaceRepoUrl, + workspaceRepoRef: setup.workspaceRepoRef, + workspaceHints: setup.workspaceHints, + agentHome: setup.agentHome, + executionTargetIsRemote: true, + executionCwd: effectiveExecutionCwd, + }); + + if (adapterExecutionTargetUsesManagedHome(input.executionTarget) && prepared.runtimeRootDir) { + setup.env.HOME = prepared.runtimeRootDir; + } + + const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd( + input.executionTarget, + effectiveExecutionCwd, + ); + const remoteSessionIdentity = adapterExecutionTargetSessionIdentity(runtimeExecutionTarget); + const remoteCordis = prepared.assetDirs.cordis + ? path.posix.join(prepared.assetDirs.cordis, "paperclip.cordis.yml") + : setup.cordisConfigPath; + const remoteBridge = prepared.assetDirs.bridge + ? path.posix.join(prepared.assetDirs.bridge, "remote-bridge.mjs") + : resolveDeepseekRemoteBridgePath(); + const remoteSessions = prepared.assetDirs.sessions ?? setup.sessionRoot; + const remoteSkills = prepared.assetDirs.skills ?? setup.skillsDir; + + setup.env.DSH_CWD = effectiveExecutionCwd; + setup.env.DSH_SESSION_ROOT = remoteSessions; + setup.env.DSH_CORDIS_CONFIG = remoteCordis; + setup.env.DSH_MODEL = setup.model; + setup.env.DSH_PROVIDER = setup.provider; + setup.env.DSH_JSONRPC_COMMAND = setup.command; + if (extraArgs.length > 0) setup.env.DSH_JSONRPC_ARGS = JSON.stringify(extraArgs); + if (remoteSkills) setup.env.DSH_BUNDLED_SKILL_DIR = remoteSkills; + if (setup.maxTokens > 0) setup.env.DSH_MAX_TOKENS = String(setup.maxTokens); + + if (adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) { + paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({ + runId, + target: runtimeExecutionTarget, + enableSandboxDuplexBridge: adapterExecutionTargetEnablesSandboxDuplexBridge(runtimeExecutionTarget), + duplexTelemetryRecorder: adapterExecutionTargetDuplexTelemetryRecorder(runtimeExecutionTarget), + runtimeRootDir: prepared.runtimeRootDir, + adapterKey: "deepseek", + timeoutSec: setup.timeoutSec, + hostApiToken: setup.env.PAPERCLIP_API_KEY, + }); + if (paperclipBridge) Object.assign(setup.env, paperclipBridge.env); + } + + const runtimeEnv = Object.fromEntries( + Object.entries(ensurePathInEnv({ ...process.env, ...setup.env })).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: runtimeExecutionTarget, + installCommand: ctx.runtimeCommandSpec?.installCommand ?? null, + detectCommand: ctx.runtimeCommandSpec?.detectCommand ?? setup.command, + cwd: setup.cwd, + env: runtimeEnv, + timeoutSec: setup.timeoutSec, + graceSec: setup.graceSec, + onLog, + }); + await ensureAdapterExecutionTargetCommandResolvable( + setup.command, + runtimeExecutionTarget, + setup.cwd, + runtimeEnv, + ); + const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs( + setup.command, + runtimeExecutionTarget, + setup.cwd, + runtimeEnv, + ); + + if (onMeta) { + await onMeta({ + adapterType: ADAPTER_TYPE, + command: `node ${remoteBridge}`, + cwd: effectiveExecutionCwd, + commandNotes: [ + "Remote DeepSeek runs use a one-shot JSON-RPC bridge because execution targets only accept one-shot stdin.", + `Inner runtime: ${resolvedCommand}`, + `Cordis config: ${remoteCordis}`, + "installCommand is null; the remote host must already have dsh-jsonrpc-agent and harness plugins.", + ], + env: buildInvocationEnvForLogs(setup.env, { runtimeEnv }), + prompt: setup.prompt, + promptMetrics: { + promptChars: setup.prompt.length, + wakePromptChars: setup.wakePrompt.length, + heartbeatPromptChars: setup.renderedPrompt.length, + }, + context: { + sessionId: setup.sessionId, + sessionRoot: remoteSessions, + model: setup.model, + provider: setup.provider, + }, + }); + } + + const runTurn = async (sessionId: string) => { + setup.env.DSH_SESSION_ID = sessionId; + setup.env.DSH_TIMEOUT_MS = String(setup.timeoutMs); + const turnEnv = Object.fromEntries( + Object.entries(ensurePathInEnv({ ...process.env, ...setup.env })).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + let stdout = ""; + const result = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, "node", [remoteBridge], { + cwd: effectiveExecutionCwd, + env: turnEnv, + stdin: setup.prompt, + timeoutSec: setup.timeoutSec, + graceSec: setup.graceSec, + onLog: async (stream, chunk) => { + if (stream === "stdout") stdout += chunk; + await onLog(stream, chunk); + }, + onRuntimeProgress: ctx.onRuntimeProgress, + onSpawn, + }); + const parsed = parseBridgeStdout(stdout); + return { result, parsed }; + }; + + let sessionId = setup.sessionId; + let turn = await runTurn(sessionId); + let parsed = turn.parsed; + let clearSession = false; + if (parsed.unknownSession || isUnknownSessionError(parsed.errorMessage ?? "")) { + clearSession = true; + sessionId = mintSessionId(); + await onLog("stdout", `[paperclip] DeepSeek session was unknown; retrying with a fresh session.\n`); + turn = await runTurn(sessionId); + parsed = turn.parsed; + } + + result = buildRemoteResult({ + sessionId, + cwd: effectiveExecutionCwd, + sessionRoot: remoteSessions, + model: setup.model, + persist: setup.persist, + parsed, + exitCode: turn.result.exitCode ?? (parsed.errorMessage ? 1 : 0), + signal: turn.result.signal, + timedOut: turn.result.timedOut, + clearSession, + remoteExecution: remoteSessionIdentity, + }); + } catch (error) { + const errorMessage = formatRpcError(error); + result = { + exitCode: 1, + signal: null, + timedOut: /timed out/i.test(errorMessage), + errorMessage, + errorFamily: classifyDeepseekError(errorMessage), + provider: "deepseek", + model: setup.model, + usageBasis: "per_run", + }; + } finally { + await paperclipBridge?.stop?.().catch(() => undefined); + if (restoreRemoteWorkspace) { + try { + await restoreRemoteWorkspace(); + } catch (error) { + const restoreMessage = `Workspace restore failed: ${formatRpcError(error)}`; + await onLog("stderr", `[paperclip] ${restoreMessage}\n`); + if (result) { + result = { + ...result, + exitCode: 1, + errorMessage: result.errorMessage ?? restoreMessage, + }; + } + } + } + if (stageDir) { + await fs.rm(stageDir, { recursive: true, force: true }).catch(() => undefined); + } + } + return result ?? { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: "DeepSeek remote execute produced no result", + provider: "deepseek", + model: setup.model, + usageBasis: "per_run", + }; +} + +function buildRemoteResult(input: { + sessionId: string; + cwd: string; + sessionRoot: string; + model: string; + persist: boolean; + parsed: DeepseekTurnParse; + exitCode: number | null; + signal: string | null; + timedOut: boolean; + clearSession: boolean; + remoteExecution: Record | null; +}): AdapterExecutionResult { + return { + exitCode: input.parsed.errorMessage ? 1 : input.exitCode, + signal: input.signal, + timedOut: input.timedOut, + errorMessage: input.parsed.errorMessage, + errorFamily: input.parsed.errorFamily, + usage: input.parsed.usage, + usageBasis: "per_run", + provider: "deepseek", + model: input.model, + summary: input.parsed.summary, + clearSession: input.clearSession, + ...(input.persist + ? { + sessionId: input.sessionId, + sessionParams: { + sessionId: input.sessionId, + cwd: input.cwd, + sessionRoot: input.sessionRoot, + ...(input.remoteExecution ? { remoteExecution: input.remoteExecution } : {}), + }, + sessionDisplayId: input.sessionId, + } + : {}), + }; +} diff --git a/packages/adapters/deepseek-harness/src/server/execute-setup.ts b/packages/adapters/deepseek-harness/src/server/execute-setup.ts new file mode 100644 index 000000000000..bd9f134abff1 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/execute-setup.ts @@ -0,0 +1,205 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; +import { + asNumber, + asString, + buildPaperclipEnv, + ensureAbsoluteDirectory, + joinPromptSections, + parseObject, + renderPaperclipWakePrompt, + renderTemplate, + isPaperclipRecoveryWakePayload, + stringifyPaperclipWakePayload, + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, +} from "@paperclipai/adapter-utils/server-utils"; +import { sessionCodec } from "./session.js"; +import { + persistSessionEnabled, + resolveCordisConfigPath, + resolveDeepseekCommand, + resolveDeepseekModel, + resolveDeepseekProvider, + resolveDeepseekSessionRoot, + resolveGraceSec, + resolveHarnessRoot, + resolveTimeoutSec, + buildRuntimeNodePath, +} from "./runtime-config.js"; +import { materializeDeepseekSkills, resolveDeepseekSkillsDir } from "./skills.js"; + +export interface DeepseekExecuteSetup { + cwd: string; + env: Record; + envConfig: Record; + command: string; + extraArgs: string[]; + harnessRoot: string | null; + cordisConfigPath: string; + model: string; + provider: string; + timeoutSec: number; + graceSec: number; + timeoutMs: number; + maxTokens: number; + sessionRoot: string; + skillsDir: string | null; + persist: boolean; + savedSessionId: string; + savedCwd: string; + savedSessionRoot: string; + wakePrompt: string; + renderedPrompt: string; + prompt: string; + workspaceCwd: string; + workspaceSource: string; + workspaceId: string; + workspaceRepoUrl: string; + workspaceRepoRef: string; + workspaceHints: Array>; + agentHome: string; + sessionId: string; +} + +export async function resolveDeepseekExecuteSetup( + ctx: AdapterExecutionContext, + input: { sessionId: string; canResume: boolean }, +): Promise { + const { runId, agent, runtime, config, context, onLog, authToken } = ctx; + const workspaceContext = parseObject(context.paperclipWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const workspaceSource = asString(workspaceContext.source, ""); + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const cwd = (useConfiguredInsteadOfAgentHome ? "" : workspaceCwd) || configuredCwd || process.cwd(); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + + const envConfig = parseObject(config.env); + const env: Record = { ...buildPaperclipEnv(agent) }; + env.PAPERCLIP_RUN_ID = runId; + if (authToken) env.PAPERCLIP_API_KEY = authToken; + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + + const wakeTaskId = asString(context.taskId, "") || asString(context.issueId, "") || null; + if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + + const sessionRoot = resolveDeepseekSessionRoot({ + companyId: agent.companyId, + agentId: agent.id, + sessionRoot: asString(config.sessionRoot, ""), + }); + await fs.mkdir(sessionRoot, { recursive: true }); + const skillsDir = resolveDeepseekSkillsDir({ companyId: agent.companyId, agentId: agent.id }); + const stagedSkills = await materializeDeepseekSkills({ config, destDir: skillsDir }); + if (stagedSkills > 0) { + env.DSH_BUNDLED_SKILL_DIR = skillsDir; + await onLog("stdout", `[paperclip] Materialized ${stagedSkills} DeepSeek skill(s) into ${skillsDir}\n`); + } + + const command = resolveDeepseekCommand(config, env); + const harnessRoot = resolveHarnessRoot(config, env); + const cordisConfigPath = resolveCordisConfigPath(config, env); + const model = resolveDeepseekModel(config, env); + const provider = resolveDeepseekProvider(config); + const timeoutSec = resolveTimeoutSec(config); + const graceSec = resolveGraceSec(config); + const maxTokens = asNumber(config.maxTokens, 0); + + env.DSH_CWD = cwd; + env.DSH_SESSION_ROOT = sessionRoot; + env.DSH_MODEL = model; + env.DSH_CORDIS_CONFIG = cordisConfigPath; + const nodePath = buildRuntimeNodePath(harnessRoot, env.NODE_PATH ?? process.env.NODE_PATH); + if (nodePath) env.NODE_PATH = nodePath; + + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + if (instructionsFilePath) { + try { + const instructionsContent = await fs.readFile(instructionsFilePath, "utf8"); + const instructionsFileDir = path.dirname(path.resolve(instructionsFilePath)); + env.DSH_SYSTEM_PROMPT = + `${instructionsContent}\nThe above agent instructions were loaded from ${instructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsFileDir}. ` + + `This base directory is authoritative for sibling instruction files such as ` + + `./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`; + } catch { + await onLog( + "stderr", + `[paperclip] DeepSeek instructions file was missing: ${instructionsFilePath}\n`, + ); + } + } + + const runtimeSession = sessionCodec.deserialize(runtime.sessionParams) ?? {}; + const savedSessionId = asString(runtimeSession.sessionId, runtime.sessionId ?? ""); + const savedCwd = asString(runtimeSession.cwd, ""); + const savedSessionRoot = asString(runtimeSession.sessionRoot, ""); + const persist = persistSessionEnabled(config); + + const promptTemplate = asString(config.promptTemplate, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE); + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { + resumedSession: input.canResume, + }); + const renderedPrompt = + (input.canResume && wakePrompt.length > 0) || isPaperclipRecoveryWakePayload(context.paperclipWake) + ? "" + : renderTemplate(promptTemplate, { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }); + const prompt = joinPromptSections([ + wakePrompt, + asString(context.paperclipSessionHandoffMarkdown, "").trim(), + renderedPrompt, + ]); + + const contextRecord = context as Record; + const paperclipWorkspaces = Array.isArray(contextRecord.paperclipWorkspaces) + ? contextRecord.paperclipWorkspaces.filter((value): value is Record => ( + typeof value === "object" && value !== null && !Array.isArray(value) + )) + : []; + + return { + cwd, + env, + envConfig, + command, + extraArgs: [], + harnessRoot, + cordisConfigPath, + model, + provider, + timeoutSec, + graceSec, + timeoutMs: timeoutSec > 0 ? timeoutSec * 1000 : 30 * 60 * 1000, + maxTokens, + sessionRoot, + skillsDir: stagedSkills > 0 ? skillsDir : null, + persist, + savedSessionId, + savedCwd, + savedSessionRoot, + wakePrompt, + renderedPrompt, + prompt, + workspaceCwd, + workspaceSource, + workspaceId: asString(workspaceContext.workspaceId, asString(workspaceContext.id, "")), + workspaceRepoUrl: asString(workspaceContext.repoUrl, ""), + workspaceRepoRef: asString(workspaceContext.repoRef, ""), + workspaceHints: paperclipWorkspaces, + agentHome: asString(workspaceContext.agentHome, ""), + sessionId: input.sessionId, + }; +} diff --git a/packages/adapters/deepseek-harness/src/server/execute.remote.test.ts b/packages/adapters/deepseek-harness/src/server/execute.remote.test.ts new file mode 100644 index 000000000000..15c5a4462fc7 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/execute.remote.test.ts @@ -0,0 +1,177 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; + +const prepareMock = vi.hoisted(() => vi.fn()); +const runProcessMock = vi.hoisted(() => vi.fn()); +const restoreMock = vi.hoisted(() => vi.fn(async () => {})); +const bridgeStopMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + prepareAdapterExecutionTargetRuntime: prepareMock, + runAdapterExecutionTargetProcess: runProcessMock, + ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => {}), + ensureAdapterExecutionTargetRuntimeCommandInstalled: vi.fn(async () => {}), + resolveAdapterExecutionTargetCommandForLogs: vi.fn(async () => "dsh-jsonrpc-agent"), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ + env: { + PAPERCLIP_API_URL: "http://127.0.0.1:4310", + PAPERCLIP_API_KEY: "bridge-token", + }, + stop: bridgeStopMock, + })), + }; +}); + +import { execute } from "./execute.js"; + +const tempDirs: string[] = []; +const originalHome = process.env.PAPERCLIP_HOME; + +beforeEach(async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "dsh-remote-home-")); + tempDirs.push(home); + process.env.PAPERCLIP_HOME = home; + prepareMock.mockReset(); + runProcessMock.mockReset(); + restoreMock.mockClear(); + bridgeStopMock.mockClear(); + prepareMock.mockResolvedValue({ + workspaceRemoteDir: "/remote/workspace", + runtimeRootDir: "/remote/runtime", + assetDirs: { + cordis: "/remote/assets/cordis", + bridge: "/remote/assets/bridge", + sessions: "/remote/assets/sessions", + }, + restoreWorkspace: restoreMock, + }); + runProcessMock.mockImplementation(async (_runId, _target, command, args, options) => { + expect(command).toBe("node"); + expect(args[0]).toBe("/remote/assets/bridge/remote-bridge.mjs"); + const sessionId = options.env.DSH_SESSION_ID; + await options.onLog( + "stdout", + `${JSON.stringify({ + paperclipDeepseek: 1, + kind: "bridge-result", + sessionId, + usage: { inputTokens: 22, outputTokens: 8, cachedInputTokens: 4 }, + summary: "ack remote", + toolName: "bash", + })}\n`, + ); + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: 99, + startedAt: new Date().toISOString(), + }; + }); +}); + +afterEach(async () => { + if (originalHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = originalHome; + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +async function makeRemoteCtx(): Promise { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dsh-remote-cwd-")); + tempDirs.push(cwd); + return { + runId: "run-remote-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "DeepSeek", + adapterType: "deepseek_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + cwd, + persistSession: true, + timeoutSec: 10, + graceSec: 2, + env: { DEEPSEEK_API_KEY: "test-key" }, + }, + context: {}, + executionTarget: { + kind: "remote", + transport: "sandbox", + remoteCwd: "/remote/workspace", + providerKey: "fake-plugin", + }, + onLog: async () => {}, + } as AdapterExecutionContext; +} + +describe("execute remote", () => { + it("prepares assets, runs the JSON-RPC bridge, and restores the workspace", async () => { + const ctx = await makeRemoteCtx(); + const result = await execute(ctx); + expect(result.exitCode).toBe(0); + expect(result.summary).toBe("ack remote"); + expect(result.usage).toEqual({ inputTokens: 22, outputTokens: 8, cachedInputTokens: 4 }); + expect(result.sessionParams).toMatchObject({ + cwd: "/remote/workspace", + sessionRoot: "/remote/assets/sessions", + remoteExecution: { + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/remote/workspace", + }, + }); + expect(prepareMock).toHaveBeenCalledTimes(1); + const prepareInput = prepareMock.mock.calls[0]?.[0] as { assets?: Array<{ key: string }> }; + expect(prepareInput.assets?.map((asset) => asset.key).sort()).toEqual(["bridge", "cordis", "sessions"]); + expect(runProcessMock).toHaveBeenCalledTimes(1); + expect(restoreMock).toHaveBeenCalledTimes(1); + expect(bridgeStopMock).toHaveBeenCalledTimes(1); + }); + + it("retries the bridge once when the remote session is unknown", async () => { + runProcessMock + .mockImplementationOnce(async (_runId, _target, _command, _args, options) => { + await options.onLog( + "stdout", + `${JSON.stringify({ + paperclipDeepseek: 1, + kind: "bridge-result", + sessionId: options.env.DSH_SESSION_ID, + errorMessage: "unknown session stale-old", + })}\n`, + ); + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: 99, + startedAt: new Date().toISOString(), + }; + }); + const ctx = await makeRemoteCtx(); + const result = await execute(ctx); + expect(result.exitCode).toBe(0); + expect(result.clearSession).toBe(true); + expect(runProcessMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/adapters/deepseek-harness/src/server/execute.test.ts b/packages/adapters/deepseek-harness/src/server/execute.test.ts index 38ebb6543793..540dcd7ba06b 100644 --- a/packages/adapters/deepseek-harness/src/server/execute.test.ts +++ b/packages/adapters/deepseek-harness/src/server/execute.test.ts @@ -91,18 +91,6 @@ describe("execute", () => { expect(result.summary).toMatch(/^ack /); }); - it("rejects remote execution targets in Phase 1/2", async () => { - const ctx = await makeCtx(); - ctx.executionTarget = { - kind: "remote", - transport: "sandbox", - remoteCwd: "/remote/workspace", - } as AdapterExecutionContext["executionTarget"]; - const result = await execute(ctx); - expect(result.errorCode).toBe("remote_not_implemented"); - expect(result.exitCode).toBe(1); - }); - it("resumes the same sessionId on a second heartbeat", async () => { const firstCtx = await makeCtx(); const first = await execute(firstCtx); diff --git a/packages/adapters/deepseek-harness/src/server/execute.ts b/packages/adapters/deepseek-harness/src/server/execute.ts index d1d30be35a0d..fc1a1e3b60d0 100644 --- a/packages/adapters/deepseek-harness/src/server/execute.ts +++ b/packages/adapters/deepseek-harness/src/server/execute.ts @@ -1,41 +1,26 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { adapterExecutionTargetIsRemote, + adapterExecutionTargetSessionMatches, readAdapterExecutionTarget, } from "@paperclipai/adapter-utils/execution-target"; import { - asNumber, - asString, asStringArray, buildInvocationEnvForLogs, - buildPaperclipEnv, - ensureAbsoluteDirectory, - joinPromptSections, parseObject, - renderPaperclipWakePrompt, - renderTemplate, - isPaperclipRecoveryWakePayload, - stringifyPaperclipWakePayload, - DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, } from "@paperclipai/adapter-utils/server-utils"; +import { + buildLocalProcessSandboxSpawnTarget, + parseLocalProcessFilesystemScope, + parseLocalProcessNetworkAllowlist, + parseLocalProcessNetworkScope, + parseLocalProcessSandboxExtraPaths, + type LocalProcessSandboxOptions, +} from "@paperclipai/adapter-utils/local-process-sandbox"; import { ADAPTER_TYPE } from "../shared/constants.js"; import { classifyDeepseekError, isUnknownSessionError } from "./protocol.js"; import { parseTurnNotifications } from "./parse.js"; -import { canResumeDeepseekSession, sessionCodec } from "./session.js"; -import { - buildRuntimeNodePath, - persistSessionEnabled, - resolveCordisConfigPath, - resolveDeepseekCommand, - resolveDeepseekModel, - resolveDeepseekProvider, - resolveDeepseekSessionRoot, - resolveGraceSec, - resolveHarnessRoot, - resolveTimeoutSec, -} from "./runtime-config.js"; +import { canResumeDeepseekSession } from "./session.js"; import { formatRpcError, initializeRuntime, @@ -43,177 +28,127 @@ import { promptAndWait, spawnDeepseekRuntime, } from "./jsonrpc-runtime.js"; -import { materializeDeepseekSkills, resolveDeepseekSkillsDir } from "./skills.js"; +import { resolveDeepseekExecuteSetup } from "./execute-setup.js"; +import { executeRemote } from "./execute-remote.js"; import type { JsonRpcNotification } from "./jsonrpc-client.js"; export async function execute(ctx: AdapterExecutionContext): Promise { - const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; + const { runtime, config, onLog, onMeta, onSpawn } = ctx; const executionTarget = readAdapterExecutionTarget({ executionTarget: ctx.executionTarget, legacyRemoteExecution: ctx.executionTransport?.remoteExecution, }); - if (adapterExecutionTargetIsRemote(executionTarget)) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - "deepseek_local remote/SSH/sandbox execution is not implemented in Phase 1. Use a local execution target.", - errorCode: "remote_not_implemented", - }; - } - - const workspaceContext = parseObject(context.paperclipWorkspace); - const workspaceCwd = asString(workspaceContext.cwd, ""); - const workspaceSource = asString(workspaceContext.source, ""); - const configuredCwd = asString(config.cwd, ""); - const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; - const cwd = (useConfiguredInsteadOfAgentHome ? "" : workspaceCwd) || configuredCwd || process.cwd(); - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); - - const envConfig = parseObject(config.env); - const env: Record = { ...buildPaperclipEnv(agent) }; - env.PAPERCLIP_RUN_ID = runId; - if (authToken) env.PAPERCLIP_API_KEY = authToken; - for (const [key, value] of Object.entries(envConfig)) { - if (typeof value === "string") env[key] = value; - } - - const wakeTaskId = - asString(context.taskId, "") || asString(context.issueId, "") || null; - if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; - const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); - if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget); - const sessionRoot = resolveDeepseekSessionRoot({ - companyId: agent.companyId, - agentId: agent.id, - sessionRoot: asString(config.sessionRoot, ""), + const preliminary = await resolveDeepseekExecuteSetup(ctx, { + sessionId: mintSessionId(), + canResume: false, }); - await fs.mkdir(sessionRoot, { recursive: true }); - const skillsDir = resolveDeepseekSkillsDir({ companyId: agent.companyId, agentId: agent.id }); - const stagedSkills = await materializeDeepseekSkills({ config, destDir: skillsDir }); - if (stagedSkills > 0) { - env.DSH_BUNDLED_SKILL_DIR = skillsDir; - await onLog("stdout", `[paperclip] Materialized ${stagedSkills} DeepSeek skill(s) into ${skillsDir}\n`); - } - - const command = resolveDeepseekCommand(config, env); - const harnessRoot = resolveHarnessRoot(config, env); - const cordisConfigPath = resolveCordisConfigPath(config, env); - const model = resolveDeepseekModel(config, env); - const provider = resolveDeepseekProvider(config); - const timeoutSec = resolveTimeoutSec(config); - const graceSec = resolveGraceSec(config); - const maxTokens = asNumber(config.maxTokens, 0); - - env.DSH_CWD = cwd; - env.DSH_SESSION_ROOT = sessionRoot; - env.DSH_MODEL = model; - env.DSH_CORDIS_CONFIG = cordisConfigPath; - const nodePath = buildRuntimeNodePath(harnessRoot, env.NODE_PATH ?? process.env.NODE_PATH); - if (nodePath) env.NODE_PATH = nodePath; - - const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); - if (instructionsFilePath) { - try { - const instructionsContent = await fs.readFile(instructionsFilePath, "utf8"); - const instructionsFileDir = path.dirname(path.resolve(instructionsFilePath)); - env.DSH_SYSTEM_PROMPT = - `${instructionsContent}\nThe above agent instructions were loaded from ${instructionsFilePath}. ` + - `Resolve any relative file references from ${instructionsFileDir}. ` + - `This base directory is authoritative for sibling instruction files such as ` + - `./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`; - } catch { - await onLog( - "stderr", - `[paperclip] DeepSeek instructions file was missing: ${instructionsFilePath}\n`, - ); - } - } - - const runtimeSession = sessionCodec.deserialize(runtime.sessionParams) ?? {}; - const savedSessionId = asString(runtimeSession.sessionId, runtime.sessionId ?? ""); - const savedCwd = asString(runtimeSession.cwd, ""); - const savedSessionRoot = asString(runtimeSession.sessionRoot, ""); - const persist = persistSessionEnabled(config); + const savedRemoteExecution = parseObject( + (runtime.sessionParams && typeof runtime.sessionParams === "object" + ? (runtime.sessionParams as Record).remoteExecution + : null), + ); const canResume = - persist && + preliminary.persist && canResumeDeepseekSession({ - sessionId: savedSessionId || null, - savedCwd: savedCwd || null, - savedSessionRoot: savedSessionRoot || null, - cwd, - sessionRoot, - }); - let sessionId = canResume ? savedSessionId : persist ? mintSessionId() : mintSessionId(); - if (savedSessionId && !canResume) { + sessionId: preliminary.savedSessionId || null, + savedCwd: executionTargetIsRemote ? null : preliminary.savedCwd || null, + savedSessionRoot: preliminary.savedSessionRoot || null, + cwd: preliminary.cwd, + sessionRoot: preliminary.sessionRoot, + }) && + (executionTargetIsRemote + ? adapterExecutionTargetSessionMatches(savedRemoteExecution, executionTarget) + : Object.keys(savedRemoteExecution).length === 0); + const sessionId = canResume && preliminary.savedSessionId ? preliminary.savedSessionId : mintSessionId(); + const setup = canResume + ? await resolveDeepseekExecuteSetup(ctx, { sessionId, canResume: true }) + : { ...preliminary, sessionId }; + setup.extraArgs = asStringArray(config.extraArgs); + + if (preliminary.savedSessionId && !canResume) { await onLog( "stdout", - `[paperclip] DeepSeek session "${savedSessionId}" does not match cwd/sessionRoot and will not be resumed.\n`, + `[paperclip] DeepSeek session "${preliminary.savedSessionId}" does not match cwd/sessionRoot/target and will not be resumed.\n`, ); } - const promptTemplate = asString(config.promptTemplate, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE); - const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { - resumedSession: Boolean(canResume && savedSessionId), - }); - const renderedPrompt = - (canResume && wakePrompt.length > 0) || isPaperclipRecoveryWakePayload(context.paperclipWake) - ? "" - : renderTemplate(promptTemplate, { - agentId: agent.id, - companyId: agent.companyId, - runId, - company: { id: agent.companyId }, - agent, - run: { id: runId, source: "on_demand" }, - context, - }); - const prompt = joinPromptSections([ - wakePrompt, - asString(context.paperclipSessionHandoffMarkdown, "").trim(), - renderedPrompt, - ]); - - const runtimeEnv = Object.fromEntries( - Object.entries({ ...process.env, ...env }).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ); + if (executionTargetIsRemote && executionTarget) { + return executeRemote({ ctx, setup, executionTarget }); + } if (onMeta) { await onMeta({ adapterType: ADAPTER_TYPE, - command, - cwd, + command: setup.command, + cwd: setup.cwd, commandNotes: [ "Drives DeepSeek Harness over JSON-RPC stdio (initialize + session/prompt).", - `Cordis config: ${cordisConfigPath}`, - harnessRoot ? `Harness root / NODE_PATH: ${harnessRoot}` : "No harnessRoot set; plugins must resolve from the configuration project.", + `Cordis config: ${setup.cordisConfigPath}`, + setup.harnessRoot + ? `Harness root / NODE_PATH: ${setup.harnessRoot}` + : "No harnessRoot set; plugins must resolve from the configuration project.", "Do not add a stdout logger to the Cordis composition.", ], - env: buildInvocationEnvForLogs(env, { runtimeEnv }), - prompt, + env: buildInvocationEnvForLogs(setup.env, { + runtimeEnv: Object.fromEntries( + Object.entries({ ...process.env, ...setup.env }).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ), + }), + prompt: setup.prompt, promptMetrics: { - promptChars: prompt.length, - wakePromptChars: wakePrompt.length, - heartbeatPromptChars: renderedPrompt.length, + promptChars: setup.prompt.length, + wakePromptChars: setup.wakePrompt.length, + heartbeatPromptChars: setup.renderedPrompt.length, }, - context: { sessionId, sessionRoot, model, provider }, + context: { sessionId: setup.sessionId, sessionRoot: setup.sessionRoot, model: setup.model, provider: setup.provider }, }); } - const timeoutMs = timeoutSec > 0 ? timeoutSec * 1000 : 30 * 60 * 1000; - let timedOut = false; - let clearSession = false; - let exitCode: number | null = 0; - let signal: string | null = null; - let errorMessage: string | null = null; + const filesystemScope = parseLocalProcessFilesystemScope(config.filesystemScope); + const networkScope = parseLocalProcessNetworkScope(config.networkScope); + let command = setup.command; + let args = setup.extraArgs; + let cwd = setup.cwd; + const runtimeEnv: NodeJS.ProcessEnv = Object.fromEntries( + Object.entries({ ...process.env, ...setup.env }).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + let sandboxCleanup: (() => Promise) | undefined; + if (filesystemScope || networkScope) { + const localProcessSandbox: LocalProcessSandboxOptions = { + workspaceDir: setup.cwd, + filesystemScope, + extraPaths: parseLocalProcessSandboxExtraPaths(config.filesystemExtraPaths), + homeDir: filesystemScope ? setup.sessionRoot : null, + networkScope, + networkAllowlist: parseLocalProcessNetworkAllowlist(config.networkAllowlist), + command: typeof config.filesystemSandboxCommand === "string" ? config.filesystemSandboxCommand : "bwrap", + }; + const scopes = [filesystemScope ? "workspace filesystem" : null, networkScope ? `${networkScope} network` : null] + .filter(Boolean) + .join(" and "); + await onLog("stdout", `[paperclip] Confining DeepSeek Harness with ${scopes} scope.\n`); + const sandboxed = await buildLocalProcessSandboxSpawnTarget({ + executable: command, + args, + cwd, + options: localProcessSandbox, + }); + command = sandboxed.command; + args = sandboxed.args; + cwd = sandboxed.cwd; + Object.assign(runtimeEnv, sandboxed.env); + sandboxCleanup = sandboxed.cleanup; + } const runtimeHandle = await spawnDeepseekRuntime({ command, - args: asStringArray(config.extraArgs), + args, cwd, env: runtimeEnv, onStderr: (chunk) => onLog("stderr", chunk), @@ -226,39 +161,36 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? { maxTokens } : {}), + cwd: setup.cwd, + provider: setup.provider, + model: setup.model, + ...(setup.maxTokens > 0 ? { maxTokens: setup.maxTokens } : {}), }); const runPrompt = async (id: string) => promptAndWait({ client: runtimeHandle.client, sessionId: id, - prompt, - timeoutMs, + prompt: setup.prompt, + timeoutMs: setup.timeoutMs, onNotification: emitNotification, }); try { - const turn = await runPrompt(sessionId); + const turn = await runPrompt(setup.sessionId); const parsed = parseTurnNotifications(turn.notifications); - errorMessage = parsed.errorMessage; if (parsed.unknownSession) { - clearSession = true; - sessionId = mintSessionId(); + const retryId = mintSessionId(); await onLog("stdout", `[paperclip] DeepSeek session was unknown; retrying with a fresh session.\n`); - const retry = await runPrompt(sessionId); + const retry = await runPrompt(retryId); const retryParsed = parseTurnNotifications(retry.notifications); - const closed = await runtimeHandle.close(graceSec * 1000); + const closed = await runtimeHandle.close(setup.graceSec * 1000); return buildResult({ - sessionId, - cwd, - sessionRoot, - model, - provider, - persist, + sessionId: retryId, + cwd: setup.cwd, + sessionRoot: setup.sessionRoot, + model: setup.model, + persist: setup.persist, parsed: retryParsed, exitCode: closed.exitCode ?? (retryParsed.errorMessage ? 1 : 0), signal: closed.signal, @@ -266,39 +198,34 @@ export async function execute(ctx: AdapterExecutionContext): Promise undefined); } } @@ -366,7 +296,6 @@ function buildResult(input: { cwd: string; sessionRoot: string; model: string; - provider: string; persist: boolean; parsed: ReturnType; exitCode: number | null; diff --git a/packages/adapters/deepseek-harness/src/server/parse.test.ts b/packages/adapters/deepseek-harness/src/server/parse.test.ts index 60f8775ac569..0e51e77df29c 100644 --- a/packages/adapters/deepseek-harness/src/server/parse.test.ts +++ b/packages/adapters/deepseek-harness/src/server/parse.test.ts @@ -86,3 +86,22 @@ describe("parseTurnNotifications", () => { expect(parsed.usage).toEqual({ inputTokens: 3, outputTokens: 1, cachedInputTokens: 0 }); }); }); + +describe("parseBridgeStdout", () => { + it("reads the last bridge-result line", async () => { + const { parseBridgeStdout } = await import("./parse.js"); + const parsed = parseBridgeStdout([ + JSON.stringify({ paperclipDeepseek: 1, method: "session.status", params: { status: "idle" } }), + JSON.stringify({ + paperclipDeepseek: 1, + kind: "bridge-result", + sessionId: "s1", + usage: { inputTokens: 2, outputTokens: 1, cachedInputTokens: 0 }, + summary: "ok", + }), + ].join("\n")); + expect(parsed.sessionId).toBe("s1"); + expect(parsed.summary).toBe("ok"); + expect(parsed.usage.inputTokens).toBe(2); + }); +}); diff --git a/packages/adapters/deepseek-harness/src/server/parse.ts b/packages/adapters/deepseek-harness/src/server/parse.ts index c2350de26bf0..45911b8b1ad7 100644 --- a/packages/adapters/deepseek-harness/src/server/parse.ts +++ b/packages/adapters/deepseek-harness/src/server/parse.ts @@ -65,4 +65,46 @@ export function parseTurnNotifications(notifications: JsonRpcNotification[]): De }; } +export function parseBridgeResultLine(line: string): DeepseekTurnParse & { sessionId?: string } | null { + const trimmed = line.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed) as Record; + if (parsed.paperclipDeepseek !== 1 || parsed.kind !== "bridge-result") return null; + const usage = isRecord(parsed.usage) ? parsed.usage : {}; + const errorMessage = typeof parsed.errorMessage === "string" && parsed.errorMessage ? parsed.errorMessage : null; + return { + usage: { + inputTokens: Number(usage.inputTokens ?? 0), + outputTokens: Number(usage.outputTokens ?? 0), + cachedInputTokens: Number(usage.cachedInputTokens ?? 0), + }, + summary: typeof parsed.summary === "string" ? parsed.summary : null, + toolName: typeof parsed.toolName === "string" ? parsed.toolName : null, + errorMessage, + errorFamily: errorMessage ? classifyDeepseekError(errorMessage) : null, + unknownSession: errorMessage ? isUnknownSessionError(errorMessage) : false, + sessionId: typeof parsed.sessionId === "string" ? parsed.sessionId : undefined, + }; + } catch { + return null; + } +} + +export function parseBridgeStdout(stdout: string): DeepseekTurnParse & { sessionId?: string } { + let latest: DeepseekTurnParse & { sessionId?: string } | null = null; + for (const line of stdout.split(/\r?\n/)) { + const parsed = parseBridgeResultLine(line); + if (parsed) latest = parsed; + } + return latest ?? { + usage: EMPTY_USAGE, + summary: null, + toolName: null, + errorMessage: "DeepSeek remote bridge did not emit a result", + errorFamily: null, + unknownSession: false, + }; +} + export { isUnknownSessionError }; diff --git a/packages/adapters/deepseek-harness/src/server/remote-bridge-path.ts b/packages/adapters/deepseek-harness/src/server/remote-bridge-path.ts new file mode 100644 index 000000000000..c2ce837d2615 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/remote-bridge-path.ts @@ -0,0 +1,6 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export function resolveDeepseekRemoteBridgePath(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "remote-bridge.mjs"); +} diff --git a/packages/adapters/deepseek-harness/src/server/remote-bridge.mjs b/packages/adapters/deepseek-harness/src/server/remote-bridge.mjs new file mode 100644 index 000000000000..f0a404e2e796 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/remote-bridge.mjs @@ -0,0 +1,397 @@ +#!/usr/bin/env node +/** + * One-shot JSON-RPC driver for remote/SSH/sandbox targets. + * Paperclip's execution-target process API only supplies one-shot stdin, so this + * process owns the duplex JSON-RPC conversation and prints paperclipDeepseek + * JSONL (plus a final bridge-result) on stdout. + * + * Prompt: stdin + * Runtime: DSH_JSONRPC_COMMAND + optional DSH_JSONRPC_ARGS (JSON array) + */ +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { once } from "node:events"; + +const command = (process.env.DSH_JSONRPC_COMMAND ?? "dsh-jsonrpc-agent").trim(); +const args = parseJsonArgs(process.env.DSH_JSONRPC_ARGS); +const cwd = (process.env.DSH_CWD ?? process.cwd()).trim() || process.cwd(); +const sessionId = (process.env.DSH_SESSION_ID ?? `paperclip-dsh-${Date.now()}`).trim(); +const model = (process.env.DSH_MODEL ?? "deepseek-v4-flash").trim(); +const provider = (process.env.DSH_PROVIDER ?? "deepseek-official").trim(); +const timeoutMs = Math.max(1_000, Number(process.env.DSH_TIMEOUT_MS ?? 30 * 60 * 1000) || 30 * 60 * 1000); +const maxTokens = Number(process.env.DSH_MAX_TOKENS ?? 0) || 0; + +const prompt = await readStdin(); +if (!prompt.trim()) { + writeBridgeResult({ errorMessage: "DeepSeek remote bridge received an empty prompt" }); + process.exit(1); +} + +const child = spawn(command, args, { + cwd, + env: process.env, + stdio: ["pipe", "pipe", "pipe"], +}); + +child.stderr.on("data", (chunk) => { + process.stderr.write(chunk); +}); + +const client = createNdjsonClient(child); +const notifications = []; +const sessionParents = new Map(); + +function emitNotification(notification) { + notifications.push(notification); + writeLine({ paperclipDeepseek: 1, method: notification.method, params: notification.params }); +} + +try { + await request(client, "initialize", { + cwd, + provider, + model, + ...(maxTokens > 0 ? { maxTokens } : {}), + }, 15_000); + + const result = await request(client, "session/prompt", { + sessionId, + contentBlocks: [{ type: "text", text: prompt }], + }, 15_000); + const messageId = result && typeof result.messageId === "string" ? result.messageId : ""; + if (!messageId) throw new Error(`session/prompt did not return messageId: ${JSON.stringify(result)}`); + + await waitForPromptTurn({ + client, + sessionId, + messageId, + timeoutMs, + sessionParents, + onNotification: emitNotification, + }); + + try { + await Promise.race([request(client, "shutdown", {}, 2_000), sleep(1_000)]); + } catch { + // Process kill is the cancel story. + } + child.stdin.end(); + await Promise.race([once(child, "exit"), sleep(2_000)]); + writeBridgeResult(parseNotifications(notifications, sessionId)); + client.close(); + process.exit(0); +} catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + writeBridgeResult({ ...parseNotifications(notifications, sessionId), errorMessage }); + try { + child.kill("SIGTERM"); + } catch { + // already gone + } + client.close(); + process.exit(/unknown session/i.test(errorMessage) ? 0 : 1); +} + +function parseJsonArgs(raw) { + if (!raw || !raw.trim()) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.map((value) => String(value)) : []; + } catch { + return []; + } +} + +function readStdin() { + return new Promise((resolve, reject) => { + const chunks = []; + process.stdin.on("data", (chunk) => chunks.push(chunk)); + process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + process.stdin.on("error", reject); + }); +} + +function writeLine(value) { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +function writeBridgeResult(result) { + writeLine({ paperclipDeepseek: 1, kind: "bridge-result", ...result }); +} + +function createNdjsonClient(child) { + let nextId = 1; + const pending = new Map(); + const waiters = []; + const queue = []; + let closed = false; + let failure = null; + const rl = createInterface({ input: child.stdout }); + + function fail(error) { + if (failure || closed) return; + failure = error; + for (const waiter of pending.values()) waiter.reject(error); + pending.clear(); + for (const waiter of waiters) waiter.reject(error); + waiters.length = 0; + } + + rl.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let message; + try { + message = JSON.parse(trimmed); + } catch { + return; + } + if (message.id != null && (message.result !== undefined || message.error)) { + const waiter = pending.get(Number(message.id)); + if (!waiter) return; + pending.delete(Number(message.id)); + if (message.error) { + waiter.reject(new Error(message.error.message || JSON.stringify(message.error))); + } else { + waiter.resolve(message.result); + } + return; + } + if (typeof message.method !== "string") return; + const notification = { + method: message.method, + params: message.params && typeof message.params === "object" ? message.params : {}, + }; + if (waiters.length > 0) waiters.shift().resolve(notification); + else queue.push(notification); + }); + rl.on("close", () => fail(new Error("DeepSeek Harness JSON-RPC stdout closed"))); + child.on("error", fail); + + return { + request(method, params) { + if (closed) return Promise.reject(new Error("JSON-RPC client is closed")); + const id = nextId++; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`, (error) => { + if (error) { + pending.delete(id); + reject(error); + } + }); + }); + }, + async next() { + if (failure) throw failure; + if (queue.length > 0) return queue.shift(); + return new Promise((resolve, reject) => { + if (failure) { + reject(failure); + return; + } + waiters.push({ resolve, reject }); + }); + }, + tryNext() { + if (failure) throw failure; + return queue.shift() ?? null; + }, + close() { + if (closed) return; + closed = true; + rl.close(); + const error = new Error("JSON-RPC client closed"); + for (const waiter of pending.values()) waiter.reject(error); + pending.clear(); + for (const waiter of waiters) waiter.reject(error); + waiters.length = 0; + }, + }; +} + +async function request(client, method, params, timeoutMsValue) { + const pending = client.request(method, params); + const timeout = timeoutReject(timeoutMsValue, `${method} timed out`); + try { + return await Promise.race([pending, timeout.promise]); + } finally { + timeout.cancel(); + void pending.catch(() => {}); + } +} + +async function waitForPromptTurn(input) { + let received = false; + const deadline = Date.now() + input.timeoutMs; + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error( + received + ? `timed out waiting for session.status idle after inbox receipt ${input.messageId}` + : `timed out waiting for agent/inbox/spliced receipt ${input.messageId}`, + ); + } + const notification = await nextWithTimeout(input.client, remaining); + if (!isInSessionTree(notification, input.sessionId, input.sessionParents)) continue; + recordSessionRelationship(notification, input.sessionParents); + if (!received) { + if ( + notification.method !== "session.event" || + notification.params.sessionId !== input.sessionId || + !isInboxReceipt(notification.params.event, input.messageId) + ) { + continue; + } + received = true; + } + input.onNotification(notification); + if ( + notification.method === "session.status" && + notification.params.sessionId === input.sessionId && + notification.params.status === "idle" + ) { + return; + } + } +} + +async function nextWithTimeout(client, timeoutMsValue) { + const queued = client.tryNext(); + if (queued) return queued; + const pending = client.next(); + const timeout = timeoutReject(timeoutMsValue, "timed out waiting for JSON-RPC notification"); + try { + return await Promise.race([pending, timeout.promise]); + } finally { + timeout.cancel(); + void pending.catch(() => {}); + } +} + +function timeoutReject(ms, message) { + let timer; + const promise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }); + return { + promise, + cancel() { + clearTimeout(timer); + }, + }; +} + +function isInboxReceipt(event, messageId) { + return Boolean( + event && + event.type === "agent/inbox/spliced" && + Array.isArray(event.data?.inserted) && + event.data.inserted.some((message) => message && message.id === messageId), + ); +} + +function recordSessionRelationship(notification, sessionParents) { + if (notification.method !== "subagent.started") return; + const parentId = notification.params.parentSessionId; + const childId = notification.params.childSessionId; + if (typeof parentId === "string" && typeof childId === "string" && parentId && childId && parentId !== childId) { + sessionParents.set(childId, parentId); + } +} + +function isInSessionTree(notification, rootSessionId, sessionParents) { + if ( + notification.method === "subagent.started" || + notification.method === "subagent.finished" + ) { + const parentId = notification.params.parentSessionId; + if (typeof parentId === "string" && isDescendantOf(parentId, rootSessionId, sessionParents)) return true; + return notification.params.childSessionId === rootSessionId; + } + const relatedId = notification.params.sessionId; + return typeof relatedId === "string" && isDescendantOf(relatedId, rootSessionId, sessionParents); +} + +function isDescendantOf(sessionId, rootSessionId, sessionParents) { + const visited = new Set(); + let current = sessionId; + while (current) { + if (current === rootSessionId) return true; + if (visited.has(current)) return false; + visited.add(current); + current = sessionParents.get(current) ?? ""; + } + return false; +} + +function parseNotifications(items, usedSessionId) { + let usage = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0 }; + let chunkUsage = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0 }; + let sawMessageUsage = false; + let summary = null; + let toolName = null; + let errorMessage = null; + for (const notification of items) { + if (notification.method !== "session.event") continue; + const event = notification.params.event; + if (!event || typeof event !== "object") continue; + if (event.type === "assistant/message" && event.data?.usage) { + usage = addUsage(usage, mapUsage(event.data.usage)); + sawMessageUsage = true; + } + if (event.type === "assistant/chunk" && event.data?.chunk?.type === "usage") { + chunkUsage = addUsage(chunkUsage, mapUsage(event.data.chunk.usage)); + } + if (event.type === "assistant/message") { + const content = event.data?.message?.content ?? event.data?.content; + const text = textFromBlocks(content); + if (text) summary = text; + } + if (event.type === "tool/call" && typeof event.data?.name === "string") { + toolName = event.data.name; + } + if (event.type === "turn/end" && event.data?.reason?.kind === "error") { + errorMessage = event.data.reason.error?.message || "DeepSeek Harness turn ended with an error"; + } else if (event.type === "turn/end" && event.data?.reason?.kind === "max-tokens") { + errorMessage = "DeepSeek Harness turn ended: max-tokens"; + } + } + return { + sessionId: usedSessionId, + usage: sawMessageUsage ? usage : chunkUsage, + summary, + toolName, + errorMessage, + }; +} + +function mapUsage(usage) { + return { + inputTokens: Number(usage?.inputTokens ?? 0), + outputTokens: Number(usage?.outputTokens ?? 0), + cachedInputTokens: Number(usage?.cacheReadTokens ?? 0), + }; +} + +function addUsage(left, right) { + return { + inputTokens: left.inputTokens + right.inputTokens, + outputTokens: left.outputTokens + right.outputTokens, + cachedInputTokens: left.cachedInputTokens + right.cachedInputTokens, + }; +} + +function textFromBlocks(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((block) => block && block.type === "text" && typeof block.text === "string") + .map((block) => block.text) + .join(""); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/adapters/deepseek-harness/src/server/remote-bridge.test.ts b/packages/adapters/deepseek-harness/src/server/remote-bridge.test.ts new file mode 100644 index 000000000000..0071c4555f00 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/remote-bridge.test.ts @@ -0,0 +1,42 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseBridgeStdout } from "./parse.js"; +import { resolveDeepseekRemoteBridgePath } from "./remote-bridge-path.js"; + +const mockRuntime = fileURLToPath(new URL("./mock-jsonrpc-runtime.mjs", import.meta.url)); + +describe("remote-bridge", () => { + it("drives a mock JSON-RPC runtime over one-shot stdin and emits a bridge-result", async () => { + const child = spawn(process.execPath, [resolveDeepseekRemoteBridgePath()], { + env: { + ...process.env, + DSH_JSONRPC_COMMAND: process.execPath, + DSH_JSONRPC_ARGS: JSON.stringify([mockRuntime]), + DSH_SESSION_ID: "paperclip-dsh-bridge-test", + DSH_CWD: path.dirname(mockRuntime), + DSH_TIMEOUT_MS: "5000", + }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stdin.end("Hello from the remote bridge.\n"); + const [code] = await Promise.race([ + once(child, "exit"), + new Promise((_, reject) => { + setTimeout(() => reject(new Error("remote bridge test timed out")), 8_000); + }), + ]); + expect(code).toBe(0); + const parsed = parseBridgeStdout(stdout); + expect(parsed.summary).toMatch(/^ack /); + expect(parsed.toolName).toBe("bash"); + expect(parsed.usage).toEqual({ inputTokens: 22, outputTokens: 8, cachedInputTokens: 4 }); + expect(parsed.errorMessage).toBeNull(); + }, 10_000); +}); diff --git a/packages/adapters/deepseek-harness/src/server/test.ts b/packages/adapters/deepseek-harness/src/server/test.ts index b53df183d561..6ea68addb533 100644 --- a/packages/adapters/deepseek-harness/src/server/test.ts +++ b/packages/adapters/deepseek-harness/src/server/test.ts @@ -9,8 +9,12 @@ import type { import { describeAdapterExecutionTarget, ensureAdapterExecutionTargetCommandResolvable, + prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, + runAdapterExecutionTargetProcess, } from "@paperclipai/adapter-utils/execution-target"; +import { resolveDeepseekRemoteBridgePath } from "./remote-bridge-path.js"; +import { parseBridgeStdout } from "./parse.js"; import { asNumber, asString, ensureAbsoluteDirectory, parseObject } from "@paperclipai/adapter-utils/server-utils"; import { ADAPTER_TYPE, DEFAULT_HELLO_PROBE_TIMEOUT_SEC } from "../shared/constants.js"; import { @@ -76,9 +80,9 @@ export async function testEnvironment( if (targetIsRemote) { checks.push({ - code: "remote_not_implemented", - level: "warn", - message: "deepseek_local remote environment probes land in Phase 3. Local JSON-RPC checks ran on the Paperclip host.", + code: "target_remote", + level: "info", + message: "Remote DeepSeek probes use the one-shot JSON-RPC bridge (execution targets do not expose duplex stdin).", }); } @@ -102,7 +106,7 @@ export async function testEnvironment( } try { - await ensureAdapterExecutionTargetCommandResolvable(command, null, cwd, { + await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, { ...process.env, ...env, }); @@ -184,37 +188,51 @@ export async function testEnvironment( const probeCwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-dsh-probe-")); const timeoutSec = Math.max(5, asNumber(config.helloProbeTimeoutSec, DEFAULT_HELLO_PROBE_TIMEOUT_SEC)); try { - const runtime = await spawnDeepseekRuntime({ - command, - cwd: probeCwd, - env: { - ...process.env, - ...env, - DSH_CWD: probeCwd, - DSH_CORDIS_CONFIG: cordisConfigPath, - DSH_SESSION_ROOT: path.join(probeCwd, "sessions"), - DSH_MODEL: model, - ...(harnessRoot - ? { NODE_PATH: `${path.join(harnessRoot, "node_modules")}${path.delimiter}${process.env.NODE_PATH ?? ""}` } - : {}), - }, - }); - try { - await initializeRuntime(runtime.client, { cwd: probeCwd, provider, model }); - await promptAndWait({ - client: runtime.client, - sessionId: mintSessionId(), - prompt: "Respond with hello.", - timeoutMs: timeoutSec * 1000, + if (targetIsRemote && target) { + await probeRemoteHello({ + runId: "deepseek-env-test", + target, + command, + cwd: probeCwd, + env, + cordisConfigPath, + model, + provider, + timeoutSec, }); - checks.push({ - code: "hello_probe", - level: "info", - message: "JSON-RPC initialize + session/prompt succeeded", + } else { + const runtime = await spawnDeepseekRuntime({ + command, + cwd: probeCwd, + env: { + ...process.env, + ...env, + DSH_CWD: probeCwd, + DSH_CORDIS_CONFIG: cordisConfigPath, + DSH_SESSION_ROOT: path.join(probeCwd, "sessions"), + DSH_MODEL: model, + ...(harnessRoot + ? { NODE_PATH: `${path.join(harnessRoot, "node_modules")}${path.delimiter}${process.env.NODE_PATH ?? ""}` } + : {}), + }, }); - } finally { - await runtime.close(5_000); + try { + await initializeRuntime(runtime.client, { cwd: probeCwd, provider, model }); + await promptAndWait({ + client: runtime.client, + sessionId: mintSessionId(), + prompt: "Respond with hello.", + timeoutMs: timeoutSec * 1000, + }); + } finally { + await runtime.close(5_000); + } } + checks.push({ + code: "hello_probe", + level: "info", + message: "JSON-RPC initialize + session/prompt succeeded", + }); } catch (error) { checks.push({ code: "hello_probe", @@ -234,3 +252,82 @@ export async function testEnvironment( testedAt: new Date().toISOString(), }; } + +async function probeRemoteHello(input: { + runId: string; + target: NonNullable; + command: string; + cwd: string; + env: Record; + cordisConfigPath: string; + model: string; + provider: string; + timeoutSec: number; +}): Promise { + const stageDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-dsh-probe-assets-")); + let restore: (() => Promise) | null = null; + try { + const cordisDir = path.join(stageDir, "cordis"); + const bridgeDir = path.join(stageDir, "bridge"); + const sessionDir = path.join(stageDir, "sessions"); + await fs.mkdir(cordisDir, { recursive: true }); + await fs.mkdir(bridgeDir, { recursive: true }); + await fs.mkdir(sessionDir, { recursive: true }); + await fs.copyFile(input.cordisConfigPath, path.join(cordisDir, "paperclip.cordis.yml")); + await fs.copyFile(resolveDeepseekRemoteBridgePath(), path.join(bridgeDir, "remote-bridge.mjs")); + const prepared = await prepareAdapterExecutionTargetRuntime({ + runId: input.runId, + target: input.target, + adapterKey: "deepseek", + workspaceLocalDir: input.cwd, + timeoutSec: input.timeoutSec, + syncWorkspace: false, + assets: [ + { key: "cordis", localDir: cordisDir }, + { key: "bridge", localDir: bridgeDir }, + { key: "sessions", localDir: sessionDir }, + ], + }); + restore = () => prepared.restoreWorkspace(); + const remoteCwd = prepared.workspaceRemoteDir ?? input.cwd; + const remoteBridge = prepared.assetDirs.bridge + ? path.posix.join(prepared.assetDirs.bridge, "remote-bridge.mjs") + : resolveDeepseekRemoteBridgePath(); + const remoteCordis = prepared.assetDirs.cordis + ? path.posix.join(prepared.assetDirs.cordis, "paperclip.cordis.yml") + : input.cordisConfigPath; + const remoteSessions = prepared.assetDirs.sessions ?? sessionDir; + let stdout = ""; + const result = await runAdapterExecutionTargetProcess(input.runId, input.target, "node", [remoteBridge], { + cwd: remoteCwd, + env: Object.fromEntries( + Object.entries({ + ...process.env, + ...input.env, + DSH_CWD: remoteCwd, + DSH_CORDIS_CONFIG: remoteCordis, + DSH_SESSION_ROOT: remoteSessions, + DSH_MODEL: input.model, + DSH_PROVIDER: input.provider, + DSH_JSONRPC_COMMAND: input.command, + DSH_SESSION_ID: mintSessionId(), + DSH_TIMEOUT_MS: String(input.timeoutSec * 1000), + }).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ), + stdin: "Respond with hello.", + timeoutSec: input.timeoutSec, + graceSec: 5, + onLog: async (stream, chunk) => { + if (stream === "stdout") stdout += chunk; + }, + }); + const parsed = parseBridgeStdout(stdout); + if (result.exitCode && result.exitCode !== 0) { + throw new Error(parsed.errorMessage || `Remote hello probe exited ${result.exitCode}`); + } + if (parsed.errorMessage) throw new Error(parsed.errorMessage); + } finally { + await restore?.().catch(() => undefined); + await fs.rm(stageDir, { recursive: true, force: true }).catch(() => undefined); + } +} diff --git a/packages/shared/src/environment-support.test.ts b/packages/shared/src/environment-support.test.ts index 94926016aeb5..9b9712ea4291 100644 --- a/packages/shared/src/environment-support.test.ts +++ b/packages/shared/src/environment-support.test.ts @@ -43,6 +43,14 @@ describe("isSandboxProviderSupportedForAdapter", () => { ]); }); + it("treats deepseek_local as a remote-managed local adapter", () => { + expect(adapterSupportsRemoteManagedEnvironments("deepseek_local")).toBe(true); + expect(supportedEnvironmentDriversForAdapter("deepseek_local")).toEqual(["local", "ssh", "sandbox"]); + expect( + isSandboxProviderSupportedForAdapter("deepseek_local", "fake-plugin", ["fake-plugin"]), + ).toBe(true); + }); + it("treats kimi_local as a remote-managed local adapter", () => { expect(adapterSupportsRemoteManagedEnvironments("kimi_local")).toBe(true); expect(supportedEnvironmentDriversForAdapter("kimi_local")).toEqual(["local", "ssh", "sandbox"]); diff --git a/packages/shared/src/environment-support.ts b/packages/shared/src/environment-support.ts index 5bb5a076ea3f..36999be57439 100644 --- a/packages/shared/src/environment-support.ts +++ b/packages/shared/src/environment-support.ts @@ -73,6 +73,7 @@ const REMOTE_MANAGED_ADAPTERS = new Set([ "kimi_local", "opencode_local", "pi_local", + "deepseek_local", ]); export function adapterSupportsRemoteManagedEnvironments(adapterType: string): boolean { From b4d775f6f642a39f271cf9e55c6d1e42f3027d12 Mon Sep 17 00:00:00 2001 From: soyboyscout Date: Sat, 29 Aug 2026 18:08:48 -0400 Subject: [PATCH 6/8] Preserve DeepSeek remoteExecution in the session codec. Heartbeat serialize/deserialize would otherwise drop SSH/sandbox identity and refuse to resume the next remote turn. Co-authored-by: Cursor --- doc/plans/2026-08-29-deepseek-harness-adapter.md | 7 ++++++- .../deepseek-harness/src/server/session.test.ts | 11 +++++++++++ .../adapters/deepseek-harness/src/server/session.ts | 7 +++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/doc/plans/2026-08-29-deepseek-harness-adapter.md b/doc/plans/2026-08-29-deepseek-harness-adapter.md index eb63848c5755..30551887fddd 100644 --- a/doc/plans/2026-08-29-deepseek-harness-adapter.md +++ b/doc/plans/2026-08-29-deepseek-harness-adapter.md @@ -1,6 +1,6 @@ # DeepSeek Harness Adapter — Full Paperclip-Feature Parity Plan -Status: Phase 3 landed (`feat/deepseek-harness-support`); Phase 4 stretch optional +Status: Complete (`feat/deepseek-harness-support`) Date: 2026-08-29 Audience: Engineering Scope: Add a Paperclip agent adapter for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`) that matches `claude_local` on Paperclip control-plane features (heartbeat execute, sessions, skills, instructions, usage, transcripts, env test, remote/sandbox, JWT, config schema). Not a goal: make `dsh` behave like Claude Code. @@ -494,3 +494,8 @@ Parser and turn accounting now match dsh `0.1.1-rc.2` `SessionEventMap` / `Strea - Workspace restore runs in `finally`. Restore failure fails the run. - Local Linux `filesystemScope` / `networkScope` wrap the JSON-RPC runtime via `buildLocalProcessSandboxSpawnTarget`. - Remote env test uses the same bridge after `prepareAdapterExecutionTargetRuntime`. +- `sessionCodec` preserves `remoteExecution` so heartbeat serialize/deserialize does not drop SSH/sandbox resume identity. + +## 22. Phase 4 (deferred) + +Not implemented in this branch. There is no stable DeepSeek quota API for `getQuotaWindows`. JSON-RPC MCP would require a stdout-safe plugin composition. A Hermes-style reverse task-bridge is out of scope for Claude execute parity. diff --git a/packages/adapters/deepseek-harness/src/server/session.test.ts b/packages/adapters/deepseek-harness/src/server/session.test.ts index 30145e42af8f..5f83559e454c 100644 --- a/packages/adapters/deepseek-harness/src/server/session.test.ts +++ b/packages/adapters/deepseek-harness/src/server/session.test.ts @@ -16,6 +16,17 @@ describe("sessionCodec", () => { }); }); + it("preserves remoteExecution so SSH/sandbox resume can match the target", () => { + const raw = { + sessionId: "s1", + cwd: "/remote/workspace", + sessionRoot: "/remote/sessions", + remoteExecution: { transport: "sandbox", providerKey: "fake", remoteCwd: "/remote/workspace" }, + }; + expect(sessionCodec.deserialize(raw)).toEqual(raw); + expect(sessionCodec.serialize(raw)).toEqual(raw); + }); + it("returns null without a session id", () => { expect(sessionCodec.deserialize({ cwd: "/tmp" })).toBeNull(); }); diff --git a/packages/adapters/deepseek-harness/src/server/session.ts b/packages/adapters/deepseek-harness/src/server/session.ts index fbe7c4346cd4..c8bd6d269842 100644 --- a/packages/adapters/deepseek-harness/src/server/session.ts +++ b/packages/adapters/deepseek-harness/src/server/session.ts @@ -26,10 +26,17 @@ export const sessionCodec: AdapterSessionCodec = { const sessionRoot = readNonEmptyString(record.sessionRoot) ?? readNonEmptyString(record.session_root); + const remoteExecution = + typeof record.remoteExecution === "object" && + record.remoteExecution !== null && + !Array.isArray(record.remoteExecution) + ? record.remoteExecution + : null; return { sessionId, ...(cwd ? { cwd } : {}), ...(sessionRoot ? { sessionRoot } : {}), + ...(remoteExecution ? { remoteExecution } : {}), }; }, serialize(params: Record | null) { From 5aba0e7c3d5a3b46a85dc24f70989d5b6fc5b256 Mon Sep 17 00:00:00 2001 From: soyboyscout Date: Sat, 29 Aug 2026 18:16:45 -0400 Subject: [PATCH 7/8] Copy DeepSeek remote session files back and pair tool-result names. Remote resume needs the harness session store on the next heartbeat, and the run viewer needs tool/result cards to keep the call name from the official wire. Co-authored-by: Cursor --- .../2026-08-29-deepseek-harness-adapter.md | 1 + docs/adapters/deepseek-local.md | 2 +- .../src/server/execute-remote.ts | 25 +++++- .../src/server/execute.remote.test.ts | 12 +++ .../src/server/execute.test.ts | 9 +- .../src/server/remote-bridge.mjs | 32 +++++++ .../src/server/session-export.test.ts | 36 ++++++++ .../src/server/session-export.ts | 67 ++++++++++++++ .../deepseek-harness/src/server/skills.ts | 13 ++- .../src/server/test.remote.test.ts | 90 +++++++++++++++++++ .../deepseek-harness/src/ui-parser.ts | 19 ++-- .../src/ui/parse-stdout.test.ts | 32 +++++++ 12 files changed, 326 insertions(+), 12 deletions(-) create mode 100644 packages/adapters/deepseek-harness/src/server/session-export.test.ts create mode 100644 packages/adapters/deepseek-harness/src/server/session-export.ts create mode 100644 packages/adapters/deepseek-harness/src/server/test.remote.test.ts diff --git a/doc/plans/2026-08-29-deepseek-harness-adapter.md b/doc/plans/2026-08-29-deepseek-harness-adapter.md index 30551887fddd..3296e40916b8 100644 --- a/doc/plans/2026-08-29-deepseek-harness-adapter.md +++ b/doc/plans/2026-08-29-deepseek-harness-adapter.md @@ -495,6 +495,7 @@ Parser and turn accounting now match dsh `0.1.1-rc.2` `SessionEventMap` / `Strea - Local Linux `filesystemScope` / `networkScope` wrap the JSON-RPC runtime via `buildLocalProcessSandboxSpawnTarget`. - Remote env test uses the same bridge after `prepareAdapterExecutionTargetRuntime`. - `sessionCodec` preserves `remoteExecution` so heartbeat serialize/deserialize does not drop SSH/sandbox resume identity. +- Remote session files are copied back via `.paperclip-session-export.json` on asset restore so the next heartbeat can re-seed `DSH_SESSION_ROOT`. ## 22. Phase 4 (deferred) diff --git a/docs/adapters/deepseek-local.md b/docs/adapters/deepseek-local.md index 44b3c5f99580..316cef0f5c2f 100644 --- a/docs/adapters/deepseek-local.md +++ b/docs/adapters/deepseek-local.md @@ -57,6 +57,6 @@ Desired Paperclip skills are copied into `$PAPERCLIP_HOME/adapter-state/ { + try { + const exportBytes = await readFile(path.posix.join(assetDir, SESSION_EXPORT_FILENAME)); + const restored = await restoreDeepseekSessionExport({ + localSessionRoot: setup.sessionRoot, + exportBytes, + }); + if (restored > 0) { + await onLog( + "stdout", + `[paperclip] Restored ${restored} DeepSeek session file(s) from the remote target.\n`, + ); + } + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === "ENOENT") return; + throw error; + } + }, + }, ...(setup.skillsDir ? [{ key: "skills", localDir: setup.skillsDir, followSymlinks: true }] : []), diff --git a/packages/adapters/deepseek-harness/src/server/execute.remote.test.ts b/packages/adapters/deepseek-harness/src/server/execute.remote.test.ts index 15c5a4462fc7..bbec6edda52a 100644 --- a/packages/adapters/deepseek-harness/src/server/execute.remote.test.ts +++ b/packages/adapters/deepseek-harness/src/server/execute.remote.test.ts @@ -141,6 +141,18 @@ describe("execute remote", () => { expect(prepareMock).toHaveBeenCalledTimes(1); const prepareInput = prepareMock.mock.calls[0]?.[0] as { assets?: Array<{ key: string }> }; expect(prepareInput.assets?.map((asset) => asset.key).sort()).toEqual(["bridge", "cordis", "sessions"]); + const sessionsAsset = (prepareMock.mock.calls[0]?.[0] as { + assets?: Array<{ key: string; restore?: (ctx: { assetDir: string; readFile: (p: string) => Promise }) => Promise }>; + }).assets?.find((asset) => asset.key === "sessions"); + const sessionRoot = path.join(String(process.env.PAPERCLIP_HOME), "adapter-state", "company-1", "agent-1", "deepseek", "sessions"); + await sessionsAsset?.restore?.({ + assetDir: "/remote/assets/sessions", + readFile: async () => + Buffer.from(JSON.stringify({ + files: [{ path: "session.json", contents: Buffer.from("{\"ok\":true}", "utf8").toString("base64") }], + })), + }); + expect(await fs.readFile(path.join(sessionRoot, "session.json"), "utf8")).toBe("{\"ok\":true}"); expect(runProcessMock).toHaveBeenCalledTimes(1); expect(restoreMock).toHaveBeenCalledTimes(1); expect(bridgeStopMock).toHaveBeenCalledTimes(1); diff --git a/packages/adapters/deepseek-harness/src/server/execute.test.ts b/packages/adapters/deepseek-harness/src/server/execute.test.ts index 540dcd7ba06b..798cd0e3c187 100644 --- a/packages/adapters/deepseek-harness/src/server/execute.test.ts +++ b/packages/adapters/deepseek-harness/src/server/execute.test.ts @@ -122,9 +122,16 @@ describe("execute", () => { cwd, extra: { instructionsFilePath: instructions }, }); + let capturedPrompt = ""; + ctx.onMeta = async (meta) => { + const env = meta.env as Record | undefined; + capturedPrompt = env?.DSH_SYSTEM_PROMPT ?? ""; + }; const result = await execute(ctx); expect(result.exitCode).toBe(0); - expect(ctx.logs.join("")).not.toMatch(/instructions file was missing/); + expect(capturedPrompt).toContain("You are the company soul."); + expect(capturedPrompt).toContain("HEARTBEAT.md"); + expect(capturedPrompt).toContain(path.dirname(instructions)); }); it("retries with clearSession when the runtime rejects the session", async () => { diff --git a/packages/adapters/deepseek-harness/src/server/remote-bridge.mjs b/packages/adapters/deepseek-harness/src/server/remote-bridge.mjs index f0a404e2e796..7dd21ff06289 100644 --- a/packages/adapters/deepseek-harness/src/server/remote-bridge.mjs +++ b/packages/adapters/deepseek-harness/src/server/remote-bridge.mjs @@ -11,6 +11,8 @@ import { spawn } from "node:child_process"; import { createInterface } from "node:readline"; import { once } from "node:events"; +import fs from "node:fs/promises"; +import path from "node:path"; const command = (process.env.DSH_JSONRPC_COMMAND ?? "dsh-jsonrpc-agent").trim(); const args = parseJsonArgs(process.env.DSH_JSONRPC_ARGS); @@ -77,6 +79,7 @@ try { } child.stdin.end(); await Promise.race([once(child, "exit"), sleep(2_000)]); + await exportSessionRoot(process.env.DSH_SESSION_ROOT); writeBridgeResult(parseNotifications(notifications, sessionId)); client.close(); process.exit(0); @@ -395,3 +398,32 @@ function textFromBlocks(content) { function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } + +async function exportSessionRoot(sessionRoot) { + const root = typeof sessionRoot === "string" ? sessionRoot.trim() : ""; + if (!root) return; + const files = []; + async function walk(dir, relative) { + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (entry.name === ".paperclip-session-export.json") continue; + const nextRel = relative ? `${relative}/${entry.name}` : entry.name; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(full, nextRel); + continue; + } + if (!entry.isFile()) continue; + files.push({ + path: nextRel, + contents: (await fs.readFile(full)).toString("base64"), + }); + } + } + await walk(root, ""); + await fs.writeFile( + path.join(root, ".paperclip-session-export.json"), + JSON.stringify({ files }), + "utf8", + ).catch(() => undefined); +} diff --git a/packages/adapters/deepseek-harness/src/server/session-export.test.ts b/packages/adapters/deepseek-harness/src/server/session-export.test.ts new file mode 100644 index 000000000000..7c349149995d --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/session-export.test.ts @@ -0,0 +1,36 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { restoreDeepseekSessionExport, writeDeepseekSessionExport } from "./session-export.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("session export", () => { + it("round-trips session files and rejects path escape", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "dsh-sess-")); + tempDirs.push(root); + await fs.writeFile(path.join(root, "session.json"), "{\"id\":\"s1\"}\n", "utf8"); + await fs.mkdir(path.join(root, "nested"), { recursive: true }); + await fs.writeFile(path.join(root, "nested", "log.jsonl"), "event\n", "utf8"); + expect(await writeDeepseekSessionExport(root)).toBe(2); + + const dest = await fs.mkdtemp(path.join(os.tmpdir(), "dsh-sess-dest-")); + tempDirs.push(dest); + const exportBytes = await fs.readFile(path.join(root, ".paperclip-session-export.json")); + const parsed = JSON.parse(exportBytes.toString("utf8")) as { files: Array<{ path: string; contents: string }> }; + parsed.files.push({ path: "../escape.txt", contents: Buffer.from("nope").toString("base64") }); + const restored = await restoreDeepseekSessionExport({ + localSessionRoot: dest, + exportBytes: Buffer.from(JSON.stringify(parsed)), + }); + expect(restored).toBe(2); + expect(await fs.readFile(path.join(dest, "session.json"), "utf8")).toContain("s1"); + expect(await fs.readFile(path.join(dest, "nested", "log.jsonl"), "utf8")).toBe("event\n"); + await expect(fs.stat(path.join(dest, "..", "escape.txt"))).rejects.toBeDefined(); + }); +}); diff --git a/packages/adapters/deepseek-harness/src/server/session-export.ts b/packages/adapters/deepseek-harness/src/server/session-export.ts new file mode 100644 index 000000000000..9855b39e50b5 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/session-export.ts @@ -0,0 +1,67 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export const SESSION_EXPORT_FILENAME = ".paperclip-session-export.json"; + +export interface DeepseekSessionExportFile { + path: string; + contents: string; +} + +export async function writeDeepseekSessionExport(sessionRoot: string): Promise { + const root = path.resolve(sessionRoot); + const files: DeepseekSessionExportFile[] = []; + await walkSessionFiles(root, "", files); + await fs.writeFile(path.join(root, SESSION_EXPORT_FILENAME), JSON.stringify({ files }), "utf8"); + return files.length; +} + +export async function restoreDeepseekSessionExport(input: { + localSessionRoot: string; + exportBytes: Buffer; +}): Promise { + const root = path.resolve(input.localSessionRoot); + await fs.mkdir(root, { recursive: true }); + const parsed = JSON.parse(input.exportBytes.toString("utf8")) as { files?: unknown }; + if (!Array.isArray(parsed.files)) return 0; + let count = 0; + for (const entry of parsed.files) { + if (!entry || typeof entry !== "object") continue; + const relative = typeof (entry as DeepseekSessionExportFile).path === "string" + ? (entry as DeepseekSessionExportFile).path + : ""; + const contents = typeof (entry as DeepseekSessionExportFile).contents === "string" + ? (entry as DeepseekSessionExportFile).contents + : ""; + if (!relative || relative.includes("\0") || path.isAbsolute(relative)) continue; + const dest = path.resolve(root, relative); + const contained = dest === root || dest.startsWith(`${root}${path.sep}`); + if (!contained) continue; + await fs.mkdir(path.dirname(dest), { recursive: true }); + await fs.writeFile(dest, Buffer.from(contents, "base64")); + count += 1; + } + return count; +} + +async function walkSessionFiles( + dir: string, + relative: string, + files: DeepseekSessionExportFile[], +): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (entry.name === SESSION_EXPORT_FILENAME) continue; + const nextRel = relative ? `${relative}/${entry.name}` : entry.name; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walkSessionFiles(full, nextRel, files); + continue; + } + if (!entry.isFile()) continue; + files.push({ + path: nextRel, + contents: (await fs.readFile(full)).toString("base64"), + }); + } +} diff --git a/packages/adapters/deepseek-harness/src/server/skills.ts b/packages/adapters/deepseek-harness/src/server/skills.ts index a215c6154b34..87e0a15cbe1a 100644 --- a/packages/adapters/deepseek-harness/src/server/skills.ts +++ b/packages/adapters/deepseek-harness/src/server/skills.ts @@ -30,17 +30,22 @@ export async function materializeDeepseekSkills(input: { config: Record; destDir: string; }): Promise { + const destDir = path.resolve(input.destDir); + const root = path.parse(destDir).root; + if (!path.isAbsolute(destDir) || destDir === root) { + throw new Error(`DeepSeek skill destDir must be a non-root absolute path: ${input.destDir}`); + } const availableEntries = await readPaperclipRuntimeSkillEntries(input.config, moduleDir); const desired = new Set(resolvePaperclipDesiredSkillNames(input.config, availableEntries)); - await fs.mkdir(input.destDir, { recursive: true }); - const existing = await fs.readdir(input.destDir, { withFileTypes: true }); + await fs.mkdir(destDir, { recursive: true }); + const existing = await fs.readdir(destDir, { withFileTypes: true }); await Promise.all( - existing.map((entry) => fs.rm(path.join(input.destDir, entry.name), { recursive: true, force: true })), + existing.map((entry) => fs.rm(path.join(destDir, entry.name), { recursive: true, force: true })), ); let count = 0; for (const entry of availableEntries) { if (!desired.has(entry.key)) continue; - await materializePaperclipSkillCopy(entry.source, path.join(input.destDir, entry.runtimeName)); + await materializePaperclipSkillCopy(entry.source, path.join(destDir, entry.runtimeName)); count += 1; } return count; diff --git a/packages/adapters/deepseek-harness/src/server/test.remote.test.ts b/packages/adapters/deepseek-harness/src/server/test.remote.test.ts new file mode 100644 index 000000000000..9587a642e39b --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/test.remote.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; + +const prepareMock = vi.hoisted(() => vi.fn()); +const runProcessMock = vi.hoisted(() => vi.fn()); +const restoreMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + prepareAdapterExecutionTargetRuntime: prepareMock, + runAdapterExecutionTargetProcess: runProcessMock, + ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => {}), + }; +}); + +import { testEnvironment } from "./test.js"; + +describe("deepseek remote environment diagnostics", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("stages the JSON-RPC bridge and probes hello on the remote target", async () => { + prepareMock.mockResolvedValue({ + workspaceRemoteDir: "/remote/workspace", + runtimeRootDir: "/remote/runtime", + assetDirs: { + cordis: "/remote/assets/cordis", + bridge: "/remote/assets/bridge", + sessions: "/remote/assets/sessions", + }, + restoreWorkspace: restoreMock, + }); + runProcessMock.mockImplementation(async (_runId, _target, command, args, options) => { + expect(command).toBe("node"); + expect(args[0]).toBe("/remote/assets/bridge/remote-bridge.mjs"); + await options.onLog( + "stdout", + `${JSON.stringify({ + paperclipDeepseek: 1, + kind: "bridge-result", + sessionId: options.env.DSH_SESSION_ID, + summary: "hello", + })}\n`, + ); + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: 1, + startedAt: new Date().toISOString(), + }; + }); + + const remoteTarget: AdapterExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/remote/workspace", + }; + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "deepseek_local", + config: { + command: process.execPath, + cwd: process.cwd(), + env: { DEEPSEEK_API_KEY: "test-key" }, + }, + executionTarget: remoteTarget, + environmentName: "QA sandbox", + }); + + expect(result.status).not.toBe("fail"); + expect(result.checks.some((check) => check.code === "target" && check.message.includes("QA sandbox"))).toBe(true); + expect(result.checks.some((check) => check.code === "hello_probe" && check.level === "info")).toBe(true); + expect(prepareMock).toHaveBeenCalledTimes(1); + const prepareInput = prepareMock.mock.calls[0]?.[0] as { adapterKey?: string; assets?: Array<{ key: string }> }; + expect(prepareInput.adapterKey).toBe("deepseek"); + expect(prepareInput.assets?.map((asset) => asset.key).sort()).toEqual(["bridge", "cordis", "sessions"]); + expect(runProcessMock).toHaveBeenCalledTimes(1); + expect(restoreMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/adapters/deepseek-harness/src/ui-parser.ts b/packages/adapters/deepseek-harness/src/ui-parser.ts index ce8c93853805..56c8072cc88c 100644 --- a/packages/adapters/deepseek-harness/src/ui-parser.ts +++ b/packages/adapters/deepseek-harness/src/ui-parser.ts @@ -71,7 +71,11 @@ function addUsage( }; } -function toolResultCard(data: Record, ts: string): Record | null { +function toolResultCard( + data: Record, + ts: string, + state: ParserState | null, +): Record | null { const message = asRecord(data.message); const blocks = message?.content; const first = Array.isArray(blocks) ? asRecord(blocks[0]) : null; @@ -87,7 +91,7 @@ function toolResultCard(data: Record, ts: string): Record; }; function emptyUsage() { @@ -126,6 +131,7 @@ function emptyState(): ParserState { sawMessageUsage: false, streamedText: false, streamedThinking: false, + toolNames: {}, }; } @@ -212,17 +218,20 @@ function parseProtocolLine(line: string, ts: string, state: ParserState | null): } if (type === "tool/call") { + const toolUseId = asString(data.callId, asString(data.id)); + const name = asString(data.name, "tool"); + if (state && toolUseId) state.toolNames[toolUseId] = name; return [{ kind: "tool_call", ts, - name: asString(data.name, "tool"), + name, input: parseToolArguments(data.arguments), - toolUseId: asString(data.callId, asString(data.id)), + toolUseId, }]; } if (type === "tool/result") { - const card = toolResultCard(data, ts); + const card = toolResultCard(data, ts, state); return card ? [card] : []; } diff --git a/packages/adapters/deepseek-harness/src/ui/parse-stdout.test.ts b/packages/adapters/deepseek-harness/src/ui/parse-stdout.test.ts index cf6b841d8133..5f88d3388318 100644 --- a/packages/adapters/deepseek-harness/src/ui/parse-stdout.test.ts +++ b/packages/adapters/deepseek-harness/src/ui/parse-stdout.test.ts @@ -121,4 +121,36 @@ describe("createDeepseekStdoutParser", () => { errors: [], }]); }); + + it("pairs official tool/result cards with the earlier tool/call name", () => { + const parser = createDeepseekStdoutParser(); + parser.parseLine( + eventLine("tool/call", { + turn: 1, + step: 1, + name: "bash", + callId: "c1", + arguments: JSON.stringify({ command: "ls" }), + }), + "t0", + ); + expect( + parser.parseLine( + eventLine("tool/result", { + turn: 1, + step: 1, + message: { + role: "user", + content: [{ + type: "tool-result", + toolCallId: "c1", + content: [{ type: "text", text: "ok" }], + isError: false, + }], + }, + }), + "t1", + ), + ).toEqual([{ kind: "tool_result", ts: "t1", toolUseId: "c1", toolName: "bash", content: "ok", isError: false }]); + }); }); From 225c0f548430f60365e91a402f0a64bee4f1342a Mon Sep 17 00:00:00 2001 From: soyboyscout Date: Sat, 29 Aug 2026 18:34:27 -0400 Subject: [PATCH 8/8] Align DeepSeek adapter with master after merge. Master renamed the duplex observability helper and now requires every built-in adapter to declare runtime tool delivery. Co-authored-by: Cursor --- .../adapters/deepseek-harness/package.json | 1 + .../deepseek-harness/src/index.test.ts | 1 + .../adapters/deepseek-harness/src/index.ts | 1 + .../src/server/execute-remote.ts | 8 +++- pnpm-lock.yaml | 38 +++++++++++++++++++ .../environment-execution-target.test.ts | 1 + server/src/adapters/registry.test.ts | 1 + 7 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/adapters/deepseek-harness/package.json b/packages/adapters/deepseek-harness/package.json index fa3603908da0..540b5e354886 100644 --- a/packages/adapters/deepseek-harness/package.json +++ b/packages/adapters/deepseek-harness/package.json @@ -59,6 +59,7 @@ }, "dependencies": { "@paperclipai/adapter-utils": "workspace:*", + "@paperclipai/shared": "workspace:*", "picocolors": "^1.1.1" }, "devDependencies": { diff --git a/packages/adapters/deepseek-harness/src/index.test.ts b/packages/adapters/deepseek-harness/src/index.test.ts index 0e59052abe6b..17092a47ac8f 100644 --- a/packages/adapters/deepseek-harness/src/index.test.ts +++ b/packages/adapters/deepseek-harness/src/index.test.ts @@ -11,6 +11,7 @@ describe("createServerAdapter", () => { expect(adapter.requiresMaterializedRuntimeSkills).toBe(true); expect(adapter.sessionCodec).toBeDefined(); expect(adapter.getRuntimeCommandSpec?.({})?.installCommand).toBeNull(); + expect(adapter.runtimeToolDelivery).toBe("environment"); expect(adapter.loginCapability).toBeUndefined(); expect(adapter.acp).toBeUndefined(); expect(adapter.listSkills).toBeTypeOf("function"); diff --git a/packages/adapters/deepseek-harness/src/index.ts b/packages/adapters/deepseek-harness/src/index.ts index 689b3cb3b4da..ccdb713c9899 100644 --- a/packages/adapters/deepseek-harness/src/index.ts +++ b/packages/adapters/deepseek-harness/src/index.ts @@ -112,6 +112,7 @@ SSH and sandbox targets run a one-shot JSON-RPC bridge because execution-target export function createServerAdapter(): ServerAdapterModule { return { type, + runtimeToolDelivery: "environment", execute, testEnvironment, sessionCodec, diff --git a/packages/adapters/deepseek-harness/src/server/execute-remote.ts b/packages/adapters/deepseek-harness/src/server/execute-remote.ts index 0261ecf9ac2f..5f414c73ecde 100644 --- a/packages/adapters/deepseek-harness/src/server/execute-remote.ts +++ b/packages/adapters/deepseek-harness/src/server/execute-remote.ts @@ -7,7 +7,7 @@ import { adapterExecutionTargetSessionIdentity, adapterExecutionTargetUsesManagedHome, adapterExecutionTargetUsesPaperclipBridge, - adapterExecutionTargetDuplexTelemetryRecorder, + adapterExecutionTargetDuplexObservabilityRecorder, adapterExecutionTargetEnablesSandboxDuplexBridge, describeAdapterExecutionTarget, ensureAdapterExecutionTargetCommandResolvable, @@ -150,7 +150,7 @@ export async function executeRemote(input: { runId, target: runtimeExecutionTarget, enableSandboxDuplexBridge: adapterExecutionTargetEnablesSandboxDuplexBridge(runtimeExecutionTarget), - duplexTelemetryRecorder: adapterExecutionTargetDuplexTelemetryRecorder(runtimeExecutionTarget), + duplexObservabilityRecorder: adapterExecutionTargetDuplexObservabilityRecorder(runtimeExecutionTarget), runtimeRootDir: prepared.runtimeRootDir, adapterKey: "deepseek", timeoutSec: setup.timeoutSec, @@ -181,6 +181,10 @@ export async function executeRemote(input: { runtimeExecutionTarget, setup.cwd, runtimeEnv, + { + installCommand: ctx.runtimeCommandSpec?.installCommand ?? null, + timeoutSec: setup.timeoutSec, + }, ); const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs( setup.command, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bf8dedc6c9b..813dc97c16e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-deepseek-harness': + specifier: workspace:* + version: link:../packages/adapters/deepseek-harness '@paperclipai/adapter-gemini-local': specifier: workspace:* version: link:../packages/adapters/gemini-local @@ -217,6 +220,28 @@ importers: specifier: ^7.0.2 version: 7.0.2 + packages/adapters/deepseek-harness: + dependencies: + '@paperclipai/adapter-utils': + specifier: workspace:* + version: link:../../adapter-utils + '@paperclipai/shared': + specifier: workspace:* + version: link:../../shared + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12) + packages/adapters/gemini-local: dependencies: '@paperclipai/adapter-utils': @@ -788,6 +813,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-deepseek-harness': + specifier: workspace:* + version: link:../packages/adapters/deepseek-harness '@paperclipai/adapter-gemini-local': specifier: workspace:* version: link:../packages/adapters/gemini-local @@ -969,6 +997,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-deepseek-harness': + specifier: workspace:* + version: link:../packages/adapters/deepseek-harness '@paperclipai/adapter-gemini-local': specifier: workspace:* version: link:../packages/adapters/gemini-local @@ -7874,6 +7905,11 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -15455,6 +15491,8 @@ snapshots: typedarray@0.0.6: {} + typescript@5.9.3: {} + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 diff --git a/server/src/__tests__/environment-execution-target.test.ts b/server/src/__tests__/environment-execution-target.test.ts index b9d96aabc344..9a41ec3ce18a 100644 --- a/server/src/__tests__/environment-execution-target.test.ts +++ b/server/src/__tests__/environment-execution-target.test.ts @@ -266,6 +266,7 @@ describe("resolveEnvironmentExecutionTarget", () => { "gemini_local", "grok_local", "kimi_local", + "deepseek_local", "opencode_local", "pi_local", ]) { diff --git a/server/src/adapters/registry.test.ts b/server/src/adapters/registry.test.ts index a5c8037629b0..1cced9a24366 100644 --- a/server/src/adapters/registry.test.ts +++ b/server/src/adapters/registry.test.ts @@ -59,6 +59,7 @@ describe("built-in runtime connection tool delivery", () => { ["hermes_gateway", "invocation_context"], ["hermes_local", "environment"], ["kimi_local", "environment"], + ["deepseek_local", "environment"], ["openclaw_gateway", "invocation_context"], ["opencode_local", "environment"], ["paperclip_runner", "environment"],