copilotkit 0.1.91__tar.gz → 0.1.92__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {copilotkit-0.1.91 → copilotkit-0.1.92}/PKG-INFO +1 -1
  2. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/__init__.py +14 -0
  3. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/crewai/crewai_sdk.py +56 -15
  4. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/exc.py +23 -4
  5. copilotkit-0.1.92/copilotkit/header_propagation.py +201 -0
  6. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/langgraph.py +52 -9
  7. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/langgraph_agui_agent.py +86 -26
  8. {copilotkit-0.1.91 → copilotkit-0.1.92}/pyproject.toml +2 -2
  9. copilotkit-0.1.91/copilotkit/header_propagation.py +0 -59
  10. {copilotkit-0.1.91 → copilotkit-0.1.92}/README.md +0 -0
  11. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/a2ui.py +0 -0
  12. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/action.py +0 -0
  13. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/agent.py +0 -0
  14. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/copilotkit_lg_middleware.py +0 -0
  15. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/crewai/__init__.py +0 -0
  16. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/crewai/copilotkit_integration.py +0 -0
  17. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/crewai/crewai_agent.py +0 -0
  18. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/html.py +0 -0
  19. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/integrations/__init__.py +0 -0
  20. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/integrations/fastapi.py +0 -0
  21. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/langchain.py +0 -0
  22. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/logging.py +0 -0
  23. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/parameter.py +0 -0
  24. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/protocol.py +0 -0
  25. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/py.typed +0 -0
  26. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/runloop.py +0 -0
  27. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/sdk.py +0 -0
  28. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/types.py +0 -0
  29. {copilotkit-0.1.91 → copilotkit-0.1.92}/copilotkit/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: copilotkit
3
- Version: 0.1.91
3
+ Version: 0.1.92
4
4
  Summary: CopilotKit python SDK
5
5
  License: MIT
6
6
  Keywords: copilot,copilotkit,langgraph,langchain,ai,langsmith,langserve
@@ -7,6 +7,14 @@ from .sdk import (
7
7
  CopilotKitSDKContext,
8
8
  )
9
9
  from .action import Action
10
+ from .exc import (
11
+ CopilotKitError,
12
+ CopilotKitMisuseError,
13
+ ActionNotFoundException,
14
+ AgentNotFoundException,
15
+ ActionExecutionException,
16
+ AgentExecutionException,
17
+ )
10
18
  from .langgraph import CopilotKitState
11
19
  from .parameter import Parameter
12
20
  from .agent import Agent
