138 lines
6 KiB
Python
138 lines
6 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any, TypedDict
|
|
from uuid import UUID
|
|
|
|
from control_plane.events.bus import EventBus
|
|
from graph.models import GraphEdgeTraversal, GraphRun, GraphRunStatus
|
|
from graph.native_runtime import NativeGraphRuntime
|
|
from graph.registry import NodeHandlerRegistry
|
|
from graph.runtime import GraphRuntime
|
|
from graph.spec import ExecutionGraphSpec
|
|
|
|
|
|
class LangGraphState(TypedDict, total=False):
|
|
graph_run_id: int
|
|
current_node: str
|
|
status: str
|
|
edge_result: str
|
|
|
|
|
|
class GraphInterrupted(RuntimeError):
|
|
pass
|
|
|
|
|
|
class LangGraphRuntime(GraphRuntime):
|
|
"""LangGraph adapter behind Artifex's runtime-neutral graph boundary."""
|
|
|
|
def __init__(self, registry: NodeHandlerRegistry | None = None, bus: EventBus | None = None) -> None:
|
|
self.registry = registry
|
|
self.bus = bus or EventBus()
|
|
|
|
async def start(self, project_id: UUID | None = None, **kwargs: Any) -> str:
|
|
graph_run = kwargs.get("graph_run")
|
|
if graph_run is not None:
|
|
self.run_until_terminal_or_paused(graph_run)
|
|
return str(graph_run.id)
|
|
return f"project-{project_id}"
|
|
|
|
async def pause(self, run_id: str) -> None:
|
|
return None
|
|
|
|
async def resume(self, run_id: str) -> None:
|
|
if self.registry is None:
|
|
return None
|
|
graph_run = GraphRun.objects.get(id=run_id)
|
|
self.run_until_terminal_or_paused(graph_run)
|
|
|
|
async def cancel(self, run_id: str) -> None:
|
|
return None
|
|
|
|
async def signal(self, run_id: str, event: dict[str, Any]) -> None:
|
|
return None
|
|
|
|
def run_until_terminal_or_paused(self, graph_run: GraphRun, *, interrupt_after: str | None = None) -> GraphRun:
|
|
if self.registry is None:
|
|
raise RuntimeError("LangGraphRuntime requires a NodeHandlerRegistry")
|
|
try:
|
|
from langgraph.graph import END, StateGraph
|
|
except ImportError as exc:
|
|
raise RuntimeError("LangGraphRuntime requires the langgraph package") from exc
|
|
|
|
spec = ExecutionGraphSpec.from_dict(graph_run.execution_graph_version.graph_spec)
|
|
native = NativeGraphRuntime(self.registry)
|
|
|
|
workflow = StateGraph(LangGraphState)
|
|
for node_id in spec.nodes:
|
|
workflow.add_node(node_id, self._node_runner(native, graph_run, node_id, interrupt_after))
|
|
workflow.set_entry_point(graph_run.current_node or spec.entry)
|
|
for node_id in spec.nodes:
|
|
if node_id in spec.terminal_nodes:
|
|
workflow.add_edge(node_id, END)
|
|
continue
|
|
edges = [edge for edge in spec.edges if edge.source == node_id]
|
|
if not edges:
|
|
workflow.add_edge(node_id, END)
|
|
continue
|
|
workflow.add_conditional_edges(node_id, self._route(graph_run, spec, node_id), {edge.condition or "success": edge.target for edge in edges})
|
|
compiled = workflow.compile()
|
|
try:
|
|
compiled.invoke({"graph_run_id": graph_run.id, "current_node": graph_run.current_node or spec.entry})
|
|
except GraphInterrupted:
|
|
pass
|
|
graph_run.refresh_from_db()
|
|
return graph_run
|
|
|
|
def _node_runner(self, native: NativeGraphRuntime, graph_run: GraphRun, node_id: str, interrupt_after: str | None):
|
|
def run_node(state: LangGraphState) -> LangGraphState:
|
|
if graph_run.current_node != node_id:
|
|
graph_run.current_node = node_id
|
|
graph_run.save(update_fields=["current_node", "updated_at"])
|
|
native.run_until_terminal_or_paused(graph_run, interrupt_after=node_id)
|
|
graph_run.refresh_from_db()
|
|
if interrupt_after == node_id:
|
|
raise GraphInterrupted(node_id)
|
|
return {
|
|
"graph_run_id": graph_run.id,
|
|
"current_node": graph_run.current_node,
|
|
"status": graph_run.status,
|
|
"edge_result": str(graph_run.metadata.get("interrupted_edge_result", graph_run.metadata.get("last_edge_result", "success"))),
|
|
}
|
|
|
|
return run_node
|
|
|
|
def _route(self, graph_run: GraphRun, spec: ExecutionGraphSpec, node_id: str):
|
|
def route(state: LangGraphState) -> str:
|
|
graph_run.refresh_from_db()
|
|
edge_result = str(state.get("edge_result") or graph_run.metadata.get("interrupted_edge_result") or "success")
|
|
target = None
|
|
for edge in spec.edges:
|
|
if edge.source == node_id and edge.condition == edge_result:
|
|
target = edge.target
|
|
break
|
|
if target is None:
|
|
for edge in spec.edges:
|
|
if edge.source == node_id and not edge.condition:
|
|
target = edge.target
|
|
edge_result = edge.condition or "success"
|
|
break
|
|
if target is None:
|
|
graph_run.status = GraphRunStatus.FAILED
|
|
graph_run.failure_reason = f"No edge from {node_id} for {edge_result}"
|
|
graph_run.save(update_fields=["status", "failure_reason", "updated_at"])
|
|
return edge_result
|
|
GraphEdgeTraversal.objects.create(graph_run=graph_run, source_node=node_id, target_node=target, condition=edge_result, result=edge_result)
|
|
metadata = dict(graph_run.metadata)
|
|
metadata["last_node_id"] = node_id
|
|
metadata["last_edge_result"] = edge_result
|
|
metadata["last_output"] = metadata.get("interrupted_output", {})
|
|
metadata.pop("interrupted_after_node", None)
|
|
metadata.pop("interrupted_edge_result", None)
|
|
metadata.pop("interrupted_output", None)
|
|
graph_run.current_node = target
|
|
graph_run.metadata = metadata
|
|
graph_run.save(update_fields=["current_node", "metadata", "updated_at"])
|
|
self.bus.publish("GRAPH_EDGE_TRAVERSED", project=graph_run.project, task=graph_run.task, payload={"graph_run_id": graph_run.id, "source": node_id, "target": target, "condition": edge_result})
|
|
return edge_result
|
|
|
|
return route
|