feat: langgraph interrupt hook (#1273)

This commit is contained in:
Ran Shemtov
2025-02-04 17:32:42 +01:00
committed by GitHub
parent dc6078c0ed
commit 7b3141d3a9
44 changed files with 840 additions and 227 deletions
+2
View File
@@ -4,6 +4,7 @@ from typing import Optional, List, TypedDict
from abc import ABC, abstractmethod
from .types import Message
from .action import ActionDict
from .types import MetaEvent
class AgentDict(TypedDict):
"""Agent dictionary"""
@@ -30,6 +31,7 @@ class Agent(ABC):
thread_id: Optional[str] = None,
node_name: Optional[str] = None,
actions: Optional[List[ActionDict]] = None,
meta_events: Optional[List[MetaEvent]] = None,
):
"""Execute the agent"""
@@ -4,11 +4,11 @@ import logging
import asyncio
#
from concurrent.futures import ThreadPoolExecutor
from typing import List, Any, cast
from typing import List, Any, cast, Optional
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from ..sdk import CopilotKitRemoteEndpoint, CopilotKitContext
from ..types import Message
from ..types import Message, MetaEvent
from ..exc import (
ActionNotFoundException,
ActionExecutionException,
@@ -98,6 +98,7 @@ async def handler(request: Request, sdk: CopilotKitRemoteEndpoint):
state = body_get_or_raise(body, "state")
messages = body_get_or_raise(body, "messages")
actions = cast(List[ActionDict], body.get("actions", []))
meta_events = cast(List[MetaEvent], body.get("metaEvents", []))
return handle_execute_agent(
sdk=sdk,
@@ -108,6 +109,7 @@ async def handler(request: Request, sdk: CopilotKitRemoteEndpoint):
state=state,
messages=messages,
actions=actions,
meta_events=meta_events,
)
if method == 'POST' and path == 'agents/state':
@@ -164,6 +166,7 @@ def handle_execute_agent( # pylint: disable=too-many-arguments
state: dict,
messages: List[Message],
actions: List[ActionDict],
meta_events: Optional[List[MetaEvent]] = None,
):
"""Handle continue agent execution request with FastAPI"""
try:
@@ -175,6 +178,7 @@ def handle_execute_agent( # pylint: disable=too-many-arguments
state=state,
messages=messages,
actions=actions,
meta_events=meta_events,
)
return StreamingResponse(events, media_type="application/json")
except AgentNotFoundException as exc:
+30 -9
View File
@@ -8,10 +8,11 @@ from langgraph.graph.graph import CompiledGraph
from langchain.load.dump import dumps as langchain_dumps
from langchain.schema import BaseMessage, SystemMessage
from langchain_core.runnables import RunnableConfig, ensure_config
from langgraph.types import Command
from partialjson.json_parser import JSONParser
from .types import Message
from .types import Message, MetaEvent
from .langgraph import copilotkit_messages_to_langchain, langchain_messages_to_copilotkit
from .action import ActionDict
from .agent import Agent
@@ -218,8 +219,8 @@ class LangGraphAgent(Agent):
thread_id: Optional[str] = None,
node_name: Optional[str] = None,
actions: Optional[List[ActionDict]] = None,
meta_events: Optional[List[MetaEvent]] = None
):
config = ensure_config(cast(Any, self.langgraph_config.copy()) if self.langgraph_config else {}) # pylint: disable=line-too-long
config["configurable"] = config.get("configurable", {})
config["configurable"]["thread_id"] = thread_id
@@ -235,19 +236,26 @@ class LangGraphAgent(Agent):
agent_name=self.name
)
# Handle meta events for interrupts
interrupt_event = next((ev for ev in (meta_events or []) if ev.get("name") == "LangGraphInterruptEvent"), None)
input = None
if interrupt_event and interrupt_event.get("response"):
input = Command(resume=interrupt_event["response"])
mode = "continue" if thread_id and node_name != "__end__" and node_name is not None else "start"
thread_id = thread_id or str(uuid.uuid4())
config["configurable"]["thread_id"] = thread_id
if mode == "continue":
if mode == "continue" and not interrupt_event:
self.graph.update_state(config, state, as_node=node_name)
return self._stream_events(
mode=mode,
config=config,
state=state,
node_name=node_name
node_name=node_name,
input=input
)
async def _stream_events( # pylint: disable=too-many-locals
@@ -256,7 +264,8 @@ class LangGraphAgent(Agent):
mode: str,
config: RunnableConfig,
state: Any,
node_name: Optional[str] = None
node_name: Optional[str] = None,
input: Optional[Command] = None
):
streaming_state_extractor = _StreamingStateExtractor([])
@@ -267,12 +276,23 @@ class LangGraphAgent(Agent):
manually_emitted_state = None
thread_id = cast(Any, config)["configurable"]["thread_id"]
async for event in self.graph.astream_events(initial_state, config, version="v2"):
# Use provided input or fallback to initial_state
stream_input = input if input else initial_state
async for event in self.graph.astream_events(stream_input, config, version="v2"):
current_node_name = event.get("name")
event_type = event.get("event")
run_id = event.get("run_id")
metadata = event.get("metadata", {})
interrupt_event = event["data"]["chunk"].get("__interrupt__", None) if isinstance(event.get("data"), dict) and isinstance(event["data"].get("chunk"), dict) else None
if (interrupt_event):
yield langchain_dumps({
"event": "on_interrupt",
"value": interrupt_event[0].value
}) + "\n"
continue
should_exit = should_exit or (
event_type == "on_custom_event" and
event["name"] == "copilotkit_exit"
@@ -355,9 +375,10 @@ class LangGraphAgent(Agent):
yield langchain_dumps(event) + "\n"
state = await self.graph.aget_state(config)
is_end_node = state.next == ()
node_name = list(state.metadata["writes"].keys())[0]
tasks = self.graph.get_state(config).tasks
interrupts = tasks[0].interrupts if tasks and len(tasks) > 0 else None
node_name = node_name if interrupts else list(state.metadata["writes"].keys())[0]
is_end_node = state.next == () and not interrupts
yield self._emit_state_sync_event(
thread_id=thread_id,
+4 -1
View File
@@ -8,7 +8,7 @@ from typing import List, Callable, Union, Optional, Any, Coroutine
from typing_extensions import TypedDict, Tuple
from .agent import Agent, AgentDict
from .action import Action, ActionDict, ActionResultDict
from .types import Message
from .types import Message, MetaEvent
from .exc import (
ActionNotFoundException,
AgentNotFoundException,
@@ -266,6 +266,7 @@ class CopilotKitRemoteEndpoint:
state: dict,
messages: List[Message],
actions: List[ActionDict],
meta_events: Optional[List[MetaEvent]] = None,
) -> Any:
"""
Execute an agent
@@ -285,6 +286,7 @@ class CopilotKitRemoteEndpoint:
("State", state),
("Messages", messages),
("Actions", actions),
("MetaEvents", meta_events),
]
)
@@ -295,6 +297,7 @@ class CopilotKitRemoteEndpoint:
state=state,
messages=messages,
actions=actions,
meta_events=meta_events
)
except Exception as error:
raise AgentExecutionException(name, error) from error
+5
View File
@@ -38,3 +38,8 @@ class IntermediateStateConfig(TypedDict):
state_key: str
tool: str
tool_argument: NotRequired[str]
class MetaEvent(TypedDict):
"""Type definition for meta events"""
name: str
response: NotRequired[str]