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,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}
|