aethergraph 0.1.0a3__py3-none-any.whl → 0.1.0a4__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 (113) hide show
  1. aethergraph/api/v1/artifacts.py +23 -4
  2. aethergraph/api/v1/schemas.py +7 -0
  3. aethergraph/api/v1/session.py +123 -4
  4. aethergraph/config/config.py +2 -0
  5. aethergraph/config/search.py +49 -0
  6. aethergraph/contracts/services/channel.py +18 -1
  7. aethergraph/contracts/services/execution.py +58 -0
  8. aethergraph/contracts/services/llm.py +26 -0
  9. aethergraph/contracts/services/memory.py +10 -4
  10. aethergraph/contracts/services/planning.py +53 -0
  11. aethergraph/contracts/storage/event_log.py +8 -0
  12. aethergraph/contracts/storage/search_backend.py +47 -0
  13. aethergraph/contracts/storage/vector_index.py +73 -0
  14. aethergraph/core/graph/action_spec.py +76 -0
  15. aethergraph/core/graph/graph_fn.py +75 -2
  16. aethergraph/core/graph/graphify.py +74 -2
  17. aethergraph/core/runtime/graph_runner.py +2 -1
  18. aethergraph/core/runtime/node_context.py +66 -3
  19. aethergraph/core/runtime/node_services.py +8 -0
  20. aethergraph/core/runtime/run_manager.py +263 -271
  21. aethergraph/core/runtime/run_types.py +54 -1
  22. aethergraph/core/runtime/runtime_env.py +35 -14
  23. aethergraph/core/runtime/runtime_services.py +308 -18
  24. aethergraph/plugins/agents/default_chat_agent.py +266 -74
  25. aethergraph/plugins/agents/default_chat_agent_v2.py +487 -0
  26. aethergraph/plugins/channel/adapters/webui.py +69 -21
  27. aethergraph/plugins/channel/routes/webui_routes.py +8 -48
  28. aethergraph/runtime/__init__.py +12 -0
  29. aethergraph/server/app_factory.py +3 -0
  30. aethergraph/server/ui_static/assets/index-CFktGdbW.js +4913 -0
  31. aethergraph/server/ui_static/assets/index-DcfkFlTA.css +1 -0
  32. aethergraph/server/ui_static/index.html +2 -2
  33. aethergraph/services/artifacts/facade.py +157 -21
  34. aethergraph/services/artifacts/types.py +35 -0
  35. aethergraph/services/artifacts/utils.py +42 -0
  36. aethergraph/services/channel/channel_bus.py +3 -1
  37. aethergraph/services/channel/event_hub copy.py +55 -0
  38. aethergraph/services/channel/event_hub.py +81 -0
  39. aethergraph/services/channel/factory.py +3 -2
  40. aethergraph/services/channel/session.py +709 -74
  41. aethergraph/services/container/default_container.py +69 -7
  42. aethergraph/services/execution/__init__.py +0 -0
  43. aethergraph/services/execution/local_python.py +118 -0
  44. aethergraph/services/indices/__init__.py +0 -0
  45. aethergraph/services/indices/global_indices.py +21 -0
  46. aethergraph/services/indices/scoped_indices.py +292 -0
  47. aethergraph/services/llm/generic_client.py +342 -46
  48. aethergraph/services/llm/generic_embed_client.py +359 -0
  49. aethergraph/services/llm/types.py +3 -1
  50. aethergraph/services/memory/distillers/llm_long_term.py +60 -109
  51. aethergraph/services/memory/distillers/llm_long_term_v1.py +180 -0
  52. aethergraph/services/memory/distillers/llm_meta_summary.py +57 -266
  53. aethergraph/services/memory/distillers/llm_meta_summary_v1.py +342 -0
  54. aethergraph/services/memory/distillers/long_term.py +48 -131
  55. aethergraph/services/memory/distillers/long_term_v1.py +170 -0
  56. aethergraph/services/memory/facade/chat.py +18 -8
  57. aethergraph/services/memory/facade/core.py +159 -19
  58. aethergraph/services/memory/facade/distillation.py +86 -31
  59. aethergraph/services/memory/facade/retrieval.py +100 -1
  60. aethergraph/services/memory/factory.py +4 -1
  61. aethergraph/services/planning/__init__.py +0 -0
  62. aethergraph/services/planning/action_catalog.py +271 -0
  63. aethergraph/services/planning/bindings.py +56 -0
  64. aethergraph/services/planning/dependency_index.py +65 -0
  65. aethergraph/services/planning/flow_validator.py +263 -0
  66. aethergraph/services/planning/graph_io_adapter.py +150 -0
  67. aethergraph/services/planning/input_parser.py +312 -0
  68. aethergraph/services/planning/missing_inputs.py +28 -0
  69. aethergraph/services/planning/node_planner.py +613 -0
  70. aethergraph/services/planning/orchestrator.py +112 -0
  71. aethergraph/services/planning/plan_executor.py +506 -0
  72. aethergraph/services/planning/plan_types.py +321 -0
  73. aethergraph/services/planning/planner.py +617 -0
  74. aethergraph/services/planning/planner_service.py +369 -0
  75. aethergraph/services/planning/planning_context_builder.py +43 -0
  76. aethergraph/services/planning/quick_actions.py +29 -0
  77. aethergraph/services/planning/routers/__init__.py +0 -0
  78. aethergraph/services/planning/routers/simple_router.py +26 -0
  79. aethergraph/services/rag/facade.py +0 -3
  80. aethergraph/services/scope/scope.py +30 -30
  81. aethergraph/services/scope/scope_factory.py +15 -7
  82. aethergraph/services/skills/__init__.py +0 -0
  83. aethergraph/services/skills/skill_registry.py +465 -0
  84. aethergraph/services/skills/skills.py +220 -0
  85. aethergraph/services/skills/utils.py +194 -0
  86. aethergraph/storage/artifacts/artifact_index_jsonl.py +16 -10
  87. aethergraph/storage/artifacts/artifact_index_sqlite.py +12 -2
  88. aethergraph/storage/docstore/sqlite_doc_sync.py +1 -1
  89. aethergraph/storage/memory/event_persist.py +42 -2
  90. aethergraph/storage/memory/fs_persist.py +32 -2
  91. aethergraph/storage/search_backend/__init__.py +0 -0
  92. aethergraph/storage/search_backend/generic_vector_backend.py +230 -0
  93. aethergraph/storage/search_backend/null_backend.py +34 -0
  94. aethergraph/storage/search_backend/sqlite_lexical_backend.py +387 -0
  95. aethergraph/storage/search_backend/utils.py +31 -0
  96. aethergraph/storage/search_factory.py +75 -0
  97. aethergraph/storage/vector_index/faiss_index.py +72 -4
  98. aethergraph/storage/vector_index/sqlite_index.py +521 -52
  99. aethergraph/storage/vector_index/sqlite_index_vanila.py +311 -0
  100. aethergraph/storage/vector_index/utils.py +22 -0
  101. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/METADATA +1 -1
  102. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/RECORD +107 -63
  103. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/WHEEL +1 -1
  104. aethergraph/plugins/agents/default_chat_agent copy.py +0 -90
  105. aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +0 -1
  106. aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +0 -400
  107. aethergraph/services/eventhub/event_hub.py +0 -76
  108. aethergraph/services/llm/generic_client copy.py +0 -691
  109. aethergraph/services/prompts/file_store.py +0 -41
  110. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/entry_points.txt +0 -0
  111. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/LICENSE +0 -0
  112. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/NOTICE +0 -0
  113. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/top_level.txt +0 -0
