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,510 @@
1
+ from collections.abc import Callable
2
+ from functools import wraps
3
+ import importlib
4
+ import inspect
5
+ from typing import Any
6
+ import uuid
7
+
8
+ from ..execution.step_forward import _normalize_result
9
+ from ..graph.graph_builder import current_builder
10
+ from ..graph.interpreter import AwaitableResult, SimpleNS, current_interpreter
11
+ from ..graph.node_handle import NodeHandle
12
+ from ..runtime.runtime_registry import current_registry
13
+ from .waitable import DualStageTool, waitable_tool
14
+
15
+
16
+ def _infer_inputs_from_signature(fn: Callable) -> list[str]:
17
+ sig = inspect.signature(fn)
18
+ keys = []
19
+ for p in sig.parameters.values():
20
+ if p.kind in (p.VAR_KEYWORD, p.VAR_POSITIONAL):
21
+ continue
22
+ keys.append(p.name)
23
+ return keys
24
+
25
+
26
+ def _normalize_result_to_dict(res: Any) -> dict:
27
+ """Normalize function result into a dict of outputs.
28
+ Supports:
29
+ - None -> {}
30
+ - dict -> as-is
31
+ - tuple -> {"out0": v0, "out1": v1, ...}
32
+ - single value -> {"result": value}
33
+ """
34
+ if res is None:
35
+ return {}
36
+ if isinstance(res, dict):
37
+ return res
38
+ if isinstance(res, tuple):
39
+ return {f"out{i}": v for i, v in enumerate(res)}
40
+ return {"result": res}
41
+
42
+
43
+ def _check_contract(outputs, out, impl):
44
+ missing = [k for k in outputs if k not in out]
45
+ if missing:
46
+ raise ValueError(
47
+ f"Tool {getattr(impl, '__name__', type(impl).__name__)} missing outputs: {missing}"
48
+ )
49
+
50
+
51
+ def resolve_dotted(path: str):
52
+ """Resolve a dotted path to a callable."""
53
+ # "pkg.mod:symbol" or "pkg.mod.symbol"
54
+ if ":" in path:
55
+ mod, _, sym = path.partition(":")
56
+ return getattr(importlib.import_module(mod), sym)
57
+ mod, _, attr = path.rpartition(".")
58
+ return getattr(importlib.import_module(mod), attr)
59
+
60
+
61
+ CONTROL_KW = ("_after", "_name", "_condition", "_id", "_alias", "_labels")
62
+
63
+
64
+ def _split_control_kwargs(kwargs: dict):
65
+ ctrl = {k: kwargs.pop(k) for k in CONTROL_KW if k in kwargs}
66
+ return ctrl, kwargs
67
+
68
+
69
+ def tool(
70
+ outputs: list[str],
71
+ inputs: list[str] | None = None,
72
+ *,
73
+ name: str | None = None,
74
+ version: str = "0.1.0",
75
+ ):
76
+ """
77
+ Dual-mode decorator for plain functions and DualStageTool classes.
78
+ - Graph mode: builds node (returns NodeHandle)
79
+ - Immediate mode: executes (sync returns dict; async returns awaitable)
80
+ - Registry: if provided, we always record a registry key on the proxy.
81
+ - persist=None -> register_callable (dev hot reload)
82
+ - persist="file" -> persist code under project/tools/... and register_file
83
+ """
84
+
85
+ def _wrap(obj):
86
+ # -- normalize impl --
87
+ waitable = inspect.isclass(obj) and issubclass(obj, DualStageTool)
88
+ impl = waitable_tool(obj) if waitable else obj
89
+ sig = inspect.signature(impl)
90
+ declared_inputs = inputs or [
91
+ p.name
92
+ for p in sig.parameters.values()
93
+ if p.kind not in (p.VAR_KEYWORD, p.VAR_POSITIONAL)
94
+ ]
95
+ is_async = inspect.iscoroutinefunction(impl) or (
96
+ callable(impl) and inspect.iscoroutinefunction(impl.__call__)
97
+ )
98
+
99
+ # -- proxy --
100
+ if is_async:
101
+
102
+ async def _immediate(call_kwargs):
103
+ res = await impl(**call_kwargs)
104
+ out = _normalize_result(res)
105
+ _check_contract(outputs, out, impl)
106
+ return out
107
+
108
+ @wraps(impl)
109
+ def proxy(*args, **kwargs):
110
+ ctrl, kwargs = _split_control_kwargs(dict(kwargs)) # copy+strip control
111
+
112
+ bound = sig.bind_partial(*args, **kwargs)
113
+ bound.apply_defaults()
114
+ call_kwargs = dict(bound.arguments)
115
+ if current_builder() is not None:
116
+ return call_tool(proxy, **call_kwargs, **ctrl)
117
+ return _immediate(call_kwargs)
118
+ else:
119
+
120
+ @wraps(impl)
121
+ def proxy(*args, **kwargs):
122
+ ctrl, kwargs = _split_control_kwargs(dict(kwargs)) # copy+strip control
123
+ bound = sig.bind_partial(*args, **kwargs)
124
+ bound.apply_defaults()
125
+ call_kwargs = dict(bound.arguments)
126
+ if current_builder() is not None:
127
+ return call_tool(proxy, **call_kwargs, **ctrl)
128
+ out = _normalize_result(impl(**call_kwargs))
129
+ _check_contract(outputs, out, impl)
130
+ return out
131
+
132
+ # annotate
133
+ proxy.__aether_inputs__ = list(declared_inputs)
134
+ proxy.__aether_outputs__ = list(outputs)
135
+ proxy.__aether_impl__ = impl
136
+
137
+ if waitable:
138
+ proxy.__aether_waitable__ = True
139
+ proxy.__aether_tool_class__ = obj # original class
140
+
141
+ # registry behavior
142
+ registry = current_registry()
143
+ if registry is not None:
144
+ registry.register(
145
+ nspace="tool",
146
+ name=name or getattr(impl, "__name__", "tool"),
147
+ version=version,
148
+ obj=impl,
149
+ )
150
+
151
+ return proxy
152
+
153
+ return _wrap
154
+
155
+
156
+ def _id_of(x):
157
+ return getattr(x, "node_id", x) # accepts NodeHandle or str
158
+
159
+
160
+ def _ensure_list(x):
161
+ if x is None:
162
+ return []
163
+ if isinstance(x, list | tuple | set):
164
+ return list(x)
165
+ return [x]
166
+
167
+
168
+ def call_tool_old(fn_or_path, **kwargs):
169
+ builder = current_builder()
170
+ interp = current_interpreter()
171
+ # --- extract control-plane early
172
+
173
+ ctrl, kwargs = _split_control_kwargs(kwargs)
174
+ after_raw = ctrl.get("_after", None)
175
+ name_hint = ctrl.get("_name", None)
176
+ alias = ctrl.get("_alias", None)
177
+ node_id_kw = ctrl.get("_id", None) # hard override for node_id
178
+ labels = _ensure_list(ctrl.get("_labels", None))
179
+ # condition = ctrl.get("_condition", None) # not implemented yet
180
+
181
+ after_ids = [_id_of(a) for a in _ensure_list(after_raw)]
182
+
183
+ if interp is not None:
184
+ """ running under an interpreter (graph execution)
185
+ 1. if builder is present, we are in graph-building mode inside a graph(...) context
186
+ 2. if no builder, we are in immediate execution mode; run the node directly
187
+ """
188
+ if isinstance(fn_or_path, str):
189
+ logic = fn_or_path
190
+ logic_name = logic.rsplit(".", 1)[-1]
191
+ inputs_decl = list(kwargs.keys())
192
+ outputs_decl = ["result"]
193
+ else:
194
+ impl = getattr(fn_or_path, "__aether_impl__", fn_or_path)
195
+ reg_key = getattr(fn_or_path, "__aether_registry_key__", None)
196
+
197
+ # Prefer registry for portability (esp. waitables)
198
+ if reg_key:
199
+ logic = f"registry:{reg_key}" # e.g. "registry:tool:approve_report@0.1.0"
200
+ logic_name = reg_key.split(":")[1].split("@")[0]
201
+ logic_version = reg_key.split("@")[1] if "@" in reg_key else None
202
+ else:
203
+ logic = f"{impl.__module__}.{getattr(impl, '__name__', 'tool')}"
204
+ logic_name = getattr(impl, "__name__", "tool")
205
+ logic_version = getattr(impl, "__version__", None)
206
+
207
+ inputs_decl = getattr(
208
+ fn_or_path, "__aether_inputs__", _infer_inputs_from_signature(impl)
209
+ )
210
+ outputs_decl = getattr(fn_or_path, "__aether_outputs__", ["result"])
211
+
212
+ # add node to the (fresh) graph; dependencies are enforced by the schedule order we create
213
+ node_id = (
214
+ builder.next_id(logic_name=logic_name)
215
+ if builder
216
+ else f"{logic_name}_{uuid.uuid4().hex[:6]}"
217
+ )
218
+
219
+ if builder is None:
220
+ raise RuntimeError(
221
+ "Interpreter expects a TaskGraph builder context; missing `with graph(...)`"
222
+ )
223
+
224
+ if node_id_kw:
225
+ node_id = node_id_kw # override if provided
226
+ elif alias:
227
+ node_id = alias # override if provided
228
+ else:
229
+ node_id = builder.next_id(logic_name=logic_name)
230
+
231
+ if node_id in builder.spec.nodes:
232
+ raise ValueError(
233
+ f"Node ID '{node_id}' already exists in graph '{builder.spec.graph_id}'"
234
+ )
235
+
236
+ builder.add_tool_node(
237
+ node_id=node_id,
238
+ logic=logic,
239
+ inputs=kwargs,
240
+ expected_input_keys=inputs_decl,
241
+ expected_output_keys=outputs_decl,
242
+ # after=kwargs.pop("_after", None),
243
+ after=after_ids,
244
+ tool_name=logic_name,
245
+ tool_version=logic_version,
246
+ )
247
+ builder.graph.__post_init__() # reify runtime nodes
248
+ builder.register_logic_name(logic_name, node_id) # register logic name for reverse lookup
249
+ builder.register_labels(labels, node_id) # register labels for reverse lookup
250
+ if alias:
251
+ builder.register_alias(alias, node_id)
252
+
253
+ # stash alias/labels in metadata for downstream (promotion, audit)
254
+ builder.spec.nodes[node_id].metadata.update(
255
+ {
256
+ "alias": alias,
257
+ "labels": labels,
258
+ "display_name": name_hint or logic_name,
259
+ }
260
+ )
261
+
262
+ async def _runner():
263
+ outs = await interp.run_one(node=builder.graph.node(node_id))
264
+ # persist on node for audit/trace
265
+ # n = builder.graph.node(node_id)
266
+ # await builder.graph.set_node_outputs(node_id, outs)
267
+ return SimpleNS(outs, node_id=node_id)
268
+
269
+ return AwaitableResult(_runner)
270
+
271
+ if builder is not None:
272
+ """ building a static graph from within a graph(...) context
273
+ Add a tool node to the current builder graph and return a NodeHandle.
274
+ """
275
+
276
+ if isinstance(fn_or_path, str):
277
+ logic = fn_or_path
278
+ logic_name = logic.rsplit(".", 1)[-1]
279
+ inputs_decl = list(kwargs.keys())
280
+ outputs_decl = ["result"]
281
+ else:
282
+ impl = getattr(fn_or_path, "__aether_impl__", fn_or_path)
283
+ reg_key = getattr(fn_or_path, "__aether_registry_key__", None)
284
+
285
+ # Prefer registry for portability (esp. waitables)
286
+ if reg_key:
287
+ logic = f"registry:{reg_key}" # e.g. "registry:tool:approve_report@0.1.0"
288
+ logic_name = reg_key.split(":")[1].split("@")[0]
289
+ logic_version = reg_key.split("@")[1] if "@" in reg_key else None
290
+ else:
291
+ logic = f"{impl.__module__}.{getattr(impl, '__name__', 'tool')}"
292
+ logic_name = getattr(impl, "__name__", "tool")
293
+ logic_version = getattr(impl, "__version__", None)
294
+
295
+ inputs_decl = getattr(
296
+ fn_or_path, "__aether_inputs__", _infer_inputs_from_signature(impl)
297
+ )
298
+ outputs_decl = getattr(fn_or_path, "__aether_outputs__", ["result"])
299
+
300
+ if node_id_kw:
301
+ node_id = node_id_kw # override if provided
302
+ elif alias:
303
+ node_id = alias # override if provided
304
+ else:
305
+ node_id = builder.next_id(logic_name=logic_name)
306
+
307
+ if node_id in builder.spec.nodes:
308
+ raise ValueError(
309
+ f"Node ID '{node_id}' already exists in graph '{builder.spec.graph_id}'"
310
+ )
311
+
312
+ builder.add_tool_node(
313
+ node_id=node_id,
314
+ logic=logic,
315
+ inputs=kwargs,
316
+ expected_input_keys=inputs_decl,
317
+ expected_output_keys=outputs_decl,
318
+ after=after_ids,
319
+ tool_name=logic_name,
320
+ tool_version=logic_version,
321
+ )
322
+ builder.register_logic_name(logic_name, node_id)
323
+ builder.register_labels(labels, node_id)
324
+ if alias:
325
+ builder.register_alias(alias, node_id)
326
+
327
+ # stash alias/labels in metadata for downstream (promotion, audit)
328
+ builder.spec.nodes[node_id].metadata.update(
329
+ {
330
+ "alias": alias,
331
+ "labels": labels,
332
+ "display_name": name_hint or logic_name,
333
+ }
334
+ )
335
+ return NodeHandle(node_id=node_id, output_keys=outputs_decl)
336
+
337
+ # immediate mode
338
+ fn = resolve_dotted(fn_or_path) if isinstance(fn_or_path, str) else fn_or_path
339
+ return _normalize_result_to_dict(fn(**kwargs))
340
+
341
+
342
+ def call_tool(fn_or_path, **kwargs):
343
+ builder = current_builder()
344
+ interp = current_interpreter()
345
+
346
+ # --- extract control-plane early
347
+ ctrl, kwargs = _split_control_kwargs(kwargs)
348
+ after_raw = ctrl.get("_after", None)
349
+ name_hint = ctrl.get("_name", None)
350
+ alias = ctrl.get("_alias", None)
351
+ node_id_kw = ctrl.get("_id", None) # hard override for node_id
352
+ labels = _ensure_list(ctrl.get("_labels", None))
353
+ # condition = ctrl.get("_condition", None) # TODO
354
+
355
+ after_ids = [_id_of(a) for a in _ensure_list(after_raw)]
356
+
357
+ # ---------- Interpreter (reactive) mode ----------
358
+ if interp is not None:
359
+ if isinstance(fn_or_path, str):
360
+ logic = fn_or_path
361
+ logic_name = logic.rsplit(".", 1)[-1]
362
+ inputs_decl = list(kwargs.keys())
363
+ outputs_decl = ["result"]
364
+ logic_version = None # ✅ ensure defined
365
+ else:
366
+ impl = getattr(fn_or_path, "__aether_impl__", fn_or_path)
367
+ reg_key = getattr(fn_or_path, "__aether_registry_key__", None)
368
+ if reg_key:
369
+ logic = f"registry:{reg_key}"
370
+ logic_name = reg_key.split(":")[1].split("@")[0]
371
+ logic_version = reg_key.split("@")[1] if "@" in reg_key else None
372
+ else:
373
+ logic = f"{impl.__module__}.{getattr(impl, '__name__', 'tool')}"
374
+ logic_name = getattr(impl, "__name__", "tool")
375
+ logic_version = getattr(impl, "__version__", None)
376
+
377
+ inputs_decl = getattr(
378
+ fn_or_path, "__aether_inputs__", _infer_inputs_from_signature(impl)
379
+ )
380
+ outputs_decl = getattr(fn_or_path, "__aether_outputs__", ["result"])
381
+
382
+ if builder is None:
383
+ raise RuntimeError(
384
+ "Interpreter expects a TaskGraph builder context; missing `with graph(...)`"
385
+ )
386
+
387
+ # node_id selection
388
+ if node_id_kw:
389
+ node_id = node_id_kw
390
+ elif alias:
391
+ node_id = alias
392
+ else:
393
+ node_id = builder.next_id(logic_name=logic_name)
394
+
395
+ if node_id in builder.spec.nodes:
396
+ raise ValueError(
397
+ f"Node ID '{node_id}' already exists in graph '{builder.spec.graph_id}'"
398
+ )
399
+
400
+ builder.add_tool_node(
401
+ node_id=node_id,
402
+ logic=logic,
403
+ inputs=kwargs,
404
+ expected_input_keys=inputs_decl,
405
+ expected_output_keys=outputs_decl,
406
+ after=after_ids,
407
+ tool_name=logic_name,
408
+ tool_version=logic_version,
409
+ )
410
+
411
+ # ✅ flush (reify) incrementally instead of calling __post_init__ directly
412
+ if hasattr(builder, "flush"):
413
+ builder.flush()
414
+ else:
415
+ builder.graph.__post_init__() # fallback
416
+
417
+ builder.register_logic_name(logic_name, node_id)
418
+ builder.register_labels(labels, node_id)
419
+ if alias:
420
+ builder.register_alias(alias, node_id)
421
+
422
+ builder.spec.nodes[node_id].metadata.update(
423
+ {
424
+ "alias": alias,
425
+ "labels": labels,
426
+ "display_name": name_hint or logic_name,
427
+ }
428
+ )
429
+
430
+ async def _runner():
431
+ outs = await interp.run_one(node=builder.graph.node(node_id))
432
+ return SimpleNS(outs, node_id=node_id) # ✅ include node_id
433
+
434
+ return AwaitableResult(_runner, node_id=node_id)
435
+
436
+ # ---------- Static build mode (no interpreter, inside graph(...)) ----------
437
+ if builder is not None:
438
+ if isinstance(fn_or_path, str):
439
+ logic = fn_or_path
440
+ logic_name = logic.rsplit(".", 1)[-1]
441
+ inputs_decl = list(kwargs.keys())
442
+ outputs_decl = ["result"]
443
+ logic_version = None # ✅ ensure defined
444
+ else:
445
+ impl = getattr(fn_or_path, "__aether_impl__", fn_or_path)
446
+ reg_key = getattr(fn_or_path, "__aether_registry_key__", None)
447
+ if reg_key:
448
+ logic = f"registry:{reg_key}"
449
+ logic_name = reg_key.split(":")[1].split("@")[0]
450
+ logic_version = reg_key.split("@")[1] if "@" in reg_key else None
451
+ else:
452
+ logic = f"{impl.__module__}.{getattr(impl, '__name__', 'tool')}"
453
+ logic_name = getattr(impl, "__name__", "tool")
454
+ logic_version = getattr(impl, "__version__", None)
455
+
456
+ inputs_decl = getattr(
457
+ fn_or_path, "__aether_inputs__", _infer_inputs_from_signature(impl)
458
+ )
459
+ outputs_decl = getattr(fn_or_path, "__aether_outputs__", ["result"])
460
+
461
+ if node_id_kw:
462
+ node_id = node_id_kw
463
+ elif alias:
464
+ node_id = alias
465
+ else:
466
+ node_id = builder.next_id(logic_name=logic_name)
467
+
468
+ if node_id in builder.spec.nodes:
469
+ raise ValueError(
470
+ f"Node ID '{node_id}' already exists in graph '{builder.spec.graph_id}'"
471
+ )
472
+
473
+ builder.add_tool_node(
474
+ node_id=node_id,
475
+ logic=logic,
476
+ inputs=kwargs,
477
+ expected_input_keys=inputs_decl,
478
+ expected_output_keys=outputs_decl,
479
+ after=after_ids,
480
+ tool_name=logic_name,
481
+ tool_version=logic_version,
482
+ )
483
+ builder.register_logic_name(logic_name, node_id)
484
+ builder.register_labels(labels, node_id)
485
+ if alias:
486
+ builder.register_alias(alias, node_id)
487
+
488
+ builder.spec.nodes[node_id].metadata.update(
489
+ {
490
+ "alias": alias,
491
+ "labels": labels,
492
+ "display_name": name_hint or logic_name,
493
+ }
494
+ )
495
+
496
+ # Return a build-time handle; ensure it carries node_id
497
+ return NodeHandle(
498
+ node_id=node_id, output_keys=outputs_decl
499
+ ) # or SimpleNS({}, node_id=node_id)
500
+
501
+ # ---------- Immediate mode (outside graph & interpreter) ----------
502
+ fn = resolve_dotted(fn_or_path) if isinstance(fn_or_path, str) else fn_or_path
503
+ if inspect.iscoroutinefunction(fn):
504
+
505
+ async def _run_async():
506
+ return _normalize_result_to_dict(await fn(**kwargs))
507
+
508
+ return AwaitableResult(_run_async) # caller can await
509
+ else:
510
+ return _normalize_result_to_dict(fn(**kwargs))
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ import inspect
5
+ from typing import Any
6
+
7
+ from ..execution.wait_types import WaitRequested, WaitSpec
8
+ from ..graph.graph_refs import RESERVED_INJECTABLES
9
+
10
+
11
+ class DualStageTool:
12
+ """
13
+ Subclass and implement:
14
+ - outputs: List[str] # declare once
15
+ - async def setup(self, **kwargs) -> WaitSpec | Dict[str,Any]
16
+ - async def on_resume(self, resume: Dict[str,Any], **kwargs) -> Dict[str,Any]
17
+
18
+ """
19
+
20
+ outputs: list[str] = []
21
+
22
+ async def setup(self, context, **kwargs) -> Any:
23
+ raise NotImplementedError("DualStageTool subclass must implement setup()")
24
+
25
+ async def on_resume(self, resume: dict[str, Any], context: Any) -> dict[str, Any]:
26
+ raise NotImplementedError("DualStageTool subclass must implement on_resume()")
27
+
28
+
29
+ # ----- helpers -----
30
+ def _is_coro_fn(obj: Callable) -> bool:
31
+ """Check if obj is a coroutine function or has an async __call__."""
32
+ return inspect.iscoroutinefunction(obj) or (
33
+ callable(obj) and inspect.iscoroutinefunction(obj.__call__) # async __call__
34
+ )
35
+
36
+
37
+ async def _maybe_await(fn: Callable, *args, **kwargs):
38
+ """Call fn with args; await if it's a coroutine function."""
39
+ if _is_coro_fn(fn):
40
+ return await fn(*args, **kwargs)
41
+ return fn(*args, **kwargs)
42
+
43
+
44
+ def _infer_inputs_from_method(m: Callable) -> list[str]:
45
+ """Infer input parameter names from a method, excluding reserved names and variadic params."""
46
+ sig = inspect.signature(m)
47
+ inputs = []
48
+ for name, p in sig.parameters.items():
49
+ if name in RESERVED_INJECTABLES or name == "self":
50
+ # reserved or self parameter
51
+ continue
52
+ if p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
53
+ # variadic parameters
54
+ continue
55
+ # treat params with defaults as inputs too (they are optional graph inputs)
56
+ inputs.append(name)
57
+ return inputs
58
+
59
+
60
+ def _infer_inputs_for_waitable(cls_or_inst) -> list[str]:
61
+ # Prefer the setup(...) signature for required inputs
62
+ target = cls_or_inst.setup if inspect.isclass(cls_or_inst) else cls_or_inst.setup
63
+ sig = inspect.signature(target)
64
+ keys = []
65
+ for p in sig.parameters.values():
66
+ if p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
67
+ continue
68
+ if p.name in {"node", "context", "logger"}:
69
+ continue
70
+ keys.append(p.name)
71
+ return keys
72
+
73
+
74
+ def waitable_tool(cls_or_inst):
75
+ """Wrap a DualStageTool subclass or instance into a runtime callable that:
76
+ - on first call (no resume) -> raises WaitRequested(WaitSpec)
77
+ - on resume -> returns outputs dict
78
+ """
79
+
80
+ def _make_instance():
81
+ return cls_or_inst() if inspect.isclass(cls_or_inst) else cls_or_inst
82
+
83
+ async def _impl(*, resume=None, context=None, **kwargs):
84
+ tool = _make_instance()
85
+ if hasattr(tool, "bind_node"):
86
+ tool.bind_node(context=context)
87
+
88
+ # FIRST CALL: request wait
89
+ if resume is None:
90
+ spec = await _maybe_await(tool.setup, **kwargs, context=context)
91
+ if isinstance(spec, dict):
92
+ # harden to a single shape; ensure channel is a STRING
93
+ chan = spec.get("channel") or None
94
+ spec["channel"] = chan
95
+ spec = WaitSpec(**spec)
96
+
97
+ raise WaitRequested(spec.to_dict())
98
+
99
+ # RESUME CALL: process resume payload
100
+ out = await _maybe_await(tool.on_resume, resume, context=context)
101
+ return out
102
+
103
+ # annotate for graph-mode build
104
+ _impl.__aether_impl__ = _impl # impl is this coroutine function
105
+ _impl.__aether_inputs__ = _infer_inputs_for_waitable(cls_or_inst) # optional helper
106
+ _impl.__aether_outputs__ = list(getattr(cls_or_inst, "outputs", [])) or ["result"]
107
+ _impl.__name__ = getattr(cls_or_inst, "__name__", "waitable_tool")
108
+ _impl.__module__ = getattr(cls_or_inst, "__module__", _impl.__module__)
109
+ return _impl
File without changes
File without changes