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.
- copilotkit_intelligence/__init__.py +62 -0
- copilotkit_intelligence/client.py +805 -0
- copilotkit_intelligence/entitlements.py +142 -0
- copilotkit_intelligence/inspector.py +182 -0
- copilotkit_intelligence/learned_skills.py +98 -0
- copilotkit_intelligence/py.typed +0 -0
- copilotkit_intelligence/resources.py +134 -0
- copilotkit_intelligence_runtime-0.1.0.dist-info/METADATA +403 -0
- copilotkit_intelligence_runtime-0.1.0.dist-info/RECORD +22 -0
- copilotkit_intelligence_runtime-0.1.0.dist-info/WHEEL +4 -0
- copilotkit_intelligence_runtime-0.1.0.dist-info/licenses/LICENSE +21 -0
- copilotkit_runtime/__init__.py +27 -0
- copilotkit_runtime/a2ui.py +559 -0
- copilotkit_runtime/agents.py +64 -0
- copilotkit_runtime/finalizer.py +75 -0
- copilotkit_runtime/gateway.py +287 -0
- copilotkit_runtime/mcp_apps.py +299 -0
- copilotkit_runtime/models.py +77 -0
- copilotkit_runtime/platform.py +67 -0
- copilotkit_runtime/py.typed +0 -0
- copilotkit_runtime/runtime.py +878 -0
- copilotkit_runtime/telemetry.py +263 -0
|
@@ -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")
|