athanore 0.0.1__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.
athanore/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ from athanore.dispatcher import AgentDispatcher
2
+ from athanore.queue_adapters.base import Queue, QueueAdapter, Task, TaskComment
3
+ from athanore.queue_adapters.json_file import JsonFileAdapter
4
+ from athanore.agent_adapters.base import AgentAdapter, AgentContext, AgentEvent, AgentResult, AgentStatus, EventCallback
5
+ from athanore.agent_adapters.subprocess_adapter import SubprocessAgentAdapter
6
+ from athanore.event_bus import EventBus
7
+
8
+ __all__ = [
9
+ "AgentDispatcher",
10
+ "AgentAdapter",
11
+ "AgentContext",
12
+ "AgentEvent",
13
+ "AgentResult",
14
+ "AgentStatus",
15
+ "EventBus",
16
+ "EventCallback",
17
+ "SubprocessAgentAdapter",
18
+ "JsonFileAdapter",
19
+ "Queue",
20
+ "Task",
21
+ "QueueAdapter",
22
+ "TaskComment",
23
+ ]
@@ -0,0 +1 @@
1
+ # Agent adapters for different agent types
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Any, Protocol
7
+
8
+ if TYPE_CHECKING:
9
+ from athanore.config import RouteConfig
10
+ from athanore.queue_adapters.base import QueueAdapter, Task
11
+
12
+
13
+ @dataclass
14
+ class AgentEvent:
15
+ """A single event emitted by an agent adapter during execution."""
16
+ task_id: str
17
+ event_type: str # "lifecycle", "output", "info"
18
+ data: dict[str, Any]
19
+ timestamp: float = field(default_factory=time.time)
20
+ sequence: int = 0 # set by EventBus (per-task)
21
+ global_sequence: int = -1 # set by EventBus (global stream, -1 = not published globally)
22
+
23
+
24
+ EventCallback = Callable[[AgentEvent], None]
25
+
26
+
27
+ @dataclass
28
+ class AgentContext:
29
+ task: Task
30
+ route: RouteConfig
31
+ queue_adapter: QueueAdapter
32
+ prompt: str
33
+ timeout: int | None = None
34
+ event_callback: EventCallback | None = None
35
+
36
+
37
+ @dataclass
38
+ class AgentResult:
39
+ success: bool
40
+ exit_code: int | None = None
41
+ timed_out: bool = False
42
+ cancelled: bool = False
43
+ error_message: str = ""
44
+
45
+
46
+ @dataclass
47
+ class AgentStatus:
48
+ task_id: str
49
+ started_at: float
50
+ extras: dict[str, Any] = field(default_factory=dict)
51
+
52
+
53
+ class AgentAdapter(Protocol):
54
+ async def run(self, context: AgentContext) -> AgentResult: ...
55
+ def get_status(self) -> dict[str, AgentStatus]: ...
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from athanore.agent_adapters.base import AgentContext, AgentEvent, AgentResult, AgentStatus
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ @dataclass
15
+ class _RunningAgent:
16
+ task_id: str
17
+ process: asyncio.subprocess.Process
18
+ command_str: str = ""
19
+ started_at: float = field(default_factory=time.time)
20
+ extras: dict[str, Any] = field(default_factory=dict)
21
+
22
+
23
+ class SubprocessAgentAdapter:
24
+ """Agent adapter that manages agents as subprocesses.
25
+
26
+ Owns the full lifecycle: spawn, monitor stdout, enforce timeouts, cleanup.
27
+ Subclass and override hook methods to customize behavior.
28
+ """
29
+
30
+ def __init__(self) -> None:
31
+ self._running: dict[str, _RunningAgent] = {}
32
+
33
+ def build_command(self, context: AgentContext) -> list[str]:
34
+ """Build the command to execute.
35
+
36
+ Default: uses context.route.format_command() with task details.
37
+ """
38
+ return context.route.format_command(
39
+ task_id=context.task.id,
40
+ task_name=context.task.name,
41
+ task_url=context.task.url,
42
+ )
43
+
44
+ def process_stdout_line(
45
+ self, line: str, running: _RunningAgent, context: AgentContext
46
+ ) -> None:
47
+ """Handle a single line of stdout. No-op by default."""
48
+ pass
49
+
50
+ def format_spawn_comment(
51
+ self, command_str: str, context: AgentContext
52
+ ) -> str:
53
+ """Format the comment posted when spawning an agent."""
54
+ return f"Spawning agent with command `{command_str}`"
55
+
56
+ def format_exit_comment(
57
+ self, running: _RunningAgent, result: AgentResult, context: AgentContext
58
+ ) -> str:
59
+ """Format the comment posted when an agent exits."""
60
+ if result.timed_out:
61
+ return f"Agent session ended: timed out after {context.timeout}s"
62
+ elif result.cancelled:
63
+ return "Agent session ended: cancelled"
64
+ elif result.exit_code == 0:
65
+ return "Agent session ended normally (exit 0)"
66
+ else:
67
+ return f"Agent session ended: exit code {result.exit_code}: {result.error_message}"
68
+
69
+ def get_agent_extras(self, running: _RunningAgent) -> dict[str, Any]:
70
+ """Return adapter-specific extras for status reporting. Empty by default."""
71
+ return {}
72
+
73
+ @staticmethod
74
+ def _emit(context: AgentContext, event_type: str, data: dict[str, Any]) -> None:
75
+ """Emit an event via context.event_callback, if set."""
76
+ if context.event_callback is not None:
77
+ try:
78
+ context.event_callback(AgentEvent(
79
+ task_id=context.task.id,
80
+ event_type=event_type,
81
+ data=data,
82
+ ))
83
+ except Exception:
84
+ log.warning("Error emitting event for task %s", context.task.id, exc_info=True)
85
+
86
+ async def run(self, context: AgentContext) -> AgentResult:
87
+ """Full agent lifecycle: spawn, monitor, enforce timeout, cleanup."""
88
+ cmd = self.build_command(context)
89
+ command_str = " ".join(cmd)
90
+
91
+ # Post spawn comment
92
+ try:
93
+ comment = self.format_spawn_comment(command_str, context)
94
+ context.queue_adapter.add_comment(context.task.id, comment)
95
+ except Exception:
96
+ log.warning("Failed to add spawn comment for task %s", context.task.id, exc_info=True)
97
+
98
+ log.info("Spawning agent for task %s: %s", context.task.id, cmd)
99
+ process = await asyncio.create_subprocess_exec(
100
+ *cmd,
101
+ stdout=asyncio.subprocess.PIPE,
102
+ )
103
+
104
+ running = _RunningAgent(
105
+ task_id=context.task.id,
106
+ process=process,
107
+ command_str=command_str,
108
+ started_at=time.time(),
109
+ )
110
+ self._running[context.task.id] = running
111
+ log.debug("Slot acquired: task=%s pid=%s", context.task.id, process.pid)
112
+ self._emit(context, "lifecycle", {"status": "spawned", "pid": process.pid})
113
+
114
+ timed_out = False
115
+ cancelled = False
116
+ stdout_task = None
117
+
118
+ async def read_stdout() -> None:
119
+ if process.stdout:
120
+ try:
121
+ async for raw_line in process.stdout:
122
+ line = raw_line.decode(errors="replace").strip()
123
+ try:
124
+ self.process_stdout_line(line, running, context)
125
+ except Exception:
126
+ log.warning("Error processing stdout line for task %s", context.task.id, exc_info=True)
127
+ self._emit(context, "output", {"text": line, "source": "stdout"})
128
+ except asyncio.CancelledError:
129
+ pass
130
+
131
+ try:
132
+ stdout_task = asyncio.create_task(read_stdout())
133
+
134
+ if context.timeout is not None:
135
+ try:
136
+ await asyncio.wait_for(process.wait(), timeout=context.timeout)
137
+ except TimeoutError:
138
+ timed_out = True
139
+ log.warning("Agent for task %s timed out after %d seconds", context.task.id, context.timeout)
140
+ try:
141
+ process.terminate()
142
+ except ProcessLookupError:
143
+ log.debug("Process for task %s already exited before terminate", context.task.id)
144
+ try:
145
+ await asyncio.wait_for(process.wait(), timeout=5)
146
+ except TimeoutError:
147
+ log.warning("Agent for task %s did not terminate gracefully, killing", context.task.id)
148
+ try:
149
+ process.kill()
150
+ except ProcessLookupError:
151
+ log.debug("Process for task %s already exited before kill", context.task.id)
152
+ try:
153
+ await asyncio.wait_for(process.wait(), timeout=10)
154
+ except TimeoutError:
155
+ log.error("Process for task %s did not exit after SIGKILL, forcibly releasing slot", context.task.id)
156
+ else:
157
+ await process.wait()
158
+
159
+ rc = process.returncode
160
+ log.info("Agent for task %s exited with code %s", context.task.id, rc)
161
+
162
+ except asyncio.CancelledError:
163
+ cancelled = True
164
+ log.info("Agent cancelled for task %s, terminating process", context.task.id)
165
+ try:
166
+ process.terminate()
167
+ except ProcessLookupError:
168
+ pass
169
+ try:
170
+ await asyncio.wait_for(process.wait(), timeout=5)
171
+ except TimeoutError:
172
+ try:
173
+ process.kill()
174
+ except ProcessLookupError:
175
+ pass
176
+ try:
177
+ await asyncio.wait_for(process.wait(), timeout=10)
178
+ except TimeoutError:
179
+ log.error("Process for task %s did not exit after SIGKILL, forcibly releasing slot", context.task.id)
180
+
181
+ finally:
182
+ # Clean up stdout reader
183
+ if stdout_task is not None and not stdout_task.done():
184
+ stdout_task.cancel()
185
+ try:
186
+ await stdout_task
187
+ except asyncio.CancelledError:
188
+ pass
189
+ elif stdout_task is not None:
190
+ try:
191
+ await asyncio.wait_for(stdout_task, timeout=5)
192
+ except Exception:
193
+ log.warning("Error awaiting stdout reader for task %s", context.task.id, exc_info=True)
194
+
195
+ # Update extras before building result
196
+ running.extras = self.get_agent_extras(running)
197
+
198
+ rc = process.returncode or 0
199
+ result = AgentResult(
200
+ success=not timed_out and not cancelled and rc == 0,
201
+ exit_code=rc,
202
+ timed_out=timed_out,
203
+ cancelled=cancelled,
204
+ )
205
+
206
+ if timed_out:
207
+ self._emit(context, "lifecycle", {"status": "timeout", "exit_code": rc})
208
+ elif cancelled:
209
+ self._emit(context, "lifecycle", {"status": "cancelled"})
210
+ elif rc == 0:
211
+ self._emit(context, "lifecycle", {"status": "completed", "exit_code": rc})
212
+ else:
213
+ self._emit(context, "lifecycle", {"status": "error", "exit_code": rc})
214
+
215
+ # Post exit comment
216
+ try:
217
+ comment = self.format_exit_comment(running, result, context)
218
+ context.queue_adapter.add_comment(context.task.id, comment)
219
+ except Exception:
220
+ log.warning("Failed to add exit comment for task %s", context.task.id, exc_info=True)
221
+
222
+ self._running.pop(context.task.id, None)
223
+
224
+ return result
225
+
226
+ def get_status(self) -> dict[str, AgentStatus]:
227
+ """Return status for all currently running agents."""
228
+ statuses: dict[str, AgentStatus] = {}
229
+ for task_id, running in self._running.items():
230
+ extras = self.get_agent_extras(running)
231
+ extras["pid"] = running.process.pid
232
+ extras["command"] = running.command_str
233
+ statuses[task_id] = AgentStatus(
234
+ task_id=task_id,
235
+ started_at=running.started_at,
236
+ extras=extras,
237
+ )
238
+ return statuses
athanore/config.py ADDED
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from athanore.agent_adapters.base import AgentAdapter
9
+
10
+
11
+ @dataclass
12
+ class RouteConfig:
13
+ queue_name: str
14
+ command: str = ""
15
+ args: list[str] = field(default_factory=list)
16
+ in_progress_queue: str = "In Progress"
17
+ timeout: int | None = None # timeout in seconds, None = no timeout
18
+ poll_interval: int | None = None # per-queue poll interval in seconds, None = use global
19
+ priority: int | None = None # dispatch priority, lower = higher priority, None = use registration index
20
+ max_retries: int | None = None # max retry attempts, None = use dispatcher default
21
+ dead_letter_queue: str | None = None # queue for tasks that exhaust retries, None = use dispatcher default
22
+ prompt_fn: Callable[[str, str], str] | None = None # (task_id, task_name) -> prompt string
23
+ agent: Any = None # per-route AgentAdapter, None = use default
24
+
25
+ def format_command(
26
+ self,
27
+ task_id: str,
28
+ task_name: str,
29
+ task_url: str = "",
30
+ ) -> list[str]:
31
+ replacements = {
32
+ "{task_id}": task_id,
33
+ "{task_name}": task_name,
34
+ "{task_url}": task_url,
35
+ }
36
+ formatted_args = []
37
+ for arg in self.args:
38
+ for placeholder, value in replacements.items():
39
+ arg = arg.replace(placeholder, value)
40
+ formatted_args.append(arg)
41
+
42
+ cmd = [self.command, *formatted_args] if self.command else list(formatted_args)
43
+
44
+ if self.prompt_fn is not None:
45
+ prompt = self.prompt_fn(task_id, task_name)
46
+ cmd.append(prompt)
47
+
48
+ return cmd