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,106 @@
1
+ import asyncio
2
+ import sys
3
+
4
+ from aethergraph.contracts.services.channel import ChannelAdapter, OutEvent
5
+ from aethergraph.services.continuations.continuation import Correlator
6
+
7
+
8
+ class ConsoleChannelAdapter(ChannelAdapter):
9
+ # console can now ask for text and approvals (buttons via numeric mapping)
10
+ capabilities: set[str] = {"text", "input", "buttons"}
11
+
12
+ def __init__(self):
13
+ self._seq_by_chan: dict[str, int] = {}
14
+
15
+ async def send(self, event: OutEvent) -> dict | None:
16
+ # non-interactive path: just print
17
+ if event.type not in ("session.need_input", "session.need_approval"):
18
+ line = f"[console] {event.type} :: {event.text or ''}"
19
+ if event.image:
20
+ line += f" [image] {event.image.get('title', '')}: {event.image.get('url', '')}"
21
+ if event.file:
22
+ line += f" [file] {event.file.get('filename', '')}: {event.file.get('url', '') or '(binary)'}"
23
+ if event.buttons:
24
+ labels = ", ".join(b.label for b in event.buttons)
25
+ line += f" [buttons] {labels}"
26
+ print(line)
27
+
28
+ seq = self._seq_by_chan.get(event.channel, 0) + 1
29
+ self._seq_by_chan[event.channel] = seq
30
+ return {
31
+ "correlator": Correlator(
32
+ scheme="console",
33
+ channel=event.channel,
34
+ thread=None,
35
+ message=str(seq),
36
+ )
37
+ }
38
+
39
+ # Interactive: input
40
+ if event.type == "session.need_input":
41
+ prompt = (event.text or "Please reply: ").rstrip() + " "
42
+ try:
43
+ answer = await self._readline(prompt)
44
+ return {"payload": {"text": answer}}
45
+ except _NoInlineInput:
46
+ # Signal the waiter to persist a real continuation instead of inlining
47
+ print(
48
+ "\n[console] (no input captured; will persist a continuation and wait for resume)"
49
+ )
50
+ return None
51
+
52
+ # Interactive: approval
53
+ if event.type == "session.need_approval":
54
+ labels = [b.label for b in (event.buttons or [])] or (event.meta or {}).get(
55
+ "options", []
56
+ )
57
+ if not labels:
58
+ labels = ["Approve", "Reject"]
59
+
60
+ print((event.text or "Choose an option:").strip())
61
+ for i, label in enumerate(labels, 1):
62
+ print(f" {i}. {label}")
63
+
64
+ try:
65
+ ans = await self._readline("Reply with number or label: ")
66
+ by_num = {str(i): label for i, label in enumerate(labels, 1)}
67
+ choice_label = by_num.get(ans, ans).strip()
68
+ approved = choice_label.lower() in {"approve", "approved", "yes", "y", "ok"}
69
+ return {"payload": {"approved": approved, "choice": choice_label}}
70
+ except _NoInlineInput:
71
+ print(
72
+ "\n[console] (no choice captured; will persist a continuation and wait for resume)"
73
+ )
74
+ return None
75
+
76
+ # unreachable
77
+ return None
78
+
79
+ async def _readline(self, prompt: str | None = None) -> str:
80
+ # Print prompt and flush so it’s visible before we block
81
+ if prompt:
82
+ print(prompt, end="", flush=True)
83
+
84
+ loop = asyncio.get_running_loop()
85
+ try:
86
+ line = await loop.run_in_executor(None, sys.stdin.readline)
87
+ except KeyboardInterrupt:
88
+ # User pressed Ctrl+C while we were blocked on input — treat as “no inline input”
89
+ raise _NoInlineInput() from None
90
+
91
+ if line is None:
92
+ # Extremely defensive; run_in_executor should always give a str
93
+ raise _NoInlineInput() from None
94
+
95
+ line = line.rstrip("\n")
96
+ if line == "":
97
+ # Empty (e.g., Ctrl+C causing an immediate return on some terminals, or EOF)
98
+ raise _NoInlineInput() from None
99
+
100
+ return line.strip()
101
+
102
+
103
+ class _NoInlineInput(Exception):
104
+ """Signal to the wait machinery that the adapter should not inline-resume."""
105
+
106
+ pass
@@ -0,0 +1,102 @@
1
+ """
2
+ Simple file-based channel adapter for logging events to local files.
3
+ Channel key format:
4
+ file:<relative_path>
5
+
6
+ This is an inform-only adapter; it does not support receiving messages.
7
+
8
+ Use cases include:
9
+ - Logging events to local files for debugging or auditing.
10
+ - Storing conversation logs in a structured manner.
11
+ """
12
+
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+ import json
16
+ import logging
17
+ from pathlib import Path
18
+
19
+ from aethergraph.contracts.services.channel import ChannelAdapter, OutEvent
20
+
21
+
22
+ @dataclass
23
+ class FileChannelAdapter(ChannelAdapter):
24
+ """
25
+ Simple inform-only file adapter.
26
+
27
+ Channel key format:
28
+ file:<relative_path>
29
+
30
+ Examples:
31
+ file:logs/default.log
32
+ file:runs/exp_01.txt
33
+ """
34
+
35
+ root: Path # base directory where logs will be stored
36
+
37
+ # Capabilities: we mostly care about text; we log meta/rich as JSON if present
38
+ capabilities: set[str] = frozenset({"text", "rich", "file", "buttons"})
39
+
40
+ def __init__(self, root: str | Path):
41
+ self.root = Path(root)
42
+
43
+ def _path_for(self, channel_key: str) -> Path:
44
+ # channel_key = "file:logs/default.log"
45
+ try:
46
+ _, rel = channel_key.split(":", 1)
47
+ except ValueError:
48
+ # fallback if someone passes just "file"
49
+ rel = "logs/default.log"
50
+ rel = rel or "logs/default.log"
51
+ return (self.root / rel).resolve()
52
+
53
+ def _format_line(self, event: OutEvent) -> str:
54
+ ts = datetime.now(timezone.utc).isoformat()
55
+ # base = {
56
+ # "type": event.type,
57
+ # "channel": event.channel,
58
+ # "text": event.text,
59
+ # "meta": event.meta or {},
60
+ # "rich": event.rich or {},
61
+ # }
62
+ # We keep the outer format human-readable, but include structured JSON as needed
63
+ line = f"[{ts}] {event.type}: {event.text or ''}"
64
+ extras: dict = {}
65
+ if event.meta:
66
+ extras["meta"] = event.meta
67
+ if event.rich:
68
+ extras["rich"] = event.rich
69
+ if event.file:
70
+ extras["file"] = {
71
+ "name": event.file.get("filename") or event.file.get("name"),
72
+ "mimetype": event.file.get("mimetype"),
73
+ }
74
+ if event.buttons:
75
+ extras["buttons"] = {
76
+ k: {
77
+ "label": b.label,
78
+ "value": b.value,
79
+ "url": b.url,
80
+ "style": b.style,
81
+ }
82
+ for k, b in event.buttons.items()
83
+ }
84
+
85
+ if extras:
86
+ line += " | " + json.dumps(extras, ensure_ascii=False)
87
+
88
+ return line + "\n"
89
+
90
+ async def send(self, event: OutEvent) -> None:
91
+ path = self._path_for(event.channel)
92
+ path.parent.mkdir(parents=True, exist_ok=True)
93
+ line = self._format_line(event)
94
+
95
+ # Simple sync write is fine for low-volume logging
96
+ try:
97
+ with path.open("a", encoding="utf-8") as f:
98
+ f.write(line)
99
+ except Exception as e:
100
+ # Best-effort; this is an inform-only channel
101
+ logger = logging.getLogger("aethergraph.plugins.channel.adapters.file")
102
+ logger.warning(f"[FileChannelAdapter] Failed to write to {path}: {e}")
@@ -0,0 +1,285 @@
1
+ import os
2
+
3
+ from aethergraph.contracts.services.channel import ChannelAdapter, OutEvent
4
+ from aethergraph.services.continuations.continuation import Correlator
5
+ from aethergraph.utils.optdeps import require
6
+
7
+
8
+ class SlackChannelAdapter(ChannelAdapter):
9
+ capabilities: set[str] = {"text", "buttons", "image", "file", "edit", "stream"}
10
+
11
+ def __init__(self, bot_token: str | None = None):
12
+ """Slack channel adapter for handling Slack events.
13
+ The bot token can be provided via the `SLACK_BOT_TOKEN` environment variable.
14
+ The channel key format is: "slack:team/T:chan/C[:thread/TS]"
15
+ """
16
+
17
+ require(pkg="slack_sdk", extra="slack")
18
+ from slack_sdk.web.async_client import AsyncWebClient
19
+
20
+ self.client = AsyncWebClient(token=bot_token or os.environ["SLACK_BOT_TOKEN"])
21
+ self._first_ts_by_chan: dict[str, str] = {} # cache of first message ts by channel
22
+
23
+ def _render_bar(self, percent: float, width: int = 20) -> str:
24
+ p = max(0.0, min(1.0, float(percent)))
25
+ filled = int(round(p * width))
26
+ return "█" * filled + "░" * (width - filled)
27
+
28
+ def _fmt_eta(self, sec) -> str:
29
+ if sec is None:
30
+ return ""
31
+ try:
32
+ s = int(max(0, float(sec)))
33
+ except Exception:
34
+ return ""
35
+ if s < 60:
36
+ return f"{s}s"
37
+ m, s = divmod(s, 60)
38
+ if m < 60:
39
+ return f"{m}m {s}s"
40
+ h, m = divmod(m, 60)
41
+ return f"{h}h {m}m"
42
+
43
+ @staticmethod
44
+ def _parse(channel_key: str):
45
+ """Parse the channel key into its components.
46
+ E.g., "slack:team/T:chan/C[:thread/TS]" -> {"team": "T", "chan": "C", "thread": "TS"}
47
+ """
48
+ parts = channel_key.split(":")[1:] # drop "slack"
49
+ d = {}
50
+ for p in parts:
51
+ k, v = p.split("/", 1)
52
+ d[k] = v
53
+ return d
54
+
55
+ async def _ensure_thread(self, channel_key: str, seed_text: str | None = None):
56
+ meta = self._parse(channel_key)
57
+ channel = meta["chan"]
58
+ thread_ts = meta.get("thread")
59
+
60
+ if thread_ts:
61
+ return channel, thread_ts
62
+
63
+ cached = self._first_ts_by_chan.get(channel_key)
64
+ if cached:
65
+ return channel, cached
66
+
67
+ # Neutral root; DO NOT consume event.text
68
+ resp = await self.client.chat_postMessage(channel=channel, text="(starting thread)")
69
+ ts = resp.get("ts")
70
+ self._first_ts_by_chan[channel_key] = ts
71
+ return channel, ts
72
+
73
+ async def peek_thread(self, channel_key: str):
74
+ """
75
+ Return the thread_ts currently associated with the channel_key if know,
76
+ without creating a new thread.
77
+ """
78
+ meta = self._parse(channel_key)
79
+ if meta.get("thread"):
80
+ return meta["thread"]
81
+ # fallback to cache if first message ts (created ealier by _ensure_thread)
82
+ return self._first_ts_by_chan.get(channel_key)
83
+
84
+ async def send(self, event: OutEvent) -> dict | None:
85
+ channel, thread_ts = await self._ensure_thread(event.channel)
86
+
87
+ # streaming/upsert: we use chat.update keyed by upsert_key
88
+ if (
89
+ event.type
90
+ in (
91
+ "agent.stream.start",
92
+ "agent.stream.delta",
93
+ "agent.stream.end",
94
+ "agent.message.update",
95
+ )
96
+ and event.upsert_key
97
+ ):
98
+ # stash ts per upsert_key inside thread cache
99
+ key = (event.channel, event.upsert_key)
100
+ ts = self._first_ts_by_chan.get(key)
101
+ if ts is None:
102
+ resp = await self.client.chat_postMessage(
103
+ channel=channel, thread_ts=thread_ts, text=event.text or "…"
104
+ )
105
+ ts = resp.get("ts")
106
+ self._first_ts_by_chan[key] = ts
107
+ else:
108
+ if event.text:
109
+ # In slack, chat.update requires non-empty text for stream updates
110
+ await self.client.chat_update(channel=channel, ts=ts, text=event.text)
111
+ return
112
+
113
+ if event.type in ("session.need_approval", "link.buttons"):
114
+ # Collect up to 5 buttons (Slack max per "actions" block)
115
+ elements = []
116
+ buttons = getattr(event, "buttons", None) or []
117
+ if not buttons:
118
+ # fallback to meta options
119
+ opts = (event.meta or {}).get("options", ["Approve", "Reject"])
120
+ buttons = [
121
+ # mimic button objects;
122
+ type(
123
+ "B",
124
+ (),
125
+ {"label": opts[0], "value": "approve", "style": "primary", "url": None},
126
+ ),
127
+ type(
128
+ "B",
129
+ (),
130
+ {"label": opts[-1], "value": "reject", "style": "danger", "url": None},
131
+ ),
132
+ ]
133
+
134
+ if len(buttons) > 5:
135
+ self._warn("Slack supports max 5 buttons; truncating.")
136
+ buttons = buttons[:5]
137
+
138
+ for i, b in enumerate(buttons[:5]): # Slack: max 5 elements
139
+ btn: dict = {
140
+ "type": "button",
141
+ "text": {"type": "plain_text", "text": b.label, "emoji": True},
142
+ }
143
+
144
+ # Interactive buttons need an action_id; make them unique-ish
145
+ btn["action_id"] = f"ag_button_{i}"
146
+
147
+ # Either a URL button OR a value payload (not both)
148
+ if getattr(b, "url", None):
149
+ btn["url"] = b.url
150
+ else:
151
+ # pack choice + correlators into value for /slack/interact
152
+ value_payload = {
153
+ "choice": getattr(b, "value", None) or b.label,
154
+ }
155
+ # if passing correlators via event.meta
156
+ if event.meta:
157
+ for k in ("run_id", "node_id", "token"):
158
+ if k in event.meta:
159
+ value_payload[k] = event.meta[k]
160
+ import json
161
+
162
+ btn["value"] = json.dumps(value_payload)
163
+
164
+ # Style: only set if valid
165
+ style = getattr(b, "style", None)
166
+ if style in ("primary", "danger"):
167
+ btn["style"] = style
168
+ # else omit (default appearance)
169
+
170
+ elements.append(btn)
171
+
172
+ blocks = [
173
+ {
174
+ "type": "section",
175
+ "text": {"type": "mrkdwn", "text": event.text or "Please approve:"},
176
+ },
177
+ {"type": "actions", "elements": elements},
178
+ ]
179
+
180
+ resp = await self.client.chat_postMessage(
181
+ channel=channel,
182
+ thread_ts=thread_ts,
183
+ text=event.text or "Please approve:",
184
+ blocks=blocks,
185
+ )
186
+ return {
187
+ "correlator": Correlator(
188
+ scheme="slack",
189
+ channel=event.channel,
190
+ thread=thread_ts,
191
+ message=resp.get("ts"), # message ts
192
+ )
193
+ }
194
+
195
+ # file upload (url or bytes)
196
+ if event.type == "file.upload" and event.file:
197
+ if "bytes" in event.file:
198
+ await self.client.files_upload_v2(
199
+ channel=channel,
200
+ thread_ts=thread_ts,
201
+ filename=event.file.get("filename", "file.bin"),
202
+ initial_comment=event.text,
203
+ file=event.file["bytes"],
204
+ )
205
+ return
206
+ if "url" in event.file:
207
+ # fall back to posting a link
208
+ await self.client.chat_postMessage(
209
+ channel=channel,
210
+ thread_ts=thread_ts,
211
+ text=f"{event.text or 'File'}: {event.file['url']}",
212
+ )
213
+ return
214
+
215
+ # progress upsert (single message updated by upsert_key)
216
+ if (
217
+ event.type in ("agent.progress.start", "agent.progress.update", "agent.progress.end")
218
+ and event.upsert_key
219
+ ):
220
+ r = event.rich or {}
221
+ title = r.get("title") or "Working..."
222
+ subtitle = r.get("subtitle") or ""
223
+ total = r.get("total")
224
+ current = r.get("current") or 0
225
+ eta_seconds = r.get("eta_seconds")
226
+
227
+ # compute percent + bar
228
+ p = max(0.0, min(1.0, float(current) / float(total))) if total else 0.0
229
+ bar = self._render_bar(p, 20) if total else ""
230
+ pct_text = f"{int(round(p * 100))}%" if total else ""
231
+ eta_text = self._fmt_eta(eta_seconds)
232
+ header = f"⏳ {title}"
233
+ if event.type == "agent.progress.end":
234
+ header = f"{'✅' if (r.get('success', True)) else '⚠️'} {title}"
235
+ if total:
236
+ bar = self._render_bar(1.0, 20)
237
+ pct_text = "100%"
238
+
239
+ # Build Slack blocks
240
+ blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": f"*{header}*"}}]
241
+ if total:
242
+ blocks.append(
243
+ {"type": "section", "text": {"type": "mrkdwn", "text": f"`{bar}` {pct_text}"}}
244
+ )
245
+ # optional subtitle + ETA
246
+ ctx_tail = " • ".join(
247
+ [t for t in (subtitle, f"ETA {eta_text}" if eta_text else "") if t]
248
+ )
249
+ if ctx_tail:
250
+ blocks.append(
251
+ {"type": "context", "elements": [{"type": "mrkdwn", "text": ctx_tail}]}
252
+ )
253
+
254
+ # Upsert using the same cache dict already in use (keyed by (channel, upsert_key))
255
+ key = (event.channel, event.upsert_key)
256
+ ts = self._first_ts_by_chan.get(key)
257
+ if ts is None:
258
+ resp = await self.client.chat_postMessage(
259
+ channel=channel,
260
+ thread_ts=thread_ts,
261
+ text=f"{title} {pct_text}".strip(),
262
+ blocks=blocks,
263
+ )
264
+ self._first_ts_by_chan[key] = resp.get("ts")
265
+ else:
266
+ await self.client.chat_update(
267
+ channel=channel,
268
+ ts=ts,
269
+ text=f"{title} {pct_text}".strip() or "…",
270
+ blocks=blocks,
271
+ )
272
+ return
273
+
274
+ # default: plain message, include (session.need_input) etc.
275
+ resp = await self.client.chat_postMessage(
276
+ channel=channel, thread_ts=thread_ts, text=event.text or ""
277
+ )
278
+ return {
279
+ "correlator": Correlator(
280
+ scheme="slack",
281
+ channel=event.channel,
282
+ thread=thread_ts,
283
+ message=resp.get("ts"), # message ts
284
+ )
285
+ }