@@ -1,12 +1,84 @@
1
1
  from collections.abc import AsyncIterator, Iterable
2
2
  from contextlib import asynccontextmanager
3
+ import inspect
3
4
  import logging
4
- from typing import Any
5
+ from pathlib import Path, PurePath
6
+ from typing import Any, Literal
7
+ import uuid
5
8
 
9
+ from aethergraph.contracts.services.artifacts import Artifact
6
10
  from aethergraph.contracts.services.channel import Button, FileRef, OutEvent
7
11
  from aethergraph.services.continuations.continuation import Correlator
8
12
 
9
13
 
14
+ def _artifact_filename(artifact: Artifact, fallback: str | None = None) -> str:
15
+ labels = artifact.labels or {}
16
+ if "filename" in labels and labels["filename"]:
17
+ return str(labels["filename"])
18
+
19
+ # If no explicit filename label, try URI
20
+ if artifact.uri:
21
+ try:
22
+ return PurePath(artifact.uri).name or fallback or artifact.artifact_id
23
+ except Exception:
24
+ pass
25
+
26
+ return fallback or artifact.artifact_id
27
+
28
+
29
+ def _artifact_to_chat_file(
30
+ artifact: Artifact,
31
+ fallback_filename: str | None = None,
32
+ ) -> dict[str, Any]:
33
+ labels = artifact.labels or {}
34
+
35
+ filename = (
36
+ labels.get("filename")
37
+ or (PurePath(artifact.uri).name if artifact.uri else None)
38
+ or fallback_filename
39
+ or artifact.artifact_id
40
+ )
41
+
42
+ # 👇 Renderer hint from labels (e.g. {"renderer": "image"})
43
+ renderer = labels.get("renderer")
44
+
45
+ # Make sure we actually set a mimetype if possible
46
+ mime = artifact.mime
47
+ if not mime and filename:
48
+ # crude but fine: infer from extension
49
+ lower = filename.lower()
50
+ if lower.endswith(".png"):
51
+ mime = "image/png"
52
+ elif lower.endswith((".jpg", ".jpeg")):
53
+ mime = "image/jpeg"
54
+ elif lower.endswith(".gif"):
55
+ mime = "image/gif"
56
+
57
+ size = (
58
+ getattr(artifact, "bytes", None)
59
+ or getattr(artifact, "size", None)
60
+ or getattr(artifact, "size_bytes", None)
61
+ )
62
+
63
+ return {
64
+ "name": filename,
65
+ "filename": filename,
66
+ "mimetype": mime,
67
+ "size": size,
68
+ "uri": artifact.artifact_id,
69
+ # "url" key is adapter-specific; we don't set it here; webUI will build from artifact_id and its api
70
+ "renderer": renderer, # key for the UI
71
+ }
72
+
73
+
74
+ def _image_filename(title: str | None, alt: str | None) -> str:
75
+ base = title or alt or "image"
76
+ p = Path(base)
77
+ if p.suffix:
78
+ return base
79
+ return base + ".png"
80
+
81
+
10
82
  class ChannelSession:
