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.
- aethergraph/api/v1/artifacts.py +23 -4
- aethergraph/api/v1/schemas.py +7 -0
- aethergraph/api/v1/session.py +123 -4
- aethergraph/config/config.py +2 -0
- aethergraph/config/search.py +49 -0
- aethergraph/contracts/services/channel.py +18 -1
- aethergraph/contracts/services/execution.py +58 -0
- aethergraph/contracts/services/llm.py +26 -0
- aethergraph/contracts/services/memory.py +10 -4
- aethergraph/contracts/services/planning.py +53 -0
- aethergraph/contracts/storage/event_log.py +8 -0
- aethergraph/contracts/storage/search_backend.py +47 -0
- aethergraph/contracts/storage/vector_index.py +73 -0
- aethergraph/core/graph/action_spec.py +76 -0
- aethergraph/core/graph/graph_fn.py +75 -2
- aethergraph/core/graph/graphify.py +74 -2
- aethergraph/core/runtime/graph_runner.py +2 -1
- aethergraph/core/runtime/node_context.py +66 -3
- aethergraph/core/runtime/node_services.py +8 -0
- aethergraph/core/runtime/run_manager.py +263 -271
- aethergraph/core/runtime/run_types.py +54 -1
- aethergraph/core/runtime/runtime_env.py +35 -14
- aethergraph/core/runtime/runtime_services.py +308 -18
- aethergraph/plugins/agents/default_chat_agent.py +266 -74
- aethergraph/plugins/agents/default_chat_agent_v2.py +487 -0
- aethergraph/plugins/channel/adapters/webui.py +69 -21
- aethergraph/plugins/channel/routes/webui_routes.py +8 -48
- aethergraph/runtime/__init__.py +12 -0
- aethergraph/server/app_factory.py +3 -0
- aethergraph/server/ui_static/assets/index-CFktGdbW.js +4913 -0
- aethergraph/server/ui_static/assets/index-DcfkFlTA.css +1 -0
- aethergraph/server/ui_static/index.html +2 -2
- aethergraph/services/artifacts/facade.py +157 -21
- aethergraph/services/artifacts/types.py +35 -0
- aethergraph/services/artifacts/utils.py +42 -0
- aethergraph/services/channel/channel_bus.py +3 -1
- aethergraph/services/channel/event_hub copy.py +55 -0
- aethergraph/services/channel/event_hub.py +81 -0
- aethergraph/services/channel/factory.py +3 -2
- aethergraph/services/channel/session.py +709 -74
- aethergraph/services/container/default_container.py +69 -7
- aethergraph/services/execution/__init__.py +0 -0
- aethergraph/services/execution/local_python.py +118 -0
- aethergraph/services/indices/__init__.py +0 -0
- aethergraph/services/indices/global_indices.py +21 -0
- aethergraph/services/indices/scoped_indices.py +292 -0
- aethergraph/services/llm/generic_client.py +342 -46
- aethergraph/services/llm/generic_embed_client.py +359 -0
- aethergraph/services/llm/types.py +3 -1
- aethergraph/services/memory/distillers/llm_long_term.py +60 -109
- aethergraph/services/memory/distillers/llm_long_term_v1.py +180 -0
- aethergraph/services/memory/distillers/llm_meta_summary.py +57 -266
- aethergraph/services/memory/distillers/llm_meta_summary_v1.py +342 -0
- aethergraph/services/memory/distillers/long_term.py +48 -131
- aethergraph/services/memory/distillers/long_term_v1.py +170 -0
- aethergraph/services/memory/facade/chat.py +18 -8
- aethergraph/services/memory/facade/core.py +159 -19
- aethergraph/services/memory/facade/distillation.py +86 -31
- aethergraph/services/memory/facade/retrieval.py +100 -1
- aethergraph/services/memory/factory.py +4 -1
- aethergraph/services/planning/__init__.py +0 -0
- aethergraph/services/planning/action_catalog.py +271 -0
- aethergraph/services/planning/bindings.py +56 -0
- aethergraph/services/planning/dependency_index.py +65 -0
- aethergraph/services/planning/flow_validator.py +263 -0
- aethergraph/services/planning/graph_io_adapter.py +150 -0
- aethergraph/services/planning/input_parser.py +312 -0
- aethergraph/services/planning/missing_inputs.py +28 -0
- aethergraph/services/planning/node_planner.py +613 -0
- aethergraph/services/planning/orchestrator.py +112 -0
- aethergraph/services/planning/plan_executor.py +506 -0
- aethergraph/services/planning/plan_types.py +321 -0
- aethergraph/services/planning/planner.py +617 -0
- aethergraph/services/planning/planner_service.py +369 -0
- aethergraph/services/planning/planning_context_builder.py +43 -0
- aethergraph/services/planning/quick_actions.py +29 -0
- aethergraph/services/planning/routers/__init__.py +0 -0
- aethergraph/services/planning/routers/simple_router.py +26 -0
- aethergraph/services/rag/facade.py +0 -3
- aethergraph/services/scope/scope.py +30 -30
- aethergraph/services/scope/scope_factory.py +15 -7
- aethergraph/services/skills/__init__.py +0 -0
- aethergraph/services/skills/skill_registry.py +465 -0
- aethergraph/services/skills/skills.py +220 -0
- aethergraph/services/skills/utils.py +194 -0
- aethergraph/storage/artifacts/artifact_index_jsonl.py +16 -10
- aethergraph/storage/artifacts/artifact_index_sqlite.py +12 -2
- aethergraph/storage/docstore/sqlite_doc_sync.py +1 -1
- aethergraph/storage/memory/event_persist.py +42 -2
- aethergraph/storage/memory/fs_persist.py +32 -2
- aethergraph/storage/search_backend/__init__.py +0 -0
- aethergraph/storage/search_backend/generic_vector_backend.py +230 -0
- aethergraph/storage/search_backend/null_backend.py +34 -0
- aethergraph/storage/search_backend/sqlite_lexical_backend.py +387 -0
- aethergraph/storage/search_backend/utils.py +31 -0
- aethergraph/storage/search_factory.py +75 -0
- aethergraph/storage/vector_index/faiss_index.py +72 -4
- aethergraph/storage/vector_index/sqlite_index.py +521 -52
- aethergraph/storage/vector_index/sqlite_index_vanila.py +311 -0
- aethergraph/storage/vector_index/utils.py +22 -0
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/METADATA +1 -1
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/RECORD +107 -63
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/WHEEL +1 -1
- aethergraph/plugins/agents/default_chat_agent copy.py +0 -90
- aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +0 -1
- aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +0 -400
- aethergraph/services/eventhub/event_hub.py +0 -76
- aethergraph/services/llm/generic_client copy.py +0 -691
- aethergraph/services/prompts/file_store.py +0 -41
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/entry_points.txt +0 -0
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/LICENSE +0 -0
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/NOTICE +0 -0
- {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
|
|
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,
|
|
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
|
|
216
|
-
|
|
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
|
-
|
|
477
|
+
Sending a single rich block:
|
|
221
478
|
```python
|
|
222
479
|
await context.channel().send_rich(
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
492
|
+
Sending multiple rich blocks:
|
|
229
493
|
```python
|
|
230
494
|
await context.channel().send_rich(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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:
|
|
240
|
-
rich: A dictionary
|
|
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
|
-
|
|
249
|
-
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
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,
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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="
|
|
815
|
+
type="agent.message", # UI treats as normal message with attachment
|
|
379
816
|
channel=self._resolve_key(channel),
|
|
380
|
-
text=title,
|
|
381
|
-
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
|
-
|
|
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(
|
|
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":
|
|
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,
|
|
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
|
-
|
|
713
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
1397
|
+
|
|
807
1398
|
await self._outer._bus.publish(
|
|
808
1399
|
OutEvent(
|
|
809
|
-
type="agent.
|
|
1400
|
+
type="agent.stream.delta",
|
|
810
1401
|
channel=self._channel_key,
|
|
811
|
-
text=
|
|
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(
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
|
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),
|