copilotkit-intelligence-runtime 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,559 @@
1
+ """Native A2UI v0.9 middleware, aligned with AG-UI middleware 0.0.10."""
2
+
3
+ import copy
4
+ import json
5
+ from collections.abc import AsyncIterator
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+ from uuid import uuid4
9
+
10
+ from .models import Json
11
+
12
+ SCHEMA_DESCRIPTION = "A2UI Component Schema — available components for generating UI surfaces. Use these component names and properties when creating A2UI operations."
13
+ BASIC_CATALOG = "https://a2ui.org/specification/v0_9/basic_catalog.json"
14
+ _DECODER = json.JSONDecoder()
15
+ _OPS = ("createSurface", "updateComponents", "updateDataModel", "deleteSurface")
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class A2UIConfig:
20
+ """Configure per-agent A2UI context, tool injection and recovery presentation."""
21
+
22
+ enabled: bool = True
23
+ agents: tuple[str, ...] | None = None
24
+ schema: Json | list[Json] | None = None
25
+ inject_tool: bool | str = False
26
+ tool_names: tuple[str, ...] = ("render_a2ui",)
27
+ default_catalog_id: str | None = None
28
+ max_attempts: int = 3
29
+ show_progress_tokens: bool = True
30
+ debug_exposure: str | None = None
31
+ max_argument_bytes: int = 2 * 1024 * 1024
32
+
33
+ def __post_init__(self) -> None:
34
+ """Validate public configuration before a run starts."""
35
+ if not isinstance(self.inject_tool, (bool, str)) or self.inject_tool == "":
36
+ raise ValueError("inject_tool must be a bool or nonempty tool name")
37
+ if self.max_attempts < 1 or self.max_argument_bytes < 1:
38
+ raise ValueError("A2UI limits must be positive")
39
+ if self.debug_exposure not in (None, "hidden", "collapsed", "verbose"):
40
+ raise ValueError("Invalid A2UI debug exposure")
41
+
42
+ def applies(self, agent_id: str) -> bool:
43
+ """Return whether A2UI is enabled for this configured agent."""
44
+ return self.enabled and (self.agents is None or agent_id in self.agents)
45
+
46
+
47
+ def _references(value: Any, path: str) -> list[tuple[str, str]]:
48
+ """Extract references from a string, template or reference list."""
49
+ if isinstance(value, str):
50
+ return [(path, value)]
51
+ if isinstance(value, dict) and isinstance(value.get("componentId"), str):
52
+ return [(path, value["componentId"])]
53
+ if isinstance(value, list):
54
+ return [
55
+ edge
56
+ for index, item in enumerate(value)
57
+ for edge in _references(item, f"{path}[{index}]")
58
+ ]
59
+ return []
60
+
61
+
62
+ def _edges(component: Json, schema: Json) -> list[tuple[str, str]]:
63
+ """Read implicit children plus catalog-marked component references."""
64
+ result = _references(component.get("child"), "child") + _references(
65
+ component.get("children"), "children"
66
+ )
67
+ for field, prop in schema.get("properties", {}).items():
68
+ if field in ("child", "children") or not isinstance(prop, dict):
69
+ continue
70
+ if prop.get("format") in ("componentRef", "componentRefList"):
71
+ result.extend(_references(component.get(field), field))
72
+ elif prop.get("type") == "array" and isinstance(prop.get("items"), dict):
73
+ values = component.get(field)
74
+ if not isinstance(values, list):
75
+ continue
76
+ for index, item in enumerate(values):
77
+ if not isinstance(item, dict):
78
+ continue
79
+ for name, sub in prop["items"].get("properties", {}).items():
80
+ if isinstance(sub, dict) and sub.get("format") in (
81
+ "componentRef",
82
+ "componentRefList",
83
+ ):
84
+ result.extend(_references(item.get(name), f"{field}[{index}].{name}"))
85
+ return result
86
+
87
+
88
+ def validate_components(components: Any, catalog: Json | None = None) -> list[Json]:
89
+ """Validate the atomic component tree before paint; bindings remain deferred."""
90
+ errors: list[Json] = []
91
+
92
+ def add(code: str, path: str, message: str) -> None:
93
+ errors.append({"code": code, "path": path, "message": message})
94
+
95
+ if not isinstance(components, list) or not components:
96
+ add("empty_components", "components", "A2UI components must be a non-empty array")
97
+ return errors
98
+ ids: set[str] = set()
99
+ adjacency: dict[str, list[str]] = {}
100
+ for component in components:
101
+ identifier = component.get("id") if isinstance(component, dict) else None
102
+ if isinstance(identifier, str):
103
+ if identifier in ids:
104
+ add(
105
+ "duplicate_id",
106
+ f"components[id={identifier}]",
107
+ f"Duplicate component id '{identifier}'",
108
+ )
109
+ ids.add(identifier)
110
+ for index, component in enumerate(components):
111
+ component = component if isinstance(component, dict) else {}
112
+ identifier, kind = component.get("id"), component.get("component")
113
+ if not isinstance(identifier, str) or not identifier:
114
+ add(
115
+ "missing_id",
116
+ f"components[{index}].id",
117
+ f"Component at index {index} is missing a string 'id'",
118
+ )
119
+ if not isinstance(kind, str) or not kind:
120
+ add(
121
+ "missing_component_type",
122
+ f"components[{index}].component",
123
+ f"Component at index {index} is missing a string 'component' type",
124
+ )
125
+ schema = (
126
+ (catalog or {}).get("components", {}).get(kind, {}) if isinstance(kind, str) else {}
127
+ )
128
+ if catalog and isinstance(kind, str):
129
+ if kind not in catalog.get("components", {}):
130
+ add(
131
+ "unknown_component",
132
+ f"components[{index}].component",
133
+ f"Component type '{kind}' is not in the catalog",
134
+ )
135
+ else:
136
+ for prop in schema.get("required", []):
137
+ if prop not in component:
138
+ add(
139
+ "missing_required_prop",
140
+ f"components[{index}].{prop}",
141
+ f"Component '{kind}' (index {index}) is missing required prop '{prop}'",
142
+ )
143
+ edges = _edges(component, schema)
144
+ for path, ref in edges:
145
+ if ref not in ids:
146
+ add(
147
+ "unresolved_child",
148
+ f"components[{index}].{path}",
149
+ f"Child reference '{ref}' does not match any component id",
150
+ )
151
+ if isinstance(identifier, str):
152
+ adjacency[identifier] = [ref for _, ref in edges]
153
+ colors: dict[str, int] = {}
154
+ cycles: set[tuple[str, ...]] = set()
155
+ for root in adjacency:
156
+ if colors.get(root):
157
+ continue
158
+ stack = [(root, 0)]
159
+ chain = [root]
160
+ colors[root] = 1
161
+ while stack:
162
+ node, index = stack[-1]
163
+ neighbors = adjacency.get(node, [])
164
+ if index >= len(neighbors):
165
+ colors[node] = 2
166
+ stack.pop()
167
+ chain.pop()
168
+ continue
169
+ stack[-1] = (node, index + 1)
170
+ child = neighbors[index]
171
+ if not colors.get(child):
172
+ colors[child] = 1
173
+ stack.append((child, 0))
174
+ chain.append(child)
175
+ elif colors[child] == 1:
176
+ cycle = chain[chain.index(child) :]
177
+ smallest = cycle.index(min(cycle))
178
+ canonical = tuple(cycle[smallest:] + cycle[:smallest])
179
+ if canonical not in cycles:
180
+ cycles.add(canonical)
181
+ add(
182
+ "child_cycle",
183
+ f"components[id={canonical[0]}]",
184
+ "Child reference cycle detected: "
185
+ + " -> ".join([*canonical, canonical[0]]),
186
+ )
187
+ if "root" not in ids:
188
+ add("no_root", "components", "No component has id 'root'")
189
+ return errors
190
+
191
+
192
+ def _field(raw: str, name: str) -> str | None:
193
+ """Find a root property without mistaking quoted content for a field name."""
194
+ raw = raw.lstrip()
195
+ if not raw.startswith("{"):
196
+ return None
197
+ position = 1
198
+ try:
199
+ while position < len(raw):
200
+ position += len(raw[position:]) - len(raw[position:].lstrip())
201
+ key, end = _DECODER.raw_decode(raw, position)
202
+ position = end
203
+ position += len(raw[position:]) - len(raw[position:].lstrip())
204
+ if raw[position] != ":":
205
+ return None
206
+ position += 1
207
+ position += len(raw[position:]) - len(raw[position:].lstrip())
208
+ if key == name:
209
+ return raw[position:]
210
+ _, position = _DECODER.raw_decode(raw, position)
211
+ position += len(raw[position:]) - len(raw[position:].lstrip())
212
+ if raw[position] != ",":
213
+ return None
214
+ position += 1
215
+ except (ValueError, IndexError):
216
+ return None
217
+ return None
218
+
219
+
220
+ def _value(raw: str, field: str) -> Any:
221
+ """Read a complete root value from otherwise partial JSON."""
222
+ fragment = _field(raw, field)
223
+ if fragment is None:
224
+ return None
225
+ try:
226
+ return _DECODER.raw_decode(fragment)[0]
227
+ except ValueError:
228
+ return None
229
+
230
+
231
+ def _array(fragment: str | None) -> tuple[list[Any], bool]:
232
+ """Return only complete array entries, plus the array closure status."""
233
+ if fragment is None or not fragment.startswith("["):
234
+ return [], False
235
+ items: list[Any] = []
236
+ position = 1
237
+ try:
238
+ while position < len(fragment):
239
+ position += len(fragment[position:]) - len(fragment[position:].lstrip())
240
+ if fragment[position] == "]":
241
+ return items, True
242
+ item, position = _DECODER.raw_decode(fragment, position)
243
+ items.append(item)
244
+ position += len(fragment[position:]) - len(fragment[position:].lstrip())
245
+ if fragment[position] == "]":
246
+ return items, True
247
+ if fragment[position] != ",":
248
+ return items, False
249
+ position += 1
250
+ except (ValueError, IndexError):
251
+ pass
252
+ return items, False
253
+
254
+
255
+ class A2UIMiddleware:
256
+ """Create isolated stream state per run; model retry decisions stay with agents."""
257
+
258
+ def __init__(self, config: A2UIConfig) -> None:
259
+ self.config = config
260
+ self.names = set(config.tool_names)
261
+ self.tool_name = (
262
+ config.inject_tool if isinstance(config.inject_tool, str) else "render_a2ui"
263
+ )
264
+ if config.inject_tool:
265
+ self.names.add(self.tool_name)
266
+
267
+ def prepare(self, input: Json) -> Json:
268
+ """Inject server context, optional tool schema, and synthetic action history."""
269
+ result = copy.deepcopy(input)
270
+ context = result.setdefault("context", [])
271
+ forwarded = result.setdefault("forwardedProps", {})
272
+ action = forwarded.get("a2uiAction", {}).get("userAction")
273
+ if isinstance(action, dict):
274
+ call_id = str(uuid4())
275
+ text = f'User performed action "{action.get("name", "unknown_action")}" on surface "{action.get("surfaceId", "unknown_surface")}"'
276
+ if action.get("sourceComponentId"):
277
+ text += f" (component: {action['sourceComponentId']})"
278
+ text += ". Context: " + json.dumps(action.get("context", {}), separators=(",", ":"))
279
+ result.setdefault("messages", []).extend(
280
+ [
281
+ {
282
+ "id": str(uuid4()),
283
+ "role": "assistant",
284
+ "content": "",
285
+ "toolCalls": [
286
+ {
287
+ "id": call_id,
288
+ "type": "function",
289
+ "function": {
290
+ "name": "log_a2ui_event",
291
+ "arguments": json.dumps(action),
292
+ },
293
+ }
294
+ ],
295
+ },
296
+ {"id": str(uuid4()), "role": "tool", "toolCallId": call_id, "content": text},
297
+ ]
298
+ )
299
+ if self.config.schema:
300
+ context[:] = [
301
+ entry for entry in context if entry.get("description") != SCHEMA_DESCRIPTION
302
+ ]
303
+ context.append(
304
+ {"description": SCHEMA_DESCRIPTION, "value": json.dumps(self.config.schema)}
305
+ )
306
+ if self.config.inject_tool:
307
+ result["tools"] = [
308
+ tool for tool in result.get("tools", []) if tool.get("name") != self.tool_name
309
+ ]
310
+ result["tools"].append(
311
+ {
312
+ "name": self.tool_name,
313
+ "description": "Render a dynamic A2UI v0.9 surface with structured parameters. Follow the A2UI render tool usage guide provided in context.",
314
+ "parameters": {
315
+ "type": "object",
316
+ "properties": {
317
+ "surfaceId": {
318
+ "type": "string",
319
+ "description": "Unique surface identifier.",
320
+ },
321
+ "components": {"type": "array", "items": {"type": "object"}},
322
+ "data": {"type": "object"},
323
+ },
324
+ "required": ["surfaceId", "components"],
325
+ },
326
+ }
327
+ )
328
+ forwarded["injectA2UITool"] = self.config.inject_tool
329
+ description = (
330
+ f"A2UI render tool usage guide — how to call {self.tool_name} with valid arguments."
331
+ )
332
+ context[:] = [entry for entry in context if entry.get("description") != description]
333
+ context.append(
334
+ {
335
+ "description": description,
336
+ "value": f'Call {self.tool_name} with surfaceId and a nonempty components array. Use flat v0.9 components with unique id and component fields. Include id root. Reference children by ID. Only use catalog component names and required properties. Data bindings use {{"path":"/key"}}. Repeated children use {{"componentId":"item","path":"/items"}}. Supply data for bindings. The host owns catalogId. Actions use an event object with name and context. Do not invent image URLs.',
337
+ }
338
+ )
339
+ return result
340
+
341
+ def _activity(self, key: str, content: Json) -> Json:
342
+ """Keep building/retry/failure/paint states on one replaceable message ID."""
343
+ if "status" in content and self.config.debug_exposure:
344
+ content = {**content, "debugExposure": self.config.debug_exposure}
345
+ return {
346
+ "type": "ACTIVITY_SNAPSHOT",
347
+ "messageId": "a2ui-surface-" + key,
348
+ "activityType": "a2ui-surface",
349
+ "content": content,
350
+ "replace": True,
351
+ }
352
+
353
+ async def transform(self, source: AsyncIterator[Json], original: Json) -> AsyncIterator[Json]:
354
+ """Emit validated atomic components and cumulative progressive data before persistence."""
355
+ calls: dict[str, Json] = {}
356
+ resolved = {
357
+ message.get("toolCallId")
358
+ for message in original.get("messages", [])
359
+ if message.get("role") == "tool"
360
+ }
361
+ outer: str | None = None
362
+ painted: set[str] = set()
363
+ retries: set[str] = set()
364
+ attempts: dict[str, int] = {}
365
+ frontend_catalog = None
366
+ for entry in original.get("context", []):
367
+ if entry.get("description") == SCHEMA_DESCRIPTION:
368
+ try:
369
+ frontend_catalog = json.loads(entry["value"]).get("catalogId")
370
+ except (ValueError, AttributeError, KeyError):
371
+ pass
372
+ catalog = (
373
+ self.config.schema
374
+ if isinstance(self.config.schema, dict) and self.config.schema.get("components")
375
+ else None
376
+ )
377
+ async for event in source:
378
+ kind, call_id = event.get("type"), event.get("toolCallId", "")
379
+ if kind == "TOOL_CALL_START":
380
+ if event.get("toolCallName") in self.names:
381
+ key = outer or call_id
382
+ attempts[key] = attempts.get(key, 0) + 1
383
+ calls[call_id] = {
384
+ "args": "",
385
+ "key": key,
386
+ "components": None,
387
+ "rejected": False,
388
+ "items": 0,
389
+ "complete": False,
390
+ "tokens": 0,
391
+ }
392
+ if key not in retries:
393
+ yield self._activity(key, {"status": "building"})
394
+ elif event.get("toolCallName") != "log_a2ui_event":
395
+ outer = call_id
396
+ elif kind == "TOOL_CALL_ARGS" and call_id in calls:
397
+ state = calls[call_id]
398
+ state["args"] += event.get("delta", "")
399
+ if len(state["args"].encode()) > self.config.max_argument_bytes:
400
+ raise ValueError("A2UI arguments exceed configured limit")
401
+ raw, key = state["args"], state["key"]
402
+ tokens = int(len(raw) / 4 + 0.5)
403
+ if (
404
+ self.config.show_progress_tokens
405
+ and not state["components"]
406
+ and key not in retries
407
+ and tokens - state["tokens"] >= 20
408
+ ):
409
+ state["tokens"] = tokens
410
+ yield self._activity(key, {"status": "building", "progressTokens": tokens})
411
+ surface = _value(raw, "surfaceId") or call_id
412
+ streamed_catalog = _value(raw, "catalogId")
413
+ catalog_id = (
414
+ self.config.default_catalog_id
415
+ or frontend_catalog
416
+ or (
417
+ streamed_catalog
418
+ if streamed_catalog and streamed_catalog != "basic"
419
+ else BASIC_CATALOG
420
+ )
421
+ )
422
+ components_advanced = False
423
+ if state["components"] is None and not state["rejected"]:
424
+ components, closed = _array(_field(raw, "components"))
425
+ if closed:
426
+ errors = validate_components(components, catalog)
427
+ if errors:
428
+ state["rejected"] = True
429
+ retries.add(key)
430
+ yield self._activity(
431
+ key,
432
+ {
433
+ "status": "retrying",
434
+ "attempt": min(attempts[key] + 1, self.config.max_attempts),
435
+ "maxAttempts": self.config.max_attempts,
436
+ "errors": errors,
437
+ },
438
+ )
439
+ else:
440
+ state["components"] = components
441
+ components_advanced = True
442
+ state["surface"] = surface
443
+ state["catalog"] = catalog_id
444
+ if state["components"]:
445
+ surface = state["surface"]
446
+ catalog_id = state["catalog"]
447
+ data_key = next(
448
+ (
449
+ component["children"]["path"].removeprefix("/")
450
+ for component in state["components"]
451
+ if isinstance(component.get("children"), dict)
452
+ and isinstance(component["children"].get("path"), str)
453
+ ),
454
+ "items",
455
+ )
456
+ data_fragment = _field(raw, "data")
457
+ items, _ = _array(_field(data_fragment, data_key) if data_fragment else None)
458
+ ops = [
459
+ {
460
+ "version": "v0.9",
461
+ "createSurface": {"surfaceId": surface, "catalogId": catalog_id},
462
+ },
463
+ {
464
+ "version": "v0.9",
465
+ "updateComponents": {
466
+ "surfaceId": surface,
467
+ "components": state["components"],
468
+ },
469
+ },
470
+ ]
471
+ if components_advanced or (
472
+ len(items) > state["items"] and not state["complete"]
473
+ ):
474
+ state["items"] = len(items)
475
+ progressive = ops + (
476
+ [
477
+ {
478
+ "version": "v0.9",
479
+ "updateDataModel": {
480
+ "surfaceId": surface,
481
+ "path": "/",
482
+ "value": {data_key: items},
483
+ },
484
+ }
485
+ ]
486
+ if items
487
+ else []
488
+ )
489
+ yield self._activity(key, {"a2ui_operations": progressive})
490
+ painted.add(surface)
491
+ retries.discard(key)
492
+ data = _value(raw, "data")
493
+ if isinstance(data, dict) and not state["complete"]:
494
+ state["complete"] = True
495
+ yield self._activity(
496
+ key,
497
+ {
498
+ "a2ui_operations": ops
499
+ + [
500
+ {
501
+ "version": "v0.9",
502
+ "updateDataModel": {
503
+ "surfaceId": surface,
504
+ "path": "/",
505
+ "value": data,
506
+ },
507
+ }
508
+ ]
509
+ },
510
+ )
511
+ if kind == "RUN_FINISHED":
512
+ for pending_id in calls.keys() - resolved:
513
+ yield {
514
+ "type": "TOOL_CALL_RESULT",
515
+ "messageId": str(uuid4()),
516
+ "toolCallId": pending_id,
517
+ "content": json.dumps({"status": "rendered"}),
518
+ }
519
+ resolved.add(pending_id)
520
+ yield event
521
+ if kind == "TOOL_CALL_RESULT":
522
+ resolved.add(call_id)
523
+ try:
524
+ content = json.loads(event.get("content", ""))
525
+ except (ValueError, TypeError):
526
+ content = None
527
+ if isinstance(content, dict):
528
+ operations = content.get("a2ui_operations")
529
+ if isinstance(operations, list):
530
+ groups: dict[str, list[Json]] = {}
531
+ for operation in operations:
532
+ if not isinstance(operation, dict):
533
+ continue
534
+ op_name = next(
535
+ (name for name in _OPS if isinstance(operation.get(name), dict)),
536
+ None,
537
+ )
538
+ if op_name:
539
+ surface = operation[op_name].get("surfaceId", "default")
540
+ if surface not in painted:
541
+ groups.setdefault(surface, []).append(operation)
542
+ for surface, group in groups.items():
543
+ key = outer or call_id
544
+ if len(groups) > 1:
545
+ key = surface + "-" + key
546
+ yield self._activity(key, {"a2ui_operations": group})
547
+ elif content.get("code") == "a2ui_recovery_exhausted":
548
+ history = content.get("attempts", [])
549
+ yield self._activity(
550
+ outer or call_id,
551
+ {
552
+ "status": "failed",
553
+ "error": content.get("error", "A2UI generation failed"),
554
+ "attempts": history,
555
+ "maxAttempts": len(history) or self.config.max_attempts,
556
+ },
557
+ )
558
+ if call_id == outer:
559
+ outer = None
@@ -0,0 +1,64 @@
1
+ """Native async agents and bounded HTTP AG-UI event streaming."""
2
+
3
+ import json
4
+ from collections.abc import AsyncIterator
5
+ from dataclasses import dataclass, field
6
+ from typing import Protocol
7
+
8
+ import httpx
9
+
10
+ from .models import Json
11
+
12
+
13
+ class Agent(Protocol):
14
+ """Implement run as an async iterator; cancellation must close upstream work."""
15
+
16
+ description: str
17
+
18
+ def run(self, input: Json) -> AsyncIterator[Json]:
19
+ """Yield AG-UI event objects for one isolated execution."""
20
+ ...
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class HttpAgent:
25
+ """Call an HTTP AG-UI agent without forwarding browser authentication headers."""
26
+
27
+ url: str
28
+ description: str = ""
29
+ headers: dict[str, str] = field(default_factory=dict, repr=False)
30
+ timeout: float = 120
31
+ max_event_bytes: int = 2 * 1024 * 1024
32
+
33
+ async def run(self, input: Json) -> AsyncIterator[Json]:
34
+ """Parse multiline SSE data and stop on cancellation or malformed events."""
35
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
36
+ async with client.stream(
37
+ "POST",
38
+ self.url,
39
+ json=input,
40
+ headers={"Accept": "text/event-stream", **self.headers},
41
+ ) as response:
42
+ response.raise_for_status()
43
+ data: list[str] = []
44
+ size = 0
45
+ async for line in response.aiter_lines():
46
+ size += len(line.encode("utf-8"))
47
+ if size > self.max_event_bytes:
48
+ raise ValueError("Agent event exceeds configured limit")
49
+ if line == "":
50
+ if data:
51
+ raw = "\n".join(data)
52
+ if raw == "[DONE]":
53
+ return
54
+ event = json.loads(raw)
55
+ if not isinstance(event, dict) or not isinstance(
56
+ event.get("type"), str
57
+ ):
58
+ raise ValueError("Invalid AG-UI event")
59
+ yield event
60
+ data, size = [], 0
61
+ elif line.startswith("data:"):
62
+ data.append(line[5:].removeprefix(" "))
63
+ if data:
64
+ raise ValueError("Agent stream ended inside an SSE event")