@@ -32,6 +40,12 @@ __all__ = [
32
40
  "Agent",
33
41
  "CopilotKitContext",
34
42
  "CopilotKitSDKContext",
43
+ "CopilotKitError",
44
+ "CopilotKitMisuseError",
45
+ "ActionNotFoundException",
46
+ "AgentNotFoundException",
47
+ "ActionExecutionException",
48
+ "AgentExecutionException",
35
49
  "CrewAIAgent", # pyright: ignore[reportUnsupportedDunderAll] pylint: disable=undefined-all-variable
36
50
  "LangGraphAGUIAgent",
37
51
  "CopilotKitMiddleware",
@@ -5,7 +5,8 @@ CrewAI integration for CopilotKit
5
5
  import uuid
6
6
  import json
7
7
  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
9
10
  from pydantic import BaseModel, Field
10
11
  from litellm.types.utils import (
11
12
  ModelResponse,
@@ -226,14 +227,19 @@ async def copilotkit_emit_message(message: str) -> str:
226
227
  return message_id
227
228
 
228
229
 
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:
230
233
  """
231
234
  Manually emits a tool call to CopilotKit.
232
235
 
233
236
  ```python
234
237
  from copilotkit.crewai import copilotkit_emit_tool_call
235
238
 
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")
237
243
  ```
238
244
 
239
245
  Parameters
@@ -242,22 +248,57 @@ async def copilotkit_emit_tool_call(*, name: str, args: Dict[str, Any]) -> str:
242
248
  The name of the tool to emit.
243
249
  args : Dict[str, Any]
244
250
  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.
245
256
 
246
257
  Returns
247
258
  -------
248
- Awaitable[bool]
249
- Always return True.
259
+ str
260
+ The tool call ID used for the emitted tool call.
250
261
  """
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
261
302
 
262
303
  return message_id
263
304
 
@@ -1,7 +1,16 @@
1
1
  """Exceptions for CopilotKit."""
2
2
 
3
3
 
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):
5
14
  """Exception raised when an action or agent is not found."""
6
15
 
7
16
  def __init__(self, name: str):
@@ -9,7 +18,7 @@ class ActionNotFoundException(Exception):
9
18
  super().__init__(f"Action '{name}' not found.")
10
19
 
11
20
 
12
- class AgentNotFoundException(Exception):
21
+ class AgentNotFoundException(CopilotKitError):
13
22
  """Exception raised when an agent is not found."""
14
23
 
15
24
  def __init__(self, name: str):
@@ -17,7 +26,7 @@ class AgentNotFoundException(Exception):
17
26
  super().__init__(f"Agent '{name}' not found.")
18
27
 
19
28
 
20
- class ActionExecutionException(Exception):
29
+ class ActionExecutionException(CopilotKitError):
21
30
  """Exception raised when an action fails to execute."""
22
31
 
23
32
  def __init__(self, name: str, error: Exception):
@@ -26,10 +35,20 @@ class ActionExecutionException(Exception):
26
35
  super().__init__(f"Action '{name}' failed to execute: {error}")
27
36
 
28
37
 
29
- class AgentExecutionException(Exception):
38
+ class AgentExecutionException(CopilotKitError):
30
39
  """Exception raised when an agent fails to execute."""
31
40
 
32
41
  def __init__(self, name: str, error: Exception):
33
42
  self.name = name
34
43
  self.error = error
35
44
  super().__init__(f"Agent '{name}' failed to execute: {error}")
45
+
46
+
47
+ class CopilotKitMisuseError(CopilotKitError, ValueError):
48
+ """Exception raised when CopilotKit detects incorrect usage of its APIs.
49
+
50
+ Inherits from both CopilotKitError (for ``except CopilotKitError``) and
51
+ ValueError (for backward compatibility with ``except ValueError`` handlers).
52
+ """
53
+
54
+ pass
@@ -0,0 +1,201 @@
1
+ """Forward CopilotKit request-context headers onto outbound LLM/provider HTTP calls
2
+ so downstream services (e.g. the aimock test server, proxies, request routing /
3
+ fixture-matching infrastructure) can correlate the outbound provider call with the
4
+ original inbound request.
5
+
6
+ What this module does
7
+ ---------------------
8
+ On each inbound request the application stores a small set of ``x-*`` prefixed
9
+ headers (for example ``x-aimock-context``, ``x-aimock-session``, ``x-request-id``,
10
+ ``x-trace-id``) on a per-request ``contextvars.ContextVar``. When the application
11
+ later makes an outbound HTTP call to an LLM provider (OpenAI, Anthropic, or any
12
+ client that wraps ``httpx``), an httpx request event hook reads that ContextVar
13
+ and copies those same headers onto the outbound request so downstream services
14
+ can correlate the two.
15
+
16
+ This is plain header propagation, not data collection. Scope and limits:
17
+
18
+ * Only headers the application itself set on the request context via
19
+ ``set_forwarded_headers`` are forwarded. The module never reads request
20
+ bodies, cookies, user data, credentials, or anything off the inbound
21
+ request beyond the headers explicitly handed to it.
22
+ * Only ``x-*`` prefixed headers pass the filter; ``authorization``,
23
+ ``content-type``, and any other non ``x-*`` headers are dropped.
24
+ * Nothing is collected, persisted, logged, or sent anywhere by this module
25
+ itself — it only attaches headers to an HTTP request that the caller was
26
+ already going to make. There is no telemetry, no out-of-band channel, and
27
+ no end-user data flow.
28
+
29
+ Mechanics
30
+ ---------
31
+ ``install_httpx_hook`` does two small things:
32
+
33
+ 1. It walks the ``._client`` chain on the given object (modern provider SDKs
34
+ wrap their httpx client behind several layers of ``._client``) to find the
35
+ first object that exposes an httpx-style ``event_hooks`` mapping.
36
+ 2. It attaches a request event hook to that mapping. The hook flavor matches
37
+ the client: an async coroutine hook for ``httpx.AsyncClient`` (httpx awaits
38
+ request hooks on async clients), and a plain sync hook for ``httpx.Client``.
39
+ Installation is idempotent via a marker attribute on the installed callable.
40
+
41
+ This mirrors the CopilotKit runtime's ``extractForwardableHeaders()`` behavior
42
+ on the Node side so the Python SDK forwards the same set of context headers.
43
+ """
44
+
45
+ import contextvars
46
+ import warnings
47
+ from typing import Any, Dict, Optional
48
+
49
+ # Per-request storage for the set of headers the application has asked to forward
50
+ # onto outbound LLM/provider calls (populated by ``set_forwarded_headers``).
51
+ _forwarded_headers: contextvars.ContextVar[Dict[str, str]] = contextvars.ContextVar(
52
+ "copilotkit_forwarded_headers"
53
+ )
54
+
55
+ # Marker used to identify hooks we have already installed, so install_httpx_hook
56
+ # is idempotent across repeated calls on the same client.
57
+ _HOOK_MARKER = "_copilotkit_forwarded_header_hook"
58
+
59
+ # Bound on how deep we'll walk a ``._client`` chain looking for event_hooks.
60
+ # The modern OpenAI SDK shape is:
61
+ # ChatOpenAI.client -> Completions/AsyncCompletions resource
62
+ # -> ._client = openai.OpenAI / AsyncOpenAI (no event_hooks)
63
+ # -> ._client._client = httpx wrapper (HAS event_hooks)
64
+ # 5 hops is plenty of headroom for similar SDKs without risking pathological loops.
65
+ _MAX_CHAIN_DEPTH = 5
66
+
67
+
68
+ def set_forwarded_headers(headers: Dict[str, str]) -> None:
69
+ """Record the set of headers to forward onto outbound LLM/provider calls
70
+ made later in this request context.
71
+
72
+ Only ``x-*`` prefixed headers are kept; everything else is dropped.
73
+ """
74
+ filtered = {k.lower(): v for k, v in headers.items() if k.lower().startswith("x-")}
75
+ _forwarded_headers.set(filtered)
76
+
77
+
78
+ def get_forwarded_headers() -> Dict[str, str]:
79
+ """Return the headers the application has asked to forward onto outbound
80
+ LLM/provider calls in the current request context."""
81
+ return _forwarded_headers.get({})
82
+
83
+
84
+ def _find_event_hooks_target(client: Any) -> Optional[Any]:
85
+ """Walk the ``._client`` chain looking for the first object that exposes
86
+ an httpx-style ``event_hooks`` mapping.
87
+
88
+ Returns the target object, or ``None`` if no such object is found within
89
+ ``_MAX_CHAIN_DEPTH`` hops.
90
+ """
91
+ current = client
92
+ for _ in range(_MAX_CHAIN_DEPTH + 1):
93
+ if current is None:
94
+ return None
95
+ if hasattr(current, "event_hooks"):
96
+ return current
97
+ nxt = getattr(current, "_client", None)
98
+ if nxt is current or nxt is None:
99
+ return None
100
+ current = nxt
101
+ return None
102
+
103
+
104
+ def install_httpx_hook(client: Any) -> None:
105
+ """Attach a request event hook to ``client``'s underlying httpx client so
106
+ that headers recorded via ``set_forwarded_headers`` are copied onto
107
+ outbound requests.
108
+
109
+ Works with OpenAI and Anthropic Python SDKs (both wrap httpx internally,
110
+ sometimes via several layers of ``._client`` indirection), as well as raw
111
+ ``httpx.Client`` / ``httpx.AsyncClient`` instances.
112
+
113
+ For ``httpx.AsyncClient`` an async hook is attached (httpx awaits request
114
+ hooks on async clients); for sync clients a sync hook is attached.
115
+
116
+ Idempotent: a marker attribute on the installed callable prevents double
117
+ installation on the same target.
118
+
119
+ Parameters
120
+ ----------
121
+ client : object
122
+ An OpenAI/Anthropic client instance, or a raw httpx.Client/AsyncClient.
123
+ """
124
+ target = _find_event_hooks_target(client)
125
+
126
+ if target is None:
127
+ warnings.warn(
128
+ f"install_httpx_hook: client of type {type(client).__name__} has no "
129
+ "recognized event_hooks attribute; x-* headers will not be forwarded",
130
+ stacklevel=2,
131
+ )
132
+ return
133
+
134
+ request_hooks = target.event_hooks.get("request", [])
135
+
136
+ # Idempotency: don't double-install on the same target.
137
+ for existing in request_hooks:
138
+ if getattr(existing, _HOOK_MARKER, False):
139
+ return
140
+
141
+ # Choose sync vs async hook flavor based on the target class.
142
+ # httpx.AsyncClient awaits request hooks; a sync hook returning None would
143
+ # raise "TypeError: object NoneType can't be used in 'await' expression",
144
+ # which surfaces as APIConnectionError to the caller.
145
+ is_async = _is_async_httpx_target(target)
146
+
147
+ if is_async:
148
+
149
+ async def _inject_headers_async(request):
150
+ headers = get_forwarded_headers()
151
+ for key, value in headers.items():
152
+ request.headers[key] = value
153
+
154
+ setattr(_inject_headers_async, _HOOK_MARKER, True)
155
+ request_hooks.append(_inject_headers_async)
156
+ else:
157
+
158
+ def _inject_headers(request):
159
+ headers = get_forwarded_headers()
160
+ for key, value in headers.items():
161
+ request.headers[key] = value
162
+
163
+ setattr(_inject_headers, _HOOK_MARKER, True)
164
+ request_hooks.append(_inject_headers)
165
+
166
+ # In case ``event_hooks`` returned a fresh list (defensive), make sure the
167
+ # mutation is reflected on the target.
168
+ target.event_hooks["request"] = request_hooks
169
+
170
+
171
+ def _is_async_httpx_target(target: Any) -> bool:
172
+ """Best-effort detection: is this object an httpx async client?
173
+
174
+ Tries ``isinstance`` against the real ``httpx.AsyncClient`` / ``httpx.Client``
175
+ first (the authoritative answer for real clients). If httpx is not
176
+ importable, or the target is neither of those (e.g. a wrapped or
177
+ duck-typed client used in tests), falls back to an EXACT MRO class-name
178
+ match against ``"AsyncClient"``. Avoids a broad ``startswith("Async")``
179
+ check, which would misclassify a sync client whose MRO happens to
180
+ include an ``Async*``-named base (e.g. ``AsyncContextManager``) as
181
+ async — attaching an async hook that httpx calls synchronously would
182
+ leave the coroutine unawaited and the forwarded headers would not be
183
+ attached to the outbound request.
184
+ """
185
+ try:
186
+ import httpx # local import keeps httpx an optional concern at import time
187
+
188
+ if isinstance(target, httpx.AsyncClient):
189
+ return True
190
+ if isinstance(target, httpx.Client):
191
+ return False
192
+ except (
193
+ ImportError
194
+ ): # pragma: no cover - httpx should always be importable in practice
195
+ pass
196
+
197
+ # Fall back to exact class-name match for wrapped/duck-typed clients.
198
+ for cls in type(target).__mro__:
199
+ if cls.__name__ == "AsyncClient":
200
+ return True
201
+ return False
@@ -23,6 +23,7 @@ from langchain_core.callbacks.manager import adispatch_custom_event
23
23
  from langgraph.types import interrupt
24
24
 
25
25
  from .types import Message, IntermediateStateConfig
26
+ from .exc import CopilotKitMisuseError
26
27
  from .logging import get_logger
27
28
 
28
29
  logger = get_logger(__name__)
@@ -368,21 +369,28 @@ async def copilotkit_emit_message(config: RunnableConfig, message: str):
368
369
  {"message": message, "message_id": str(uuid.uuid4()), "role": "assistant"},
369
370
  config=config,
370
371
  )
371
- await asyncio.sleep(0.02)
372
+ await asyncio.shield(asyncio.sleep(0.02))
372
373
 
373
374
  return True
374
375
 
375
376
 
376
377
  async def copilotkit_emit_tool_call(
377
- config: RunnableConfig, *, name: str, args: Dict[str, Any]
378
- ):
378
+ config: RunnableConfig,
379
+ *,
380
+ name: str,
381
+ args: Dict[str, Any],
382
+ tool_call_id: Optional[str] = None,
383
+ ) -> str:
379
384
  """
380
385
  Manually emits a tool call to CopilotKit.
381
386
 
382
387
  ```python
383
388
  from copilotkit.langgraph import copilotkit_emit_tool_call
384
389
 
385
- 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})
391
+
392
+ # With a custom ID for correlation/idempotency:
393
+ custom_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id")
386
394
  ```
387
395
 
388
396
  Parameters
@@ -393,21 +401,56 @@ async def copilotkit_emit_tool_call(
393
401
  The name of the tool to emit.
394
402
  args : Dict[str, Any]
395
403
  The arguments to emit.
404
+ tool_call_id : Optional[str]
405
+ Optional tool call ID. If not provided, a random UUID is generated.
406
+ When provided, this ID is used as the toolCallId and parentMessageId
407
+ in AG-UI protocol events. The caller is responsible for ensuring uniqueness.
396
408
 
397
409
  Returns
398
410
  -------
399
- Awaitable[bool]
400
- Always return True.
411
+ str
412
+ The tool call ID used for the emitted tool call.
401
413
  """
414
+ if not isinstance(name, str) or not name.strip():
415
+ raise CopilotKitMisuseError(
416
+ "Tool name must be a non-empty string for copilotkit_emit_tool_call"
417
+ )
418
+
419
+ if tool_call_id is not None:
420
+ if not isinstance(tool_call_id, str) or not tool_call_id.strip():
421
+ raise CopilotKitMisuseError(
422
+ "Tool call id must be a non-empty string when provided for copilotkit_emit_tool_call"
423
+ )
424
+ else:
425
+ tool_call_id = str(uuid.uuid4())
426
+
427
+ try:
428
+ json.dumps(args)
429
+ except (TypeError, ValueError) as e:
430
+ raise CopilotKitMisuseError(
431
+ f"Tool arguments for '{name}' are not JSON-serializable: {e}"
432
+ ) from e
402
433
 
403
434
  await adispatch_custom_event(
404
435
  "copilotkit_manually_emit_tool_call",
405
- {"name": name, "args": args, "id": str(uuid.uuid4())},
436
+ {"name": name, "args": args, "id": tool_call_id},
406
437
  config=config,
407
438
  )
408
- await asyncio.sleep(0.02)
439
+ # LangGraph's adispatch_custom_event is async but does not guarantee the event
440
+ # has been flushed to the SSE stream before it returns. Without this sleep,
441
+ # a subsequent emit can interleave and corrupt event ordering on the client.
442
+ # Shielded so that task cancellation doesn't prevent us from returning the ID.
443
+ try:
444
+ await asyncio.shield(asyncio.sleep(0.02))
445
+ except asyncio.CancelledError:
446
+ logger.warning(
447
+ "copilotkit_emit_tool_call cancelled during post-dispatch flush for "
448
+ "tool_call_id=%s; event was already dispatched",
449
+ tool_call_id,
450
+ )
451
+ raise
409
452
 
