superpos-agent-core 0.1.0__tar.gz

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 (51) hide show
  1. superpos_agent_core-0.1.0/PKG-INFO +59 -0
  2. superpos_agent_core-0.1.0/README.md +44 -0
  3. superpos_agent_core-0.1.0/pyproject.toml +44 -0
  4. superpos_agent_core-0.1.0/setup.cfg +4 -0
  5. superpos_agent_core-0.1.0/src/superpos_agent_core/__init__.py +67 -0
  6. superpos_agent_core-0.1.0/src/superpos_agent_core/config.py +157 -0
  7. superpos_agent_core-0.1.0/src/superpos_agent_core/executor.py +174 -0
  8. superpos_agent_core-0.1.0/src/superpos_agent_core/main.py +315 -0
  9. superpos_agent_core-0.1.0/src/superpos_agent_core/module_loader.py +148 -0
  10. superpos_agent_core-0.1.0/src/superpos_agent_core/module_setup.py +202 -0
  11. superpos_agent_core-0.1.0/src/superpos_agent_core/modules/superpos-issues/SKILL.md +174 -0
  12. superpos_agent_core-0.1.0/src/superpos_agent_core/modules/superpos-issues/module.yaml +7 -0
  13. superpos_agent_core-0.1.0/src/superpos_agent_core/modules/superpos-issues/scripts/superpos-issues +269 -0
  14. superpos_agent_core-0.1.0/src/superpos_agent_core/modules/superpos-knowledge/SKILL.md +167 -0
  15. superpos_agent_core-0.1.0/src/superpos_agent_core/modules/superpos-knowledge/module.yaml +10 -0
  16. superpos_agent_core-0.1.0/src/superpos_agent_core/modules/superpos-knowledge/scripts/superpos-knowledge +315 -0
  17. superpos_agent_core-0.1.0/src/superpos_agent_core/modules/superpos-workflows/SKILL.md +401 -0
  18. superpos_agent_core-0.1.0/src/superpos_agent_core/modules/superpos-workflows/module.yaml +7 -0
  19. superpos_agent_core-0.1.0/src/superpos_agent_core/modules/superpos-workflows/scripts/superpos-workflows +333 -0
  20. superpos_agent_core-0.1.0/src/superpos_agent_core/progress_reporter.py +165 -0
  21. superpos_agent_core-0.1.0/src/superpos_agent_core/py.typed +0 -0
  22. superpos_agent_core-0.1.0/src/superpos_agent_core/recent_tasks.py +64 -0
  23. superpos_agent_core-0.1.0/src/superpos_agent_core/redactor.py +44 -0
  24. superpos_agent_core-0.1.0/src/superpos_agent_core/runtime_config.py +84 -0
  25. superpos_agent_core-0.1.0/src/superpos_agent_core/session_store.py +186 -0
  26. superpos_agent_core-0.1.0/src/superpos_agent_core/sub_agent_sync.py +455 -0
  27. superpos_agent_core-0.1.0/src/superpos_agent_core/superpos_client.py +1450 -0
  28. superpos_agent_core-0.1.0/src/superpos_agent_core/superpos_poller.py +483 -0
  29. superpos_agent_core-0.1.0/src/superpos_agent_core/superpos_task.py +276 -0
  30. superpos_agent_core-0.1.0/src/superpos_agent_core/telegram_bot.py +422 -0
  31. superpos_agent_core-0.1.0/src/superpos_agent_core/telegram_gateway.py +316 -0
  32. superpos_agent_core-0.1.0/src/superpos_agent_core/telegram_streamer.py +432 -0
  33. superpos_agent_core-0.1.0/src/superpos_agent_core/worktree_manager.py +159 -0
  34. superpos_agent_core-0.1.0/src/superpos_agent_core.egg-info/PKG-INFO +59 -0
  35. superpos_agent_core-0.1.0/src/superpos_agent_core.egg-info/SOURCES.txt +49 -0
  36. superpos_agent_core-0.1.0/src/superpos_agent_core.egg-info/dependency_links.txt +1 -0
  37. superpos_agent_core-0.1.0/src/superpos_agent_core.egg-info/requires.txt +7 -0
  38. superpos_agent_core-0.1.0/src/superpos_agent_core.egg-info/top_level.txt +1 -0
  39. superpos_agent_core-0.1.0/tests/test_executor_cancel.py +217 -0
  40. superpos_agent_core-0.1.0/tests/test_knowledge_module_script.py +261 -0
  41. superpos_agent_core-0.1.0/tests/test_module_loader_bundled.py +297 -0
  42. superpos_agent_core-0.1.0/tests/test_progress_reporter.py +369 -0
  43. superpos_agent_core-0.1.0/tests/test_session_store.py +217 -0
  44. superpos_agent_core-0.1.0/tests/test_sub_agent_sync.py +443 -0
  45. superpos_agent_core-0.1.0/tests/test_superpos_client_issues.py +388 -0
  46. superpos_agent_core-0.1.0/tests/test_superpos_client_knowledge.py +336 -0
  47. superpos_agent_core-0.1.0/tests/test_superpos_client_surface_fill.py +737 -0
  48. superpos_agent_core-0.1.0/tests/test_superpos_client_workflows.py +428 -0
  49. superpos_agent_core-0.1.0/tests/test_superpos_poller_knowledge.py +151 -0
  50. superpos_agent_core-0.1.0/tests/test_superpos_poller_resync.py +158 -0
  51. superpos_agent_core-0.1.0/tests/test_telegram_streamer.py +114 -0
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: superpos-agent-core
3
+ Version: 0.1.0
4
+ Summary: Shared runtime for Superpos slim agents (Telegram bot, Superpos poller, worktree manager, telegram streamer).
5
+ Author: Superpos
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: python-telegram-bot>=21.0
10
+ Requires-Dist: httpx>=0.27.0
11
+ Requires-Dist: pyyaml>=6.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == "dev"
14
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
15
+
16
+ # superpos-agent-core
17
+
18
+ Shared runtime for the Superpos "slim agent" family — the part that is identical across Claude, Codex, Gemini, Qwen, and any future LLM-CLI-backed agent.
19
+
20
+ ## What lives here
21
+
22
+ - `Executor` protocol + `ExecutionRequest` — the contract every agent implementation must satisfy.
23
+ - `SuperposClient` — REST client for the Superpos backend (tasks, persona, agent lifecycle).
24
+ - `run_superpos_poller` — daemon that polls Superpos for tasks and enqueues them on an `Executor`.
25
+ - `TelegramGateway` + `TelegramStreamer` + `build_telegram_app` / `run_telegram_bot` — Telegram I/O with backpressure, flood-ban handling, message streaming.
26
+ - `WorktreeManager` helpers — per-branch isolation via `git worktree`.
27
+ - `SessionStore`, `RecentTasksLog`, `RuntimeConfig` — persistence and runtime knobs.
28
+ - `redact()` — strip known secret patterns from outbound text.
29
+ - `run_agent()` — orchestrator that wires everything together given a concrete `Executor` factory.
30
+
31
+ ## What does NOT live here
32
+
33
+ - Per-agent executor implementations (`ClaudeExecutor`, `CodexExecutor`, `GeminiExecutor`, …) — they live in each agent's own repo and depend on this package.
34
+ - LLM SDK / CLI installation — each agent's Dockerfile is responsible.
35
+ - Per-agent config (model lists, auth env vars) — subclassed from `BaseConfig`.
36
+
37
+ ## Using it
38
+
39
+ ```python
40
+ from superpos_agent_core import run_agent, BaseConfig, Executor
41
+
42
+ class MyAgentConfig(BaseConfig):
43
+ my_api_key: str = ""
44
+
45
+ class MyExecutor:
46
+ # implements superpos_agent_core.Executor
47
+ ...
48
+
49
+ if __name__ == "__main__":
50
+ import asyncio
51
+ config = MyAgentConfig.from_env(extra={"my_api_key": "MY_API_KEY"})
52
+ asyncio.run(run_agent(
53
+ config=config,
54
+ executor_factory=lambda cfg, runtime, superpos, gateway, persona:
55
+ MyExecutor(cfg, runtime, superpos, gateway, persona=persona),
56
+ ))
57
+ ```
58
+
59
+ See `Slim-Agent-Gemini/` for a complete working example.
@@ -0,0 +1,44 @@
1
+ # superpos-agent-core
2
+
3
+ Shared runtime for the Superpos "slim agent" family — the part that is identical across Claude, Codex, Gemini, Qwen, and any future LLM-CLI-backed agent.
4
+
5
+ ## What lives here
6
+
7
+ - `Executor` protocol + `ExecutionRequest` — the contract every agent implementation must satisfy.
8
+ - `SuperposClient` — REST client for the Superpos backend (tasks, persona, agent lifecycle).
9
+ - `run_superpos_poller` — daemon that polls Superpos for tasks and enqueues them on an `Executor`.
10
+ - `TelegramGateway` + `TelegramStreamer` + `build_telegram_app` / `run_telegram_bot` — Telegram I/O with backpressure, flood-ban handling, message streaming.
11
+ - `WorktreeManager` helpers — per-branch isolation via `git worktree`.
12
+ - `SessionStore`, `RecentTasksLog`, `RuntimeConfig` — persistence and runtime knobs.
13
+ - `redact()` — strip known secret patterns from outbound text.
14
+ - `run_agent()` — orchestrator that wires everything together given a concrete `Executor` factory.
15
+
16
+ ## What does NOT live here
17
+
18
+ - Per-agent executor implementations (`ClaudeExecutor`, `CodexExecutor`, `GeminiExecutor`, …) — they live in each agent's own repo and depend on this package.
19
+ - LLM SDK / CLI installation — each agent's Dockerfile is responsible.
20
+ - Per-agent config (model lists, auth env vars) — subclassed from `BaseConfig`.
21
+
22
+ ## Using it
23
+
24
+ ```python
25
+ from superpos_agent_core import run_agent, BaseConfig, Executor
26
+
27
+ class MyAgentConfig(BaseConfig):
28
+ my_api_key: str = ""
29
+
30
+ class MyExecutor:
31
+ # implements superpos_agent_core.Executor
32
+ ...
33
+
34
+ if __name__ == "__main__":
35
+ import asyncio
36
+ config = MyAgentConfig.from_env(extra={"my_api_key": "MY_API_KEY"})
37
+ asyncio.run(run_agent(
38
+ config=config,
39
+ executor_factory=lambda cfg, runtime, superpos, gateway, persona:
40
+ MyExecutor(cfg, runtime, superpos, gateway, persona=persona),
41
+ ))
42
+ ```
43
+
44
+ See `Slim-Agent-Gemini/` for a complete working example.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "superpos-agent-core"
7
+ version = "0.1.0"
8
+ description = "Shared runtime for Superpos slim agents (Telegram bot, Superpos poller, worktree manager, telegram streamer)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Superpos" }]
13
+ dependencies = [
14
+ "python-telegram-bot>=21.0",
15
+ "httpx>=0.27.0",
16
+ "pyyaml>=6.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8.0",
22
+ "pytest-asyncio>=0.23",
23
+ ]
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.setuptools.package-data]
29
+ superpos_agent_core = [
30
+ "py.typed",
31
+ "modules/**/*.yaml",
32
+ "modules/**/*.md",
33
+ "modules/**/scripts/*",
34
+ ]
35
+
36
+ [tool.ruff]
37
+ line-length = 120
38
+ target-version = "py310"
39
+
40
+ [tool.ruff.lint]
41
+ select = ["E", "F"]
42
+
43
+ [tool.ruff.lint.per-file-ignores]
44
+ "tests/*" = ["F401"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,67 @@
1
+ """Slim-agent core runtime: shared code for every Superpos LLM-backed agent."""
2
+
3
+ from .config import BaseConfig
4
+ from .executor import Executor, ExecutionRequest
5
+ from .main import ExecutorFactory, run_agent, setup_logging
6
+ from .module_loader import (
7
+ bundled_modules_dir,
8
+ collect_mcp_servers,
9
+ discover_modules,
10
+ generate_modules_doc,
11
+ )
12
+ from .module_setup import run_setup as run_module_setup
13
+ from .module_setup import symlink_module_scripts
14
+ from .sub_agent_sync import sync_sub_agents
15
+ from .progress_reporter import report_progress
16
+ from .recent_tasks import RecentTasksLog, TaskSummary
17
+ from .redactor import redact
18
+ from .runtime_config import RuntimeConfig
19
+ from .session_store import SessionStore
20
+ from .superpos_client import SuperposClient
21
+ from .superpos_poller import run_superpos_poller
22
+ from .telegram_bot import build_telegram_app, run_telegram_bot
23
+ from .telegram_gateway import Priority, TelegramGateway
24
+ from .telegram_streamer import TelegramStreamer
25
+ from .worktree_manager import (
26
+ ensure_worktree,
27
+ infer_branch,
28
+ is_git_repo,
29
+ prune_worktrees,
30
+ slot_key,
31
+ worktree_path,
32
+ )
33
+
34
+ __all__ = [
35
+ "BaseConfig",
36
+ "Executor",
37
+ "ExecutionRequest",
38
+ "ExecutorFactory",
39
+ "Priority",
40
+ "RecentTasksLog",
41
+ "RuntimeConfig",
42
+ "SessionStore",
43
+ "SuperposClient",
44
+ "TaskSummary",
45
+ "TelegramGateway",
46
+ "TelegramStreamer",
47
+ "build_telegram_app",
48
+ "bundled_modules_dir",
49
+ "collect_mcp_servers",
50
+ "discover_modules",
51
+ "ensure_worktree",
52
+ "generate_modules_doc",
53
+ "infer_branch",
54
+ "is_git_repo",
55
+ "prune_worktrees",
56
+ "redact",
57
+ "report_progress",
58
+ "run_agent",
59
+ "run_module_setup",
60
+ "run_superpos_poller",
61
+ "run_telegram_bot",
62
+ "setup_logging",
63
+ "slot_key",
64
+ "sync_sub_agents",
65
+ "symlink_module_scripts",
66
+ "worktree_path",
67
+ ]
@@ -0,0 +1,157 @@
1
+ """Base configuration shared across every slim agent.
2
+
3
+ Each per-agent package defines its own subclass adding LLM-specific fields
4
+ (model id, API key env var name, reasoning effort, …). Superpos and Telegram
5
+ fields are universal and live here.
6
+
7
+ Fields are seeded from env at startup and may be mutated at runtime when the
8
+ Superpos ``/agents/me`` endpoint returns server-authoritative values for
9
+ ``hive_id``, ``capabilities``, ``permissions``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from dataclasses import dataclass, field
16
+
17
+
18
+ @dataclass
19
+ class BaseConfig:
20
+ """Universal slim-agent config. Subclass to add per-agent fields."""
21
+
22
+ # ── Superpos ──────────────────────────────────────────────────────
23
+ superpos_base_url: str = ""
24
+ superpos_hive_id: str = ""
25
+ superpos_agent_id: str = ""
26
+ superpos_api_token: str = ""
27
+ superpos_refresh_token: str = ""
28
+ superpos_capabilities: list[str] = field(default_factory=list)
29
+ superpos_permissions: list[str] = field(default_factory=list)
30
+ superpos_poll_interval: int = 5
31
+
32
+ # Knowledge retrieval injection: before dispatching a Superpos task, search
33
+ # the hive knowledge store for entries relevant to the task and prepend them
34
+ # to the prompt so the agent always starts with the hive's memory in context
35
+ # (instead of only seeing it if it happens to call the knowledge CLI).
36
+ superpos_knowledge_inject: bool = True
37
+ superpos_knowledge_inject_limit: int = 5
38
+
39
+ # ── Telegram ──────────────────────────────────────────────────────
40
+ telegram_bot_token: str = ""
41
+ telegram_allowed_users: list[int] = field(default_factory=list)
42
+ telegram_chat_id: str = ""
43
+
44
+ # ── Executor (LLM-agnostic) ───────────────────────────────────────
45
+ executor_kind: str = "generic" # subclasses set this: "claude", "codex", "gemini", …
46
+ executor_working_dir: str = "/workspace"
47
+ executor_worktree_isolation: bool = False
48
+ executor_max_parallel: int = 3
49
+ executor_max_turns: int = 30
50
+
51
+ # Filesystem layout — where this agent stores per-LLM session/config state.
52
+ # Defaults to /home/agent/.<executor_kind> at runtime in __post_init__.
53
+ home_dir: str = ""
54
+
55
+ # Voice transcription (optional — only used if Telegram receives voice notes).
56
+ # Whisper API is the default; OpenAI API key may already be set for other
57
+ # reasons (Codex), but Claude/Gemini agents can set this independently.
58
+ voice_transcribe_api_key: str = ""
59
+
60
+ # Module discovery root. Agents that don't ship modules can leave blank.
61
+ modules_dir: str = ""
62
+
63
+ def __post_init__(self) -> None:
64
+ if not self.home_dir:
65
+ home = os.environ.get("HOME", "/home/agent")
66
+ self.home_dir = os.path.join(home, f".{self.executor_kind}")
67
+ if not self.modules_dir:
68
+ self.modules_dir = os.path.join(
69
+ self.executor_working_dir, f".{self.executor_kind}", "modules"
70
+ )
71
+
72
+ # ── Base env loader. Subclasses call super().from_env() and extend. ──
73
+
74
+ @classmethod
75
+ def _base_env_kwargs(cls) -> dict:
76
+ """Pull universal fields from env vars. Subclasses extend this."""
77
+ allowed = os.environ.get("TELEGRAM_ALLOWED_USERS", "")
78
+ caps = os.environ.get("SUPERPOS_CAPABILITIES", "")
79
+ working_dir = os.environ.get("EXECUTOR_WORKING_DIR") or os.environ.get(
80
+ "WORKING_DIR", "/workspace"
81
+ )
82
+
83
+ isolation_env = os.environ.get("EXECUTOR_WORKTREE_ISOLATION") or os.environ.get(
84
+ "WORKTREE_ISOLATION"
85
+ )
86
+ if isolation_env is not None:
87
+ worktree_isolation = isolation_env.lower() not in ("0", "false", "no")
88
+ else:
89
+ # Auto-enable when the working directory is a git repo
90
+ worktree_isolation = os.path.isdir(os.path.join(working_dir, ".git"))
91
+
92
+ return dict(
93
+ superpos_base_url=os.environ.get("SUPERPOS_BASE_URL", ""),
94
+ superpos_hive_id=os.environ.get("SUPERPOS_HIVE_ID", ""),
95
+ superpos_agent_id=os.environ.get("SUPERPOS_AGENT_ID", ""),
96
+ superpos_api_token=os.environ.get("SUPERPOS_API_TOKEN", ""),
97
+ superpos_refresh_token=os.environ.get("SUPERPOS_REFRESH_TOKEN", ""),
98
+ superpos_capabilities=[c.strip() for c in caps.split(",") if c.strip()],
99
+ superpos_poll_interval=int(os.environ.get("SUPERPOS_POLL_INTERVAL", "5")),
100
+ superpos_knowledge_inject=os.environ.get(
101
+ "SUPERPOS_KNOWLEDGE_INJECT", "true"
102
+ ).lower()
103
+ not in ("0", "false", "no"),
104
+ superpos_knowledge_inject_limit=int(
105
+ os.environ.get("SUPERPOS_KNOWLEDGE_INJECT_LIMIT", "5")
106
+ ),
107
+ telegram_bot_token=os.environ.get("TELEGRAM_BOT_TOKEN", ""),
108
+ telegram_allowed_users=[
109
+ int(u.strip()) for u in allowed.split(",") if u.strip()
110
+ ],
111
+ telegram_chat_id=os.environ.get("TELEGRAM_CHAT_ID", ""),
112
+ executor_working_dir=working_dir,
113
+ executor_worktree_isolation=worktree_isolation,
114
+ executor_max_parallel=int(os.environ.get("EXECUTOR_MAX_PARALLEL", "3")),
115
+ executor_max_turns=int(os.environ.get("EXECUTOR_MAX_TURNS", "30")),
116
+ voice_transcribe_api_key=os.environ.get("VOICE_TRANSCRIBE_API_KEY", "")
117
+ or os.environ.get("OPENAI_API_KEY", ""),
118
+ )
119
+
120
+ @classmethod
121
+ def from_env(cls) -> "BaseConfig":
122
+ return cls(**cls._base_env_kwargs())
123
+
124
+ # ── Properties ────────────────────────────────────────────────────
125
+
126
+ @property
127
+ def superpos_enabled(self) -> bool:
128
+ return bool(
129
+ self.superpos_base_url
130
+ and self.superpos_hive_id
131
+ and self.superpos_agent_id
132
+ and self.superpos_api_token
133
+ )
134
+
135
+ @property
136
+ def telegram_enabled(self) -> bool:
137
+ return bool(self.telegram_bot_token)
138
+
139
+ def has_permission(self, permission: str) -> bool:
140
+ """Check whether the agent has a given permission.
141
+
142
+ Matches exact, ``category:*`` wildcards, and the ``admin:*``
143
+ superwildcard. If permissions are empty (unknown — /me failed
144
+ and env doesn't carry them), returns True so the agent tries the
145
+ call; the server will reject if it truly lacks the right.
146
+ """
147
+ if not self.superpos_permissions:
148
+ return True
149
+ if permission in self.superpos_permissions:
150
+ return True
151
+ if "admin:*" in self.superpos_permissions:
152
+ return True
153
+ if ":" in permission:
154
+ category = permission.split(":", 1)[0]
155
+ if f"{category}:*" in self.superpos_permissions:
156
+ return True
157
+ return False
@@ -0,0 +1,174 @@
1
+ """Executor contract — the seam between core and per-agent implementations.
2
+
3
+ Every concrete agent (Claude, Codex, Gemini, Qwen, …) ships a subclass of
4
+ :class:`Executor` that knows how to drive its specific LLM CLI/SDK. Core
5
+ modules (``superpos_poller``, ``telegram_bot``, ``telegram_streamer``) only
6
+ interact with the abstract surface defined here, so adding a new agent
7
+ means writing one executor — not re-porting the rest of the runtime.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import abc
13
+ import asyncio
14
+ from dataclasses import dataclass
15
+
16
+
17
+ @dataclass
18
+ class ExecutionRequest:
19
+ """A single unit of work routed through the executor queue."""
20
+
21
+ prompt: str
22
+ chat_id: int | str
23
+ source: str # "telegram" | "superpos"
24
+ superpos_task_id: str | None = None
25
+ branch: str | None = None
26
+ image_paths: list[str] | None = None
27
+
28
+
29
+ class Executor(abc.ABC):
30
+ """Abstract base for per-agent LLM executors.
31
+
32
+ Subclasses MUST call ``super().__init__(max_parallel=…)`` so the queue,
33
+ in-flight set, and active counter are initialized. Subclasses then
34
+ own the consumer loop (``run``) and the actual LLM invocation.
35
+ """
36
+
37
+ def __init__(self, max_parallel: int = 1) -> None:
38
+ self.queue: asyncio.Queue[ExecutionRequest] = asyncio.Queue()
39
+ self._in_flight_superpos_tasks: set[str] = set()
40
+ self._max_parallel = max_parallel
41
+ self._active_count = 0
42
+ # Per-chat asyncio.Task tracking for /stop. Subclasses call
43
+ # ``_track_chat_task`` once they have the running task in hand;
44
+ # ``cancel_chat`` walks this map. Keyed by ``str(chat_id)`` so
45
+ # int/str keys interop with Telegram's int chat_ids and Superpos's
46
+ # string ones.
47
+ self._chat_tasks: dict[str, set[asyncio.Task]] = {}
48
+
49
+ # ── Abstract: must be implemented per agent ────────────────────────
50
+
51
+ @abc.abstractmethod
52
+ async def run(self) -> None:
53
+ """Consume the queue forever, dispatching requests to the LLM."""
54
+
55
+ @abc.abstractmethod
56
+ def update_persona(self, prompt: str | None, version: int | None = None) -> None:
57
+ """Replace the agent's persona/system prompt.
58
+
59
+ ``version`` is informational — agents that track persona versions
60
+ (Claude) can persist it; agents that don't (Codex, Gemini) may ignore.
61
+ """
62
+
63
+ @abc.abstractmethod
64
+ def clear_session(self, chat_id: int | str) -> None:
65
+ """Drop any cached conversation state for this chat."""
66
+
67
+ # ── Optional: default implementations agents can override ──────────
68
+
69
+ async def preflight(self) -> None:
70
+ """Verify auth/CLI installation before starting the main loop.
71
+
72
+ Default no-op. Agents should override to fail fast on bad creds.
73
+ """
74
+ return None
75
+
76
+ async def run_background(
77
+ self,
78
+ task_id: str,
79
+ prompt: str,
80
+ task_type: str = "dream",
81
+ timeout_seconds: int = 300,
82
+ ) -> None:
83
+ """Fire-and-forget execution for housekeeping tasks (dream, knowledge_fillin).
84
+
85
+ Default raises NotImplementedError — agents that want background work
86
+ must override.
87
+ """
88
+ raise NotImplementedError(
89
+ f"{type(self).__name__} does not implement background tasks"
90
+ )
91
+
92
+ def cleanup_stale_sessions(self, max_age_hours: int = 24) -> dict[str, int]:
93
+ """Delete LLM-specific stale session artifacts; return stats.
94
+
95
+ Returned dict should contain (at minimum) keys: ``projects``,
96
+ ``session_env``, ``bytes_freed``. Default returns zeros so the
97
+ ``/cleanup`` Telegram command works on agents without a
98
+ per-session disk footprint.
99
+ """
100
+ return {"projects": 0, "session_env": 0, "bytes_freed": 0}
101
+
102
+ # ── Concrete: shared bookkeeping ───────────────────────────────────
103
+
104
+ @property
105
+ def pending(self) -> int:
106
+ return self.queue.qsize()
107
+
108
+ @property
109
+ def is_busy(self) -> bool:
110
+ return self._active_count > 0
111
+
112
+ @property
113
+ def has_free_slots(self) -> bool:
114
+ """True if more concurrent work can be accepted.
115
+
116
+ Uses the in-flight task set rather than ``qsize()`` / ``_active_count``
117
+ because a task can be claimed but waiting for the semaphore —
118
+ ``qsize()`` is 0 then, but the slot is taken.
119
+ """
120
+ return len(self._in_flight_superpos_tasks) < self._max_parallel
121
+
122
+ def add_superpos_task(self, task_id: str) -> None:
123
+ self._in_flight_superpos_tasks.add(task_id)
124
+
125
+ def remove_superpos_task(self, task_id: str) -> None:
126
+ self._in_flight_superpos_tasks.discard(task_id)
127
+
128
+ def has_superpos_task(self, task_id: str) -> bool:
129
+ return task_id in self._in_flight_superpos_tasks
130
+
131
+ # ── /stop support ─────────────────────────────────────────────────
132
+
133
+ def _track_chat_task(
134
+ self, chat_id: int | str, task: asyncio.Task,
135
+ ) -> None:
136
+ """Register an in-flight asyncio.Task so ``cancel_chat`` can find it.
137
+
138
+ Subclasses should call this from ``_execute`` (or wherever the
139
+ per-request worker is spawned) right after they create the task,
140
+ then trust the auto-removal on done. Calling more than once for
141
+ the same chat is fine — a chat with parallel work (e.g. branch-
142
+ scoped tasks) gets a set of tasks tracked.
143
+ """
144
+ key = str(chat_id)
145
+ bucket = self._chat_tasks.setdefault(key, set())
146
+ bucket.add(task)
147
+ task.add_done_callback(lambda _t: self._untrack_chat_task(key, _t))
148
+
149
+ def _untrack_chat_task(self, chat_key: str, task: asyncio.Task) -> None:
150
+ bucket = self._chat_tasks.get(chat_key)
151
+ if not bucket:
152
+ return
153
+ bucket.discard(task)
154
+ if not bucket:
155
+ self._chat_tasks.pop(chat_key, None)
156
+
157
+ def cancel_chat(self, chat_id: int | str) -> int:
158
+ """Cancel every in-flight task tracked for ``chat_id``.
159
+
160
+ Returns the number of tasks signalled (which may be 0 if nothing
161
+ was running). Subclasses that need richer behaviour (kill a
162
+ subprocess, send a Telegram "stopped" message, mark the Superpos
163
+ task as failed) should override and call ``super().cancel_chat``
164
+ to keep the asyncio cancellation path uniform.
165
+ """
166
+ bucket = self._chat_tasks.get(str(chat_id))
167
+ if not bucket:
168
+ return 0
169
+ cancelled = 0
170
+ for task in list(bucket):
171
+ if not task.done():
172
+ task.cancel()
173
+ cancelled += 1
174
+ return cancelled