Skip to content

Commit be9b60c

Browse files
mxmzbclaude
andcommitted
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>
1 parent d5cc135 commit be9b60c

7 files changed

Lines changed: 181 additions & 39 deletions

File tree

packages/sdk-js/src/langgraph/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,10 +301,10 @@ export async function copilotkitEmitMessage(
301301
* ```typescript
302302
* import { copilotkitEmitToolCall } from "@copilotkit/sdk-js";
303303
*
304-
* const toolCallId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 });
304+
* const autoId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 });
305305
*
306306
* // With a custom ID for correlation/idempotency:
307-
* const toolCallId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }, { toolCallId: "my-custom-id" });
307+
* const customId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }, { toolCallId: "my-custom-id" });
308308
* ```
309309
*/
310310
export async function copilotkitEmitToolCall(

sdk-python/copilotkit/crewai/crewai_sdk.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,10 @@ async def copilotkit_emit_tool_call(
236236
```python
237237
from copilotkit.crewai import copilotkit_emit_tool_call
238238
239-
tool_call_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10})
239+
auto_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10})
240240
241241
# With a custom ID for correlation/idempotency:
242-
tool_call_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id")
242+
custom_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id")
243243
```
244244
245245
Parameters

sdk-python/copilotkit/exc.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,32 @@
11
"""Exceptions for CopilotKit."""
22

33

4-
class ActionNotFoundException(Exception):
4+
class CopilotKitError(Exception):
5+
"""Base exception for all CopilotKit errors.
6+
7+
Catch this to handle any CopilotKit-specific exception.
8+
"""
9+
10+
pass
11+
12+
13+
class ActionNotFoundException(CopilotKitError):
514
"""Exception raised when an action or agent is not found."""
615

716
def __init__(self, name: str):
817
self.name = name
918
super().__init__(f"Action '{name}' not found.")
1019

1120

12-
class AgentNotFoundException(Exception):
21+
class AgentNotFoundException(CopilotKitError):
1322
"""Exception raised when an agent is not found."""
1423

1524
def __init__(self, name: str):
1625
self.name = name
1726
super().__init__(f"Agent '{name}' not found.")
1827

1928

20-
class ActionExecutionException(Exception):
29+
class ActionExecutionException(CopilotKitError):
2130
"""Exception raised when an action fails to execute."""
2231

2332
def __init__(self, name: str, error: Exception):
@@ -26,7 +35,7 @@ def __init__(self, name: str, error: Exception):
2635
super().__init__(f"Action '{name}' failed to execute: {error}")
2736

2837

29-
class AgentExecutionException(Exception):
38+
class AgentExecutionException(CopilotKitError):
3039
"""Exception raised when an agent fails to execute."""
3140

3241
def __init__(self, name: str, error: Exception):
@@ -35,10 +44,11 @@ def __init__(self, name: str, error: Exception):
3544
super().__init__(f"Agent '{name}' failed to execute: {error}")
3645

3746

38-
class CopilotKitMisuseError(ValueError):
47+
class CopilotKitMisuseError(CopilotKitError, ValueError):
3948
"""Exception raised when CopilotKit detects incorrect usage of its APIs.
4049
41-
Subclasses ValueError for backward compatibility with existing handlers.
50+
Inherits from both CopilotKitError (for ``except CopilotKitError``) and
51+
ValueError (for backward compatibility with ``except ValueError`` handlers).
4252
"""
4353

4454
pass

sdk-python/copilotkit/langgraph.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -387,10 +387,10 @@ async def copilotkit_emit_tool_call(
387387
```python
388388
from copilotkit.langgraph import copilotkit_emit_tool_call
389389
390-
tool_call_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10})
390+
auto_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10})
391391
392392
# With a custom ID for correlation/idempotency:
393-
tool_call_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id")
393+
custom_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id")
394394
```
395395
396396
Parameters

sdk-python/copilotkit/langgraph_agui_agent.py

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import json
2+
import logging
23
from typing import Dict, Any, List, Optional, Union, AsyncGenerator
34
from enum import Enum
45
from .exc import CopilotKitMisuseError
6+
7+
logger = logging.getLogger(__name__)
58
from ag_ui_langgraph import LangGraphAgent
69
from ag_ui.core import (
710
EventType,
@@ -106,6 +109,11 @@ def _dispatch_event(self, event) -> str:
106109

107110
if custom_event.name == CustomEventNames.ManuallyEmitToolCall.value:
108111
value = custom_event.value
112+
if not isinstance(value, dict):
113+
raise CopilotKitMisuseError(
114+
f"ManuallyEmitToolCall event 'value' must be a dict, got {type(value).__name__}"
115+
)
116+
109117
tool_call_id = value.get("id")
110118
tool_call_name = value.get("name")
111119
tool_call_args = value.get("args")
@@ -118,9 +126,10 @@ def _dispatch_event(self, event) -> str:
118126
raise CopilotKitMisuseError(
119127
f"ManuallyEmitToolCall event missing valid 'name': got {type(tool_call_name).__name__}"
120128
)
121-
if tool_call_args is None:
129+
if not isinstance(tool_call_args, (dict, str)):
122130
raise CopilotKitMisuseError(
123-
"ManuallyEmitToolCall event missing 'args'"
131+
f"ManuallyEmitToolCall 'args' must be a dict or pre-serialized JSON string, "
132+
f"got {type(tool_call_args).__name__} for tool_call_id={tool_call_id}"
124133
)
125134

126135
try:
@@ -129,35 +138,54 @@ def _dispatch_event(self, event) -> str:
129138
if isinstance(tool_call_args, str)
130139
else json.dumps(tool_call_args)
131140
)
132-
except (TypeError, ValueError) as e:
141+
except Exception as e:
133142
raise CopilotKitMisuseError(
134143
f"ManuallyEmitToolCall 'args' is not JSON-serializable for tool_call_id={tool_call_id}: {e}"
135144
) from e
136145

137-
super()._dispatch_event(
138-
ToolCallStartEvent(
139-
type=EventType.TOOL_CALL_START,
140-
tool_call_id=tool_call_id,
141-
tool_call_name=tool_call_name,
142-
parent_message_id=tool_call_id,
143-
raw_event=event,
146+
dispatched_start = False
147+
try:
148+
super()._dispatch_event(
149+
ToolCallStartEvent(
150+
type=EventType.TOOL_CALL_START,
151+
tool_call_id=tool_call_id,
152+
tool_call_name=tool_call_name,
153+
parent_message_id=tool_call_id,
154+
raw_event=event,
155+
)
144156
)
145-
)
146-
super()._dispatch_event(
147-
ToolCallArgsEvent(
148-
type=EventType.TOOL_CALL_ARGS,
149-
tool_call_id=tool_call_id,
150-
delta=delta,
151-
raw_event=event,
157+
dispatched_start = True
158+
super()._dispatch_event(
159+
ToolCallArgsEvent(
160+
type=EventType.TOOL_CALL_ARGS,
161+
tool_call_id=tool_call_id,
162+
delta=delta,
163+
raw_event=event,
164+
)
152165
)
153-
)
154-
super()._dispatch_event(
155-
ToolCallEndEvent(
156-
type=EventType.TOOL_CALL_END,
157-
tool_call_id=tool_call_id,
158-
raw_event=event,
166+
super()._dispatch_event(
167+
ToolCallEndEvent(
168+
type=EventType.TOOL_CALL_END,
169+
tool_call_id=tool_call_id,
170+
raw_event=event,
171+
)
159172
)
160-
)
173+
except Exception:
174+
if dispatched_start:
175+
try:
176+
super()._dispatch_event(
177+
ToolCallEndEvent(
178+
type=EventType.TOOL_CALL_END,
179+
tool_call_id=tool_call_id,
180+
raw_event=event,
181+
)
182+
)
183+
except Exception as close_err:
184+
logger.error(
185+
"Failed to emit compensating TOOL_CALL_END for %s: %s",
186+
tool_call_id, close_err,
187+
)
188+
raise
161189
return super()._dispatch_event(event)
162190

163191
if custom_event.name == CustomEventNames.ManuallyEmitState.value:

sdk-python/copilotkit/sdk.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from .action import Action, ActionDict, ActionResultDict
1111
from .types import Message, MetaEvent
1212
from .exc import (
13+
CopilotKitError,
14+
CopilotKitMisuseError,
1315
ActionNotFoundException,
1416
AgentNotFoundException,
1517
ActionExecutionException,

sdk-python/tests/test_emit_tool_call_optional_id.py

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
1. LangGraph variant: default UUID generation, custom ID passthrough, return value
55
2. CrewAI variant: default UUID generation, custom ID passthrough, return value
66
3. AG-UI agent dispatch: custom ID propagates to all three TOOL_CALL events
7+
4. AG-UI dispatch validation: defensive CopilotKitMisuseError paths for
8+
missing/invalid id, name, args, non-serializable args, and non-dict value
79
"""
810

911
import json
@@ -163,6 +165,43 @@ async def test_whitespace_only_id_raises(self):
163165
config, name="Tool", args={}, tool_call_id=" "
164166
)
165167

168+
@pytest.mark.asyncio
169+
async def test_whitespace_only_name_raises(self):
170+
"""Passing a whitespace-only name should raise CopilotKitMisuseError."""
171+
with patch(
172+
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
173+
):
174+
from copilotkit.langgraph import copilotkit_emit_tool_call
175+
176+
config = {"metadata": {}}
177+
with pytest.raises(CopilotKitMisuseError, match="non-empty string"):
178+
await copilotkit_emit_tool_call(config, name=" ", args={})
179+
180+
@pytest.mark.asyncio
181+
async def test_empty_name_raises(self):
182+
"""Passing an empty name should raise CopilotKitMisuseError."""
183+
with patch(
184+
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
185+
):
186+
from copilotkit.langgraph import copilotkit_emit_tool_call
187+
188+
config = {"metadata": {}}
189+
with pytest.raises(CopilotKitMisuseError, match="non-empty string"):
190+
await copilotkit_emit_tool_call(config, name="", args={})
191+
192+
@pytest.mark.asyncio
193+
async def test_non_dict_args_raises(self):
194+
"""Passing non-dict args should raise CopilotKitMisuseError."""
195+
with patch(
196+
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
197+
):
198+
from copilotkit.langgraph import copilotkit_emit_tool_call
199+
200+
config = {"metadata": {}}
201+
for bad_args in [None, [1, 2], "raw", 42]:
202+
with pytest.raises(CopilotKitMisuseError, match="must be a dict"):
203+
await copilotkit_emit_tool_call(config, name="Tool", args=bad_args)
204+
166205

167206
# ---- CrewAI variant tests ----
168207

@@ -254,6 +293,39 @@ async def test_whitespace_only_id_raises(self):
254293
name="Tool", args={}, tool_call_id=" "
255294
)
256295

296+
@pytest.mark.asyncio
297+
async def test_none_id_generates_uuid(self):
298+
"""Explicitly passing tool_call_id=None should behave the same as omitting it."""
299+
with patch(
300+
"copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock
301+
):
302+
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
303+
304+
result = await copilotkit_emit_tool_call(
305+
name="Tool", args={}, tool_call_id=None
306+
)
307+
assert isinstance(result, str)
308+
uuid.UUID(result)
309+
310+
@pytest.mark.asyncio
311+
async def test_whitespace_only_name_raises(self):
312+
"""Passing a whitespace-only name should raise CopilotKitMisuseError."""
313+
with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock):
314+
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
315+
316+
with pytest.raises(CopilotKitMisuseError, match="non-empty string"):
317+
await copilotkit_emit_tool_call(name=" ", args={})
318+
319+
@pytest.mark.asyncio
320+
async def test_non_dict_args_raises(self):
321+
"""Passing non-dict args should raise CopilotKitMisuseError."""
322+
with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock):
323+
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
324+
325+
for bad_args in [None, [1, 2], "raw", 42]:
326+
with pytest.raises(CopilotKitMisuseError, match="must be a dict"):
327+
await copilotkit_emit_tool_call(name="Tool", args=bad_args)
328+
257329

258330
# ---- AG-UI dispatch: custom ID propagates through all events ----
259331

@@ -426,15 +498,45 @@ def test_missing_args_raises(self, agent):
426498
name=CustomEventNames.ManuallyEmitToolCall.value,
427499
value={"id": "valid-id", "name": "Tool"},
428500
)
429-
with pytest.raises(CopilotKitMisuseError, match="missing 'args'"):
501+
with pytest.raises(CopilotKitMisuseError, match="must be a dict or pre-serialized"):
430502
agent._dispatch_event(event)
431503

432504
def test_non_serializable_args_raises(self, agent):
433-
"""Event with non-JSON-serializable args should raise CopilotKitMisuseError."""
505+
"""Event with non-JSON-serializable args (set) should raise CopilotKitMisuseError."""
434506
event = CustomEvent(
435507
type=EventType.CUSTOM,
436508
name=CustomEventNames.ManuallyEmitToolCall.value,
437509
value={"id": "valid-id", "name": "Tool", "args": {1, 2, 3}},
438510
)
439-
with pytest.raises(CopilotKitMisuseError, match="not JSON-serializable"):
511+
with pytest.raises(CopilotKitMisuseError, match="must be a dict or pre-serialized"):
512+
agent._dispatch_event(event)
513+
514+
def test_non_dict_value_raises(self, agent):
515+
"""Event with non-dict value should raise CopilotKitMisuseError."""
516+
event = CustomEvent(
517+
type=EventType.CUSTOM,
518+
name=CustomEventNames.ManuallyEmitToolCall.value,
519+
value=None,
520+
)
521+
with pytest.raises(CopilotKitMisuseError, match="must be a dict"):
522+
agent._dispatch_event(event)
523+
524+
def test_list_args_raises(self, agent):
525+
"""Event with list args should raise CopilotKitMisuseError."""
526+
event = CustomEvent(
527+
type=EventType.CUSTOM,
528+
name=CustomEventNames.ManuallyEmitToolCall.value,
529+
value={"id": "valid-id", "name": "Tool", "args": [1, 2, 3]},
530+
)
531+
with pytest.raises(CopilotKitMisuseError, match="must be a dict or pre-serialized"):
532+
agent._dispatch_event(event)
533+
534+
def test_int_args_raises(self, agent):
535+
"""Event with int args should raise CopilotKitMisuseError."""
536+
event = CustomEvent(
537+
type=EventType.CUSTOM,
538+
name=CustomEventNames.ManuallyEmitToolCall.value,
539+
value={"id": "valid-id", "name": "Tool", "args": 42},
540+
)
541+
with pytest.raises(CopilotKitMisuseError, match="must be a dict or pre-serialized"):
440542
agent._dispatch_event(event)

0 commit comments

Comments
 (0)