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