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