mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
fix(sdk): harden AG-UI dispatch, add exception hierarchy, fix docstrings
Address code review findings: - Wrap AG-UI tool call dispatch in try/except with compensating TOOL_CALL_END to prevent clients hanging on partial emission - Reject non-dict/non-str args at the dispatch layer (lists, ints, None) - Guard against None event value before calling .get() - Fix docstring examples that reuse variable names (won't compile) - Introduce CopilotKitError base class; all exceptions now inherit from it; CopilotKitMisuseError inherits from both CopilotKitError and ValueError - Add missing validation tests for name and args across LangGraph and CrewAI Python variants, plus AG-UI dispatch edge cases Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -301,10 +301,10 @@ export async function copilotkitEmitMessage(
|
||||
* ```typescript
|
||||
* import { copilotkitEmitToolCall } from "@copilotkit/sdk-js";
|
||||
*
|
||||
* const toolCallId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 });
|
||||
* const autoId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 });
|
||||
*
|
||||
* // With a custom ID for correlation/idempotency:
|
||||
* const toolCallId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }, { toolCallId: "my-custom-id" });
|
||||
* const customId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }, { toolCallId: "my-custom-id" });
|
||||
* ```
|
||||
*/
|
||||
export async function copilotkitEmitToolCall(
|
||||
|
||||
@@ -236,10 +236,10 @@ async def copilotkit_emit_tool_call(
|
||||
```python
|
||||
from copilotkit.crewai import copilotkit_emit_tool_call
|
||||
|
||||
tool_call_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10})
|
||||
auto_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10})
|
||||
|
||||
# With a custom ID for correlation/idempotency:
|
||||
tool_call_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id")
|
||||
custom_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id")
|
||||
```
|
||||
|
||||
Parameters
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
"""Exceptions for CopilotKit."""
|
||||
|
||||
|
||||
class ActionNotFoundException(Exception):
|
||||
class CopilotKitError(Exception):
|
||||
"""Base exception for all CopilotKit errors.
|
||||
|
||||
Catch this to handle any CopilotKit-specific exception.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ActionNotFoundException(CopilotKitError):
|
||||
"""Exception raised when an action or agent is not found."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
@@ -9,7 +18,7 @@ class ActionNotFoundException(Exception):
|
||||
super().__init__(f"Action '{name}' not found.")
|
||||
|
||||
|
||||
class AgentNotFoundException(Exception):
|
||||
class AgentNotFoundException(CopilotKitError):
|
||||
"""Exception raised when an agent is not found."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
@@ -17,7 +26,7 @@ class AgentNotFoundException(Exception):
|
||||
super().__init__(f"Agent '{name}' not found.")
|
||||
|
||||
|
||||
class ActionExecutionException(Exception):
|
||||
class ActionExecutionException(CopilotKitError):
|
||||
"""Exception raised when an action fails to execute."""
|
||||
|
||||
def __init__(self, name: str, error: Exception):
|
||||
@@ -26,7 +35,7 @@ class ActionExecutionException(Exception):
|
||||
super().__init__(f"Action '{name}' failed to execute: {error}")
|
||||
|
||||
|
||||
class AgentExecutionException(Exception):
|
||||
class AgentExecutionException(CopilotKitError):
|
||||
"""Exception raised when an agent fails to execute."""
|
||||
|
||||
def __init__(self, name: str, error: Exception):
|
||||
@@ -35,10 +44,11 @@ class AgentExecutionException(Exception):
|
||||
super().__init__(f"Agent '{name}' failed to execute: {error}")
|
||||
|
||||
|
||||
class CopilotKitMisuseError(ValueError):
|
||||
class CopilotKitMisuseError(CopilotKitError, ValueError):
|
||||
"""Exception raised when CopilotKit detects incorrect usage of its APIs.
|
||||
|
||||
Subclasses ValueError for backward compatibility with existing handlers.
|
||||
Inherits from both CopilotKitError (for ``except CopilotKitError``) and
|
||||
ValueError (for backward compatibility with ``except ValueError`` handlers).
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@@ -387,10 +387,10 @@ async def copilotkit_emit_tool_call(
|
||||
```python
|
||||
from copilotkit.langgraph import copilotkit_emit_tool_call
|
||||
|
||||
tool_call_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10})
|
||||
auto_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10})
|
||||
|
||||
# With a custom ID for correlation/idempotency:
|
||||
tool_call_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id")
|
||||
custom_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id")
|
||||
```
|
||||
|
||||
Parameters
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional, Union, AsyncGenerator
|
||||
from enum import Enum
|
||||
from .exc import CopilotKitMisuseError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from ag_ui_langgraph import LangGraphAgent
|
||||
from ag_ui.core import (
|
||||
EventType,
|
||||
@@ -106,6 +109,11 @@ class LangGraphAGUIAgent(LangGraphAgent):
|
||||
|
||||
if custom_event.name == CustomEventNames.ManuallyEmitToolCall.value:
|
||||
value = custom_event.value
|
||||
if not isinstance(value, dict):
|
||||
raise CopilotKitMisuseError(
|
||||
f"ManuallyEmitToolCall event 'value' must be a dict, got {type(value).__name__}"
|
||||
)
|
||||
|
||||
tool_call_id = value.get("id")
|
||||
tool_call_name = value.get("name")
|
||||
tool_call_args = value.get("args")
|
||||
@@ -118,9 +126,10 @@ class LangGraphAGUIAgent(LangGraphAgent):
|
||||
raise CopilotKitMisuseError(
|
||||
f"ManuallyEmitToolCall event missing valid 'name': got {type(tool_call_name).__name__}"
|
||||
)
|
||||
if tool_call_args is None:
|
||||
if not isinstance(tool_call_args, (dict, str)):
|
||||
raise CopilotKitMisuseError(
|
||||
"ManuallyEmitToolCall event missing 'args'"
|
||||
f"ManuallyEmitToolCall 'args' must be a dict or pre-serialized JSON string, "
|
||||
f"got {type(tool_call_args).__name__} for tool_call_id={tool_call_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -129,35 +138,54 @@ class LangGraphAGUIAgent(LangGraphAgent):
|
||||
if isinstance(tool_call_args, str)
|
||||
else json.dumps(tool_call_args)
|
||||
)
|
||||
except (TypeError, ValueError) as e:
|
||||
except Exception as e:
|
||||
raise CopilotKitMisuseError(
|
||||
f"ManuallyEmitToolCall 'args' is not JSON-serializable for tool_call_id={tool_call_id}: {e}"
|
||||
) from e
|
||||
|
||||
super()._dispatch_event(
|
||||
ToolCallStartEvent(
|
||||
type=EventType.TOOL_CALL_START,
|
||||
tool_call_id=tool_call_id,
|
||||
tool_call_name=tool_call_name,
|
||||
parent_message_id=tool_call_id,
|
||||
raw_event=event,
|
||||
dispatched_start = False
|
||||
try:
|
||||
super()._dispatch_event(
|
||||
ToolCallStartEvent(
|
||||
type=EventType.TOOL_CALL_START,
|
||||
tool_call_id=tool_call_id,
|
||||
tool_call_name=tool_call_name,
|
||||
parent_message_id=tool_call_id,
|
||||
raw_event=event,
|
||||
)
|
||||
)
|
||||
)
|
||||
super()._dispatch_event(
|
||||
ToolCallArgsEvent(
|
||||
type=EventType.TOOL_CALL_ARGS,
|
||||
tool_call_id=tool_call_id,
|
||||
delta=delta,
|
||||
raw_event=event,
|
||||
dispatched_start = True
|
||||
super()._dispatch_event(
|
||||
ToolCallArgsEvent(
|
||||
type=EventType.TOOL_CALL_ARGS,
|
||||
tool_call_id=tool_call_id,
|
||||
delta=delta,
|
||||
raw_event=event,
|
||||
)
|
||||
)
|
||||
)
|
||||
super()._dispatch_event(
|
||||
ToolCallEndEvent(
|
||||
type=EventType.TOOL_CALL_END,
|
||||
tool_call_id=tool_call_id,
|
||||
raw_event=event,
|
||||
super()._dispatch_event(
|
||||
ToolCallEndEvent(
|
||||
type=EventType.TOOL_CALL_END,
|
||||
tool_call_id=tool_call_id,
|
||||
raw_event=event,
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
if dispatched_start:
|
||||
try:
|
||||
super()._dispatch_event(
|
||||
ToolCallEndEvent(
|
||||
type=EventType.TOOL_CALL_END,
|
||||
tool_call_id=tool_call_id,
|
||||
raw_event=event,
|
||||
)
|
||||
)
|
||||
except Exception as close_err:
|
||||
logger.error(
|
||||
"Failed to emit compensating TOOL_CALL_END for %s: %s",
|
||||
tool_call_id, close_err,
|
||||
)
|
||||
raise
|
||||
return super()._dispatch_event(event)
|
||||
|
||||
if custom_event.name == CustomEventNames.ManuallyEmitState.value:
|
||||
|
||||
@@ -10,6 +10,8 @@ from .agent import Agent, AgentDict
|
||||
from .action import Action, ActionDict, ActionResultDict
|
||||
from .types import Message, MetaEvent
|
||||
from .exc import (
|
||||
CopilotKitError,
|
||||
CopilotKitMisuseError,
|
||||
ActionNotFoundException,
|
||||
AgentNotFoundException,
|
||||
ActionExecutionException,
|
||||
|
||||
@@ -4,6 +4,8 @@ Covers:
|
||||
1. LangGraph variant: default UUID generation, custom ID passthrough, return value
|
||||
2. CrewAI variant: default UUID generation, custom ID passthrough, return value
|
||||
3. AG-UI agent dispatch: custom ID propagates to all three TOOL_CALL events
|
||||
4. AG-UI dispatch validation: defensive CopilotKitMisuseError paths for
|
||||
missing/invalid id, name, args, non-serializable args, and non-dict value
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -163,6 +165,43 @@ class TestLangGraphEmitToolCallOptionalId:
|
||||
config, name="Tool", args={}, tool_call_id=" "
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitespace_only_name_raises(self):
|
||||
"""Passing a whitespace-only name should raise CopilotKitMisuseError."""
|
||||
with patch(
|
||||
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
|
||||
):
|
||||
from copilotkit.langgraph import copilotkit_emit_tool_call
|
||||
|
||||
config = {"metadata": {}}
|
||||
with pytest.raises(CopilotKitMisuseError, match="non-empty string"):
|
||||
await copilotkit_emit_tool_call(config, name=" ", args={})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_name_raises(self):
|
||||
"""Passing an empty name should raise CopilotKitMisuseError."""
|
||||
with patch(
|
||||
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
|
||||
):
|
||||
from copilotkit.langgraph import copilotkit_emit_tool_call
|
||||
|
||||
config = {"metadata": {}}
|
||||
with pytest.raises(CopilotKitMisuseError, match="non-empty string"):
|
||||
await copilotkit_emit_tool_call(config, name="", args={})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_dict_args_raises(self):
|
||||
"""Passing non-dict args should raise CopilotKitMisuseError."""
|
||||
with patch(
|
||||
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
|
||||
):
|
||||
from copilotkit.langgraph import copilotkit_emit_tool_call
|
||||
|
||||
config = {"metadata": {}}
|
||||
for bad_args in [None, [1, 2], "raw", 42]:
|
||||
with pytest.raises(CopilotKitMisuseError, match="must be a dict"):
|
||||
await copilotkit_emit_tool_call(config, name="Tool", args=bad_args)
|
||||
|
||||
|
||||
# ---- CrewAI variant tests ----
|
||||
|
||||
@@ -254,6 +293,39 @@ class TestCrewAIEmitToolCallOptionalId:
|
||||
name="Tool", args={}, tool_call_id=" "
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_id_generates_uuid(self):
|
||||
"""Explicitly passing tool_call_id=None should behave the same as omitting it."""
|
||||
with patch(
|
||||
"copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock
|
||||
):
|
||||
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
|
||||
|
||||
result = await copilotkit_emit_tool_call(
|
||||
name="Tool", args={}, tool_call_id=None
|
||||
)
|
||||
assert isinstance(result, str)
|
||||
uuid.UUID(result)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitespace_only_name_raises(self):
|
||||
"""Passing a whitespace-only name should raise CopilotKitMisuseError."""
|
||||
with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock):
|
||||
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
|
||||
|
||||
with pytest.raises(CopilotKitMisuseError, match="non-empty string"):
|
||||
await copilotkit_emit_tool_call(name=" ", args={})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_dict_args_raises(self):
|
||||
"""Passing non-dict args should raise CopilotKitMisuseError."""
|
||||
with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock):
|
||||
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
|
||||
|
||||
for bad_args in [None, [1, 2], "raw", 42]:
|
||||
with pytest.raises(CopilotKitMisuseError, match="must be a dict"):
|
||||
await copilotkit_emit_tool_call(name="Tool", args=bad_args)
|
||||
|
||||
|
||||
# ---- AG-UI dispatch: custom ID propagates through all events ----
|
||||
|
||||
@@ -426,15 +498,45 @@ class TestAGUIDispatchValidation:
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={"id": "valid-id", "name": "Tool"},
|
||||
)
|
||||
with pytest.raises(CopilotKitMisuseError, match="missing 'args'"):
|
||||
with pytest.raises(CopilotKitMisuseError, match="must be a dict or pre-serialized"):
|
||||
agent._dispatch_event(event)
|
||||
|
||||
def test_non_serializable_args_raises(self, agent):
|
||||
"""Event with non-JSON-serializable args should raise CopilotKitMisuseError."""
|
||||
"""Event with non-JSON-serializable args (set) should raise CopilotKitMisuseError."""
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={"id": "valid-id", "name": "Tool", "args": {1, 2, 3}},
|
||||
)
|
||||
with pytest.raises(CopilotKitMisuseError, match="not JSON-serializable"):
|
||||
with pytest.raises(CopilotKitMisuseError, match="must be a dict or pre-serialized"):
|
||||
agent._dispatch_event(event)
|
||||
|
||||
def test_non_dict_value_raises(self, agent):
|
||||
"""Event with non-dict value should raise CopilotKitMisuseError."""
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value=None,
|
||||
)
|
||||
with pytest.raises(CopilotKitMisuseError, match="must be a dict"):
|
||||
agent._dispatch_event(event)
|
||||
|
||||
def test_list_args_raises(self, agent):
|
||||
"""Event with list args should raise CopilotKitMisuseError."""
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={"id": "valid-id", "name": "Tool", "args": [1, 2, 3]},
|
||||
)
|
||||
with pytest.raises(CopilotKitMisuseError, match="must be a dict or pre-serialized"):
|
||||
agent._dispatch_event(event)
|
||||
|
||||
def test_int_args_raises(self, agent):
|
||||
"""Event with int args should raise CopilotKitMisuseError."""
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={"id": "valid-id", "name": "Tool", "args": 42},
|
||||
)
|
||||
with pytest.raises(CopilotKitMisuseError, match="must be a dict or pre-serialized"):
|
||||
agent._dispatch_event(event)
|
||||
|
||||
Reference in New Issue
Block a user