aury-agent 0.0.4__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.
Files changed (149) hide show
  1. aury/__init__.py +2 -0
  2. aury/agents/__init__.py +55 -0
  3. aury/agents/a2a/__init__.py +168 -0
  4. aury/agents/backends/__init__.py +196 -0
  5. aury/agents/backends/artifact/__init__.py +9 -0
  6. aury/agents/backends/artifact/memory.py +130 -0
  7. aury/agents/backends/artifact/types.py +133 -0
  8. aury/agents/backends/code/__init__.py +65 -0
  9. aury/agents/backends/file/__init__.py +11 -0
  10. aury/agents/backends/file/local.py +66 -0
  11. aury/agents/backends/file/types.py +40 -0
  12. aury/agents/backends/invocation/__init__.py +8 -0
  13. aury/agents/backends/invocation/memory.py +81 -0
  14. aury/agents/backends/invocation/types.py +110 -0
  15. aury/agents/backends/memory/__init__.py +8 -0
  16. aury/agents/backends/memory/memory.py +179 -0
  17. aury/agents/backends/memory/types.py +136 -0
  18. aury/agents/backends/message/__init__.py +9 -0
  19. aury/agents/backends/message/memory.py +122 -0
  20. aury/agents/backends/message/types.py +124 -0
  21. aury/agents/backends/sandbox.py +275 -0
  22. aury/agents/backends/session/__init__.py +8 -0
  23. aury/agents/backends/session/memory.py +93 -0
  24. aury/agents/backends/session/types.py +124 -0
  25. aury/agents/backends/shell/__init__.py +11 -0
  26. aury/agents/backends/shell/local.py +110 -0
  27. aury/agents/backends/shell/types.py +55 -0
  28. aury/agents/backends/shell.py +209 -0
  29. aury/agents/backends/snapshot/__init__.py +19 -0
  30. aury/agents/backends/snapshot/git.py +95 -0
  31. aury/agents/backends/snapshot/hybrid.py +125 -0
  32. aury/agents/backends/snapshot/memory.py +86 -0
  33. aury/agents/backends/snapshot/types.py +59 -0
  34. aury/agents/backends/state/__init__.py +29 -0
  35. aury/agents/backends/state/composite.py +49 -0
  36. aury/agents/backends/state/file.py +57 -0
  37. aury/agents/backends/state/memory.py +52 -0
  38. aury/agents/backends/state/sqlite.py +262 -0
  39. aury/agents/backends/state/types.py +178 -0
  40. aury/agents/backends/subagent/__init__.py +165 -0
  41. aury/agents/cli/__init__.py +41 -0
  42. aury/agents/cli/chat.py +239 -0
  43. aury/agents/cli/config.py +236 -0
  44. aury/agents/cli/extensions.py +460 -0
  45. aury/agents/cli/main.py +189 -0
  46. aury/agents/cli/session.py +337 -0
  47. aury/agents/cli/workflow.py +276 -0
  48. aury/agents/context_providers/__init__.py +66 -0
  49. aury/agents/context_providers/artifact.py +299 -0
  50. aury/agents/context_providers/base.py +177 -0
  51. aury/agents/context_providers/memory.py +70 -0
  52. aury/agents/context_providers/message.py +130 -0
  53. aury/agents/context_providers/skill.py +50 -0
  54. aury/agents/context_providers/subagent.py +46 -0
  55. aury/agents/context_providers/tool.py +68 -0
  56. aury/agents/core/__init__.py +83 -0
  57. aury/agents/core/base.py +573 -0
  58. aury/agents/core/context.py +797 -0
  59. aury/agents/core/context_builder.py +303 -0
  60. aury/agents/core/event_bus/__init__.py +15 -0
  61. aury/agents/core/event_bus/bus.py +203 -0
  62. aury/agents/core/factory.py +169 -0
  63. aury/agents/core/isolator.py +97 -0
  64. aury/agents/core/logging.py +95 -0
  65. aury/agents/core/parallel.py +194 -0
  66. aury/agents/core/runner.py +139 -0
  67. aury/agents/core/services/__init__.py +5 -0
  68. aury/agents/core/services/file_session.py +144 -0
  69. aury/agents/core/services/message.py +53 -0
  70. aury/agents/core/services/session.py +53 -0
  71. aury/agents/core/signals.py +109 -0
  72. aury/agents/core/state.py +363 -0
  73. aury/agents/core/types/__init__.py +107 -0
  74. aury/agents/core/types/action.py +176 -0
  75. aury/agents/core/types/artifact.py +135 -0
  76. aury/agents/core/types/block.py +736 -0
  77. aury/agents/core/types/message.py +350 -0
  78. aury/agents/core/types/recall.py +144 -0
  79. aury/agents/core/types/session.py +257 -0
  80. aury/agents/core/types/subagent.py +154 -0
  81. aury/agents/core/types/tool.py +205 -0
  82. aury/agents/eval/__init__.py +331 -0
  83. aury/agents/hitl/__init__.py +57 -0
  84. aury/agents/hitl/ask_user.py +242 -0
  85. aury/agents/hitl/compaction.py +230 -0
  86. aury/agents/hitl/exceptions.py +87 -0
  87. aury/agents/hitl/permission.py +617 -0
  88. aury/agents/hitl/revert.py +216 -0
  89. aury/agents/llm/__init__.py +31 -0
  90. aury/agents/llm/adapter.py +367 -0
  91. aury/agents/llm/openai.py +294 -0
  92. aury/agents/llm/provider.py +476 -0
  93. aury/agents/mcp/__init__.py +153 -0
  94. aury/agents/memory/__init__.py +46 -0
  95. aury/agents/memory/compaction.py +394 -0
  96. aury/agents/memory/manager.py +465 -0
  97. aury/agents/memory/processor.py +177 -0
  98. aury/agents/memory/store.py +187 -0
  99. aury/agents/memory/types.py +137 -0
  100. aury/agents/messages/__init__.py +40 -0
  101. aury/agents/messages/config.py +47 -0
  102. aury/agents/messages/raw_store.py +224 -0
  103. aury/agents/messages/store.py +118 -0
  104. aury/agents/messages/types.py +88 -0
  105. aury/agents/middleware/__init__.py +31 -0
  106. aury/agents/middleware/base.py +341 -0
  107. aury/agents/middleware/chain.py +342 -0
  108. aury/agents/middleware/message.py +129 -0
  109. aury/agents/middleware/message_container.py +126 -0
  110. aury/agents/middleware/raw_message.py +153 -0
  111. aury/agents/middleware/truncation.py +139 -0
  112. aury/agents/middleware/types.py +81 -0
  113. aury/agents/plugin.py +162 -0
  114. aury/agents/react/__init__.py +4 -0
  115. aury/agents/react/agent.py +1923 -0
  116. aury/agents/sandbox/__init__.py +23 -0
  117. aury/agents/sandbox/local.py +239 -0
  118. aury/agents/sandbox/remote.py +200 -0
  119. aury/agents/sandbox/types.py +115 -0
  120. aury/agents/skill/__init__.py +16 -0
  121. aury/agents/skill/loader.py +180 -0
  122. aury/agents/skill/types.py +83 -0
  123. aury/agents/tool/__init__.py +39 -0
  124. aury/agents/tool/builtin/__init__.py +23 -0
  125. aury/agents/tool/builtin/ask_user.py +155 -0
  126. aury/agents/tool/builtin/bash.py +107 -0
  127. aury/agents/tool/builtin/delegate.py +726 -0
  128. aury/agents/tool/builtin/edit.py +121 -0
  129. aury/agents/tool/builtin/plan.py +277 -0
  130. aury/agents/tool/builtin/read.py +91 -0
  131. aury/agents/tool/builtin/thinking.py +111 -0
  132. aury/agents/tool/builtin/yield_result.py +130 -0
  133. aury/agents/tool/decorator.py +252 -0
  134. aury/agents/tool/set.py +204 -0
  135. aury/agents/usage/__init__.py +12 -0
  136. aury/agents/usage/tracker.py +236 -0
  137. aury/agents/workflow/__init__.py +85 -0
  138. aury/agents/workflow/adapter.py +268 -0
  139. aury/agents/workflow/dag.py +116 -0
  140. aury/agents/workflow/dsl.py +575 -0
  141. aury/agents/workflow/executor.py +659 -0
  142. aury/agents/workflow/expression.py +136 -0
  143. aury/agents/workflow/parser.py +182 -0
  144. aury/agents/workflow/state.py +145 -0
  145. aury/agents/workflow/types.py +86 -0
  146. aury_agent-0.0.4.dist-info/METADATA +90 -0
  147. aury_agent-0.0.4.dist-info/RECORD +149 -0
  148. aury_agent-0.0.4.dist-info/WHEEL +4 -0
  149. aury_agent-0.0.4.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,736 @@
