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