Run task execution through LangGraph runtime
This commit is contained in:
parent
de0f92e71b
commit
1c3f5289d6
4 changed files with 125 additions and 15 deletions
|
|
@ -3,7 +3,8 @@ from __future__ import annotations
|
||||||
from typing import Any, TypedDict
|
from typing import Any, TypedDict
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from graph.models import GraphRun
|
from control_plane.events.bus import EventBus
|
||||||
|
from graph.models import GraphEdgeTraversal, GraphRun, GraphRunStatus
|
||||||
from graph.native_runtime import NativeGraphRuntime
|
from graph.native_runtime import NativeGraphRuntime
|
||||||
from graph.registry import NodeHandlerRegistry
|
from graph.registry import NodeHandlerRegistry
|
||||||
from graph.runtime import GraphRuntime
|
from graph.runtime import GraphRuntime
|
||||||
|
|
@ -17,11 +18,16 @@ class LangGraphState(TypedDict, total=False):
|
||||||
edge_result: str
|
edge_result: str
|
||||||
|
|
||||||
|
|
||||||
|
class GraphInterrupted(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class LangGraphRuntime(GraphRuntime):
|
class LangGraphRuntime(GraphRuntime):
|
||||||
"""LangGraph adapter behind Artifex's runtime-neutral graph boundary."""
|
"""LangGraph adapter behind Artifex's runtime-neutral graph boundary."""
|
||||||
|
|
||||||
def __init__(self, registry: NodeHandlerRegistry | None = None) -> None:
|
def __init__(self, registry: NodeHandlerRegistry | None = None, bus: EventBus | None = None) -> None:
|
||||||
self.registry = registry
|
self.registry = registry
|
||||||
|
self.bus = bus or EventBus()
|
||||||
|
|
||||||
async def start(self, project_id: UUID | None = None, **kwargs: Any) -> str:
|
async def start(self, project_id: UUID | None = None, **kwargs: Any) -> str:
|
||||||
graph_run = kwargs.get("graph_run")
|
graph_run = kwargs.get("graph_run")
|
||||||
|
|
@ -45,7 +51,7 @@ class LangGraphRuntime(GraphRuntime):
|
||||||
async def signal(self, run_id: str, event: dict[str, Any]) -> None:
|
async def signal(self, run_id: str, event: dict[str, Any]) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def run_until_terminal_or_paused(self, graph_run: GraphRun) -> GraphRun:
|
def run_until_terminal_or_paused(self, graph_run: GraphRun, *, interrupt_after: str | None = None) -> GraphRun:
|
||||||
if self.registry is None:
|
if self.registry is None:
|
||||||
raise RuntimeError("LangGraphRuntime requires a NodeHandlerRegistry")
|
raise RuntimeError("LangGraphRuntime requires a NodeHandlerRegistry")
|
||||||
try:
|
try:
|
||||||
|
|
@ -58,7 +64,7 @@ class LangGraphRuntime(GraphRuntime):
|
||||||
|
|
||||||
workflow = StateGraph(LangGraphState)
|
workflow = StateGraph(LangGraphState)
|
||||||
for node_id in spec.nodes:
|
for node_id in spec.nodes:
|
||||||
workflow.add_node(node_id, self._node_runner(native, graph_run, node_id))
|
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)
|
workflow.set_entry_point(graph_run.current_node or spec.entry)
|
||||||
for node_id in spec.nodes:
|
for node_id in spec.nodes:
|
||||||
if node_id in spec.terminal_nodes:
|
if node_id in spec.terminal_nodes:
|
||||||
|
|
@ -68,28 +74,65 @@ class LangGraphRuntime(GraphRuntime):
|
||||||
if not edges:
|
if not edges:
|
||||||
workflow.add_edge(node_id, END)
|
workflow.add_edge(node_id, END)
|
||||||
continue
|
continue
|
||||||
workflow.add_conditional_edges(
|
workflow.add_conditional_edges(node_id, self._route(graph_run, spec, node_id), {edge.condition or "success": edge.target for edge in edges})
|
||||||
node_id,
|
|
||||||
lambda state: str(state.get("edge_result", "success")),
|
|
||||||
{edge.condition or "success": edge.target for edge in edges},
|
|
||||||
)
|
|
||||||
compiled = workflow.compile()
|
compiled = workflow.compile()
|
||||||
|
try:
|
||||||
compiled.invoke({"graph_run_id": graph_run.id, "current_node": graph_run.current_node or spec.entry})
|
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()
|
graph_run.refresh_from_db()
|
||||||
return graph_run
|
return graph_run
|
||||||
|
|
||||||
def _node_runner(self, native: NativeGraphRuntime, graph_run: GraphRun, node_id: str):
|
def _node_runner(self, native: NativeGraphRuntime, graph_run: GraphRun, node_id: str, interrupt_after: str | None):
|
||||||
def run_node(state: LangGraphState) -> LangGraphState:
|
def run_node(state: LangGraphState) -> LangGraphState:
|
||||||
if graph_run.current_node != node_id:
|
if graph_run.current_node != node_id:
|
||||||
graph_run.current_node = node_id
|
graph_run.current_node = node_id
|
||||||
graph_run.save(update_fields=["current_node", "updated_at"])
|
graph_run.save(update_fields=["current_node", "updated_at"])
|
||||||
native.run_until_terminal_or_paused(graph_run, interrupt_after=node_id)
|
native.run_until_terminal_or_paused(graph_run, interrupt_after=node_id)
|
||||||
graph_run.refresh_from_db()
|
graph_run.refresh_from_db()
|
||||||
|
if interrupt_after == node_id:
|
||||||
|
raise GraphInterrupted(node_id)
|
||||||
return {
|
return {
|
||||||
"graph_run_id": graph_run.id,
|
"graph_run_id": graph_run.id,
|
||||||
"current_node": graph_run.current_node,
|
"current_node": graph_run.current_node,
|
||||||
"status": graph_run.status,
|
"status": graph_run.status,
|
||||||
"edge_result": str(graph_run.metadata.get("last_edge_result", "success")),
|
"edge_result": str(graph_run.metadata.get("interrupted_edge_result", graph_run.metadata.get("last_edge_result", "success"))),
|
||||||
}
|
}
|
||||||
|
|
||||||
return run_node
|
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
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,11 @@ class NativeGraphRuntime(GraphRuntime):
|
||||||
else:
|
else:
|
||||||
result = self._execute_node(context, node_run)
|
result = self._execute_node(context, node_run)
|
||||||
if interrupt_after == node_id:
|
if interrupt_after == node_id:
|
||||||
graph_run.metadata = {**dict(graph_run.metadata), "interrupted_after_node": node_id}
|
metadata = self._merge_summary(graph_run.metadata, result)
|
||||||
|
metadata["interrupted_after_node"] = node_id
|
||||||
|
metadata["interrupted_edge_result"] = result.edge_result
|
||||||
|
metadata["interrupted_output"] = self._bounded(result.output_metadata or {})
|
||||||
|
graph_run.metadata = metadata
|
||||||
graph_run.save(update_fields=["metadata", "updated_at"])
|
graph_run.save(update_fields=["metadata", "updated_at"])
|
||||||
graph_run.refresh_from_db()
|
graph_run.refresh_from_db()
|
||||||
return graph_run
|
return graph_run
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ from control_plane.events.models import EventType
|
||||||
from control_plane.projects.models import CommitRecord, Task, TaskAttempt, TaskStatus, Worktree
|
from control_plane.projects.models import CommitRecord, Task, TaskAttempt, TaskStatus, Worktree
|
||||||
from control_plane.verification.models import VerificationResult
|
from control_plane.verification.models import VerificationResult
|
||||||
from graph.bootstrap import champion_task_execution_graph_v1
|
from graph.bootstrap import champion_task_execution_graph_v1
|
||||||
|
from graph.langgraph_runtime import LangGraphRuntime
|
||||||
from graph.models import GraphRun
|
from graph.models import GraphRun
|
||||||
from graph.native_runtime import NativeGraphRuntime
|
|
||||||
from graph.scheduler import TaskScheduler
|
from graph.scheduler import TaskScheduler
|
||||||
from graph.task_nodes import TaskExecutionServices, task_execution_registry
|
from graph.task_nodes import TaskExecutionServices, task_execution_registry
|
||||||
from knowledge.context_builder import WorkerContextBuilder
|
from knowledge.context_builder import WorkerContextBuilder
|
||||||
|
|
@ -59,7 +59,7 @@ class AutonomousTaskLoop:
|
||||||
current_node=graph_version.graph_spec["entry"],
|
current_node=graph_version.graph_spec["entry"],
|
||||||
)
|
)
|
||||||
services = TaskExecutionServices(self.router, bus=self.bus, test_command=test_command)
|
services = TaskExecutionServices(self.router, bus=self.bus, test_command=test_command)
|
||||||
NativeGraphRuntime(task_execution_registry(services), bus=self.bus).run_until_terminal_or_paused(graph_run)
|
LangGraphRuntime(task_execution_registry(services), bus=self.bus).run_until_terminal_or_paused(graph_run)
|
||||||
return
|
return
|
||||||
|
|
||||||
coder_version = self._champion(AgentRole.CODER)
|
coder_version = self._champion(AgentRole.CODER)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from control_plane.projects.models import CommitRecord, Project, ProjectPlan, Mi
|
||||||
from control_plane.verification.models import Review, TestRun, Verification, VerificationResult
|
from control_plane.verification.models import Review, TestRun, Verification, VerificationResult
|
||||||
from graph.models import ExecutionGraphDefinition, ExecutionGraphVersion, ExecutionGraphVersionStatus, GraphEdgeTraversal, GraphRun, GraphRunStatus
|
from graph.models import ExecutionGraphDefinition, ExecutionGraphVersion, ExecutionGraphVersionStatus, GraphEdgeTraversal, GraphRun, GraphRunStatus
|
||||||
from graph.native_runtime import NativeGraphRuntime
|
from graph.native_runtime import NativeGraphRuntime
|
||||||
|
from graph.langgraph_runtime import LangGraphRuntime
|
||||||
from graph.task_execution import task_execution_graph_v1
|
from graph.task_execution import task_execution_graph_v1
|
||||||
from graph.task_nodes import TaskExecutionServices, task_execution_registry
|
from graph.task_nodes import TaskExecutionServices, task_execution_registry
|
||||||
from model_router.router import ModelRouter
|
from model_router.router import ModelRouter
|
||||||
|
|
@ -58,6 +59,13 @@ def run_graph(task: Task, *, interrupt_after: str | None = None) -> GraphRun:
|
||||||
return runtime.run_until_terminal_or_paused(graph_run_for_task(task), interrupt_after=interrupt_after)
|
return runtime.run_until_terminal_or_paused(graph_run_for_task(task), interrupt_after=interrupt_after)
|
||||||
|
|
||||||
|
|
||||||
|
def run_langgraph(task: Task, *, interrupt_after: str | None = None) -> GraphRun:
|
||||||
|
SeedAgentsCommand().handle()
|
||||||
|
services = TaskExecutionServices(ModelRouter({"qwen": DeterministicCodingProvider()}), test_command=["python", "manage.py", "test"])
|
||||||
|
runtime = LangGraphRuntime(task_execution_registry(services))
|
||||||
|
return runtime.run_until_terminal_or_paused(graph_run_for_task(task), interrupt_after=interrupt_after)
|
||||||
|
|
||||||
|
|
||||||
def test_native_task_execution_graph_success_matches_loop_semantics(tmp_path: Path) -> None:
|
def test_native_task_execution_graph_success_matches_loop_semantics(tmp_path: Path) -> None:
|
||||||
repo = create_disposable_django_repo(tmp_path)
|
repo = create_disposable_django_repo(tmp_path)
|
||||||
task = create_task(repo, "Add a /health endpoint returning JSON {\"status\": \"ok\"} and add tests.", ["/health returns JSON ok", "tests pass"])
|
task = create_task(repo, "Add a /health endpoint returning JSON {\"status\": \"ok\"} and add tests.", ["/health returns JSON ok", "tests pass"])
|
||||||
|
|
@ -110,3 +118,58 @@ def test_native_task_execution_graph_resume_after_coder_prevents_duplicate_commi
|
||||||
assert task.status == TaskStatus.COMPLETE
|
assert task.status == TaskStatus.COMPLETE
|
||||||
assert CommitRecord.objects.filter(task=task).count() == 1
|
assert CommitRecord.objects.filter(task=task).count() == 1
|
||||||
assert graph_run.node_runs.filter(node_id="coder").count() == 1
|
assert graph_run.node_runs.filter(node_id="coder").count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_langgraph_task_execution_graph_success_matches_native_domain_outcome(tmp_path: Path) -> None:
|
||||||
|
repo = create_disposable_django_repo(tmp_path)
|
||||||
|
task = create_task(repo, "Add a /health endpoint returning JSON {\"status\": \"ok\"} and add tests.", ["/health returns JSON ok", "tests pass"])
|
||||||
|
|
||||||
|
graph_run = run_langgraph(task)
|
||||||
|
|
||||||
|
task.refresh_from_db()
|
||||||
|
assert graph_run.status == GraphRunStatus.COMPLETE
|
||||||
|
assert graph_run.execution_graph_version.graph.name == "task_execution"
|
||||||
|
assert task.status == TaskStatus.COMPLETE
|
||||||
|
commit = CommitRecord.objects.get(task=task)
|
||||||
|
assert commit.graph_run == graph_run
|
||||||
|
assert TestRun.objects.get(task=task).status == "PASS"
|
||||||
|
assert Review.objects.get(task=task).status == "PASS"
|
||||||
|
assert Verification.objects.get(task=task).result == VerificationResult.PASS
|
||||||
|
assert GraphEdgeTraversal.objects.filter(graph_run=graph_run, source_node="review", target_node="judge", condition="PASS").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_langgraph_task_execution_graph_retry_exhaustion_matches_native_domain_outcome(tmp_path: Path) -> None:
|
||||||
|
repo = create_disposable_django_repo(tmp_path)
|
||||||
|
task = create_task(repo, "FORCE_BAD_IMPLEMENTATION Add a /health endpoint returning JSON {\"status\": \"ok\"} and add tests.", ["/health returns JSON ok", "tests pass"], max_retries=1)
|
||||||
|
|
||||||
|
graph_run = run_langgraph(task)
|
||||||
|
|
||||||
|
task.refresh_from_db()
|
||||||
|
assert graph_run.status == GraphRunStatus.FAILED
|
||||||
|
assert task.status == TaskStatus.FAILED
|
||||||
|
assert task.retry_count == 2
|
||||||
|
assert CommitRecord.objects.filter(task=task).count() == 0
|
||||||
|
assert Event.objects.filter(task=task, event_type="TASK_RETRY_EXHAUSTED").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_langgraph_resume_after_coder_tests_and_judge_does_not_duplicate_commit(tmp_path: Path) -> None:
|
||||||
|
for node_id in ["coder", "run_tests", "judge"]:
|
||||||
|
root = tmp_path / node_id
|
||||||
|
root.mkdir()
|
||||||
|
repo = create_disposable_django_repo(root)
|
||||||
|
task = create_task(repo, "Add a /health endpoint returning JSON {\"status\": \"ok\"} and add tests.", ["/health returns JSON ok", "tests pass"])
|
||||||
|
SeedAgentsCommand().handle()
|
||||||
|
graph_run = graph_run_for_task(task)
|
||||||
|
services = TaskExecutionServices(ModelRouter({"qwen": DeterministicCodingProvider()}), test_command=["python", "manage.py", "test"])
|
||||||
|
runtime = LangGraphRuntime(task_execution_registry(services))
|
||||||
|
|
||||||
|
runtime.run_until_terminal_or_paused(graph_run, interrupt_after=node_id)
|
||||||
|
graph_run.refresh_from_db()
|
||||||
|
assert graph_run.current_node == node_id
|
||||||
|
|
||||||
|
runtime.run_until_terminal_or_paused(graph_run)
|
||||||
|
runtime.run_until_terminal_or_paused(graph_run)
|
||||||
|
|
||||||
|
task.refresh_from_db()
|
||||||
|
assert task.status == TaskStatus.COMPLETE
|
||||||
|
assert CommitRecord.objects.filter(task=task).count() == 1
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue