copilotkit 0.1.93__tar.gz → 0.1.94__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 (28) hide show
  1. {copilotkit-0.1.93 → copilotkit-0.1.94}/PKG-INFO +3 -3
  2. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/copilotkit_lg_middleware.py +234 -7
  3. {copilotkit-0.1.93 → copilotkit-0.1.94}/pyproject.toml +2 -2
  4. {copilotkit-0.1.93 → copilotkit-0.1.94}/README.md +0 -0
  5. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/__init__.py +0 -0
  6. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/a2ui.py +0 -0
  7. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/action.py +0 -0
  8. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/agent.py +0 -0
  9. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/crewai/__init__.py +0 -0
  10. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/crewai/copilotkit_integration.py +0 -0
  11. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/crewai/crewai_agent.py +0 -0
  12. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/crewai/crewai_sdk.py +0 -0
  13. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/exc.py +0 -0
  14. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/header_propagation.py +0 -0
  15. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/html.py +0 -0
  16. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/integrations/__init__.py +0 -0
  17. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/integrations/fastapi.py +0 -0
  18. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/langchain.py +0 -0
  19. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/langgraph.py +0 -0
  20. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/langgraph_agui_agent.py +0 -0
  21. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/logging.py +0 -0
  22. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/parameter.py +0 -0
  23. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/protocol.py +0 -0
  24. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/py.typed +0 -0
  25. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/runloop.py +0 -0
  26. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/sdk.py +0 -0
  27. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/types.py +0 -0
  28. {copilotkit-0.1.93 → copilotkit-0.1.94}/copilotkit/utils.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.3
2
2
  Name: copilotkit
3
- Version: 0.1.93
3
+ Version: 0.1.94
4
4
  Summary: CopilotKit python SDK
5
5
  License: MIT
6
6
  Keywords: copilot,copilotkit,langgraph,langchain,ai,langsmith,langserve
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Provides-Extra: crewai
18
- Requires-Dist: ag-ui-langgraph[fastapi] (>=0.0.35)
18
+ Requires-Dist: ag-ui-langgraph[fastapi] (>=0.0.38)
19
19
  Requires-Dist: ag-ui-protocol (>=0.1.15)
20
20
  Requires-Dist: crewai (>=0.118.0) ; (python_version >= "3.10" and python_version < "3.14") and (extra == "crewai")
21
21
  Requires-Dist: fastapi (>=0.115.0,<1.0.0)
@@ -30,10 +30,51 @@ from langgraph.runtime import Runtime
30
30
  from .header_propagation import install_httpx_hook, set_forwarded_headers
31
31
  from .langgraph import CopilotKitProperties
32
32
 
33
+ # Optional dependency: the A2UI subagent-tool factory ships in ag-ui-langgraph.
34
+ # Guarded so an older/skewed version without the factory degrades to
35
+ # "no auto-A2UI" instead of breaking the whole middleware import.
36
+ try: # pragma: no cover - exercised indirectly via the a2ui injection path
37
+ from ag_ui_langgraph import get_a2ui_tools
38
+ except Exception: # noqa: BLE001 - any import failure means the feature is off
39
+ get_a2ui_tools = None
40
+
33
41
  # Track which httpx clients already have the header-propagation hook installed
34
42
  # (by object id) so we never double-install on repeated model calls.
35
43
  _hooked_clients: set[int] = set()
36
44
 
45
+ # ---------------------------------------------------------------------------
46
+ # Auto-A2UI: bridge the inferred model from the model-call hook to the
47
+ # tool-call hook
48
+ # ---------------------------------------------------------------------------
49
+ # The generate_a2ui tool drives a structured-output subagent and so needs a
50
+ # chat model. We "infer" that model from ``request.model`` in
51
+ # ``wrap_model_call`` (the only hook that exposes the bound model) and reuse it.
52
+ # But the tool actually *executes* later in ``wrap_tool_call``, whose request
53
+ # does NOT carry the model. ContextVars do not reliably survive LangGraph node
54
+ # boundaries, so we bridge the built tool across nodes via a module-level map
55
+ # keyed by the run's thread id.
56
+ _a2ui_tools_by_thread: dict[str, Any] = {}
57
+
58
+ # Fallback key for runs without a thread id (e.g. an in-memory invoke with no
59
+ # checkpointer). Collisions across concurrent context-less runs are an
60
+ # acceptable edge — the deployed path always carries a thread id.
61
+ _DEFAULT_THREAD_KEY = "__copilotkit_a2ui_default__"
62
+
63
+
64
+ def _current_thread_id() -> "str | None":
65
+ """Best-effort read of the active run's thread id from the LangGraph config.
66
+
67
+ Returns ``None`` outside a runnable context (e.g. unit tests); callers then
68
+ fall back to ``_DEFAULT_THREAD_KEY``.
69
+ """
70
+ try:
71
+ from langgraph.config import get_config
72
+
73
+ cfg = get_config() or {}
74
+ return (cfg.get("configurable") or {}).get("thread_id")
75
+ except Exception: # noqa: BLE001 - no active context / older langgraph
76
+ return None
77
+
37
78
 
