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,49 @@
1
+ __version__ = "0.1.0a1"
2
+
3
+
4
+ # Server
5
+ # Channel buttons
6
+ from .contracts.services.channel import Button
7
+
8
+ # Graphs
9
+ from .core.graph.graph_fn import graph_fn # full-featured graph decorator
10
+ from .core.graph.graphify import graphify # graphify decorator to build TaskGraphs from functions
11
+ from .core.graph.task_graph import (
12
+ TaskGraph, # full task graph object for type checking, serialization, etc.
13
+ )
14
+ from .core.runtime.base_service import Service # base service class for custom services
15
+
16
+ # Runtime
17
+ from .core.runtime.node_context import NodeContext # per-node execution context (run_id)
18
+
19
+ # Tools
20
+ from .core.tools.toolkit import tool
21
+ from .server.start import (
22
+ start_server, # start a local sidecar server
23
+ start_server_async, # async version of start_server
24
+ stop_server, # stop the sidecar server
25
+ )
26
+
27
+ __all__ = [
28
+ # Server
29
+ "start_server",
30
+ "stop_server",
31
+ "start_server_async",
32
+ # Tools
33
+ "tool",
34
+ "graph_fn",
35
+ "graphify",
36
+ "TaskGraph",
37
+ "RuntimeEnv",
38
+ "NodeContext",
39
+ # Services
40
+ "Service",
41
+ # Channel buttons
42
+ "Button",
43
+ ]
44
+
45
+
46
+ # Setup a default null logger to avoid "No handler found" warnings
47
+ import logging
48
+
49
+ logging.getLogger("aethergraph").addHandler(logging.NullHandler())
File without changes
@@ -0,0 +1,121 @@
1
+ # aethergraph/config.py
2
+ from typing import Literal
3
+
4
+ from pydantic import BaseModel, Field, SecretStr
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+ from .llm import LLMSettings
8
+
9
+
10
+ class LoggingSettings(BaseModel):
11
+ nspace: str = Field("aethergraph", description="Root logger namespace")
12
+ level: str = Field("INFO", description="Root log level")
13
+ json_logs: bool = Field(False, description="Emit JSON logs")
14
+ enable_queue: bool = Field(default=False, description="Enable async logging via queue")
15
+
16
+ external_level: str = Field("WARNING", description="Level for third-party loggers")
17
+ quiet_loggers: list[str] = Field(
18
+ default_factory=lambda: ["httpx", "faiss", "faiss.loader", "slack_sdk"],
19
+ description="Additional loggers to set to external_level",
20
+ )
21
+
22
+
23
+ class SlackSettings(BaseModel):
24
+ # Turn Slack integration on/off globally
25
+ enabled: bool = Field(default=False)
26
+
27
+ # Tokens
28
+ bot_token: SecretStr | None = None # xoxb-...
29
+ app_token: SecretStr | None = None # xapp-... (Socket Mode)
30
+ signing_secret: SecretStr | None = None # only needed for HTTP/webhook
31
+
32
+ # Transport mode flags
33
+ #
34
+ # Local / individual default:
35
+ # enabled = true
36
+ # socket_mode_enabled = true
37
+ # webhook_enabled = false
38
+ #
39
+ # Production / webhook default:
40
+ # enabled = true
41
+ # socket_mode_enabled = false (optional)
42
+ # webhook_enabled = true
43
+
44
+ socket_mode_enabled: bool = Field(
45
+ default=True, description="Use Slack Socket Mode (WS outbound) when app_token is set."
46
+ )
47
+ webhook_enabled: bool = Field(
48
+ default=False,
49
+ description="Expose /slack/events & /slack/interact HTTP endpoints for Slack.",
50
+ )
51
+
52
+ # Default routing
53
+ #
54
+ # For simple setups likely only need default_channel_id (+ maybe default_team_id).
55
+ # default_channel_key is the more general 'slack:team/T:chan/C' form.
56
+ # TODO: later we might deprecate the default setting in .env and require explicit channel keys in code.
57
+ default_team_id: str | None = None # e.g. 'T...'
58
+ default_channel_id: str | None = None # e.g. 'C...'
59
+ default_channel_key: str | None = None # e.g. 'slack:team/T...:chan/C...'
60
+
61
+
62
+ class TelegramSettings(BaseModel):
63
+ enabled: bool = Field(default=False)
64
+ bot_token: SecretStr | None = None
65
+
66
+ # for webhook mode
67
+ webhook_enabled: bool = False
68
+ webhook_secret: SecretStr | None = None # used ONLY for HTTP webhook verification
69
+
70
+ # for local / dev mode
71
+ polling_enabled: bool = True # use getUpdates loop by default for local
72
+
73
+ # default chat key
74
+ default_chat_id: str | None = None
75
+
76
+
77
+ class ContinuationStoreSettings(BaseModel):
78
+ kind: Literal["fs", "inmem"] = "fs"
79
+ secret: SecretStr | None = None
80
+ root: str = "./artifacts/continuations"
81
+
82
+
83
+ class MemorySettings(BaseModel):
84
+ hot_limit: int = 1000
85
+ hot_ttl_s: int = 7 * 24 * 3600
86
+ signal_threshold: float = 0.25
87
+
88
+
89
+ class ChannelSettings(BaseModel):
90
+ # room for Telegram / Console etc.
91
+ default: str = "console:stdin"
92
+
93
+
94
+ class RAGSettings(BaseModel):
95
+ root: str = "./aethergraph_data/rag" # base dir for rag; should not use it unless customized
96
+ backend: str = "sqlite" # "sqlite" | "faiss"
97
+ index_path: str | None = None # defaults set at runtime if None
98
+ dim: int | None = None # only for faiss; optional
99
+
100
+
101
+ class AppSettings(BaseSettings):
102
+ model_config = SettingsConfigDict(
103
+ env_prefix="AETHERGRAPH_", env_nested_delimiter="__", extra="ignore", case_sensitive=False
104
+ )
105
+
106
+ # top-level for workspace root
107
+ root: str = "./aethergraph_data"
108
+
109
+ logging: LoggingSettings = LoggingSettings()
110
+ slack: SlackSettings = SlackSettings()
111
+ telegram: TelegramSettings = TelegramSettings()
112
+ llm: LLMSettings = LLMSettings()
113
+ cont: ContinuationStoreSettings = ContinuationStoreSettings()
114
+ memory: MemorySettings = MemorySettings()
115
+ channels: ChannelSettings = ChannelSettings()
116
+ rag: RAGSettings = RAGSettings()
117
+
118
+ # Future fields:
119
+ # authn: ...
120
+ # authz: ...
121
+ # tracer: ...
@@ -0,0 +1,16 @@
1
+ from contextvars import ContextVar
2
+
3
+ from .config import AppSettings
4
+
5
+ _current_cfg: ContextVar[AppSettings | None] = ContextVar("current_cfg", default=None)
6
+
7
+
8
+ def set_current_settings(cfg: AppSettings) -> None:
9
+ _current_cfg.set(cfg)
10
+
11
+
12
+ def current_settings() -> AppSettings:
13
+ cfg = _current_cfg.get()
14
+ if cfg is None:
15
+ raise RuntimeError("Settings not installed. Call set_current_settings() at startup.")
16
+ return cfg
@@ -0,0 +1,26 @@
1
+ from pydantic import BaseModel, Field, SecretStr
2
+
3
+ from aethergraph.services.llm.providers import Provider
4
+
5
+
6
+ class LLMProfile(BaseModel):
7
+ provider: Provider = "openai"
8
+ model: str = "gpt-4o-mini"
9
+ embed_model: str | None = None # separate embedding model
10
+ base_url: str | None = None
11
+ timeout: float = 60.0
12
+
13
+ # provider-specific
14
+ azure_deployment: str | None = None
15
+
16
+ # secrets (either direct value or ref name)
17
+ api_key: SecretStr | None = None
18
+ api_key_ref: str | None = Field(
19
+ default=None, description="Name in secret store, e.g. 'OPENAI_API_KEY'"
20
+ )
21
+
22
+
23
+ class LLMSettings(BaseModel):
24
+ enabled: bool = True
25
+ default: LLMProfile = LLMProfile()
26
+ profiles: dict[str, LLMProfile] = Field(default_factory=dict)
@@ -0,0 +1,60 @@
1
+ # aethergraph/config_loader.py
2
+ from collections.abc import Iterable
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from .config import AppSettings
8
+
9
+
10
+ def _existing(paths: Iterable[Path]) -> list[Path]:
11
+ return [p for p in paths if p and p.exists()]
12
+
13
+
14
+ def load_settings() -> AppSettings:
15
+ log = logging.getLogger("aethergraph.config.loader")
16
+
17
+ # 1) explicit override
18
+ explicit = os.getenv("AETHERGRAPH_ENV_FILE")
19
+ explicit_path = Path(explicit).expanduser().resolve() if explicit else None
20
+
21
+ # 2) execution context (project) – where user runs `python ...`
22
+ cwd = Path.cwd()
23
+
24
+ # 3) workspace-level (if user sets it)
25
+ workspace = Path(os.getenv("AETHERGRAPH_ROOT", "./aethergraph_data")).expanduser().resolve()
26
+
27
+ # 4) user config dir (~/.config/aethergraph/.env or XDG)
28
+ xdg = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")).expanduser().resolve()
29
+ user_cfg_env = xdg / "aethergraph" / ".env"
30
+
31
+ # Optional: keep a *repo dev* fallback if running from source
32
+ # (safe; only used if that path actually exists)
33
+ try:
34
+ repo_root = Path(__file__).resolve().parents[3]
35
+ except Exception:
36
+ repo_root = None
37
+ repo_env = (
38
+ (repo_root / ".env").resolve() if (repo_root and (repo_root / ".env").exists()) else None
39
+ )
40
+
41
+ candidates = _existing(
42
+ [
43
+ explicit_path or Path(), # explicit if set
44
+ cwd / ".env",
45
+ cwd / ".env.local",
46
+ workspace / ".env",
47
+ user_cfg_env,
48
+ repo_env if repo_env else Path(), # dev fallback only if exists
49
+ ]
50
+ )
51
+
52
+ if explicit and not explicit_path.exists():
53
+ raise FileNotFoundError(f"AETHERGRAPH_ENV_FILE not found: {explicit_path}")
54
+
55
+ if not candidates:
56
+ log.warning("No .env files found; using OS environment variables only.")
57
+ return AppSettings()
58
+
59
+ # Later files override earlier ones
60
+ return AppSettings(_env_file=[str(p) for p in candidates])
@@ -0,0 +1,9 @@
1
+ from functools import lru_cache
2
+
3
+ from .config import AppSettings
4
+ from .loader import load_settings
5
+
6
+
7
+ @lru_cache(maxsize=1)
8
+ def get_settings() -> AppSettings:
9
+ return load_settings()
@@ -0,0 +1,44 @@
1
+ # Typed exceptions (ValidationError, MigrationError, etc.)
2
+
3
+
4
+ class AetherGraphError(Exception):
5
+ """Base class for all AetherGraph errors."""
6
+
7
+
8
+ class NodeContractError(AetherGraphError):
9
+ """Raised when a TaskNodeRuntime violates its declared input/output contract."""
10
+
11
+
12
+ class MissingInputError(NodeContractError):
13
+ """Raised when a required input key is missing."""
14
+
15
+
16
+ class MissingOutputError(NodeContractError):
17
+ """Raised when a required output key is missing."""
18
+
19
+
20
+ class ExecutionError(AetherGraphError):
21
+ """Raised when a node’s logic fails during execution."""
22
+
23
+
24
+ class GraphHasPendingWaits(RuntimeError):
25
+ """Raised when attempting to finalize a graph that has pending waits."""
26
+
27
+ def __init__(
28
+ self, message: str, waiting_nodes: list[str], continuations: list[dict] | None = None
29
+ ):
30
+ super().__init__(message)
31
+ self.waiting_nodes = waiting_nodes
32
+ self.continuations = continuations or []
33
+
34
+
35
+ class ResumeIncompatibleSnapshot(RuntimeError):
36
+ """
37
+ Raised when a snapshot is not allowed for resume under the current policy
38
+ (e.g., contains non-JSON outputs or external refs like __aether_ref__).
39
+ """
40
+
41
+ def __init__(self, run_id: str, reasons: list[str]):
42
+ super().__init__(f"Resume blocked for run_id={run_id}. " + " / ".join(reasons))
43
+ self.run_id = run_id
44
+ self.reasons = reasons
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import AbstractAsyncContextManager
4
+ from dataclasses import dataclass
5
+ from typing import Any, Protocol
6
+
7
+
8
+ @dataclass
9
+ class Artifact:
10
+ artifact_id: str
11
+ uri: str
12
+ kind: str
13
+ bytes: int
14
+ sha256: str
15
+ mime: str | None
16
+ run_id: str
17
+ graph_id: str
18
+ node_id: str
19
+ tool_name: str
20
+ tool_version: str
21
+ created_at: str
22
+ labels: dict[str, Any]
23
+ metrics: dict[str, Any]
24
+ preview_uri: str | None = None # for rendering previews in UI, not tied to storage
25
+ pinned: bool = False
26
+
27
+ def to_dict(self) -> dict[str, Any]:
28
+ return {
29
+ "artifact_id": self.artifact_id,
30
+ "uri": self.uri,
31
+ "kind": self.kind,
32
+ "bytes": self.bytes,
33
+ "sha256": self.sha256,
34
+ "mime": self.mime,
35
+ "run_id": self.run_id,
36
+ "graph_id": self.graph_id,
37
+ "node_id": self.node_id,
38
+ "tool_name": self.tool_name,
39
+ "tool_version": self.tool_version,
40
+ "created_at": self.created_at,
41
+ "labels": self.labels,
42
+ "metrics": self.metrics,
43
+ "preview_uri": self.preview_uri,
44
+ "pinned": self.pinned,
45
+ }
46
+
47
+
48
+ class AsyncArtifactStore(Protocol):
49
+ async def save_file(
50
+ self,
51
+ *,
52
+ path: str,
53
+ kind: str,
54
+ run_id: str,
55
+ graph_id: str,
56
+ node_id: str,
57
+ tool_name: str,
58
+ tool_version: str,
59
+ suggested_uri: str | None = None,
60
+ pin: bool = False,
61
+ labels: dict | None = None,
62
+ metrics: dict | None = None,
63
+ preview_uri: str | None = None,
64
+ ) -> Artifact: ...
65
+ async def open_writer(
66
+ self,
67
+ *,
68
+ kind: str,
69
+ run_id: str,
70
+ graph_id: str,
71
+ node_id: str,
72
+ tool_name: str,
73
+ tool_version: str,
74
+ planned_ext: str | None = None,
75
+ pin: bool = False,
76
+ ) -> AbstractAsyncContextManager[Any]: ...
77
+ async def plan_staging_path(self, planned_ext: str = "") -> str: ...
78
+ async def ingest_staged_file(
79
+ self,
80
+ *,
81
+ staged_path: str,
82
+ kind: str,
83
+ run_id: str,
84
+ graph_id: str,
85
+ node_id: str,
86
+ tool_name: str,
87
+ tool_version: str,
88
+ pin: bool = False,
89
+ labels: dict | None = None,
90
+ metrics: dict | None = None,
91
+ preview_uri: str | None = None,
92
+ suggested_uri: str | None = None,
93
+ ) -> Artifact: ...
94
+ async def plan_staging_dir(self, suffix: str = "") -> str: ...
95
+ async def ingest_directory(
96
+ self,
97
+ *,
98
+ staged_dir: str,
99
+ kind: str,
100
+ run_id: str,
101
+ graph_id: str,
102
+ node_id: str,
103
+ tool_name: str,
104
+ tool_version: str,
105
+ include: list[str] | None = None,
106
+ exclude: list[str] | None = None,
107
+ index_children: bool = False,
108
+ pin: bool = False,
109
+ labels: dict | None = None,
110
+ metrics: dict | None = None,
111
+ suggested_uri: str | None = None,
112
+ archive: bool = False,
113
+ archive_name: str = "bundle.tar.gz",
114
+ cleanup: bool = True,
115
+ store: str | None = None,
116
+ ) -> Artifact: ...
117
+ async def load_artifact(self, uri: str) -> Any: ...
118
+ async def load_artifact_bytes(self, uri: str) -> bytes: ...
119
+ async def load_artifact_dir(self, uri: str) -> str: ...
120
+ async def cleanup_tmp(self, max_age_hours: int = 24) -> None: ...
121
+ async def save_text(self, payload: str, suggested_uri: str | None = None) -> Artifact: ...
122
+ async def save_json(self, payload: dict, suggested_uri: str | None = None) -> Artifact: ...
123
+ @property
124
+ def base_uri(self) -> str: ...
125
+
126
+
127
+ class AsyncArtifactIndex(Protocol):
128
+ async def upsert(self, a: Artifact) -> None: ...
129
+ async def list_for_run(self, run_id: str) -> list[Artifact]: ...
130
+ async def search(
131
+ self,
132
+ *,
133
+ kind: str | None = None,
134
+ labels: dict | None = None,
135
+ metric: str | None = None,
136
+ mode: str | None = None,
137
+ ) -> list[Artifact]: ...
138
+ async def best(
139
+ self, *, kind: str, metric: str, mode: str, filters: dict | None = None
140
+ ) -> Artifact | None: ...
141
+ async def pin(self, artifact_id: str, pinned: bool = True) -> None: ...
142
+ async def record_occurrence(self, a: Artifact, extra_labels: dict | None = None) -> None: ...
@@ -0,0 +1,72 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, Literal, Protocol, TypedDict
3
+
4
+ EventType = Literal[
5
+ "agent.message",
6
+ "agent.message.update", # simple text messages
7
+ "agent.stream.start",
8
+ "agent.stream.delta",
9
+ "agent.stream.end", # streaming messages
10
+ "agent.progress.start",
11
+ "agent.progress.update",
12
+ "agent.progress.end", # progress bar
13
+ "session.need_input",
14
+ "session.need_approval",
15
+ "session.waiting",
16
+ "file.upload",
17
+ "link.buttons", # link preview with buttons
18
+ ]
19
+
20
+
21
+ class FileRef(TypedDict, total=False):
22
+ id: str # platform file id (e.g., Slack file ID)
23
+ name: str # suggested filename
24
+ mimetype: str # MIME type, e.g., "image/png", "application/pdf"
25
+ size: int # size in bytes
26
+ uri: str # URL to download the file (artifact storage or platform URL)
27
+ url_private: str # private URL if applicable (e.g., Slack private URL)
28
+ platform: str # platform name, e.g., "slack", "telegram", "console"
29
+ channel_key: str # normalized channel key where the file was sent, e.g., "slack:team/T:chan/C"
30
+ ts: str | float # timestamp of the file upload
31
+
32
+
33
+ @dataclass
34
+ class Button:
35
+ label: str
36
+ value: str | None = None
37
+ url: str | None = None
38
+ style: Literal["primary", "danger", "default"] | None = None # for slack buttons
39
+
40
+
41
+ @dataclass
42
+ class OutEvent:
43
+ type: EventType # "agent.message", "session.need_input", "session.need_approval", "agent.stream.*"
44
+ channel: str # routing key, e.g., "console:stdout" or "slack:team/T:chan/C[:thread/TS]"
45
+ text: str | None = None
46
+ rich: dict[str, Any] | None = None
47
+ meta: dict[str, Any] | None = None
48
+ # Optional structured extras most adapters can use, e.g., for buttons, attachments, files, etc.
49
+ buttons: dict[str, Button] | None = None # for approvals or link actions
50
+ image: dict[str, Any] | None = None # e.g., {"url": "...", "alt": "...", "title": "..."}
51
+ file: dict[str, Any] | None = (
52
+ None # e.g., {"bytes" b"...", "filename": "...", "mimetype": "..."} or {"url": "...", "filename": "...", "mimetype": "..."}
53
+ )
54
+ upsert_key: str | None = None # for idempotent upserts, e.g., message ID to update same message
55
+
56
+ def to_printable(self) -> str:
57
+ """Only contains printable parts of the event."""
58
+ return (
59
+ f"Event(type={self.type}, channel={self.channel}, text={self.text}, meta={self.meta})"
60
+ )
61
+
62
+
63
+ class ChannelAdapter(Protocol):
64
+ # Capabilities helper
65
+ capabilities: set[str] # e.g. {"text", "image", "file", "buttons", "rich"}
66
+
67
+ async def send(self, event: OutEvent) -> None:
68
+ """
69
+ Send an outgoing event to the appropriate channel.
70
+ E.g., print to console, post to Slack, enqueue in UI, etc.
71
+ """
72
+ pass
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol
4
+
5
+
6
+ class AsyncContinuationStore(Protocol):
7
+ async def mint_token(self, run_id: str, node_id: str, attempts: int) -> str: ...
8
+ async def save(self, cont: Any) -> None: ...
9
+ async def get(self, run_id: str, node_id: str) -> Any | None: ...
10
+ async def delete(self, run_id: str, node_id: str) -> None: ...
11
+
12
+ async def get_by_token(self, token: str) -> Any | None: ...
13
+ async def mark_closed(self, token: str) -> None: ...
14
+ async def verify_token(self, run_id: str, node_id: str, token: str) -> bool: ...
15
+
16
+ async def bind_correlator(self, *, token: str, corr: Any) -> None: ...
17
+ async def find_by_correlator(self, *, corr: Any) -> Any | None: ...
18
+
19
+ # Optional helpers
20
+ async def last_open(self, *, channel: str, kind: str) -> Any | None: ...
21
+ async def list_waits(self) -> list[dict[str, Any]]: ...
22
+ async def clear(self) -> None: ...
23
+ async def alias_for(self, token: str) -> str | None: ...
@@ -0,0 +1,12 @@
1
+ from collections.abc import Awaitable, Callable
2
+ from typing import Any, Protocol
3
+
4
+ Handler = Callable[[dict[str, Any]], Awaitable[None]]
5
+
6
+
7
+ class EventBus(Protocol):
8
+ """Protocol for an event bus service."""
9
+
10
+ async def publish(self, topic: str, event: dict[str, Any]) -> None: ...
11
+
12
+ def subscribe(self, topic: str, handler: Handler) -> None: ...
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from typing import Any, Protocol
5
+
6
+
7
+ class AsyncKV(Protocol):
8
+ async def get(self, key: str, default: Any = None) -> Any: ...
9
+ async def set(self, key: str, value: Any, *, ttl_s: int | None = None) -> None: ...
10
+ async def delete(self, key: str) -> None: ...
11
+
12
+ # list helpers (JSON list values)
13
+ async def list_append_unique(
14
+ self, key: str, items: list, *, id_key: str = "id", ttl_s: int | None = None
15
+ ) -> list: ...
16
+ async def list_pop_all(self, key: str) -> list: ...
17
+
18
+ # optional but useful
19
+ async def mget(self, keys: Iterable[str]) -> dict[str, Any]: ...
20
+ async def mset(self, items: dict[str, Any], *, ttl_s: int | None = None) -> None: ...
21
+ async def incr(self, key: str, amount: int = 1, *, ttl_s: int | None = None) -> int: ...
22
+ async def exists(self, key: str) -> bool: ...
23
+ async def expire(self, key: str, ttl_s: int) -> bool: ...
24
+ async def scan_prefix(self, prefix: str, limit: int = 1000) -> Iterable[str]: ...
@@ -0,0 +1,17 @@
1
+ from typing import Any, Protocol
2
+
3
+
4
+ class LLMClientProtocol(Protocol):
5
+ async def chat(self, messages: list[dict[str, Any]], **kw) -> tuple[str, dict[str, int]]: ...
6
+ async def embed(self, texts: list[str], **kw) -> list[list[float]]: ...
7
+ async def raw(
8
+ self,
9
+ *,
10
+ method: str = "POST",
11
+ path: str | None = None,
12
+ url: str | None = None,
13
+ json: Any | None = None,
14
+ params: dict[str, Any] | None = None,
15
+ headers: dict[str, str] | None = None,
16
+ return_response: bool = False,
17
+ ) -> Any: ...
@@ -0,0 +1,22 @@
1
+ from typing import Any, TypedDict
2
+
3
+
4
+ class MCPTool(TypedDict):
5
+ name: str
6
+ description: str | None
7
+ input_schema: dict[str, Any] | None
8
+
9
+
10
+ class MCPResource(TypedDict):
11
+ uri: str
12
+ mime: str | None
13
+ description: str | None
14
+
15
+
16
+ class MCPClientProtocol:
17
+ async def open(self): ...
18
+ async def close(self): ...
19
+ async def list_tools(self) -> list[MCPTool]: ...
20
+ async def call(self, tool: str, params: dict[str, Any] | None = None) -> dict[str, Any]: ...
21
+ async def list_resources(self) -> list[MCPResource]: ...
22
+ async def read_resource(self, uri: str) -> dict[str, Any]: ...