11
83
  """Helper to manage a channel-based session within a NodeContext.
12
84
  Provides methods to send messages, ask for user input or approval, and stream messages.
@@ -17,6 +89,18 @@ class ChannelSession:
17
89
  self.ctx = context
18
90
  self._override_key = channel_key # optional strong binding
19
91
 
92
+ @property
93
+ def _memory_facade(self):
94
+ """
95
+ Best-effort resolver for MemoryFacade.
96
+
97
+ We intentionally go via ctx.services.memory_facade instead of ctx.memory()
98
+ so that:
99
+ - we reuse the same scoped facade NodeContext exposes, and
100
+ - we do NOT raise if memory is not bound (auto logging should be optional).
101
+ """
102
+ return getattr(self.ctx.services, "memory_facade", None)
103
+
20
104
  # Channel bus
21
105
  @property
22
106
  def _bus(self):
@@ -70,6 +154,69 @@ class ChannelSession:
70
154
 
71
155
  return base
72
156
 
157
+ def _default_chat_tags(
158
+ self,
159
+ extra: list[str] | None = None,
160
+ *,
161
+ channel: str | None = None,
162
+ ) -> list[str]:
163
+ """
164
+ Derive some lightweight, structured tags from context
165
+ and merge with caller-provided tags.
166
+ """
167
+ tags: list[str] = []
168
+
169
+ # Channel name is very useful when debugging
170
+ try:
171
+ ch = self._resolve_key(channel)
172
+ tags.append(f"channel:{ch}")
173
+ except Exception:
174
+ pass
175
+
176
+ if self._run_id:
177
+ tags.append(f"run:{self._run_id}")
178
+ if self._session_id:
179
+ tags.append(f"session:{self._session_id}")
180
+ if self._node_id:
181
+ tags.append(f"node:{self._node_id}")
182
+
183
+ if extra:
184
+ tags.extend(extra)
185
+
186
+ return tags
187
+
188
+ async def _log_chat(
189
+ self,
190
+ role: Literal["user", "assistant", "system", "tool"],
191
+ text: str,
192
+ *,
193
+ tags: list[str] | None = None,
194
+ data: dict[str, Any] | None = None,
195
+ severity: int = 2,
196
+ signal: float | None = None,
197
+ enabled: bool = True,
198
+ channel: str | None = None,
199
+ ) -> None:
200
+ """
201
+ Internal helper: best-effort chat logging.
202
+ Respects `enabled` and silently no-ops if memory is missing.
203
+ """
204
+ if not enabled or not text:
205
+ return
206
+
207
+ mem = self._memory_facade
208
+ if not mem:
209
+ return
210
+
211
+ await mem.record_chat(
212
+ role,
213
+ text,
214
+ tags=self._default_chat_tags(tags, channel=channel),
215
+ data=data,
216
+ severity=severity,
217
+ signal=signal,
218
+ )
219
+
73
220
  def _resolve_default_key(self) -> str:
74
221
  """Unified default resolver (bus default → console)."""
75
222
  return self._bus.get_default_channel_key() or "console:stdin"
@@ -152,8 +299,94 @@ class ChannelSession:
152
299
  event.meta = self._inject_context_meta(event.meta)
153
300
  await self._bus.publish(event)
154
301
 
302
+ async def send_phase(
303
+ self,
304
+ phase: str,
305
+ status: Literal["pending", "active", "done", "failed", "skipped"],
306
+ *,
307
+ label: str | None = None,
308
+ detail: str | None = None,
309
+ code: str | None = None,
310
+ channel: str | None = None,
311
+ key_suffix: str | None = None,
312
+ ) -> None:
313
+ """
314
+ This method constructs a normalized outbound event representing the current
315
+ phase and its status, merges context-derived metadata, and dispatches the
316
+ event via the channel bus.
317
+
318
+ Examples:
319
+ Sending a phase update with a "pending" status:
320
+ ```python
321
+ await context.channel().send_phase("routing", "pending")
322
+ ```
323
+
324
+ Sending a phase update with additional details and a specific channel:
325
+ ```python
326
+ await context.channel().send_phase(
327
+ "planning",
328
+ "active",
329
+ label="Planning Phase",
330
+ detail="Calculating optimal routes",
331
+ )
332
+ ```
333
+
334
+ Args:
335
+ phase: The logical stage name (e.g., "routing", "planning", "reasoning").
336
+ status: The current state of the phase ("pending", "active", "done", "failed", "skipped").
337
+ label: Optional custom label for the phase (default: capitalized phase name).
338
+ detail: Optional detailed description of the phase (default: empty string).
339
+ code: Optional code identifier for the phase.
340
+ channel: Optional explicit channel key to override the default or session-bound channel.
341
+ key_suffix: Optional suffix to customize the upsert key (default: "phase:{phase}").
342
+
343
+ Returns:
344
+ None
345
+
346
+ Notes:
347
+ - This method will show a phase block in AG WebUI's timeline view if the adapter supports it.
348
+ - Other adapters may choose to render this differently or ignore the rich payload.
349
+ - All stage, status, label, detail, code fields can be customized without a fixed schema; the UI will treat them as opaque strings for display and filtering.
350
+ - The `upsert_key` ensures stable updates for the same phase within a node/run.
351
+ """
352
+ ch_key = self._resolve_key(channel)
353
+ # Stable upsert per phase per node/run
354
+ suffix = key_suffix or f"phase:{phase}"
355
+ upsert_key = f"{self._run_id}:{self._node_id}:{suffix}"
356
+
357
+ rich = {
358
+ "kind": "phase",
359
+ "phase": phase,
360
+ "status": status,
361
+ "label": label or phase.title(),
362
+ "detail": detail or "",
363
+ }
364
+ if code:
365
+ rich["code"] = code
366
+
367
+ await self._bus.publish(
368
+ OutEvent(
369
+ type="agent.progress.update",
370
+ channel=ch_key,
371
+ upsert_key=upsert_key,
372
+ rich=rich,
373
+ meta=self._inject_context_meta(None),
374
+ )
375
+ )
376
+
155
377
  async def send_text(
156
- self, text: str, *, meta: dict[str, Any] | None = None, channel: str | None = None
378
+ self,
379
+ text: str,
380
+ *,
381
+ meta: dict[str, Any] | None = None,
382
+ channel: str | None = None,
383
+ # memory logging handled separately
384
+ memory_log: bool = True,
385
+ memory_role: Literal["user", "assistant", "system", "tool"] = "assistant",
386
+ memory_tags: list[str] | None = None,
387
+ memory_data: dict[str, Any] | None = None, # extra structured data
388
+ memory_severity: int = 2,
389
+ memory_signal: float | None = None,
157
390
  ):
158
391
  """
159
392
  Send a plain text message to the configured channel.
@@ -180,6 +413,12 @@ class ChannelSession:
180
413
  text: The primary text content to send.
181
414
  meta: Optional dictionary of metadata to include with the event.
182
415
  channel: Optional explicit channel key to override the default or session-bound channel.
416
+ memory_log: Whether to log this message to memory (default: True).
417
+ memory_role: The role to use when logging to memory (default: "assistant").
418
+ memory_tags: Optional list of tags to associate with the memory log entry.
419
+ memory_data: Optional structured data to include with the memory log entry.
420
+ memory_severity: Severity level for the memory log entry (default: 2).
421
+ memory_signal: Optional signal value for the memory log entry.
183
422
 
184
423
  Returns:
185
424
  None
@@ -193,6 +432,18 @@ class ChannelSession:
193
432
  }
194
433
  ```
195
434
  """
435
+
436
+ await self._log_chat(
437
+ memory_role,
438
+ text,
439
+ tags=memory_tags,
440
+ data=memory_data,
441
+ severity=memory_severity,
442
+ signal=memory_signal,
443
+ enabled=memory_log,
444
+ channel=channel,
445
+ )
446
+
196
447
  event = OutEvent(
197
448
  type="agent.message",
198
449
  channel=self._resolve_key(channel),
@@ -208,51 +459,100 @@ class ChannelSession:
208
459
  rich: dict[str, Any] | None = None,
209
460
  meta: dict[str, Any] | None = None,
210
461
  channel: str | None = None,
462
+ # memory logging handled separately
463
+ memory_log: bool = True,
464
+ memory_role: Literal["user", "assistant", "system", "tool"] = "assistant",
465
+ memory_tags: list[str] | None = None,
466
+ memory_data: dict[str, Any] | None = None, # extra structured data
467
+ memory_severity: int = 2,
468
+ memory_signal: float | None = None,
211
469
  ):
212
470
  """
213
- Send a rich message to the configured channel.
471
+ Send a rich UI message to the configured channel.
214
472
 
215
- This method constructs and dispatches an outbound event that can include both plain text and
216
- structured rich content (such as cards, tables, or custom payloads). Context-derived metadata
217
- is automatically merged, and the event is published via the channel bus.
473
+ This method constructs a normalized outbound event, merges context-derived metadata,
474
+ and dispatches the message with an optional `rich` payload for UI-aware adapters.
218
475
 
219
476
  Examples:
220
- Basic usage to send a rich message:
477
+ Sending a single rich block:
221
478
  ```python
