mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
Add CrewAI support (#1308)
Co-authored-by: Suhas Deshpande <suhasdeshpande@users.noreply.github.com> Co-authored-by: Ariel Weinberger <Weinberger.Ariel@gmail.com> Co-authored-by: Suhas Deshpande <suhas2u@gmail.com>
This commit is contained in:
Vendored
+3
-1
@@ -19,6 +19,8 @@
|
||||
"childrens",
|
||||
"partialjson",
|
||||
"dotenv",
|
||||
"adispatch"
|
||||
"adispatch",
|
||||
"crewai",
|
||||
"litellm"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
"""CopilotKit SDK"""
|
||||
from .sdk import CopilotKitRemoteEndpoint, CopilotKitContext, CopilotKitSDK, CopilotKitSDKContext
|
||||
from .action import Action
|
||||
from .langgraph_agent import LangGraphAgent
|
||||
from .state import CopilotKitState
|
||||
from .langgraph import CopilotKitState
|
||||
from .parameter import Parameter
|
||||
from .agent import Agent
|
||||
from .langgraph_agent import LangGraphAgent
|
||||
|
||||
|
||||
|
||||
__all__ = [
|
||||
'CopilotKitRemoteEndpoint',
|
||||
'CopilotKitSDK',
|
||||
'Action',
|
||||
'LangGraphAgent',
|
||||
'CopilotKitState',
|
||||
'CopilotKitState',
|
||||
'Parameter',
|
||||
'Agent',
|
||||
'CopilotKitContext',
|
||||
'CopilotKitSDKContext'
|
||||
'CopilotKitSDKContext',
|
||||
'CrewAIAgent', # pyright: ignore[reportUnsupportedDunderAll] pylint: disable=undefined-all-variable
|
||||
'LangGraphAgent', # pyright: ignore[reportUnsupportedDunderAll] pylint: disable=undefined-all-variable
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Actions"""
|
||||
|
||||
|
||||
import re
|
||||
from inspect import iscoroutinefunction
|
||||
from typing import Optional, List, Callable, TypedDict, Any, cast
|
||||
from .parameter import Parameter, normalize_parameters
|
||||
@@ -30,6 +30,12 @@ class Action: # pylint: disable=too-few-public-methods
|
||||
self.parameters = parameters
|
||||
self.handler = handler
|
||||
|
||||
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
|
||||
raise ValueError(
|
||||
f"Invalid action name '{name}': " +
|
||||
"must consist of alphanumeric characters, underscores, and hyphens only"
|
||||
)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Agents"""
|
||||
|
||||
import re
|
||||
from typing import Optional, List, TypedDict
|
||||
from abc import ABC, abstractmethod
|
||||
from .types import Message
|
||||
@@ -22,6 +23,12 @@ class Agent(ABC):
|
||||
self.name = name
|
||||
self.description = description
|
||||
|
||||
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
|
||||
raise ValueError(
|
||||
f"Invalid agent name '{name}': " +
|
||||
"must consist of alphanumeric characters, underscores, and hyphens only"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def execute( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
@@ -29,10 +36,10 @@ class Agent(ABC):
|
||||
state: dict,
|
||||
configurable: Optional[dict] = None,
|
||||
messages: List[Message],
|
||||
thread_id: Optional[str] = None,
|
||||
node_name: Optional[str] = None,
|
||||
thread_id: str,
|
||||
actions: Optional[List[ActionDict]] = None,
|
||||
meta_events: Optional[List[MetaEvent]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Execute the agent"""
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
CrewAI
|
||||
"""
|
||||
from .crewai_agent import CrewAIAgent
|
||||
from .crewai_sdk import (
|
||||
CopilotKitProperties,
|
||||
CopilotKitState,
|
||||
copilotkit_emit_state,
|
||||
copilotkit_emit_message,
|
||||
copilotkit_emit_tool_call,
|
||||
copilotkit_stream,
|
||||
copilotkit_exit,
|
||||
copilotkit_predict_state,
|
||||
)
|
||||
__all__ = [
|
||||
"CrewAIAgent",
|
||||
"CopilotKitProperties",
|
||||
"CopilotKitState",
|
||||
"copilotkit_emit_state",
|
||||
"copilotkit_emit_message",
|
||||
"copilotkit_emit_tool_call",
|
||||
"copilotkit_stream",
|
||||
"copilotkit_exit",
|
||||
"copilotkit_predict_state",
|
||||
]
|
||||
@@ -0,0 +1,493 @@
|
||||
"""
|
||||
CrewAI Agent
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Callable
|
||||
from typing_extensions import TypedDict, NotRequired, Any, Dict, cast
|
||||
from crewai import Crew, Flow
|
||||
from crewai.flow import start
|
||||
from crewai.cli.crew_chat import (
|
||||
initialize_chat_llm as crew_chat_initialize_chat_llm,
|
||||
generate_crew_chat_inputs as crew_chat_generate_crew_chat_inputs,
|
||||
generate_crew_tool_schema as crew_chat_generate_crew_tool_schema,
|
||||
build_system_message as crew_chat_build_system_message,
|
||||
create_tool_function as crew_chat_create_tool_function
|
||||
)
|
||||
from litellm import completion
|
||||
from copilotkit.agent import Agent
|
||||
from copilotkit.types import Message
|
||||
from copilotkit.action import ActionDict
|
||||
from copilotkit.protocol import (
|
||||
emit_runtime_events,
|
||||
agent_state_message,
|
||||
)
|
||||
from .crewai_sdk import (
|
||||
copilotkit_messages_to_crewai_flow,
|
||||
crewai_flow_messages_to_copilotkit,
|
||||
crewai_flow_async_runner,
|
||||
copilotkit_stream,
|
||||
copilotkit_exit
|
||||
)
|
||||
|
||||
from copilotkit.runloop import copilotkit_run, CopilotKitRunExecution
|
||||
|
||||
class CopilotKitConfig(TypedDict):
|
||||
"""
|
||||
CopilotKit config for CrewAIAgent
|
||||
|
||||
This is used for advanced cases where you want to customize how CopilotKit interacts with
|
||||
CrewAI.
|
||||
|
||||
```python
|
||||
# Function signatures:
|
||||
def merge_state(
|
||||
*,
|
||||
state: dict,
|
||||
messages: List[BaseMessage],
|
||||
actions: List[Any],
|
||||
agent_name: str
|
||||
):
|
||||
# ...implementation...
|
||||
|
||||
```
|
||||
|
||||
Parameters
|
||||
----------
|
||||
merge_state : Callable
|
||||
This function lets you customize how CopilotKit merges the agent state.
|
||||
"""
|
||||
merge_state: NotRequired[Callable]
|
||||
|
||||
class CrewAIFlowExecutionState(TypedDict):
|
||||
"""
|
||||
State for an execution of a CrewAI Flow agent
|
||||
"""
|
||||
should_exit: bool
|
||||
node_name: str
|
||||
is_finished: bool
|
||||
predict_state_configuration: Dict[str, Any]
|
||||
predicted_state: Dict[str, Any]
|
||||
argument_buffer: str
|
||||
current_tool_call: Optional[str]
|
||||
|
||||
class CrewAIAgent(Agent):
|
||||
"""
|
||||
CrewAIAgent lets you define your agent for use with CopilotKit.
|
||||
|
||||
To install, run:
|
||||
|
||||
```bash
|
||||
pip install copilotkit
|
||||
```
|
||||
|
||||
Every agent must have the `name` and either `crew` or `flow` properties defined. An optional
|
||||
`description` can also be provided. This is used when CopilotKit is dynamically routing requests
|
||||
to the agent.
|
||||
|
||||
## Serving a Crew based agent
|
||||
|
||||
To serve a Crew based agent, pass in a `Crew` object to the `crew` parameter.
|
||||
|
||||
Note:
|
||||
You need to make sure to have a `chat_llm` set on the `Crew` object.
|
||||
See [the CrewAI docs](https://docs.crewai.com/concepts/cli#9-chat) for more information.
|
||||
|
||||
```python
|
||||
from copilotkit import CrewAIAgent
|
||||
|
||||
|
||||
CrewAIAgent(
|
||||
name="email_agent_crew",
|
||||
description="This crew based agent sends emails",
|
||||
crew=SendEmailCrew(),
|
||||
)
|
||||
```
|
||||
|
||||
## Serving a Flow based agent
|
||||
|
||||
To serve a Flow based agent, pass in a `Flow` object to the `flow` parameter.
|
||||
|
||||
```python
|
||||
CrewAIAgent(
|
||||
name="email_agent_flow",
|
||||
description="This flow based agent sends emails",
|
||||
flow=SendEmailFlow(),
|
||||
)
|
||||
```
|
||||
|
||||
Note:
|
||||
Either a `crew` or `flow` must be provided to CrewAIAgent.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
The name of the agent.
|
||||
crew : Crew
|
||||
When using a Crew based agent, pass in a `Crew` object to the `crew` parameter.
|
||||
flow : Flow
|
||||
When using a Flow based agent, pass in a `Flow` object to the `flow` parameter.
|
||||
description : Optional[str]
|
||||
The description of the agent.
|
||||
copilotkit_config : Optional[CopilotKitConfig]
|
||||
The CopilotKit config to use with the agent.
|
||||
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
description: Optional[str] = None,
|
||||
crew: Optional[Crew] = None,
|
||||
flow: Optional[Flow] = None,
|
||||
copilotkit_config: Optional[CopilotKitConfig] = None,
|
||||
):
|
||||
super().__init__(
|
||||
name=name,
|
||||
description=description,
|
||||
)
|
||||
if (crew is None) == (flow is None):
|
||||
raise ValueError("Either crew or flow must be provided to CrewAIAgent")
|
||||
|
||||
|
||||
self.crew = crew
|
||||
self.flow = flow
|
||||
self.copilotkit_config = copilotkit_config or {}
|
||||
|
||||
def execute( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
*,
|
||||
state: dict,
|
||||
thread_id: str,
|
||||
messages: List[Message],
|
||||
actions: Optional[List[ActionDict]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Execute the agent"""
|
||||
if self.crew:
|
||||
crew = deepcopy(self.crew)
|
||||
return self.execute_crew(
|
||||
state=state,
|
||||
messages=messages,
|
||||
thread_id=thread_id,
|
||||
actions=actions,
|
||||
crew=crew,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
if self.flow:
|
||||
flow = deepcopy(self.flow)
|
||||
return self.execute_flow(
|
||||
state=state,
|
||||
messages=messages,
|
||||
thread_id=thread_id,
|
||||
actions=actions,
|
||||
flow=flow,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
raise ValueError("Either crew or flow must be provided to CrewAIAgent")
|
||||
|
||||
def execute_crew( # pylint: disable=too-many-arguments,unused-argument
|
||||
self,
|
||||
*,
|
||||
state: dict,
|
||||
crew: Crew,
|
||||
thread_id: str,
|
||||
messages: List[Message],
|
||||
actions: Optional[List[ActionDict]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Execute a `Crew` based agent"""
|
||||
|
||||
flow = ChatWithCrewFlow(
|
||||
crew=crew,
|
||||
crew_name=self.name,
|
||||
thread_id=thread_id,
|
||||
cache_key=f"crew_{id(self.crew)}"
|
||||
)
|
||||
|
||||
return self.execute_flow(
|
||||
state=state,
|
||||
messages=messages,
|
||||
thread_id=thread_id,
|
||||
actions=actions,
|
||||
flow=flow,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
async def execute_flow( # pylint: disable=too-many-arguments,unused-argument,too-many-locals
|
||||
self,
|
||||
*,
|
||||
state: dict,
|
||||
messages: List[Message],
|
||||
thread_id: Optional[str] = None,
|
||||
actions: Optional[List[ActionDict]] = None,
|
||||
flow: Flow,
|
||||
**kwargs,
|
||||
):
|
||||
"""Execute a `Flow` based agent"""
|
||||
|
||||
if thread_id is None:
|
||||
raise ValueError("Thread ID is required")
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
|
||||
|
||||
merge_state = self.copilotkit_config.get("merge_state", crewai_flow_default_merge_state)
|
||||
|
||||
crewai_flow_messages = copilotkit_messages_to_crewai_flow(messages)
|
||||
|
||||
state = merge_state(
|
||||
state=state,
|
||||
messages=crewai_flow_messages,
|
||||
actions=actions or [],
|
||||
agent_name=self.name,
|
||||
flow=flow
|
||||
)
|
||||
|
||||
execution: CopilotKitRunExecution = CopilotKitRunExecution(
|
||||
thread_id=thread_id,
|
||||
agent_name=self.name,
|
||||
run_id=run_id,
|
||||
should_exit=False,
|
||||
node_name="start",
|
||||
is_finished=False,
|
||||
predict_state_configuration={},
|
||||
predicted_state={},
|
||||
argument_buffer="",
|
||||
current_tool_call=None,
|
||||
state=state
|
||||
)
|
||||
|
||||
async for event in copilotkit_run(
|
||||
fn=lambda: crewai_flow_async_runner(flow, deepcopy(state)),
|
||||
execution=execution
|
||||
):
|
||||
yield event
|
||||
|
||||
state = {**(flow.state.model_dump() if isinstance(flow.state, BaseModel) else flow.state)}
|
||||
if "messages" in state:
|
||||
state["messages"] = crewai_flow_messages_to_copilotkit(state["messages"])
|
||||
|
||||
# emit the final state
|
||||
yield emit_runtime_events(
|
||||
agent_state_message(
|
||||
thread_id=thread_id,
|
||||
agent_name=self.name,
|
||||
node_name=execution["node_name"],
|
||||
run_id=run_id,
|
||||
active=False,
|
||||
role="assistant",
|
||||
state=json.dumps(filter_state(state, exclude_keys=["id"])),
|
||||
running=not execution["should_exit"]
|
||||
)
|
||||
)
|
||||
|
||||
async def get_state(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
):
|
||||
if self.flow and self.flow._persistence: # pylint: disable=protected-access
|
||||
try:
|
||||
stored_state = self.flow._persistence.load_state(thread_id) # pylint: disable=protected-access
|
||||
return {
|
||||
"threadId": thread_id,
|
||||
"threadExists": True,
|
||||
"state": stored_state,
|
||||
"messages": []
|
||||
}
|
||||
except: # pylint: disable=bare-except
|
||||
pass
|
||||
|
||||
return {
|
||||
"threadId": thread_id,
|
||||
"threadExists": False,
|
||||
"state": {},
|
||||
"messages": []
|
||||
}
|
||||
|
||||
|
||||
def dict_repr(self):
|
||||
super_repr = super().dict_repr()
|
||||
return {
|
||||
**super_repr,
|
||||
'type': 'crewai'
|
||||
}
|
||||
|
||||
def crewai_flow_default_merge_state( # pylint: disable=unused-argument, too-many-arguments
|
||||
*,
|
||||
state: dict,
|
||||
flow: Flow,
|
||||
messages: List[Any],
|
||||
actions: List[Any],
|
||||
agent_name: str,
|
||||
):
|
||||
"""Default merge state for CrewAI"""
|
||||
if len(messages) > 0:
|
||||
if "role" in messages[0] and messages[0]["role"] == "system":
|
||||
messages = messages[1:]
|
||||
|
||||
|
||||
actions = [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
**action,
|
||||
}
|
||||
} for action in actions]
|
||||
|
||||
new_state = {
|
||||
**state,
|
||||
"messages": messages,
|
||||
"copilotkit": {
|
||||
"actions": actions
|
||||
}
|
||||
}
|
||||
|
||||
return new_state
|
||||
|
||||
|
||||
def filter_state(state: Dict[str, Any], exclude_keys: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""Filter out messages and id from the state"""
|
||||
exclude_keys = exclude_keys or ["messages", "id"]
|
||||
return {k: v for k, v in state.items() if k not in exclude_keys}
|
||||
|
||||
CREW_EXIT_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "crew_exit",
|
||||
"description": "Call this when the user has indicated that they are done with the crew",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_CREW_INPUTS_CACHE = {}
|
||||
|
||||
class ChatWithCrewFlow(Flow):
|
||||
"""Chat with crew"""
|
||||
|
||||
def __init__(
|
||||
self, *,
|
||||
crew: Crew,
|
||||
crew_name: str,
|
||||
thread_id: str,
|
||||
cache_key: str
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.crew = cast(Any, crew).crew()
|
||||
|
||||
if self.crew.chat_llm is None:
|
||||
raise ValueError("Crew chat LLM is not set")
|
||||
|
||||
self.crew_name = crew_name
|
||||
self.thread_id = thread_id
|
||||
self.chat_llm = crew_chat_initialize_chat_llm(self.crew)
|
||||
|
||||
if cache_key not in _CREW_INPUTS_CACHE:
|
||||
self.crew_chat_inputs = crew_chat_generate_crew_chat_inputs(
|
||||
self.crew,
|
||||
self.crew_name,
|
||||
self.chat_llm
|
||||
)
|
||||
_CREW_INPUTS_CACHE[cache_key] = self.crew_chat_inputs
|
||||
else:
|
||||
self.crew_chat_inputs = _CREW_INPUTS_CACHE[cache_key]
|
||||
|
||||
self.crew_tool_schema = crew_chat_generate_crew_tool_schema(self.crew_chat_inputs)
|
||||
self.system_message = crew_chat_build_system_message(self.crew_chat_inputs)
|
||||
|
||||
super().__init__()
|
||||
|
||||
@start()
|
||||
async def chat(self):
|
||||
"""Chat with the crew"""
|
||||
|
||||
system_message = self.system_message
|
||||
if self.state.get("inputs"):
|
||||
system_message += "\n\nCurrent inputs: " + json.dumps(self.state["inputs"])
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": system_message,
|
||||
"id": self.thread_id + "-system"
|
||||
},
|
||||
*self.state["messages"]
|
||||
]
|
||||
|
||||
tools = [action for action in self.state["copilotkit"]["actions"]
|
||||
if action["function"]["name"] != self.crew_name]
|
||||
|
||||
tools += [self.crew_tool_schema, CREW_EXIT_TOOL]
|
||||
|
||||
response = await copilotkit_stream(
|
||||
completion(
|
||||
model=self.crew.chat_llm,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
parallel_tool_calls=False,
|
||||
stream=True
|
||||
)
|
||||
)
|
||||
|
||||
message = cast(Any, response).choices[0]["message"]
|
||||
self.state["messages"].append(message)
|
||||
|
||||
if message.get("tool_calls"):
|
||||
if message["tool_calls"][0]["function"]["name"] == self.crew_name:
|
||||
# run the crew
|
||||
crew_function = crew_chat_create_tool_function(self.crew, messages)
|
||||
args = json.loads(message["tool_calls"][0]["function"]["arguments"])
|
||||
result = crew_function(**args)
|
||||
|
||||
if isinstance(result, str):
|
||||
self.state["outputs"] = result
|
||||
elif hasattr(result, "json_dict"):
|
||||
self.state["outputs"] = result.json_dict
|
||||
elif hasattr(result, "raw"):
|
||||
self.state["outputs"] = result.raw
|
||||
else:
|
||||
raise ValueError("Unexpected result type", type(result))
|
||||
|
||||
self.state["messages"].append({
|
||||
"role": "tool",
|
||||
"content": result,
|
||||
"tool_call_id": message["tool_calls"][0]["id"]
|
||||
})
|
||||
elif message["tool_calls"][0]["function"]["name"] == CREW_EXIT_TOOL["function"]["name"]:
|
||||
await copilotkit_exit()
|
||||
self.state["messages"].append({
|
||||
"role": "tool",
|
||||
"content": "Crew exited",
|
||||
"tool_call_id": message["tool_calls"][0]["id"]
|
||||
})
|
||||
|
||||
response = await copilotkit_stream(
|
||||
completion( # pylint: disable=too-many-arguments
|
||||
model=self.crew.chat_llm,
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Indicate to the user that the crew has exited",
|
||||
"id": self.thread_id + "-system"
|
||||
},
|
||||
*self.state["messages"]
|
||||
],
|
||||
tools=tools,
|
||||
parallel_tool_calls=False,
|
||||
stream=True,
|
||||
tool_choice="none"
|
||||
)
|
||||
)
|
||||
message = cast(Any, response).choices[0]["message"]
|
||||
self.state["messages"].append(message)
|
||||
@@ -0,0 +1,615 @@
|
||||
"""
|
||||
CrewAI integration for CopilotKit
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import json
|
||||
import asyncio
|
||||
from typing_extensions import Any, Dict, List, Literal
|
||||
from pydantic import BaseModel, Field
|
||||
from litellm.types.utils import (
|
||||
ModelResponse,
|
||||
Choices,
|
||||
Message as LiteLLMMessage,
|
||||
ChatCompletionMessageToolCall,
|
||||
Function as LiteLLMFunction
|
||||
)
|
||||
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
||||
from crewai.flow.flow import FlowState, Flow
|
||||
from crewai.flow.flow_events import (
|
||||
Event as CrewAIFlowEvent,
|
||||
FlowStartedEvent,
|
||||
MethodExecutionStartedEvent,
|
||||
MethodExecutionFinishedEvent,
|
||||
FlowFinishedEvent,
|
||||
)
|
||||
from copilotkit.types import Message
|
||||
from copilotkit.logging import get_logger
|
||||
from copilotkit.runloop import queue_put, get_context_execution
|
||||
from copilotkit.protocol import (
|
||||
RuntimeEventTypes,
|
||||
RunStarted,
|
||||
RunFinished,
|
||||
RunError,
|
||||
NodeStarted,
|
||||
NodeFinished,
|
||||
agent_state_message,
|
||||
text_message_start,
|
||||
text_message_content,
|
||||
text_message_end,
|
||||
action_execution_start,
|
||||
action_execution_args,
|
||||
action_execution_end,
|
||||
meta_event,
|
||||
RuntimeMetaEventName,
|
||||
PredictStateConfig
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class CopilotKitProperties(BaseModel):
|
||||
"""CopilotKit properties"""
|
||||
actions: List[Any] = Field(default_factory=list)
|
||||
|
||||
class CopilotKitState(FlowState):
|
||||
"""CopilotKit state"""
|
||||
messages: List[Any] = Field(default_factory=list)
|
||||
copilotkit: CopilotKitProperties = Field(default_factory=CopilotKitProperties)
|
||||
|
||||
async def crewai_flow_async_runner(flow: Flow, inputs: Dict[str, Any]):
|
||||
"""
|
||||
Runs a flow in a separate thread. Workaround since the flow will use
|
||||
asyncio.run().
|
||||
"""
|
||||
|
||||
async def crewai_flow_event_subscriber(flow: Any, event: CrewAIFlowEvent):
|
||||
if isinstance(event, FlowStartedEvent):
|
||||
await queue_put(RunStarted(
|
||||
type=RuntimeEventTypes.RUN_STARTED,
|
||||
state=flow.state
|
||||
), priority=True)
|
||||
elif isinstance(event, MethodExecutionStartedEvent):
|
||||
await queue_put(NodeStarted(
|
||||
type=RuntimeEventTypes.NODE_STARTED,
|
||||
node_name=event.method_name,
|
||||
state=flow.state
|
||||
), priority=True)
|
||||
elif isinstance(event, MethodExecutionFinishedEvent):
|
||||
await queue_put(NodeFinished(
|
||||
type=RuntimeEventTypes.NODE_FINISHED,
|
||||
node_name=event.method_name,
|
||||
state=flow.state
|
||||
), priority=True)
|
||||
elif isinstance(event, FlowFinishedEvent):
|
||||
await queue_put(RunFinished(
|
||||
type=RuntimeEventTypes.RUN_FINISHED,
|
||||
state=flow.state
|
||||
), priority=True)
|
||||
|
||||
def crewai_flow_event_subscriber_sync(flow: Any, event: CrewAIFlowEvent):
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.call_soon(lambda: asyncio.create_task(crewai_flow_event_subscriber(flow, event)))
|
||||
|
||||
flow.event_emitter.connect(crewai_flow_event_subscriber_sync)
|
||||
|
||||
try:
|
||||
flow.event_emitter.send(
|
||||
flow,
|
||||
event=FlowStartedEvent(
|
||||
type="flow_started",
|
||||
flow_name=flow.__class__.__name__,
|
||||
),
|
||||
)
|
||||
|
||||
flow._initialize_state(inputs) # pylint: disable=protected-access
|
||||
await flow.kickoff_async()
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
await queue_put(RunError(
|
||||
type=RuntimeEventTypes.RUN_ERROR,
|
||||
error=e
|
||||
))
|
||||
|
||||
async def copilotkit_emit_state(state: Any) -> Literal[True]:
|
||||
"""
|
||||
Emits intermediate state to CopilotKit.
|
||||
Useful if you have a longer running node and you want to update the user with the current state of the node.
|
||||
|
||||
To install the CopilotKit SDK, run:
|
||||
|
||||
```bash
|
||||
pip install copilotkit
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```python
|
||||
from copilotkit.crewai import copilotkit_emit_state
|
||||
|
||||
for i in range(10):
|
||||
await some_long_running_operation(i)
|
||||
await copilotkit_emit_state({"progress": i})
|
||||
```
|
||||
|
||||
Parameters
|
||||
----------
|
||||
state : Any
|
||||
The state to emit (Must be JSON serializable).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Awaitable[bool]
|
||||
Always return True.
|
||||
|
||||
"""
|
||||
execution = get_context_execution()
|
||||
|
||||
state = {
|
||||
k: v for k, v in state.items() if k not in ["messages"]
|
||||
}
|
||||
|
||||
await queue_put(
|
||||
agent_state_message(
|
||||
thread_id=execution["thread_id"],
|
||||
agent_name=execution["agent_name"],
|
||||
node_name=execution["node_name"],
|
||||
run_id=execution["run_id"],
|
||||
active=True,
|
||||
role="assistant",
|
||||
state=json.dumps(state),
|
||||
running=True
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
return True
|
||||
|
||||
async def copilotkit_emit_message(message: str) -> str:
|
||||
"""
|
||||
Manually emits a message to CopilotKit. Useful in longer running nodes to update the user.
|
||||
Important: You still need to return the messages from the node.
|
||||
|
||||
### Examples
|
||||
|
||||
```python
|
||||
from copilotkit.crewai import copilotkit_emit_message
|
||||
|
||||
message = "Step 1 of 10 complete"
|
||||
await copilotkit_emit_message(message)
|
||||
|
||||
# Return the message from the node
|
||||
return {
|
||||
"messages": [AIMessage(content=message)]
|
||||
}
|
||||
```
|
||||
|
||||
Parameters
|
||||
----------
|
||||
message : str
|
||||
The message to emit.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Awaitable[bool]
|
||||
Always return True.
|
||||
"""
|
||||
message_id = str(uuid.uuid4())
|
||||
|
||||
await queue_put(
|
||||
text_message_start(
|
||||
message_id=message_id,
|
||||
parent_message_id=None
|
||||
),
|
||||
text_message_content(
|
||||
message_id=message_id,
|
||||
content=message
|
||||
),
|
||||
text_message_end(
|
||||
message_id=message_id
|
||||
)
|
||||
)
|
||||
|
||||
return message_id
|
||||
|
||||
async def copilotkit_emit_tool_call(*, name: str, args: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Manually emits a tool call to CopilotKit.
|
||||
|
||||
```python
|
||||
from copilotkit.crewai import copilotkit_emit_tool_call
|
||||
|
||||
await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10})
|
||||
```
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
The name of the tool to emit.
|
||||
args : Dict[str, Any]
|
||||
The arguments to emit.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Awaitable[bool]
|
||||
Always return True.
|
||||
"""
|
||||
message_id = str(uuid.uuid4())
|
||||
await queue_put(
|
||||
action_execution_start(
|
||||
action_execution_id=message_id,
|
||||
action_name=name,
|
||||
parent_message_id=message_id
|
||||
),
|
||||
action_execution_args(
|
||||
action_execution_id=message_id,
|
||||
args=json.dumps(args)
|
||||
),
|
||||
action_execution_end(
|
||||
action_execution_id=message_id
|
||||
)
|
||||
)
|
||||
|
||||
return message_id
|
||||
|
||||
|
||||
async def copilotkit_stream(response):
|
||||
"""
|
||||
Stream litellm responses token by token to CopilotKit.
|
||||
|
||||
```python
|
||||
response = await copilotkit_stream(
|
||||
completion(
|
||||
model="openai/gpt-4o",
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
stream=True # this must be set to True for streaming
|
||||
)
|
||||
)
|
||||
```
|
||||
"""
|
||||
if isinstance(response, ModelResponse):
|
||||
return _copilotkit_stream_response(response)
|
||||
if isinstance(response, CustomStreamWrapper):
|
||||
return await _copilotkit_stream_custom_stream_wrapper(response)
|
||||
raise ValueError("Invalid response type")
|
||||
|
||||
|
||||
async def _copilotkit_stream_custom_stream_wrapper(response: CustomStreamWrapper):
|
||||
message_id: str = ""
|
||||
tool_call_id: str = ""
|
||||
content = ""
|
||||
created = 0
|
||||
model = ""
|
||||
system_fingerprint = ""
|
||||
finish_reason=None
|
||||
mode = None
|
||||
all_tool_calls = []
|
||||
|
||||
for chunk in response:
|
||||
if message_id is None:
|
||||
message_id = chunk["id"]
|
||||
|
||||
tool_calls = chunk["choices"][0]["delta"]["tool_calls"]
|
||||
finish_reason = chunk["choices"][0]["finish_reason"]
|
||||
created = chunk["created"]
|
||||
model = chunk["model"]
|
||||
system_fingerprint = chunk["system_fingerprint"]
|
||||
|
||||
if mode == "text" and (tool_calls is not None or finish_reason is not None):
|
||||
# end the current text message
|
||||
await queue_put(
|
||||
text_message_end(
|
||||
message_id=message_id
|
||||
)
|
||||
)
|
||||
|
||||
elif mode == "tool" and (tool_calls is None or finish_reason is not None):
|
||||
# end the current tool call
|
||||
await queue_put(
|
||||
action_execution_end(
|
||||
action_execution_id=tool_call_id
|
||||
)
|
||||
)
|
||||
|
||||
if finish_reason is not None:
|
||||
break
|
||||
|
||||
if mode != "text" and tool_calls is None:
|
||||
# start a new text message
|
||||
await queue_put(
|
||||
text_message_start(
|
||||
message_id=message_id,
|
||||
parent_message_id=None
|
||||
)
|
||||
)
|
||||
elif mode != "tool" and tool_calls is not None and tool_calls[0].id is not None:
|
||||
# start a new tool call
|
||||
tool_call_id = tool_calls[0].id
|
||||
|
||||
await queue_put(
|
||||
action_execution_start(
|
||||
action_execution_id=tool_call_id,
|
||||
action_name=tool_calls[0].function["name"],
|
||||
parent_message_id=message_id
|
||||
)
|
||||
)
|
||||
|
||||
all_tool_calls.append(
|
||||
{
|
||||
"id": tool_call_id,
|
||||
"name": tool_calls[0].function["name"],
|
||||
"arguments": "",
|
||||
}
|
||||
)
|
||||
|
||||
mode = "tool" if tool_calls is not None else "text"
|
||||
|
||||
if mode == "text":
|
||||
text_content = chunk["choices"][0]["delta"]["content"]
|
||||
if text_content is not None:
|
||||
content += text_content
|
||||
await queue_put(
|
||||
text_message_content(
|
||||
message_id=message_id,
|
||||
content=text_content
|
||||
)
|
||||
)
|
||||
|
||||
elif mode == "tool":
|
||||
tool_arguments = tool_calls[0].function["arguments"]
|
||||
if tool_arguments is not None:
|
||||
await queue_put(
|
||||
action_execution_args(
|
||||
action_execution_id=tool_call_id,
|
||||
args=tool_arguments
|
||||
)
|
||||
)
|
||||
|
||||
all_tool_calls[-1]["arguments"] += tool_arguments
|
||||
|
||||
tool_calls = [
|
||||
ChatCompletionMessageToolCall(
|
||||
function=LiteLLMFunction(
|
||||
arguments=tool_call["arguments"],
|
||||
name=tool_call["name"]
|
||||
),
|
||||
id=tool_call["id"],
|
||||
type="function"
|
||||
)
|
||||
for tool_call in all_tool_calls
|
||||
]
|
||||
return ModelResponse(
|
||||
id=message_id,
|
||||
created=created,
|
||||
model=model,
|
||||
object='chat.completion',
|
||||
system_fingerprint=system_fingerprint,
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason=finish_reason,
|
||||
index=0,
|
||||
message=LiteLLMMessage(
|
||||
content=content,
|
||||
role='assistant',
|
||||
tool_calls=tool_calls if len(tool_calls) > 0 else None,
|
||||
function_call=None
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def _copilotkit_stream_response(response: ModelResponse):
|
||||
return response
|
||||
|
||||
|
||||
async def copilotkit_exit() -> Literal[True]:
|
||||
"""
|
||||
Exits the current agent after the run completes. Calling copilotkit_exit() will
|
||||
not immediately stop the agent. Instead, it signals to CopilotKit to stop the agent after
|
||||
the run completes.
|
||||
|
||||
### Examples
|
||||
|
||||
```python
|
||||
from copilotkit.crewai import copilotkit_exit
|
||||
|
||||
def my_function():
|
||||
await copilotkit_exit()
|
||||
return state
|
||||
```
|
||||
|
||||
Returns
|
||||
-------
|
||||
Awaitable[bool]
|
||||
Always return True.
|
||||
"""
|
||||
await queue_put(
|
||||
meta_event(
|
||||
name=RuntimeMetaEventName.EXIT,
|
||||
value=True
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def copilotkit_predict_state(
|
||||
config: Dict[str, PredictStateConfig]
|
||||
) -> Literal[True]:
|
||||
"""
|
||||
Stream tool calls as state to CopilotKit.
|
||||
|
||||
To emit a tool call as streaming CrewAI state, pass the destination key in state,
|
||||
the tool name and optionally the tool argument. (If you don't pass the argument name,
|
||||
all arguments are emitted under the state key.)
|
||||
|
||||
```python
|
||||
from copilotkit.crewai import copilotkit_predict_state
|
||||
|
||||
await copilotkit_predict_state(
|
||||
{
|
||||
"steps": {
|
||||
"tool_name": "SearchTool",
|
||||
"tool_argument": "steps",
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config : Dict[str, CopilotKitPredictStateConfig]
|
||||
The configuration to predict the state.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Awaitable[bool]
|
||||
Always return True.
|
||||
"""
|
||||
|
||||
await queue_put(
|
||||
meta_event(
|
||||
name=RuntimeMetaEventName.PREDICT_STATE,
|
||||
value=config
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def copilotkit_messages_to_crewai_flow(messages: List[Message]) -> List[Any]:
|
||||
"""
|
||||
Convert CopilotKit messages to CrewAI Flow messages
|
||||
"""
|
||||
result = []
|
||||
processed_action_executions = set()
|
||||
|
||||
for message in messages:
|
||||
message_id = message["id"]
|
||||
message_type = message.get("type")
|
||||
|
||||
if message_type == "TextMessage":
|
||||
result.append({
|
||||
"id": message_id,
|
||||
"role": message.get("role"),
|
||||
"content": message.get("content")
|
||||
})
|
||||
elif message_type == "ActionExecutionMessage":
|
||||
# convert multiple tool calls to a single message
|
||||
original_message_id = message.get("parentMessageId", message_id)
|
||||
if original_message_id in processed_action_executions:
|
||||
continue
|
||||
|
||||
processed_action_executions.add(original_message_id)
|
||||
|
||||
all_tool_calls = []
|
||||
|
||||
# Find all tool calls for this message
|
||||
for msg in messages:
|
||||
msg_id = msg["id"]
|
||||
if (msg.get("parentMessageId", None) == original_message_id or
|
||||
msg_id == original_message_id):
|
||||
all_tool_calls.append(msg)
|
||||
|
||||
tool_calls = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": t["name"],
|
||||
"arguments": json.dumps(t["arguments"]),
|
||||
},
|
||||
"id": t["id"],
|
||||
} for t in all_tool_calls]
|
||||
|
||||
result.append(
|
||||
{
|
||||
"id": original_message_id,
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": tool_calls
|
||||
}
|
||||
)
|
||||
|
||||
elif message_type == "ResultMessage":
|
||||
result.append(
|
||||
{
|
||||
"id": message_id,
|
||||
"role": "tool",
|
||||
"tool_call_id": message.get("actionExecutionId"),
|
||||
"content": message.get("result"),
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def crewai_flow_messages_to_copilotkit(messages: List[Dict]) -> List[Message]: # pylint: disable=too-many-branches
|
||||
"""
|
||||
Convert CrewAI Flow messages to CopilotKit messages
|
||||
"""
|
||||
result = []
|
||||
tool_call_names = {}
|
||||
|
||||
message_ids = {
|
||||
id(m): m.get("id", str(uuid.uuid4())) for m in messages
|
||||
}
|
||||
|
||||
for message in messages:
|
||||
if "content" in message and message.get("role") == "assistant":
|
||||
if message.get("tool_calls"):
|
||||
for tool_call in message["tool_calls"]:
|
||||
tool_call_names[tool_call["id"]] = tool_call["function"]["name"]
|
||||
|
||||
for message in messages:
|
||||
message_id = message_ids[id(message)]
|
||||
|
||||
if message.get("role") == "tool":
|
||||
result.append({
|
||||
"actionExecutionId": message["tool_call_id"],
|
||||
"actionName": tool_call_names.get(message["tool_call_id"], message.get("name", "")),
|
||||
"result": message["content"],
|
||||
"id": message_id,
|
||||
})
|
||||
elif message.get("tool_calls"):
|
||||
for tool_call in message["tool_calls"]:
|
||||
if tool_call.get("function"):
|
||||
result.append({
|
||||
"id": tool_call["id"],
|
||||
"name": tool_call["function"]["name"],
|
||||
"arguments": json.loads(tool_call["function"]["arguments"]),
|
||||
"parentMessageId": message_id,
|
||||
})
|
||||
else:
|
||||
result.append({
|
||||
"id": tool_call["id"],
|
||||
"name": tool_call["name"],
|
||||
"arguments": tool_call["arguments"],
|
||||
"parentMessageId": message_id,
|
||||
})
|
||||
elif message.get("content"):
|
||||
result.append({
|
||||
"role": message["role"],
|
||||
"content": message["content"],
|
||||
"id": message_id,
|
||||
})
|
||||
|
||||
# Create a dictionary to map message ids to their corresponding messages
|
||||
results_dict = {msg["actionExecutionId"]: msg for msg in result if "actionExecutionId" in msg}
|
||||
|
||||
|
||||
# since we are splitting multiple tool calls into multiple messages,
|
||||
# we need to reorder the corresponding result messages to be after the tool call
|
||||
reordered_result = []
|
||||
|
||||
for msg in result:
|
||||
|
||||
# add all messages that are not tool call results
|
||||
if not "actionExecutionId" in msg:
|
||||
reordered_result.append(msg)
|
||||
|
||||
# if the message is a tool call, also add the corresponding result message
|
||||
# immediately after the tool call
|
||||
if msg.get("name"):
|
||||
msg_id = msg["id"]
|
||||
if msg_id in results_dict:
|
||||
reordered_result.append(results_dict[msg_id])
|
||||
else:
|
||||
logger.warning("Tool call result message not found for id: %s", msg_id)
|
||||
|
||||
return reordered_result
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
HTML templates, used when the info endpoint is accessed from the browser.
|
||||
"""
|
||||
import json
|
||||
from copilotkit.sdk import InfoDict
|
||||
|
||||
HEAD_HTML = """
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CopilotKit Remote Endpoint v0.1.12</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f4f4f4;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 30px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.version {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.kite-icon {
|
||||
font-size: 38px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.card {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
padding: 20px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
margin-left: 10px;
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
pre {
|
||||
background-color: #f1f1f1;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
code {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
"""
|
||||
|
||||
INFO_TEMPLATE= """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{head_html}
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1><span class="kite-icon">🪁</span>CopilotKit Remote Endpoint <span class="version">(v{version})</span></h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section>
|
||||
<h2>Actions</h2>
|
||||
<div class="grid">
|
||||
{action_html}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Agents</h2>
|
||||
<div class="grid">
|
||||
{agent_html}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
ACTION_TEMPLATE = """
|
||||
<div class="card">
|
||||
<h3>{name}</h3>
|
||||
<p>{description}</p>
|
||||
<h4>Arguments:</h4>
|
||||
<pre><code>{arguments}</code></pre>
|
||||
</div>
|
||||
"""
|
||||
|
||||
AGENT_TEMPLATE = """
|
||||
<div class="card">
|
||||
<h3>{name} <span class="badge">{type}</span></h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
NO_ACTIONS_FOUND_HTML = """
|
||||
<div class="card">
|
||||
<p>No actions found</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
NO_AGENTS_FOUND_HTML = """
|
||||
<div class="card">
|
||||
<p>No agents found</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
def generate_info_html(info: InfoDict) -> str:
|
||||
"""
|
||||
Generate HTML for the info endpoint
|
||||
"""
|
||||
print(info, flush=True)
|
||||
action_html = ""
|
||||
for action in info["actions"]:
|
||||
action_html += ACTION_TEMPLATE.format(
|
||||
name=action["name"],
|
||||
description=action["description"],
|
||||
arguments=json.dumps(action.get("parameters", []), indent=2),
|
||||
)
|
||||
agent_html = ""
|
||||
for agent in info["agents"]:
|
||||
agent_type = agent.get("type", "Unknown")
|
||||
if agent_type == "langgraph":
|
||||
agent_type = "LangGraph"
|
||||
elif agent_type == "crewai":
|
||||
agent_type = "CrewAI"
|
||||
|
||||
agent_html += AGENT_TEMPLATE.format(
|
||||
name=agent["name"],
|
||||
type=agent_type,
|
||||
description=agent["description"],
|
||||
)
|
||||
return INFO_TEMPLATE.format(
|
||||
head_html=HEAD_HTML,
|
||||
version=info["sdkVersion"],
|
||||
action_html=action_html or NO_ACTIONS_FOUND_HTML,
|
||||
agent_html=agent_html or NO_AGENTS_FOUND_HTML,
|
||||
)
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import re
|
||||
import uuid
|
||||
import warnings
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import List, Any, cast, Optional
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse
|
||||
from ..sdk import CopilotKitRemoteEndpoint, CopilotKitContext
|
||||
from ..types import Message, MetaEvent
|
||||
from ..exc import (
|
||||
@@ -17,7 +18,7 @@ from ..exc import (
|
||||
AgentExecutionException,
|
||||
)
|
||||
from ..action import ActionDict
|
||||
|
||||
from ..html import generate_info_html
|
||||
logging.basicConfig(level=logging.ERROR)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,8 +27,8 @@ def add_fastapi_endpoint(
|
||||
sdk: CopilotKitRemoteEndpoint,
|
||||
prefix: str,
|
||||
*,
|
||||
max_workers: int = 10,
|
||||
use_thread_pool: bool = False,
|
||||
max_workers: int = 10,
|
||||
):
|
||||
"""Add FastAPI endpoint with configurable ThreadPoolExecutor size"""
|
||||
if use_thread_pool:
|
||||
@@ -36,21 +37,22 @@ def add_fastapi_endpoint(
|
||||
"and will be removed in a future version.",
|
||||
DeprecationWarning
|
||||
)
|
||||
executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||
|
||||
def run_handler_in_thread(request: Request, sdk: CopilotKitRemoteEndpoint):
|
||||
# Run the handler coroutine in the event loop
|
||||
# Run the handler coroutine in the event loop
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(handler(request, sdk))
|
||||
|
||||
async def make_handler(request: Request):
|
||||
if use_thread_pool:
|
||||
executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||
loop = asyncio.get_event_loop()
|
||||
future = loop.run_in_executor(executor, run_handler_in_thread, request, sdk)
|
||||
return await future
|
||||
return await handler(request, sdk)
|
||||
|
||||
|
||||
# Ensure the prefix starts with a slash and remove trailing slashes
|
||||
normalized_prefix = '/' + prefix.strip('/')
|
||||
|
||||
@@ -73,22 +75,110 @@ async def handler(request: Request, sdk: CopilotKitRemoteEndpoint):
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail="Request body is required") from exc
|
||||
except: # pylint: disable=bare-except
|
||||
body = None
|
||||
|
||||
path = request.path_params.get('path')
|
||||
method = request.method
|
||||
context = cast(
|
||||
CopilotKitContext,
|
||||
CopilotKitContext,
|
||||
{
|
||||
"properties": body.get("properties", {}),
|
||||
"frontend_url": body.get("frontendUrl", None)
|
||||
"properties": (body or {}).get("properties", {}),
|
||||
"frontend_url": (body or {}).get("frontendUrl", None),
|
||||
"headers": request.headers,
|
||||
}
|
||||
)
|
||||
|
||||
# handle / request for info endpoint
|
||||
if method in ['GET', 'POST'] and path == '':
|
||||
accept_header = request.headers.get('accept', '')
|
||||
return await handle_info(
|
||||
sdk=sdk,
|
||||
context=context,
|
||||
as_html='text/html' in accept_header,
|
||||
)
|
||||
|
||||
# handle /agent/name request for executing an agent
|
||||
if method == 'POST' and (match := re.match(r'agent/([a-zA-Z0-9_-]+)', path)):
|
||||
name = match.group(1)
|
||||
body = body or {}
|
||||
|
||||
thread_id = body.get("threadId", str(uuid.uuid4()))
|
||||
state = body.get("state", {})
|
||||
messages = body.get("messages", [])
|
||||
actions = body.get("actions", [])
|
||||
|
||||
# used for LangGraph only
|
||||
node_name = body.get("nodeName")
|
||||
|
||||
return handle_execute_agent(
|
||||
sdk=sdk,
|
||||
context=context,
|
||||
thread_id=thread_id,
|
||||
node_name=node_name,
|
||||
name=name,
|
||||
state=state,
|
||||
messages=messages,
|
||||
actions=actions,
|
||||
)
|
||||
|
||||
# handle /agent/name/state request for getting agent state
|
||||
if method == 'POST' and (match := re.match(r'agent/([a-zA-Z0-9_-]+)/state', path)):
|
||||
name = match.group(1)
|
||||
thread_id = body_get_or_raise(body, "threadId")
|
||||
|
||||
return await handle_get_agent_state(
|
||||
sdk=sdk,
|
||||
context=context,
|
||||
thread_id=thread_id,
|
||||
name=name,
|
||||
)
|
||||
|
||||
# handle /action/name request for executing an action
|
||||
if method == 'POST' and (match := re.match(r'action/([a-zA-Z0-9_-]+)', path)):
|
||||
name = match.group(1)
|
||||
arguments = body.get("arguments", {})
|
||||
|
||||
return await handle_execute_action(
|
||||
sdk=sdk,
|
||||
context=context,
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
# v2: POST /agents/name/state
|
||||
|
||||
# Deal with backwards compatibility
|
||||
result_v1 = await handler_v1(
|
||||
sdk=sdk,
|
||||
method=method,
|
||||
path=path,
|
||||
body=body,
|
||||
context=context,
|
||||
)
|
||||
|
||||
if result_v1 is not None:
|
||||
return result_v1
|
||||
|
||||
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
async def handler_v1(
|
||||
sdk: CopilotKitRemoteEndpoint,
|
||||
method: str,
|
||||
path: str,
|
||||
body: Any,
|
||||
context: CopilotKitContext,
|
||||
):
|
||||
"""Handle FastAPI request for v1"""
|
||||
|
||||
if body is None:
|
||||
raise HTTPException(status_code=400, detail="Request body is required")
|
||||
|
||||
if method == 'POST' and path == 'info':
|
||||
return await handle_info(sdk=sdk, context=context)
|
||||
|
||||
|
||||
if method == 'POST' and path == 'actions/execute':
|
||||
name = body_get_or_raise(body, "name")
|
||||
arguments = body.get("arguments", {})
|
||||
@@ -124,6 +214,7 @@ async def handler(request: Request, sdk: CopilotKitRemoteEndpoint):
|
||||
meta_events=meta_events,
|
||||
)
|
||||
|
||||
|
||||
if method == 'POST' and path == 'agents/state':
|
||||
thread_id = body_get_or_raise(body, "threadId")
|
||||
name = body_get_or_raise(body, "name")
|
||||
@@ -135,12 +226,19 @@ async def handler(request: Request, sdk: CopilotKitRemoteEndpoint):
|
||||
name=name,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return None
|
||||
|
||||
|
||||
async def handle_info(*, sdk: CopilotKitRemoteEndpoint, context: CopilotKitContext):
|
||||
async def handle_info(
|
||||
*,
|
||||
sdk: CopilotKitRemoteEndpoint,
|
||||
context: CopilotKitContext,
|
||||
as_html: bool = False,
|
||||
):
|
||||
"""Handle info request with FastAPI"""
|
||||
result = sdk.info(context=context)
|
||||
if as_html:
|
||||
return HTMLResponse(content=generate_info_html(result))
|
||||
return JSONResponse(content=result)
|
||||
|
||||
async def handle_execute_action(
|
||||
@@ -173,12 +271,12 @@ def handle_execute_agent( # pylint: disable=too-many-arguments
|
||||
sdk: CopilotKitRemoteEndpoint,
|
||||
context: CopilotKitContext,
|
||||
thread_id: str,
|
||||
node_name: str,
|
||||
name: str,
|
||||
state: dict,
|
||||
configurable: Optional[dict] = None,
|
||||
messages: List[Message],
|
||||
actions: List[ActionDict],
|
||||
node_name: str,
|
||||
meta_events: Optional[List[MetaEvent]] = None,
|
||||
):
|
||||
"""Handle continue agent execution request with FastAPI"""
|
||||
|
||||
@@ -5,8 +5,11 @@ LangChain specific utilities for CopilotKit
|
||||
import uuid
|
||||
import json
|
||||
import warnings
|
||||
from typing import List, Optional, Any, Union, Dict, Callable, cast
|
||||
import asyncio
|
||||
from typing import List, Optional, Any, Union, Dict, Callable, cast
|
||||
from typing_extensions import TypedDict
|
||||
from langgraph.graph import MessagesState
|
||||
|
||||
|
||||
from langchain_core.messages import (
|
||||
HumanMessage,
|
||||
@@ -25,6 +28,14 @@ from .logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CopilotKitProperties(TypedDict):
|
||||
"""CopilotKit state"""
|
||||
actions: List[Any]
|
||||
|
||||
class CopilotKitState(MessagesState):
|
||||
"""CopilotKit state"""
|
||||
copilotkit: CopilotKitProperties
|
||||
|
||||
|
||||
def copilotkit_messages_to_langchain(
|
||||
use_function_call: bool = False
|
||||
@@ -200,7 +211,7 @@ def copilotkit_customize_config(
|
||||
"""
|
||||
Customize the LangGraph configuration for use in CopilotKit.
|
||||
|
||||
To the CopilotKit SDK, run:
|
||||
To install the CopilotKit SDK, run:
|
||||
|
||||
```bash
|
||||
pip install copilotkit
|
||||
|
||||
@@ -6,10 +6,10 @@ from typing import Optional, List, Callable, Any, cast, Union, TypedDict, Litera
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
from langgraph.graph.graph import CompiledGraph
|
||||
from langgraph.types import Command
|
||||
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
|
||||
|
||||
@@ -130,7 +130,7 @@ class LangGraphAgent(Agent):
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
graph: CompiledGraph = None,
|
||||
graph: Optional[CompiledGraph] = None,
|
||||
description: Optional[str] = None,
|
||||
langgraph_config: Union[Optional[RunnableConfig], dict] = None,
|
||||
copilotkit_config: Optional[CopilotKitConfig] = None,
|
||||
@@ -216,18 +216,20 @@ class LangGraphAgent(Agent):
|
||||
"running": running,
|
||||
"role": "assistant"
|
||||
})
|
||||
|
||||
|
||||
def execute( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
*,
|
||||
state: dict,
|
||||
configurable: Optional[dict] = None,
|
||||
messages: List[Message],
|
||||
thread_id: Optional[str] = None,
|
||||
node_name: Optional[str] = None,
|
||||
thread_id: str,
|
||||
actions: Optional[List[ActionDict]] = None,
|
||||
meta_events: Optional[List[MetaEvent]] = None
|
||||
meta_events: Optional[List[MetaEvent]] = None,
|
||||
**kwargs
|
||||
):
|
||||
node_name = kwargs.get("node_name")
|
||||
|
||||
return self._stream_events(
|
||||
state=state,
|
||||
configurable=configurable,
|
||||
@@ -293,7 +295,7 @@ class LangGraphAgent(Agent):
|
||||
manually_emitted_state = None
|
||||
thread_id = cast(Any, config)["configurable"]["thread_id"]
|
||||
|
||||
# Use provided input or fallback to initial_state
|
||||
# Use provided resume_input or fallback to initial_state
|
||||
stream_input = resume_input if resume_input else initial_state
|
||||
|
||||
# Get the output and input schema keys the user has allowed for this graph
|
||||
@@ -308,8 +310,15 @@ class LangGraphAgent(Agent):
|
||||
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):
|
||||
interrupt_event = (
|
||||
event["data"].get("chunk", {}).get("__interrupt__", None)
|
||||
if (
|
||||
isinstance(event.get("data"), dict) and
|
||||
isinstance(event["data"].get("chunk"), dict)
|
||||
)
|
||||
else None
|
||||
)
|
||||
if interrupt_event:
|
||||
self.active_interrupt_event = True
|
||||
value = interrupt_event[0].value
|
||||
if not isinstance(value, str) and "__copilotkit_interrupt_value__" in value:
|
||||
@@ -407,7 +416,7 @@ class LangGraphAgent(Agent):
|
||||
yield langchain_dumps(event) + "\n"
|
||||
|
||||
state = await self.graph.aget_state(config)
|
||||
tasks = (await self.graph.aget_state(config)).tasks
|
||||
tasks = state.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
|
||||
|
||||
@@ -20,6 +20,6 @@ def bold(text: str) -> str:
|
||||
"""
|
||||
Bold the given text.
|
||||
"""
|
||||
if sys.stdout.isatty():
|
||||
if hasattr(sys.stdout, 'isatty') and sys.stdout.isatty():
|
||||
return f"\033[1m{text}\033[0m"
|
||||
return text
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
CopilotKit Protocol
|
||||
"""
|
||||
|
||||
import json
|
||||
from enum import Enum
|
||||
from typing import Union, Optional
|
||||
from typing_extensions import TypedDict, Literal, Any, Dict
|
||||
|
||||
class RuntimeEventTypes(Enum):
|
||||
"""CopilotKit Runtime Event Types"""
|
||||
TEXT_MESSAGE_START = "TextMessageStart"
|
||||
TEXT_MESSAGE_CONTENT = "TextMessageContent"
|
||||
TEXT_MESSAGE_END = "TextMessageEnd"
|
||||
ACTION_EXECUTION_START = "ActionExecutionStart"
|
||||
ACTION_EXECUTION_ARGS = "ActionExecutionArgs"
|
||||
ACTION_EXECUTION_END = "ActionExecutionEnd"
|
||||
ACTION_EXECUTION_RESULT = "ActionExecutionResult"
|
||||
AGENT_STATE_MESSAGE = "AgentStateMessage"
|
||||
META_EVENT = "MetaEvent"
|
||||
RUN_STARTED = "RunStarted"
|
||||
RUN_FINISHED = "RunFinished"
|
||||
RUN_ERROR = "RunError"
|
||||
NODE_STARTED = "NodeStarted"
|
||||
NODE_FINISHED = "NodeFinished"
|
||||
|
||||
class RuntimeMetaEventName(Enum):
|
||||
"""Runtime Meta Event Name"""
|
||||
LANG_GRAPH_INTERRUPT_EVENT = "LangGraphInterruptEvent"
|
||||
PREDICT_STATE = "PredictState"
|
||||
EXIT = "Exit"
|
||||
|
||||
|
||||
class TextMessageStart(TypedDict):
|
||||
"""Text Message Start Event"""
|
||||
type: Literal[RuntimeEventTypes.TEXT_MESSAGE_START]
|
||||
messageId: str
|
||||
parentMessageId: Optional[str]
|
||||
|
||||
class TextMessageContent(TypedDict):
|
||||
"""Text Message Content Event"""
|
||||
type: Literal[RuntimeEventTypes.TEXT_MESSAGE_CONTENT]
|
||||
messageId: str
|
||||
content: str
|
||||
|
||||
class TextMessageEnd(TypedDict):
|
||||
"""Text Message End Event"""
|
||||
type: Literal[RuntimeEventTypes.TEXT_MESSAGE_END]
|
||||
messageId: str
|
||||
|
||||
class ActionExecutionStart(TypedDict):
|
||||
"""Action Execution Start Event"""
|
||||
type: Literal[RuntimeEventTypes.ACTION_EXECUTION_START]
|
||||
actionExecutionId: str
|
||||
actionName: str
|
||||
parentMessageId: Optional[str]
|
||||
|
||||
class ActionExecutionArgs(TypedDict):
|
||||
"""Action Execution Args Event"""
|
||||
type: Literal[RuntimeEventTypes.ACTION_EXECUTION_ARGS]
|
||||
actionExecutionId: str
|
||||
args: str
|
||||
|
||||
class ActionExecutionEnd(TypedDict):
|
||||
"""Action Execution End Event"""
|
||||
type: Literal[RuntimeEventTypes.ACTION_EXECUTION_END]
|
||||
actionExecutionId: str
|
||||
|
||||
class ActionExecutionResult(TypedDict):
|
||||
"""Action Execution Result Event"""
|
||||
type: Literal[RuntimeEventTypes.ACTION_EXECUTION_RESULT]
|
||||
actionName: str
|
||||
actionExecutionId: str
|
||||
result: str
|
||||
|
||||
class AgentStateMessage(TypedDict):
|
||||
"""Agent State Message Event"""
|
||||
type: Literal[RuntimeEventTypes.AGENT_STATE_MESSAGE]
|
||||
threadId: str
|
||||
agentName: str
|
||||
nodeName: str
|
||||
runId: str
|
||||
active: bool
|
||||
role: str
|
||||
state: str
|
||||
running: bool
|
||||
|
||||
class MetaEvent(TypedDict):
|
||||
"""Meta Event"""
|
||||
type: Literal[RuntimeEventTypes.META_EVENT]
|
||||
name: RuntimeMetaEventName
|
||||
value: Any
|
||||
|
||||
class RunStarted(TypedDict):
|
||||
"""Run Started Event"""
|
||||
type: Literal[RuntimeEventTypes.RUN_STARTED]
|
||||
state: Dict[str, Any]
|
||||
|
||||
class RunFinished(TypedDict):
|
||||
"""Run Finished Event"""
|
||||
type: Literal[RuntimeEventTypes.RUN_FINISHED]
|
||||
state: Dict[str, Any]
|
||||
|
||||
class RunError(TypedDict):
|
||||
"""Run Error Event"""
|
||||
type: Literal[RuntimeEventTypes.RUN_ERROR]
|
||||
error: Any
|
||||
|
||||
class NodeStarted(TypedDict):
|
||||
"""Node Started Event"""
|
||||
type: Literal[RuntimeEventTypes.NODE_STARTED]
|
||||
node_name: str
|
||||
state: Dict[str, Any]
|
||||
|
||||
class NodeFinished(TypedDict):
|
||||
"""Node Finished Event"""
|
||||
type: Literal[RuntimeEventTypes.NODE_FINISHED]
|
||||
node_name: str
|
||||
state: Dict[str, Any]
|
||||
|
||||
RuntimeProtocolEvent = Union[
|
||||
TextMessageStart,
|
||||
TextMessageContent,
|
||||
TextMessageEnd,
|
||||
ActionExecutionStart,
|
||||
ActionExecutionArgs,
|
||||
ActionExecutionEnd,
|
||||
ActionExecutionResult,
|
||||
AgentStateMessage,
|
||||
MetaEvent
|
||||
]
|
||||
|
||||
RuntimeLifecycleEvent = Union[
|
||||
RunStarted,
|
||||
RunFinished,
|
||||
RunError,
|
||||
NodeStarted,
|
||||
NodeFinished,
|
||||
]
|
||||
|
||||
RuntimeEvent = Union[
|
||||
RuntimeProtocolEvent,
|
||||
RuntimeLifecycleEvent,
|
||||
]
|
||||
|
||||
|
||||
class PredictStateConfig(TypedDict):
|
||||
"""
|
||||
Predict State Config
|
||||
"""
|
||||
tool_name: str
|
||||
tool_argument: Optional[str]
|
||||
|
||||
def text_message_start(
|
||||
*,
|
||||
message_id: str,
|
||||
parent_message_id: Optional[str] = None
|
||||
) -> TextMessageStart:
|
||||
"""Utility function to create a text message start event"""
|
||||
return {
|
||||
"type": RuntimeEventTypes.TEXT_MESSAGE_START,
|
||||
"messageId": message_id,
|
||||
"parentMessageId": parent_message_id
|
||||
}
|
||||
|
||||
def text_message_content(*, message_id: str, content: str) -> TextMessageContent:
|
||||
"""Utility function to create a text message content event"""
|
||||
return {
|
||||
"type": RuntimeEventTypes.TEXT_MESSAGE_CONTENT,
|
||||
"messageId": message_id,
|
||||
"content": content
|
||||
}
|
||||
|
||||
def text_message_end(*, message_id: str) -> TextMessageEnd:
|
||||
"""Utility function to create a text message end event"""
|
||||
return {
|
||||
"type": RuntimeEventTypes.TEXT_MESSAGE_END,
|
||||
"messageId": message_id
|
||||
}
|
||||
|
||||
def action_execution_start(
|
||||
*,
|
||||
action_execution_id: str,
|
||||
action_name: str,
|
||||
parent_message_id: Optional[str] = None
|
||||
) -> ActionExecutionStart:
|
||||
"""Utility function to create an action execution start event"""
|
||||
return {
|
||||
"type": RuntimeEventTypes.ACTION_EXECUTION_START,
|
||||
"actionExecutionId": action_execution_id,
|
||||
"actionName": action_name,
|
||||
"parentMessageId": parent_message_id
|
||||
}
|
||||
|
||||
def action_execution_args(*, action_execution_id: str, args: str) -> ActionExecutionArgs:
|
||||
"""Utility function to create an action execution args event"""
|
||||
return {
|
||||
"type": RuntimeEventTypes.ACTION_EXECUTION_ARGS,
|
||||
"actionExecutionId": action_execution_id,
|
||||
"args": args
|
||||
}
|
||||
|
||||
def action_execution_end(*, action_execution_id: str) -> ActionExecutionEnd:
|
||||
"""Utility function to create an action execution end event"""
|
||||
return {
|
||||
"type": RuntimeEventTypes.ACTION_EXECUTION_END,
|
||||
"actionExecutionId": action_execution_id
|
||||
}
|
||||
|
||||
def action_execution_result(
|
||||
*,
|
||||
action_name: str,
|
||||
action_execution_id: str,
|
||||
result: str
|
||||
) -> ActionExecutionResult:
|
||||
"""Utility function to create an action execution result event"""
|
||||
return {
|
||||
"type": RuntimeEventTypes.ACTION_EXECUTION_RESULT,
|
||||
"actionName": action_name,
|
||||
"actionExecutionId": action_execution_id,
|
||||
"result": result
|
||||
}
|
||||
|
||||
def agent_state_message( # pylint: disable=too-many-arguments
|
||||
*,
|
||||
thread_id: str,
|
||||
agent_name: str,
|
||||
node_name: str,
|
||||
run_id: str,
|
||||
active: bool,
|
||||
role: str,
|
||||
state: str,
|
||||
running: bool
|
||||
) -> AgentStateMessage:
|
||||
"""Utility function to create an agent state message event"""
|
||||
return {
|
||||
"type": RuntimeEventTypes.AGENT_STATE_MESSAGE,
|
||||
"threadId": thread_id,
|
||||
"agentName": agent_name,
|
||||
"nodeName": node_name,
|
||||
"runId": run_id,
|
||||
"active": active,
|
||||
"role": role,
|
||||
"state": state,
|
||||
"running": running
|
||||
}
|
||||
|
||||
def meta_event(*, name: RuntimeMetaEventName, value: Any) -> MetaEvent:
|
||||
"""Utility function to create a meta event"""
|
||||
return {
|
||||
"type": RuntimeEventTypes.META_EVENT,
|
||||
"name": name,
|
||||
"value": value
|
||||
}
|
||||
|
||||
def emit_runtime_events(*events: RuntimeProtocolEvent) -> str:
|
||||
"""Emit a list of runtime events"""
|
||||
def serialize_event(event):
|
||||
# Convert enum values to their string representation
|
||||
if isinstance(event, dict):
|
||||
return {k: (v.value if isinstance(v, Enum) else v) for k, v in event.items()}
|
||||
return event
|
||||
|
||||
return "\n".join(json.dumps(serialize_event(event)) for event in events) + "\n"
|
||||
|
||||
def emit_runtime_event(event: RuntimeProtocolEvent) -> str:
|
||||
"""Emit a single runtime event"""
|
||||
return emit_runtime_events(event)
|
||||
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
CopilotKit Run Loop
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import json
|
||||
import traceback
|
||||
from typing import Callable
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import Any, Dict, Optional, List, TypedDict, cast
|
||||
from partialjson.json_parser import JSONParser as PartialJSONParser
|
||||
|
||||
from .protocol import (
|
||||
RuntimeEvent,
|
||||
RuntimeEventTypes,
|
||||
RuntimeMetaEventName,
|
||||
emit_runtime_event,
|
||||
emit_runtime_events,
|
||||
agent_state_message,
|
||||
AgentStateMessage,
|
||||
PredictStateConfig,
|
||||
RuntimeProtocolEvent
|
||||
)
|
||||
|
||||
async def yield_control():
|
||||
"""
|
||||
Yield control to the event loop.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.create_future()
|
||||
loop.call_soon(future.set_result, None)
|
||||
await future
|
||||
|
||||
|
||||
class CopilotKitRunExecution(TypedDict):
|
||||
"""
|
||||
CopilotKit Run Execution
|
||||
"""
|
||||
thread_id: str
|
||||
agent_name: str
|
||||
run_id: str
|
||||
should_exit: bool
|
||||
node_name: str
|
||||
is_finished: bool
|
||||
predict_state_configuration: Dict[str, PredictStateConfig]
|
||||
predicted_state: Dict[str, Any]
|
||||
argument_buffer: str
|
||||
current_tool_call: Optional[str]
|
||||
state: Dict[str, Any]
|
||||
|
||||
_CONTEXT_QUEUE = contextvars.ContextVar('queue', default=None)
|
||||
_CONTEXT_EXECUTION = contextvars.ContextVar('execution', default=None)
|
||||
|
||||
def get_context_queue() -> asyncio.Queue:
|
||||
"""
|
||||
Retrieve the queue from this task's context.
|
||||
"""
|
||||
q = _CONTEXT_QUEUE.get()
|
||||
if q is None:
|
||||
raise RuntimeError("No context queue is set!")
|
||||
return q
|
||||
|
||||
def set_context_queue(q: asyncio.Queue) -> contextvars.Token:
|
||||
"""
|
||||
Set the queue in this task's context.
|
||||
"""
|
||||
token = _CONTEXT_QUEUE.set(cast(Any, q))
|
||||
return token
|
||||
|
||||
def reset_context_queue(token: contextvars.Token):
|
||||
"""
|
||||
Reset the queue in this task's context.
|
||||
"""
|
||||
_CONTEXT_QUEUE.reset(token)
|
||||
|
||||
def get_context_execution() -> CopilotKitRunExecution:
|
||||
"""
|
||||
Get the execution from this task's context.
|
||||
"""
|
||||
return cast(CopilotKitRunExecution, _CONTEXT_EXECUTION.get())
|
||||
|
||||
def set_context_execution(execution: CopilotKitRunExecution) -> contextvars.Token:
|
||||
"""
|
||||
Set the execution in this task's context.
|
||||
"""
|
||||
token = _CONTEXT_EXECUTION.set(cast(Any, execution))
|
||||
return token
|
||||
|
||||
def reset_context_execution(token: contextvars.Token):
|
||||
"""
|
||||
Reset the execution in this task's context.
|
||||
"""
|
||||
_CONTEXT_EXECUTION.reset(token)
|
||||
|
||||
|
||||
async def queue_put(*events: RuntimeEvent, priority: bool = False):
|
||||
"""
|
||||
Put an event in the queue.
|
||||
"""
|
||||
if not priority:
|
||||
# yield control so that priority events can be processed first
|
||||
await yield_control()
|
||||
|
||||
q = get_context_queue()
|
||||
for event in events:
|
||||
await q.put(event)
|
||||
|
||||
# yield control so that the reader can process the event
|
||||
await yield_control()
|
||||
|
||||
|
||||
def _to_dict_if_pydantic(obj):
|
||||
if isinstance(obj, BaseModel):
|
||||
return obj.model_dump()
|
||||
return obj
|
||||
|
||||
def _filter_state(
|
||||
*,
|
||||
state: Dict[str, Any],
|
||||
exclude_keys: Optional[List[str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Filter out messages and id from the state"""
|
||||
state = _to_dict_if_pydantic(state)
|
||||
exclude_keys = exclude_keys or ["messages", "id"]
|
||||
return {k: v for k, v in state.items() if k not in exclude_keys}
|
||||
|
||||
|
||||
async def copilotkit_run(
|
||||
fn: Callable,
|
||||
*,
|
||||
execution: CopilotKitRunExecution
|
||||
):
|
||||
"""
|
||||
Run a task with a local queue.
|
||||
"""
|
||||
local_queue = asyncio.Queue()
|
||||
token_queue = set_context_queue(local_queue)
|
||||
token_execution = set_context_execution(execution)
|
||||
|
||||
task = asyncio.create_task(
|
||||
fn()
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
event = await local_queue.get()
|
||||
local_queue.task_done()
|
||||
|
||||
json_lines = handle_runtime_event(
|
||||
event=event,
|
||||
execution=execution
|
||||
)
|
||||
|
||||
if json_lines is not None:
|
||||
yield json_lines
|
||||
|
||||
if execution["is_finished"]:
|
||||
break
|
||||
|
||||
# return control to the containing run loop to send events
|
||||
await yield_control()
|
||||
|
||||
await task
|
||||
|
||||
finally:
|
||||
reset_context_queue(token_queue)
|
||||
reset_context_execution(token_execution)
|
||||
|
||||
def handle_runtime_event(
|
||||
*,
|
||||
event: RuntimeEvent,
|
||||
execution: CopilotKitRunExecution
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Handle a runtime event.
|
||||
"""
|
||||
|
||||
if event["type"] in [
|
||||
RuntimeEventTypes.TEXT_MESSAGE_START,
|
||||
RuntimeEventTypes.TEXT_MESSAGE_CONTENT,
|
||||
RuntimeEventTypes.TEXT_MESSAGE_END,
|
||||
RuntimeEventTypes.ACTION_EXECUTION_START,
|
||||
RuntimeEventTypes.ACTION_EXECUTION_ARGS,
|
||||
RuntimeEventTypes.ACTION_EXECUTION_END,
|
||||
RuntimeEventTypes.ACTION_EXECUTION_RESULT,
|
||||
RuntimeEventTypes.AGENT_STATE_MESSAGE
|
||||
]:
|
||||
events: List[RuntimeProtocolEvent] = [cast(RuntimeProtocolEvent, event)]
|
||||
if event["type"] in [
|
||||
RuntimeEventTypes.ACTION_EXECUTION_START,
|
||||
RuntimeEventTypes.ACTION_EXECUTION_ARGS
|
||||
]:
|
||||
message = predict_state(
|
||||
thread_id=execution["thread_id"],
|
||||
agent_name=execution["agent_name"],
|
||||
run_id=execution["run_id"],
|
||||
event=event,
|
||||
execution=execution,
|
||||
)
|
||||
if message is not None:
|
||||
events.append(message)
|
||||
return emit_runtime_events(*events)
|
||||
|
||||
if event["type"] == RuntimeEventTypes.META_EVENT:
|
||||
if event["name"] == RuntimeMetaEventName.PREDICT_STATE:
|
||||
execution["predict_state_configuration"] = event["value"]
|
||||
return None
|
||||
if event["name"] == RuntimeMetaEventName.EXIT:
|
||||
execution["should_exit"] = event["value"]
|
||||
return None
|
||||
return None
|
||||
|
||||
if event["type"] == RuntimeEventTypes.RUN_STARTED:
|
||||
execution["state"] = event["state"]
|
||||
return None
|
||||
|
||||
if event["type"] == RuntimeEventTypes.NODE_STARTED:
|
||||
execution["node_name"] = event["node_name"]
|
||||
execution["state"] = event["state"]
|
||||
|
||||
return emit_runtime_event(
|
||||
agent_state_message(
|
||||
thread_id=execution["thread_id"],
|
||||
agent_name=execution["agent_name"],
|
||||
node_name=execution["node_name"],
|
||||
run_id=execution["run_id"],
|
||||
active=True,
|
||||
role="assistant",
|
||||
state=json.dumps(_filter_state(state=execution["state"])),
|
||||
running=True
|
||||
)
|
||||
)
|
||||
|
||||
if event["type"] == RuntimeEventTypes.NODE_FINISHED:
|
||||
|
||||
# reset the predict state configuration at the end of the method execution
|
||||
execution["predict_state_configuration"] = {}
|
||||
execution["current_tool_call"] = None
|
||||
execution["argument_buffer"] = ""
|
||||
execution["predicted_state"] = {}
|
||||
execution["state"] = event["state"]
|
||||
|
||||
return emit_runtime_event(
|
||||
agent_state_message(
|
||||
thread_id=execution["thread_id"],
|
||||
agent_name=execution["agent_name"],
|
||||
node_name=execution["node_name"],
|
||||
run_id=execution["run_id"],
|
||||
active=False,
|
||||
role="assistant",
|
||||
state=json.dumps(_filter_state(state=execution["state"])),
|
||||
running=True
|
||||
)
|
||||
)
|
||||
|
||||
if event["type"] == RuntimeEventTypes.RUN_FINISHED:
|
||||
execution["is_finished"] = True
|
||||
return None
|
||||
|
||||
if event["type"] == RuntimeEventTypes.RUN_ERROR:
|
||||
print("Flow execution error", flush=True)
|
||||
error_info = event["error"]
|
||||
|
||||
if isinstance(error_info, Exception):
|
||||
# If it's an exception, print the traceback
|
||||
print("Exception occurred:", flush=True)
|
||||
print(
|
||||
''.join(
|
||||
traceback.format_exception(
|
||||
None,
|
||||
error_info,
|
||||
error_info.__traceback__
|
||||
)
|
||||
),
|
||||
flush=True
|
||||
)
|
||||
else:
|
||||
# Otherwise, assume it's a string and print it
|
||||
print(error_info, flush=True)
|
||||
|
||||
execution["is_finished"] = True
|
||||
return None
|
||||
|
||||
def predict_state(
|
||||
*,
|
||||
thread_id: str,
|
||||
agent_name: str,
|
||||
run_id: str,
|
||||
event: Any,
|
||||
execution: CopilotKitRunExecution,
|
||||
) -> Optional[AgentStateMessage]:
|
||||
"""Predict the state"""
|
||||
|
||||
if event["type"] == RuntimeEventTypes.ACTION_EXECUTION_START:
|
||||
execution["current_tool_call"] = event["actionName"]
|
||||
execution["argument_buffer"] = ""
|
||||
elif event["type"] == RuntimeEventTypes.ACTION_EXECUTION_ARGS:
|
||||
execution["argument_buffer"] += event["args"]
|
||||
|
||||
tool_names = [
|
||||
config.get("tool_name")
|
||||
for config in execution["predict_state_configuration"].values()
|
||||
]
|
||||
|
||||
if execution["current_tool_call"] not in tool_names:
|
||||
return None
|
||||
|
||||
current_arguments = {}
|
||||
try:
|
||||
current_arguments = PartialJSONParser().parse(execution["argument_buffer"])
|
||||
except: # pylint: disable=bare-except
|
||||
return None
|
||||
|
||||
emit_update = False
|
||||
for k, v in execution["predict_state_configuration"].items():
|
||||
if v["tool_name"] == execution["current_tool_call"]:
|
||||
tool_argument = v.get("tool_argument")
|
||||
if tool_argument is not None:
|
||||
argument_value = current_arguments.get(tool_argument)
|
||||
if argument_value is not None:
|
||||
execution["predicted_state"][k] = argument_value
|
||||
emit_update = True
|
||||
else:
|
||||
execution["predicted_state"][k] = current_arguments
|
||||
emit_update = True
|
||||
|
||||
if emit_update:
|
||||
return agent_state_message(
|
||||
thread_id=thread_id,
|
||||
agent_name=agent_name,
|
||||
node_name=execution["node_name"],
|
||||
run_id=run_id,
|
||||
active=True,
|
||||
role="assistant",
|
||||
state=json.dumps(
|
||||
_filter_state(
|
||||
state={
|
||||
**execution["state"],
|
||||
**execution["predicted_state"]
|
||||
}
|
||||
)
|
||||
),
|
||||
running=True
|
||||
)
|
||||
|
||||
return None
|
||||
@@ -5,7 +5,7 @@ from importlib import metadata
|
||||
|
||||
from pprint import pformat
|
||||
from typing import List, Callable, Union, Optional, Any, Coroutine
|
||||
from typing_extensions import TypedDict, Tuple
|
||||
from typing_extensions import TypedDict, Tuple, cast, Mapping
|
||||
from .agent import Agent, AgentDict
|
||||
from .action import Action, ActionDict, ActionResultDict
|
||||
from .types import Message, MetaEvent
|
||||
@@ -19,7 +19,7 @@ from .logging import get_logger, bold
|
||||
|
||||
|
||||
try:
|
||||
__version__ = metadata.version(__package__)
|
||||
__version__ = metadata.version(cast(str, __package__))
|
||||
except metadata.PackageNotFoundError:
|
||||
# Case where package metadata is not available.
|
||||
__version__ = ""
|
||||
@@ -47,9 +47,12 @@ class CopilotKitContext(TypedDict):
|
||||
The properties provided to the frontend via `<CopilotKit properties={...} />`
|
||||
frontend_url : Optional[str]
|
||||
The current URL of the frontend
|
||||
headers : Mapping[str, str]
|
||||
The headers of the request
|
||||
"""
|
||||
properties: Any
|
||||
frontend_url: Optional[str]
|
||||
headers: Mapping[str, str]
|
||||
|
||||
# Alias for backwards compatibility
|
||||
CopilotKitSDKContext = CopilotKitContext
|
||||
@@ -66,9 +69,10 @@ class CopilotKitRemoteEndpoint:
|
||||
pip install copilotkit
|
||||
```
|
||||
|
||||
### Examples
|
||||
## Adding actions
|
||||
|
||||
In this example, we provide a simple action to the Copilot:
|
||||
|
||||
For example, to provide a simple action to the Copilot:
|
||||
```python
|
||||
from copilotkit import CopilotKitRemoteEndpoint, Action
|
||||
|
||||
@@ -91,7 +95,7 @@ class CopilotKitRemoteEndpoint:
|
||||
```
|
||||
|
||||
You can also dynamically build actions by providing a callable that returns a list of actions.
|
||||
In this example, we use `"name"` from the `properties` object to parameterize the action handler.
|
||||
In this example, we use "name" from the `properties` object to parameterize the action handler.
|
||||
|
||||
```python
|
||||
from copilotkit import CopilotKitRemoteEndpoint, Action
|
||||
@@ -119,7 +123,9 @@ class CopilotKitRemoteEndpoint:
|
||||
)
|
||||
```
|
||||
|
||||
Similarly, you can give a list of static or dynamic agents to the Copilot:
|
||||
## Adding agents
|
||||
|
||||
Serving agents works in a similar way to serving actions:
|
||||
|
||||
```python
|
||||
from copilotkit import CopilotKitRemoteEndpoint, LangGraphAgent
|
||||
@@ -127,7 +133,7 @@ class CopilotKitRemoteEndpoint:
|
||||
|
||||
sdk = CopilotKitRemoteEndpoint(
|
||||
agents=[
|
||||
LangGraphAgent(
|
||||
LangGraphAgent(
|
||||
name="email_agent",
|
||||
description="This agent sends emails",
|
||||
graph=graph,
|
||||
@@ -136,6 +142,41 @@ class CopilotKitRemoteEndpoint:
|
||||
)
|
||||
```
|
||||
|
||||
To dynamically build agents, provide a callable that returns a list of agents:
|
||||
|
||||
```python
|
||||
from copilotkit import CopilotKitRemoteEndpoint, LangGraphAgent
|
||||
from my_agent.agent import graph
|
||||
|
||||
sdk = CopilotKitRemoteEndpoint(
|
||||
agents=lambda context: [
|
||||
LangGraphAgent(
|
||||
name="email_agent",
|
||||
description="This agent sends emails",
|
||||
graph=graph,
|
||||
langgraph_config={
|
||||
"token": context["properties"]["token"]
|
||||
}
|
||||
)
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
To restrict the agents available to the Copilot, simply return a different list of agents based on the `context`:
|
||||
|
||||
```python
|
||||
from copilotkit import CopilotKitRemoteEndpoint
|
||||
from my_agents import agent_a, agent_b, is_admin
|
||||
|
||||
sdk = CopilotKitRemoteEndpoint(
|
||||
agents=lambda context: (
|
||||
[agent_a, agent_b] if is_admin(context["properties"]["token"]) else [agent_a]
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Serving the CopilotKit SDK
|
||||
|
||||
To serve the CopilotKit SDK, you can use the `add_fastapi_endpoint` function from the `copilotkit.integrations.fastapi` module:
|
||||
|
||||
```python
|
||||
@@ -262,11 +303,11 @@ class CopilotKitRemoteEndpoint:
|
||||
context: CopilotKitContext,
|
||||
name: str,
|
||||
thread_id: str,
|
||||
node_name: str,
|
||||
state: dict,
|
||||
configurable: Optional[dict] = None,
|
||||
messages: List[Message],
|
||||
actions: List[ActionDict],
|
||||
node_name: str,
|
||||
meta_events: Optional[List[MetaEvent]] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
@@ -305,7 +346,7 @@ class CopilotKitRemoteEndpoint:
|
||||
except Exception as error:
|
||||
raise AgentExecutionException(name, error) from error
|
||||
|
||||
def get_agent_state(
|
||||
async def get_agent_state(
|
||||
self,
|
||||
*,
|
||||
context: CopilotKitContext,
|
||||
@@ -329,7 +370,7 @@ class CopilotKitRemoteEndpoint:
|
||||
]
|
||||
)
|
||||
try:
|
||||
return agent.get_state(thread_id=thread_id)
|
||||
return await agent.get_state(thread_id=thread_id)
|
||||
except Exception as error:
|
||||
raise AgentExecutionException(name, error) from error
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"""CopilotKit state"""
|
||||
|
||||
from typing import List, Any
|
||||
from typing_extensions import TypedDict
|
||||
from langgraph.graph import MessagesState
|
||||
|
||||
class CopilotKitProperties(TypedDict):
|
||||
"""CopilotKit state"""
|
||||
actions: List[Any]
|
||||
|
||||
class CopilotKitState(MessagesState):
|
||||
"""CopilotKit state"""
|
||||
copilotkit: CopilotKitProperties
|
||||
Generated
+2732
-84
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "copilotkit"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40-alpha.2"
|
||||
description = "CopilotKit python SDK"
|
||||
authors = ["Markus Ecker <markus.ecker@gmail.com>"]
|
||||
license = "MIT"
|
||||
@@ -10,15 +10,12 @@ repository = "https://github.com/CopilotKit/sdk-python"
|
||||
keywords = ["copilot", "copilotkit", "langgraph", "langchain", "ai", "langsmith", "langserve"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.9,<4.0"
|
||||
langgraph = ">=0.2.50"
|
||||
httpx = "^0.27.2"
|
||||
python = ">=3.10,<3.13"
|
||||
langgraph = {version = ">=0.2.50"}
|
||||
langchain = {version = "^0.3.3"}
|
||||
crewai = {version = ">=0.100.0,<1.0.0"}
|
||||
fastapi = "^0.115.0"
|
||||
langchain = "^0.3.3"
|
||||
langchain-openai = ">=0.2.3,<0.4.0"
|
||||
langchain-anthropic = ">=0.2.3,<0.4.0"
|
||||
partialjson = "^0.0.8"
|
||||
langgraph-sdk = "^0.1.32"
|
||||
toml = "^0.10.2"
|
||||
|
||||
[build-system]
|
||||
|
||||
Reference in New Issue
Block a user