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,224 @@
1
+ from collections.abc import Callable
2
+ from contextlib import contextmanager
3
+ from contextvars import ContextVar
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from aethergraph.contracts.services.llm import LLMClientProtocol
8
+ from aethergraph.services.llm.generic_client import GenericLLMClient
9
+
10
+ _current = ContextVar("aeg_services", default=None)
11
+ # process-wide fallback (handles contextvar boundary issues)
12
+ _services_global: Any = None
13
+
14
+
15
+ def install_services(services: Any) -> None:
16
+ global _services_global
17
+ _services_global = services
18
+ return _current.set(services)
19
+
20
+
21
+ def ensure_services_installed(factory: Callable[[], Any]) -> Any:
22
+ global _services_global
23
+ svc = _current.get() or _services_global
24
+ if svc is None:
25
+ svc = factory()
26
+ _services_global = svc
27
+ _current.set(svc) # keep ContextVar in sync for this context
28
+ return svc
29
+
30
+
31
+ def current_services() -> Any:
32
+ svc = _current.get() or _services_global
33
+ if svc is None:
34
+ raise RuntimeError(
35
+ "No services installed. Call install_services(container) at app startup."
36
+ )
37
+ return svc
38
+
39
+
40
+ @contextmanager
41
+ def use_services(services):
42
+ tok = _current.set(services)
43
+ try:
44
+ yield
45
+ finally:
46
+ _current.reset(tok)
47
+
48
+
49
+ # --------- Channel service helpers ---------
50
+ def get_channel_service() -> Any:
51
+ svc = current_services()
52
+ return svc.channels # ChannelBus
53
+
54
+
55
+ def set_default_channel(key: str) -> None:
56
+ svc = current_services()
57
+ svc.channels.set_default_channel_key(key)
58
+ return
59
+
60
+
61
+ def get_default_channel() -> str:
62
+ svc = current_services()
63
+ return svc.channels.default_channel_key
64
+
65
+
66
+ def set_channel_alias(alias: str, channel_key: str) -> None:
67
+ svc = current_services()
68
+ svc.channels.register_alias(alias, channel_key)
69
+
70
+
71
+ def register_channel_adapter(name: str, adapter: Any) -> None:
72
+ svc = current_services()
73
+ svc.channel.register_adapter(name, adapter)
74
+
75
+
76
+ # --------- LLM service helpers ---------
77
+ def get_llm_service() -> Any:
78
+ svc = current_services()
79
+ return svc.llm
80
+
81
+
82
+ def register_llm_client(
83
+ profile: str,
84
+ provider: str,
85
+ model: str,
86
+ embed_model: str | None = None,
87
+ base_url: str | None = None,
88
+ api_key: str | None = None,
89
+ timeout: float | None = None,
90
+ ) -> None:
91
+ svc = current_services()
92
+ client = svc.llm.configure_profile(
93
+ profile=profile,
94
+ provider=provider,
95
+ model=model,
96
+ embed_model=embed_model,
97
+ base_url=base_url,
98
+ api_key=api_key,
99
+ timeout=timeout,
100
+ )
101
+ return client
102
+
103
+
104
+ # backend compatibility
105
+ set_llm_client = register_llm_client
106
+
107
+
108
+ def set_rag_llm_client(
109
+ client: LLMClientProtocol | None = None,
110
+ *,
111
+ provider: str | None = None,
112
+ model: str | None = None,
113
+ embed_model: str | None = None,
114
+ base_url: str | None = None,
115
+ api_key: str | None = None,
116
+ timeout: float | None = None,
117
+ ) -> LLMClientProtocol:
118
+ """Set the LLM client to use for RAG service.
119
+ If client is provided, use it directly.
120
+ Otherwise, create a new client using the provided parameters."""
121
+ svc = current_services()
122
+ if client is None:
123
+ if provider is None or model is None or embed_model is None:
124
+ raise ValueError(
125
+ "Must provide provider, model, and embed_model to create RAG LLM client"
126
+ )
127
+ try:
128
+ client = GenericLLMClient(
129
+ provider=provider,
130
+ model=model,
131
+ embed_model=embed_model,
132
+ base_url=base_url,
133
+ api_key=api_key,
134
+ timeout=timeout,
135
+ )
136
+ except Exception as e:
137
+ raise RuntimeError(f"Failed to create RAG LLM client: {e}") from e
138
+
139
+ svc.rag.set_llm_client(client=client)
140
+ return client
141
+
142
+
143
+ def set_rag_index_backend(
144
+ *,
145
+ backend: str | None = None, # "sqlite" | "faiss"
146
+ index_path: str | None = None,
147
+ dim: int | None = None,
148
+ ):
149
+ """
150
+ Configure the RAG index backend. If backend='faiss' but FAISS is missing,
151
+ we log a warning and fall back to SQLite automatically.
152
+ """
153
+ from aethergraph.services.rag.index_factory import create_vector_index
154
+
155
+ svc = current_services()
156
+ # resolve defaults from settings
157
+ s = svc.settings.rag # AppSettings.rag bound into services
158
+ backend = backend or s.backend
159
+ index_path = index_path or s.index_path
160
+ dim = dim if dim is not None else s.dim
161
+ root = svc.settings.root
162
+
163
+ index = create_vector_index(
164
+ backend=backend, index_path=index_path, dim=dim, root=str(Path(root) / "rag")
165
+ )
166
+ svc.rag.set_index_backend(index)
167
+ return index
168
+
169
+
170
+ # --------- Logger helpers ---------
171
+ def current_logger_factory() -> Any:
172
+ svc = current_services()
173
+ return svc.logger
174
+
175
+
176
+ # --------- External context services ---------
177
+ def register_context_service(name: str, service: Any) -> None:
178
+ svc = current_services()
179
+ svc.ext_services[name] = service
180
+
181
+
182
+ def get_ext_context_service(name: str) -> Any:
183
+ svc = current_services()
184
+ return svc.ext_services.get(name)
185
+
186
+
187
+ def list_ext_context_services() -> list[str]:
188
+ svc = current_services()
189
+ return list(svc.ext_services.keys())
190
+
191
+
192
+ # --------- MCP service helpers ---------
193
+ def set_mcp_service(mcp_service: Any) -> None:
194
+ svc = current_services()
195
+ svc.mcp = mcp_service
196
+
197
+
198
+ def get_mcp_service() -> Any:
199
+ svc = current_services()
200
+ return svc.mcp
201
+
202
+
203
+ def register_mcp_client(name: str, client: Any) -> None:
204
+ svc = current_services()
205
+ if svc.mcp is None:
206
+ raise RuntimeError("No MCP service installed. Call set_mcp_service() first.")
207
+ svc.mcp.register(name, client)
208
+
209
+
210
+ def list_mcp_clients() -> list[str]:
211
+ svc = current_services()
212
+ if svc.mcp:
213
+ return svc.mcp.list_clients()
214
+ return []
215
+
216
+
217
+ # --------- Scheduler helpers --------- - (Not used)
218
+ def ensure_global_scheduler_started() -> None:
219
+ svc = current_services()
220
+ sched = svc.schedulers.get("global")
221
+ if sched and not sched.is_running():
222
+ import asyncio
223
+
224
+ asyncio.create_task(sched.run_forever())
@@ -0,0 +1,40 @@
1
+ import asyncio
2
+ import time
3
+
4
+ """This is a template implementation of a WakeupWatcher that periodically checks for
5
+ Currenlty, we have not materialize the wakeup method in Aethergraph core.
6
+ """
7
+
8
+
9
+ class WakeupWatcher:
10
+ def __init__(self, cont_store, resume_bus, poll_sec: int = 10):
11
+ self.cont_store = cont_store
12
+ self.resume_bus = resume_bus
13
+ self.poll_sec = poll_sec
14
+ self._task = None
15
+ self._stop = asyncio.Event()
16
+
17
+ async def start(self):
18
+ self._task = asyncio.create_task(self._loop())
19
+
20
+ async def stop(self):
21
+ self._stop.set()
22
+ if self._task:
23
+ await self._task
24
+
25
+ async def _loop(self):
26
+ while not self._stop.is_set():
27
+ now = time.time()
28
+ due = await self.cont_store.list_due_wakeups(now)
29
+ for cont in due:
30
+ # Publish to bus; bus routes to the right scheduler via run_id ↦ scheduler mapping
31
+ await self.resume_bus.post_wakeup(cont.run_id, cont.node_id)
32
+ # update next_wakeup in the store if needed
33
+ await self.cont_store.bump_wakeup(cont)
34
+ try:
35
+ await asyncio.wait_for(self._stop.wait(), timeout=self.poll_sec)
36
+ except asyncio.TimeoutError:
37
+ import logging
38
+
39
+ logger = logging.getLogger("aethergraph.core.runtime.wakeup_watcher")
40
+ logger.info("WakeupWatcher polling for due continuations...")
@@ -0,0 +1,10 @@
1
+ from .builtins.toolset import (
2
+ ask_approval as ask_approval,
3
+ ask_files as ask_files,
4
+ ask_text as ask_text,
5
+ get_latest_uploads as get_latest_uploads,
6
+ send_buttons as send_buttons,
7
+ send_file as send_file,
8
+ send_image as send_image,
9
+ send_text as send_text,
10
+ )
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from aethergraph.services.channel.wait_helpers import create_and_notify_continuation
6
+
7
+ from ...execution.wait_types import WaitSpec
8
+ from ..waitable import DualStageTool, waitable_tool
9
+
10
+
11
+ def normalize_approval_result(payload: dict) -> dict:
12
+ """
13
+ Normalize approval result from various adapters into a consistent format.
14
+
15
+ It assumes the payload may contain:
16
+ - "approved": bool (explicit approval flag)
17
+ - "choice": str (the user's choice)
18
+ It infers "approved" from "choice" if "approved" is not present.
19
+ """
20
+ choice = payload.get("choice")
21
+
22
+ # infer from options (first = approved)
23
+ options = payload.get("options") or payload.get("buttons")
24
+ if not options:
25
+ prompt = payload.get("prompt")
26
+ if isinstance(prompt, dict):
27
+ options = prompt.get("buttons")
28
+
29
+ if not options or choice is None:
30
+ approved = False
31
+ else:
32
+ choice_norm = str(choice).strip().lower()
33
+ first_norm = str(options[0]).strip().lower()
34
+ approved = choice_norm == first_norm
35
+
36
+ return {"approved": approved, "choice": choice}
37
+
38
+
39
+ # ----- AskTextTool -----
40
+ class AskText(DualStageTool):
41
+ outputs = ["text"]
42
+
43
+ async def setup(
44
+ self,
45
+ prompt: str | None = None,
46
+ *,
47
+ silent: bool = False,
48
+ timeout_s: int = 3600,
49
+ channel: str | None = None,
50
+ context,
51
+ ) -> WaitSpec | dict[str, Any]:
52
+ token, inline = await create_and_notify_continuation(
53
+ context=context,
54
+ kind="user_input",
55
+ payload={"prompt": prompt, "_force_push": True, "_silent": silent}
56
+ if prompt
57
+ else {"_force_push": True, "_silent": silent},
58
+ timeout_s=timeout_s,
59
+ channel=channel,
60
+ )
61
+ return WaitSpec(
62
+ channel=channel,
63
+ token=token,
64
+ kind="user_input",
65
+ deadline=timeout_s,
66
+ notified=True,
67
+ inline_payload=inline,
68
+ )
69
+
70
+ async def on_resume(self, resume: dict[str, Any], *, context: Any) -> dict[str, Any]:
71
+ text = resume.get("text", "")
72
+ return {"text": text}
73
+
74
+
75
+ ask_text_ds = waitable_tool(AskText)
76
+
77
+
78
+ # ----- WaitText Tool -----
79
+ class WaitText(DualStageTool):
80
+ outputs = ["text"]
81
+
82
+ async def setup(
83
+ self,
84
+ *,
85
+ timeout_s: int = 3600,
86
+ channel: str | None = None,
87
+ context,
88
+ ) -> WaitSpec | dict[str, Any]:
89
+ token, inline = await create_and_notify_continuation(
90
+ context=context,
91
+ kind="user_input",
92
+ payload={"prompt": None, "_force_push": True, "_silent": True},
93
+ timeout_s=timeout_s,
94
+ channel=channel,
95
+ )
96
+ return WaitSpec(
97
+ channel=channel,
98
+ token=token,
99
+ kind="user_input",
100
+ deadline=timeout_s,
101
+ notified=True,
102
+ inline_payload=inline,
103
+ )
104
+
105
+ async def on_resume(self, resume: dict[str, Any], *, context: Any) -> dict[str, Any]:
106
+ text = resume.get("text", "")
107
+ return {"text": text}
108
+
109
+
110
+ wait_text_ds = waitable_tool(WaitText)
111
+
112
+
113
+ # ----- AskApprovalTool -----
114
+ class AskApproval(DualStageTool):
115
+ outputs = ["approved", "choice"]
116
+
117
+ async def setup(
118
+ self,
119
+ prompt: str,
120
+ options: list[str] | tuple[str, ...] = ("Approve", "Reject"),
121
+ *,
122
+ timeout_s: int = 3600,
123
+ channel: str | None = None,
124
+ context: Any,
125
+ ) -> WaitSpec | dict[str, Any]:
126
+ token, inline = await create_and_notify_continuation(
127
+ context=context,
128
+ kind="approval",
129
+ payload={"prompt": {"title": prompt, "buttons": list(options)}, "_force_push": True},
130
+ timeout_s=timeout_s,
131
+ channel=channel,
132
+ )
133
+ return WaitSpec(
134
+ channel=channel,
135
+ token=token,
136
+ kind="approval",
137
+ deadline=timeout_s,
138
+ notified=True,
139
+ inline_payload=inline,
140
+ )
141
+
142
+ async def on_resume(self, resume: dict[str, Any], *, context: Any) -> dict[str, Any]:
143
+ return normalize_approval_result(resume)
144
+
145
+
146
+ ask_approval_ds = waitable_tool(AskApproval)
147
+
148
+
149
+ # ----- AskFiles Tool -----
150
+ class AskFiles(DualStageTool):
151
+ outputs = ["text", "files"]
152
+
153
+ async def setup(
154
+ self,
155
+ *,
156
+ prompt: str,
157
+ accept: list[str] | None = None,
158
+ multiple: bool = True,
159
+ timeout_s: int = 3600,
160
+ channel: str | None = None,
161
+ context: Any,
162
+ ) -> WaitSpec | dict[str, Any]:
163
+ token, inline = await create_and_notify_continuation(
164
+ context=context,
165
+ kind="user_files",
166
+ payload={
167
+ "prompt": prompt,
168
+ "accept": accept or [],
169
+ "multiple": bool(multiple),
170
+ "_force_push": True,
171
+ },
172
+ timeout_s=timeout_s,
173
+ channel=channel,
174
+ )
175
+ return WaitSpec(
176
+ channel=channel,
177
+ token=token,
178
+ kind="user_files",
179
+ deadline=timeout_s,
180
+ notified=True,
181
+ inline_payload=inline,
182
+ )
183
+
184
+ async def on_resume(self, resume: dict[str, Any], *, context: Any) -> dict[str, Any]:
185
+ files = resume.get("files", [])
186
+ if not isinstance(files, list):
187
+ files = []
188
+ return {
189
+ "text": str(resume.get("text", "")),
190
+ "files": files,
191
+ }
192
+
193
+
194
+ ask_files_ds = waitable_tool(AskFiles)
@@ -0,0 +1,134 @@
1
+ from typing import Any
2
+
3
+ from aethergraph.contracts.services.channel import Button, FileRef
4
+
5
+ from ..toolkit import tool
6
+ from .channel_tools import ask_approval_ds, ask_files_ds, ask_text_ds, wait_text_ds
7
+
8
+
9
+ @tool(name="ask_text", outputs=["text"])
10
+ async def ask_text(
11
+ *,
12
+ resume=None,
13
+ context=None,
14
+ prompt: str | None = None,
15
+ silent: bool = False,
16
+ timeout_s: int = 3600,
17
+ channel: str | None = None,
18
+ ):
19
+ return await ask_text_ds(
20
+ resume=resume,
21
+ context=context,
22
+ prompt=prompt,
23
+ silent=silent,
24
+ timeout_s=timeout_s,
25
+ channel=channel,
26
+ )
27
+
28
+
29
+ @tool(name="wait_text", outputs=["text"])
30
+ async def wait_text(
31
+ *, resume=None, context=None, timeout_s: int = 3600, channel: str | None = None
32
+ ):
33
+ return await wait_text_ds(resume=resume, context=context, timeout_s=timeout_s, channel=channel)
34
+
35
+
36
+ @tool(name="ask_approval", outputs=["approved", "choice"])
37
+ async def ask_approval(
38
+ *,
39
+ resume=None,
40
+ context=None,
41
+ prompt: str,
42
+ options: list[str] | tuple[str, ...] = ("Approve", "Reject"),
43
+ timeout_s: int = 3600,
44
+ channel: str | None = None,
45
+ ):
46
+ return await ask_approval_ds(
47
+ resume=resume,
48
+ context=context,
49
+ prompt=prompt,
50
+ options=options,
51
+ timeout_s=timeout_s,
52
+ channel=channel,
53
+ )
54
+
55
+
56
+ @tool(name="ask_files", outputs=["text", "files"])
57
+ async def ask_files(
58
+ *,
59
+ resume=None,
60
+ context=None,
61
+ prompt: str,
62
+ accept: list[str] | None = None,
63
+ multiple: bool = True,
64
+ timeout_s: int = 3600,
65
+ channel: str | None = None,
66
+ ):
67
+ return await ask_files_ds(
68
+ resume=resume,
69
+ context=context,
70
+ prompt=prompt,
71
+ accept=accept,
72
+ multiple=multiple,
73
+ timeout_s=timeout_s,
74
+ channel=channel,
75
+ )
76
+
77
+
78
+ @tool(name="send_text", outputs=["ok"])
79
+ async def send_text(
80
+ *, text: str, meta: dict[str, Any] | None = None, channel: str | None = None, context=None
81
+ ):
82
+ ch = context.channel(channel)
83
+ await ch.send_text(text, meta=meta or {})
84
+ return {"ok": True}
85
+
86
+
87
+ @tool(name="send_image", outputs=["ok"])
88
+ async def send_image(
89
+ *,
90
+ url: str | None = None,
91
+ alt: str = "image",
92
+ title: str | None = None,
93
+ channel: str | None = None,
94
+ context=None,
95
+ ):
96
+ ch = context.channel(channel)
97
+ await ch.send_image(url=url, alt=alt, title=title)
98
+ return {"ok": True}
99
+
100
+
101
+ @tool(name="send_file", outputs=["ok"])
102
+ async def send_file(
103
+ *,
104
+ url: str | None = None,
105
+ file_bytes: bytes | None = None,
106
+ filename: str = "file.bin",
107
+ title: str | None = None,
108
+ channel: str | None = None,
109
+ context=None,
110
+ ):
111
+ ch = context.channel(channel)
112
+ await ch.send_file(url=url, file_bytes=file_bytes, filename=filename, title=title)
113
+ return {"ok": True}
114
+
115
+
116
+ @tool(name="send_buttons", outputs=["ok"])
117
+ async def send_buttons(
118
+ *,
119
+ text: str,
120
+ buttons: list[Button],
121
+ meta: dict[str, Any] | None = None,
122
+ channel: str | None = None,
123
+ context=None,
124
+ ):
125
+ ch = context.channel(channel)
126
+ await ch.send_buttons(text=text, buttons=buttons, meta=meta or {})
127
+ return {"ok": True}
128
+
129
+
130
+ @tool(name="get_lastest_uploads", outputs=["files"])
131
+ async def get_latest_uploads(*, clear: bool = True, context) -> list[FileRef]:
132
+ ch = context.channel() # any channel session will expose the same get_latest_uploads
133
+ files = await ch.get_latest_uploads(clear=clear)
134
+ return {"files": files}