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.
- aethergraph/__init__.py +49 -0
- aethergraph/config/__init__.py +0 -0
- aethergraph/config/config.py +121 -0
- aethergraph/config/context.py +16 -0
- aethergraph/config/llm.py +26 -0
- aethergraph/config/loader.py +60 -0
- aethergraph/config/runtime.py +9 -0
- aethergraph/contracts/errors/errors.py +44 -0
- aethergraph/contracts/services/artifacts.py +142 -0
- aethergraph/contracts/services/channel.py +72 -0
- aethergraph/contracts/services/continuations.py +23 -0
- aethergraph/contracts/services/eventbus.py +12 -0
- aethergraph/contracts/services/kv.py +24 -0
- aethergraph/contracts/services/llm.py +17 -0
- aethergraph/contracts/services/mcp.py +22 -0
- aethergraph/contracts/services/memory.py +108 -0
- aethergraph/contracts/services/resume.py +28 -0
- aethergraph/contracts/services/state_stores.py +33 -0
- aethergraph/contracts/services/wakeup.py +28 -0
- aethergraph/core/execution/base_scheduler.py +77 -0
- aethergraph/core/execution/forward_scheduler.py +777 -0
- aethergraph/core/execution/global_scheduler.py +634 -0
- aethergraph/core/execution/retry_policy.py +22 -0
- aethergraph/core/execution/step_forward.py +411 -0
- aethergraph/core/execution/step_result.py +18 -0
- aethergraph/core/execution/wait_types.py +72 -0
- aethergraph/core/graph/graph_builder.py +192 -0
- aethergraph/core/graph/graph_fn.py +219 -0
- aethergraph/core/graph/graph_io.py +67 -0
- aethergraph/core/graph/graph_refs.py +154 -0
- aethergraph/core/graph/graph_spec.py +115 -0
- aethergraph/core/graph/graph_state.py +59 -0
- aethergraph/core/graph/graphify.py +128 -0
- aethergraph/core/graph/interpreter.py +145 -0
- aethergraph/core/graph/node_handle.py +33 -0
- aethergraph/core/graph/node_spec.py +46 -0
- aethergraph/core/graph/node_state.py +63 -0
- aethergraph/core/graph/task_graph.py +747 -0
- aethergraph/core/graph/task_node.py +82 -0
- aethergraph/core/graph/utils.py +37 -0
- aethergraph/core/graph/visualize.py +239 -0
- aethergraph/core/runtime/ad_hoc_context.py +61 -0
- aethergraph/core/runtime/base_service.py +153 -0
- aethergraph/core/runtime/bind_adapter.py +42 -0
- aethergraph/core/runtime/bound_memory.py +69 -0
- aethergraph/core/runtime/execution_context.py +220 -0
- aethergraph/core/runtime/graph_runner.py +349 -0
- aethergraph/core/runtime/lifecycle.py +26 -0
- aethergraph/core/runtime/node_context.py +203 -0
- aethergraph/core/runtime/node_services.py +30 -0
- aethergraph/core/runtime/recovery.py +159 -0
- aethergraph/core/runtime/run_registration.py +33 -0
- aethergraph/core/runtime/runtime_env.py +157 -0
- aethergraph/core/runtime/runtime_registry.py +32 -0
- aethergraph/core/runtime/runtime_services.py +224 -0
- aethergraph/core/runtime/wakeup_watcher.py +40 -0
- aethergraph/core/tools/__init__.py +10 -0
- aethergraph/core/tools/builtins/channel_tools.py +194 -0
- aethergraph/core/tools/builtins/toolset.py +134 -0
- aethergraph/core/tools/toolkit.py +510 -0
- aethergraph/core/tools/waitable.py +109 -0
- aethergraph/plugins/channel/__init__.py +0 -0
- aethergraph/plugins/channel/adapters/__init__.py +0 -0
- aethergraph/plugins/channel/adapters/console.py +106 -0
- aethergraph/plugins/channel/adapters/file.py +102 -0
- aethergraph/plugins/channel/adapters/slack.py +285 -0
- aethergraph/plugins/channel/adapters/telegram.py +302 -0
- aethergraph/plugins/channel/adapters/webhook.py +104 -0
- aethergraph/plugins/channel/adapters/webui.py +134 -0
- aethergraph/plugins/channel/routes/__init__.py +0 -0
- aethergraph/plugins/channel/routes/console_routes.py +86 -0
- aethergraph/plugins/channel/routes/slack_routes.py +49 -0
- aethergraph/plugins/channel/routes/telegram_routes.py +26 -0
- aethergraph/plugins/channel/routes/webui_routes.py +136 -0
- aethergraph/plugins/channel/utils/__init__.py +0 -0
- aethergraph/plugins/channel/utils/slack_utils.py +278 -0
- aethergraph/plugins/channel/utils/telegram_utils.py +324 -0
- aethergraph/plugins/channel/websockets/slack_ws.py +68 -0
- aethergraph/plugins/channel/websockets/telegram_polling.py +151 -0
- aethergraph/plugins/mcp/fs_server.py +128 -0
- aethergraph/plugins/mcp/http_server.py +101 -0
- aethergraph/plugins/mcp/ws_server.py +180 -0
- aethergraph/plugins/net/http.py +10 -0
- aethergraph/plugins/utils/data_io.py +359 -0
- aethergraph/runner/__init__.py +5 -0
- aethergraph/runtime/__init__.py +62 -0
- aethergraph/server/__init__.py +3 -0
- aethergraph/server/app_factory.py +84 -0
- aethergraph/server/start.py +122 -0
- aethergraph/services/__init__.py +10 -0
- aethergraph/services/artifacts/facade.py +284 -0
- aethergraph/services/artifacts/factory.py +35 -0
- aethergraph/services/artifacts/fs_store.py +656 -0
- aethergraph/services/artifacts/jsonl_index.py +123 -0
- aethergraph/services/artifacts/paths.py +23 -0
- aethergraph/services/artifacts/sqlite_index.py +209 -0
- aethergraph/services/artifacts/utils.py +124 -0
- aethergraph/services/auth/dev.py +16 -0
- aethergraph/services/channel/channel_bus.py +293 -0
- aethergraph/services/channel/factory.py +44 -0
- aethergraph/services/channel/session.py +511 -0
- aethergraph/services/channel/wait_helpers.py +57 -0
- aethergraph/services/clock/clock.py +9 -0
- aethergraph/services/container/default_container.py +320 -0
- aethergraph/services/continuations/continuation.py +56 -0
- aethergraph/services/continuations/factory.py +34 -0
- aethergraph/services/continuations/stores/fs_store.py +264 -0
- aethergraph/services/continuations/stores/inmem_store.py +95 -0
- aethergraph/services/eventbus/inmem.py +21 -0
- aethergraph/services/features/static.py +10 -0
- aethergraph/services/kv/ephemeral.py +90 -0
- aethergraph/services/kv/factory.py +27 -0
- aethergraph/services/kv/layered.py +41 -0
- aethergraph/services/kv/sqlite_kv.py +128 -0
- aethergraph/services/llm/factory.py +157 -0
- aethergraph/services/llm/generic_client.py +542 -0
- aethergraph/services/llm/providers.py +3 -0
- aethergraph/services/llm/service.py +105 -0
- aethergraph/services/logger/base.py +36 -0
- aethergraph/services/logger/compat.py +50 -0
- aethergraph/services/logger/formatters.py +106 -0
- aethergraph/services/logger/std.py +203 -0
- aethergraph/services/mcp/helpers.py +23 -0
- aethergraph/services/mcp/http_client.py +70 -0
- aethergraph/services/mcp/mcp_tools.py +21 -0
- aethergraph/services/mcp/registry.py +14 -0
- aethergraph/services/mcp/service.py +100 -0
- aethergraph/services/mcp/stdio_client.py +70 -0
- aethergraph/services/mcp/ws_client.py +115 -0
- aethergraph/services/memory/bound.py +106 -0
- aethergraph/services/memory/distillers/episode.py +116 -0
- aethergraph/services/memory/distillers/rolling.py +74 -0
- aethergraph/services/memory/facade.py +633 -0
- aethergraph/services/memory/factory.py +78 -0
- aethergraph/services/memory/hotlog_kv.py +27 -0
- aethergraph/services/memory/indices.py +74 -0
- aethergraph/services/memory/io_helpers.py +72 -0
- aethergraph/services/memory/persist_fs.py +40 -0
- aethergraph/services/memory/resolver.py +152 -0
- aethergraph/services/metering/noop.py +4 -0
- aethergraph/services/prompts/file_store.py +41 -0
- aethergraph/services/rag/chunker.py +29 -0
- aethergraph/services/rag/facade.py +593 -0
- aethergraph/services/rag/index/base.py +27 -0
- aethergraph/services/rag/index/faiss_index.py +121 -0
- aethergraph/services/rag/index/sqlite_index.py +134 -0
- aethergraph/services/rag/index_factory.py +52 -0
- aethergraph/services/rag/parsers/md.py +7 -0
- aethergraph/services/rag/parsers/pdf.py +14 -0
- aethergraph/services/rag/parsers/txt.py +7 -0
- aethergraph/services/rag/utils/hybrid.py +39 -0
- aethergraph/services/rag/utils/make_fs_key.py +62 -0
- aethergraph/services/redactor/simple.py +16 -0
- aethergraph/services/registry/key_parsing.py +44 -0
- aethergraph/services/registry/registry_key.py +19 -0
- aethergraph/services/registry/unified_registry.py +185 -0
- aethergraph/services/resume/multi_scheduler_resume_bus.py +65 -0
- aethergraph/services/resume/router.py +73 -0
- aethergraph/services/schedulers/registry.py +41 -0
- aethergraph/services/secrets/base.py +7 -0
- aethergraph/services/secrets/env.py +8 -0
- aethergraph/services/state_stores/externalize.py +135 -0
- aethergraph/services/state_stores/graph_observer.py +131 -0
- aethergraph/services/state_stores/json_store.py +67 -0
- aethergraph/services/state_stores/resume_policy.py +119 -0
- aethergraph/services/state_stores/serialize.py +249 -0
- aethergraph/services/state_stores/utils.py +91 -0
- aethergraph/services/state_stores/validate.py +78 -0
- aethergraph/services/tracing/noop.py +18 -0
- aethergraph/services/waits/wait_registry.py +91 -0
- aethergraph/services/wakeup/memory_queue.py +57 -0
- aethergraph/services/wakeup/scanner_producer.py +56 -0
- aethergraph/services/wakeup/worker.py +31 -0
- aethergraph/tools/__init__.py +25 -0
- aethergraph/utils/optdeps.py +8 -0
- aethergraph-0.1.0a1.dist-info/METADATA +410 -0
- aethergraph-0.1.0a1.dist-info/RECORD +182 -0
- aethergraph-0.1.0a1.dist-info/WHEEL +5 -0
- aethergraph-0.1.0a1.dist-info/entry_points.txt +2 -0
- aethergraph-0.1.0a1.dist-info/licenses/LICENSE +176 -0
- aethergraph-0.1.0a1.dist-info/licenses/NOTICE +31 -0
- 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
|