cortex-agent-sdk 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.
Files changed (41) hide show
  1. cortex_agent_sdk/__init__.py +7 -0
  2. cortex_agent_sdk/agent.py +321 -0
  3. cortex_agent_sdk/engine.py +150 -0
  4. cortex_agent_sdk/errores/__init__.py +2 -0
  5. cortex_agent_sdk/errores/catalogo.py +35 -0
  6. cortex_agent_sdk/errores/excepcion.py +19 -0
  7. cortex_agent_sdk/gateway.py +23 -0
  8. cortex_agent_sdk/google/__init__.py +1 -0
  9. cortex_agent_sdk/history/__init__.py +2 -0
  10. cortex_agent_sdk/history/models.py +145 -0
  11. cortex_agent_sdk/history/pipeline.py +53 -0
  12. cortex_agent_sdk/history/transform.py +17 -0
  13. cortex_agent_sdk/hooks.py +102 -0
  14. cortex_agent_sdk/immutable.py +54 -0
  15. cortex_agent_sdk/lifecycle.py +67 -0
  16. cortex_agent_sdk/openai/__init__.py +2 -0
  17. cortex_agent_sdk/openai/engine.py +304 -0
  18. cortex_agent_sdk/openai/options.py +19 -0
  19. cortex_agent_sdk/postgres/__init__.py +1 -0
  20. cortex_agent_sdk/postgres/store.py +468 -0
  21. cortex_agent_sdk/py.typed +1 -0
  22. cortex_agent_sdk/redis/__init__.py +1 -0
  23. cortex_agent_sdk/redis/scripts.py +65 -0
  24. cortex_agent_sdk/redis/store.py +293 -0
  25. cortex_agent_sdk/results.py +29 -0
  26. cortex_agent_sdk/runtime.py +39 -0
  27. cortex_agent_sdk/sessions/__init__.py +4 -0
  28. cortex_agent_sdk/sessions/codec.py +27 -0
  29. cortex_agent_sdk/sessions/lease.py +85 -0
  30. cortex_agent_sdk/sessions/memory.py +186 -0
  31. cortex_agent_sdk/sessions/models.py +70 -0
  32. cortex_agent_sdk/sessions/store.py +38 -0
  33. cortex_agent_sdk/tools/__init__.py +1 -0
  34. cortex_agent_sdk/tools/contracts.py +152 -0
  35. cortex_agent_sdk/tools/decorators.py +15 -0
  36. cortex_agent_sdk/tools/execution.py +181 -0
  37. cortex_agent_sdk/tools/models.py +57 -0
  38. cortex_agent_sdk-0.0.1.dist-info/METADATA +192 -0
  39. cortex_agent_sdk-0.0.1.dist-info/RECORD +41 -0
  40. cortex_agent_sdk-0.0.1.dist-info/WHEEL +4 -0
  41. cortex_agent_sdk-0.0.1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,468 @@