410
- return True
453
+ return tool_call_id
411
454
 
412
455
 
413
456
  def copilotkit_interrupt(
@@ -1,6 +1,10 @@
1
1
  import json
2
+ import logging
2
3
  from typing import Dict, Any, List, Optional, Union, AsyncGenerator
3
4
  from enum import Enum
5
+ from .exc import CopilotKitMisuseError
6
+
7
+ logger = logging.getLogger(__name__)
4
8
  from ag_ui_langgraph import LangGraphAgent
5
9
  from ag_ui.core import (
6
10
  EventType,
@@ -104,33 +108,86 @@ class LangGraphAGUIAgent(LangGraphAgent):
104
108
  return super()._dispatch_event(event)
105
109
 
106
110
  if custom_event.name == CustomEventNames.ManuallyEmitToolCall.value:
107
- # Emit the tool call events
108
- super()._dispatch_event(
109
- ToolCallStartEvent(
110
- type=EventType.TOOL_CALL_START,
111
- tool_call_id=custom_event.value["id"],
112
- tool_call_name=custom_event.value["name"],
113
- parent_message_id=custom_event.value["id"],
114
- raw_event=event,
111
+ 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
115
  )
116
- )
117
- super()._dispatch_event(
118
- ToolCallArgsEvent(
119
- type=EventType.TOOL_CALL_ARGS,
120
- tool_call_id=custom_event.value["id"],
121
- delta=custom_event.value["args"]
122
- if isinstance(custom_event.value["args"], str)
123
- else json.dumps(custom_event.value["args"]),
124
- raw_event=event,
116
+
117
+ tool_call_id = value.get("id")
118
+ tool_call_name = value.get("name")
119
+ tool_call_args = value.get("args")
120
+
121
+ if not isinstance(tool_call_id, str) or not tool_call_id.strip():
122
+ raise CopilotKitMisuseError(
123
+ f"ManuallyEmitToolCall event missing valid 'id': got {type(tool_call_id).__name__}"
125
124
  )
126
- )
127
- super()._dispatch_event(
128
- ToolCallEndEvent(
129
- type=EventType.TOOL_CALL_END,
130
- tool_call_id=custom_event.value["id"],
131
- raw_event=event,
125
+ if not isinstance(tool_call_name, str) or not tool_call_name.strip():
126
+ raise CopilotKitMisuseError(
127
+ f"ManuallyEmitToolCall event missing valid 'name': got {type(tool_call_name).__name__}"
132
128
  )
133
- )
129
+ if tool_call_args is None:
130
+ raise CopilotKitMisuseError(
131
+ f"ManuallyEmitToolCall event missing 'args' for tool_call_id={tool_call_id}"
132
+ )
133
+
134
+ try:
135
+ delta = (
136
+ tool_call_args
137
+ if isinstance(tool_call_args, str)
138
+ else json.dumps(tool_call_args)
139
+ )
140
+ except (TypeError, ValueError) as e:
141
+ raise CopilotKitMisuseError(
142
+ f"ManuallyEmitToolCall 'args' is not JSON-serializable for tool_call_id={tool_call_id}: {e}"
143
+ ) from e
144
+
145
+ dispatched_start = False
146
+ end_dispatched = 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
+ )
156
+ )
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
+ )
165
+ )
166
+ super()._dispatch_event(
167
+ ToolCallEndEvent(
168
+ type=EventType.TOOL_CALL_END,
169
+ tool_call_id=tool_call_id,
170
+ raw_event=event,
171
+ )
172
+ )
173
+ end_dispatched = True
174
+ except Exception:
175
+ if dispatched_start and not end_dispatched:
176
+ try:
177
+ super()._dispatch_event(
178
+ ToolCallEndEvent(
179
+ type=EventType.TOOL_CALL_END,
180
+ tool_call_id=tool_call_id,
181
+ raw_event=event,
182
+ )
183
+ )
184
+ except Exception:
185
+ logger.error(
186
+ "Failed to emit compensating TOOL_CALL_END for %s",
187
+ tool_call_id,
188
+ exc_info=True,
189
+ )
190
+ raise
134
191
  return super()._dispatch_event(event)
