Skip to content

Commit faae87b

Browse files
committed
chore(release): devsper 2.5.0
LangChain/LangGraph integration adapters, examples, and tests. Platform runtime events, execution graph metadata, MCP/tool runner updates, and compatibility shims included in this release. See CHANGELOG.md for details. Made-with: Cursor
1 parent dbc5945 commit faae87b

34 files changed

+2219
-91
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.5.0] — 2026-04-06
11+
12+
### Added
13+
14+
- **LangChain / LangGraph integrations**`devsper.integrations.langchain_adapter` and `langgraph_adapter` for wrapping LangChain runnables as Devsper tasks and running compiled LangGraph nodes with Devsper DAG scheduling; CLI-friendly examples under `examples/langchain_agent.py` and `examples/langgraph_swarm.py`; optional extra `pip install 'devsper[langgraph]'` to pin LangGraph.
15+
1016
## [2.4.1] — 2026-03-31
1117

1218
### Fixed

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,28 @@ Credentials are injected from the keyring (or env) when config is resolved—no
129129

130130
---
131131

132+
## LangChain integration
133+
134+
Devsper ships a **small adapter layer** (not a parallel orchestration stack) so you can keep building in **LangChain** and **LangGraph** while aligning execution with Devsper’s **task / DAG** mental model.
135+
136+
| Module | Purpose |
137+
|--------|---------|
138+
| `devsper.integrations.langchain_adapter` | Wrap LangChain runnables and agents as tagged `Task`s; `run_langchain_runnable()` runs `ainvoke` / `invoke` and normalizes output for `Task.result`. |
139+
| `devsper.integrations.langgraph_adapter` | Derive a Devsper task graph from a compiled LangGraph graph (`compiled_graph_to_tasks`); `run_compiled_graph_as_devsper_tasks()` runs each node with bounded concurrency and pluggable state merge (default: concatenate list fields—good for `MessagesState`). |
140+
141+
**Install:** `langchain` is a core dependency of the `devsper` package; **LangGraph** is pulled in transitively. To pin it explicitly: `pip install 'devsper[langgraph]'`.
142+
143+
**Examples (from the runtime repo root):**
144+
145+
```bash
146+
uv run python examples/langchain_agent.py --prompt "Say hi in one sentence."
147+
uv run python examples/langgraph_swarm.py --workers 4
148+
```
149+
150+
Docs: [LangChain integration](https://docs.devsper.com/docs/langchain-integration) (source: `docs/docs/langchain-integration.md` in the docs tree).
151+
152+
---
153+
132154
## Credentials
133155

134156
API keys are **not** stored in config files. Use the **credential store** (OS keychain) or environment variables.
@@ -286,6 +308,8 @@ Rust workers: set `DEVSPER_WORKER_MODEL=github:gpt-4o` (or your model), `DEVSPER
286308
| Dataset analysis | `uv run python examples/data_science/dataset_analysis.py [path-to.csv]` |
287309
| Document intelligence | `uv run python examples/documents/analyze_documents.py [dir]` |
288310
| Parameter sweep | `uv run python examples/experiments/parameter_sweep.py --params '{"lr":[0.01,0.1]}'` |
311+
| LangChain agent (adapter) | `uv run python examples/langchain_agent.py [--live] [--prompt "..."]` |
312+
| LangGraph DAG (as Devsper tasks) | `uv run python examples/langgraph_swarm.py [--workers N]` |
289313

290314
Outputs under `examples/output/`. Run from project root when using script paths.
291315

devsper/contracts/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Shared contracts between runtime and platform (event type strings, etc.)."""
2+
3+
from devsper.contracts.platform_event_type import DEVSPER_TO_PLATFORM, PlatformEventType
4+
5+
__all__ = ["DEVSPER_TO_PLATFORM", "PlatformEventType"]
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""
2+
Canonical `event_type` strings accepted by the platform runtime ingest API and
3+
emitted by the runtime forwarder.
4+
5+
**Single source of truth for Python.** Mirror:
6+
`platform/services/api/internal/contracts/runtime_events.go`
7+
"""
8+
9+
from __future__ import annotations
10+
11+
12+
class PlatformEventType:
13+
"""Platform SSE / run_events.event_type values (string constants only)."""
14+
15+
RUN_STARTED = "run_started"
16+
STEP_STARTED = "step_started"
17+
STEP_COMPLETED = "step_completed"
18+
AGENT_STARTED = "agent_started"
19+
AGENT_FINISHED = "agent_finished"
20+
RUN_PROGRESS = "run_progress"
21+
TOOL_CALLED = "tool_called"
22+
RUN_COMPLETED = "run_completed"
23+
RUN_FAILED = "run_failed"
24+
CLARIFICATION_REQUESTED = "clarification_requested"
25+
CLARIFICATION_ANSWERED = "clarification_answered"
26+
RUN_PAUSED = "run_paused"
27+
RUN_RESUMED = "run_resumed"
28+
LOG = "log"
29+
EXECUTION_GRAPH_UPDATED = "EXECUTION_GRAPH_UPDATED"
30+
SPECULATIVE_TASK_STARTED = "SPECULATIVE_TASK_STARTED"
31+
SPECULATIVE_TASK_CANCELLED = "SPECULATIVE_TASK_CANCELLED"
32+
HITL_REQUESTED = "HITL_REQUESTED"
33+
HITL_RESOLVED = "HITL_RESOLVED"
34+
WORKER_ASSIGNED = "WORKER_ASSIGNED"
35+
AGENT_POOL_USED = "AGENT_POOL_USED"
36+
EXECUTOR_FINISHED = "executor_finished"
37+
38+
39+
# DevSper `events` enum value (as string) -> platform ingest type
40+
DEVSPER_TO_PLATFORM: dict[str, str] = {
41+
"swarm_started": PlatformEventType.RUN_STARTED,
42+
"executor_started": PlatformEventType.RUN_STARTED,
43+
"task_started": PlatformEventType.STEP_STARTED,
44+
"task_completed": PlatformEventType.STEP_COMPLETED,
45+
"agent_started": PlatformEventType.AGENT_STARTED,
46+
"agent_finished": PlatformEventType.AGENT_FINISHED,
47+
"run_completed": PlatformEventType.RUN_COMPLETED,
48+
"task_failed": PlatformEventType.RUN_FAILED,
49+
"run_failed": PlatformEventType.RUN_FAILED,
50+
"tool_called": PlatformEventType.TOOL_CALLED,
51+
"clarification_requested": PlatformEventType.CLARIFICATION_REQUESTED,
52+
"clarification_needed": PlatformEventType.CLARIFICATION_REQUESTED,
53+
"clarification_received": PlatformEventType.CLARIFICATION_ANSWERED,
54+
"planner_started": PlatformEventType.RUN_PROGRESS,
55+
"planner_finished": PlatformEventType.RUN_PROGRESS,
56+
"reasoning_node_added": PlatformEventType.RUN_PROGRESS,
57+
"budget_warning": PlatformEventType.RUN_PROGRESS,
58+
"task_created": PlatformEventType.RUN_PROGRESS,
59+
"task_model_selected": PlatformEventType.RUN_PROGRESS,
60+
"agent_broadcast": PlatformEventType.RUN_PROGRESS,
61+
"run_manifest_emitted": PlatformEventType.RUN_PROGRESS,
62+
"worker_assigned": PlatformEventType.WORKER_ASSIGNED,
63+
"speculative_started": PlatformEventType.SPECULATIVE_TASK_STARTED,
64+
"speculative_cancelled": PlatformEventType.SPECULATIVE_TASK_CANCELLED,
65+
"hitl_requested": PlatformEventType.HITL_REQUESTED,
66+
"hitl_resolved": PlatformEventType.HITL_RESOLVED,
67+
"executor_finished": PlatformEventType.EXECUTOR_FINISHED,
68+
}

devsper/debug_events.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""DEVSPER_DEBUG_EVENTS=1 — log runtime → platform → delivery trail (stderr / logging)."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import logging
7+
import os
8+
from typing import Any
9+
10+
_LOG = logging.getLogger("devsper.debug_events")
11+
12+
13+
def debug_events_enabled() -> bool:
14+
return os.environ.get("DEVSPER_DEBUG_EVENTS", "").strip().lower() in (
15+
"1",
16+
"true",
17+
"yes",
18+
"on",
19+
)
20+
21+
22+
def log_runtime_emit(stage: str, payload: dict[str, Any]) -> None:
23+
if not debug_events_enabled():
24+
return
25+
try:
26+
line = json.dumps({"debug_events": stage, **payload}, default=str)
27+
except TypeError:
28+
line = str(payload)
29+
_LOG.info(line)
30+
31+
32+
def log_platform_body(body: dict[str, Any]) -> None:
33+
if not debug_events_enabled():
34+
return
35+
try:
36+
_LOG.info("[platform_event] %s", json.dumps(body, default=str))
37+
except TypeError:
38+
_LOG.info("[platform_event] %s", body)

devsper/docs/architecture/execution-model.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ The canonical execution model document lives under **docs**:
66

77
That document is the source of truth for DAG execution, task state machine, HITL clarification (single-node and distributed, including Rust worker), bus topics, TUI lifecycle, and headless mode.
88

9-
For the latest runtime release notes (including 2.4.1 stabilization fixes), see:
9+
For the latest runtime release notes (including 2.5.0 LangChain/LangGraph adapters), see:
1010

1111
- `https://docs.devsper.com/docs/release_notes`
1212
- `https://devsper.com/blog/distributed-runtime-upgrade-2-4-0`

devsper/integrations/__init__.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Optional bridges between Devsper and popular agent frameworks (LangChain, LangGraph)."""
2+
3+
from __future__ import annotations
4+
5+
__all__ = [
6+
"langchain_task",
7+
"run_langchain_runnable",
8+
"stringify_langchain_output",
9+
"LANGCHAIN_TASK_PREFIX",
10+
"compiled_graph_to_tasks",
11+
"default_list_merge_state",
12+
"run_compiled_graph_as_devsper_tasks",
13+
]
14+
15+
16+
def __getattr__(name: str):
17+
if name in (
18+
"langchain_task",
19+
"run_langchain_runnable",
20+
"stringify_langchain_output",
21+
"LANGCHAIN_TASK_PREFIX",
22+
):
23+
from devsper.integrations import langchain_adapter as m
24+
25+
return getattr(m, name)
26+
if name in (
27+
"compiled_graph_to_tasks",
28+
"default_list_merge_state",
29+
"run_compiled_graph_as_devsper_tasks",
30+
):
31+
from devsper.integrations import langgraph_adapter as m
32+
33+
return getattr(m, name)
34+
raise AttributeError(name)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Wrap LangChain runnables and agent pipelines as Devsper tasks (minimal surface, no extra framework)."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import json
7+
from typing import Any
8+
9+
from devsper.types.task import Task, TaskStatus
10+
11+
LANGCHAIN_TASK_PREFIX = "[devsper:langchain]"
12+
13+
14+
def langchain_task(
15+
task_id: str,
16+
*,
17+
description: str = "",
18+
dependencies: list[str] | None = None,
19+
role: str | None = None,
20+
) -> Task:
21+
"""Return a Devsper `Task` tagged for LangChain execution (visible in logs and DAG exports)."""
22+
desc = (description or "").strip() or f"LangChain runnable ({task_id})"
23+
if LANGCHAIN_TASK_PREFIX not in desc:
24+
desc = f"{LANGCHAIN_TASK_PREFIX} {desc}"
25+
return Task(
26+
id=task_id,
27+
description=desc,
28+
dependencies=list(dependencies or []),
29+
status=TaskStatus.PENDING,
30+
role=role,
31+
)
32+
33+
34+
def stringify_langchain_output(value: Any, *, max_len: int = 32000) -> str:
35+
"""Normalize LangChain/LangGraph outputs to a string suitable for `Task.result`."""
36+
if value is None:
37+
return ""
38+
if hasattr(value, "content"):
39+
text = getattr(value, "content", value)
40+
return str(text)[:max_len]
41+
if isinstance(value, dict):
42+
if "output" in value:
43+
return stringify_langchain_output(value["output"], max_len=max_len)
44+
if "messages" in value:
45+
parts: list[str] = []
46+
for m in value["messages"]:
47+
if hasattr(m, "content"):
48+
parts.append(str(getattr(m, "content", m)))
49+
else:
50+
parts.append(str(m))
51+
return "\n".join(parts).strip()[:max_len]
52+
try:
53+
return json.dumps(value, default=str)[:max_len]
54+
except TypeError:
55+
return str(value)[:max_len]
56+
if isinstance(value, str):
57+
return value[:max_len]
58+
return str(value)[:max_len]
59+
60+
61+
async def run_langchain_runnable(
62+
task: Task,
63+
runnable: Any,
64+
input_data: dict[str, Any] | str | list[Any],
65+
*,
66+
config: Any | None = None,
67+
) -> str:
68+
"""
69+
Run a LangChain `Runnable`, agent, or legacy executor with async `ainvoke` when available.
70+
71+
`input_data` may be a string (wrapped as `{"input": ...}`), a message list (chat models), or a dict.
72+
"""
73+
inp: Any = input_data
74+
if isinstance(inp, str):
75+
inp = {"input": inp}
76+
if hasattr(runnable, "ainvoke"):
77+
out = await runnable.ainvoke(inp, config=config)
78+
else:
79+
out = await asyncio.to_thread(runnable.invoke, inp, config)
80+
return stringify_langchain_output(out)

0 commit comments

Comments
 (0)