Skip to content

Commit ca7ed42

Browse files
authored
feat(sdk): add optional id parameter to copilotkit_emit_tool_call (CopilotKit#4923)
## Summary - Adds an optional `tool_call_id` / `toolCallId` parameter to `copilotkit_emit_tool_call` across all three SDK variants (Python LangGraph, Python CrewAI, JS/TS) - When provided, the custom ID is used as the `toolCallId` in AG-UI protocol events; when omitted, a random UUID is generated (backwards compatible) - All variants now return the tool call ID (`str` / `Promise<string>`) instead of `True` / `void`, aligning the LangGraph Python and JS variants with CrewAI which already returned it - Introduces `CopilotKitError` base exception class and `CopilotKitMisuseError` for invalid API usage in the Python SDK - Adds compensating `TOOL_CALL_END` / `action_execution_end` dispatch when mid-stream failures occur, preventing stuck client UIs - Exports all exception types from the `copilotkit` package root ## Motivation Customer request — enables use cases including: - **Correlation across agent steps** — emit a tool call with a known ID and reference it later - **Idempotency / replay safety** — deterministic IDs prevent duplicate tool calls on LangGraph checkpoint replays - **External system linking** — use a job ID, trace span ID, or request correlation ID as the tool call ID - **Custom HITL flows** — programmatically approve/reject a specific tool call by predictable ID - **Testing** — deterministic IDs make snapshot/integration tests stable ## Behavioral changes - **JS dispatch errors are no longer wrapped in `CopilotKitMisuseError`**: Previously, `copilotkitEmitToolCall` wrapped any `dispatchCustomEvent` failure in a `CopilotKitMisuseError`. Transport/dispatch errors now propagate as their native error types. This is more correct — a dispatch failure is not a misuse error — but callers that specifically caught `CopilotKitMisuseError` for transport errors will need to update their catch clauses. - **Python return type changed**: `copilotkit_emit_tool_call` previously returned `True` (documented as `Awaitable[bool]`). It now returns `str` (the tool call ID). Code using truthiness checks (`if result:`) is unaffected; strict equality (`== True`) will break. ## Test plan - [x] 4 unit tests for LangGraph Python variant (default UUID, custom ID, return value, explicit None) - [x] 3 unit tests for CrewAI Python variant (skipped when crewai not installed) - [x] 3 integration tests for AG-UI dispatch (custom ID propagates to all TOOL_CALL events) - [x] All 26 existing `test_agui_agent.py` tests pass (no regressions) - [x] JS SDK builds cleanly (`nx run @copilotkit/sdk-js:build`) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents a1f5512 + dff634d commit ca7ed42

9 files changed

Lines changed: 1195 additions & 62 deletions

File tree

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

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
import { vi } from "vitest";
2+
import { dispatchCustomEvent } from "@langchain/core/callbacks/dispatch";
13
import {
24
copilotkitCustomizeConfig,
5+
copilotkitEmitToolCall,
36
convertActionsToDynamicStructuredTools,
47
convertActionToDynamicStructuredTool,
58
} from "../utils";
69

10+
vi.mock("@langchain/core/callbacks/dispatch", () => ({
11+
dispatchCustomEvent: vi.fn().mockResolvedValue(undefined),
12+
}));
13+
714
describe("copilotkitCustomizeConfig", () => {
815
it("returns config unchanged when no options provided", () => {
916
const baseConfig = { metadata: { existing: true } };
@@ -252,3 +259,105 @@ describe("convertActionsToDynamicStructuredTools", () => {
252259
);
253260
});
254261
});
262+
263+
describe("copilotkitEmitToolCall", () => {
264+
const mockConfig = { metadata: {} } as any;
265+
const mockedDispatch = vi.mocked(dispatchCustomEvent);
266+
267+
beforeEach(() => {
268+
mockedDispatch.mockClear();
269+
});
270+
271+
it("returns a generated id when no id is provided", async () => {
272+
const result = await copilotkitEmitToolCall(mockConfig, "SearchTool", {
273+
steps: 10,
274+
});
275+
276+
expect(typeof result).toBe("string");
277+
expect(result.length).toBeGreaterThan(0);
278+
expect(mockedDispatch).toHaveBeenCalledWith(
279+
"copilotkit_manually_emit_tool_call",
280+
{ name: "SearchTool", args: { steps: 10 }, id: result },
281+
mockConfig,
282+
);
283+
});
284+
285+
it("uses caller-provided toolCallId verbatim", async () => {
286+
const result = await copilotkitEmitToolCall(
287+
mockConfig,
288+
"SearchTool",
289+
{ steps: 10 },
290+
{ toolCallId: "custom-abc" },
291+
);
292+
293+
expect(result).toBe("custom-abc");
294+
expect(mockedDispatch).toHaveBeenCalledWith(
295+
"copilotkit_manually_emit_tool_call",
296+
{ name: "SearchTool", args: { steps: 10 }, id: "custom-abc" },
297+
mockConfig,
298+
);
299+
});
300+
301+
it("treats undefined options the same as omitted", async () => {
302+
const result = await copilotkitEmitToolCall(
303+
mockConfig,
304+
"SearchTool",
305+
{},
306+
undefined,
307+
);
308+
309+
expect(typeof result).toBe("string");
310+
expect(result.length).toBeGreaterThan(0);
311+
expect(mockedDispatch).toHaveBeenCalledWith(
312+
"copilotkit_manually_emit_tool_call",
313+
{ name: "SearchTool", args: {}, id: result },
314+
mockConfig,
315+
);
316+
});
317+
318+
it("throws on empty string toolCallId", async () => {
319+
await expect(
320+
copilotkitEmitToolCall(mockConfig, "SearchTool", {}, { toolCallId: "" }),
321+
).rejects.toThrow("non-empty string");
322+
});
323+
324+
it("throws on whitespace-only toolCallId", async () => {
325+
await expect(
326+
copilotkitEmitToolCall(
327+
mockConfig,
328+
"SearchTool",
329+
{},
330+
{ toolCallId: " " },
331+
),
332+
).rejects.toThrow("non-empty string");
333+
});
334+
335+
it("throws on whitespace-only name", async () => {
336+
await expect(copilotkitEmitToolCall(mockConfig, " ", {})).rejects.toThrow(
337+
"non-empty string",
338+
);
339+
});
340+
341+
it("throws on undefined args", async () => {
342+
await expect(
343+
copilotkitEmitToolCall(mockConfig, "SearchTool", undefined),
344+
).rejects.toThrow("required");
345+
});
346+
347+
it("throws on non-JSON-serializable args", async () => {
348+
const circular: any = {};
349+
circular.self = circular;
350+
await expect(
351+
copilotkitEmitToolCall(mockConfig, "SearchTool", circular),
352+
).rejects.toThrow("not JSON-serializable");
353+
});
354+
355+
it("propagates dispatch errors with tool-call context", async () => {
356+
mockedDispatch.mockRejectedValueOnce(new Error("transport closed"));
357+
await expect(
358+
copilotkitEmitToolCall(mockConfig, "SearchTool", {}),
359+
).rejects.toThrow(
360+
/copilotkitEmitToolCall dispatch failed.*SearchTool.*transport closed/,
361+
);
362+
});
363+
});

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

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
import { RunnableConfig } from "@langchain/core/runnables";
1+
import type { RunnableConfig } from "@langchain/core/runnables";
22
import { dispatchCustomEvent } from "@langchain/core/callbacks/dispatch";
33
import {
44
convertJsonSchemaToZodSchema,
55
randomId,
6+
randomUUID,
67
CopilotKitMisuseError,
78
} from "@copilotkit/shared";
89
import { interrupt } from "@langchain/langgraph";
910
import { DynamicStructuredTool } from "@langchain/core/tools";
1011
import { AIMessage } from "@langchain/core/messages";
11-
import { OptionsConfig } from "./types";
12+
import type { OptionsConfig } from "./types";
1213

1314
/**
1415
* Customize the LangGraph configuration for use in CopilotKit.
@@ -301,8 +302,14 @@ export async function copilotkitEmitMessage(
301302
* ```typescript
302303
* import { copilotkitEmitToolCall } from "@copilotkit/sdk-js";
303304
*
304-
* await copilotkitEmitToolCall(config, name="SearchTool", args={"steps": 10})
305+
* const autoId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 });
306+
*
307+
* // With a custom ID for correlation/idempotency:
308+
* const customId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }, { toolCallId: "my-custom-id" });
305309
* ```
310+
*
311+
* @returns The tool call ID used for the emitted call — equals `options.toolCallId`
312+
* when provided, otherwise a randomly generated ID.
306313
*/
307314
export async function copilotkitEmitToolCall(
308315
/**
@@ -317,37 +324,73 @@ export async function copilotkitEmitToolCall(
317324
* The arguments to emit.
318325
*/
319326
args: any,
320-
) {
327+
/**
328+
* Options for the tool call emission.
329+
*/
330+
options?: {
331+
/**
332+
* Optional tool call ID. If not provided, a random ID is generated.
333+
* When provided, this ID is used as the toolCallId and parentMessageId
334+
* in AG-UI protocol events. The caller is responsible for ensuring uniqueness.
335+
*/
336+
toolCallId?: string;
337+
},
338+
): Promise<string> {
321339
if (!config) {
322340
throw new CopilotKitMisuseError({
323341
message: "LangGraph configuration is required for copilotkitEmitToolCall",
324342
});
325343
}
326344

327-
if (!name || typeof name !== "string") {
345+
if (typeof name !== "string" || name.trim().length === 0) {
328346
throw new CopilotKitMisuseError({
329347
message:
330348
"Tool name must be a non-empty string for copilotkitEmitToolCall",
331349
});
332350
}
333351

352+
if (
353+
options?.toolCallId !== undefined &&
354+
(typeof options.toolCallId !== "string" ||
355+
options.toolCallId.trim().length === 0)
356+
) {
357+
throw new CopilotKitMisuseError({
358+
message:
359+
"Tool call id must be a non-empty string when provided for copilotkitEmitToolCall",
360+
});
361+
}
362+
334363
if (args === undefined) {
335364
throw new CopilotKitMisuseError({
336365
message: "Tool arguments are required for copilotkitEmitToolCall",
337366
});
338367
}
339368

369+
try {
370+
JSON.stringify(args);
371+
} catch (error) {
372+
throw new CopilotKitMisuseError({
373+
message: `Tool arguments for '${name}' are not JSON-serializable: ${error instanceof Error ? error.message : String(error)}`,
374+
});
375+
}
376+
377+
const toolCallId = options?.toolCallId ?? randomUUID();
378+
340379
try {
341380
await dispatchCustomEvent(
342381
"copilotkit_manually_emit_tool_call",
343-
{ name, args, id: randomId() },
382+
{ name, args, id: toolCallId },
344383
config,
345384
);
346385
} catch (error) {
347-
throw new CopilotKitMisuseError({
348-
message: `Failed to emit tool call '${name}': ${error instanceof Error ? error.message : String(error)}`,
349-
});
386+
const wrapped = new Error(
387+
`copilotkitEmitToolCall dispatch failed for tool="${name}" id="${toolCallId}": ${error instanceof Error ? error.message : String(error)}`,
388+
);
389+
(wrapped as any).cause = error;
390+
throw wrapped;
350391
}
392+
393+
return toolCallId;
351394
}
352395

353396
export function convertActionToDynamicStructuredTool(

sdk-python/copilotkit/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@
77
CopilotKitSDKContext,
88
)
99
from .action import Action
10+
from .exc import (
11+
CopilotKitError,
12+
CopilotKitMisuseError,
13+
ActionNotFoundException,
14+
AgentNotFoundException,
15+
ActionExecutionException,
16+
AgentExecutionException,
17+
)
1018
from .langgraph import CopilotKitState
1119
from .parameter import Parameter
1220
from .agent import Agent
@@ -32,6 +40,12 @@
3240
"Agent",
3341
"CopilotKitContext",
3442
"CopilotKitSDKContext",
43+
"CopilotKitError",
44+
"CopilotKitMisuseError",
45+
"ActionNotFoundException",
46+
"AgentNotFoundException",
47+
"ActionExecutionException",
48+
"AgentExecutionException",
3549
"CrewAIAgent", # pyright: ignore[reportUnsupportedDunderAll] pylint: disable=undefined-all-variable
3650
"LangGraphAGUIAgent",
3751
"CopilotKitMiddleware",

sdk-python/copilotkit/crewai/crewai_sdk.py

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import uuid
66
import json
77
import asyncio
8-
from typing_extensions import Any, Dict, List, Literal
8+
from typing_extensions import Any, Dict, List, Literal, Optional
9+
from copilotkit.exc import CopilotKitMisuseError
910
from pydantic import BaseModel, Field
1011
from litellm.types.utils import (
1112
ModelResponse,
@@ -226,14 +227,19 @@ async def copilotkit_emit_message(message: str) -> str:
226227
return message_id
227228

228229

229-
async def copilotkit_emit_tool_call(*, name: str, args: Dict[str, Any]) -> str:
230+
async def copilotkit_emit_tool_call(
231+
*, name: str, args: Dict[str, Any], tool_call_id: Optional[str] = None
232+
) -> str:
230233
"""
231234
Manually emits a tool call to CopilotKit.
232235
233236
```python
234237
from copilotkit.crewai import copilotkit_emit_tool_call
235238
236-
await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10})
239+
auto_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10})
240+
241+
# With a custom ID for correlation/idempotency:
242+
custom_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id")
237243
```
238244
239245
Parameters
@@ -242,22 +248,57 @@ async def copilotkit_emit_tool_call(*, name: str, args: Dict[str, Any]) -> str:
242248
The name of the tool to emit.
243249
args : Dict[str, Any]
244250
The arguments to emit.
251+
tool_call_id : Optional[str]
252+
Optional tool call ID. If not provided, a random UUID is generated.
253+
When provided, this ID is used as both the toolCallId and
254+
parentMessageId in AG-UI protocol events.
255+
The caller is responsible for ensuring uniqueness.
245256
246257
Returns
247258
-------
248-
Awaitable[bool]
249-
Always return True.
259+
str
260+
The tool call ID used for the emitted tool call.
250261
"""
251-
message_id = str(uuid.uuid4())
252-
await queue_put(
253-
action_execution_start(
254-
action_execution_id=message_id,
255-
action_name=name,
256-
parent_message_id=message_id,
257-
),
258-
action_execution_args(action_execution_id=message_id, args=json.dumps(args)),
259-
action_execution_end(action_execution_id=message_id),
260-
)
262+
if not isinstance(name, str) or not name.strip():
263+
raise CopilotKitMisuseError(
264+
"Tool name must be a non-empty string for copilotkit_emit_tool_call"
265+
)
266+
267+
if tool_call_id is not None:
268+
if not isinstance(tool_call_id, str) or not tool_call_id.strip():
269+
raise CopilotKitMisuseError(
270+
"Tool call id must be a non-empty string when provided for copilotkit_emit_tool_call"
271+
)
272+
try:
273+
args_json = json.dumps(args)
274+
except (TypeError, ValueError) as e:
275+
raise CopilotKitMisuseError(
276+
f"Tool arguments for '{name}' are not JSON-serializable: {e}"
277+
) from e
278+
279+
message_id = tool_call_id if tool_call_id is not None else str(uuid.uuid4())
280+
try:
281+
await queue_put(
282+
action_execution_start(
283+
action_execution_id=message_id,
284+
action_name=name,
285+
parent_message_id=message_id,
286+
),
287+
action_execution_args(action_execution_id=message_id, args=args_json),
288+
action_execution_end(action_execution_id=message_id),
289+
)
290+
except Exception:
291+
try:
292+
await queue_put(
293+
action_execution_end(action_execution_id=message_id),
294+
)
295+
except Exception:
296+
logger.error(
297+
"Failed to emit compensating action_execution_end for %s",
298+
message_id,
299+
exc_info=True,
300+
)
301+
raise
261302

262303
return message_id
263304

0 commit comments

Comments
 (0)