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:
Markus Ecker
2025-02-28 12:21:54 +01:00
committed by GitHub
parent 5dab180a0b
commit 082db7a996
245 changed files with 47491 additions and 850 deletions
+3 -1
View File
@@ -19,6 +19,8 @@
"childrens",
"partialjson",
"dotenv",
"adispatch"
"adispatch",
"crewai",
"litellm"
]
}
+8 -5
View File
@@ -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
]
+7 -1
View File
@@ -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,
*,
+9 -2
View File
@@ -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"""
+25
View File
@@ -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)
+615
View File
@@ -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
+176
View File
@@ -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,
)
+112 -14
View File
@@ -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"""
+13 -2
View File
@@ -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
+19 -10
View File
@@ -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
+1 -1
View File
@@ -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
+268
View File
@@ -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)
+346
View File
@@ -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
+51 -10
View File
@@ -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
-13
View File
@@ -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
+2732 -84
View File
File diff suppressed because it is too large Load Diff
+5 -8
View File
@@ -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]