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
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,24 @@ BUB_API_BASE=https://openrouter.ai/api/v1
# BUB_MODEL_TIMEOUT_SECONDS=300
# BUB_HOME=~/.bub

# ---------------------------------------------------------------------------
# LangChain / DeepAgents via DashScope (optional)
# Enable when using `bubseek-langchain` as the model backend.
# ---------------------------------------------------------------------------
# BUB_LANGCHAIN_MODE=runnable
# BUB_LANGCHAIN_FACTORY=examples.langchain.deepagents_dashscope:dashscope_deep_agent
# BUB_LANGCHAIN_INCLUDE_BUB_TOOLS=true
# BUB_LANGCHAIN_TAPE=true
# Example-specific overrides are optional. If omitted, the DeepAgents example
# derives the raw chat model name from BUB_MODEL by stripping the provider prefix.
# Example:
# BUB_MODEL=openai:glm-5.1
# BUB_API_KEY=your-dashscope-api-key
# BUB_API_BASE=https://dashscope.aliyuncs.com/compatible-mode/v1
# BUB_DEEPAGENTS_MODEL=glm-5.1
# BUB_DEEPAGENTS_API_KEY=your-dashscope-api-key
# BUB_DEEPAGENTS_API_BASE=https://dashscope.aliyuncs.com/compatible-mode/v1

# ---------------------------------------------------------------------------
# Database (required for tape storage)
# ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/setup-python-env/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,5 @@ runs:
cache-suffix: ${{ matrix.python-version }}

- name: Install Python dependencies
run: uv sync --frozen
run: uv sync --frozen --all-packages
shell: bash
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ lock: ## Update uv.lock against PyPI (ignore UV_INDEX_URL so lock stays canonica
.PHONY: install
install: ## Install the virtual environment and install the pre-commit hooks
@echo "🚀 Creating virtual environment using uv"
@uv sync
@uv sync --all-packages
@uv run pre-commit install

.PHONY: check
Expand Down
81 changes: 81 additions & 0 deletions contrib/bubseek-langchain/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# bubseek-langchain

`bubseek-langchain` is an optional Bub plugin that routes `run_model` through a LangChain `Runnable`.

Current scope:

- only `Runnable` mode is supported;
- Bub tools can be bridged into LangChain tools;
- Bub tape recording still works for user / assistant turns and tool spans;
- prompts starting with `,` still fall through to Bub built-in internal commands.

## Install

From the repo root:

```bash
uv sync --extra langchain
```

Or install only the plugin package:

```bash
uv pip install -e contrib/bubseek-langchain
```

If you want the DashScope DeepAgents example too:

```bash
uv pip install -e 'contrib/bubseek-langchain[deepagents]'
```

## Enable

Set:

```bash
export BUB_LANGCHAIN_MODE=runnable
export BUB_LANGCHAIN_FACTORY=examples.langchain.minimal_runnable:minimal_lc_agent
```

Optional flags:

- `BUB_LANGCHAIN_INCLUDE_BUB_TOOLS=true|false` (default `true`)
- `BUB_LANGCHAIN_TAPE=true|false` (default `true`)

## Factory Contract

`BUB_LANGCHAIN_FACTORY` must point to a `module:attr`.

The imported object may be:

- a `Runnable` instance;
- a factory callable returning a `Runnable`;
- a factory callable returning `(runnable, invoke_input)`.

If the callable accepts them, the adapter injects:

- `state`
- `session_id`
- `workspace`
- `tools`
- `system_prompt`
- `prompt`

If the factory returns only a runnable, the adapter uses the Bub prompt as default invoke input.
If you need a different input shape, derive it from `prompt` and return `(runnable, invoke_input)` explicitly.

## Examples

Repository examples live under [examples/langchain/README.md](/home/shangzhuoran.szr/oceanbase/bubseek/examples/langchain/README.md):

- [examples/langchain/minimal_runnable.py](/home/shangzhuoran.szr/oceanbase/bubseek/examples/langchain/minimal_runnable.py)
- [examples/langchain/deepagents_dashscope.py](/home/shangzhuoran.szr/oceanbase/bubseek/examples/langchain/deepagents_dashscope.py)

Typical minimal run:

```bash
export BUB_LANGCHAIN_MODE=runnable
export BUB_LANGCHAIN_FACTORY=examples.langchain.minimal_runnable:minimal_lc_agent
uv run bub run "Summarize this workspace in one sentence."
```
30 changes: 30 additions & 0 deletions contrib/bubseek-langchain/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[project]
name = "bubseek-langchain"
version = "0.1.0"
description = "LangChain Runnable adapter for Bubseek"
readme = "README.md"
authors = [{ name = "Chojan Shang", email = "psiace@apache.org" }]
requires-python = ">=3.12"
dependencies = [
"bub",
"langchain-core>=0.3.0",
"pydantic>=2.0",
"pydantic-settings>=2.0.0",
]

[project.optional-dependencies]
deepagents = [
"deepagents>=0.5.3",
"langchain-openai>=0.3.0",
]

[project.entry-points."bub"]
bubseek-langchain = "bubseek_langchain.plugin:main"

