diff --git a/cli/package.json b/cli/package.json index 438934766939..c318ea1fb0a5 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 new file mode 100644 index 000000000000..3296e40916b8 --- /dev/null +++ b/doc/plans/2026-08-29-deepseek-harness-adapter.md @@ -0,0 +1,502 @@ +# DeepSeek Harness Adapter — Full Paperclip-Feature Parity Plan + +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. + +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 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. +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:** 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:** `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`. + +**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`. + +## 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. + +## 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. + +### 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. + +## 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`. +- `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) + +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/docs/adapters/deepseek-local.md b/docs/adapters/deepseek-local.md new file mode 100644 index 000000000000..316cef0f5c2f --- /dev/null +++ b/docs/adapters/deepseek-local.md @@ -0,0 +1,62 @@ +--- +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 + +`deepseek_local` is a remote-managed adapter (local, SSH, and sandbox). Execution targets only accept one-shot stdin, so Paperclip uploads a one-shot JSON-RPC bridge plus the shipped Cordis file, skill root, and session root. The bridge owns the duplex `initialize` / `session/prompt` / idle interval on the target and prints `paperclipDeepseek` JSONL plus a final `bridge-result`. + +`getRuntimeCommandSpec.installCommand` is `null`. The remote host must already have `dsh-jsonrpc-agent` (or `adapterConfig.command`) and a harness install that can resolve `@deepseek-ai/dsh-*` plugins. Workspace restore runs in `finally` and never `git push`. After each remote turn the bridge writes `.paperclip-session-export.json` so Paperclip can copy session files back onto the host `DSH_SESSION_ROOT`. + +Local Linux hosts can also set `filesystemScope=workspace` and/or `networkScope=deny|allowlist` to wrap the JSON-RPC runtime with the same `bwrap` confinement other local adapters use. diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 55b0dcc5259e..d3350c4bcbb1 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 over JSON-RPC stdio (local, SSH, or sandbox via a one-shot bridge) | | 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/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`. + +## 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 new file mode 100644 index 000000000000..540b5e354886 --- /dev/null +++ b/packages/adapters/deepseek-harness/package.json @@ -0,0 +1,73 @@ +{ + "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 && cp src/server/remote-bridge.mjs dist/server/remote-bridge.mjs", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "@paperclipai/shared": "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..704520102afc --- /dev/null +++ b/packages/adapters/deepseek-harness/src/cli/format-event.ts @@ -0,0 +1,38 @@ +import pc from "picocolors"; +import { parseStdoutLine } from "../ui-parser.js"; + +export function printDeepseekStreamEvent(raw: string, _debug: boolean): void { + const line = raw.trim(); + if (!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); + } +} 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..17092a47ac8f --- /dev/null +++ b/packages/adapters/deepseek-harness/src/index.test.ts @@ -0,0 +1,20 @@ +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.runtimeToolDelivery).toBe("environment"); + 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 new file mode 100644 index 000000000000..ccdb713c9899 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/index.ts @@ -0,0 +1,135 @@ +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 { + 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; +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\`. + +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 { + return { + type, + runtimeToolDelivery: "environment", + execute, + testEnvironment, + sessionCodec, + sessionManagement, + models, + modelProfiles, + listModels: listDeepseekModels, + refreshModels: refreshDeepseekModels, + listSkills: listDeepseekSkills, + syncSkills: syncDeepseekSkills, + detectModel, + 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..b500ed4b1ab2 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/config-schema.ts @@ -0,0 +1,97 @@ +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.", + }, + { + 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..5f414c73ecde --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/execute-remote.ts @@ -0,0 +1,356 @@ +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, + adapterExecutionTargetDuplexObservabilityRecorder, + 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 { restoreDeepseekSessionExport, SESSION_EXPORT_FILENAME } from "./session-export.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, + restore: async ({ assetDir, readFile }) => { + 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 }] + : []), + ], + }); + 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), + duplexObservabilityRecorder: adapterExecutionTargetDuplexObservabilityRecorder(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, + { + installCommand: ctx.runtimeCommandSpec?.installCommand ?? null, + timeoutSec: setup.timeoutSec, + }, + ); + 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..bbec6edda52a --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/execute.remote.test.ts @@ -0,0 +1,189 @@ +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"]); + 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); + }); + + 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 new file mode 100644 index 000000000000..798cd0e3c187 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/execute.test.ts @@ -0,0 +1,153 @@ +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(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("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("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 }, + }); + 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(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 () => { + 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..fc1a1e3b60d0 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/execute.ts @@ -0,0 +1,330 @@ +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + adapterExecutionTargetIsRemote, + adapterExecutionTargetSessionMatches, + readAdapterExecutionTarget, +} from "@paperclipai/adapter-utils/execution-target"; +import { + asStringArray, + buildInvocationEnvForLogs, + parseObject, +} 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 } from "./session.js"; +import { + formatRpcError, + initializeRuntime, + mintSessionId, + promptAndWait, + spawnDeepseekRuntime, +} from "./jsonrpc-runtime.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 { runtime, config, onLog, onMeta, onSpawn } = ctx; + const executionTarget = readAdapterExecutionTarget({ + executionTarget: ctx.executionTarget, + legacyRemoteExecution: ctx.executionTransport?.remoteExecution, + }); + const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget); + + const preliminary = await resolveDeepseekExecuteSetup(ctx, { + sessionId: mintSessionId(), + canResume: false, + }); + const savedRemoteExecution = parseObject( + (runtime.sessionParams && typeof runtime.sessionParams === "object" + ? (runtime.sessionParams as Record).remoteExecution + : null), + ); + const canResume = + preliminary.persist && + canResumeDeepseekSession({ + 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 "${preliminary.savedSessionId}" does not match cwd/sessionRoot/target and will not be resumed.\n`, + ); + } + + if (executionTargetIsRemote && executionTarget) { + return executeRemote({ ctx, setup, executionTarget }); + } + + if (onMeta) { + await onMeta({ + adapterType: ADAPTER_TYPE, + command: setup.command, + cwd: setup.cwd, + commandNotes: [ + "Drives DeepSeek Harness over JSON-RPC stdio (initialize + session/prompt).", + `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(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: setup.prompt.length, + wakePromptChars: setup.wakePrompt.length, + heartbeatPromptChars: setup.renderedPrompt.length, + }, + context: { sessionId: setup.sessionId, sessionRoot: setup.sessionRoot, model: setup.model, provider: setup.provider }, + }); + } + + 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, + 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: 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: setup.prompt, + timeoutMs: setup.timeoutMs, + onNotification: emitNotification, + }); + + try { + const turn = await runPrompt(setup.sessionId); + const parsed = parseTurnNotifications(turn.notifications); + if (parsed.unknownSession) { + const retryId = mintSessionId(); + await onLog("stdout", `[paperclip] DeepSeek session was unknown; retrying with a fresh session.\n`); + const retry = await runPrompt(retryId); + const retryParsed = parseTurnNotifications(retry.notifications); + const closed = await runtimeHandle.close(setup.graceSec * 1000); + return buildResult({ + 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, + timedOut: false, + clearSession: true, + }); + } + const closed = await runtimeHandle.close(setup.graceSec * 1000); + return buildResult({ + sessionId: setup.sessionId, + cwd: setup.cwd, + sessionRoot: setup.sessionRoot, + model: setup.model, + persist: setup.persist, + parsed, + exitCode: closed.exitCode ?? (parsed.errorMessage ? 1 : 0), + signal: closed.signal, + timedOut: false, + clearSession: false, + }); + } catch (error) { + const message = formatRpcError(error); + if (isUnknownSessionError(message)) { + const retryId = mintSessionId(); + await onLog("stdout", `[paperclip] DeepSeek session was unknown; retrying with a fresh session.\n`); + try { + const retry = await runPrompt(retryId); + const retryParsed = parseTurnNotifications(retry.notifications); + const closed = await runtimeHandle.close(setup.graceSec * 1000); + return buildResult({ + 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, + timedOut: false, + clearSession: true, + }); + } catch (retryError) { + const retryMessage = formatRpcError(retryError); + const closed = await runtimeHandle.close(setup.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: setup.model, + usageBasis: "per_run", + }; + } + } + const timedOut = /timed out/i.test(message); + const closed = await runtimeHandle.close(setup.graceSec * 1000); + return { + exitCode: timedOut ? null : (closed.exitCode ?? 1), + signal: closed.signal, + timedOut, + errorMessage: message, + errorFamily: classifyDeepseekError(message), + clearSession: false, + provider: "deepseek", + model: setup.model, + usageBasis: "per_run", + ...(setup.persist + ? { + sessionId: setup.sessionId, + sessionParams: { + sessionId: setup.sessionId, + cwd: setup.cwd, + sessionRoot: setup.sessionRoot, + }, + sessionDisplayId: setup.sessionId, + } + : {}), + }; + } + } catch (error) { + const errorMessage = formatRpcError(error); + const closed = await runtimeHandle.close(setup.graceSec * 1000); + return { + exitCode: closed.exitCode ?? 1, + signal: closed.signal, + timedOut: false, + errorMessage, + errorFamily: classifyDeepseekError(errorMessage), + provider: "deepseek", + model: setup.model, + usageBasis: "per_run", + }; + } finally { + await sandboxCleanup?.().catch(() => undefined); + } +} + +function buildResult(input: { + sessionId: string; + cwd: string; + sessionRoot: string; + model: 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..c06fec8c7eca --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/index.ts @@ -0,0 +1,8 @@ +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"; +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 new file mode 100644 index 000000000000..adcd4246da1b --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/jsonrpc-client.ts @@ -0,0 +1,225 @@ +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; + } +} + +type NotificationWaiter = { + resolve: (notification: JsonRpcNotification) => void; + reject: (error: Error) => void; +}; + +export class NotificationSubscription { + private readonly queue: JsonRpcNotification[] = []; + private readonly waiters: NotificationWaiter[] = []; + 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.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; + } + + close() { + if (this.closed) return; + this.closed = true; + const error = new Error("JSON-RPC subscription closed"); + for (const waiter of this.waiters) waiter.reject(error); + this.waiters.length = 0; + 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, reject }); + }); + } +} + +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)); + this.readline.on("close", () => { + this.failTransport(new Error("DeepSeek Harness JSON-RPC stdout closed")); + }); + } + + failTransport(error: Error) { + if (this.closed) return; + this.closed = true; + this.readline.close(); + 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 { + 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..73328a4ad5b0 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/jsonrpc-runtime.ts @@ -0,0 +1,187 @@ +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, + 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); + 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 { + 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, + timeoutMs = 15_000, +): Promise { + 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 !== DSH_SERVER_INFO_NAME) { + 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)); +} + +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); + void promise.catch(() => {}); + } +} + +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..5d2a30d843f5 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/mock-jsonrpc-runtime.mjs @@ -0,0 +1,83 @@ +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: { + turn: 1, + step: 1, + name: "bash", + callId: `call-${messageId}`, + arguments: JSON.stringify({ command: "true" }), + }, + }, + }); + 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/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/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 new file mode 100644 index 000000000000..1d6f74d5e54c --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/paperclip.cordis.yml @@ -0,0 +1,103 @@ +# 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: true + 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: 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' + +- 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..0e51e77df29c --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/parse.test.ts @@ -0,0 +1,107 @@ +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 official turn/end LlmFailure and unknown sessions", () => { + const parsed = parseTurnNotifications([ + { + method: "session.event", + params: { + sessionId: "s1", + 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 }); + }); +}); + +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 new file mode 100644 index 000000000000..45911b8b1ad7 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/parse.ts @@ -0,0 +1,110 @@ +import type { AdapterExecutionErrorFamily, UsageSummary } from "@paperclipai/adapter-utils/types"; +import { + addUsage, + classifyDeepseekError, + extractAssistantText, + extractChunkUsage, + extractEventUsage, + extractTurnEndError, + 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 chunkUsage = EMPTY_USAGE; + let sawMessageUsage = false; + 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); + sawMessageUsage = true; + } + const streamedUsage = extractChunkUsage(event); + if (streamedUsage) chunkUsage = addUsage(chunkUsage, streamedUsage); + + 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; + } + + const turnError = extractTurnEndError(event); + if (turnError) errorMessage = turnError; + } + + return { + usage: sawMessageUsage ? usage : chunkUsage, + summary, + toolName, + errorMessage, + errorFamily: errorMessage ? classifyDeepseekError(errorMessage) : null, + unknownSession: errorMessage ? isUnknownSessionError(errorMessage) : false, + }; +} + +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/protocol.ts b/packages/adapters/deepseek-harness/src/server/protocol.ts new file mode 100644 index 000000000000..8b2d70011a96 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/protocol.ts @@ -0,0 +1,154 @@ +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) || 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"; + 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/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..7dd21ff06289 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/remote-bridge.mjs @@ -0,0 +1,429 @@ +#!/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"; +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); +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)]); + await exportSessionRoot(process.env.DSH_SESSION_ROOT); + 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)); +} + +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/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/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-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/session.test.ts b/packages/adapters/deepseek-harness/src/server/session.test.ts new file mode 100644 index 000000000000..5f83559e454c --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/session.test.ts @@ -0,0 +1,71 @@ +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("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(); + }); +}); + +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..c8bd6d269842 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/session.ts @@ -0,0 +1,78 @@ +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); + 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) { + 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/skills.test.ts b/packages/adapters/deepseek-harness/src/server/skills.test.ts new file mode 100644 index 000000000000..9723f02ae0a9 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/skills.test.ts @@ -0,0 +1,87 @@ +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("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", + 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..87e0a15cbe1a --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/skills.ts @@ -0,0 +1,86 @@ +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 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(destDir, { recursive: true }); + const existing = await fs.readdir(destDir, { withFileTypes: true }); + await Promise.all( + 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(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.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/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..6ea68addb533 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/test.ts @@ -0,0 +1,333 @@ +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, + 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 { + 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: "target_remote", + level: "info", + message: "Remote DeepSeek probes use the one-shot JSON-RPC bridge (execution targets do not expose duplex stdin).", + }); + } + + try { + if (!path.isAbsolute(cwd)) { + throw new Error(`Working directory must be absolute: ${cwd}`); + } + await ensureAbsoluteDirectory(cwd, { createIfMissing: 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, target, 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 { + if (targetIsRemote && target) { + await probeRemoteHello({ + runId: "deepseek-env-test", + target, + command, + cwd: probeCwd, + env, + cordisConfigPath, + model, + provider, + timeoutSec, + }); + } 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 ?? ""}` } + : {}), + }, + }); + 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", + 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(), + }; +} + +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/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..8811ed9e16b8 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/server/wait-for-turn.ts @@ -0,0 +1,92 @@ +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; + const nextPromise = subscription.next(); + try { + return await Promise.race([ + 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/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..56c8072cc88c --- /dev/null +++ b/packages/adapters/deepseek-harness/src/ui-parser.ts @@ -0,0 +1,267 @@ +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(""); +} + +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, + state: ParserState | null, +): 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, state?.toolNames[toolUseId] || "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; + toolNames: Record; +}; + +function emptyUsage() { + return { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }; +} + +function emptyState(): ParserState { + return { + messageUsage: emptyUsage(), + chunkUsage: emptyUsage(), + sawMessageUsage: false, + streamedText: false, + streamedThinking: false, + toolNames: {}, + }; +} + +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)); + 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") { + if (!state) return []; + return [{ + kind: "result", + ts, + text: "Run completed", + inputTokens: resultUsage(state).inputTokens, + outputTokens: resultUsage(state).outputTokens, + cachedTokens: resultUsage(state).cachedTokens, + 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 chunk = asRecord(data.chunk) ?? {}; + const chunkType = asString(chunk.type); + if (chunkType === "usage") { + const usage = usageFromRecord(chunk.usage); + if (usage && state) state.chunkUsage = addUsage(state.chunkUsage, usage); + return []; + } + const text = asString(chunk.text); + if (!text) return []; + if (chunkType === "reasoning-delta") { + if (state) state.streamedThinking = true; + return [{ kind: "thinking", ts, text, delta: true }]; + } + if (chunkType === "text-delta") { + if (state) state.streamedText = true; + return [{ kind: "assistant", ts, text, delta: true }]; + } + return []; + } + + if (type === "assistant/message") { + const message = asRecord(data.message) ?? {}; + const content = message.content; + const usage = usageFromRecord(data.usage); + if (usage && state) { + state.messageUsage = addUsage(state.messageUsage, usage); + state.sawMessageUsage = true; + } + const entries: Array> = []; + const thinking = thinkingFromBlocks(content); + const text = textFromBlocks(content); + if (thinking && !state?.streamedThinking) entries.push({ kind: "thinking", ts, text: thinking }); + if (text && !state?.streamedText) entries.push({ kind: "assistant", ts, text }); + return entries; + } + + 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, + input: parseToolArguments(data.arguments), + toolUseId, + }]; + } + + if (type === "tool/result") { + const card = toolResultCard(data, ts, state); + return card ? [card] : []; + } + + 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 stderr = turnEndStderr(data, ts); + return stderr ? [stderr] : []; + } + + return []; +} + +export function createStdoutParser() { + let state = emptyState(); + return { + parseLine(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/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..a0a6de2ffb39 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/ui/index.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 000000000000..5f88d3388318 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/ui/parse-stdout.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +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 official StreamChunk deltas, JSON-string tool arguments, and tool-result messages", () => { + expect( + parseDeepseekStdoutLine( + 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( + 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", + ), + ).toEqual([{ kind: "tool_call", ts: "t1", name: "bash", input: { cmd: "ls" }, toolUseId: "c1" }]); + + expect( + parseDeepseekStdoutLine( + eventLine("tool/result", { + turn: 1, + step: 1, + message: { + role: "user", + content: [{ + type: "tool-result", + toolCallId: "c1", + content: [{ type: "text", text: "ok" }], + isError: false, + }], + }, + }), + "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: "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: [], + }]); + }); + + 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 }]); + }); +}); 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..46b3e84ce9a3 --- /dev/null +++ b/packages/adapters/deepseek-harness/src/ui/parse-stdout.ts @@ -0,0 +1,18 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +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/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 333cd475f38f..dead5f4e0276 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -39,6 +39,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/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 { 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/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" diff --git a/server/package.json b/server/package.json index 9d8300cfb1c0..3919c5907de3 100644 --- a/server/package.json +++ b/server/package.json @@ -50,6 +50,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/__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/builtin-adapter-types.ts b/server/src/adapters/builtin-adapter-types.ts index 58c2e95b6d73..ad5f33729fe6 100644 --- a/server/src/adapters/builtin-adapter-types.ts +++ b/server/src/adapters/builtin-adapter-types.ts @@ -8,6 +8,7 @@ export const BUILTIN_ADAPTER_TYPES = new Set([ "paperclip_runner", "cursor_cloud", "cursor", + "deepseek_local", "gemini_local", "grok_local", "hermes_gateway", 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"], diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index c80dee114b58..5f28410c6694 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -105,6 +105,7 @@ import { createHermesGatewayServerAdapter, createHermesLocalServerAdapter, } from "@paperclipai/hermes-paperclip-adapter"; +import { createServerAdapter as createDeepseekLocalServerAdapter } from "@paperclipai/adapter-deepseek-harness"; import { execute as openCodeExecute, listOpenCodeSkills, @@ -534,6 +535,8 @@ const hermesLocalAdapter: ServerAdapterModule = { runtimeToolDelivery: "environment", }; +const deepseekLocalAdapter = createDeepseekLocalServerAdapter(); + const openclawGatewayAdapter: ServerAdapterModule = { type: "openclaw_gateway", runtimeToolDelivery: "invocation_context", @@ -613,6 +616,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 e2da98277cd1..02694555f6cb 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -509,6 +509,7 @@ const GIT_SENSITIVE_LOCAL_ADAPTER_TYPES = new Set([ "claude_local", "codex_local", "cursor", + "deepseek_local", "gemini_local", "grok_local", "hermes_local", @@ -840,6 +841,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 9ac53ea64fb3..c229ae8c9553 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -107,6 +107,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 994ee490fb97..92b31ea65adb 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/openclaw-gateway" }, diff --git a/ui/package.json b/ui/package.json index 38ccf73e10b9..9f617df3b292 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 b4f500eed607..fba963e6fecd 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -122,6 +122,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..31bae00f3155 --- /dev/null +++ b/ui/src/adapters/deepseek-local/index.ts @@ -0,0 +1,12 @@ +import type { UIAdapterModule } from "../types"; +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, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 85ee9e02401a..0baf4769e7a5 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -4,6 +4,7 @@ import { codexLocalUIAdapter } from "./codex-local"; import { paperclipRunnerUIAdapter } from "./paperclip-runner"; 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"; @@ -66,6 +67,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 332c29e0c25d..f8aabc401508 100644 --- a/ui/src/adapters/use-adapter-capabilities.ts +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -30,6 +30,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: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: false }, openclaw_gateway: ALL_FALSE, };