135
192
 
136
193
  if custom_event.name == CustomEventNames.ManuallyEmitState.value:
@@ -234,5 +291,8 @@ class LangGraphAGUIAgent(LangGraphAgent):
234
291
 
235
292
  def dict_repr(self):
236
293
  """Return dictionary representation of the agent"""
237
- super_repr = super().dict_repr()
238
- return {**super_repr, "type": "langgraph_agui"}
294
+ return {
295
+ "name": self.name,
296
+ "description": self.description or "",
297
+ "type": "langgraph_agui",
298
+ }
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "copilotkit"
3
- version = "0.1.91"
3
+ version = "0.1.92"
4
4
  description = "CopilotKit python SDK"
5
5
  authors = ["Markus Ecker <markus.ecker@gmail.com>"]
6
6
  license = "MIT"
@@ -48,4 +48,4 @@ requires = ["poetry-core"]
48
48
  build-backend = "poetry.core.masonry.api"
49
49
 
50
50
  [dependency-groups]
51
- dev = ["pytest (>=9.0.2,<10.0.0)"]
51
+ dev = ["pytest (>=9.0.2,<10.0.0)", "pytest-asyncio (>=1.0.0,<2.0.0)"]
@@ -1,59 +0,0 @@
1
- """Header propagation for forwarding x-* prefixed headers to outgoing LLM calls.
2
-
3
- Uses Python contextvars for per-request ambient state in async FastAPI handlers.
4
- An httpx event hook reads the ContextVar and injects headers on outgoing requests.
5
- Matches the CopilotKit runtime's extractForwardableHeaders() behavior.
6
- """
7
-
8
- import contextvars
9
- import warnings
10
- from typing import Dict
11
-
12
- # Ambient per-request state for headers to forward to LLM calls
13
- _forwarded_headers: contextvars.ContextVar[Dict[str, str]] = contextvars.ContextVar(
14
- "copilotkit_forwarded_headers"
15
- )
16
-
17
-
18
- def set_forwarded_headers(headers: Dict[str, str]) -> None:
19
- """Store headers to forward to outgoing LLM calls.
20
- Filters to x-* prefixed headers only."""
21
- filtered = {k.lower(): v for k, v in headers.items() if k.lower().startswith("x-")}
22
- _forwarded_headers.set(filtered)
23
-
24
-
25
- def get_forwarded_headers() -> Dict[str, str]:
26
- """Get headers that should be forwarded to outgoing LLM calls."""
27
- return _forwarded_headers.get({})
28
-
29
-
30
- def install_httpx_hook(client) -> None:
31
- """Append an event hook to an httpx client that injects forwarded headers.
32
-
33
- Works with OpenAI and Anthropic Python SDKs (both use httpx internally).
34
- No-op when no headers are set (demo traffic).
35
-
36
- Parameters
37
- ----------
38
- client : object
39
- An OpenAI/Anthropic client instance, or a raw httpx.Client/AsyncClient.
40
- For SDK clients the underlying transport lives at ``client._client``.
41
- """
42
-
43
- def _inject_headers(request):
44
- headers = get_forwarded_headers()
45
- for key, value in headers.items():
46
- request.headers[key] = value
47
-
48
- # OpenAI / Anthropic SDKs wrap an httpx client at client._client
49
- if hasattr(client, "_client") and hasattr(client._client, "event_hooks"):
50
- client._client.event_hooks["request"].append(_inject_headers)
51
- elif hasattr(client, "event_hooks"):
52
- # Raw httpx.Client / httpx.AsyncClient
53
- client.event_hooks["request"].append(_inject_headers)
54
- else:
55
- warnings.warn(
56
- f"install_httpx_hook: client of type {type(client).__name__} has no "
57
- "recognized event_hooks attribute; x-* headers will not be forwarded",
58
- stacklevel=2,
59
- )
File without changes