aethergraph 0.1.0a1__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 (182) hide show
  1. aethergraph/__init__.py +49 -0
  2. aethergraph/config/__init__.py +0 -0
  3. aethergraph/config/config.py +121 -0
  4. aethergraph/config/context.py +16 -0
  5. aethergraph/config/llm.py +26 -0
  6. aethergraph/config/loader.py +60 -0
  7. aethergraph/config/runtime.py +9 -0
  8. aethergraph/contracts/errors/errors.py +44 -0
  9. aethergraph/contracts/services/artifacts.py +142 -0
  10. aethergraph/contracts/services/channel.py +72 -0
  11. aethergraph/contracts/services/continuations.py +23 -0
  12. aethergraph/contracts/services/eventbus.py +12 -0
  13. aethergraph/contracts/services/kv.py +24 -0
  14. aethergraph/contracts/services/llm.py +17 -0
  15. aethergraph/contracts/services/mcp.py +22 -0
  16. aethergraph/contracts/services/memory.py +108 -0
  17. aethergraph/contracts/services/resume.py +28 -0
  18. aethergraph/contracts/services/state_stores.py +33 -0
  19. aethergraph/contracts/services/wakeup.py +28 -0
  20. aethergraph/core/execution/base_scheduler.py +77 -0
  21. aethergraph/core/execution/forward_scheduler.py +777 -0
  22. aethergraph/core/execution/global_scheduler.py +634 -0
  23. aethergraph/core/execution/retry_policy.py +22 -0
  24. aethergraph/core/execution/step_forward.py +411 -0
  25. aethergraph/core/execution/step_result.py +18 -0
  26. aethergraph/core/execution/wait_types.py +72 -0
  27. aethergraph/core/graph/graph_builder.py +192 -0
  28. aethergraph/core/graph/graph_fn.py +219 -0
  29. aethergraph/core/graph/graph_io.py +67 -0
  30. aethergraph/core/graph/graph_refs.py +154 -0
  31. aethergraph/core/graph/graph_spec.py +115 -0
  32. aethergraph/core/graph/graph_state.py +59 -0
  33. aethergraph/core/graph/graphify.py +128 -0
  34. aethergraph/core/graph/interpreter.py +145 -0
  35. aethergraph/core/graph/node_handle.py +33 -0
  36. aethergraph/core/graph/node_spec.py +46 -0
  37. aethergraph/core/graph/node_state.py +63 -0
  38. aethergraph/core/graph/task_graph.py +747 -0
  39. aethergraph/core/graph/task_node.py +82 -0
  40. aethergraph/core/graph/utils.py +37 -0
  41. aethergraph/core/graph/visualize.py +239 -0
  42. aethergraph/core/runtime/ad_hoc_context.py +61 -0
  43. aethergraph/core/runtime/base_service.py +153 -0
  44. aethergraph/core/runtime/bind_adapter.py +42 -0
  45. aethergraph/core/runtime/bound_memory.py +69 -0
  46. aethergraph/core/runtime/execution_context.py +220 -0
  47. aethergraph/core/runtime/graph_runner.py +349 -0
  48. aethergraph/core/runtime/lifecycle.py +26 -0
  49. aethergraph/core/runtime/node_context.py +203 -0
  50. aethergraph/core/runtime/node_services.py +30 -0
  51. aethergraph/core/runtime/recovery.py +159 -0
  52. aethergraph/core/runtime/run_registration.py +33 -0
  53. aethergraph/core/runtime/runtime_env.py +157 -0
  54. aethergraph/core/runtime/runtime_registry.py +32 -0
  55. aethergraph/core/runtime/runtime_services.py +224 -0
  56. aethergraph/core/runtime/wakeup_watcher.py +40 -0
  57. aethergraph/core/tools/__init__.py +10 -0
  58. aethergraph/core/tools/builtins/channel_tools.py +194 -0
  59. aethergraph/core/tools/builtins/toolset.py +134 -0
  60. aethergraph/core/tools/toolkit.py +510 -0
  61. aethergraph/core/tools/waitable.py +109 -0
  62. aethergraph/plugins/channel/__init__.py +0 -0
  63. aethergraph/plugins/channel/adapters/__init__.py +0 -0
  64. aethergraph/plugins/channel/adapters/console.py +106 -0
  65. aethergraph/plugins/channel/adapters/file.py +102 -0
  66. aethergraph/plugins/channel/adapters/slack.py +285 -0
  67. aethergraph/plugins/channel/adapters/telegram.py +302 -0
  68. aethergraph/plugins/channel/adapters/webhook.py +104 -0
  69. aethergraph/plugins/channel/adapters/webui.py +134 -0
  70. aethergraph/plugins/channel/routes/__init__.py +0 -0
  71. aethergraph/plugins/channel/routes/console_routes.py +86 -0
  72. aethergraph/plugins/channel/routes/slack_routes.py +49 -0
  73. aethergraph/plugins/channel/routes/telegram_routes.py +26 -0
  74. aethergraph/plugins/channel/routes/webui_routes.py +136 -0
  75. aethergraph/plugins/channel/utils/__init__.py +0 -0
  76. aethergraph/plugins/channel/utils/slack_utils.py +278 -0
  77. aethergraph/plugins/channel/utils/telegram_utils.py +324 -0
  78. aethergraph/plugins/channel/websockets/slack_ws.py +68 -0
  79. aethergraph/plugins/channel/websockets/telegram_polling.py +151 -0
  80. aethergraph/plugins/mcp/fs_server.py +128 -0
  81. aethergraph/plugins/mcp/http_server.py +101 -0
  82. aethergraph/plugins/mcp/ws_server.py +180 -0
  83. aethergraph/plugins/net/http.py +10 -0
  84. aethergraph/plugins/utils/data_io.py +359 -0
  85. aethergraph/runner/__init__.py +5 -0
  86. aethergraph/runtime/__init__.py +62 -0
  87. aethergraph/server/__init__.py +3 -0
  88. aethergraph/server/app_factory.py +84 -0
  89. aethergraph/server/start.py +122 -0
  90. aethergraph/services/__init__.py +10 -0
  91. aethergraph/services/artifacts/facade.py +284 -0
  92. aethergraph/services/artifacts/factory.py +35 -0
  93. aethergraph/services/artifacts/fs_store.py +656 -0
  94. aethergraph/services/artifacts/jsonl_index.py +123 -0
  95. aethergraph/services/artifacts/paths.py +23 -0
  96. aethergraph/services/artifacts/sqlite_index.py +209 -0
  97. aethergraph/services/artifacts/utils.py +124 -0
  98. aethergraph/services/auth/dev.py +16 -0
  99. aethergraph/services/channel/channel_bus.py +293 -0
  100. aethergraph/services/channel/factory.py +44 -0
  101. aethergraph/services/channel/session.py +511 -0
  102. aethergraph/services/channel/wait_helpers.py +57 -0
  103. aethergraph/services/clock/clock.py +9 -0
  104. aethergraph/services/container/default_container.py +320 -0
  105. aethergraph/services/continuations/continuation.py +56 -0
  106. aethergraph/services/continuations/factory.py +34 -0
  107. aethergraph/services/continuations/stores/fs_store.py +264 -0
  108. aethergraph/services/continuations/stores/inmem_store.py +95 -0
  109. aethergraph/services/eventbus/inmem.py +21 -0
  110. aethergraph/services/features/static.py +10 -0
  111. aethergraph/services/kv/ephemeral.py +90 -0
  112. aethergraph/services/kv/factory.py +27 -0
  113. aethergraph/services/kv/layered.py +41 -0
  114. aethergraph/services/kv/sqlite_kv.py +128 -0
  115. aethergraph/services/llm/factory.py +157 -0
  116. aethergraph/services/llm/generic_client.py +542 -0
  117. aethergraph/services/llm/providers.py +3 -0
  118. aethergraph/services/llm/service.py +105 -0
  119. aethergraph/services/logger/base.py +36 -0
  120. aethergraph/services/logger/compat.py +50 -0
  121. aethergraph/services/logger/formatters.py +106 -0
  122. aethergraph/services/logger/std.py +203 -0
  123. aethergraph/services/mcp/helpers.py +23 -0
  124. aethergraph/services/mcp/http_client.py +70 -0
  125. aethergraph/services/mcp/mcp_tools.py +21 -0
  126. aethergraph/services/mcp/registry.py +14 -0
  127. aethergraph/services/mcp/service.py +100 -0
  128. aethergraph/services/mcp/stdio_client.py +70 -0
  129. aethergraph/services/mcp/ws_client.py +115 -0
  130. aethergraph/services/memory/bound.py +106 -0
  131. aethergraph/services/memory/distillers/episode.py +116 -0
  132. aethergraph/services/memory/distillers/rolling.py +74 -0
  133. aethergraph/services/memory/facade.py +633 -0
  134. aethergraph/services/memory/factory.py +78 -0
  135. aethergraph/services/memory/hotlog_kv.py +27 -0
  136. aethergraph/services/memory/indices.py +74 -0
  137. aethergraph/services/memory/io_helpers.py +72 -0
  138. aethergraph/services/memory/persist_fs.py +40 -0
  139. aethergraph/services/memory/resolver.py +152 -0
  140. aethergraph/services/metering/noop.py +4 -0
  141. aethergraph/services/prompts/file_store.py +41 -0
  142. aethergraph/services/rag/chunker.py +29 -0
  143. aethergraph/services/rag/facade.py +593 -0
  144. aethergraph/services/rag/index/base.py +27 -0
  145. aethergraph/services/rag/index/faiss_index.py +121 -0
  146. aethergraph/services/rag/index/sqlite_index.py +134 -0
  147. aethergraph/services/rag/index_factory.py +52 -0
  148. aethergraph/services/rag/parsers/md.py +7 -0
  149. aethergraph/services/rag/parsers/pdf.py +14 -0
  150. aethergraph/services/rag/parsers/txt.py +7 -0
  151. aethergraph/services/rag/utils/hybrid.py +39 -0
  152. aethergraph/services/rag/utils/make_fs_key.py +62 -0
  153. aethergraph/services/redactor/simple.py +16 -0
  154. aethergraph/services/registry/key_parsing.py +44 -0
  155. aethergraph/services/registry/registry_key.py +19 -0
  156. aethergraph/services/registry/unified_registry.py +185 -0
  157. aethergraph/services/resume/multi_scheduler_resume_bus.py +65 -0
  158. aethergraph/services/resume/router.py +73 -0
  159. aethergraph/services/schedulers/registry.py +41 -0
  160. aethergraph/services/secrets/base.py +7 -0
  161. aethergraph/services/secrets/env.py +8 -0
  162. aethergraph/services/state_stores/externalize.py +135 -0
  163. aethergraph/services/state_stores/graph_observer.py +131 -0
  164. aethergraph/services/state_stores/json_store.py +67 -0
  165. aethergraph/services/state_stores/resume_policy.py +119 -0
  166. aethergraph/services/state_stores/serialize.py +249 -0
  167. aethergraph/services/state_stores/utils.py +91 -0
  168. aethergraph/services/state_stores/validate.py +78 -0
  169. aethergraph/services/tracing/noop.py +18 -0
  170. aethergraph/services/waits/wait_registry.py +91 -0
  171. aethergraph/services/wakeup/memory_queue.py +57 -0
  172. aethergraph/services/wakeup/scanner_producer.py +56 -0
  173. aethergraph/services/wakeup/worker.py +31 -0
  174. aethergraph/tools/__init__.py +25 -0
  175. aethergraph/utils/optdeps.py +8 -0
  176. aethergraph-0.1.0a1.dist-info/METADATA +410 -0
  177. aethergraph-0.1.0a1.dist-info/RECORD +182 -0
  178. aethergraph-0.1.0a1.dist-info/WHEEL +5 -0
  179. aethergraph-0.1.0a1.dist-info/entry_points.txt +2 -0
  180. aethergraph-0.1.0a1.dist-info/licenses/LICENSE +176 -0
  181. aethergraph-0.1.0a1.dist-info/licenses/NOTICE +31 -0
  182. aethergraph-0.1.0a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,511 @@
