aethergraph 0.1.0a2__py3-none-any.whl → 0.1.0a4__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/__main__.py +3 -0
- aethergraph/api/v1/artifacts.py +23 -4
- aethergraph/api/v1/schemas.py +7 -0
- aethergraph/api/v1/session.py +123 -4
- aethergraph/config/config.py +2 -0
- aethergraph/config/search.py +49 -0
- aethergraph/contracts/services/channel.py +18 -1
- aethergraph/contracts/services/execution.py +58 -0
- aethergraph/contracts/services/llm.py +26 -0
- aethergraph/contracts/services/memory.py +10 -4
- aethergraph/contracts/services/planning.py +53 -0
- aethergraph/contracts/storage/event_log.py +8 -0
- aethergraph/contracts/storage/search_backend.py +47 -0
- aethergraph/contracts/storage/vector_index.py +73 -0
- aethergraph/core/graph/action_spec.py +76 -0
- aethergraph/core/graph/graph_fn.py +75 -2
- aethergraph/core/graph/graphify.py +74 -2
- aethergraph/core/runtime/graph_runner.py +2 -1
- aethergraph/core/runtime/node_context.py +66 -3
- aethergraph/core/runtime/node_services.py +8 -0
- aethergraph/core/runtime/run_manager.py +263 -271
- aethergraph/core/runtime/run_types.py +54 -1
- aethergraph/core/runtime/runtime_env.py +35 -14
- aethergraph/core/runtime/runtime_services.py +308 -18
- aethergraph/plugins/agents/default_chat_agent.py +266 -74
- aethergraph/plugins/agents/default_chat_agent_v2.py +487 -0
- aethergraph/plugins/channel/adapters/webui.py +69 -21
- aethergraph/plugins/channel/routes/webui_routes.py +8 -48
- aethergraph/runtime/__init__.py +12 -0
- aethergraph/server/app_factory.py +10 -1
- aethergraph/server/ui_static/assets/index-CFktGdbW.js +4913 -0
- aethergraph/server/ui_static/assets/index-DcfkFlTA.css +1 -0
- aethergraph/server/ui_static/index.html +2 -2
- aethergraph/services/artifacts/facade.py +157 -21
- aethergraph/services/artifacts/types.py +35 -0
- aethergraph/services/artifacts/utils.py +42 -0
- aethergraph/services/channel/channel_bus.py +3 -1
- aethergraph/services/channel/event_hub copy.py +55 -0
- aethergraph/services/channel/event_hub.py +81 -0
- aethergraph/services/channel/factory.py +3 -2
- aethergraph/services/channel/session.py +709 -74
- aethergraph/services/container/default_container.py +69 -7
- aethergraph/services/execution/__init__.py +0 -0
- aethergraph/services/execution/local_python.py +118 -0
- aethergraph/services/indices/__init__.py +0 -0
- aethergraph/services/indices/global_indices.py +21 -0
- aethergraph/services/indices/scoped_indices.py +292 -0
- aethergraph/services/llm/generic_client.py +342 -46
- aethergraph/services/llm/generic_embed_client.py +359 -0
- aethergraph/services/llm/types.py +3 -1
- aethergraph/services/memory/distillers/llm_long_term.py +60 -109
- aethergraph/services/memory/distillers/llm_long_term_v1.py +180 -0
- aethergraph/services/memory/distillers/llm_meta_summary.py +57 -266
- aethergraph/services/memory/distillers/llm_meta_summary_v1.py +342 -0
- aethergraph/services/memory/distillers/long_term.py +48 -131
- aethergraph/services/memory/distillers/long_term_v1.py +170 -0
- aethergraph/services/memory/facade/chat.py +18 -8
- aethergraph/services/memory/facade/core.py +159 -19
- aethergraph/services/memory/facade/distillation.py +86 -31
- aethergraph/services/memory/facade/retrieval.py +100 -1
- aethergraph/services/memory/factory.py +4 -1
- aethergraph/services/planning/__init__.py +0 -0
- aethergraph/services/planning/action_catalog.py +271 -0
- aethergraph/services/planning/bindings.py +56 -0
- aethergraph/services/planning/dependency_index.py +65 -0
- aethergraph/services/planning/flow_validator.py +263 -0
- aethergraph/services/planning/graph_io_adapter.py +150 -0
- aethergraph/services/planning/input_parser.py +312 -0
- aethergraph/services/planning/missing_inputs.py +28 -0
- aethergraph/services/planning/node_planner.py +613 -0
- aethergraph/services/planning/orchestrator.py +112 -0
- aethergraph/services/planning/plan_executor.py +506 -0
- aethergraph/services/planning/plan_types.py +321 -0
- aethergraph/services/planning/planner.py +617 -0
- aethergraph/services/planning/planner_service.py +369 -0
- aethergraph/services/planning/planning_context_builder.py +43 -0
- aethergraph/services/planning/quick_actions.py +29 -0
- aethergraph/services/planning/routers/__init__.py +0 -0
- aethergraph/services/planning/routers/simple_router.py +26 -0
- aethergraph/services/rag/facade.py +0 -3
- aethergraph/services/scope/scope.py +30 -30
- aethergraph/services/scope/scope_factory.py +15 -7
- aethergraph/services/skills/__init__.py +0 -0
- aethergraph/services/skills/skill_registry.py +465 -0
- aethergraph/services/skills/skills.py +220 -0
- aethergraph/services/skills/utils.py +194 -0
- aethergraph/storage/artifacts/artifact_index_jsonl.py +16 -10
- aethergraph/storage/artifacts/artifact_index_sqlite.py +12 -2
- aethergraph/storage/docstore/sqlite_doc_sync.py +1 -1
- aethergraph/storage/memory/event_persist.py +42 -2
- aethergraph/storage/memory/fs_persist.py +32 -2
- aethergraph/storage/search_backend/__init__.py +0 -0
- aethergraph/storage/search_backend/generic_vector_backend.py +230 -0
- aethergraph/storage/search_backend/null_backend.py +34 -0
- aethergraph/storage/search_backend/sqlite_lexical_backend.py +387 -0
- aethergraph/storage/search_backend/utils.py +31 -0
- aethergraph/storage/search_factory.py +75 -0
- aethergraph/storage/vector_index/faiss_index.py +72 -4
- aethergraph/storage/vector_index/sqlite_index.py +521 -52
- aethergraph/storage/vector_index/sqlite_index_vanila.py +311 -0
- aethergraph/storage/vector_index/utils.py +22 -0
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/METADATA +1 -1
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/RECORD +108 -64
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/WHEEL +1 -1
- aethergraph/plugins/agents/default_chat_agent copy.py +0 -90
- aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +0 -1
- aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +0 -400
- aethergraph/services/eventhub/event_hub.py +0 -76
- aethergraph/services/llm/generic_client copy.py +0 -691
- aethergraph/services/prompts/file_store.py +0 -41
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/entry_points.txt +0 -0
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/LICENSE +0 -0
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/NOTICE +0 -0
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
import asyncio
|
|
5
5
|
from collections.abc import AsyncIterator
|
|
6
6
|
from contextlib import asynccontextmanager
|
|
7
|
-
from datetime import datetime
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
8
|
import json
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Any, Literal
|
|
@@ -12,12 +12,15 @@ from urllib.parse import urlparse
|
|
|
12
12
|
|
|
13
13
|
from aethergraph.contracts.services.artifacts import Artifact, AsyncArtifactStore
|
|
14
14
|
from aethergraph.contracts.storage.artifact_index import AsyncArtifactIndex
|
|
15
|
+
from aethergraph.contracts.storage.search_backend import ScoredItem
|
|
15
16
|
from aethergraph.core.runtime.runtime_metering import current_metering
|
|
16
17
|
from aethergraph.core.runtime.runtime_services import current_services
|
|
17
18
|
from aethergraph.services.artifacts.paths import _from_uri_or_path
|
|
19
|
+
from aethergraph.services.artifacts.types import ArtifactContent, ArtifactSearchResult, ArtifactView
|
|
20
|
+
from aethergraph.services.artifacts.utils import _infer_content_mode
|
|
21
|
+
from aethergraph.services.indices.scoped_indices import ScopedIndices
|
|
18
22
|
from aethergraph.services.scope.scope import Scope
|
|
19
|
-
|
|
20
|
-
ArtifactView = Literal["node", "graph", "run", "all"]
|
|
23
|
+
from aethergraph.storage.vector_index.utils import build_index_meta_from_scope
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
class ArtifactFacade:
|
|
@@ -37,8 +40,9 @@ class ArtifactFacade:
|
|
|
37
40
|
node_id: str,
|
|
38
41
|
tool_name: str,
|
|
39
42
|
tool_version: str,
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
art_store: AsyncArtifactStore,
|
|
44
|
+
art_index: AsyncArtifactIndex,
|
|
45
|
+
scoped_indices: ScopedIndices | None = None,
|
|
42
46
|
scope: Scope | None = None,
|
|
43
47
|
) -> None:
|
|
44
48
|
self.run_id = run_id
|
|
@@ -46,8 +50,9 @@ class ArtifactFacade:
|
|
|
46
50
|
self.node_id = node_id
|
|
47
51
|
self.tool_name = tool_name
|
|
48
52
|
self.tool_version = tool_version
|
|
49
|
-
self.store =
|
|
50
|
-
self.index =
|
|
53
|
+
self.store = art_store
|
|
54
|
+
self.index = art_index
|
|
55
|
+
self.scoped_indices = scoped_indices
|
|
51
56
|
|
|
52
57
|
# set scope -- this should be done outside in NodeContext and passed in, but here is a fallback
|
|
53
58
|
self.scope = scope
|
|
@@ -124,11 +129,94 @@ class ArtifactFacade:
|
|
|
124
129
|
a.session_id = a.session_id or dims.get("session_id")
|
|
125
130
|
# run_id / graph_id / node_id are already set
|
|
126
131
|
|
|
132
|
+
# 1.1) Normalize timestamp: ensure a.created_at is a UTC datetime
|
|
133
|
+
created_dt: datetime
|
|
134
|
+
if isinstance(a.created_at, datetime):
|
|
135
|
+
if a.created_at.tzinfo is None:
|
|
136
|
+
created_dt = a.created_at.replace(tzinfo=timezone.utc)
|
|
137
|
+
else:
|
|
138
|
+
created_dt = a.created_at.astimezone(timezone.utc)
|
|
139
|
+
elif isinstance(a.created_at, str):
|
|
140
|
+
# Best-effort parse; if it fails, fall back to "now"
|
|
141
|
+
try:
|
|
142
|
+
# Accept either Z-suffixed or offset ISO
|
|
143
|
+
if a.created_at.endswith("Z"):
|
|
144
|
+
created_dt = datetime.fromisoformat(a.created_at.replace("Z", "+00:00"))
|
|
145
|
+
else:
|
|
146
|
+
created_dt = datetime.fromisoformat(a.created_at)
|
|
147
|
+
if created_dt.tzinfo is None:
|
|
148
|
+
created_dt = created_dt.replace(tzinfo=timezone.utc)
|
|
149
|
+
else:
|
|
150
|
+
created_dt = created_dt.astimezone(timezone.utc)
|
|
151
|
+
except Exception:
|
|
152
|
+
created_dt = datetime.now(timezone.utc)
|
|
153
|
+
else:
|
|
154
|
+
# No timestamp provided → use now
|
|
155
|
+
created_dt = datetime.now(timezone.utc)
|
|
156
|
+
|
|
157
|
+
# Persist normalized timestamp back onto the artifact
|
|
158
|
+
a.created_at = created_dt
|
|
159
|
+
# Canonical forms:
|
|
160
|
+
ts_iso = created_dt.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
161
|
+
created_at_ts = created_dt.timestamp()
|
|
162
|
+
|
|
127
163
|
# 2) Record in index + occurrence log
|
|
128
164
|
await self.index.upsert(a)
|
|
129
165
|
await self.index.record_occurrence(a)
|
|
130
166
|
self.last_artifact = a
|
|
131
167
|
|
|
168
|
+
# 2.1) Wire artifact text into ScopedIndices for searchability
|
|
169
|
+
if self.scoped_indices is not None and self.scoped_indices.backend is not None:
|
|
170
|
+
try:
|
|
171
|
+
# Build a simple text blob from labels, metrics, and kind
|
|
172
|
+
# Build text blob
|
|
173
|
+
parts: list[str] = []
|
|
174
|
+
if a.kind:
|
|
175
|
+
parts.append(str(a.kind))
|
|
176
|
+
if a.labels:
|
|
177
|
+
parts.append("; ".join(f"{k}: {v}" for k, v in a.labels.items()))
|
|
178
|
+
if a.metrics:
|
|
179
|
+
parts.append("; ".join(f"{k}: {v}" for k, v in a.metrics.items()))
|
|
180
|
+
text = " ".join(parts).strip()
|
|
181
|
+
|
|
182
|
+
extra_meta: dict[str, Any] = {
|
|
183
|
+
"run_id": a.run_id,
|
|
184
|
+
"graph_id": a.graph_id,
|
|
185
|
+
"node_id": a.node_id,
|
|
186
|
+
"tool_name": a.tool_name,
|
|
187
|
+
"tool_version": a.tool_version,
|
|
188
|
+
"pinned": a.pinned,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Add artifact kind as explicit metadata field
|
|
192
|
+
if a.kind:
|
|
193
|
+
extra_meta["artifact_kind"] = a.kind
|
|
194
|
+
|
|
195
|
+
# Flatten labels last so they can override if needed
|
|
196
|
+
if a.labels:
|
|
197
|
+
extra_meta.update(a.labels)
|
|
198
|
+
|
|
199
|
+
meta = build_index_meta_from_scope(
|
|
200
|
+
kind="artifact",
|
|
201
|
+
source="artifact_index",
|
|
202
|
+
ts=ts_iso, # human-readable ISO
|
|
203
|
+
created_at_ts=created_at_ts, # numeric timestamp
|
|
204
|
+
extra=extra_meta,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
await self.scoped_indices.upsert(
|
|
208
|
+
corpus="artifact",
|
|
209
|
+
item_id=a.artifact_id,
|
|
210
|
+
text=text,
|
|
211
|
+
metadata=meta,
|
|
212
|
+
)
|
|
213
|
+
except Exception:
|
|
214
|
+
import logging
|
|
215
|
+
|
|
216
|
+
logging.getLogger("aethergraph.indices").exception(
|
|
217
|
+
"scoped_indices_artifact_upsert_failed"
|
|
218
|
+
)
|
|
219
|
+
|
|
132
220
|
# 3) Metering hook for artifact writes
|
|
133
221
|
try:
|
|
134
222
|
meter = current_metering()
|
|
@@ -158,18 +246,6 @@ class ArtifactFacade:
|
|
|
158
246
|
except Exception:
|
|
159
247
|
return # outside runtime context, nothing to do
|
|
160
248
|
|
|
161
|
-
# Normalize timestamp
|
|
162
|
-
ts: datetime | None
|
|
163
|
-
if isinstance(a.created_at, datetime):
|
|
164
|
-
ts = a.created_at
|
|
165
|
-
elif isinstance(a.created_at, str):
|
|
166
|
-
try:
|
|
167
|
-
ts = datetime.fromisoformat(a.created_at)
|
|
168
|
-
except Exception:
|
|
169
|
-
ts = None
|
|
170
|
-
else:
|
|
171
|
-
ts = None
|
|
172
|
-
|
|
173
249
|
# Update run metadata
|
|
174
250
|
run_store = getattr(services, "run_store", None)
|
|
175
251
|
if run_store is not None and a.run_id:
|
|
@@ -178,7 +254,7 @@ class ArtifactFacade:
|
|
|
178
254
|
await record_artifact(
|
|
179
255
|
a.run_id,
|
|
180
256
|
artifact_id=a.artifact_id,
|
|
181
|
-
created_at=
|
|
257
|
+
created_at=created_dt,
|
|
182
258
|
)
|
|
183
259
|
|
|
184
260
|
# Update session metadata
|
|
@@ -189,7 +265,7 @@ class ArtifactFacade:
|
|
|
189
265
|
if callable(sess_record_artifact):
|
|
190
266
|
await sess_record_artifact(
|
|
191
267
|
session_id,
|
|
192
|
-
created_at=
|
|
268
|
+
created_at=created_dt,
|
|
193
269
|
)
|
|
194
270
|
|
|
195
271
|
# ---------- core staging/ingest ----------
|
|
@@ -823,6 +899,53 @@ class ArtifactFacade:
|
|
|
823
899
|
text = await self.load_text_by_id(artifact_id, encoding=encoding, errors=errors)
|
|
824
900
|
return json.loads(text)
|
|
825
901
|
|
|
902
|
+
async def load_content(
|
|
903
|
+
self,
|
|
904
|
+
artifact_id: str,
|
|
905
|
+
*,
|
|
906
|
+
encoding: str = "utf-8",
|
|
907
|
+
errors: str = "strict",
|
|
908
|
+
max_bytes: int | None = None,
|
|
909
|
+
) -> ArtifactContent:
|
|
910
|
+
"""
|
|
911
|
+
Docstring for load_content
|
|
912
|
+
|
|
913
|
+
:param self: Description
|
|
914
|
+
:param artifact_id: Description
|
|
915
|
+
:type artifact_id: str
|
|
916
|
+
:param encoding: Description
|
|
917
|
+
:type encoding: str
|
|
918
|
+
:param errors: Description
|
|
919
|
+
:type errors: str
|
|
920
|
+
:param max_bytes: Description
|
|
921
|
+
:type max_bytes: int | None
|
|
922
|
+
:return: Description
|
|
923
|
+
:rtype: ArtifactContent
|
|
924
|
+
"""
|
|
925
|
+
art = await self.get_by_id(artifact_id=artifact_id)
|
|
926
|
+
if art is None:
|
|
927
|
+
raise FileNotFoundError(f"Artifact {artifact_id} not found")
|
|
928
|
+
|
|
929
|
+
mode = _infer_content_mode(art)
|
|
930
|
+
|
|
931
|
+
def maybe_truncate(data: bytes) -> bytes:
|
|
932
|
+
if max_bytes is not None and len(data) > max_bytes:
|
|
933
|
+
return data[:max_bytes]
|
|
934
|
+
return data
|
|
935
|
+
|
|
936
|
+
if mode == "json":
|
|
937
|
+
data = await self.load_json_by_id(artifact_id, encoding=encoding, errors=errors)
|
|
938
|
+
return ArtifactContent(artifact=art, mode=mode, json=data)
|
|
939
|
+
|
|
940
|
+
if mode == "text":
|
|
941
|
+
text = await self.load_text_by_id(artifact_id, encoding=encoding, errors=errors)
|
|
942
|
+
return ArtifactContent(artifact=art, mode=mode, text=text)
|
|
943
|
+
|
|
944
|
+
# raw bytes
|
|
945
|
+
raw = await self.load_bytes_by_id(artifact_id)
|
|
946
|
+
raw = maybe_truncate(raw)
|
|
947
|
+
return ArtifactContent(artifact=art, mode="bytes", data=raw)
|
|
948
|
+
|
|
826
949
|
async def as_local_file_by_id(
|
|
827
950
|
self,
|
|
828
951
|
artifact_id: str,
|
|
@@ -1279,6 +1402,19 @@ class ArtifactFacade:
|
|
|
1279
1402
|
"""
|
|
1280
1403
|
await self.index.pin(artifact_id, pinned=pinned)
|
|
1281
1404
|
|
|
1405
|
+
# ---------- search result hydration ----------
|
|
1406
|
+
async def fetch_artifacts_for_search_results(
|
|
1407
|
+
self,
|
|
1408
|
+
scored_items: list[ScoredItem],
|
|
1409
|
+
corpus: str = "artifact",
|
|
1410
|
+
) -> list[ArtifactSearchResult]:
|
|
1411
|
+
artifact_items = [it for it in scored_items if it.corpus == corpus]
|
|
1412
|
+
results: list[ArtifactSearchResult] = []
|
|
1413
|
+
for it in artifact_items:
|
|
1414
|
+
art = await self.get_by_id(it.item_id)
|
|
1415
|
+
results.append(ArtifactSearchResult(item=it, artifact=art))
|
|
1416
|
+
return results
|
|
1417
|
+
|
|
1282
1418
|
# ---------- internal helpers ----------
|
|
1283
1419
|
async def _record_simple(self, a: Artifact) -> None:
|
|
1284
1420
|
"""Record artifact in index and occurrence log."""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Literal, NamedTuple
|
|
3
|
+
|
|
4
|
+
from aethergraph.contracts.services.artifacts import Artifact
|
|
5
|
+
from aethergraph.contracts.storage.search_backend import ScoredItem
|
|
6
|
+
|
|
7
|
+
ContentMode = Literal["json", "text", "bytes"]
|
|
8
|
+
|
|
9
|
+
ArtifactView = Literal["node", "graph", "run", "all"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ArtifactSearchResult(NamedTuple):
|
|
13
|
+
item: ScoredItem # raw search result (score, metadata, etc.)
|
|
14
|
+
artifact: Artifact | None # hydrated Artifact, or None if missing
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def id(self) -> str:
|
|
18
|
+
return self.item.item_id
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def score(self) -> float:
|
|
22
|
+
return self.item.score
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def meta(self) -> dict[str, Any]:
|
|
26
|
+
return self.item.metadata
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ArtifactContent:
|
|
31
|
+
artifact: Artifact
|
|
32
|
+
mode: ContentMode # "json", "text", or "bytes"
|
|
33
|
+
text: str | None = None
|
|
34
|
+
json: Any | None = None
|
|
35
|
+
data: bytes | None = None
|
|
@@ -6,6 +6,9 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
from aethergraph.contracts.services.artifacts import Artifact
|
|
10
|
+
from aethergraph.services.artifacts.types import ContentMode
|
|
11
|
+
|
|
9
12
|
|
|
10
13
|
def now_iso() -> str:
|
|
11
14
|
return datetime.now(timezone.utc).isoformat()
|
|
@@ -122,3 +125,42 @@ def _maybe_cleanup_tmp_parent(tmp_root: str, path: str):
|
|
|
122
125
|
parent = os.path.dirname(parent)
|
|
123
126
|
except Exception:
|
|
124
127
|
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _infer_content_mode(artifact: Artifact) -> ContentMode:
|
|
131
|
+
"""
|
|
132
|
+
Decide how to load this artifact: as JSON, text, or raw bytes.
|
|
133
|
+
|
|
134
|
+
Priority:
|
|
135
|
+
1) artifact.kind
|
|
136
|
+
2) filename extension
|
|
137
|
+
3) default to bytes
|
|
138
|
+
"""
|
|
139
|
+
k = (getattr(artifact, "kind", None) or "").lower()
|
|
140
|
+
|
|
141
|
+
# 1) Use explicit kind when we know what to do
|
|
142
|
+
if k == "json":
|
|
143
|
+
return "json"
|
|
144
|
+
if k in {"text", "log", "note"}:
|
|
145
|
+
return "text"
|
|
146
|
+
if k in {"image", "plot", "figure"}:
|
|
147
|
+
return "bytes" # bytes → caller decides what to do with an image
|
|
148
|
+
|
|
149
|
+
# 2) Fall back to filename extension
|
|
150
|
+
filename = None
|
|
151
|
+
if getattr(artifact, "labels", None):
|
|
152
|
+
filename = artifact.labels.get("filename")
|
|
153
|
+
if not filename:
|
|
154
|
+
filename = getattr(artifact, "name", None)
|
|
155
|
+
|
|
156
|
+
if filename:
|
|
157
|
+
ext = Path(filename).suffix.lower()
|
|
158
|
+
if ext == ".json":
|
|
159
|
+
return "json"
|
|
160
|
+
if ext in {".txt", ".md", ".log", ".csv"}:
|
|
161
|
+
return "text"
|
|
162
|
+
if ext in {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}:
|
|
163
|
+
return "bytes"
|
|
164
|
+
|
|
165
|
+
# 3) Unknown → safest is bytes
|
|
166
|
+
return "bytes"
|
|
@@ -47,7 +47,9 @@ class ChannelBus:
|
|
|
47
47
|
def register_alias(self, alias: str, target: str) -> None:
|
|
48
48
|
"""Register or overwrite a human-friendly alias -> canonical key."""
|
|
49
49
|
if self._prefix(target) not in self.adapters:
|
|
50
|
-
raise RuntimeError(
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
f"Cannot alias to unknown channel prefix: {self._prefix(target)}. Please check if you have enabled the required channel service in .env and registered the adapter."
|
|
52
|
+
)
|
|
51
53
|
|
|
52
54
|
self.channel_aliases[alias] = target
|
|
53
55
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EventHub:
|
|
10
|
+
"""
|
|
11
|
+
In-memory pub/sub for UI events.
|
|
12
|
+
|
|
13
|
+
- Keys by scope_id (session_id or run_id).
|
|
14
|
+
- Optionally filters by kind (e.g. "session_chat", "run_channel").
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._subscribers: dict[str, set[asyncio.Queue]] = defaultdict(set)
|
|
19
|
+
self._lock = asyncio.Lock()
|
|
20
|
+
|
|
21
|
+
async def subscribe(self, scope_id: str) -> AsyncIterator[dict[str, Any]]:
|
|
22
|
+
"""
|
|
23
|
+
Async generator: yields raw EventLog-like rows with keys:
|
|
24
|
+
{ "id", "ts", "scope_id", "kind", "payload": {...} }
|
|
25
|
+
"""
|
|
26
|
+
q: asyncio.Queue = asyncio.Queue()
|
|
27
|
+
async with self._lock:
|
|
28
|
+
self._subscribers[scope_id].add(q)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
while True:
|
|
32
|
+
row = await q.get()
|
|
33
|
+
yield row
|
|
34
|
+
finally:
|
|
35
|
+
async with self._lock:
|
|
36
|
+
self._subscribers[scope_id].discard(q)
|
|
37
|
+
if not self._subscribers[scope_id]:
|
|
38
|
+
self._subscribers.pop(scope_id, None)
|
|
39
|
+
|
|
40
|
+
async def broadcast(self, row: dict[str, Any]) -> None:
|
|
41
|
+
scope_id = row.get("scope_id")
|
|
42
|
+
if not scope_id:
|
|
43
|
+
return
|
|
44
|
+
async with self._lock:
|
|
45
|
+
subs = list(self._subscribers.get(scope_id, []))
|
|
46
|
+
for q in subs:
|
|
47
|
+
# Best-effort; if queue is full we drop rather than block worker
|
|
48
|
+
try:
|
|
49
|
+
q.put_nowait(row)
|
|
50
|
+
except asyncio.QueueFull:
|
|
51
|
+
# log if you want
|
|
52
|
+
import logging
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
logger.warning(f"EventHub queue full for scope_id={scope_id}, dropping event")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_SENTINEL = object()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EventHub:
|
|
13
|
+
"""
|
|
14
|
+
In-memory pub/sub for UI events.
|
|
15
|
+
Keys by (scope_id, kind).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._subscribers: dict[tuple[str, str], set[asyncio.Queue]] = defaultdict(set)
|
|
20
|
+
self._lock = asyncio.Lock()
|
|
21
|
+
self._closed = False
|
|
22
|
+
|
|
23
|
+
async def subscribe(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
scope_id: str,
|
|
27
|
+
kind: str,
|
|
28
|
+
max_queue: int = 256,
|
|
29
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
30
|
+
"""
|
|
31
|
+
Async generator that yields rows for (scope_id, kind).
|
|
32
|
+
"""
|
|
33
|
+
q: asyncio.Queue = asyncio.Queue(maxsize=max_queue)
|
|
34
|
+
|
|
35
|
+
async with self._lock:
|
|
36
|
+
if self._closed:
|
|
37
|
+
return
|
|
38
|
+
self._subscribers[(scope_id, kind)].add(q)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
while True:
|
|
42
|
+
item = await q.get()
|
|
43
|
+
if item is _SENTINEL:
|
|
44
|
+
return
|
|
45
|
+
yield item
|
|
46
|
+
finally:
|
|
47
|
+
async with self._lock:
|
|
48
|
+
self._subscribers[(scope_id, kind)].discard(q)
|
|
49
|
+
if not self._subscribers[(scope_id, kind)]:
|
|
50
|
+
self._subscribers.pop((scope_id, kind), None)
|
|
51
|
+
|
|
52
|
+
async def broadcast(self, row: dict[str, Any]) -> None:
|
|
53
|
+
scope_id = row.get("scope_id")
|
|
54
|
+
kind = row.get("kind")
|
|
55
|
+
if not scope_id or not kind:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
async with self._lock:
|
|
59
|
+
subs = list(self._subscribers.get((scope_id, kind), []))
|
|
60
|
+
|
|
61
|
+
for q in subs:
|
|
62
|
+
try:
|
|
63
|
+
q.put_nowait(row)
|
|
64
|
+
except asyncio.QueueFull:
|
|
65
|
+
# Drop instead of blocking producer
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
async def close(self) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Wake and detach all subscribers (useful on shutdown/reload).
|
|
71
|
+
"""
|
|
72
|
+
async with self._lock:
|
|
73
|
+
self._closed = True
|
|
74
|
+
all_queues = []
|
|
75
|
+
for qs in self._subscribers.values():
|
|
76
|
+
all_queues.extend(list(qs))
|
|
77
|
+
self._subscribers.clear()
|
|
78
|
+
|
|
79
|
+
for q in all_queues:
|
|
80
|
+
with suppress(Exception):
|
|
81
|
+
q.put_nowait(_SENTINEL)
|
|
@@ -12,10 +12,11 @@ from aethergraph.plugins.channel.adapters.slack import SlackChannelAdapter
|
|
|
12
12
|
from aethergraph.plugins.channel.adapters.telegram import TelegramChannelAdapter
|
|
13
13
|
from aethergraph.plugins.channel.adapters.webhook import WebhookChannelAdapter
|
|
14
14
|
from aethergraph.services.channel.channel_bus import ChannelBus
|
|
15
|
+
from aethergraph.services.channel.event_hub import EventHub
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def make_channel_adapters_from_env(
|
|
18
|
-
cfg: AppSettings, event_log: EventLog | None = None
|
|
19
|
+
cfg: AppSettings, event_log: EventLog | None = None, event_hub: EventHub | None = None
|
|
19
20
|
) -> dict[str, Any]:
|
|
20
21
|
# Always include console adapter
|
|
21
22
|
adapters = {"console": ConsoleChannelAdapter()}
|
|
@@ -40,7 +41,7 @@ def make_channel_adapters_from_env(
|
|
|
40
41
|
|
|
41
42
|
if event_log is None:
|
|
42
43
|
raise ValueError("event_log must be provided to create WebUIChannelAdapter")
|
|
43
|
-
adapters["ui"] = WebUIChannelAdapter(event_log=event_log)
|
|
44
|
+
adapters["ui"] = WebUIChannelAdapter(event_log=event_log, event_hub=event_hub)
|
|
44
45
|
return adapters
|
|
45
46
|
|
|
46
47
|
|