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 +23 -0
- athanore/agent_adapters/__init__.py +1 -0
- athanore/agent_adapters/base.py +55 -0
- athanore/agent_adapters/subprocess_adapter.py +238 -0
- athanore/config.py +48 -0
- athanore/dashboard.py +2382 -0
- athanore/dispatcher.py +212 -0
- athanore/event_bus.py +171 -0
- athanore/http_api.py +539 -0
- athanore/queue_adapters/__init__.py +1 -0
- athanore/queue_adapters/base.py +74 -0
- athanore/queue_adapters/json_file.py +189 -0
- athanore/queue_operations.py +34 -0
- athanore/route_operations.py +68 -0
- athanore/router.py +576 -0
- athanore/task_operations.py +77 -0
- athanore-0.0.1.dist-info/METADATA +437 -0
- athanore-0.0.1.dist-info/RECORD +20 -0
- athanore-0.0.1.dist-info/WHEEL +4 -0
- athanore-0.0.1.dist-info/licenses/LICENSE +21 -0
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
|