agent-runtime-core 0.1.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.
@@ -0,0 +1,420 @@
1
+ """
2
+ SQLite queue implementation.
3
+
4
+ Good for:
5
+ - Local development with persistence
6
+ - Single-process deployments
7
+ - Testing with real database
8
+ """
9
+
10
+ import json
11
+ import sqlite3
12
+ from contextlib import contextmanager
13
+ from datetime import datetime, timedelta, timezone
14
+ from typing import Optional
15
+ from uuid import UUID
16
+
17
+ from agent_runtime.queue.base import RunQueue, QueuedRun, RunStatus
18
+
19
+
20
+ class SQLiteQueue(RunQueue):
21
+ """
22
+ SQLite-backed queue implementation.
23
+
24
+ Stores runs in a local SQLite database.
25
+ Uses row-level locking for claim operations.
26
+ """
27
+
28
+ def __init__(self, path: str = "agent_runtime.db", lease_ttl_seconds: int = 30):
29
+ self.path = path
30
+ self.lease_ttl_seconds = lease_ttl_seconds
31
+ self._initialized = False
32
+
33
+ @contextmanager
34
+ def _get_connection(self):
35
+ """Get a database connection."""
36
+ conn = sqlite3.connect(self.path, timeout=30.0)
37
+ conn.row_factory = sqlite3.Row
38
+ conn.execute("PRAGMA journal_mode=WAL")
39
+ try:
40
+ yield conn
41
+ finally:
42
+ conn.close()
43
+
44
+ def _ensure_initialized(self):
45
+ """Ensure the database schema exists."""
46
+ if self._initialized:
47
+ return
48
+
49
+ with self._get_connection() as conn:
50
+ conn.execute("""
51
+ CREATE TABLE IF NOT EXISTS runs (
52
+ run_id TEXT PRIMARY KEY,
53
+ agent_key TEXT NOT NULL,
54
+ input TEXT NOT NULL,
55
+ metadata TEXT NOT NULL,
56
+ max_attempts INTEGER NOT NULL DEFAULT 3,
57
+ attempt INTEGER NOT NULL DEFAULT 1,
58
+ status TEXT NOT NULL DEFAULT 'queued',
59
+ lease_owner TEXT DEFAULT '',
60
+ lease_expires_at TEXT,
61
+ cancel_requested_at TEXT,
62
+ output TEXT,
63
+ error TEXT,
64
+ created_at TEXT NOT NULL,
65
+ started_at TEXT,
66
+ finished_at TEXT,
67
+ available_at TEXT NOT NULL
68
+ )
69
+ """)
70
+ conn.execute("""
71
+ CREATE INDEX IF NOT EXISTS idx_runs_status
72
+ ON runs(status)
73
+ """)
74
+ conn.execute("""
75
+ CREATE INDEX IF NOT EXISTS idx_runs_available
76
+ ON runs(status, available_at)
77
+ """)
78
+ conn.commit()
79
+
80
+ self._initialized = True
81
+
82
+ async def enqueue(
83
+ self,
84
+ run_id: UUID,
85
+ agent_key: str,
86
+ input: dict,
87
+ metadata: Optional[dict] = None,
88
+ max_attempts: int = 3,
89
+ ) -> QueuedRun:
90
+ """Add a new run to the queue."""
91
+ self._ensure_initialized()
92
+ now = datetime.now(timezone.utc)
93
+
94
+ with self._get_connection() as conn:
95
+ conn.execute(
96
+ """
97
+ INSERT INTO runs (
98
+ run_id, agent_key, input, metadata, max_attempts,
99
+ status, created_at, available_at
100
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
101
+ """,
102
+ (
103
+ str(run_id),
104
+ agent_key,
105
+ json.dumps(input),
106
+ json.dumps(metadata or {}),
107
+ max_attempts,
108
+ RunStatus.QUEUED.value,
109
+ now.isoformat(),
110
+ now.isoformat(),
111
+ ),
112
+ )
113
+ conn.commit()
114
+
115
+ return QueuedRun(
116
+ run_id=run_id,
117
+ agent_key=agent_key,
118
+ attempt=1,
119
+ lease_expires_at=now,
120
+ input=input,
121
+ metadata=metadata or {},
122
+ max_attempts=max_attempts,
123
+ status=RunStatus.QUEUED,
124
+ )
125
+
126
+ async def claim(
127
+ self,
128
+ worker_id: str,
129
+ agent_keys: Optional[list[str]] = None,
130
+ batch_size: int = 1,
131
+ ) -> list[QueuedRun]:
132
+ """Claim runs from the queue."""
133
+ self._ensure_initialized()
134
+ now = datetime.now(timezone.utc)
135
+ lease_expires = now + timedelta(seconds=self.lease_ttl_seconds)
136
+
137
+ with self._get_connection() as conn:
138
+ # Build query
139
+ query = """
140
+ SELECT run_id, agent_key, input, metadata, max_attempts, attempt
141
+ FROM runs
142
+ WHERE status = ?
143
+ AND available_at <= ?
144
+ """
145
+ params = [RunStatus.QUEUED.value, now.isoformat()]
146
+
147
+ if agent_keys:
148
+ placeholders = ",".join("?" * len(agent_keys))
149
+ query += f" AND agent_key IN ({placeholders})"
150
+ params.extend(agent_keys)
151
+
152
+ query += f" ORDER BY created_at ASC LIMIT {batch_size}"
153
+
154
+ rows = conn.execute(query, params).fetchall()
155
+
156
+ claimed = []
157
+ for row in rows:
158
+ run_id = row["run_id"]
159
+
160
+ # Try to claim with optimistic locking
161
+ result = conn.execute(
162
+ """
163
+ UPDATE runs
164
+ SET status = ?, lease_owner = ?, lease_expires_at = ?,
165
+ started_at = COALESCE(started_at, ?)
166
+ WHERE run_id = ? AND status = ?
167
+ """,
168
+ (
169
+ RunStatus.RUNNING.value,
170
+ worker_id,
171
+ lease_expires.isoformat(),
172
+ now.isoformat(),
173
+ run_id,
174
+ RunStatus.QUEUED.value,
175
+ ),
176
+ )
177
+
178
+ if result.rowcount > 0:
179
+ claimed.append(QueuedRun(
180
+ run_id=UUID(row["run_id"]),
181
+ agent_key=row["agent_key"],
182
+ attempt=row["attempt"],
183
+ lease_expires_at=lease_expires,
184
+ input=json.loads(row["input"]),
185
+ metadata=json.loads(row["metadata"]),
186
+ max_attempts=row["max_attempts"],
187
+ status=RunStatus.RUNNING,
188
+ ))
189
+
190
+ conn.commit()
191
+ return claimed
192
+
193
+ async def extend_lease(self, run_id: UUID, worker_id: str, seconds: int) -> bool:
194
+ """Extend the lease on a run."""
195
+ self._ensure_initialized()
196
+ new_expires = datetime.now(timezone.utc) + timedelta(seconds=seconds)
197
+
198
+ with self._get_connection() as conn:
199
+ result = conn.execute(
200
+ """
201
+ UPDATE runs
202
+ SET lease_expires_at = ?
203
+ WHERE run_id = ? AND lease_owner = ? AND status = ?
204
+ """,
205
+ (
206
+ new_expires.isoformat(),
207
+ str(run_id),
208
+ worker_id,
209
+ RunStatus.RUNNING.value,
210
+ ),
211
+ )
212
+ conn.commit()
213
+ return result.rowcount > 0
214
+
215
+ async def release(
216
+ self,
217
+ run_id: UUID,
218
+ worker_id: str,
219
+ success: bool,
220
+ output: Optional[dict] = None,
221
+ error: Optional[dict] = None,
222
+ ) -> None:
223
+ """Release a run after completion."""
224
+ self._ensure_initialized()
225
+ now = datetime.now(timezone.utc)
226
+
227
+ with self._get_connection() as conn:
228
+ conn.execute(
229
+ """
230
+ UPDATE runs
231
+ SET status = ?, finished_at = ?, lease_owner = '',
232
+ lease_expires_at = NULL, output = ?, error = ?
233
+ WHERE run_id = ? AND lease_owner = ?
234
+ """,
235
+ (
236
+ RunStatus.SUCCEEDED.value if success else RunStatus.FAILED.value,
237
+ now.isoformat(),
238
+ json.dumps(output) if output else None,
239
+ json.dumps(error) if error else None,
240
+ str(run_id),
241
+ worker_id,
242
+ ),
243
+ )
244
+ conn.commit()
245
+
246
+ async def requeue_for_retry(
247
+ self,
248
+ run_id: UUID,
249
+ worker_id: str,
250
+ error: dict,
251
+ delay_seconds: int = 0,
252
+ ) -> bool:
253
+ """Requeue a run for retry."""
254
+ self._ensure_initialized()
255
+ now = datetime.now(timezone.utc)
256
+ available_at = now + timedelta(seconds=delay_seconds)
257
+
258
+ with self._get_connection() as conn:
259
+ # Get current attempt and max
260
+ row = conn.execute(
261
+ "SELECT attempt, max_attempts FROM runs WHERE run_id = ? AND lease_owner = ?",
262
+ (str(run_id), worker_id),
263
+ ).fetchone()
264
+
265
+ if not row:
266
+ return False
267
+
268
+ if row["attempt"] >= row["max_attempts"]:
269
+ conn.execute(
270
+ """
271
+ UPDATE runs
272
+ SET status = ?, error = ?, finished_at = ?,
273
+ lease_owner = '', lease_expires_at = NULL
274
+ WHERE run_id = ?
275
+ """,
276
+ (
277
+ RunStatus.FAILED.value,
278
+ json.dumps(error),
279
+ now.isoformat(),
280
+ str(run_id),
281
+ ),
282
+ )
283
+ conn.commit()
284
+ return False
285
+
286
+ conn.execute(
287
+ """
288
+ UPDATE runs
289
+ SET status = ?, attempt = attempt + 1, error = ?,
290
+ lease_owner = '', lease_expires_at = NULL, available_at = ?
291
+ WHERE run_id = ?
292
+ """,
293
+ (
294
+ RunStatus.QUEUED.value,
295
+ json.dumps(error),
296
+ available_at.isoformat(),
297
+ str(run_id),
298
+ ),
299
+ )
300
+ conn.commit()
301
+ return True
302
+
303
+ async def cancel(self, run_id: UUID) -> bool:
304
+ """Mark a run for cancellation."""
305
+ self._ensure_initialized()
306
+ now = datetime.now(timezone.utc)
307
+
308
+ with self._get_connection() as conn:
309
+ result = conn.execute(
310
+ """
311
+ UPDATE runs
312
+ SET cancel_requested_at = ?
313
+ WHERE run_id = ? AND status IN (?, ?)
314
+ """,
315
+ (
316
+ now.isoformat(),
317
+ str(run_id),
318
+ RunStatus.QUEUED.value,
319
+ RunStatus.RUNNING.value,
320
+ ),
321
+ )
322
+ conn.commit()
323
+ return result.rowcount > 0
324
+
325
+ async def is_cancelled(self, run_id: UUID) -> bool:
326
+ """Check if a run has been cancelled."""
327
+ self._ensure_initialized()
328
+
329
+ with self._get_connection() as conn:
330
+ row = conn.execute(
331
+ "SELECT cancel_requested_at FROM runs WHERE run_id = ?",
332
+ (str(run_id),),
333
+ ).fetchone()
334
+
335
+ return bool(row and row["cancel_requested_at"])
336
+
337
+ async def recover_expired_leases(self) -> int:
338
+ """Recover runs with expired leases."""
339
+ self._ensure_initialized()
340
+ now = datetime.now(timezone.utc)
341
+
342
+ with self._get_connection() as conn:
343
+ # Get expired runs
344
+ rows = conn.execute(
345
+ """
346
+ SELECT run_id, attempt, max_attempts, agent_key
347
+ FROM runs
348
+ WHERE status = ? AND lease_expires_at < ?
349
+ """,
350
+ (RunStatus.RUNNING.value, now.isoformat()),
351
+ ).fetchall()
352
+
353
+ count = 0
354
+ for row in rows:
355
+ if row["attempt"] >= row["max_attempts"]:
356
+ conn.execute(
357
+ """
358
+ UPDATE runs
359
+ SET status = ?, finished_at = ?, error = ?,
360
+ lease_owner = '', lease_expires_at = NULL
361
+ WHERE run_id = ?
362
+ """,
363
+ (
364
+ RunStatus.TIMED_OUT.value,
365
+ now.isoformat(),
366
+ json.dumps({
367
+ "type": "LeaseExpired",
368
+ "message": "Worker lease expired without completion",
369
+ "retriable": False,
370
+ }),
371
+ row["run_id"],
372
+ ),
373
+ )
374
+ else:
375
+ conn.execute(
376
+ """
377
+ UPDATE runs
378
+ SET status = ?, attempt = attempt + 1,
379
+ lease_owner = '', lease_expires_at = NULL
380
+ WHERE run_id = ?
381
+ """,
382
+ (RunStatus.QUEUED.value, row["run_id"]),
383
+ )
384
+ count += 1
385
+
386
+ conn.commit()
387
+ return count
388
+
389
+ async def get_run(self, run_id: UUID) -> Optional[QueuedRun]:
390
+ """Get a run by ID."""
391
+ self._ensure_initialized()
392
+
393
+ with self._get_connection() as conn:
394
+ row = conn.execute(
395
+ """
396
+ SELECT run_id, agent_key, input, metadata, max_attempts,
397
+ attempt, status, lease_expires_at
398
+ FROM runs WHERE run_id = ?
399
+ """,
400
+ (str(run_id),),
401
+ ).fetchone()
402
+
403
+ if not row:
404
+ return None
405
+
406
+ lease_expires = row["lease_expires_at"]
407
+
408
+ return QueuedRun(
409
+ run_id=UUID(row["run_id"]),
410
+ agent_key=row["agent_key"],
411
+ attempt=row["attempt"],
412
+ lease_expires_at=(
413
+ datetime.fromisoformat(lease_expires) if lease_expires
414
+ else datetime.now(timezone.utc)
415
+ ),
416
+ input=json.loads(row["input"]),
417
+ metadata=json.loads(row["metadata"]),
418
+ max_attempts=row["max_attempts"],
419
+ status=RunStatus(row["status"]),
420
+ )
@@ -0,0 +1,74 @@
1
+ """
2
+ Agent runtime registry.
3
+
4
+ Provides a global registry for agent runtimes, allowing them to be
5
+ looked up by key.
6
+ """
7
+
8
+ from typing import Optional
9
+
10
+ from agent_runtime.interfaces import AgentRuntime
11
+
12
+
13
+ # Global registry
14
+ _runtimes: dict[str, AgentRuntime] = {}
15
+
16
+
17
+ def register_runtime(runtime: AgentRuntime) -> None:
18
+ """
19
+ Register an agent runtime.
20
+
21
+ Args:
22
+ runtime: The runtime to register
23
+
24
+ Raises:
25
+ ValueError: If a runtime with the same key is already registered
26
+ """
27
+ key = runtime.key
28
+ if key in _runtimes:
29
+ raise ValueError(f"Runtime already registered: {key}")
30
+ _runtimes[key] = runtime
31
+
32
+
33
+ def get_runtime(key: str) -> Optional[AgentRuntime]:
34
+ """
35
+ Get a registered runtime by key.
36
+
37
+ Args:
38
+ key: The runtime key
39
+
40
+ Returns:
41
+ The runtime, or None if not found
42
+ """
43
+ return _runtimes.get(key)
44
+
45
+
46
+ def list_runtimes() -> list[str]:
47
+ """
48
+ List all registered runtime keys.
49
+
50
+ Returns:
51
+ List of runtime keys
52
+ """
53
+ return list(_runtimes.keys())
54
+
55
+
56
+ def unregister_runtime(key: str) -> bool:
57
+ """
58
+ Unregister a runtime.
59
+
60
+ Args:
61
+ key: The runtime key
62
+
63
+ Returns:
64
+ True if unregistered, False if not found
65
+ """
66
+ if key in _runtimes:
67
+ del _runtimes[key]
68
+ return True
69
+ return False
70
+
71
+
72
+ def clear_registry() -> None:
73
+ """Clear all registered runtimes. Useful for testing."""
74
+ _runtimes.clear()