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,82 @@
1
+ from collections.abc import Iterable
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+ from .node_spec import TaskNodeSpec
6
+ from .node_state import NodeStatus, TaskNodeState
7
+
8
+
9
+ @dataclass
10
+ class TaskNodeRuntime:
11
+ spec: TaskNodeSpec
12
+ state: TaskNodeState
13
+ _parent_graph: Any # back-reference to parent graph
14
+
15
+ # ---- Spec pass-through ----
16
+ @property
17
+ def node_id(self) -> str:
18
+ return self.spec.node_id
19
+
20
+ @property
21
+ def type(self) -> str:
22
+ return self.spec.type
23
+
24
+ @property
25
+ def logic(self) -> Any:
26
+ return self.spec.logic
27
+
28
+ @property
29
+ def dependencies(self) -> list[str]:
30
+ return self.spec.dependencies
31
+
32
+ @property
33
+ def inputs(self) -> dict[str, Any]:
34
+ return self.spec.inputs
35
+
36
+ @property
37
+ def expected_input_keys(self) -> list[str]:
38
+ return self.spec.expected_input_keys
39
+
40
+ @property
41
+ def expected_output_keys(self) -> list[str]:
42
+ return self.spec.output_keys
43
+
44
+ @property
45
+ def condition(self) -> Any:
46
+ return self.spec.condition
47
+
48
+ @property
49
+ def metadata(self) -> dict[str, Any]:
50
+ return self.spec.metadata
51
+
52
+ @property
53
+ def tool_name(self) -> str | None:
54
+ return self.spec.tool_name
55
+
56
+ @property
57
+ def tool_version(self) -> str | None:
58
+ return self.spec.tool_version
59
+
60
+ # ---- State pass-through ----
61
+ @property
62
+ def status(self) -> NodeStatus:
63
+ return self.state.status
64
+
65
+ @property
66
+ def outputs(self) -> dict[str, Any]:
67
+ return self.state.outputs
68
+
69
+ @property
70
+ def output(self) -> Any:
71
+ return self.state.output
72
+
73
+ # --- Compat helpers ---
74
+ def allow(self, reads: Iterable[str] | None, writes: Iterable[str] | None) -> "TaskNodeRuntime":
75
+ """Return ad *new* spec via a patch rather than mutating in place."""
76
+ patch = {"node_id": self.node_id}
77
+ if reads:
78
+ patch["reads_add"] = list(reads)
79
+ if writes:
80
+ patch["writes_add"] = list(writes)
81
+ self._parent_graph.add_acl_patch(patch)
82
+ return self
@@ -0,0 +1,37 @@
1
+ import inspect
2
+ from typing import Any
3
+
4
+
5
+ # ---------- helpers for printing and debugging ----------
6
+ def _short(x: Any, maxlen: int = 42) -> str:
7
+ """Shorten a string representation to maxlen, adding ellipsis if needed."""
8
+ s = str(x)
9
+ return s if len(s) <= maxlen else s[: maxlen - 1] + "…"
10
+
11
+
12
+ def _status_label(s: Any) -> str:
13
+ """Return a string label for a status value.
14
+
15
+ E.g., if s is an Enum-like object with a .name attribute, return that.
16
+ """
17
+ # Accept Enum-like (with .name), strings, or None
18
+ if s is None:
19
+ return "-"
20
+ return getattr(s, "name", str(s))
21
+
22
+
23
+ def _logic_label(logic: Any) -> str:
24
+ """Return a string label for a logic value.
25
+
26
+ E.g., if logic is a function, return its module and name.
27
+ """
28
+ # Show a dotted path when possible; fall back to repr/str
29
+ if isinstance(logic, str):
30
+ return logic
31
+ # Unwrap @tool proxies if present
32
+ impl = getattr(logic, "__aether_impl__", logic)
33
+ if inspect.isfunction(impl) or inspect.ismethod(impl):
34
+ mod = getattr(impl, "__module__", None) or ""
35
+ name = getattr(impl, "__name__", None) or "tool"
36
+ return f"{mod}.{name}".strip(".")
37
+ return _short(repr(logic), 80)
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ _STATUS_COLOR = {
6
+ "PENDING": "#d3d3d3",
7
+ "RUNNING": "#ffd966",
8
+ "DONE": "#b6d7a8",
9
+ "FAILED": "#e06666",
10
+ "FAILED_TIMEOUT": "#e69138",
11
+ "SKIPPED": "#cccccc",
12
+ "WAITING_HUMAN": "#9fc5e8",
13
+ "WAITING_ROBOT": "#9fc5e8",
14
+ "WAITING_EXTERNAL": "#9fc5e8",
15
+ "WAITING_TIME": "#9fc5e8",
16
+ "WAITING_EVENT": "#9fc5e8",
17
+ }
18
+
19
+ _NODE_SHAPE = {
20
+ "tool": "box",
21
+ "llm": "component",
22
+ "human": "oval",
23
+ "robot": "hexagon",
24
+ "custom": "box3d",
25
+ }
26
+
27
+
28
+ def _safe_get(d, key, default=None):
29
+ try:
30
+ return d.get(key, default)
31
+ except Exception:
32
+ return default
33
+
34
+
35
+ def _escape(s: str) -> str:
36
+ return str(s).replace('"', r"\"")
37
+
38
+
39
+ def _fmt_multiline(*lines):
40
+ return "\\n".join(str(x) for x in lines if x is not None and str(x) != "")
41
+
42
+
43
+ @dataclass
44
+ class _VizConfig:
45
+ rankdir: str = "LR"
46
+ fontname: str = "Inter,Helvetica,Arial,sans-serif"
47
+ fontsize: int = 11
48
+ show_io_brief: bool = True
49
+ show_logic: bool = True
50
+ dashed_control_deps: bool = True
51
+
52
+
53
+ # === Add these methods onto TaskGraph ========================================
54
+
55
+
56
+ def to_dot(self, cfg: _VizConfig | None = None) -> str:
57
+ """
58
+ Build a Graphviz DOT string of the graph with status-aware coloring and
59
+ dashed control-deps (if present in node.metadata['control_deps']).
60
+ """
61
+ cfg = cfg or _VizConfig()
62
+ lines = []
63
+ L = lines.append
64
+
65
+ L("digraph TaskGraph {")
66
+ L(f" rankdir={cfg.rankdir};")
67
+ L(f' graph [fontname="{cfg.fontname}"];')
68
+ L(
69
+ f' node [fontname="{cfg.fontname}", fontsize={cfg.fontsize}, style="rounded,filled", shape=box];'
70
+ )
71
+ L(f' edge [fontname="{cfg.fontname}", fontsize={cfg.fontsize}];')
72
+
73
+ # (optional) graph IO summary as a note
74
+ try:
75
+ io_lines = self.spec.io_summary_lines() if cfg.show_io_brief else []
76
+ except Exception:
77
+ io_lines = []
78
+ if io_lines:
79
+ label = _escape(_fmt_multiline("IO", *io_lines))
80
+ L(' subgraph cluster_io { style=dashed; color="#cccccc"; label="";')
81
+ L(
82
+ f' io_summary [shape=note, style="rounded,filled", fillcolor="#f7f7f7", label="{label}"];'
83
+ )
84
+ L(" }")
85
+
86
+ # nodes
87
+ for node_id, node in self.spec.nodes.items():
88
+ # Node attributes
89
+ status = _safe_get(self.state.node_status, node_id, "PENDING") or "PENDING"
90
+ fill = _STATUS_COLOR.get(status, "#d3d3d3")
91
+ ntype = getattr(node, "node_type", None)
92
+ shape = _NODE_SHAPE.get(str(ntype).lower() if ntype else "", "box")
93
+
94
+ # Label: id + type/status (+ logic)
95
+ logic = getattr(node, "logic", None)
96
+ logic_s = None
97
+ if cfg.show_logic and logic is not None:
98
+ logic_s = (
99
+ logic
100
+ if isinstance(logic, str)
101
+ else getattr(logic, "__name__", type(logic).__name__)
102
+ )
103
+ # shorten registry path a bit
104
+ if isinstance(logic_s, str) and logic_s.startswith("registry:"):
105
+ logic_s = logic_s.replace("registry:", "")
106
+
107
+ label = _escape(
108
+ _fmt_multiline(
109
+ f"{node_id}",
110
+ f"[{str(ntype).lower()}]" if ntype else None,
111
+ f"status: {status}",
112
+ logic_s,
113
+ )
114
+ )
115
+
116
+ L(f' "{_escape(node_id)}" [shape={shape}, fillcolor="{fill}", label="{label}"];')
117
+
118
+ # edges (data/control)
119
+ for node_id, node in self.spec.nodes.items():
120
+ deps = getattr(node, "dependencies", []) or []
121
+ ctrl = set(_safe_get(getattr(node, "metadata", {}) or {}, "control_deps", []) or [])
122
+
123
+ for dep in deps:
124
+ style = "dashed" if (cfg.dashed_control_deps and dep in ctrl) else "solid"
125
+ color = "#999999" if style == "dashed" else "#555555"
126
+ L(f' "{_escape(dep)}" -> "{_escape(node_id)}" [style={style}, color="{color}"];')
127
+
128
+ # (optional) Legend
129
+ L(" subgraph cluster_legend {")
130
+ L(' label="Legend"; style=dashed; color="#cccccc";')
131
+ L(" key_status [shape=plaintext, label=<")
132
+ L(' <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="6">')
133
+ L(' <TR><TD COLSPAN="2"><B>Status Colors</B></TD></TR>')
134
+ for k, v in _STATUS_COLOR.items():
135
+ L(f' <TR><TD>{k}</TD><TD BGCOLOR="{v}"> </TD></TR>')
136
+ L(" </TABLE>")
137
+ L(" >];")
138
+ L(" key_edge [shape=plaintext, label=<")
139
+ L(' <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="6">')
140
+ L(' <TR><TD COLSPAN="2"><B>Edge Types</B></TD></TR>')
141
+ L(' <TR><TD>data dependency</TD><TD><FONT COLOR="#555555">solid</FONT></TD></TR>')
142
+ L(' <TR><TD>control dependency</TD><TD><FONT COLOR="#999999">dashed</FONT></TD></TR>')
143
+ L(" </TABLE>")
144
+ L(" >];")
145
+ L(" }")
146
+
147
+ L("}")
148
+ return "\n".join(lines)
149
+
150
+
151
+ def visualize(
152
+ self,
153
+ outfile: str | None = None,
154
+ fmt: str = "svg",
155
+ view: bool = False,
156
+ cfg: _VizConfig | None = None,
157
+ return_dot: bool = False,
158
+ ) -> str:
159
+ """
160
+ Render or export the graph.
161
+
162
+ Args:
163
+ outfile: Path prefix without extension (e.g., 'out/graph').
164
+ fmt: 'svg' | 'png' | 'pdf' ...
165
+ view: If True, open the rendered file (if possible).
166
+ cfg: Visualization config (_VizConfig).
167
+ return_dot:
168
+ - If True, always return the raw DOT string so the user can paste
169
+ it into webgraphviz or other tools.
170
+ - If outfile is also provided, a .dot file will be exported to
171
+ `outfile + ".dot"` in addition to any rendering.
172
+
173
+ Returns:
174
+ - DOT string if return_dot=True
175
+ - Rendered file path if rendering was successful
176
+ - DOT string fallback if rendering fails
177
+ """
178
+ dot = self.to_dot(cfg)
179
+
180
+ dot_path = None
181
+ if outfile and return_dot:
182
+ dot_path = f"{outfile}.dot"
183
+ with open(dot_path, "w", encoding="utf-8") as f:
184
+ f.write(dot)
185
+ import logging
186
+
187
+ log = logging.getLogger("aethergraph.core.graph.visualize")
188
+ log.info(
189
+ f"DOT file written to: {dot_path}. View the graph at https://dreampuf.github.io/GraphvizOnline/"
190
+ )
191
+
192
+ if return_dot and (outfile is None):
193
+ # Only return DOT (no rendering)
194
+ return dot
195
+
196
+ try:
197
+ import graphviz
198
+
199
+ src = graphviz.Source(dot, format=fmt)
200
+ if outfile:
201
+ path = src.render(
202
+ outfile, view=view, cleanup=True
203
+ ) # This will also create outfile but without .dot extension?
204
+ # If return_dot, return DOT text (but still render the file)
205
+ return dot if return_dot else path
206
+ elif view:
207
+ # View only, no outfile
208
+ import tempfile
209
+
210
+ tmp = tempfile.mkstemp(prefix="taskgraph_", suffix=f".{fmt}")[1]
211
+ src.render(tmp[: -len(f".{fmt}")], view=True, cleanup=True)
212
+ return dot if return_dot else tmp
213
+ except Exception:
214
+ import logging
215
+
216
+ log = logging.getLogger("aethergraph.core.graph.visualize")
217
+ log.warning("Graph rendering failed; returning DOT string instead.")
218
+ pass
219
+
220
+ return dot
221
+
222
+
223
+ def ascii_overview(self) -> str:
224
+ """
225
+ Minimal text view: one line per node with deps and status.
226
+ """
227
+ lines = []
228
+ for node_id, node in self.spec.nodes.items():
229
+ status = _safe_get(self.state.node_status, node_id, "PENDING") or "PENDING"
230
+ deps = getattr(node, "dependencies", []) or []
231
+ ntype = getattr(node, "node_type", "tool")
232
+ logic = getattr(node, "logic", None)
233
+ logic_s = (
234
+ (logic if isinstance(logic, str) else getattr(logic, "__name__", type(logic).__name__))
235
+ if logic
236
+ else ""
237
+ )
238
+ lines.append(f"- {node_id} [{ntype} | {status}] deps={deps} logic={logic_s}")
239
+ return "\n".join(lines)
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import asynccontextmanager
4
+ from dataclasses import dataclass
5
+ import uuid
6
+
7
+ from aethergraph.core.runtime.execution_context import ExecutionContext
8
+ from aethergraph.core.runtime.graph_runner import _build_env
9
+
10
+
11
+ # Ad-hoc node for temporary tasks
12
+ @dataclass
13
+ class _AdhocNode:
14
+ node_id: str = "adhoc"
15
+ tool_name: str | None = None
16
+ tool_version: str | None = None
17
+
18
+
19
+ async def build_adhoc_context(
20
+ *,
21
+ run_id: str | None = None,
22
+ graph_id: str = "adhoc",
23
+ node_id: str = "adhoc",
24
+ **rt_overrides,
25
+ ) -> ExecutionContext:
26
+ # Owner can be anything with max_concurrency; we won't really schedule
27
+ class _Owner:
28
+ max_concurrency = rt_overrides.get("max_concurrency", 1)
29
+
30
+ env, retry, max_conc = await _build_env(_Owner(), inputs={}, **rt_overrides)
31
+
32
+ env.run_id = run_id or f"adhoc-{uuid.uuid4().hex[:8]}"
33
+ env.graph_id = graph_id
34
+
35
+ node = _AdhocNode(node_id=node_id)
36
+ exe_ctx = env.make_ctx(node=node, resume_payload=None)
37
+ node_ctx = exe_ctx.create_node_context(node)
38
+
39
+ return node_ctx
40
+
41
+
42
+ @asynccontextmanager
43
+ async def open_session(
44
+ *,
45
+ run_id: str | None = None,
46
+ graph_id: str = "adhoc",
47
+ node_id: str = "adhoc",
48
+ **rt_overrides,
49
+ ):
50
+ """
51
+ Open an 'adhoc' context that behaves like a NodeContext, without a real graph run.
52
+ Advanced / scripting use only.
53
+ """
54
+ ctx = await build_adhoc_context(
55
+ run_id=run_id, graph_id=graph_id, node_id=node_id, **rt_overrides
56
+ )
57
+ try:
58
+ yield ctx
59
+ finally:
60
+ # optional: flush / close memory, artifacts, etc.
61
+ pass
@@ -0,0 +1,153 @@
1
+ """Base services are used for external context services and other common patterns.
2
+ Here we register service in the main runtime, and provide base classes for
3
+ TODO: confirm that external services runs with the main event loop locally, not the sidecar loop, such that asyncio.locks work as expected.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from collections.abc import Awaitable, Callable
10
+ from contextvars import ContextVar
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ if TYPE_CHECKING:
14
+ from .node_context import NodeContext
15
+
16
+ __all__ = [
17
+ "AsyncRWLock",
18
+ "BaseContextService",
19
+ "Service",
20
+ "maybe_await",
21
+ ]
22
+
23
+
24
+ class _ServiceHandle:
25
+ """
26
+ A callable, transparent handle around a bound service.
27
+ - Attribute access delegates to the underlying service.
28
+ - Calling with no args returns the service (ergonomic parity with built-ins).
29
+ - Calling with args forwards to service.__call__ if present.
30
+ """
31
+
32
+ __slots__ = ("_name", "_svc")
33
+
34
+ def __init__(self, name: str, bound_service: object):
35
+ self._svc = bound_service
36
+ self._name = name
37
+
38
+ def __getattr__(self, attr):
39
+ return getattr(self._svc, attr)
40
+
41
+ def __call__(self, *args, **kwargs):
42
+ # No-arg call => return the service instance (consistent, non-surprising)
43
+ if not args and not kwargs:
44
+ return self._svc
45
+
46
+ # If the underlying service is callable, forward the call
47
+ if callable(self._svc):
48
+ return self._svc(*args, **kwargs)
49
+
50
+ raise TypeError(
51
+ f"Service '{self._name}' is not directly callable; "
52
+ "call with no arguments to get the service instance, "
53
+ "then invoke its methods."
54
+ )
55
+
56
+ def __repr__(self):
57
+ return f"<ServiceHandle {self._name}: {self._svc!r}>"
58
+
59
+
60
+ async def maybe_await(x: Any) -> Any:
61
+ """If x is awaitable, await it; else return it directly."""
62
+ if asyncio.iscoroutine(x) or isinstance(x, Awaitable):
63
+ return await x
64
+ return x
65
+
66
+
67
+ class AsyncRWLock:
68
+ """Simple async RW lock: many readers or one writer."""
69
+
70
+ def __init__(self):
71
+ self._readers = 0
72
+ self._r_lock = asyncio.Lock()
73
+ self._w_lock = asyncio.Lock()
74
+
75
+ async def read(self):
76
+ lock = self
77
+
78
+ class _Guard:
79
+ async def __aenter__(self):
80
+ async with lock._r_lock:
81
+ lock._readers += 1
82
+ if lock._readers == 1:
83
+ await lock._w_lock.acquire()
84
+
85
+ async def __aexit__(self, exc_type, exc, tb):
86
+ async with lock._r_lock:
87
+ lock._readers -= 1
88
+ if lock._readers == 0:
89
+ lock._w_lock.release()
90
+
91
+ return _Guard()
92
+
93
+ async def write(self):
94
+ return self._w_lock
95
+
96
+
97
+ class BaseContextService:
98
+ """
99
+ Batteries-included base for context services.
100
+ - Lifecycle: start/close (async)
101
+ - Binding: bind(context) returns a context-aware handle (default: self with ContextVar)
102
+ - Concurrency: critical() async mutex, AsyncRWLock for R/W scenarios
103
+ - Utilities: run_blocking() for CPU/IO-bound sync functions
104
+ """
105
+
106
+ _current_ctx: ContextVar = ContextVar("_aeg_ctx", default=None)
107
+
108
+ def __init__(self) -> None:
109
+ self._lock = asyncio.Lock()
110
+ self._closing = False
111
+
112
+ # ---------- lifecycle ----------
113
+ async def start(self) -> None:
114
+ """Async startup hook."""
115
+ return None
116
+
117
+ async def close(self) -> None:
118
+ """Async shutdown hook."""
119
+ self._closing = True
120
+ return None
121
+
122
+ # ---------- binding ----------
123
+ def bind(self, *, context: NodeContext) -> BaseContextService:
124
+ """Return a context-bound handle to this service."""
125
+ self._current_ctx.set(context)
126
+ return self
127
+
128
+ def ctx(self) -> NodeContext:
129
+ ctx = self._current_ctx.get()
130
+ if ctx is None:
131
+ raise RuntimeError("No context bound to this service. Call bind(context) first.")
132
+ return ctx
133
+
134
+ # ---------- concurrency ----------
135
+ def critical(self):
136
+ """Decorator for async critical section (mutex)."""
137
+
138
+ def deco(fn: Callable[..., Any]) -> Any:
139
+ async def wrapped(*a, **kw):
140
+ async with self._lock:
141
+ return await maybe_await(fn(*a, **kw))
142
+
143
+ return wrapped
144
+
145
+ return deco
146
+
147
+ async def run_blocking(self, fn: Callable[..., Any], *args, **kwargs) -> Any:
148
+ """Run a blocking function in a thread pool."""
149
+ return await asyncio.to_thread(fn, *args, **kwargs)
150
+
151
+
152
+ # Alias for ergonomics
153
+ Service = BaseContextService
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any
5
+
6
+ __all__ = ["BindAdapter"]
7
+
8
+
9
+ class BindAdapter:
10
+ """
11
+ Wrap any object and make it context-bindable.
12
+ Convention: if a wrapped method wants NodeContext, accept kwarg `_ctx`.
13
+ """
14
+
15
+ def __init__(self, obj: Any):
16
+ self._obj = obj
17
+
18
+ def bind(self, *, context):
19
+ obj = self._obj
20
+ ctx = context
21
+
22
+ class Bound:
23
+ def __getattr__(self, name: str):
24
+ attr = getattr(obj, name)
25
+ if callable(attr):
26
+ if asyncio.iscoroutinefunction(attr):
27
+
28
+ async def aw(*a, **k):
29
+ k.setdefault("_ctx", ctx)
30
+ return await attr(*a, **k)
31
+
32
+ return aw
33
+ else:
34
+
35
+ def sw(*a, **k):
36
+ k.setdefault("_ctx", ctx)
37
+ return attr(*a, **k)
38
+
39
+ return sw
40
+ return attr
41
+
42
+ return Bound()
@@ -0,0 +1,69 @@
1
+ from typing import Any
2
+
3
+ from aethergraph.services.memory.facade import MemoryFacade
4
+ from aethergraph.services.memory.io_helpers import Value
5
+
6
+ # TODO: Deprecate this adapter in favor of direct MemoryFacade usage in runtime contexts.
7
+
8
+
9
+ class BoundMemoryAdapter:
10
+ """Minimal adapter to preserve ctx.mem().* API while delegating to MemoryFacade."""
11
+
12
+ def __init__(self, mem: MemoryFacade, defaults: dict[str, Any]):
13
+ self._mem = mem
14
+ self._defaults = defaults
15
+
16
+ async def record(
17
+ self,
18
+ *,
19
+ kind: str,
20
+ text: str | None = None,
21
+ severity: int = 2,
22
+ stage: str | None = None,
23
+ tags: list[str] | None = None,
24
+ entities: list[str] | None = None,
25
+ metrics: dict[str, Any] | None = None,
26
+ inputs_ref: dict[str, Any] | None = None,
27
+ outputs_ref: dict[str, Any] | None = None,
28
+ sources: list[str] | None = None,
29
+ signal: float | None = None,
30
+ ):
31
+ base = dict(
32
+ **self._defaults,
33
+ kind=kind,
34
+ stage=stage,
35
+ severity=severity,
36
+ tags=tags or [],
37
+ entities=entities or [],
38
+ inputs_ref=inputs_ref,
39
+ outputs_ref=outputs_ref,
40
+ signal=signal,
41
+ )
42
+ return await self._mem.record_raw(base=base, text=text, metrics=metrics, sources=sources)
43
+
44
+ async def user(self, text: str):
45
+ return await self.record(kind="user_msg", text=text, stage="observe")
46
+
47
+ async def assistant(self, text: str):
48
+ return await self.record(kind="assistant_msg", text=text, stage="act")
49
+
50
+ async def write_result(
51
+ self,
52
+ *,
53
+ topic: str,
54
+ inputs: list[Value] | None = None,
55
+ outputs: list[Value] | None = None,
56
+ tags: list[str] | None = None,
57
+ metrics: dict[str, float] | None = None,
58
+ message: str | None = None,
59
+ severity: int = 3,
60
+ ):
61
+ return await self._mem.write_result(
62
+ topic=topic,
63
+ inputs=inputs or [],
64
+ outputs=outputs or [],
65
+ tags=tags,
66
+ metrics=metrics,
67
+ message=message,
68
+ severity=severity,
69
+ )