1
+ import asyncio
2
+ import re
3
+ import time
4
+ from collections.abc import AsyncIterator
5
+ from contextlib import asynccontextmanager, suppress
6
+ from datetime import UTC, datetime
7
+ from typing import Self, cast
8
+ from uuid import uuid4
9
+
10
+ import asyncpg
11
+ from asyncpg import InterfaceError, Pool, PostgresError, Record
12
+
13
+ from cortex_agent_sdk.errores import AppError, CodigoError
14
+ from cortex_agent_sdk.sessions.codec import SessionCodec
15
+ from cortex_agent_sdk.sessions.models import SessionRecord
16
+ from cortex_agent_sdk.sessions.store import SessionLease
17
+
18
+ _IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,62}")
19
+
20
+
21
+ class _PostgresLease:
22
+ def __init__(
23
+ self,
24
+ store: "PostgresSessionStore",
25
+ session_id: str,
26
+ owner: str,
27
+ fencing_token: int,
28
+ ) -> None:
29
+ self._store = store
30
+ self._session_id = session_id
31
+ self._owner = owner
32
+ self._fencing_token = fencing_token
33
+ self._active = True
34
+
35
+ @property
36
+ def session_id(self) -> str:
37
+ return self._session_id
38
+
39
+ @property
40
+ def fencing_token(self) -> int:
41
+ return self._fencing_token
42
+
43
+ @property
44
+ def renewal_interval_seconds(self) -> float:
45
+ return self._store.renewal_interval_seconds
46
+
47
+ async def verify_ownership(self, fencing_token: int) -> None:
48
+ self._ensure_active(fencing_token)
49
+ await self._store._verify(self._session_id, self._owner, self._fencing_token)
50
+
51
+ async def renew(self, fencing_token: int) -> None:
52
+ self._ensure_active(fencing_token)
53
+ await self._store._renew(self._session_id, self._owner, self._fencing_token)
54
+
55
+ async def load(self, fencing_token: int) -> SessionRecord | None:
56
+ self._ensure_active(fencing_token)
57
+ return await self._store._load(self._session_id, self._owner, self._fencing_token)
58
+
59
+ async def save(self, record: SessionRecord, fencing_token: int) -> SessionRecord:
60
+ self._ensure_active(fencing_token)
61
+ if record.session_id != self._session_id:
62
+ raise AppError(CodigoError.SESION_INVALIDA, "session_id no coincide con el lease")
63
+ return await self._store._save(record, self._owner, self._fencing_token)
64
+
65
+ def close(self) -> None:
66
+ self._active = False
67
+
68
+ def _ensure_active(self, fencing_token: int) -> None:
69
+ if not self._active or fencing_token != self._fencing_token:
70
+ raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
71
+
72
+
73
+ class PostgresSessionStore:
74
+ """Store persistente con lease renovable y CAS en Postgres."""
75
+
76
+ def __init__(
77
+ self,
78
+ dsn: str | None = None,
79
+ *,
80
+ pool: Pool | None = None,
81
+ table_name: str = "cortex_agent_sessions",
82
+ ttl_seconds: float = 86_400,
83
+ lease_seconds: float = 30,
84
+ retry_interval_seconds: float = 0.05,
85
+ io_timeout_seconds: float = 10,
86
+ ) -> None:
87
+ if (dsn is None) == (pool is None):
88
+ raise AppError(CodigoError.CONFIG_INVALIDA, "indica dsn o pool de Postgres")
89
+ if _IDENTIFIER.fullmatch(table_name) is None:
90
+ raise AppError(CodigoError.CONFIG_INVALIDA, "nombre de tabla inválido")
91
+ durations = (ttl_seconds, lease_seconds, retry_interval_seconds, io_timeout_seconds)
92
+ if min(durations) <= 0:
93
+ raise AppError(CodigoError.CONFIG_INVALIDA, "configuración de Postgres inválida")
94
+
95
+ self._dsn = dsn
96
+ self._pool = pool
97
+ self._owns_pool = pool is None
98
+ self._table = f'"{table_name}"'
99
+ self._ttl_seconds = ttl_seconds
100
+ self._lease_seconds = lease_seconds
101
+ self._retry_interval_seconds = retry_interval_seconds
102
+ self._io_timeout_seconds = io_timeout_seconds
103
+ self._pool_lock = asyncio.Lock()
104
+ self._codec = SessionCodec()
105
+ self._closed = False
106
+
107
+ @property
108
+ def renewal_interval_seconds(self) -> float:
109
+ return self._lease_seconds / 3
110
+
111
+ async def __aenter__(self) -> Self:
112
+ self._ensure_open()
113
+ return self
114
+
115
+ async def __aexit__(self, *_: object) -> None:
116
+ await self.aclose()
117
+
118
+ async def setup(self) -> None:
119
+ query = f"""
120
+ CREATE TABLE IF NOT EXISTS {self._table} (
121
+ session_id TEXT PRIMARY KEY,
122
+ payload BYTEA,
123
+ version BIGINT NOT NULL DEFAULT 0,
124
+ updated_at TIMESTAMPTZ,
125
+ expires_at TIMESTAMPTZ,
126
+ lease_owner TEXT,
127
+ fencing_token BIGINT NOT NULL DEFAULT 0,
128
+ lease_until TIMESTAMPTZ
129
+ )
130
+ """
131
+ await self._execute(query)
132
+
133
+ async def cleanup_expired(self, limit: int = 1_000) -> int:
134
+ """Borra payloads vencidos sin eliminar su fencing counter."""
135
+ if limit < 1:
136
+ raise AppError(CodigoError.CONFIG_INVALIDA, "limit debe ser positivo")
137
+ query = f"""
138
+ WITH expired AS (
139
+ SELECT session_id
140
+ FROM {self._table}
141
+ WHERE expires_at <= NOW()
142
+ AND (lease_until IS NULL OR lease_until <= NOW())
143
+ ORDER BY expires_at
144
+ LIMIT $1
145
+ FOR UPDATE SKIP LOCKED
146
+ )
147
+ UPDATE {self._table} AS sessions
148
+ SET payload = NULL, version = 0, updated_at = NULL, expires_at = NULL
149
+ FROM expired
150
+ WHERE sessions.session_id = expired.session_id
151
+ """
152
+ status = await self._execute(query, limit)
153
+ return int(status.rsplit(" ", 1)[-1])
154
+
155
+ @asynccontextmanager
156
+ async def acquire(
157
+ self,
158
+ session_id: str,
159
+ timeout_seconds: float,
160
+ ) -> AsyncIterator[SessionLease]:
161
+ self._ensure_open()
162
+ if not session_id or timeout_seconds <= 0:
163
+ raise AppError(CodigoError.CONFIG_INVALIDA, "adquisición de sesión inválida")
164
+
165
+ owner = uuid4().hex
166
+ fencing_token = await self._acquire(session_id, owner, timeout_seconds)
167
+ lease = _PostgresLease(self, session_id, owner, fencing_token)
168
+ try:
169
+ yield lease
170
+ finally:
171
+ lease.close()
172
+ with suppress(AppError):
173
+ await self._release(session_id, owner, fencing_token)
174
+
175
+ async def reset(self, session_id: str, timeout_seconds: float = 5.0) -> None:
176
+ async with self.acquire(session_id, timeout_seconds) as lease:
177
+ if not isinstance(lease, _PostgresLease):
178
+ raise AppError(CodigoError.SESION_INVALIDA, "lease de Postgres inválido")
179
+ query = f"""
180
+ UPDATE {self._table}
181
+ SET payload = NULL, version = 0, updated_at = NULL, expires_at = NULL
182
+ WHERE session_id = $1
183
+ AND lease_owner = $2
184
+ AND fencing_token = $3
185
+ AND lease_until > NOW()
186
+ RETURNING fencing_token
187
+ """
188
+ result = await self._fetchval(
189
+ query,
190
+ session_id,
191
+ lease._owner,
192
+ lease.fencing_token,
193
+ )
194
+ if result is None:
195
+ raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
196
+
197
+ async def aclose(self) -> None:
198
+ if self._closed:
199
+ return
200
+ if self._owns_pool and self._pool is not None:
201
+ try:
202
+ async with asyncio.timeout(self._io_timeout_seconds):
203
+ await self._pool.close()
204
+ except TimeoutError as error:
205
+ raise AppError(
206
+ CodigoError.SESION_STORE_FALLO,
207
+ "Postgres no cerró su pool dentro del timeout",
208
+ ) from error
209
+ except (InterfaceError, PostgresError, OSError) as error:
210
+ raise _store_error(error) from error
211
+ self._closed = True
212
+
213
+ async def _acquire(self, session_id: str, owner: str, timeout_seconds: float) -> int:
214
+ query = f"""
215
+ INSERT INTO {self._table} AS sessions (
216
+ session_id, lease_owner, fencing_token, lease_until
217
+ )
218
+ VALUES ($1, $2, 1, NOW() + $3 * INTERVAL '1 second')
219
+ ON CONFLICT (session_id) DO UPDATE
220
+ SET lease_owner = EXCLUDED.lease_owner,
221
+ fencing_token = sessions.fencing_token + 1,
222
+ lease_until = EXCLUDED.lease_until
223
+ WHERE sessions.lease_until IS NULL OR sessions.lease_until <= NOW()
224
+ RETURNING fencing_token
225
+ """
226
+ deadline = time.monotonic() + timeout_seconds
227
+ while True:
228
+ remaining = deadline - time.monotonic()
229
+ if remaining <= 0:
230
+ raise AppError(CodigoError.SESION_OCUPADA, f"sesión ocupada: {session_id}")
231
+ result = await self._fetchval(
232
+ query,
233
+ session_id,
234
+ owner,
235
+ self._lease_seconds,
236
+ timeout_seconds=remaining,
237
+ )
238
+ if result is not None:
239
+ return cast(int, result)
240
+ remaining = deadline - time.monotonic()
241
+ if remaining <= 0:
242
+ raise AppError(CodigoError.SESION_OCUPADA, f"sesión ocupada: {session_id}")
243
+ await asyncio.sleep(min(self._retry_interval_seconds, remaining))
244
+
245
+ async def _verify(self, session_id: str, owner: str, fencing_token: int) -> None:
246
+ query = f"""
247
+ SELECT EXISTS(
248
+ SELECT 1 FROM {self._table}
249
+ WHERE session_id = $1
250
+ AND lease_owner = $2
251
+ AND fencing_token = $3
252
+ AND lease_until > NOW()
253
+ )
254
+ """
255
+ if not await self._fetchval(query, session_id, owner, fencing_token):
256
+ raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
257
+
258
+ async def _renew(self, session_id: str, owner: str, fencing_token: int) -> None:
259
+ query = f"""
260
+ UPDATE {self._table}
261
+ SET lease_until = NOW() + $4 * INTERVAL '1 second'
262
+ WHERE session_id = $1
263
+ AND lease_owner = $2
264
+ AND fencing_token = $3
265
+ AND lease_until > NOW()
266
+ RETURNING fencing_token
267
+ """
268
+ result = await self._fetchval(
269
+ query,
270
+ session_id,
271
+ owner,
272
+ fencing_token,
273
+ self._lease_seconds,
274
+ )
275
+ if result is None:
276
+ raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
277
+
278
+ async def _load(
279
+ self,
280
+ session_id: str,
281
+ owner: str,
282
+ fencing_token: int,
283
+ ) -> SessionRecord | None:
284
+ query = f"""
285
+ UPDATE {self._table}
286
+ SET payload = CASE
287
+ WHEN expires_at <= NOW() THEN NULL
288
+ ELSE payload
289
+ END,
290
+ version = CASE
291
+ WHEN expires_at <= NOW() THEN 0
292
+ ELSE version
293
+ END,
294
+ updated_at = CASE
295
+ WHEN expires_at <= NOW() THEN NULL
296
+ ELSE updated_at
297
+ END,
298
+ expires_at = CASE
299
+ WHEN payload IS NULL OR expires_at <= NOW() THEN NULL
300
+ ELSE NOW() + $4 * INTERVAL '1 second'
301
+ END
302
+ WHERE session_id = $1
303
+ AND lease_owner = $2
304
+ AND fencing_token = $3
305
+ AND lease_until > NOW()
306
+ RETURNING payload
307
+ """
308
+ row = await self._fetchrow(
309
+ query,
310
+ session_id,
311
+ owner,
312
+ fencing_token,
313
+ self._ttl_seconds,
314
+ )
315
+ if row is None:
316
+ raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
317
+ payload = row["payload"]
318
+ if payload is None:
319
+ return None
320
+ return self._codec.decode(bytes(payload))
321
+
322
+ async def _save(
323
+ self,
324
+ record: SessionRecord,
325
+ owner: str,
326
+ fencing_token: int,
327
+ ) -> SessionRecord:
328
+ saved = record.model_copy(
329
+ update={
330
+ "version": record.version + 1,
331
+ "updated_at": datetime.now(UTC),
332
+ }
333
+ )
334
+ query = f"""
335
+ UPDATE {self._table}
336
+ SET payload = $5,
337
+ version = $6,
338
+ updated_at = NOW(),
339
+ expires_at = NOW() + $7 * INTERVAL '1 second'
340
+ WHERE session_id = $1
341
+ AND lease_owner = $2
342
+ AND fencing_token = $3
343
+ AND lease_until > NOW()
344
+ AND version = $4
345
+ RETURNING version
346
+ """
347
+ result = await self._fetchval(
348
+ query,
349
+ record.session_id,
350
+ owner,
351
+ fencing_token,
352
+ record.version,
353
+ self._codec.encode(saved),
354
+ saved.version,
355
+ self._ttl_seconds,
356
+ )
357
+ if result is not None:
358
+ return saved
359
+ await self._raise_save_failure(record, owner, fencing_token)
360
+ raise AssertionError("save failure no traducido")
361
+
362
+ async def _raise_save_failure(
363
+ self,
364
+ record: SessionRecord,
365
+ owner: str,
366
+ fencing_token: int,
367
+ ) -> None:
368
+ query = f"""
369
+ SELECT version, lease_owner, fencing_token, lease_until > NOW() AS lease_active
370
+ FROM {self._table}
371
+ WHERE session_id = $1
372
+ """
373
+ row = await self._fetchrow(query, record.session_id)
374
+ owns_lease = (
375
+ row is not None
376
+ and row["lease_owner"] == owner
377
+ and row["fencing_token"] == fencing_token
378
+ and row["lease_active"]
379
+ )
380
+ if owns_lease:
381
+ raise AppError(CodigoError.SESION_CONFLICTO, "la versión de sesión cambió")
382
+ raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
383
+
384
+ async def _release(self, session_id: str, owner: str, fencing_token: int) -> None:
385
+ query = f"""
386
+ UPDATE {self._table}
387
+ SET lease_owner = NULL, lease_until = NULL
388
+ WHERE session_id = $1 AND lease_owner = $2 AND fencing_token = $3
389
+ """
390
+ await self._execute(query, session_id, owner, fencing_token)
391
+
392
+ async def _get_pool(self) -> Pool:
393
+ self._ensure_open()
394
+ if self._pool is not None:
395
+ return self._pool
396
+ async with self._pool_lock:
397
+ if self._pool is None:
398
+ try:
399
+ self._pool = await asyncpg.create_pool(dsn=cast(str, self._dsn))
400
+ except (InterfaceError, PostgresError, OSError) as error:
401
+ raise _store_error(error) from error
402
+ return self._pool
403
+
404
+ async def _fetchval(
405
+ self,
406
+ query: str,
407
+ *arguments: object,
408
+ timeout_seconds: float | None = None,
409
+ ) -> object:
410
+ timeout = timeout_seconds or self._io_timeout_seconds
411
+ try:
412
+ async with asyncio.timeout(min(timeout, self._io_timeout_seconds)):
413
+ return await (await self._get_pool()).fetchval(query, *arguments)
414
+ except TimeoutError as error:
415
+ raise AppError(
416
+ CodigoError.SESION_STORE_FALLO,
417
+ "Postgres agotó el timeout de I/O",
418
+ ) from error
419
+ except (InterfaceError, PostgresError, OSError) as error:
420
+ raise _store_error(error) from error
421
+
422
+ async def _fetchrow(
423
+ self,
424
+ query: str,
425
+ *arguments: object,
426
+ timeout_seconds: float | None = None,
427
+ ) -> Record | None:
428
+ timeout = timeout_seconds or self._io_timeout_seconds
429
+ try:
430
+ async with asyncio.timeout(min(timeout, self._io_timeout_seconds)):
431
+ return await (await self._get_pool()).fetchrow(query, *arguments)
432
+ except TimeoutError as error:
433
+ raise AppError(
434
+ CodigoError.SESION_STORE_FALLO,
435
+ "Postgres agotó el timeout de I/O",
436
+ ) from error
437
+ except (InterfaceError, PostgresError, OSError) as error:
438
+ raise _store_error(error) from error
439
+
440
+ async def _execute(
441
+ self,
442
+ query: str,
443
+ *arguments: object,
444
+ timeout_seconds: float | None = None,
445
+ ) -> str:
446
+ timeout = timeout_seconds or self._io_timeout_seconds
447
+ try:
448
+ async with asyncio.timeout(min(timeout, self._io_timeout_seconds)):
449
+ return await (await self._get_pool()).execute(query, *arguments)
450
+ except TimeoutError as error:
451
+ raise AppError(
452
+ CodigoError.SESION_STORE_FALLO,
453
+ "Postgres agotó el timeout de I/O",
454
+ ) from error
455
+ except (InterfaceError, PostgresError, OSError) as error:
456
+ raise _store_error(error) from error
457
+
458
+ def _ensure_open(self) -> None:
459
+ if self._closed:
460
+ raise AppError(CodigoError.RECURSO_CERRADO, "store Postgres cerrado")
461
+
462
+
463
+ def _store_error(error: BaseException) -> AppError:
464
+ return AppError(
465
+ CodigoError.SESION_STORE_FALLO,
466
+ f"Postgres lanzó {type(error).__name__}",
467
+ )
468
+
@@ -0,0 +1 @@
1
+
@@ -0,0 +1 @@
1
+ from cortex_agent_sdk.redis.store import RedisSessionStore
@@ -0,0 +1,65 @@
1
+ ACQUIRE = """
2
+ if redis.call('exists', KEYS[1]) == 0 then
3
+ local token = redis.call('incr', KEYS[2])
4
+ redis.call('psetex', KEYS[1], ARGV[2], ARGV[1] .. ':' .. token)
5
+ return token
6
+ end
7
+ return 0
8
+ """
9
+
10
+ VERIFY = """
11
+ if redis.call('get', KEYS[1]) == ARGV[1] then
12
+ return 1
13
+ end
14
+ return 0
15
+ """
16
+
17
+ RENEW = """
18
+ if redis.call('get', KEYS[1]) == ARGV[1] then
19
+ redis.call('pexpire', KEYS[1], ARGV[2])
20
+ return 1
21
+ end
22
+ return 0
23
+ """
24
+
25
+ LOAD = """
26
+ if redis.call('get', KEYS[1]) ~= ARGV[1] then
27
+ return {0}
28
+ end
29
+ local payload = redis.call('hget', KEYS[2], 'payload')
30
+ if payload then
31
+ redis.call('pexpire', KEYS[2], ARGV[2])
32
+ return {1, payload}
33
+ end
34
+ return {1}
35
+ """
36
+
37
+ SAVE = """
38
+ if redis.call('get', KEYS[1]) ~= ARGV[1] then
39
+ return 0
40
+ end
41
+ local current = tonumber(redis.call('hget', KEYS[2], 'version') or '0')
42
+ if current ~= tonumber(ARGV[2]) then
43
+ return -1
44
+ end
45
+ redis.call('hset', KEYS[2], 'version', ARGV[3], 'payload', ARGV[4])
46
+ redis.call('pexpire', KEYS[2], ARGV[5])
47
+ return 1
48
+ """
49
+
50
+ RESET = """
51
+ if redis.call('get', KEYS[1]) ~= ARGV[1] then
52
+ return 0
53
+ end
54
+ redis.call('del', KEYS[2])
55
+ return 1
56
+ """
57
+
58
+ RELEASE = """
59
+ if redis.call('get', KEYS[1]) == ARGV[1] then
60
+ redis.call('del', KEYS[1])
61
+ return 1
62
+ end
63
+ return 0
64
+ """
65
+