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.
Files changed (100) hide show
  1. soveren_agent_platform/__init__.py +5 -0
  2. soveren_agent_platform/actions/__init__.py +33 -0
  3. soveren_agent_platform/actions/contracts.py +108 -0
  4. soveren_agent_platform/actions/registry.py +21 -0
  5. soveren_agent_platform/actions/sqlite.py +86 -0
  6. soveren_agent_platform/actions/store.py +171 -0
  7. soveren_agent_platform/actions/worker.py +243 -0
  8. soveren_agent_platform/agent/__init__.py +6 -0
  9. soveren_agent_platform/agent/contracts.py +22 -0
  10. soveren_agent_platform/agent/worker.py +118 -0
  11. soveren_agent_platform/app_api/__init__.py +6 -0
  12. soveren_agent_platform/app_api/runtime.py +240 -0
  13. soveren_agent_platform/approvals/__init__.py +6 -0
  14. soveren_agent_platform/batching/__init__.py +20 -0
  15. soveren_agent_platform/batching/contracts.py +121 -0
  16. soveren_agent_platform/batching/rules.py +184 -0
  17. soveren_agent_platform/batching/sqlite.py +80 -0
  18. soveren_agent_platform/batching/store.py +253 -0
  19. soveren_agent_platform/batching/worker.py +274 -0
  20. soveren_agent_platform/context/__init__.py +33 -0
  21. soveren_agent_platform/context/builder.py +359 -0
  22. soveren_agent_platform/context/contracts.py +28 -0
  23. soveren_agent_platform/context/formatting.py +117 -0
  24. soveren_agent_platform/context/redaction.py +92 -0
  25. soveren_agent_platform/cron/__init__.py +19 -0
  26. soveren_agent_platform/cron/contracts.py +52 -0
  27. soveren_agent_platform/cron/queue_handler.py +34 -0
  28. soveren_agent_platform/cron/sqlite.py +64 -0
  29. soveren_agent_platform/cron/store.py +191 -0
  30. soveren_agent_platform/cron/worker.py +80 -0
  31. soveren_agent_platform/decisions/__init__.py +39 -0
  32. soveren_agent_platform/decisions/dispatcher.py +242 -0
  33. soveren_agent_platform/decisions/effects.py +45 -0
  34. soveren_agent_platform/decisions/registry.py +91 -0
  35. soveren_agent_platform/decisions/sqlite.py +104 -0
  36. soveren_agent_platform/interfaces/__init__.py +6 -0
  37. soveren_agent_platform/interfaces/channels.py +25 -0
  38. soveren_agent_platform/llm/__init__.py +19 -0
  39. soveren_agent_platform/llm/backends/__init__.py +15 -0
  40. soveren_agent_platform/llm/backends/openai_compatible.py +72 -0
  41. soveren_agent_platform/llm/backends/session.py +96 -0
  42. soveren_agent_platform/llm/contracts.py +39 -0
  43. soveren_agent_platform/outbound/__init__.py +19 -0
  44. soveren_agent_platform/outbound/contracts.py +60 -0
  45. soveren_agent_platform/outbound/registry.py +21 -0
  46. soveren_agent_platform/outbound/sqlite.py +73 -0
  47. soveren_agent_platform/outbound/store.py +169 -0
  48. soveren_agent_platform/outbound/worker.py +101 -0
  49. soveren_agent_platform/queue/__init__.py +6 -0
  50. soveren_agent_platform/queue/contracts.py +50 -0
  51. soveren_agent_platform/queue/durable.py +166 -0
  52. soveren_agent_platform/queue/sqlite.py +90 -0
  53. soveren_agent_platform/runs/__init__.py +7 -0
  54. soveren_agent_platform/runs/contracts.py +26 -0
  55. soveren_agent_platform/runs/sqlite.py +47 -0
  56. soveren_agent_platform/runs/store.py +47 -0
  57. soveren_agent_platform/runtime/__init__.py +24 -0
  58. soveren_agent_platform/runtime/planner.py +294 -0
  59. soveren_agent_platform/runtime/worker_loop.py +57 -0
  60. soveren_agent_platform/sessions/__init__.py +120 -0
  61. soveren_agent_platform/sessions/backend.py +43 -0
  62. soveren_agent_platform/sessions/backends/__init__.py +27 -0
  63. soveren_agent_platform/sessions/backends/codex_app_server.py +462 -0
  64. soveren_agent_platform/sessions/backends/codex_inspector.py +30 -0
  65. soveren_agent_platform/sessions/backends/codex_tools.py +142 -0
  66. soveren_agent_platform/sessions/backends/stub.py +40 -0
  67. soveren_agent_platform/sessions/backends/tmux.py +171 -0
  68. soveren_agent_platform/sessions/contracts.py +162 -0
  69. soveren_agent_platform/sessions/events.py +28 -0
  70. soveren_agent_platform/sessions/indexer_worker.py +137 -0
  71. soveren_agent_platform/sessions/inspector_registry.py +45 -0
  72. soveren_agent_platform/sessions/lifecycle.py +370 -0
  73. soveren_agent_platform/sessions/mailbox.py +213 -0
  74. soveren_agent_platform/sessions/mailbox_worker.py +242 -0
  75. soveren_agent_platform/sessions/registry.py +52 -0
  76. soveren_agent_platform/sessions/routing.py +328 -0
  77. soveren_agent_platform/sessions/snapshots.py +162 -0
  78. soveren_agent_platform/sessions/sqlite.py +255 -0
  79. soveren_agent_platform/sessions/store.py +86 -0
  80. soveren_agent_platform/sessions/tools.py +327 -0
  81. soveren_agent_platform/storage/__init__.py +6 -0
  82. soveren_agent_platform/storage/bootstrap.py +22 -0
  83. soveren_agent_platform/storage/migrations/__init__.py +33 -0
  84. soveren_agent_platform/storage/migrations/platform/001_event_queue.sql +27 -0
  85. soveren_agent_platform/storage/migrations/platform/002_agent_runs.sql +17 -0
  86. soveren_agent_platform/storage/migrations/platform/003_cron_jobs.sql +25 -0
  87. soveren_agent_platform/storage/migrations/platform/004_inbound_batches.sql +39 -0
  88. soveren_agent_platform/storage/migrations/platform/005_runtime_sessions.sql +50 -0
  89. soveren_agent_platform/storage/migrations/platform/006_actions_and_outbound.sql +55 -0
  90. soveren_agent_platform/storage/migrations/platform/007_session_routing.sql +57 -0
  91. soveren_agent_platform/storage/migrations/runner.py +274 -0
  92. soveren_agent_platform/storage/sqlite.py +19 -0
  93. soveren_agent_platform/telegram/__init__.py +25 -0
  94. soveren_agent_platform/telegram/contracts.py +17 -0
  95. soveren_agent_platform/telegram/ingress.py +38 -0
  96. soveren_agent_platform/telegram/ptb.py +223 -0
  97. soveren_agent_platform-0.2.0.dist-info/METADATA +114 -0
  98. soveren_agent_platform-0.2.0.dist-info/RECORD +100 -0
  99. soveren_agent_platform-0.2.0.dist-info/WHEEL +4 -0
  100. soveren_agent_platform-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ """Reusable runtime primitives for durable agent applications."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.2.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
+