222
479
  await context.channel().send_rich(
223
- text="Here are your results:",
224
- rich={"table": {"rows": [["A", 1], ["B", 2]]}}
480
+ text="Here is the loss curve:",
481
+ rich={
482
+ "kind": "plot",
483
+ "title": "Training loss",
484
+ "payload": {
485
+ "engine": "vega-lite",
486
+ "spec": loss_vega_spec,
487
+ },
488
+ },
225
489
  )
226
490
  ```
227
491
 
228
- Sending with additional metadata and to a specific channel:
492
+ Sending multiple rich blocks:
229
493
  ```python
230
494
  await context.channel().send_rich(
231
- text="Task completed.",
232
- rich={"status": "success"},
233
- meta={"priority": "high"},
234
- channel="web:chat"
495
+ text="Training summary:",
496
+ rich={
497
+ "blocks": [
498
+ {
499
+ "kind": "plot",
500
+ "title": "Loss",
501
+ "payload": {
502
+ "engine": "vega-lite",
503
+ "spec": loss_vega_spec,
504
+ },
505
+ },
506
+ {
507
+ "kind": "metrics",
508
+ "title": "Key metrics",
509
+ "payload": {
510
+ "items": [
511
+ {"label": "Best val loss", "value": "0.023"},
512
+ {"label": "Epochs", "value": "20"},
513
+ ],
514
+ },
515
+ },
516
+ ]
517
+ },
235
518
  )
236
519
  ```
237
520
 
238
521
  Args:
239
- text: The primary text content to send (optional).
240
- rich: A dictionary containing structured rich content to include with the message.
522
+ text: Optional plain text content to send alongside the rich payload.
523
+ rich: A dictionary representing the structured rich payload. This can be a single block
524
+ or multiple blocks depending on the use case.
241
525
  meta: Optional dictionary of metadata to include with the event.
242
526
  channel: Optional explicit channel key to override the default or session-bound channel.
527
+ memory_log: Whether to log this message to memory (default: True).
528
+ memory_role: The role to use when logging to memory (default: "assistant").
529
+ memory_tags: Optional list of tags to associate with the memory log entry.
530
+ memory_data: Optional structured data to include with the memory log entry.
531
+ memory_severity: Severity level for the memory log entry (default: 2).
532
+ memory_signal: Optional signal value for the memory log entry.
243
533
 
244
534
  Returns:
245
535
  None
246
536
 
247
537
  Notes:
248
- for AG WebUI, you can set meta with
249
- ```python
250
- {
251
- "agent_id": "agent-123",
252
- "name": "Analyst",
253
- }
254
- ```
538
+ - For AG WebUI, the `rich` payload is passed through as-is and rendered as UI blocks.
539
+ - Other adapters may down-level these blocks to plain text or ignore them.
255
540
  """
541
+
542
+ # --- 1) Memory logging (log *something* even if text=None) ---
543
+ log_text = text or "[rich message]"
544
+ await self._log_chat(
545
+ memory_role,
546
+ log_text,
547
+ tags=memory_tags,
548
+ data=memory_data,
549
+ severity=memory_severity,
550
+ signal=memory_signal,
551
+ enabled=memory_log,
552
+ channel=channel,
553
+ )
554
+
555
+ # --- 2) Publish outbound event ---
256
556
  await self._bus.publish(
257
557
  OutEvent(
258
558
  type="agent.message",
@@ -267,19 +567,29 @@ class ChannelSession:
267
567
  self,
268
568
  url: str | None = None,
269
569
  *,
570
+ file_bytes: bytes | None = None,
270
571
  alt: str = "image",
271
572
  title: str | None = None,
272
573
  channel: str | None = None,
574
+ artifact_labels: dict[str, Any] | None = None,
575
+ # memory logging...
576
+ memory_log: bool = True,
577
+ memory_role: Literal["user", "assistant", "system", "tool"] = "assistant",
578
+ memory_tags: list[str] | None = None,
579
+ memory_data: dict[str, Any] | None = None,
580
+ memory_severity: int = 2,
581
+ memory_signal: float | None = None,
273
582
  ):
274
583
  """
275
584
  Send an image message to the configured channel.
276
585
 
277
- This method constructs and dispatches an outbound event containing image metadata,
278
- including the image URL, alternative text, and an optional title. Context-derived
279
- metadata is automatically merged, and the event is published via the channel bus.
586
+ This method constructs a normalized outbound event, merges context-derived metadata,
587
+ and dispatches the image message via the channel bus. The image can be specified
588
+ using a URL or raw bytes, and additional metadata such as alternative text and title
589
+ can be provided.
280
590
 
281
591
  Examples:
282
- Basic usage to send an image:
592
+ Basic usage to send an image by URL:
283
593
  ```python
284
594
  await context.channel().send_image(
285
595
  url="https://example.com/image.png",
@@ -287,7 +597,7 @@ class ChannelSession:
287
597
  )
288
598
  ```
289
599
 
290
- Sending with a custom title and to a specific channel:
600
+ Sending an image with a custom title and to a specific channel:
291
601
  ```python
292
602
  await context.channel().send_image(
293
603
  url="https://example.com/photo.jpg",
@@ -297,26 +607,58 @@ class ChannelSession:
297
607
  )