[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"

[tool.pdm.build]
package-dir = "src"
includes = ["src/bubseek_langchain"]
13 changes: 13 additions & 0 deletions contrib/bubseek-langchain/src/bubseek_langchain/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""LangChain Runnable adapter for Bubseek."""

from .config import LangchainPluginSettings, load_settings
from .errors import LangchainConfigError
from .plugin import LangchainPlugin, main

__all__ = [
"LangchainConfigError",
"LangchainPlugin",
"LangchainPluginSettings",
"load_settings",
"main",
]
88 changes: 88 additions & 0 deletions contrib/bubseek-langchain/src/bubseek_langchain/bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any

from bub.types import State


@dataclass(frozen=True)
class LangchainRunContext:
session_id: str
tape_name: str | None
run_id: str

def as_logger_extra(self) -> dict[str, str]:
extra = {
"session_id": self.session_id,
"langchain_run_id": self.run_id,
}
if self.tape_name:
extra["tape_name"] = self.tape_name
return extra

def as_metadata(self) -> dict[str, str]:
return self.as_logger_extra()

def as_tags(self) -> list[str]:
tags = [
"bubseek-langchain",
f"session:{self.session_id}",
f"langchain-run:{self.run_id}",
]
if self.tape_name:
tags.append(f"tape:{self.tape_name}")
return tags


def extract_prompt_text(prompt: str | list[dict[str, Any]]) -> str:
if isinstance(prompt, str):
return prompt

texts: list[str] = []
for part in prompt:
if not isinstance(part, dict):
continue
if part.get("type") != "text":
continue
text = part.get("text")
if isinstance(text, str) and text.strip():
texts.append(text)
return "\n".join(texts).strip()


def build_factory_kwargs(
*,
state: State,
session_id: str,
workspace: Path,
tools: list[Any],
system_prompt: str,
prompt: str | list[dict[str, Any]],
langchain_context: LangchainRunContext,
) -> dict[str, Any]:
return {
"state": state,
"session_id": session_id,
"workspace": workspace,
"tools": tools,
"system_prompt": system_prompt,
"prompt": prompt,
"langchain_context": langchain_context,
}


def build_runnable_config(
*,
langchain_context: LangchainRunContext,
callbacks: list[Any] | None = None,
) -> dict[str, Any]:
config: dict[str, Any] = {
"metadata": langchain_context.as_metadata(),
"tags": langchain_context.as_tags(),
"run_name": "bubseek-langchain",
}
if callbacks:
config["callbacks"] = callbacks
return config
37 changes: 37 additions & 0 deletions contrib/bubseek-langchain/src/bubseek_langchain/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from __future__ import annotations

from typing import Literal

from pydantic_settings import BaseSettings, SettingsConfigDict

from .errors import LangchainConfigError


class LangchainPluginSettings(BaseSettings):
"""Configuration for the Bubseek LangChain Runnable adapter."""

model_config = SettingsConfigDict(
env_prefix="BUB_LANGCHAIN_",
env_file=".env",
extra="ignore",
)

mode: Literal["", "runnable"] = ""
factory: str | None = None
include_bub_tools: bool = True
tape: bool = True


def load_settings() -> LangchainPluginSettings:
return LangchainPluginSettings()


def is_enabled(settings: LangchainPluginSettings) -> bool:
return settings.mode == "runnable"


def validate_config(settings: LangchainPluginSettings) -> None:
"""Raise :class:`LangchainConfigError` when required variables are missing."""

if settings.mode == "runnable" and not settings.factory:
raise LangchainConfigError("BUB_LANGCHAIN_FACTORY is required in runnable mode")
5 changes: 5 additions & 0 deletions contrib/bubseek-langchain/src/bubseek_langchain/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""User-actionable configuration and wiring errors."""


class LangchainConfigError(ValueError):
"""Raised when ``BUB_LANGCHAIN_*`` settings are inconsistent or incomplete."""
67 changes: 67 additions & 0 deletions contrib/bubseek-langchain/src/bubseek_langchain/loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from __future__ import annotations

import inspect
from importlib import import_module
from typing import Any


def import_object(spec: str) -> Any:
if ":" not in spec:
raise ValueError(f"Expected 'module:attr', got {spec!r}")
module_name, attr_name = spec.split(":", 1)
module = import_module(module_name)
try:
return getattr(module, attr_name)
except AttributeError as exc:
raise AttributeError(f"Attribute {attr_name!r} not found in module {module_name!r}") from exc


def _is_runnable_like(obj: object) -> bool:
return hasattr(obj, "invoke") and hasattr(obj, "ainvoke")


def _call_with_supported_kwargs(factory: Any, factory_kwargs: dict[str, Any]) -> Any:
signature = inspect.signature(factory)
parameters = signature.parameters
if any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values()):
return factory(**factory_kwargs)
supported_kwargs = {name: value for name, value in factory_kwargs.items() if name in parameters}
return factory(**supported_kwargs)


def ensure_runnable(obj: Any) -> Any:
if not _is_runnable_like(obj):
raise TypeError(f"Expected a Runnable with invoke/ainvoke, got {type(obj)!r}")
return obj


def _normalize_factory_result(
value: Any,
*,
factory: str,
factory_kwargs: dict[str, Any],
default_input: Any,
) -> tuple[Any, Any]:
if _is_runnable_like(value):
return ensure_runnable(value), default_input
if isinstance(value, tuple) and len(value) == 2:
runnable, invoke_input = value
return ensure_runnable(runnable), invoke_input
if callable(value):
return _normalize_factory_result(
_call_with_supported_kwargs(value, factory_kwargs),
factory=factory,
factory_kwargs=factory_kwargs,
default_input=default_input,
)
raise TypeError(f"Object from {factory!r} is neither Runnable, callable, nor (Runnable, input)")


def resolve_runnable_and_input(factory: str, factory_kwargs: dict[str, Any], default_input: Any) -> tuple[Any, Any]:
obj = import_object(factory)
return _normalize_factory_result(
obj,
factory=factory,
factory_kwargs=factory_kwargs,
default_input=default_input,
)
Loading
Loading