1
+ from collections.abc import AsyncIterator, Iterable
2
+ from contextlib import asynccontextmanager
3
+ import logging
4
+ from typing import Any
5
+
6
+ from aethergraph.contracts.services.channel import Button, FileRef, OutEvent
7
+ from aethergraph.services.continuations.continuation import Correlator
8
+
9
+
10
+ class ChannelSession:
11
+ """Helper to manage a channel-based session within a NodeContext.
12
+ Provides methods to send messages, ask for user input or approval, and stream messages.
13
+ The channel key is read from `session.channel` in the context.
14
+ """
15
+
16
+ def __init__(self, context, channel_key: str | None = None):
17
+ self.ctx = context
18
+ self._override_key = channel_key # optional strong binding
19
+
20
+ # Channel bus
21
+ @property
22
+ def _bus(self):
23
+ return self.ctx.services.channels
24
+
25
+ # Continuation store
26
+ @property
27
+ def _cont_store(self):
28
+ return self.ctx.services.continuation_store
29
+
30
+ @property
31
+ def _run_id(self):
32
+ return self.ctx.run_id
33
+
34
+ @property
35
+ def _node_id(self):
36
+ return self.ctx.node_id
37
+
38
+ def _resolve_default_key(self) -> str:
39
+ """Unified default resolver (bus default → console)."""
40
+ return self._bus.get_default_channel_key() or "console:stdin"
41
+
42
+ def _resolve_key(self, channel: str | None = None) -> str:
43
+ """
44
+ Priority: explicit arg → bound override → resolved default,
45
+ then run through ChannelBus alias resolver for canonical form.
46
+ """
47
+ raw = channel or self._override_key or self._resolve_default_key()
48
+ if not raw:
49
+ # Should never happen given the fallback, but fail fast if misconfigured
50
+ raise RuntimeError("ChannelSession: unable to resolve a channel key")
51
+ # NEW: alias → canonical resolution
52
+ return self._bus.resolve_channel_key(raw)
53
+
54
+ def _ensure_channel(self, event: "OutEvent", channel: str | None = None) -> "OutEvent":
55
+ """
56
+ Ensure event.channel is set to a concrete channel key before publishing.
57
+ If caller set event.channel already, keep it; otherwise fill in via resolver.
58
+ """
59
+ if not getattr(event, "channel", None):
60
+ event.channel = self._resolve_key(channel)
61
+ return event
62
+
63
+ @property
64
+ def _inbox_kv_key(self) -> str:
65
+ """Key for this channel's inbox in ephemeral KV store (legacy helper)."""
66
+ return f"inbox://{self._resolve_key()}"
67
+
68
+ @property
69
+ def _inbox_key(self) -> str:
70
+ return f"inbox:{self._resolve_key()}"
71
+
72
+ # -------- send --------
73
+ async def send(self, event: OutEvent, *, channel: str | None = None):
74
+ event = self._ensure_channel(event, channel=channel)
75
+ await self._bus.publish(event)
76
+
77
+ async def send_text(
78
+ self, text: str, *, meta: dict[str, Any] | None = None, channel: str | None = None
79
+ ):
80
+ event = OutEvent(
81
+ type="agent.message", channel=self._resolve_key(channel), text=text, meta=meta or {}
82
+ )
83
+ await self._bus.publish(event)
84
+
85
+ async def send_rich(
86
+ self,
87
+ text: str | None = None,
88
+ *,
89
+ rich: dict[str, Any] | None = None,
90
+ meta: dict[str, Any] | None = None,
91
+ channel: str | None = None,
92
+ ):
93
+ await self._bus.publish(
94
+ OutEvent(
95
+ type="agent.message",
96
+ channel=self._resolve_key(channel),
97
+ text=text,
98
+ rich=rich,
99
+ meta=meta or {},
100
+ )
101
+ )
102
+
103
+ async def send_image(
104
+ self,
105
+ url: str | None = None,
106
+ *,
107
+ alt: str = "image",
108
+ title: str | None = None,
109
+ channel: str | None = None,
110
+ ):
111
+ await self._bus.publish(
112
+ OutEvent(
113
+ type="agent.message",
114
+ channel=self._resolve_key(channel),
115
+ text=title or alt,
116
+ image={"url": url or "", "alt": alt, "title": title or ""},
117
+ )
118
+ )
119
+
120
+ async def send_file(
121
+ self,
122
+ url: str | None = None,
123
+ *,
124
+ file_bytes: bytes | None = None,
125
+ filename: str = "file.bin",
126
+ title: str | None = None,
127
+ channel: str | None = None,
128
+ ):
129
+ file = {"filename": filename}
130
+ if url:
131
+ file["url"] = url
132
+ if file_bytes is not None:
133
+ file["bytes"] = file_bytes
134
+ await self._bus.publish(
135
+ OutEvent(type="file.upload", channel=self._resolve_key(channel), text=title, file=file)
136
+ )
137
+
138
+ async def send_buttons(
139
+ self,
140
+ text: str,
141
+ buttons: list[Button],
142
+ *,
143
+ meta: dict[str, Any] | None = None,
144
+ channel: str | None = None,
145
+ ):
146
+ await self._bus.publish(
147
+ OutEvent(
148
+ type="link.buttons",
149
+ channel=self._resolve_key(channel),
150
+ text=text,
151
+ buttons=buttons,
152
+ meta=meta or {},
153
+ )
154
+ )
155
+
156
+ # Small core helper to avoid the wait-before-resume race and DRY the flow.
157
+ async def _ask_core(
158
+ self,
159
+ *,
160
+ kind: str,
161
+ payload: dict, # what stored in continuation.payload
162
+ channel: str | None,
163
+ timeout_s: int,
164
+ ) -> dict:
165
+ ch_key = self._resolve_key(channel)
166
+
167
+ # 1) Create continuation (with audit/security)
168
+ cont = await self.ctx.create_continuation(
169
+ channel=ch_key, kind=kind, payload=payload, deadline_s=timeout_s
170
+ )
171
+
172
+ # 2) PREPARE the wait future BEFORE notifying (prevents race)
173
+ fut = self.ctx.prepare_wait_for_resume(cont.token)
174
+
175
+ # 3) Notify (console/local-web may return {"payload": ...} inline)
176
+ res = await self._bus.notify(cont)
177
+
178
+ # 4) Inline short-circuit: skip waiting and cleanup
179
+ inline = (res or {}).get("payload")
180
+ if inline is not None:
181
+ # Defensive resolve (ok if already resolved by design)
182
+ try:
183
+ self.ctx.services.waits.resolve(cont.token, inline)
184
+ except Exception:
185
+ logger = logging.getLogger("aethergraph.services.channel.session")
186
+ logger.debug("Continuation token %s already resolved inline", cont.token)
187
+ try:
188
+ await self._cont_store.delete(self._run_id, self._node_id)
189
+ except Exception:
190
+ logger.debug("Failed to delete continuation for token %s", cont.token)
191
+ logger.exception("Error occurred while deleting continuation")
192
+ return inline
193
+
194
+ # 5) Push-only: bind correlator(s) so webhooks can locate the continuation
195
+ corr = (res or {}).get("correlator")
196
+ if corr:
197
+ await self._cont_store.bind_correlator(token=cont.token, corr=corr)
198
+ await self._cont_store.bind_correlator( # message-less key for thread roots
199
+ token=cont.token,
200
+ corr=Correlator(
201
+ scheme=corr.scheme, channel=corr.channel, thread=corr.thread, message=""
202
+ ),
203
+ )
204
+ else:
205
+ # Best-effort binding (peek thread/channel)
206
+ peek = await self._bus.peek_correlator(ch_key)
207
+ if peek:
208
+ await self._cont_store.bind_correlator(
209
+ token=cont.token, corr=Correlator(peek.scheme, peek.channel, peek.thread, "")
210
+ )
211
+ else:
212
+ await self._cont_store.bind_correlator(
213
+ token=cont.token, corr=Correlator(self._bus._prefix(ch_key), ch_key, "", "")
214
+ )
215
+
216
+ # 6) Await the already-prepared future (router will resolve it later)
217
+ return await fut
218
+
219
+ # ------------------ Public ask_* APIs (race-free, normalized) ------------------
220
+ async def ask_text(
221
+ self,
222
+ prompt: str | None,
223
+ *,
224
+ timeout_s: int = 3600,
225
+ silent: bool = False, # kept for back-compat; same behavior as before
226
+ channel: str | None = None,
227
+ ) -> str:
228
+ payload = await self._ask_core(
229
+ kind="user_input",
230
+ payload={"prompt": prompt, "_silent": silent},
231
+ channel=channel,
232
+ timeout_s=timeout_s,
233
+ )
234
+ return str(payload.get("text", ""))
235
+
236
+ async def wait_text(self, *, timeout_s: int = 3600, channel: str | None = None) -> str:
237
+ # Alias for ask_text(prompt=None) but keeps existing signature
238
+ return await self.ask_text(prompt=None, timeout_s=timeout_s, silent=True, channel=channel)
239
+
240
+ async def ask_approval(
241
+ self,
242
+ prompt: str,
243
+ options: Iterable[str] = ("Approve", "Reject"),
244
+ *,
245
+ timeout_s: int = 3600,
246
+ channel: str | None = None,
247
+ ) -> dict[str, Any]:
248
+ payload = await self._ask_core(
249
+ kind="approval",
250
+ payload={"prompt": {"title": prompt, "buttons": list(options)}},
251
+ channel=channel,
252
+ timeout_s=timeout_s,
253
+ )
254
+ choice = payload.get("choice")
255
+
256
+ # Normalize return
257
+ # 1) If adapter explicitly sets approved, trust it
258
+ buttons = list(options) # just plan list, not Button objects
259
+ # 2) Fallback: derive from choice + options
260
+ if choice is None or not buttons:
261
+ approved = False
262
+ else:
263
+ choice_norm = str(choice).strip().lower()
264
+ first_norm = str(buttons[0]).strip().lower()
265
+ approved = choice_norm == first_norm
266
+
267
+ return {
268
+ "approved": approved,
269
+ "choice": choice,
270
+ }
271
+
272
+ async def ask_files(
273
+ self,
274
+ *,
275
+ prompt: str,
276
+ accept: list[str] | None = None,
277
+ multiple: bool = True,
278
+ timeout_s: int = 3600,
279
+ channel: str | None = None,
280
+ ) -> dict:
281
+ """
282
+ Ask for file upload (plus optional text). Returns:
283
+ { "text": str, "files": List[FileRef] }
284
+ Note: console has no uploads; you’ll get only text there.
285
+
286
+ The `accept` list can contain MIME types (e.g., "image/png") or file extensions (e.g., ".png"). This
287
+ is a hint to the client UI about what file types to accept. Aethergraph does not enforce file type restrictions.
288
+ """
289
+ payload = await self._ask_core(
290
+ kind="user_files",
291
+ payload={"prompt": prompt, "accept": accept or [], "multiple": bool(multiple)},
292
+ channel=channel,
293
+ timeout_s=timeout_s,
294
+ )
295
+ return {
296
+ "text": str(payload.get("text", "")),
297
+ "files": payload.get("files", []) if isinstance(payload.get("files", []), list) else [],
298
+ }
299
+
300
+ async def ask_text_or_files(
301
+ self, *, prompt: str, timeout_s: int = 3600, channel: str | None = None
302
+ ) -> dict:
303
+ """
304
+ Ask for either text or files. Returns:
305
+ { "text": str, "files": List[FileRef] }
306
+ """
307
+ payload = await self._ask_core(
308
+ kind="user_input_or_files",
309
+ payload={"prompt": prompt},
310
+ channel=channel,
311
+ timeout_s=timeout_s,
312
+ )
313
+ return {
314
+ "text": str(payload.get("text", "")),
315
+ "files": payload.get("files", []) if isinstance(payload.get("files", []), list) else [],
316
+ }
317
+
318
+ # ---------- inbox helpers (platform-agnostic) ----------
319
+ async def get_latest_uploads(self, *, clear: bool = True) -> list[FileRef]:
320
+ """Get latest uploaded files in this channel's inbox, optionally clearing them."""
321
+ kv = getattr(self.ctx.services, "kv", None)
322
+ if kv:
323
+ if clear:
324
+ files = await kv.list_pop_all(self._inbox_kv_key) or []
325
+ else:
326
+ files = await kv.get(self._inbox_kv_key, []) or []
327
+ return files
328
+ else:
329
+ raise RuntimeError(
330
+ "EphemeralKV service not available in this context. Inbox not supported."
331
+ )
332
+
333
+ # ---------- streaming ----------
334
+ class _StreamSender:
335
+ def __init__(self, outer: "ChannelSession", *, channel_key: str | None = None):
336
+ self._outer = outer
337
+ self._started = False
338
+ # Resolve once (explicit -> bound -> default)
339
+ self._channel_key = outer._resolve_key(channel_key)
340
+ self._upsert_key = f"{outer._run_id}:{outer._node_id}:stream"
341
+
342
+ def _buf(self):
343
+ return getattr(self, "__buf", None)
344
+
345
+ def _ensure_buf(self):
346
+ if not hasattr(self, "__buf"):
347
+ self.__buf = []
348
+ return self.__buf
349
+
350
+ async def start(self):
351
+ if not self._started:
352
+ self._started = True
353
+ await self._outer._bus.publish(
354
+ OutEvent(
355
+ type="agent.stream.start",
356
+ channel=self._channel_key,
357
+ upsert_key=self._upsert_key,
358
+ )
359
+ )
360
+
361
+ async def delta(self, text_piece: str):
362
+ await self.start()
363
+ buf = self._ensure_buf()
364
+ buf.append(text_piece)
365
+ # Upsert full text so adapters can rewrite one message
366
+ await self._outer._bus.publish(
367
+ OutEvent(
368
+ type="agent.message.update",
369
+ channel=self._channel_key,
370
+ text="".join(buf),
371
+ upsert_key=self._upsert_key,
372
+ )
373
+ )
374
+
375
+ async def end(self, full_text: str | None = None):
376
+ if full_text is not None:
377
+ await self._outer._bus.publish(
378
+ OutEvent(
379
+ type="agent.message.update",
380
+ channel=self._channel_key,
381
+ text=full_text,
382
+ upsert_key=self._upsert_key,
383
+ )
384
+ )
385
+ await self._outer._bus.publish(
386
+ OutEvent(
387
+ type="agent.stream.end", channel=self._channel_key, upsert_key=self._upsert_key
388
+ )
389
+ )
390
+
391
+ @asynccontextmanager
392
+ async def stream(self, channel: str | None = None) -> AsyncIterator["_StreamSender"]:
393
+ """
394
+ Back-compat: no arg uses session/default/console.
395
+ New: pass a channel key to target a specific channel for this stream.
396
+ """
397
+ s = ChannelSession._StreamSender(self, channel_key=channel)
398
+ try:
399
+ yield s
400
+ finally:
401
+ # No auto-end; caller decides when to end()
402
+ pass
403
+
404
+ # ---------- progress ----------
405
+ class _ProgressSender:
406
+ def __init__(
407
+ self,
408
+ outer: "ChannelSession",
409
+ *,
410
+ title: str = "Working...",
411
+ total: int | None = None,
412
+ key_suffix: str = "progress",
413
+ channel_key: str | None = None,
414
+ ):
415
+ self._outer = outer
416
+ self._title = title
417
+ self._total = total
418
+ self._current = 0
419
+ self._started = False
420
+ # Resolve once (explicit -> bound -> default)
421
+ self._channel_key = outer._resolve_key(channel_key)
422
+ self._upsert_key = f"{outer._run_id}:{outer._node_id}:{key_suffix}"
423
+
424
+ async def start(self, *, subtitle: str | None = None):
425
+ if not self._started:
426
+ self._started = True
427
+ await self._outer._bus.publish(
428
+ OutEvent(
429
+ type="agent.progress.start",
430
+ channel=self._channel_key,
431
+ upsert_key=self._upsert_key,
432
+ rich={
433
+ "title": self._title,
434
+ "subtitle": subtitle or "",
435
+ "total": self._total,
436
+ "current": self._current,
437
+ },
438
+ )
439
+ )
440
+
441
+ async def update(
442
+ self,
443
+ *,
444
+ current: int | None = None,
445
+ inc: int | None = None,
446
+ subtitle: str | None = None,
447
+ percent: float | None = None,
448
+ eta_seconds: float | None = None,
449
+ ):
450
+ await self.start()
451
+ if percent is not None and self._total:
452
+ self._current = int(round(self._total * max(0.0, min(1.0, percent))))
453
+ if inc is not None:
454
+ self._current += int(inc)
455
+ if current is not None:
456
+ self._current = int(current)
457
+ payload = {
458
+ "title": self._title,
459
+ "subtitle": subtitle or "",
460
+ "total": self._total,
461
+ "current": self._current,
462
+ }
463
+ if eta_seconds is not None:
464
+ payload["eta_seconds"] = float(eta_seconds)
465
+ await self._outer._bus.publish(
466
+ OutEvent(
467
+ type="agent.progress.update",
468
+ channel=self._channel_key,
469
+ upsert_key=self._upsert_key,
470
+ rich=payload,
471
+ )
472
+ )
473
+
474
+ async def end(self, *, subtitle: str | None = "Done.", success: bool = True):
475
+ await self._outer._bus.publish(
476
+ OutEvent(
477
+ type="agent.progress.end",
478
+ channel=self._channel_key,
479
+ upsert_key=self._upsert_key,
480
+ rich={
481
+ "title": self._title,
482
+ "subtitle": subtitle or "",
483
+ "success": bool(success),
484
+ "total": self._total,
485
+ "current": self._total if self._total is not None else None,
486
+ },
487
+ )
488
+ )
489
+
490
+ @asynccontextmanager
491
+ async def progress(
492
+ self,
493
+ *,
494
+ title: str = "Working...",
495
+ total: int | None = None,
496
+ key_suffix: str = "progress",
497
+ channel: str | None = None,
498
+ ) -> AsyncIterator["_ProgressSender"]:
499
+ """
500
+ Back-compat: no channel uses session/default/console.
501
+ New: pass channel to target a specific channel for this progress bar.
502
+ """
503
+ p = ChannelSession._ProgressSender(
504
+ self, title=title, total=total, key_suffix=key_suffix, channel_key=channel
505
+ )
506
+ try:
507
+ await p.start()
508
+ yield p
509
+ finally:
510
+ # no auto-end
511
+ pass
@@ -0,0 +1,57 @@
1
+ from typing import Any
2
+
3
+ from aethergraph.services.continuations.continuation import Correlator
4
+
5
+
6
+ async def create_and_notify_continuation(
7
+ *,
8
+ context,
9
+ kind: str,
10
+ payload: dict[str, Any],
11
+ timeout_s: int,
12
+ channel: str | None = None,
13
+ ) -> tuple[str, dict[str, Any] | None]:
14
+ """
15
+ Returns (token, inline_payload_or_none)
16
+ Also binds correlators into the continuation store best-effort.
17
+ """
18
+ bus = context.services.channels # ChannelBus
19
+ store = context.services.continuation_store # ContinuationStore
20
+
21
+ ch_key = channel or bus.get_default_channel_key() or "console:stdin"
22
+
23
+ cont = await context.create_continuation(
24
+ channel=ch_key, kind=kind, payload=payload, deadline_s=timeout_s
25
+ )
26
+
27
+ res = await bus.notify(cont)
28
+ inline = (res or {}).get("payload")
29
+ if inline is not None:
30
+ # Don't short circut for DualStageTool, we will still roundtrip through resume
31
+ # so the toll path is uniform across adapters
32
+ pass
33
+
34
+ corr = (res or {}).get("correlator")
35
+ if corr:
36
+ await store.bind_correlator(token=cont.token, corr=corr)
37
+ # also bind a message-less thread root for loopup by thread only
38
+ await store.bind_correlator(
39
+ token=cont.token,
40
+ corr=Correlator(
41
+ scheme=corr.scheme, channel=corr.channel, thread=corr.thread, message=""
42
+ ),
43
+ )
44
+ else:
45
+ # best-effort: bind a correlator with just channel+thread if available
46
+ # best-effort
47
+ peek = await bus.peek_correlator(ch_key)
48
+ if peek:
49
+ await store.bind_correlator(
50
+ token=cont.token, corr=Correlator(peek.scheme, peek.channel, peek.thread, "")
51
+ )
52
+ else:
53
+ await store.bind_correlator(
54
+ token=cont.token, corr=Correlator(bus._prefix(ch_key), ch_key, "", "")
55
+ )
56
+
57
+ return str(cont.token), inline
@@ -0,0 +1,9 @@
1
+ from datetime import datetime, timezone
2
+
3
+
4
+ class SystemClock:
5
+ """System clock service."""
6
+
7
+ @staticmethod
8
+ def now() -> datetime:
9
+ return datetime.now(timezone.utc)