298
608
  ```
299
609
 
610
+ Sending an image from raw bytes:
611
+ ```python
612
+ await context.channel().send_image(
613
+ file_bytes=b"binaryimagedata...",
614
+ alt="Generated image",
615
+ title="Generated Output"
616
+ )
617
+ ```
618
+
300
619
  Args:
301
- url: The URL of the image to send. If None, an empty string is used.
620
+ url: The URL of the image to send. If None, raw bytes must be provided.
621
+ file_bytes: Optional raw bytes of the image to send.
302
622
  alt: Alternative text describing the image (for accessibility).
303
623
  title: Optional title to display with the image.
304
624
  channel: Optional explicit channel key to override the default or session-bound channel.
625
+ artifact_labels: Optional dictionary of labels to associate with the image artifact.
626
+ memory_log: Whether to log this message to memory (default: True).
627
+ memory_role: The role to use when logging to memory (default: "assistant").
628
+ memory_tags: Optional list of tags to associate with the memory log entry.
629
+ memory_data: Optional structured data to include with the memory log entry.
630
+ memory_severity: Severity level for the memory log entry (default: 2).
631
+ memory_signal: Optional signal value for the memory log entry.
305
632
 
306
633
  Returns:
307
634
  None
308
635
 
309
636
  Notes:
310
- The capability to render images depends on the client adapter.
637
+ The capability to render images depends on the client adapter. If both `url` and
638
+ `file_bytes` are provided, both will be included in the event.
311
639
  """
312
- await self._bus.publish(
313
- OutEvent(
314
- type="agent.message",
315
- channel=self._resolve_key(channel),
316
- text=title or alt,
317
- image={"url": url or "", "alt": alt, "title": title or ""},
318
- meta=self._inject_context_meta(None),
319
- )
640
+
641
+ labels = {"renderer": "image"}
642
+ if artifact_labels:
643
+ labels.update(artifact_labels)
644
+
645
+ # Reuse memory logging text
646
+ memory_tags = [*(memory_tags or []), "image"]
647
+
648
+ await self.send_file(
649
+ url=url,
650
+ file_bytes=file_bytes,
651
+ filename=_image_filename(title, alt),
652
+ title=title or alt,
653
+ channel=channel,
654
+ artifact_kind="image",
655
+ artifact_labels=labels,
656
+ memory_log=memory_log,
657
+ memory_role=memory_role,
658
+ memory_tags=memory_tags,
659
+ memory_data=memory_data,
660
+ memory_severity=memory_severity,
661
+ memory_signal=memory_signal,
320
662
  )
321
663
 
322
664
  async def send_file(
@@ -327,6 +669,16 @@ class ChannelSession:
327
669
  filename: str = "file.bin",
328
670
  title: str | None = None,
329
671
  channel: str | None = None,
672
+ # NEW: optional hints for artifact
673
+ artifact_kind: str = "file",
674
+ artifact_labels: dict[str, Any] | None = None,
675
+ # memory logging handled separately
676
+ memory_log: bool = True,
677
+ memory_role: Literal["user", "assistant", "system", "tool"] = "assistant",
678
+ memory_tags: list[str] | None = None,
679
+ memory_data: dict[str, Any] | None = None,
680
+ memory_severity: int = 2,
681
+ memory_signal: float | None = None,
330
682
  ):
331
683
  """
332
684
  Send a file to the configured channel in a normalized format.
@@ -360,6 +712,7 @@ class ChannelSession:
360
712
  filename: The display name of the file (defaults to "file.bin").
361
713
  title: Optional title to display with the file.
362
714
  channel: Optional explicit channel key to override the default or session-bound channel.
715
+ memory_*: Parameters controlling logging to memory (see `send_text` for details).
363
716
 
364
717
  Returns:
365
718
  None
@@ -368,17 +721,101 @@ class ChannelSession:
368
721
  The capability to handle file uploads depends on the client adapter.
369
722
  If both `url` and `file_bytes` are provided, both will be included in the event.
370
723
  """
371
- file = {"filename": filename}
372
- if url:
373
- file["url"] = url
724
+ # ------------------------------
725
+ # 1) Maybe create an Artifact
726
+ # ------------------------------
727
+ chat_file: dict[str, Any] = {
728
+ "name": filename,
729
+ }
730
+
731
+ artifact = None
732
+
733
+ # Ensure labels always carry filename
734
+ effective_labels: dict[str, Any] = dict(artifact_labels or {})
735
+ effective_labels.setdefault("filename", filename)
736
+
737
+ # Case A: raw bytes → stream to ArtifactStore
374
738
  if file_bytes is not None:
375
- file["bytes"] = file_bytes
739
+ try:
740
+ artifacts = self.ctx.artifacts()
741
+ async with artifacts.writer(
742
+ kind=artifact_kind,
743
+ planned_ext=Path(filename).suffix or None,
744
+ pin=False,
745
+ ) as w:
746
+ write_result = w.write(file_bytes)
747
+ if inspect.isawaitable(write_result):
748
+ await write_result
749
+
750
+ add_labels = getattr(w, "add_labels", None)
751
+ if callable(add_labels):
752
+ add_labels(effective_labels)
753
+
754
+ artifact = artifacts.last_artifact
755
+
756
+ except Exception:
757
+ import logging
758
+
759
+ logging.getLogger("aethergraph.channel").exception("send_file_artifact_failed")
760
+
761
+ # Case B: local path (non-HTTP) → save_file
762
+ elif url and not url.startswith(("http://", "https://")):
763
+ try:
764
+ artifacts = self.ctx.artifacts()
765
+ artifact = await artifacts.save_file(
766
+ path=url,
767
+ kind=artifact_kind,
768
+ labels=effective_labels,
769
+ name=filename,
770
+ pin=False,
771
+ )
772
+ except Exception:
773
+ import logging
774
+
775
+ logging.getLogger("aethergraph.channel").exception("send_file_save_failed")
776
+
777
+ # ------------------------------
778
+ # 1b) Normalize chat_file from artifact or fallback URL
779
+ # ------------------------------
780
+ if artifact is not None:
781
+ # Use artifact meta → url, mimetype, renderer (from labels), etc.
782
+ chat_file.update(_artifact_to_chat_file(artifact, fallback_filename=filename))
783
+ else:
784
+ # Fallback: just pass whatever URL we got (may be remote HTTP or None)
785
+ if url:
786
+ chat_file["url"] = url
787
+
788
+ # If caller passed renderer in labels, keep honoring it
789
+ if artifact_labels and "renderer" in artifact_labels:
790
+ chat_file["renderer"] = artifact_labels["renderer"]
791
+
792
+ # For compatibility with existing payloads that used "filename"
793
+ chat_file.setdefault("filename", chat_file.get("name"))
794
+
795
+ # ------------------------------
796
+ # 2) Log to memory
797
+ # ------------------------------
798
+ memory_tags = [*(memory_tags or []), "file"]
799
+ await self._log_chat(
800
+ memory_role,
801
+ f"File: {filename} (url: {chat_file.get('url') or 'N/A'})",
802
+ tags=memory_tags,
803
+ data=memory_data,
804
+ severity=memory_severity,
805
+ signal=memory_signal,
806
+ enabled=memory_log,
807
+ channel=channel,
808
+ )
809
+
810
+ # ------------------------------
811
+ # 3) Publish OutEvent
812
+ # ------------------------------
376
813
  await self._bus.publish(
377
814
  OutEvent(
378
- type="file.upload",
815
+ type="agent.message", # UI treats as normal message with attachment
379
816
  channel=self._resolve_key(channel),
380
- text=title,
381
- file=file,
817
+ text=title or filename,
818
+ file=chat_file,
382
819
  meta=self._inject_context_meta(None),
383
820
  )
384
821
  )
@@ -390,6 +827,13 @@ class ChannelSession:
390
827
  *,
391
828
  meta: dict[str, Any] | None = None,
392
829
  channel: str | None = None,
830
+ # memory logging handled separately
831
+ memory_log: bool = True,
832
+ memory_role: Literal["user", "assistant", "system", "tool"] = "assistant",
833
+ memory_tags: list[str] | None = None,
834
+ memory_data: dict[str, Any] | None = None, # extra structured data
835
+ memory_severity: int = 2,
836
+ memory_signal: float | None = None,
393
837
  ):
394
838
  """
395
839
  Send a message with interactive buttons to the configured channel.
@@ -421,10 +865,23 @@ class ChannelSession:
421
865
  buttons: A list of `Button` objects representing the interactive options.
422
866
  meta: Optional dictionary of metadata to include with the event.
423
867
  channel: Optional explicit channel key to override the default or session-bound channel.
868
+ memory_*: Parameters controlling logging to memory (see `send_text` for details).
424
869
 
425
870
  Returns:
426
871
  None
427
872
  """
873
+ memory_tags = [*(memory_tags or []), "buttons"]
874
+ await self._log_chat(
875
+ memory_role,
876
+ text,
877
+ tags=memory_tags,
878
+ data=memory_data,
879
+ severity=memory_severity,
880
+ signal=memory_signal,
881
+ enabled=memory_log,
882
+ channel=channel,
883
+ )
884
+
428
885
  await self._bus.publish(
429
886
  OutEvent(
430
887
  type="link.buttons",
@@ -504,6 +961,10 @@ class ChannelSession:
504
961
  timeout_s: int = 3600,
505
962
  silent: bool = False, # kept for back-compat; same behavior as before
506
963
  channel: str | None = None,
964
+ # memory config
965
+ memory_log_prompt: bool = True,
966
+ memory_log_reply: bool = True,
967
+ memory_tags: list[str] | None = None,
507
968
  ) -> str:
508
969
  """
509
970
  Prompt the user for a text response in a normalized format.
@@ -531,19 +992,49 @@ class ChannelSession:
531
992
  timeout_s: Maximum time in seconds to wait for a response (default: 3600).
532
993
  silent: If True, suppresses prompt display in some adapters (back-compat; default: False).
533
994
  channel: Optional explicit channel key to override the default or session-bound channel.
995
+ memory_log_prompt: Whether to log the prompt to memory (default: True).
996
+ memory_log_reply: Whether to log the user's reply to memory (default: True).
997
+ memory_tags: Optional list of tags to associate with the memory log entries.
534
998
 
535
999
  Returns:
536
1000
  str: The user's text response, or an empty string if no input was received.
537
1001
  """
1002
+ if prompt:
1003
+ await self._log_chat(
1004
+ "assistant",
1005
+ prompt,
1006
+ tags=[*(memory_tags or []), "ask_text", "prompt"],
1007
+ enabled=memory_log_prompt,
1008
+ channel=channel,
1009
+ )
1010
+
538
1011
  payload = await self._ask_core(
539
1012
  kind="user_input",
540
1013
  payload={"prompt": prompt, "_silent": silent},
541
1014
  channel=channel,
542
1015
  timeout_s=timeout_s,
543
1016
  )
544
- return str(payload.get("text", ""))
545
1017
 
546
- async def wait_text(self, *, timeout_s: int = 3600, channel: str | None = None) -> str:
1018
+ text = str(payload.get("text", ""))
1019
+
1020
+ if text:
1021
+ await self._log_chat(
1022
+ "user",
1023
+ text,
1024
+ tags=[*(memory_tags or []), "ask_text", "reply"],
1025
+ enabled=memory_log_reply,
1026
+ channel=channel,
1027
+ )
1028
+ return text
1029
+
1030
+ async def wait_text(
1031
+ self,
1032
+ *,
1033
+ timeout_s: int = 3600,
1034
+ channel: str | None = None,
1035
+ memory_log_reply: bool = True,
1036
+ memory_tags: list[str] | None = None,
1037
+ ) -> str:
547
1038
  """
548
1039
  Wait for a single text response from the user in a normalized format.
549
1040
 
@@ -567,12 +1058,22 @@ class ChannelSession:
567
1058
  Args:
568
1059
  timeout_s: Maximum time in seconds to wait for a response (default: 3600).
569
1060
  channel: Optional explicit channel key to override the default or session-bound channel.
1061
+ memory_log_reply: Whether to log the user's reply to memory (default: True).
1062
+ memory_tags: Optional list of tags to associate with the memory log entry.
570
1063
 
571
1064
  Returns:
572
1065
  str: The user's text response, or an empty string if no input was received.
573
1066
  """
574
1067
  # Alias for ask_text(prompt=None) but keeps existing signature
575
- return await self.ask_text(prompt=None, timeout_s=timeout_s, silent=True, channel=channel)
1068
+ return await self.ask_text(
1069
+ prompt=None,
1070
+ timeout_s=timeout_s,
1071
+ silent=True,
1072
+ channel=channel,
1073
+ memory_log_prompt=False, # no prompt to log
1074
+ memory_log_reply=memory_log_reply,
1075
+ memory_tags=memory_tags,
1076
+ )
576
1077
 
577
1078
  async def ask_approval(
578
1079
  self,
@@ -581,6 +1082,9 @@ class ChannelSession:
581
1082
  *,
582
1083
  timeout_s: int = 3600,
583
1084
  channel: str | None = None,
1085
+ memory_log_prompt: bool = True,
1086
+ memory_log_reply: bool = True,
1087
+ memory_tags: list[str] | None = None,
584
1088
  ) -> dict[str, Any]:
585
1089
  """
586
1090
  Prompt the user for approval or rejection in a normalized format.
@@ -610,6 +1114,9 @@ class ChannelSession:
610
1114
  options: Iterable of button labels for user choices (defaults to "Approve" and "Reject").
611
1115
  timeout_s: Maximum time in seconds to wait for a response (default: 3600).
612
1116
  channel: Optional explicit channel key to override the default or session-bound channel.
1117
+ memory_log_prompt: Whether to log the prompt to memory (default: True).
1118
+ memory_log_reply: Whether to log the user's reply to memory (default: True).
1119
+ memory_tags: Optional list of tags to associate with the memory log entries.
613
1120
 
614
1121
  Returns:
615
1122
  dict: A dictionary containing:
@@ -620,6 +1127,15 @@ class ChannelSession:
620
1127
  The returned "choices" are determined by the external adapter and may vary. To be robust, make sure
621
1128
  to use `choices.lower()` and strip whitespace when comparing.
622
1129
  """
1130
+ if prompt:
1131
+ await self._log_chat(
1132
+ "assistant",
1133
+ prompt,
1134
+ tags=[*(memory_tags or []), "ask_approval", "prompt"],
1135
+ enabled=memory_log_prompt,
1136
+ channel=channel,
1137
+ )
1138
+
623
1139
  payload = await self._ask_core(
624
1140
  kind="approval",
625
1141
  payload={"prompt": {"title": prompt, "buttons": list(options)}},
@@ -627,6 +1143,15 @@ class ChannelSession:
627
1143
  timeout_s=timeout_s,
628
1144
  )
629
1145
  choice = payload.get("choice")
1146
+ if choice is not None:
1147
+ await self._log_chat(
1148
+ "user",
1149
+ f"Selected: {str(choice)}",
1150
+ tags=[*(memory_tags or []), "ask_approval", "reply"],
1151
+ enabled=memory_log_reply,
1152
+ channel=channel,
1153
+ )
1154
+
630
1155
  # Normalize return
631
1156
  # 1) If adapter explicitly sets approved, trust it
632
1157
  buttons = list(options) # just plain list, not Button objects
@@ -651,6 +1176,9 @@ class ChannelSession:
651
1176
  multiple: bool = True,
652
1177
  timeout_s: int = 3600,
653
1178
  channel: str | None = None,
1179
+ memory_log_prompt: bool = True,
1180
+ memory_log_reply: bool = True,
1181
+ memory_tags: list[str] | None = None,
654
1182
  ) -> dict:
655
1183
  """
656
1184
  Prompt the user to upload one or more files, optionally with a text comment.
@@ -694,32 +1222,83 @@ class ChannelSession:
694
1222
  On console adapters, file upload is not supported; only text will be returned.
695
1223
  The `accept` parameter is a UI hint and does not guarantee file type enforcement.
696
1224
  """
1225
+ if prompt:
1226
+ await self._log_chat(
1227
+ "assistant",
1228
+ prompt,
1229
+ tags=[*(memory_tags or []), "ask_files", "prompt"],
1230
+ enabled=memory_log_prompt,
1231
+ channel=channel,
1232
+ )
1233
+
697
1234
  payload = await self._ask_core(
698
1235
  kind="user_files",
699
1236
  payload={"prompt": prompt, "accept": accept or [], "multiple": bool(multiple)},
700
1237
  channel=channel,
701
1238
  timeout_s=timeout_s,
702
1239
  )
1240
+
1241
+ text = str(payload.get("text", ""))
1242
+ if text:
1243
+ await self._log_chat(
1244
+ "user",
1245
+ text,
1246
+ tags=[*(memory_tags or []), "ask_files", "reply"],
1247
+ enabled=memory_log_reply,
1248
+ channel=channel,
1249
+ )
1250
+
703
1251
  return {
704
- "text": str(payload.get("text", "")),
1252
+ "text": text,
705
1253
  "files": payload.get("files", []) if isinstance(payload.get("files", []), list) else [],
706
1254
  }
707
1255
 
708
1256
  async def ask_text_or_files(
709
- self, *, prompt: str, timeout_s: int = 3600, channel: str | None = None
1257
+ self,
1258
+ *,
1259
+ prompt: str,
1260
+ timeout_s: int = 3600,
1261
+ channel: str | None = None,
1262
+ memory_log_prompt: bool = True,
1263
+ memory_log_reply: bool = True,
1264
+ memory_tags: list[str] | None = None,
710
1265
  ) -> dict:
711
1266
  """
712
- Ask for either text or files. Returns:
713
- { "text": str, "files": List[FileRef] }
1267
+ Prompt the user for either text input or file uploads in a normalized format.
1268
+ This method sends a prompt to the configured channel, allowing the user to respond with
1269
+ text, files, or both. It waits for the user's response and returns a normalized result
1270
+ containing the text and file references.
1271
+
1272
+ Not used very often; prefer ask_text + get_latest_uploads or ask_files.
714
1273
  """
1274
+ if prompt:
1275
+ await self._log_chat(
1276
+ "assistant",
1277
+ prompt,
1278
+ tags=[*(memory_tags or []), "ask_text_or_files", "prompt"],
1279
+ enabled=memory_log_prompt,
1280
+ channel=channel,
1281
+ )
1282
+
715
1283
  payload = await self._ask_core(
716
1284
  kind="user_input_or_files",
717
1285
  payload={"prompt": prompt},
718
1286
  channel=channel,
719
1287
  timeout_s=timeout_s,
720
1288
  )
1289
+
1290
+ text = str(payload.get("text", ""))
1291
+ if text:
1292
+ await self._log_chat(
1293
+ "user",
1294
+ text,
1295
+ tags=[*(memory_tags or []), "ask_text_or_files", "reply"],
1296
+ enabled=memory_log_reply,
1297
+ channel=channel,
1298
+ )
1299
+
721
1300
  return {
722
- "text": str(payload.get("text", "")),
1301
+ "text": text,
723
1302
  "files": payload.get("files", []) if isinstance(payload.get("files", []), list) else [],
724
1303
  }
725
1304
 
@@ -768,13 +1347,15 @@ class ChannelSession:
768
1347
  )
769
1348
 
770
1349
  # ---------- streaming ----------
1350
+
771
1351
  class _StreamSender:
772
1352
  def __init__(self, outer: "ChannelSession", *, channel_key: str | None = None):
773
1353
  self._outer = outer
774
1354
  self._started = False
775
1355
  # Resolve once (explicit -> bound -> default)
776
1356
  self._channel_key = outer._resolve_key(channel_key)
777
- self._upsert_key = f"{outer._run_id}:{outer._node_id}:stream"
1357
+ # Unique per stream so multiple streams from same node don’t collide
1358
+ self._upsert_key = f"{outer._run_id}:{outer._node_id}:stream:{uuid.uuid4().hex}"
778
1359
 
779
1360
  def _inject_context_meta(self, meta: dict[str, Any] | None = None) -> dict[str, Any]:
780
1361
  return self._outer._inject_context_meta(meta)
@@ -787,7 +1368,7 @@ class ChannelSession:
787
1368
  self.__buf = []
788
1369
  return self.__buf
789
1370
 
790
- async def start(self):
1371
+ async def start(self) -> None:
791
1372
  if not self._started:
792
1373
  self._started = True
793
1374
  await self._outer._bus.publish(
@@ -799,36 +1380,74 @@ class ChannelSession:
799
1380
  )
800
1381
  )
801
1382
 
802
- async def delta(self, text_piece: str):
1383
+ async def delta(self, text_piece: str) -> None:
1384
+ """
1385
+ Send a text delta for this stream.
1386
+
1387
+ The UI is expected to append `text_piece` to the message associated
1388
+ with `upsert_key`. We also keep an internal buffer so `end()` can log
1389
+ the full text to memory if needed.
1390
+ """
1391
+ if not text_piece:
1392
+ return
1393
+
803
1394
  await self.start()
804
1395
  buf = self._ensure_buf()
805
1396
  buf.append(text_piece)
806
- # Upsert full text so adapters can rewrite one message
1397
+
807
1398
  await self._outer._bus.publish(
808
1399
  OutEvent(
809
- type="agent.message.update",
1400
+ type="agent.stream.delta",
810
1401
  channel=self._channel_key,
811
- text="".join(buf),
1402
+ text=text_piece,
812
1403
  upsert_key=self._upsert_key,
813
1404
  meta=self._inject_context_meta(None),
814
1405
  )
815
1406
  )
816
1407
 
817
- async def end(self, full_text: str | None = None):
818
- if full_text is not None:
819
- await self._outer._bus.publish(
820
- OutEvent(
821
- type="agent.message.update",
822
- channel=self._channel_key,
823
- text=full_text,
824
- upsert_key=self._upsert_key,
825
- meta=self._inject_context_meta(None),
826
- )
827
- )
1408
+ async def end(
1409
+ self,
1410
+ full_text: str | None = None,
1411
+ *,
1412
+ memory_log: bool = True,
1413
+ memory_role: Literal["assistant", "system", "tool", "user"] = "assistant",
1414
+ memory_tags: list[str] | None = None,
1415
+ memory_data: dict[str, Any] | None = None,
1416
+ memory_severity: int = 2,
1417
+ memory_signal: float | None = None,
1418
+ ) -> None:
1419
+ """
1420
+ Finalize the stream.
1421
+
1422
+ - If `full_text` is None, uses the concatenated buffer.
1423
+ - Logs the completed text to memory (once).
1424
+ - Emits `agent.stream.end` with the final text.
1425
+ """
1426
+ # Make sure we at least emitted start if no delta was ever sent
1427
+ await self.start()
1428
+
1429
+ if full_text is None:
1430
+ buf = self._buf()
1431
+ full_text = "".join(buf) if buf else ""
1432
+
1433
+ # 1) Memory logging of the completed message
1434
+ await self._outer._log_chat(
1435
+ memory_role,
1436
+ full_text,
1437
+ tags=memory_tags,
1438
+ data=memory_data,
1439
+ severity=memory_severity,
1440
+ signal=memory_signal,
1441
+ enabled=memory_log,
1442
+ channel=self._channel_key,
1443
+ )
1444
+
1445
+ # 2) End-of-stream event with final text
828
1446
  await self._outer._bus.publish(
829
1447
  OutEvent(
830
1448
  type="agent.stream.end",
831
1449
  channel=self._channel_key,
1450
+ text=full_text,
832
1451
  upsert_key=self._upsert_key,
833
1452
  meta=self._inject_context_meta(None),
834
1453
  )
@@ -844,7 +1463,7 @@ class ChannelSession:
844
1463
  and end events to the channel bus. The caller is responsible for sending deltas and ending the stream.
845
1464
 
846
1465
  Examples:
847
- Basic usage to stream LLM output:
1466
+ Basic usage to stream texts:
848
1467
  ```python