38
79
  def _extract_forwarded_headers_from_config() -> None:
39
80
  """Extract raw ``x-*`` headers from the current LangGraph RunnableConfig and
@@ -273,7 +314,131 @@ class CopilotKitMiddleware(AgentMiddleware[StateSchema, Any]):
273
314
  system_message=SystemMessage(content=f"{base}\n\n{note}")
274
315
  )
275
316
 
276
- # Inject frontend tools and surface user state before model call
317
+ # ------------------------------------------------------------------
318
+ # Auto-A2UI tool injection
319
+ # ------------------------------------------------------------------
320
+
321
+ @staticmethod
322
+ def _resolve_a2ui_catalog(state: dict) -> "tuple[str | None, str | None] | None":
323
+ """Find the frontend-registered A2UI catalog wherever it was passed.
324
+
325
+ Returns ``(component_schema, catalog_id)`` when a catalog is present,
326
+ else ``None`` (so the tool is never advertised when the client can't
327
+ render A2UI). Two delivery paths are supported, because the catalog
328
+ lands in different places depending on how the agent is served:
329
+
330
+ - **AG-UI native endpoint** → ``state["ag-ui"]["a2ui_schema"]``, a JSON
331
+ string ``{"catalogId": ..., "components": [...]}``.
332
+ - **CopilotKit runtime proxy** → a ``state["copilotkit"]["context"]``
333
+ entry describing the A2UI catalog (catalog id + component schemas as
334
+ text).
335
+
336
+ ``component_schema`` is the text/JSON the subagent should compose from;
337
+ ``catalog_id`` binds generated surfaces to the frontend's catalog (so
338
+ BYOC custom catalogs render their own components, not the basic one).
339
+ """
340
+ # AG-UI native path.
341
+ ag_ui = state.get("ag-ui") or {}
342
+ a2ui_schema = ag_ui.get("a2ui_schema")
343
+ if a2ui_schema:
344
+ catalog_id = None
345
+ try:
346
+ parsed = (
347
+ json.loads(a2ui_schema)
348
+ if isinstance(a2ui_schema, str)
349
+ else a2ui_schema
350
+ )
351
+ if isinstance(parsed, dict):
352
+ catalog_id = parsed.get("catalogId")
353
+ except (TypeError, ValueError):
354
+ pass
355
+ # Native path: the toolkit reads ``a2ui_schema`` from state itself,
356
+ # so no composition_guide is needed — just surface the catalog id.
357
+ return None, catalog_id
358
+
359
+ # CopilotKit runtime-proxy path: the catalog arrives as a context entry.
360
+ context = (state.get("copilotkit") or {}).get("context") or []
361
+ for entry in context:
362
+ if not isinstance(entry, dict):
363
+ continue
364
+ description = entry.get("description") or ""
365
+ value = entry.get("value") or ""
366
+ if "A2UI catalog" not in description or not value:
367
+ continue
368
+ # The value lists catalogs as "- <catalogId>" lines; the first is
369
+ # the custom catalog the client registered.
370
+ match = re.search(r"(?m)^\s*-\s+(\S+)", value)
371
+ catalog_id = match.group(1) if match else None
372
+ return value, catalog_id
373
+
374
+ return None
375
+
376
+ @staticmethod
377
+ def _a2ui_inject_decision(state: dict) -> "bool | str | None":
378
+ """Return the A2UI ``injectA2UITool`` decision, or ``None``.
379
+
380
+ The ``@ag-ui/a2ui-middleware`` forwards its ``injectA2UITool`` setting on
381
+ ``forwardedProps``, which ``ag-ui-langgraph`` surfaces into agent state at
382
+ ``state["ag-ui"]["inject_a2ui_tool"]`` — present only when the host turned
383
+ the runtime A2UI tool on (truthy or a custom tool-name string). ``None``
384
+ means no signal at all (off, or no A2UI middleware in the pipeline), in
385
+ which case we do not auto-inject.
386
+ """
387
+ return (state.get("ag-ui") or {}).get("inject_a2ui_tool")
388
+
389
+ def _maybe_build_a2ui_tool(self, request: ModelRequest) -> Any | None:
390
+ """Build a ``generate_a2ui`` tool bound to the agent's own model when
391
+ A2UI tool injection is turned on for this run.
392
+
393
+ Gating, in order:
394
+
395
+ 1. **Opt-in.** Only inject when the A2UI ``injectA2UITool`` flag is
396
+ truthy (forwarded by ``@ag-ui/a2ui-middleware`` and surfaced at
397
+ ``state["ag-ui"]["inject_a2ui_tool"]``). No flag → no injection. This
398
+ is the whole contract: "no injectA2UITool, no A2UI tool injection."
399
+ 2. **No double-inject.** If the agent already exposes a tool with the
400
+ same name (e.g. a backend-defined ``generate_a2ui``), don't inject —
401
+ the host owns it, and a duplicate would show the model two tools with
402
+ one name.
403
+
404
+ The model is inferred from ``request.model`` (the bound agent model); the
405
+ component schema and catalog id come from the registered catalog (when
406
+ present) so the subagent composes the right components and surfaces bind
407
+ to the frontend's catalog — otherwise the toolkit's basic catalog is
408
+ used. The built tool is stashed for the tool-call hook to execute.
409
+ Returns the tool or ``None`` when A2UI is not applicable.
410
+ """
411
+ if get_a2ui_tools is None:
412
+ return None
413
+ state = request.state or {}
414
+
415
+ # (1) Opt-in: only inject when the host turned the A2UI tool on.
416
+ if not self._a2ui_inject_decision(state):
417
+ return None
418
+
419
+ # Bind to the frontend's catalog when one was registered (optional).
420
+ resolved = self._resolve_a2ui_catalog(state)
421
+ component_schema, catalog_id = resolved if resolved else (None, None)
422
+
423
+ kwargs: dict[str, Any] = {}
424
+ if catalog_id:
425
+ kwargs["default_catalog_id"] = catalog_id
426
+ # Feed the registered component schema to the subagent so it composes
427
+ # only catalog components (the toolkit appends this to its prompt).
428
+ if component_schema:
429
+ kwargs["composition_guide"] = component_schema
430
+
431
+ tool = get_a2ui_tools(request.model, **kwargs)
432
+
433
+ # (2) Don't double-inject if the agent already defines this tool.
434
+ existing_names = {getattr(t, "name", None) for t in (request.tools or [])}
435
+ if tool.name in existing_names:
436
+ return None
437
+
438
+ _a2ui_tools_by_thread[_current_thread_id() or _DEFAULT_THREAD_KEY] = tool
439
+ return tool
440
+
441
+ # Inject frontend + A2UI tools and surface user state before model call
277
442
  def wrap_model_call(
278
443
  self,
279
444
  request: ModelRequest,
@@ -282,13 +447,25 @@ class CopilotKitMiddleware(AgentMiddleware[StateSchema, Any]):
282
447
  _extract_forwarded_headers_from_config()
283
448
  _ensure_httpx_hook(request.model)
284
449
  request = self._apply_state_note(request)
450
+
451
+ a2ui_tool = self._maybe_build_a2ui_tool(request)
285
452
  frontend_tools = request.state.get("copilotkit", {}).get("actions", [])
453
+ if a2ui_tool is not None:
454
+ # Our generate_a2ui replaces the runtime's render tool — don't
455
+ # advertise both. Drop the render tool the A2UI middleware injected.
456
+ decision = self._a2ui_inject_decision(request.state or {})
457
+ drop = decision if isinstance(decision, str) else "render_a2ui"
458
+ frontend_tools = [
459
+ t
460
+ for t in frontend_tools
461
+ if ((t.get("function") or {}).get("name") or t.get("name")) != drop
462
+ ]
286
463
 
287
- if not frontend_tools:
464
+ if not frontend_tools and a2ui_tool is None:
288
465
  return handler(request)
289
466
 
290
- # Merge frontend tools with existing tools
291
- merged_tools = [*request.tools, *frontend_tools]
467
+ extra_tools = [a2ui_tool] if a2ui_tool is not None else []
468
+ merged_tools = [*request.tools, *extra_tools, *frontend_tools]
292
469
 
293
470
  return handler(request.override(tools=merged_tools))
294
471
 
@@ -474,16 +651,62 @@ class CopilotKitMiddleware(AgentMiddleware[StateSchema, Any]):
474
651
  self._fix_messages_for_bedrock(request.messages)
475
652
  request = self._apply_state_note(request)
476
653
 
654
+ a2ui_tool = self._maybe_build_a2ui_tool(request)
477
655
  frontend_tools = request.state.get("copilotkit", {}).get("actions", [])
656
+ if a2ui_tool is not None:
657
+ # Our generate_a2ui replaces the runtime's render tool — don't
658
+ # advertise both. Drop the render tool the A2UI middleware injected.
659
+ decision = self._a2ui_inject_decision(request.state or {})
660
+ drop = decision if isinstance(decision, str) else "render_a2ui"
661
+ frontend_tools = [
662
+ t
663
+ for t in frontend_tools
664
+ if ((t.get("function") or {}).get("name") or t.get("name")) != drop
665
+ ]
478
666
 
479
- if not frontend_tools:
667
+ if not frontend_tools and a2ui_tool is None:
480
668
  return await handler(request)
481
669
 
482
- # Merge frontend tools with existing tools
483
- merged_tools = [*request.tools, *frontend_tools]
670
+ extra_tools = [a2ui_tool] if a2ui_tool is not None else []
671
+ merged_tools = [*request.tools, *extra_tools, *frontend_tools]
484
672
 
485
673
  return await handler(request.override(tools=merged_tools))
486
674
 
675
+ # ------------------------------------------------------------------
676
+ # Auto-A2UI tool execution
677
+ # ------------------------------------------------------------------
678
+ # The generate_a2ui tool is advertised dynamically in wrap_model_call and is
679
+ # NOT in create_agent's static tool registry, so the tool node cannot
680
+ # execute it on its own. These hooks supply the implementation (built with
681
+ # the inferred model) for that one tool; their presence also disables
682
+ # create_agent's "unknown tool" guard for dynamically-advertised tools.
683
+
684
+ def _resolve_a2ui_request(self, request: Any) -> Any:
685
+ """Return a request overridden with the stashed A2UI tool when this
686
+ tool call targets it, else the original request unchanged."""
687
+ tool = _a2ui_tools_by_thread.get(_current_thread_id() or _DEFAULT_THREAD_KEY)
688
+ if (
689
+ tool is not None
690
+ and getattr(request, "tool", None) is None
691
+ and request.tool_call.get("name") == tool.name
692
+ ):
693
+ return request.override(tool=tool)
694
+ return request
695
+
696
+ def wrap_tool_call(
697
+ self,
698
+ request: Any,
699
+ handler: Callable[[Any], Any],
700
+ ) -> Any:
701
+ return handler(self._resolve_a2ui_request(request))
702
+
703
+ async def awrap_tool_call(
704
+ self,
705
+ request: Any,
706
+ handler: Callable[[Any], Awaitable[Any]],
707
+ ) -> Any:
708
+ return await handler(self._resolve_a2ui_request(request))
709
+
487
710
  # Inject app context before agent runs
488
711
  def before_agent(
489
712
  self,
@@ -678,6 +901,10 @@ class CopilotKitMiddleware(AgentMiddleware[StateSchema, Any]):
678
901
  state: StateSchema,
679
902
  runtime: Runtime[Any],
680
903
  ) -> dict[str, Any] | None:
904
+ # Drop the bridged A2UI tool for this run — all tool calls for the turn
905
+ # have executed by now; the next model call re-stashes if needed.
906
+ _a2ui_tools_by_thread.pop(_current_thread_id() or _DEFAULT_THREAD_KEY, None)
907
+
681
908
  copilotkit_state = state.get("copilotkit", {})
682
909
  intercepted_tool_calls = copilotkit_state.get("intercepted_tool_calls")
683
910
  original_message_id = copilotkit_state.get("original_ai_message_id")
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "copilotkit"
3
- version = "0.1.93"
3
+ version = "0.1.94"
4
4
  description = "CopilotKit python SDK"
5
5
  authors = ["Markus Ecker <markus.ecker@gmail.com>"]
6
6
  license = "MIT"
@@ -31,7 +31,7 @@ python = ">=3.10,<3.15"
31
31
  langgraph = { version = ">=0.3.25,<2" }
32
32
  langchain = { version = ">=0.3.0" }
33
33
  crewai = { version = ">=0.118.0", optional = true, python = ">=3.10,<3.14" }
34
- ag-ui-langgraph = { version = ">=0.0.35", extras = ["fastapi"] }
34
+ ag-ui-langgraph = { version = ">=0.0.38", extras = ["fastapi"] }
35
35
  ag-ui-protocol = ">=0.1.15"
36
36
  fastapi = ">=0.115.0,<1.0.0"
37
37
  partialjson = "^0.0.8"
File without changes