1
+ """Block and BlockEvent data structures for streaming protocol."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ from typing import TYPE_CHECKING, Any, Literal
8
+
9
+ from .session import generate_id
10
+
11
+ if TYPE_CHECKING:
12
+ from ..context import InvocationContext
13
+
14
+
15
+ class BlockKind(str, Enum):
16
+ """Framework built-in block types.
17
+
18
+ These are the default kinds used by the framework internally.
19
+ Developers can use any custom string as block kind -
20
+ this enum is not exhaustive.
21
+
22
+ Example custom kinds: "code", "chart", "table", "image", etc.
23
+ """
24
+
25
+ # === Content ===
26
+ TEXT = "text" # Plain text / markdown
27
+ THINKING = "thinking" # LLM reasoning (collapsible)
28
+
29
+ # === Tool Execution ===
30
+ TOOL_USE = "tool_use" # Tool call
31
+ TOOL_RESULT = "tool_result" # Tool result
32
+
33
+ # === Agent ===
34
+ SUB_AGENT = "sub_agent" # Sub-agent delegation
35
+ PLAN = "plan" # Execution plan with checklist
36
+
37
+ # === Workflow ===
38
+ START = "start" # Workflow start
39
+ END = "end" # Workflow end
40
+ NODE = "node" # Workflow node execution block
41
+
42
+ # === HITL ===
43
+ HITL_REQUEST = "hitl_request" # HITL request (any format - choice, input, confirm, etc.)
44
+
45
+ # === Control Flow ===
46
+ YIELD = "yield" # Return control to parent
47
+
48
+ # === Output ===
49
+ ARTIFACT = "artifact" # Generated artifact (file, document, etc.)
50
+ ERROR = "error" # Error message
51
+
52
+
53
+ class BlockOp(str, Enum):
54
+ """Block operations.
55
+
56
+ Blocks have no lifecycle - they exist once created and can be
57
+ operated on at any time via their id.
58
+ """
59
+ APPLY = "apply" # Complete data (create or replace)
60
+ DELTA = "delta" # Incremental append
61
+ PATCH = "patch" # Partial modification
62
+
63
+
64
+ class Persistence(str, Enum):
65
+ """Framework built-in persistence types.
66
+
67
+ Developers can use custom strings for specialized persistence behaviors.
68
+ """
69
+ PERSISTENT = "persistent" # Stored to backend
70
+ TRANSIENT = "transient" # Not stored (progress bars, spinners, etc.)
71
+
72
+
73
+ @dataclass
74
+ class ActorInfo:
75
+ """Actor information."""
76
+ id: str
77
+ role: Literal["user", "assistant", "system"]
78
+ name: str | None = None
79
+ meta: dict[str, Any] | None = None
80
+
81
+ def to_dict(self) -> dict[str, Any]:
82
+ return {
83
+ "id": self.id,
84
+ "role": self.role,
85
+ "name": self.name,
86
+ "meta": self.meta,
87
+ }
88
+
89
+ @classmethod
90
+ def from_dict(cls, data: dict[str, Any]) -> ActorInfo:
91
+ return cls(
92
+ id=data["id"],
93
+ role=data["role"],
94
+ name=data.get("name"),
95
+ meta=data.get("meta"),
96
+ )
97
+
98
+
99
+ @dataclass
100
+ class BlockEvent:
101
+ """Streaming block event.
102
+
103
+ Used for real-time streaming to frontend.
104
+ Operations: APPLY (create/replace), DELTA (append), PATCH (partial update)
105
+ """
106
+ block_id: str = field(default_factory=lambda: generate_id("blk"))
107
+ parent_id: str | None = None # For nesting
108
+ kind: BlockKind | str = BlockKind.TEXT
109
+ persistence: Persistence = Persistence.PERSISTENT
110
+ op: BlockOp = BlockOp.APPLY
111
+ data: dict[str, Any] | None = None
112
+ branch: str | None = None # For sub-agent isolation (e.g. "agent1.agent2")
113
+
114
+ # Protocol envelope
115
+ protocol_version: str = "1.0"
116
+ event_id: str = field(default_factory=lambda: generate_id("evt"))
117
+ timestamp: int = field(default_factory=lambda: int(datetime.now().timestamp() * 1000))
118
+ invocation_id: str = ""
119
+ session_id: str = ""
120
+ actor: ActorInfo | None = None
121
+
122
+ def to_dict(self) -> dict[str, Any]:
123
+ """Convert to dictionary for serialization."""
124
+ kind_value = self.kind.value if isinstance(self.kind, BlockKind) else self.kind
125
+ return {
126
+ "block_id": self.block_id,
127
+ "parent_id": self.parent_id,
128
+ "kind": kind_value,
129
+ "persistence": self.persistence.value,
130
+ "op": self.op.value,
131
+ "data": self.data,
132
+ "branch": self.branch,
133
+ "protocol_version": self.protocol_version,
134
+ "event_id": self.event_id,
135
+ "timestamp": self.timestamp,
136
+ "invocation_id": self.invocation_id,
137
+ "session_id": self.session_id,
138
+ "actor": self.actor.to_dict() if self.actor else None,
139
+ }
140
+
141
+ @classmethod
142
+ def from_dict(cls, data: dict[str, Any]) -> BlockEvent:
143
+ """Create from dictionary."""
144
+ kind_str = data["kind"]
145
+ try:
146
+ kind = BlockKind(kind_str)
147
+ except ValueError:
148
+ kind = kind_str # Custom kind string
149
+ return cls(
150
+ block_id=data["block_id"],
151
+ parent_id=data.get("parent_id"),
152
+ kind=kind,
153
+ persistence=Persistence(data["persistence"]),
154
+ op=BlockOp(data["op"]),
155
+ data=data.get("data"),
156
+ branch=data.get("branch"),
157
+ protocol_version=data.get("protocol_version", "1.0"),
158
+ event_id=data.get("event_id", ""),
159
+ timestamp=data.get("timestamp", 0),
160
+ invocation_id=data.get("invocation_id", ""),
161
+ session_id=data.get("session_id", ""),
162
+ actor=ActorInfo.from_dict(data["actor"]) if data.get("actor") else None,
163
+ )
164
+
165
+
166
+ # ============================================================
167
+ # BlockMerger - Merge BlockEvents by kind
168
+ # ============================================================
169
+
170
+ class BlockMerger:
171
+ """Base class for block mergers.
172
+
173
+ Mergers define how to combine multiple BlockEvents into final data.
174
+ Register custom mergers for custom block kinds.
175
+
176
+ Override apply/delta/patch methods to customize specific operations.
177
+ """
178
+
179
+ def merge(self, current: dict[str, Any] | None, event: "BlockEvent") -> dict[str, Any]:
180
+ """Merge event into current data. Dispatches to apply/delta/patch."""
181
+ if event.op == BlockOp.APPLY:
182
+ return self.apply(current, event)
183
+ elif event.op == BlockOp.DELTA:
184
+ return self.delta(current, event)
185
+ elif event.op == BlockOp.PATCH:
186
+ return self.patch(current, event)
187
+ return current or {}
188
+
189
+ def apply(self, current: dict[str, Any] | None, event: "BlockEvent") -> dict[str, Any]:
190
+ """Handle APPLY: replace entirely with new data."""
191
+ return event.data or {}
192
+
193
+ def delta(self, current: dict[str, Any] | None, event: "BlockEvent") -> dict[str, Any]:
194
+ """Handle DELTA: append/accumulate data.
195
+
196
+ Default: string concatenation, list extension.
197
+ """
198
+ data = dict(current) if current else {}
199
+ if event.data:
200
+ for key, value in event.data.items():
201
+ if isinstance(value, str) and isinstance(data.get(key, ""), str):
202
+ data[key] = data.get(key, "") + value
203
+ elif isinstance(value, list) and isinstance(data.get(key), list):
204
+ data[key] = data.get(key, []) + value
205
+ else:
206
+ data[key] = value
207
+ return data
208
+
209
+ def patch(self, current: dict[str, Any] | None, event: "BlockEvent") -> dict[str, Any]:
210
+ """Handle PATCH: partial update with JSON Path syntax.
211
+
212
+ Default: supports nested path like "a.b.c" or "items[0].name".
213
+ """
214
+ data = dict(current) if current else {}
215
+ if event.data:
216
+ for path, value in event.data.items():
217
+ self._set_path(data, path, value)
218
+ return data
219
+
220
+ def _set_path(self, data: dict, path: str, value: Any) -> None:
221
+ """Set value at path (supports nested paths).
222
+
223
+ Path syntax:
224
+ - "key" -> data["key"]
225
+ - "a.b.c" -> data["a"]["b"]["c"]
226
+ - "items[0]" -> data["items"][0]
227
+ - "items[0].name" -> data["items"][0]["name"]
228
+ """
229
+ import re
230
+
231
+ # Parse path into parts
232
+ parts = []
233
+ for segment in path.replace("]", "").split("."):
234
+ if "[" in segment:
235
+ key, idx = segment.split("[")
236
+ if key:
237
+ parts.append(key)
238
+ parts.append(int(idx))
239
+ else:
240
+ parts.append(segment)
241
+
242
+ # Navigate to parent
243
+ current = data
244
+ for part in parts[:-1]:
245
+ if isinstance(part, int):
246
+ # Ensure list exists and is long enough
247
+ if not isinstance(current, list):
248
+ break
249
+ while len(current) <= part:
250
+ current.append({})
251
+ current = current[part]
252
+ else:
253
+ if part not in current:
254
+ current[part] = {}
255
+ current = current[part]
256
+
257
+ # Set final value
258
+ final_key = parts[-1]
259
+ if isinstance(final_key, int) and isinstance(current, list):
260
+ while len(current) <= final_key:
261
+ current.append(None)
262
+ current[final_key] = value
263
+ elif isinstance(current, dict):
264
+ current[final_key] = value
265
+
266
+
267
+ # Merger registry
268
+ _block_mergers: dict[str, BlockMerger] = {}
269
+ _default_merger = BlockMerger()
270
+
271
+
272
+ def register_merger(kind: str, merger: BlockMerger) -> None:
273
+ """Register a custom merger for a block kind.
274
+
275
+ Args:
276
+ kind: Block kind (e.g. "plan", "my_custom_type")
277
+ merger: Merger instance
278
+ """
279
+ _block_mergers[kind] = merger
280
+
281
+
282
+ def get_merger(kind: str | BlockKind) -> BlockMerger:
283
+ """Get merger for a block kind.
284
+
285
+ Args:
286
+ kind: Block kind
287
+
288
+ Returns:
289
+ Registered merger or default merger
290
+ """
291
+ kind_str = kind.value if isinstance(kind, BlockKind) else kind
292
+ return _block_mergers.get(kind_str, _default_merger)
293
+
294
+
295
+ # ============================================================
296
+ # PersistedBlock
297
+ # ============================================================
298
+
299
+ @dataclass
300
+ class PersistedBlock:
301
+ """Persisted block (final state, no op).
302
+
303
+ This is what gets stored after BlockEvent stream is complete.
304
+ """
305
+ block_id: str
306
+ parent_id: str | None = None
307
+ kind: BlockKind | str = BlockKind.TEXT
308
+ data: dict[str, Any] | None = None
309
+
310
+ # From protocol envelope
311
+ session_id: str = ""
312
+ invocation_id: str = ""
313
+ actor_id: str = ""
314
+ actor_role: str = "assistant"
315
+
316
+ # Branch for sub-agent isolation
317
+ branch: str | None = None
318
+
319
+ # Timestamps
320
+ created_at: datetime = field(default_factory=datetime.now)
321
+ updated_at: datetime | None = None
322
+
323
+ metadata: dict[str, Any] = field(default_factory=dict)
324
+
325
+ def to_dict(self) -> dict[str, Any]:
326
+ """Convert to dictionary for serialization."""
327
+ kind_value = self.kind.value if isinstance(self.kind, BlockKind) else self.kind
328
+ return {
329
+ "block_id": self.block_id,
330
+ "parent_id": self.parent_id,
331
+ "kind": kind_value,
332
+ "data": self.data,
333
+ "session_id": self.session_id,
334
+ "invocation_id": self.invocation_id,
335
+ "actor_id": self.actor_id,
336
+ "actor_role": self.actor_role,
337
+ "branch": self.branch,
338
+ "created_at": self.created_at.isoformat(),
339
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None,
340
+ "metadata": self.metadata,
341
+ }
342
+
343
+ @classmethod
344
+ def from_dict(cls, data: dict[str, Any]) -> PersistedBlock:
345
+ """Create from dictionary."""
346
+ kind_str = data["kind"]
347
+ try:
348
+ kind = BlockKind(kind_str)
349
+ except ValueError:
350
+ kind = kind_str
351
+ return cls(
352
+ block_id=data["block_id"],
353
+ parent_id=data.get("parent_id"),
354
+ kind=kind,
355
+ data=data.get("data"),
356
+ session_id=data.get("session_id", ""),
357
+ invocation_id=data.get("invocation_id", ""),
358
+ actor_id=data.get("actor_id", ""),
359
+ actor_role=data.get("actor_role", "assistant"),
360
+ branch=data.get("branch"),
361
+ created_at=datetime.fromisoformat(data["created_at"]),
362
+ updated_at=datetime.fromisoformat(data["updated_at"]) if data.get("updated_at") else None,
363
+ metadata=data.get("metadata", {}),
364
+ )
365
+
366
+ @classmethod
367
+ def from_events(cls, events: list[BlockEvent]) -> "PersistedBlock":
368
+ """Aggregate BlockEvents for a SINGLE block into a PersistedBlock.
369
+
370
+ All events must have the same block_id.
371
+ Uses registered merger for the block kind to combine events.
372
+ """
373
+ if not events:
374
+ raise ValueError("Cannot create PersistedBlock from empty events")
375
+
376
+ first = events[0]
377
+ kind = first.kind
378
+
379
+ # Get merger for this kind
380
+ merger = get_merger(kind)
381
+
382
+ # Merge all events
383
+ data: dict[str, Any] | None = None
384
+ last_timestamp = first.timestamp
385
+
386
+ for event in events:
387
+ data = merger.merge(data, event)
388
+ last_timestamp = event.timestamp
389
+
390
+ return cls(
391
+ block_id=first.block_id,
392
+ parent_id=first.parent_id,
393
+ kind=kind,
394
+ data=data,
395
+ branch=first.branch,
396
+ session_id=first.session_id,
397
+ invocation_id=first.invocation_id,
398
+ actor_id=first.actor.id if first.actor else "",
399
+ actor_role=first.actor.role if first.actor else "assistant",
400
+ updated_at=datetime.fromtimestamp(last_timestamp / 1000) if len(events) > 1 else None,
401
+ )
402
+
403
+ @classmethod
404
+ def from_event_stream(cls, events: list[BlockEvent]) -> list["PersistedBlock"]:
405
+ """Aggregate BlockEvents into multiple PersistedBlocks.
406
+
407
+ Groups events by block_id, then merges each group.
408
+ Returns blocks in order of first appearance.
409
+
410
+ Args:
411
+ events: List of BlockEvents (can have multiple block_ids)
412
+
413
+ Returns:
414
+ List of PersistedBlocks, one per unique block_id
415
+ """
416
+ if not events:
417
+ return []
418
+
419
+ # Group by block_id, preserving order
420
+ from collections import OrderedDict
421
+ grouped: OrderedDict[str, list[BlockEvent]] = OrderedDict()
422
+ for event in events:
423
+ if event.block_id not in grouped:
424
+ grouped[event.block_id] = []
425
+ grouped[event.block_id].append(event)
426
+
427
+ # Create PersistedBlock for each group
428
+ return [cls.from_events(group) for group in grouped.values()]
429
+
430
+
431
+ # ============================================================
432
+ # BlockAggregator - Aggregate events into blocks
433
+ # ============================================================
434
+
435
+ class BlockAggregator:
436
+ """Aggregates BlockEvents into PersistedBlocks incrementally.
437
+
438
+ Processes events one by one, updating blocks as they arrive.
439
+
440
+ Example:
441
+ aggregator = BlockAggregator()
442
+ async for event in event_stream:
443
+ aggregator.process(event)
444
+ blocks = aggregator.blocks # Final result
445
+ """
446
+
447
+ def __init__(self):
448
+ self._blocks: dict[str, PersistedBlock] = {}
449
+ self._order: list[str] = [] # Track insertion order
450
+
451
+ def process(self, event: BlockEvent) -> PersistedBlock:
452
+ """Process a single event, updating the corresponding block.
453
+
454
+ Returns the updated PersistedBlock.
455
+ """
456
+ block_id = event.block_id
457
+ merger = get_merger(event.kind)
458
+
459
+ if block_id in self._blocks:
460
+ # Update existing block
461
+ block = self._blocks[block_id]
462
+ block.data = merger.merge(block.data, event)
463
+ block.updated_at = datetime.fromtimestamp(event.timestamp / 1000)
464
+ else:
465
+ # Create new block
466
+ block = PersistedBlock(
467
+ block_id=block_id,
468
+ parent_id=event.parent_id,
469
+ kind=event.kind,
470
+ data=merger.merge(None, event),
471
+ branch=event.branch,
472
+ session_id=event.session_id,
473
+ invocation_id=event.invocation_id,
474
+ actor_id=event.actor.id if event.actor else "",
475
+ actor_role=event.actor.role if event.actor else "assistant",
476
+ )
477
+ self._blocks[block_id] = block
478
+ self._order.append(block_id)
479
+
480
+ return block
481
+
482
+ def get(self, block_id: str) -> PersistedBlock | None:
483
+ """Get a block by ID."""
484
+ return self._blocks.get(block_id)
485
+
486
+ @property
487
+ def blocks(self) -> list[PersistedBlock]:
488
+ """Get all blocks in order of first appearance."""
489
+ return [self._blocks[bid] for bid in self._order]
490
+
491
+ def clear(self) -> None:
492
+ """Clear all blocks."""
493
+ self._blocks.clear()
494
+ self._order.clear()
495
+
496
+
497
+ # ============================================================
498
+ # BlockHandle - Manage Block lifecycle
499
+ # ============================================================
500
+
501
+ class BlockHandle:
502
+ """Block handle - manages a single Block's lifecycle.
503
+
504
+ Use this to manage a Block that needs multiple operations over time:
505
+ - Streaming text output (APPLY -> DELTA -> DELTA -> ...)
506
+ - Plan with progress updates (APPLY -> PATCH -> PATCH -> ...)
507
+ - Any Block that needs cross-content modification
508
+
509
+ Example:
510
+ # Streaming text
511
+ text = BlockHandle(ctx, kind="text")
512
+ await text.apply({"content": ""})
513
+ await text.delta({"delta": "Hello "})
514
+ await text.delta({"delta": "World"})
515
+
516
+ # Plan with updates
517
+ plan = BlockHandle(ctx, kind="plan")
518
+ await plan.apply({"steps": [...], "current": 0})
519
+ await plan.patch({"current": 1, "steps[0].status": "done"})
520
+
521
+ # Nested blocks
522
+ tool = BlockHandle(ctx, kind="tool_use")
523
+ await tool.apply({"name": "bash", "args": {...}})
524
+ output = BlockHandle(ctx, kind="text", parent=tool)
525
+ await output.apply({"content": "result..."})
526
+ """
527
+
528
+ def __init__(
529
+ self,
530
+ ctx: "InvocationContext",
531
+ kind: BlockKind | str,
532
+ block_id: str | None = None,
533
+ parent: "BlockHandle | str | None" = None,
534
+ persistence: Persistence = Persistence.PERSISTENT,
535
+ branch: str | None = None,
536
+ ):
537
+ self.ctx = ctx
538
+ self.kind = kind
539
+ self.block_id = block_id or generate_id("blk")
540
+ self.parent_id = parent.block_id if isinstance(parent, BlockHandle) else parent
541
+ self.persistence = persistence
542
+ # Use provided branch or get from context
543
+ self.branch = branch or getattr(ctx, 'branch', None)
544
+
545
+ async def apply(self, data: dict[str, Any], **kwargs: Any) -> None:
546
+ """Create or completely replace the Block."""
547
+ await self.ctx.emit(BlockEvent(
548
+ block_id=self.block_id,
549
+ parent_id=self.parent_id,
550
+ kind=self.kind,
551
+ persistence=self.persistence,
552
+ op=BlockOp.APPLY,
553
+ data=data,
554
+ branch=self.branch,
555
+ **kwargs,
556
+ ))
557
+
558
+ async def delta(self, data: dict[str, Any]) -> None:
559
+ """Append incremental data to the Block."""
560
+ await self.ctx.emit(BlockEvent(
561
+ block_id=self.block_id,
562
+ parent_id=self.parent_id,
563
+ kind=self.kind,
564
+ persistence=self.persistence,
565
+ op=BlockOp.DELTA,
566
+ data=data,
567
+ branch=self.branch,
568
+ ))
569
+
570
+ async def patch(self, data: dict[str, Any]) -> None:
571
+ """Partially update the Block."""
572
+ await self.ctx.emit(BlockEvent(
573
+ block_id=self.block_id,
574
+ parent_id=self.parent_id,
575
+ kind=self.kind,
576
+ persistence=self.persistence,
577
+ op=BlockOp.PATCH,
578
+ data=data,
579
+ branch=self.branch,
580
+ ))
581
+
582
+ def child(
583
+ self,
584
+ kind: BlockKind | str,
585
+ block_id: str | None = None,
586
+ persistence: Persistence = Persistence.PERSISTENT,
587
+ ) -> "BlockHandle":
588
+ """Create a child BlockHandle nested under this Block."""
589
+ return BlockHandle(
590
+ ctx=self.ctx,
591
+ kind=kind,
592
+ block_id=block_id,
593
+ parent=self,
594
+ persistence=persistence,
595
+ branch=self.branch, # Inherit branch from parent
596
+ )
597
+
598
+
599
+ # ============================================================
600
+ # Helper functions for creating BlockEvents (low-level API)
601
+ # ============================================================
602
+
603
+ def text_block(
604
+ block_id: str | None = None,
605
+ content: str = "",
606
+ parent_id: str | None = None,
607
+ session_id: str = "",
608
+ invocation_id: str = "",
609
+ actor: ActorInfo | None = None,
610
+ ) -> BlockEvent:
611
+ """Create a text block."""
612
+ return BlockEvent(
613
+ block_id=block_id or generate_id("blk"),
614
+ parent_id=parent_id,
615
+ kind=BlockKind.TEXT,
616
+ op=BlockOp.APPLY,
617
+ data={"content": content} if content else None,
618
+ session_id=session_id,
619
+ invocation_id=invocation_id,
620
+ actor=actor,
621
+ )
622
+
623
+
624
+ def text_delta(block_id: str, delta: str) -> BlockEvent:
625
+ """Create a text block delta event."""
626
+ return BlockEvent(
627
+ block_id=block_id,
628
+ kind=BlockKind.TEXT,
629
+ op=BlockOp.DELTA,
630
+ data={"content": delta},
631
+ )
632
+
633
+
634
+ def thinking_block(
635
+ block_id: str | None = None,
636
+ content: str = "",
637
+ parent_id: str | None = None,
638
+ session_id: str = "",
639
+ invocation_id: str = "",
640
+ ) -> BlockEvent:
641
+ """Create a thinking block."""
642
+ return BlockEvent(
643
+ block_id=block_id or generate_id("blk"),
644
+ parent_id=parent_id,
645
+ kind=BlockKind.THINKING,
646
+ op=BlockOp.APPLY,
647
+ data={"content": content} if content else None,
648
+ session_id=session_id,
649
+ invocation_id=invocation_id,
650
+ )
651
+
652
+
653
+ def thinking_delta(block_id: str, delta: str) -> BlockEvent:
654
+ """Create a thinking block delta event."""
655
+ return BlockEvent(
656
+ block_id=block_id,
657
+ kind=BlockKind.THINKING,
658
+ op=BlockOp.DELTA,
659
+ data={"content": delta},
660
+ )
661
+
662
+
663
+ def tool_use_block(
664
+ block_id: str,
665
+ name: str,
666
+ call_id: str,
667
+ args: dict[str, Any] | None = None,
668
+ parent_id: str | None = None,
669
+ session_id: str = "",
670
+ invocation_id: str = "",
671
+ ) -> BlockEvent:
672
+ """Create a tool use block."""
673
+ data = {"name": name, "call_id": call_id}
674
+ if args:
675
+ data["arguments"] = args
676
+ return BlockEvent(
677
+ block_id=block_id,
678
+ parent_id=parent_id,
679
+ kind=BlockKind.TOOL_USE,
680
+ op=BlockOp.APPLY,
681
+ data=data,
682
+ session_id=session_id,
683
+ invocation_id=invocation_id,
684
+ )
685
+
686
+
687
+ def tool_use_patch(block_id: str, args: dict[str, Any]) -> BlockEvent:
688
+ """Patch a tool use block with arguments."""
689
+ return BlockEvent(
690
+ block_id=block_id,
691
+ kind=BlockKind.TOOL_USE,
692
+ op=BlockOp.PATCH,
693
+ data={"arguments": args},
694
+ )
695
+
696
+
697
+ def tool_result_block(
698
+ block_id: str,
699
+ tool_use_id: str,
700
+ content: str,
701
+ is_error: bool = False,
702
+ parent_id: str | None = None,
703
+ ) -> BlockEvent:
704
+ """Create a tool result block."""
705
+ return BlockEvent(
706
+ block_id=block_id,
707
+ parent_id=parent_id,
708
+ kind=BlockKind.TOOL_RESULT,
709
+ op=BlockOp.APPLY,
710
+ data={
711
+ "tool_use_id": tool_use_id,
712
+ "content": content,
713
+ "is_error": is_error,
714
+ },
715
+ )
716
+
717
+
718
+ def error_block(
719
+ block_id: str,
720
+ code: str,
721
+ message: str,
722
+ recoverable: bool = True,
723
+ parent_id: str | None = None,
724
+ ) -> BlockEvent:
725
+ """Create an error block."""
726
+ return BlockEvent(
727
+ block_id=block_id,
728
+ parent_id=parent_id,
729
+ kind=BlockKind.ERROR,
730
+ op=BlockOp.APPLY,
731
+ data={
732
+ "code": code,
733
+ "message": message,
734
+ "recoverable": recoverable,
735
+ },
736
+ )