soveren-agent-platform 0.2.0__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.
- soveren_agent_platform/__init__.py +5 -0
- soveren_agent_platform/actions/__init__.py +33 -0
- soveren_agent_platform/actions/contracts.py +108 -0
- soveren_agent_platform/actions/registry.py +21 -0
- soveren_agent_platform/actions/sqlite.py +86 -0
- soveren_agent_platform/actions/store.py +171 -0
- soveren_agent_platform/actions/worker.py +243 -0
- soveren_agent_platform/agent/__init__.py +6 -0
- soveren_agent_platform/agent/contracts.py +22 -0
- soveren_agent_platform/agent/worker.py +118 -0
- soveren_agent_platform/app_api/__init__.py +6 -0
- soveren_agent_platform/app_api/runtime.py +240 -0
- soveren_agent_platform/approvals/__init__.py +6 -0
- soveren_agent_platform/batching/__init__.py +20 -0
- soveren_agent_platform/batching/contracts.py +121 -0
- soveren_agent_platform/batching/rules.py +184 -0
- soveren_agent_platform/batching/sqlite.py +80 -0
- soveren_agent_platform/batching/store.py +253 -0
- soveren_agent_platform/batching/worker.py +274 -0
- soveren_agent_platform/context/__init__.py +33 -0
- soveren_agent_platform/context/builder.py +359 -0
- soveren_agent_platform/context/contracts.py +28 -0
- soveren_agent_platform/context/formatting.py +117 -0
- soveren_agent_platform/context/redaction.py +92 -0
- soveren_agent_platform/cron/__init__.py +19 -0
- soveren_agent_platform/cron/contracts.py +52 -0
- soveren_agent_platform/cron/queue_handler.py +34 -0
- soveren_agent_platform/cron/sqlite.py +64 -0
- soveren_agent_platform/cron/store.py +191 -0
- soveren_agent_platform/cron/worker.py +80 -0
- soveren_agent_platform/decisions/__init__.py +39 -0
- soveren_agent_platform/decisions/dispatcher.py +242 -0
- soveren_agent_platform/decisions/effects.py +45 -0
- soveren_agent_platform/decisions/registry.py +91 -0
- soveren_agent_platform/decisions/sqlite.py +104 -0
- soveren_agent_platform/interfaces/__init__.py +6 -0
- soveren_agent_platform/interfaces/channels.py +25 -0
- soveren_agent_platform/llm/__init__.py +19 -0
- soveren_agent_platform/llm/backends/__init__.py +15 -0
- soveren_agent_platform/llm/backends/openai_compatible.py +72 -0
- soveren_agent_platform/llm/backends/session.py +96 -0
- soveren_agent_platform/llm/contracts.py +39 -0
- soveren_agent_platform/outbound/__init__.py +19 -0
- soveren_agent_platform/outbound/contracts.py +60 -0
- soveren_agent_platform/outbound/registry.py +21 -0
- soveren_agent_platform/outbound/sqlite.py +73 -0
- soveren_agent_platform/outbound/store.py +169 -0
- soveren_agent_platform/outbound/worker.py +101 -0
- soveren_agent_platform/queue/__init__.py +6 -0
- soveren_agent_platform/queue/contracts.py +50 -0
- soveren_agent_platform/queue/durable.py +166 -0
- soveren_agent_platform/queue/sqlite.py +90 -0
- soveren_agent_platform/runs/__init__.py +7 -0
- soveren_agent_platform/runs/contracts.py +26 -0
- soveren_agent_platform/runs/sqlite.py +47 -0
- soveren_agent_platform/runs/store.py +47 -0
- soveren_agent_platform/runtime/__init__.py +24 -0
- soveren_agent_platform/runtime/planner.py +294 -0
- soveren_agent_platform/runtime/worker_loop.py +57 -0
- soveren_agent_platform/sessions/__init__.py +120 -0
- soveren_agent_platform/sessions/backend.py +43 -0
- soveren_agent_platform/sessions/backends/__init__.py +27 -0
- soveren_agent_platform/sessions/backends/codex_app_server.py +462 -0
- soveren_agent_platform/sessions/backends/codex_inspector.py +30 -0
- soveren_agent_platform/sessions/backends/codex_tools.py +142 -0
- soveren_agent_platform/sessions/backends/stub.py +40 -0
- soveren_agent_platform/sessions/backends/tmux.py +171 -0
- soveren_agent_platform/sessions/contracts.py +162 -0
- soveren_agent_platform/sessions/events.py +28 -0
- soveren_agent_platform/sessions/indexer_worker.py +137 -0
- soveren_agent_platform/sessions/inspector_registry.py +45 -0
- soveren_agent_platform/sessions/lifecycle.py +370 -0
- soveren_agent_platform/sessions/mailbox.py +213 -0
- soveren_agent_platform/sessions/mailbox_worker.py +242 -0
- soveren_agent_platform/sessions/registry.py +52 -0
- soveren_agent_platform/sessions/routing.py +328 -0
- soveren_agent_platform/sessions/snapshots.py +162 -0
- soveren_agent_platform/sessions/sqlite.py +255 -0
- soveren_agent_platform/sessions/store.py +86 -0
- soveren_agent_platform/sessions/tools.py +327 -0
- soveren_agent_platform/storage/__init__.py +6 -0
- soveren_agent_platform/storage/bootstrap.py +22 -0
- soveren_agent_platform/storage/migrations/__init__.py +33 -0
- soveren_agent_platform/storage/migrations/platform/001_event_queue.sql +27 -0
- soveren_agent_platform/storage/migrations/platform/002_agent_runs.sql +17 -0
- soveren_agent_platform/storage/migrations/platform/003_cron_jobs.sql +25 -0
- soveren_agent_platform/storage/migrations/platform/004_inbound_batches.sql +39 -0
- soveren_agent_platform/storage/migrations/platform/005_runtime_sessions.sql +50 -0
- soveren_agent_platform/storage/migrations/platform/006_actions_and_outbound.sql +55 -0
- soveren_agent_platform/storage/migrations/platform/007_session_routing.sql +57 -0
- soveren_agent_platform/storage/migrations/runner.py +274 -0
- soveren_agent_platform/storage/sqlite.py +19 -0
- soveren_agent_platform/telegram/__init__.py +25 -0
- soveren_agent_platform/telegram/contracts.py +17 -0
- soveren_agent_platform/telegram/ingress.py +38 -0
- soveren_agent_platform/telegram/ptb.py +223 -0
- soveren_agent_platform-0.2.0.dist-info/METADATA +114 -0
- soveren_agent_platform-0.2.0.dist-info/RECORD +100 -0
- soveren_agent_platform-0.2.0.dist-info/WHEEL +4 -0
- soveren_agent_platform-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Action lifecycle runtime."""
|
|
2
|
+
|
|
3
|
+
from soveren_agent_platform.actions.contracts import ActionExecutionResult, ActionExecutor, ActionRecord, ActionStore
|
|
4
|
+
from soveren_agent_platform.actions.registry import ActionRegistry
|
|
5
|
+
from soveren_agent_platform.actions.sqlite import SQLiteActionStore
|
|
6
|
+
from soveren_agent_platform.actions.store import (
|
|
7
|
+
approve_action,
|
|
8
|
+
deny_action,
|
|
9
|
+
get_action,
|
|
10
|
+
insert_action,
|
|
11
|
+
mark_executed,
|
|
12
|
+
mark_failed,
|
|
13
|
+
mark_retryable,
|
|
14
|
+
)
|
|
15
|
+
from soveren_agent_platform.actions.worker import run_actions_queue_worker, run_actions_worker
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ActionExecutionResult",
|
|
19
|
+
"ActionExecutor",
|
|
20
|
+
"ActionRecord",
|
|
21
|
+
"ActionRegistry",
|
|
22
|
+
"ActionStore",
|
|
23
|
+
"SQLiteActionStore",
|
|
24
|
+
"approve_action",
|
|
25
|
+
"deny_action",
|
|
26
|
+
"get_action",
|
|
27
|
+
"insert_action",
|
|
28
|
+
"mark_executed",
|
|
29
|
+
"mark_failed",
|
|
30
|
+
"mark_retryable",
|
|
31
|
+
"run_actions_queue_worker",
|
|
32
|
+
"run_actions_worker",
|
|
33
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Contracts for action execution."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Literal, Protocol
|
|
6
|
+
|
|
7
|
+
ActionResultStatus = Literal["executed", "queued", "retryable_failure", "permanent_failure"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class ActionRecord:
|
|
12
|
+
id: str
|
|
13
|
+
tenant_id: str
|
|
14
|
+
kind: str
|
|
15
|
+
payload: dict[str, Any]
|
|
16
|
+
status: str
|
|
17
|
+
approval_policy: str
|
|
18
|
+
run_id: str | None = None
|
|
19
|
+
source_id: str | None = None
|
|
20
|
+
source_event_id: str | None = None
|
|
21
|
+
idempotency_key: str | None = None
|
|
22
|
+
approved_by: str | None = None
|
|
23
|
+
last_error: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class ActionExecutionResult:
|
|
28
|
+
result: dict[str, Any] = field(default_factory=dict)
|
|
29
|
+
status: ActionResultStatus = "executed"
|
|
30
|
+
error: str | None = None
|
|
31
|
+
retry_after_s: int | None = None
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def executed(cls, result: dict[str, Any] | None = None) -> "ActionExecutionResult":
|
|
35
|
+
return cls(result=result or {}, status="executed")
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def queued(cls, result: dict[str, Any] | None = None) -> "ActionExecutionResult":
|
|
39
|
+
return cls(result=result or {}, status="queued")
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def retryable_failure(
|
|
43
|
+
cls,
|
|
44
|
+
error: str,
|
|
45
|
+
*,
|
|
46
|
+
retry_after_s: int | None = None,
|
|
47
|
+
result: dict[str, Any] | None = None,
|
|
48
|
+
) -> "ActionExecutionResult":
|
|
49
|
+
return cls(
|
|
50
|
+
result=result or {},
|
|
51
|
+
status="retryable_failure",
|
|
52
|
+
error=error,
|
|
53
|
+
retry_after_s=retry_after_s,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def permanent_failure(
|
|
58
|
+
cls,
|
|
59
|
+
error: str,
|
|
60
|
+
*,
|
|
61
|
+
result: dict[str, Any] | None = None,
|
|
62
|
+
) -> "ActionExecutionResult":
|
|
63
|
+
return cls(result=result or {"error": error}, status="permanent_failure", error=error)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ActionExecutor(Protocol):
|
|
67
|
+
async def execute(self, action: ActionRecord) -> ActionExecutionResult:
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ActionStore(Protocol):
|
|
72
|
+
async def insert(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
tenant_id: str,
|
|
76
|
+
kind: str,
|
|
77
|
+
payload: dict[str, Any],
|
|
78
|
+
run_id: str | None = None,
|
|
79
|
+
approval_policy: str = "manual",
|
|
80
|
+
source_id: str | None = None,
|
|
81
|
+
source_event_id: str | None = None,
|
|
82
|
+
idempotency_key: str | None = None,
|
|
83
|
+
) -> tuple[str, bool]:
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
async def get(self, action_id: str) -> ActionRecord | None:
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
async def approve(self, action_id: str, *, approver_id: str) -> bool:
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
async def deny(self, action_id: str, *, approver_id: str) -> bool:
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
async def mark_executing(self, action_id: str) -> bool:
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
async def mark_queued(self, action_id: str, *, result: dict[str, Any] | None = None) -> None:
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
async def mark_executed(self, action_id: str, *, result: dict[str, Any]) -> None:
|
|
102
|
+
...
|
|
103
|
+
|
|
104
|
+
async def mark_failed(self, action_id: str, *, error: str) -> None:
|
|
105
|
+
...
|
|
106
|
+
|
|
107
|
+
async def mark_retryable(self, action_id: str, *, error: str) -> bool:
|
|
108
|
+
...
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Registry of app-provided action executors."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from soveren_agent_platform.actions.contracts import ActionExecutor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class ActionRegistry:
|
|
11
|
+
executors: dict[str, ActionExecutor] = field(default_factory=dict)
|
|
12
|
+
|
|
13
|
+
def register(self, kind: str, executor: ActionExecutor) -> None:
|
|
14
|
+
self.executors[kind] = executor
|
|
15
|
+
|
|
16
|
+
def get(self, kind: str) -> ActionExecutor:
|
|
17
|
+
try:
|
|
18
|
+
return self.executors[kind]
|
|
19
|
+
except KeyError as exc:
|
|
20
|
+
raise KeyError(f"no action executor registered for kind={kind!r}") from exc
|
|
21
|
+
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""SQLite adapter for action lifecycle storage."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import soveren_agent_platform.actions.store as store
|
|
10
|
+
from soveren_agent_platform.actions.contracts import ActionRecord
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SQLiteActionStore:
|
|
14
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
15
|
+
self.conn = conn
|
|
16
|
+
|
|
17
|
+
async def insert(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
tenant_id: str,
|
|
21
|
+
kind: str,
|
|
22
|
+
payload: dict[str, Any],
|
|
23
|
+
run_id: str | None = None,
|
|
24
|
+
approval_policy: str = "manual",
|
|
25
|
+
source_id: str | None = None,
|
|
26
|
+
source_event_id: str | None = None,
|
|
27
|
+
idempotency_key: str | None = None,
|
|
28
|
+
) -> tuple[str, bool]:
|
|
29
|
+
return await asyncio.to_thread(
|
|
30
|
+
store.insert_action,
|
|
31
|
+
self.conn,
|
|
32
|
+
tenant_id=tenant_id,
|
|
33
|
+
kind=kind,
|
|
34
|
+
payload=payload,
|
|
35
|
+
run_id=run_id,
|
|
36
|
+
approval_policy=approval_policy,
|
|
37
|
+
source_id=source_id,
|
|
38
|
+
source_event_id=source_event_id,
|
|
39
|
+
idempotency_key=idempotency_key,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
async def get(self, action_id: str) -> ActionRecord | None:
|
|
43
|
+
row = await asyncio.to_thread(store.get_action, self.conn, action_id)
|
|
44
|
+
return row_to_action(row) if row is not None else None
|
|
45
|
+
|
|
46
|
+
async def approve(self, action_id: str, *, approver_id: str) -> bool:
|
|
47
|
+
return await asyncio.to_thread(store.approve_action, self.conn, action_id, approver_id=approver_id)
|
|
48
|
+
|
|
49
|
+
async def deny(self, action_id: str, *, approver_id: str) -> bool:
|
|
50
|
+
return await asyncio.to_thread(store.deny_action, self.conn, action_id, approver_id=approver_id)
|
|
51
|
+
|
|
52
|
+
async def mark_executing(self, action_id: str) -> bool:
|
|
53
|
+
return await asyncio.to_thread(store.mark_executing, self.conn, action_id)
|
|
54
|
+
|
|
55
|
+
async def mark_queued(self, action_id: str, *, result: dict[str, Any] | None = None) -> None:
|
|
56
|
+
await asyncio.to_thread(store.mark_queued, self.conn, action_id, result=result)
|
|
57
|
+
|
|
58
|
+
async def mark_executed(self, action_id: str, *, result: dict[str, Any]) -> None:
|
|
59
|
+
await asyncio.to_thread(store.mark_executed, self.conn, action_id, result=result)
|
|
60
|
+
|
|
61
|
+
async def mark_failed(self, action_id: str, *, error: str) -> None:
|
|
62
|
+
await asyncio.to_thread(store.mark_failed, self.conn, action_id, error=error)
|
|
63
|
+
|
|
64
|
+
async def mark_retryable(self, action_id: str, *, error: str) -> bool:
|
|
65
|
+
return await asyncio.to_thread(store.mark_retryable, self.conn, action_id, error=error)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def row_to_action(row: sqlite3.Row) -> ActionRecord:
|
|
69
|
+
try:
|
|
70
|
+
payload = json.loads(row["payload_json"]) if row["payload_json"] else {}
|
|
71
|
+
except Exception:
|
|
72
|
+
payload = {}
|
|
73
|
+
return ActionRecord(
|
|
74
|
+
id=row["id"],
|
|
75
|
+
tenant_id=row["tenant_id"],
|
|
76
|
+
run_id=row["run_id"],
|
|
77
|
+
kind=row["kind"],
|
|
78
|
+
payload=payload,
|
|
79
|
+
status=row["status"],
|
|
80
|
+
approval_policy=row["approval_policy"],
|
|
81
|
+
source_id=row["source_id"],
|
|
82
|
+
source_event_id=row["source_event_id"],
|
|
83
|
+
idempotency_key=row["idempotency_key"],
|
|
84
|
+
approved_by=row["approved_by"],
|
|
85
|
+
last_error=row["last_error"],
|
|
86
|
+
)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""SQLite store for generic actions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _now() -> int:
|
|
12
|
+
return int(time.time())
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def insert_action(
|
|
16
|
+
conn: sqlite3.Connection,
|
|
17
|
+
*,
|
|
18
|
+
tenant_id: str,
|
|
19
|
+
kind: str,
|
|
20
|
+
payload: dict[str, Any],
|
|
21
|
+
run_id: str | None = None,
|
|
22
|
+
approval_policy: str = "manual",
|
|
23
|
+
source_id: str | None = None,
|
|
24
|
+
source_event_id: str | None = None,
|
|
25
|
+
idempotency_key: str | None = None,
|
|
26
|
+
now: int | None = None,
|
|
27
|
+
) -> tuple[str, bool]:
|
|
28
|
+
"""Create an action. Return `(action_id, created)`."""
|
|
29
|
+
if idempotency_key is not None:
|
|
30
|
+
existing = conn.execute(
|
|
31
|
+
"SELECT id FROM actions WHERE idempotency_key = ?",
|
|
32
|
+
(idempotency_key,),
|
|
33
|
+
).fetchone()
|
|
34
|
+
if existing is not None:
|
|
35
|
+
return existing["id"], False
|
|
36
|
+
now = now if now is not None else _now()
|
|
37
|
+
action_id = "act_" + uuid.uuid4().hex
|
|
38
|
+
status = "approved" if approval_policy == "auto" else "pending"
|
|
39
|
+
conn.execute(
|
|
40
|
+
"INSERT INTO actions"
|
|
41
|
+
" (id, tenant_id, run_id, kind, payload_json, status, approval_policy,"
|
|
42
|
+
" source_id, source_event_id, idempotency_key, created_at, updated_at)"
|
|
43
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
44
|
+
(
|
|
45
|
+
action_id,
|
|
46
|
+
tenant_id,
|
|
47
|
+
run_id,
|
|
48
|
+
kind,
|
|
49
|
+
json.dumps(payload, ensure_ascii=False),
|
|
50
|
+
status,
|
|
51
|
+
approval_policy,
|
|
52
|
+
source_id,
|
|
53
|
+
source_event_id,
|
|
54
|
+
idempotency_key,
|
|
55
|
+
now,
|
|
56
|
+
now,
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
return action_id, True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_action(conn: sqlite3.Connection, action_id: str) -> sqlite3.Row | None:
|
|
63
|
+
return conn.execute("SELECT * FROM actions WHERE id = ?", (action_id,)).fetchone()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def approve_action(
|
|
67
|
+
conn: sqlite3.Connection,
|
|
68
|
+
action_id: str,
|
|
69
|
+
*,
|
|
70
|
+
approver_id: str,
|
|
71
|
+
now: int | None = None,
|
|
72
|
+
) -> bool:
|
|
73
|
+
now = now if now is not None else _now()
|
|
74
|
+
cur = conn.execute(
|
|
75
|
+
"UPDATE actions"
|
|
76
|
+
" SET status = 'approved', approved_by = ?, approved_at = ?, updated_at = ?"
|
|
77
|
+
" WHERE id = ? AND status = 'pending'",
|
|
78
|
+
(approver_id, now, now, action_id),
|
|
79
|
+
)
|
|
80
|
+
return cur.rowcount > 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def deny_action(
|
|
84
|
+
conn: sqlite3.Connection,
|
|
85
|
+
action_id: str,
|
|
86
|
+
*,
|
|
87
|
+
approver_id: str,
|
|
88
|
+
now: int | None = None,
|
|
89
|
+
) -> bool:
|
|
90
|
+
now = now if now is not None else _now()
|
|
91
|
+
cur = conn.execute(
|
|
92
|
+
"UPDATE actions"
|
|
93
|
+
" SET status = 'denied', approved_by = ?, approved_at = ?, updated_at = ?"
|
|
94
|
+
" WHERE id = ? AND status = 'pending'",
|
|
95
|
+
(approver_id, now, now, action_id),
|
|
96
|
+
)
|
|
97
|
+
return cur.rowcount > 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def mark_executing(conn: sqlite3.Connection, action_id: str, *, now: int | None = None) -> bool:
|
|
101
|
+
now = now if now is not None else _now()
|
|
102
|
+
cur = conn.execute(
|
|
103
|
+
"UPDATE actions SET status = 'executing', updated_at = ?"
|
|
104
|
+
" WHERE id = ? AND status IN ('approved','queued')",
|
|
105
|
+
(now, action_id),
|
|
106
|
+
)
|
|
107
|
+
return cur.rowcount > 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def mark_queued(
|
|
111
|
+
conn: sqlite3.Connection,
|
|
112
|
+
action_id: str,
|
|
113
|
+
*,
|
|
114
|
+
result: dict[str, Any] | None = None,
|
|
115
|
+
now: int | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
now = now if now is not None else _now()
|
|
118
|
+
conn.execute(
|
|
119
|
+
"UPDATE actions SET status = 'queued', result_json = ?, updated_at = ?"
|
|
120
|
+
" WHERE id = ? AND status IN ('approved','queued','executing')",
|
|
121
|
+
(json.dumps(result or {}, ensure_ascii=False), now, action_id),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def mark_executed(
|
|
126
|
+
conn: sqlite3.Connection,
|
|
127
|
+
action_id: str,
|
|
128
|
+
*,
|
|
129
|
+
result: dict[str, Any],
|
|
130
|
+
now: int | None = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
now = now if now is not None else _now()
|
|
133
|
+
conn.execute(
|
|
134
|
+
"UPDATE actions"
|
|
135
|
+
" SET status = 'executed', executed_at = ?, result_json = ?, updated_at = ?"
|
|
136
|
+
" WHERE id = ?",
|
|
137
|
+
(now, json.dumps(result, ensure_ascii=False), now, action_id),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def mark_failed(
|
|
142
|
+
conn: sqlite3.Connection,
|
|
143
|
+
action_id: str,
|
|
144
|
+
*,
|
|
145
|
+
error: str,
|
|
146
|
+
now: int | None = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
now = now if now is not None else _now()
|
|
149
|
+
conn.execute(
|
|
150
|
+
"UPDATE actions"
|
|
151
|
+
" SET status = 'failed', last_error = ?, result_json = ?, updated_at = ?"
|
|
152
|
+
" WHERE id = ?",
|
|
153
|
+
(error[:500], json.dumps({"error": error}, ensure_ascii=False), now, action_id),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def mark_retryable(
|
|
158
|
+
conn: sqlite3.Connection,
|
|
159
|
+
action_id: str,
|
|
160
|
+
*,
|
|
161
|
+
error: str,
|
|
162
|
+
now: int | None = None,
|
|
163
|
+
) -> bool:
|
|
164
|
+
now = now if now is not None else _now()
|
|
165
|
+
cur = conn.execute(
|
|
166
|
+
"UPDATE actions"
|
|
167
|
+
" SET status = 'queued', last_error = ?, updated_at = ?"
|
|
168
|
+
" WHERE id = ? AND status = 'executing'",
|
|
169
|
+
(error[:500], now, action_id),
|
|
170
|
+
)
|
|
171
|
+
return cur.rowcount > 0
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Worker for generic platform actions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import socket
|
|
7
|
+
import sqlite3
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from soveren_agent_platform.actions.contracts import ActionExecutionResult, ActionStore
|
|
12
|
+
from soveren_agent_platform.actions.registry import ActionRegistry
|
|
13
|
+
from soveren_agent_platform.actions.sqlite import SQLiteActionStore
|
|
14
|
+
from soveren_agent_platform.queue.contracts import DurableQueue, QueueEvent
|
|
15
|
+
from soveren_agent_platform.queue.sqlite import SQLiteEventQueue, row_to_queue_event
|
|
16
|
+
from soveren_agent_platform.runtime.worker_loop import PollingWorkerConfig, run_polling_worker
|
|
17
|
+
from soveren_agent_platform.storage.sqlite import open_sqlite
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
LEASE_SECONDS = 60
|
|
22
|
+
BATCH_SIZE = 5
|
|
23
|
+
IDLE_INITIAL_S = 1.0
|
|
24
|
+
IDLE_MAX_S = 10.0
|
|
25
|
+
RETRY_BACKOFF_S = 30
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def lease_owner() -> str:
|
|
29
|
+
return f"{socket.gethostname()}/actions"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def run_actions_worker(
|
|
33
|
+
db_path: Path,
|
|
34
|
+
stop_event: asyncio.Event,
|
|
35
|
+
*,
|
|
36
|
+
registry: ActionRegistry,
|
|
37
|
+
recipient: str = "actions",
|
|
38
|
+
batch_size: int = BATCH_SIZE,
|
|
39
|
+
lease_seconds: int = LEASE_SECONDS,
|
|
40
|
+
retry_backoff_s: int = RETRY_BACKOFF_S,
|
|
41
|
+
idle_initial_s: float = IDLE_INITIAL_S,
|
|
42
|
+
idle_max_s: float = IDLE_MAX_S,
|
|
43
|
+
) -> None:
|
|
44
|
+
conn = open_sqlite(db_path)
|
|
45
|
+
try:
|
|
46
|
+
await run_actions_queue_worker(
|
|
47
|
+
SQLiteActionStore(conn),
|
|
48
|
+
SQLiteEventQueue(conn),
|
|
49
|
+
stop_event,
|
|
50
|
+
registry=registry,
|
|
51
|
+
recipient=recipient,
|
|
52
|
+
batch_size=batch_size,
|
|
53
|
+
lease_seconds=lease_seconds,
|
|
54
|
+
retry_backoff_s=retry_backoff_s,
|
|
55
|
+
idle_initial_s=idle_initial_s,
|
|
56
|
+
idle_max_s=idle_max_s,
|
|
57
|
+
)
|
|
58
|
+
finally:
|
|
59
|
+
conn.close()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def run_actions_queue_worker(
|
|
63
|
+
action_store: ActionStore,
|
|
64
|
+
queue: DurableQueue,
|
|
65
|
+
stop_event: asyncio.Event,
|
|
66
|
+
*,
|
|
67
|
+
registry: ActionRegistry,
|
|
68
|
+
recipient: str = "actions",
|
|
69
|
+
batch_size: int = BATCH_SIZE,
|
|
70
|
+
lease_seconds: int = LEASE_SECONDS,
|
|
71
|
+
retry_backoff_s: int = RETRY_BACKOFF_S,
|
|
72
|
+
idle_initial_s: float = IDLE_INITIAL_S,
|
|
73
|
+
idle_max_s: float = IDLE_MAX_S,
|
|
74
|
+
) -> None:
|
|
75
|
+
owner = lease_owner()
|
|
76
|
+
await run_polling_worker(
|
|
77
|
+
stop_event,
|
|
78
|
+
config=PollingWorkerConfig(
|
|
79
|
+
name=f"actions:{recipient}",
|
|
80
|
+
idle_initial_s=idle_initial_s,
|
|
81
|
+
idle_max_s=idle_max_s,
|
|
82
|
+
),
|
|
83
|
+
claim=lambda: queue.claim_due(
|
|
84
|
+
recipient=recipient,
|
|
85
|
+
limit=batch_size,
|
|
86
|
+
lease_owner=owner,
|
|
87
|
+
lease_seconds=lease_seconds,
|
|
88
|
+
),
|
|
89
|
+
process=lambda event: process_action_queue_event(
|
|
90
|
+
action_store,
|
|
91
|
+
event,
|
|
92
|
+
registry=registry,
|
|
93
|
+
queue=queue,
|
|
94
|
+
retry_backoff_s=retry_backoff_s,
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def process_action_event(
|
|
100
|
+
conn: sqlite3.Connection,
|
|
101
|
+
row: sqlite3.Row,
|
|
102
|
+
*,
|
|
103
|
+
registry: ActionRegistry,
|
|
104
|
+
retry_backoff_s: int = RETRY_BACKOFF_S,
|
|
105
|
+
) -> None:
|
|
106
|
+
await process_action_queue_event(
|
|
107
|
+
SQLiteActionStore(conn),
|
|
108
|
+
row_to_queue_event(row),
|
|
109
|
+
registry=registry,
|
|
110
|
+
queue=SQLiteEventQueue(conn),
|
|
111
|
+
retry_backoff_s=retry_backoff_s,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def process_action_queue_event(
|
|
116
|
+
action_store: ActionStore,
|
|
117
|
+
event: QueueEvent,
|
|
118
|
+
*,
|
|
119
|
+
registry: ActionRegistry,
|
|
120
|
+
queue: DurableQueue,
|
|
121
|
+
retry_backoff_s: int = RETRY_BACKOFF_S,
|
|
122
|
+
) -> None:
|
|
123
|
+
event_id = event.id
|
|
124
|
+
try:
|
|
125
|
+
action_id = str(event.payload["action_id"])
|
|
126
|
+
except KeyError as exc:
|
|
127
|
+
await queue.mark_retry(
|
|
128
|
+
event_id,
|
|
129
|
+
run_after=int(time.time()) + RETRY_BACKOFF_S,
|
|
130
|
+
last_error=f"bad action event payload: {exc}",
|
|
131
|
+
)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
action = await action_store.get(action_id)
|
|
135
|
+
if action is None:
|
|
136
|
+
log.warning("action %s not found, dropping event %s", action_id, event_id)
|
|
137
|
+
await queue.mark_done(event_id)
|
|
138
|
+
return
|
|
139
|
+
if action.status in ("executed", "failed", "denied", "cancelled"):
|
|
140
|
+
await queue.mark_done(event_id)
|
|
141
|
+
return
|
|
142
|
+
if action.status not in ("approved", "queued"):
|
|
143
|
+
log.info("action %s status=%s is not executable yet", action_id, action.status)
|
|
144
|
+
await queue.mark_done(event_id)
|
|
145
|
+
return
|
|
146
|
+
if not await action_store.mark_executing(action_id):
|
|
147
|
+
await queue.mark_done(event_id)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
executor = registry.get(action.kind)
|
|
152
|
+
refreshed = await action_store.get(action_id)
|
|
153
|
+
if refreshed is None:
|
|
154
|
+
raise RuntimeError(f"action disappeared during execution: {action_id}")
|
|
155
|
+
result = await executor.execute(refreshed)
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
err = f"{type(exc).__name__}: {exc}"
|
|
158
|
+
log.exception("action execution failed id=%s", action_id)
|
|
159
|
+
await _retry_action(
|
|
160
|
+
action_store,
|
|
161
|
+
queue,
|
|
162
|
+
event_id=event_id,
|
|
163
|
+
action_id=action_id,
|
|
164
|
+
error=err,
|
|
165
|
+
retry_after_s=retry_backoff_s,
|
|
166
|
+
)
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
await _apply_action_result(
|
|
170
|
+
action_store,
|
|
171
|
+
queue,
|
|
172
|
+
event_id=event_id,
|
|
173
|
+
action_id=action_id,
|
|
174
|
+
result=result,
|
|
175
|
+
retry_backoff_s=retry_backoff_s,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def _apply_action_result(
|
|
180
|
+
action_store: ActionStore,
|
|
181
|
+
queue: DurableQueue,
|
|
182
|
+
*,
|
|
183
|
+
event_id: str,
|
|
184
|
+
action_id: str,
|
|
185
|
+
result: ActionExecutionResult,
|
|
186
|
+
retry_backoff_s: int,
|
|
187
|
+
) -> None:
|
|
188
|
+
if result.status == "queued":
|
|
189
|
+
await action_store.mark_queued(action_id, result=result.result)
|
|
190
|
+
await queue.mark_done(event_id)
|
|
191
|
+
return
|
|
192
|
+
if result.status == "executed":
|
|
193
|
+
await action_store.mark_executed(action_id, result=result.result)
|
|
194
|
+
await queue.mark_done(event_id)
|
|
195
|
+
return
|
|
196
|
+
if result.status == "permanent_failure":
|
|
197
|
+
await action_store.mark_failed(action_id, error=result.error or "action failed permanently")
|
|
198
|
+
await queue.mark_done(event_id)
|
|
199
|
+
return
|
|
200
|
+
if result.status == "retryable_failure":
|
|
201
|
+
await _retry_action(
|
|
202
|
+
action_store,
|
|
203
|
+
queue,
|
|
204
|
+
event_id=event_id,
|
|
205
|
+
action_id=action_id,
|
|
206
|
+
error=result.error or "action failed transiently",
|
|
207
|
+
retry_after_s=result.retry_after_s if result.retry_after_s is not None else retry_backoff_s,
|
|
208
|
+
)
|
|
209
|
+
return
|
|
210
|
+
await _retry_action(
|
|
211
|
+
action_store,
|
|
212
|
+
queue,
|
|
213
|
+
event_id=event_id,
|
|
214
|
+
action_id=action_id,
|
|
215
|
+
error=f"unsupported action result status: {result.status!r}",
|
|
216
|
+
retry_after_s=retry_backoff_s,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def _retry_action(
|
|
221
|
+
action_store: ActionStore,
|
|
222
|
+
queue: DurableQueue,
|
|
223
|
+
*,
|
|
224
|
+
event_id: str,
|
|
225
|
+
action_id: str,
|
|
226
|
+
error: str,
|
|
227
|
+
retry_after_s: int,
|
|
228
|
+
) -> None:
|
|
229
|
+
if not await action_store.mark_retryable(action_id, error=error):
|
|
230
|
+
current = await action_store.get(action_id)
|
|
231
|
+
if current is None or current.status in ("executed", "failed", "denied", "cancelled"):
|
|
232
|
+
await queue.mark_done(event_id)
|
|
233
|
+
return
|
|
234
|
+
raise RuntimeError(
|
|
235
|
+
f"action {action_id} could not be moved to retryable state from {current.status!r}"
|
|
236
|
+
)
|
|
237
|
+
queue_status = await queue.mark_retry(
|
|
238
|
+
event_id,
|
|
239
|
+
run_after=int(time.time()) + retry_after_s,
|
|
240
|
+
last_error=error,
|
|
241
|
+
)
|
|
242
|
+
if queue_status == "dead_letter":
|
|
243
|
+
await action_store.mark_failed(action_id, error=error)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Agent runtime module: queue events in, agent handlers out."""
|
|
2
|
+
|
|
3
|
+
from soveren_agent_platform.agent.contracts import AgentEvent, AgentHandler
|
|
4
|
+
from soveren_agent_platform.agent.worker import run_agent_queue_worker, run_agent_worker
|
|
5
|
+
|
|
6
|
+
__all__ = ["AgentEvent", "AgentHandler", "run_agent_queue_worker", "run_agent_worker"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Contracts for the platform agent runtime."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class AgentEvent:
|
|
10
|
+
id: str
|
|
11
|
+
tenant_id: str
|
|
12
|
+
recipient: str
|
|
13
|
+
message_type: str
|
|
14
|
+
payload: dict[str, Any]
|
|
15
|
+
correlation_id: str | None = None
|
|
16
|
+
causation_id: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentHandler(Protocol):
|
|
20
|
+
async def handle(self, event: AgentEvent) -> None:
|
|
21
|
+
...
|
|
22
|
+
|