Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Below is the list of packages currently included in this repository.
| [`bub-tapestore-sqlite`](./packages/bub-tapestore-sqlite/README.md) | | Provides a SQLite-backed tape store for Bub conversation history. |
| [`bub-discord`](./packages/bub-discord/README.md) | [![PyPI version](https://img.shields.io/pypi/v/bub-discord)](https://pypi.org/project/bub-discord/) | Provides a Discord channel adapter for Bub message IO. |
| [`bub-dingtalk`](./packages/bub-dingtalk/README.md) | | Provides a DingTalk Stream Mode channel adapter for Bub message IO. |
| [`bub-dsh`](./packages/bub-dsh/README.md) | | Provides a `run_model` hook backed by the DeepSeek Harness Python SDK, with runtime-local session reuse. |
| [`bub-extism`](./packages/bub-extism/README.md) | | Bridges selected Bub hooks to Extism WebAssembly plugins so extensions can be written in any Extism PDK language. |
| [`bub-github-copilot`](./packages/bub-github-copilot/README.md) | | Provides a `run_model` hook backed by the GitHub Copilot SDK, plus `bub login github` device-flow login commands. |
| [`bub-kimi`](./packages/bub-kimi/README.md) | | Provides a `run_model` hook backed by the Kimi CLI, including persisted session resume support and temporary Bub skill wiring. |
Expand Down
85 changes: 85 additions & 0 deletions packages/bub-dsh/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# bub-dsh

DeepSeek Harness Python SDK-backed `run_model` plugin for `bub`.

## What It Provides

- Bub plugin entry point: `dsh`
- A `run_model` hook backed by the official `deepseek-harness-sdk`
- Runtime-local DeepSeek Harness session mapping for each Bub `session_id`

## Installation

Install using Bub's plugin manager:

```bash
bub install bub-dsh@main
```

Install directly from GitHub:

```bash
uv pip install "git+https://github.com/bubbuild/bub-contrib.git#subdirectory=packages/bub-dsh"
```

## Authentication

The plugin uses Bub's root model configuration. For example:

```bash
export BUB_MODEL=dsh:deepseek-v4-flash
export BUB_MAX_TOKENS=49152
export BUB_DSH_API_KEY=sk-your-key
export BUB_DSH_API_BASE=https://api.deepseek.com
```

`BUB_API_KEY` and `BUB_API_BASE` are also supported by Bub for non-provider-specific
values.

The root provider `dsh` maps to the DeepSeek Harness provider route
`deepseek-official`. An unqualified root model defaults to `dsh`; any other explicit
provider is passed to DeepSeek Harness unchanged.

## Configuration

Environment variables use the `BUB_DSH_` prefix:

- `BUB_DSH_REQUEST_TIMEOUT_SECONDS`: optional positive JSON-RPC request timeout
- `BUB_DSH_SHUTDOWN_TIMEOUT_SECONDS`: runtime shutdown timeout, default `1`
- `BUB_DSH_CORDIS`: optional custom Cordis configuration path
- `BUB_DSH_SESSION_ROOT`: session storage directory, default `<bub.home>/dsh`

## Runtime Behavior

The DeepSeek Harness SDK is synchronous, so the plugin runs it in a worker thread and
does not block Bub's async event loop. Runtime processes are reused until Bub exits.
Calls for the same Bub session reuse one runtime-local session id, preserving the
Harness conversation and persistent shell state without colliding with logs left by a
previous Bub process. Session storage defaults to `bub.home / "dsh"`.

String prompts and structured lists of JSON content blocks are passed to
`DeepSeekHarness.run()` unchanged.

When a run finishes with `finish_reason="error"`, the plugin raises
`DshRunError` instead of returning an empty response. The Harness error payload is
available unchanged through the exception's `error` attribute.

The bundled runtime includes local shell tools and can modify files visible from the
Bub workspace. Run it only with filesystem permissions appropriate for the task.

## Current Limitations

- DeepSeek Harness is in developer preview and may introduce breaking SDK changes.
- Restarting Bub creates new Harness session ids. The current SDK cannot restore a
persisted session into a new runtime process, although existing logs remain on disk.
- Cancelling the Bub coroutine cannot forcibly stop work already running in Python's
worker thread. The current SDK has no whole-turn timeout while it waits for the
runtime to become idle.

## Validation

```bash
uv run pytest packages/bub-dsh/tests
uv run ruff check packages/bub-dsh
uv sync
```
20 changes: 20 additions & 0 deletions packages/bub-dsh/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[project]
name = "bub-dsh"
version = "0.1.0"
description = "DeepSeek Harness SDK-backed run_model plugin for Bub"
readme = "README.md"
authors = [
{ name = "Frost Ming", email = "me@frostming.com" }
]
requires-python = ">=3.12"
dependencies = [
"deepseek-harness-sdk>=0.1.0rc7,<0.2.0",
"pydantic-settings>=2.13.1",
]

[project.entry-points.bub]
dsh = "bub_dsh.plugin"

[build-system]
requires = ["uv_build>=0.10.4,<0.11.0"]
build-backend = "uv_build"
1 change: 1 addition & 0 deletions packages/bub-dsh/src/bub_dsh/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""DeepSeek Harness integration for Bub."""
184 changes: 184 additions & 0 deletions packages/bub-dsh/src/bub_dsh/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
from __future__ import annotations

import atexit
import asyncio
import threading
import uuid
from pathlib import Path

import bub
from bub import hookimpl
from bub.builtin.settings import AgentSettings
from bub.turn import TurnState
from deepseek_harness import DeepSeekHarness, RunResult
from pydantic import Field
from pydantic_settings import SettingsConfigDict

ROOT_PROVIDER = "dsh"
DEFAULT_HARNESS_PROVIDER = "deepseek-official"


class _HarnessRuntime:
def __init__(self, kwargs: dict[str, object]) -> None:
self.harness = DeepSeekHarness(**kwargs)
self.lock = threading.Lock()
self.session_ids: dict[str, str] = {}

def session_id(self, bub_session_id: str) -> str:
return self.session_ids.setdefault(
bub_session_id,
f"bub-{uuid.uuid4().hex}",
)


_runtimes: dict[tuple[tuple[str, object], ...], _HarnessRuntime] = {}
_runtimes_lock = threading.Lock()


class DshRunError(RuntimeError):
"""Error reported by a completed DeepSeek Harness run."""

def __init__(self, error: dict[str, object] | None = None) -> None:
self.error = error
message = "DeepSeek Harness run failed"
if error:
detail = error.get("message")
code = error.get("code")
if isinstance(detail, str) and detail:
message = f"{message}: {detail}"
if isinstance(code, str) and code:
message = f"{message} [{code}]"
super().__init__(message)


@bub.config(name="dsh")
class DshSettings(bub.Settings):
"""Configuration for the DeepSeek Harness Bub plugin."""

model_config = SettingsConfigDict(env_prefix="BUB_DSH_", extra="ignore")

request_timeout_seconds: float | None = Field(default=None, gt=0)
shutdown_timeout_seconds: float = Field(default=1.0, gt=0)
cordis: str | None = None
session_root: Path = Field(default_factory=lambda: bub.home / "dsh")


def workspace_from_state(state: TurnState) -> Path:
raw = state.get("_runtime_workspace")
if isinstance(raw, str) and raw.strip():
return Path(raw).expanduser().resolve()
return Path.cwd().resolve()


def _split_root_model(model: str) -> tuple[str, str]:
provider, separator, model_id = model.partition(":")
if not separator:
provider, model_id = ROOT_PROVIDER, provider
provider = provider.strip()
model_id = model_id.strip()
if not provider or not model_id:
raise RuntimeError(f"Invalid Bub model identifier: {model!r}")
return provider, model_id


def _provider_value(
value: str | dict[str, str] | None,
provider: str,
) -> str | None:
if isinstance(value, dict):
return value.get(provider)
return value


def _harness_kwargs(workspace: Path) -> dict[str, object]:
settings = bub.ensure_config(DshSettings)
root_settings = bub.ensure_config(AgentSettings)
root_provider, model_id = _split_root_model(root_settings.model)
kwargs: dict[str, object] = {
"provider": (
DEFAULT_HARNESS_PROVIDER
if root_provider == ROOT_PROVIDER
else root_provider
),
"model": model_id,
"max_tokens": root_settings.max_tokens,
"cwd": str(workspace),
"session_root": str(settings.session_root.expanduser().resolve()),
"request_timeout_seconds": settings.request_timeout_seconds,
"shutdown_timeout_seconds": settings.shutdown_timeout_seconds,
}
if settings.cordis:
kwargs["cordis"] = settings.cordis
if api_base := _provider_value(root_settings.api_base, root_provider):
kwargs["base_url"] = api_base
if api_key := _provider_value(root_settings.api_key, root_provider):
kwargs["api_key"] = api_key
return kwargs


def _runtime_for(workspace: Path) -> _HarnessRuntime:
kwargs = _harness_kwargs(workspace)
key = tuple(sorted(kwargs.items()))
with _runtimes_lock:
runtime = _runtimes.get(key)
if runtime is None:
runtime = _HarnessRuntime(kwargs)
_runtimes[key] = runtime
return runtime


def _close_runtimes() -> None:
with _runtimes_lock:
runtimes = list(_runtimes.values())
_runtimes.clear()
for runtime in runtimes:
runtime.harness.close()


atexit.register(_close_runtimes)


def _run_with_dsh(
prompt: str | list[dict],
*,
session_id: str,
workspace: Path,
) -> str:
runtime = _runtime_for(workspace)
with runtime.lock:
result: RunResult = runtime.harness.run(
prompt,
session_id=runtime.session_id(session_id),
)
if result.finish_reason == "error":
raise DshRunError(_run_result_error(result))
return result.final_response


def _run_result_error(result: RunResult) -> dict[str, object] | None:
for event in reversed(result.events):
if event.get("type") != "turn/end":
continue
data = event.get("data")
if not isinstance(data, dict):
continue
reason = data.get("reason")
if not isinstance(reason, dict) or reason.get("kind") != "error":
continue
error = reason.get("error")
if isinstance(error, dict):
return error
return None


@hookimpl
async def run_model(prompt: str | list[dict], session_id: str, state: TurnState) -> str:
return await asyncio.to_thread(
_run_with_dsh,
prompt,
session_id=session_id,
workspace=workspace_from_state(state),
)


__all__ = ["DshRunError", "DshSettings", "run_model"]
1 change: 1 addition & 0 deletions packages/bub-dsh/src/bub_dsh/py.typed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Loading