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:
Maxim
2026-05-19 20:24:29 +02:00
parent d5cc135c5c
commit be9b60c8a9
7 changed files with 181 additions and 39 deletions
+2 -2
View File
@@ -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(
+2 -2
View File
@@ -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
+16 -6
View File
@@ -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
+2 -2
View File
@@ -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
+52 -24
View File
@@ -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:
+2
View File
@@ -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)