849
1468
  async with context.channel().stream() as s:
850
1469
  await s.delta("Hello, ")
@@ -856,7 +1475,20 @@ class ChannelSession:
856
1475
  ```python
857
1476
  async with context.channel().stream(channel="web:chat") as s:
858
1477
  await s.delta("Generating results...")
859
- await s.end(full_text="Results complete.")
1478
+ await s.end(full_text="Results complete.", memory_tags=["llm"])
1479
+ ```
1480
+
1481
+ Streaming with context.llm().stream_chat()
1482
+ ```python
1483
+ async with context.channel().stream() as s:
1484
+ # The `on_delta` callback sends each piece to the stream.
1485
+ async def on_delta(piece: str) -> None:
1486
+ await s.delta(piece)
1487
+ resp, usage = await llm.chat_stream(
1488
+ messages=messages,
1489
+ on_delta=on_delta, # send each delta to the stream as it arrives
1490
+ )
1491
+ await s.end(full_text=resp)
860
1492
  ```
861
1493
 
862
1494
  Args:
@@ -868,8 +1500,8 @@ class ChannelSession:
868
1500
  for sending deltas and ending the stream.
869
1501
 
870
1502
  Notes:
871
- The caller must explicitly call `end()` to finalize the stream. No auto-end is performed.
872
- The adapter may have specific behaviors for rendering streamed content (update vs. append).
1503
+ - The caller must explicitly call `end()` to finalize the stream. No auto-end is performed.
1504
+ - The adapter may have specific behaviors for rendering streamed content (update vs. append).
873
1505
  """
874
1506
  s = ChannelSession._StreamSender(self, channel_key=channel)
875
1507
  try:
@@ -910,6 +1542,7 @@ class ChannelSession:
910
1542
  channel=self._channel_key,
911
1543
  upsert_key=self._upsert_key,
912
1544
  rich={
1545
+ "kind": "progress",
913
1546
  "title": self._title,
914
1547
  "subtitle": subtitle or "",
915
1548
  "total": self._total,
@@ -936,6 +1569,7 @@ class ChannelSession:
936
1569
  if current is not None:
937
1570
  self._current = int(current)
938
1571
  payload = {
1572
+ "kind": "progress",
939
1573
  "title": self._title,
940
1574
  "subtitle": subtitle or "",
941
1575
  "total": self._total,
@@ -960,6 +1594,7 @@ class ChannelSession:
960
1594
  channel=self._channel_key,
961
1595
  upsert_key=self._upsert_key,
962
1596
  rich={
1597
+ "kind": "progress",
963
1598
  "title": self._title,
964
1599
  "subtitle": subtitle or "",
965
1600
  "success": bool(success),