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,293 @@
1
+ import asyncio
2
+ import time
3
+ from collections.abc import AsyncIterator
4
+ from contextlib import asynccontextmanager, suppress
5
+ from datetime import UTC, datetime
6
+ from hashlib import sha256
7
+ from typing import Self, cast
8
+ from uuid import uuid4
9
+
10
+ from redis.asyncio import Redis
11
+ from redis.exceptions import RedisError
12
+
13
+ from cortex_agent_sdk.errores import AppError, CodigoError
14
+ from cortex_agent_sdk.redis import scripts
15
+ from cortex_agent_sdk.sessions.codec import SessionCodec
16
+ from cortex_agent_sdk.sessions.models import SessionRecord
17
+ from cortex_agent_sdk.sessions.store import SessionLease
18
+
19
+ type _RedisArgument = str | bytes | int | float
20
+
21
+
22
+ class _RedisLease:
23
+ def __init__(
24
+ self,
25
+ store: "RedisSessionStore",
26
+ session_id: str,
27
+ owner: str,
28
+ fencing_token: int,
29
+ ) -> None:
30
+ self._store = store
31
+ self._session_id = session_id
32
+ self._owner = owner
33
+ self._fencing_token = fencing_token
34
+ self._active = True
35
+
36
+ @property
37
+ def session_id(self) -> str:
38
+ return self._session_id
39
+
40
+ @property
41
+ def fencing_token(self) -> int:
42
+ return self._fencing_token
43
+
44
+ @property
45
+ def renewal_interval_seconds(self) -> float:
46
+ return self._store.renewal_interval_seconds
47
+
48
+ async def verify_ownership(self, fencing_token: int) -> None:
49
+ self._ensure_active(fencing_token)
50
+ await self._store._verify(self._session_id, self._lease_value)
51
+
52
+ async def renew(self, fencing_token: int) -> None:
53
+ self._ensure_active(fencing_token)
54
+ await self._store._renew(self._session_id, self._lease_value)
55
+
56
+ async def load(self, fencing_token: int) -> SessionRecord | None:
57
+ self._ensure_active(fencing_token)
58
+ return await self._store._load(self._session_id, self._lease_value)
59
+
60
+ async def save(self, record: SessionRecord, fencing_token: int) -> SessionRecord:
61
+ self._ensure_active(fencing_token)
62
+ if record.session_id != self._session_id:
63
+ raise AppError(CodigoError.SESION_INVALIDA, "session_id no coincide con el lease")
64
+ return await self._store._save(record, self._lease_value)
65
+
66
+ def close(self) -> None:
67
+ self._active = False
68
+
69
+ @property
70
+ def _lease_value(self) -> str:
71
+ return f"{self._owner}:{self._fencing_token}"
72
+
73
+ def _ensure_active(self, fencing_token: int) -> None:
74
+ if not self._active or fencing_token != self._fencing_token:
75
+ raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
76
+
77
+
78
+ class RedisSessionStore:
79
+ """Store persistente con lease renovable y CAS atómico en Redis."""
80
+
81
+ def __init__(
82
+ self,
83
+ url: str | None = None,
84
+ *,
85
+ client: Redis | None = None,
86
+ key_prefix: str = "cortex:sessions",
87
+ ttl_seconds: float = 86_400,
88
+ lease_seconds: float = 30,
89
+ retry_interval_seconds: float = 0.05,
90
+ io_timeout_seconds: float = 10,
91
+ ) -> None:
92
+ if (url is None) == (client is None):
93
+ raise AppError(CodigoError.CONFIG_INVALIDA, "indica url o client de Redis")
94
+ durations = (ttl_seconds, lease_seconds, retry_interval_seconds, io_timeout_seconds)
95
+ if not key_prefix or min(durations) <= 0:
96
+ raise AppError(CodigoError.CONFIG_INVALIDA, "configuración de Redis inválida")
97
+
98
+ self._client = client or Redis.from_url(cast(str, url), decode_responses=False)
99
+ self._owns_client = client is None
100
+ self._key_prefix = key_prefix.rstrip(":")
101
+ self._ttl_milliseconds = _milliseconds(ttl_seconds)
102
+ self._lease_milliseconds = _milliseconds(lease_seconds)
103
+ self._retry_interval_seconds = retry_interval_seconds
104
+ self._io_timeout_seconds = io_timeout_seconds
105
+ self._codec = SessionCodec()
106
+ self._closed = False
107
+
108
+ @property
109
+ def renewal_interval_seconds(self) -> float:
110
+ return self._lease_milliseconds / 3_000
111
+
112
+ async def __aenter__(self) -> Self:
113
+ self._ensure_open()
114
+ return self
115
+
116
+ async def __aexit__(self, *_: object) -> None:
117
+ await self.aclose()
118
+
119
+ @asynccontextmanager
120
+ async def acquire(
121
+ self,
122
+ session_id: str,
123
+ timeout_seconds: float,
124
+ ) -> AsyncIterator[SessionLease]:
125
+ self._ensure_open()
126
+ if not session_id or timeout_seconds <= 0:
127
+ raise AppError(CodigoError.CONFIG_INVALIDA, "adquisición de sesión inválida")
128
+
129
+ owner = uuid4().hex
130
+ fencing_token = await self._acquire(session_id, owner, timeout_seconds)
131
+ lease = _RedisLease(self, session_id, owner, fencing_token)
132
+ try:
133
+ yield lease
134
+ finally:
135
+ lease.close()
136
+ with suppress(AppError):
137
+ await self._release(session_id, f"{owner}:{fencing_token}")
138
+
139
+ async def reset(self, session_id: str, timeout_seconds: float = 5.0) -> None:
140
+ async with self.acquire(session_id, timeout_seconds) as lease:
141
+ result = await self._eval(
142
+ scripts.RESET,
143
+ self._keys(session_id)[:2],
144
+ self._lease_value(lease),
145
+ )
146
+ if int(cast(int, result)) != 1:
147
+ raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
148
+
149
+ async def aclose(self) -> None:
150
+ if self._closed:
151
+ return
152
+ if self._owns_client:
153
+ try:
154
+ async with asyncio.timeout(self._io_timeout_seconds):
155
+ await self._client.aclose()
156
+ except TimeoutError as error:
157
+ raise AppError(
158
+ CodigoError.SESION_STORE_FALLO,
159
+ "Redis no cerró su cliente dentro del timeout",
160
+ ) from error
161
+ except RedisError as error:
162
+ raise AppError(
163
+ CodigoError.SESION_STORE_FALLO,
164
+ f"Redis lanzó {type(error).__name__} al cerrar",
165
+ ) from error
166
+ self._closed = True
167
+
168
+ async def _acquire(self, session_id: str, owner: str, timeout_seconds: float) -> int:
169
+ lease_key, _, fence_key = self._keys(session_id)
170
+ deadline = time.monotonic() + timeout_seconds
171
+ while True:
172
+ remaining = deadline - time.monotonic()
173
+ if remaining <= 0:
174
+ raise AppError(CodigoError.SESION_OCUPADA, f"sesión ocupada: {session_id}")
175
+ result = await self._eval(
176
+ scripts.ACQUIRE,
177
+ (lease_key, fence_key),
178
+ owner,
179
+ self._lease_milliseconds,
180
+ timeout_seconds=remaining,
181
+ )
182
+ fencing_token = int(cast(int, result))
183
+ if fencing_token > 0:
184
+ return fencing_token
185
+ remaining = deadline - time.monotonic()
186
+ if remaining <= 0:
187
+ raise AppError(CodigoError.SESION_OCUPADA, f"sesión ocupada: {session_id}")
188
+ await asyncio.sleep(min(self._retry_interval_seconds, remaining))
189
+
190
+ async def _verify(self, session_id: str, lease_value: str) -> None:
191
+ lease_key, _, _ = self._keys(session_id)
192
+ result = await self._eval(scripts.VERIFY, (lease_key,), lease_value)
193
+ if int(cast(int, result)) != 1:
194
+ raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
195
+
196
+ async def _renew(self, session_id: str, lease_value: str) -> None:
197
+ lease_key, _, _ = self._keys(session_id)
198
+ result = await self._eval(
199
+ scripts.RENEW,
200
+ (lease_key,),
201
+ lease_value,
202
+ self._lease_milliseconds,
203
+ )
204
+ if int(cast(int, result)) != 1:
205
+ raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
206
+
207
+ async def _load(self, session_id: str, lease_value: str) -> SessionRecord | None:
208
+ lease_key, session_key, _ = self._keys(session_id)
209
+ raw = await self._eval(
210
+ scripts.LOAD,
211
+ (lease_key, session_key),
212
+ lease_value,
213
+ self._ttl_milliseconds,
214
+ )
215
+ result = cast(list[object], raw)
216
+ if not result or int(cast(int, result[0])) != 1:
217
+ raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
218
+ if len(result) == 1:
219
+ return None
220
+ return self._codec.decode(cast(bytes, result[1]))
221
+
222
+ async def _save(self, record: SessionRecord, lease_value: str) -> SessionRecord:
223
+ saved = record.model_copy(
224
+ update={
225
+ "version": record.version + 1,
226
+ "updated_at": datetime.now(UTC),
227
+ }
228
+ )
229
+ lease_key, session_key, _ = self._keys(record.session_id)
230
+ result = await self._eval(
231
+ scripts.SAVE,
232
+ (lease_key, session_key),
233
+ lease_value,
234
+ record.version,
235
+ saved.version,
236
+ self._codec.encode(saved),
237
+ self._ttl_milliseconds,
238
+ )
239
+ status = int(cast(int, result))
240
+ if status == 1:
241
+ return saved
242
+ if status == -1:
243
+ raise AppError(CodigoError.SESION_CONFLICTO, "la versión de sesión cambió")
244
+ raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
245
+
246
+ async def _release(self, session_id: str, lease_value: str) -> None:
247
+ lease_key, _, _ = self._keys(session_id)
248
+ await self._eval(scripts.RELEASE, (lease_key,), lease_value)
249
+
250
+ async def _eval(
251
+ self,
252
+ script: str,
253
+ keys: tuple[str, ...],
254
+ *arguments: _RedisArgument,
255
+ timeout_seconds: float | None = None,
256
+ ) -> object:
257
+ self._ensure_open()
258
+ timeout = timeout_seconds or self._io_timeout_seconds
259
+ try:
260
+ async with asyncio.timeout(min(timeout, self._io_timeout_seconds)):
261
+ return await self._client.eval(script, len(keys), *keys, *arguments)
262
+ except TimeoutError as error:
263
+ raise AppError(
264
+ CodigoError.SESION_STORE_FALLO,
265
+ "Redis agotó el timeout de I/O",
266
+ ) from error
267
+ except RedisError as error:
268
+ raise AppError(
269
+ CodigoError.SESION_STORE_FALLO,
270
+ f"Redis lanzó {type(error).__name__}",
271
+ ) from error
272
+
273
+ def _keys(self, session_id: str) -> tuple[str, str, str]:
274
+ digest = sha256(session_id.encode()).hexdigest()
275
+ base = f"{self._key_prefix}:{{{digest}}}"
276
+ return f"{base}:lease", f"{base}:record", f"{base}:fence"
277
+
278
+ def _lease_value(self, lease: SessionLease) -> str:
279
+ if not isinstance(lease, _RedisLease):
280
+ raise AppError(CodigoError.SESION_INVALIDA, "lease de Redis inválido")
281
+ return lease._lease_value
282
+
283
+ def _ensure_open(self) -> None:
284
+ if self._closed:
285
+ raise AppError(CodigoError.RECURSO_CERRADO, "store Redis cerrado")
286
+
287
+
288
+ def _milliseconds(seconds: float) -> int:
289
+ value = int(seconds * 1_000)
290
+ if value < 1:
291
+ raise AppError(CodigoError.CONFIG_INVALIDA, "duración Redis demasiado pequeña")
292
+ return value
293
+
@@ -0,0 +1,29 @@
1
+ from dataclasses import dataclass
2
+ from enum import StrEnum
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from cortex_agent_sdk.engine import Usage
7
+ from cortex_agent_sdk.history.models import Turn
8
+
9
+
10
+ class AgentExitReason(StrEnum):
11
+ COMPLETED = "completed"
12
+ FINAL_ANSWER = "final_answer"
13
+ MAX_STEPS = "max_steps"
14
+ TOOL_FAILURE_LIMIT = "tool_failure_limit"
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class AgentResult:
19
+ text: str | None
20
+ reason: AgentExitReason
21
+ usage: Usage
22
+ steps: int
23
+ tool_calls: int
24
+ provider: str
25
+ model: str
26
+ history: tuple[Turn, ...]
27
+ raw_responses: tuple[object, ...]
28
+ structured: BaseModel | None = None
29
+
@@ -0,0 +1,39 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from cortex_agent_sdk.engine import Usage
6
+ from cortex_agent_sdk.history.models import Turn
7
+ from cortex_agent_sdk.sessions.lease import LeaseKeeper
8
+ from cortex_agent_sdk.sessions.models import SessionRecord
9
+
10
+
11
+ class AgentOptions(BaseModel):
12
+ """Límites del loop, hooks y sesiones."""
13
+
14
+ model_config = ConfigDict(extra="forbid", frozen=True)
15
+
16
+ max_steps: int = Field(default=8, ge=1)
17
+ max_history_turns: int = Field(default=20, ge=1)
18
+ max_consecutive_tool_failures: int = Field(default=3, ge=1)
19
+ provider_timeout_seconds: float = Field(default=120.0, gt=0)
20
+ tool_timeout_seconds: float = Field(default=30.0, gt=0)
21
+ hook_timeout_seconds: float = Field(default=10.0, gt=0)
22
+ session_lock_timeout_seconds: float = Field(default=5.0, gt=0)
23
+ shutdown_timeout_seconds: float = Field(default=120.0, gt=0)
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class RunState:
28
+ history: list[Turn]
29
+ record: SessionRecord | None
30
+ lease_keeper: LeaseKeeper | None
31
+ instructions: str
32
+ usage: Usage = field(default_factory=Usage)
33
+ raw_responses: list[object] = field(default_factory=list)
34
+ tool_calls: int = 0
35
+ consecutive_tool_failures: int = 0
36
+ last_text: str | None = None
37
+ structured: BaseModel | None = None
38
+ effective_model: str | None = None
39
+
@@ -0,0 +1,4 @@
1
+ from cortex_agent_sdk.sessions.codec import SessionCodec
2
+ from cortex_agent_sdk.sessions.memory import MemorySessionStore
3
+ from cortex_agent_sdk.sessions.models import SessionRecord
4
+ from cortex_agent_sdk.sessions.store import SessionLease, SessionStore
@@ -0,0 +1,27 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel, ConfigDict, ValidationError
4
+
5
+ from cortex_agent_sdk.errores import AppError, CodigoError
6
+ from cortex_agent_sdk.sessions.models import SessionRecord
7
+
8
+
9
+ class _SessionEnvelope(BaseModel):
10
+ model_config = ConfigDict(extra="forbid", frozen=True)
11
+
12
+ format_version: Literal[1] = 1
13
+ record: SessionRecord
14
+
15
+
16
+ class SessionCodec:
17
+ """Serializa sesiones a JSON validado, sin pickle."""
18
+
19
+ def encode(self, record: SessionRecord) -> bytes:
20
+ return _SessionEnvelope(record=record).model_dump_json().encode()
21
+
22
+ def decode(self, payload: bytes) -> SessionRecord:
23
+ try:
24
+ return _SessionEnvelope.model_validate_json(payload).record
25
+ except ValidationError as error:
26
+ raise AppError(CodigoError.SESION_INVALIDA, "payload de sesión inválido") from error
27
+
@@ -0,0 +1,85 @@
1
+ import asyncio
2
+ from collections.abc import Awaitable, Callable
3
+ from contextlib import suppress
4
+
5
+ from cortex_agent_sdk.errores import AppError, CodigoError
6
+ from cortex_agent_sdk.sessions.models import SessionRecord
7
+ from cortex_agent_sdk.sessions.store import SessionLease
8
+
9
+
10
+ class LeaseKeeper:
11
+ def __init__(self, lease: SessionLease) -> None:
12
+ self._lease = lease
13
+ self._fencing_token = lease.fencing_token
14
+ self._heartbeat: asyncio.Task[None] | None = None
15
+
16
+ async def start(self) -> None:
17
+ await self.verify()
18
+ interval = self._lease.renewal_interval_seconds
19
+ if interval is not None and interval <= 0:
20
+ raise AppError(CodigoError.CONFIG_INVALIDA, "intervalo de renovación inválido")
21
+ if interval is not None:
22
+ self._heartbeat = asyncio.create_task(self._renew_loop())
23
+
24
+ async def load(self) -> SessionRecord | None:
25
+ return await self.run(lambda: self._lease.load(self._fencing_token))
26
+
27
+ async def save(self, record: SessionRecord) -> SessionRecord:
28
+ return await self.run(lambda: self._lease.save(record, self._fencing_token))
29
+
30
+ async def verify(self) -> None:
31
+ self._raise_heartbeat_failure()
32
+ await self._lease.verify_ownership(self._fencing_token)
33
+
34
+ async def run[T](self, operation: Callable[[], Awaitable[T]]) -> T:
35
+ await self.verify()
36
+ if self._heartbeat is None:
37
+ return await operation()
38
+
39
+ operation_task = asyncio.ensure_future(operation())
40
+ try:
41
+ done, _ = await asyncio.wait(
42
+ (operation_task, self._heartbeat),
43
+ return_when=asyncio.FIRST_COMPLETED,
44
+ )
45
+ if self._heartbeat in done:
46
+ operation_task.cancel()
47
+ with suppress(asyncio.CancelledError):
48
+ await operation_task
49
+ self._raise_heartbeat_failure()
50
+ return await operation_task
51
+ finally:
52
+ if not operation_task.done():
53
+ operation_task.cancel()
54
+ with suppress(asyncio.CancelledError):
55
+ await operation_task
56
+
57
+ async def aclose(self) -> None:
58
+ if self._heartbeat is None:
59
+ return
60
+ self._heartbeat.cancel()
61
+ with suppress(asyncio.CancelledError, AppError):
62
+ await self._heartbeat
63
+
64
+ async def _renew_loop(self) -> None:
65
+ interval = self._lease.renewal_interval_seconds
66
+ if interval is None:
67
+ return
68
+ try:
69
+ while True:
70
+ await asyncio.sleep(interval)
71
+ await self._lease.renew(self._fencing_token)
72
+ except asyncio.CancelledError:
73
+ raise
74
+ except AppError:
75
+ raise
76
+ except Exception as error:
77
+ raise AppError(
78
+ CodigoError.SESION_LEASE_PERDIDO,
79
+ f"renovación lanzó {type(error).__name__}",
80
+ ) from error
81
+
82
+ def _raise_heartbeat_failure(self) -> None:
83
+ if self._heartbeat is not None and self._heartbeat.done():
84
+ self._heartbeat.result()
85
+
@@ -0,0 +1,186 @@
1
+ import asyncio
2
+ import time
3
+ from collections import OrderedDict
4
+ from collections.abc import AsyncIterator
5
+ from contextlib import asynccontextmanager
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from typing import Self
9
+
10
+ from cortex_agent_sdk.errores import AppError, CodigoError
11
+ from cortex_agent_sdk.sessions.models import SessionRecord
12
+ from cortex_agent_sdk.sessions.store import SessionLease
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class _Entry:
17
+ record: SessionRecord
18
+ expires_at: float
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class _SessionLock:
23
+ lock: asyncio.Lock
24
+ users: int = 0
25
+
26
+
27
+ class _MemoryLease:
28
+ def __init__(self, store: "MemorySessionStore", session_id: str, fencing_token: int) -> None:
29
+ self._store = store
30
+ self._session_id = session_id
31
+ self._fencing_token = fencing_token
32
+ self._active = True
33
+
34
+ @property
35
+ def session_id(self) -> str:
36
+ return self._session_id
37
+
38
+ @property
39
+ def fencing_token(self) -> int:
40
+ return self._fencing_token
41
+
42
+ @property
43
+ def renewal_interval_seconds(self) -> float | None:
44
+ return None
45
+
46
+ async def verify_ownership(self, fencing_token: int) -> None:
47
+ self._ensure_active(fencing_token)
48
+ self._store._ensure_open()
49
+
50
+ async def renew(self, fencing_token: int) -> None:
51
+ await self.verify_ownership(fencing_token)
52
+
53
+ async def load(self, fencing_token: int) -> SessionRecord | None:
54
+ await self.verify_ownership(fencing_token)
55
+ return await self._store._load(self._session_id)
56
+
57
+ async def save(self, record: SessionRecord, fencing_token: int) -> SessionRecord:
58
+ await self.verify_ownership(fencing_token)
59
+ if record.session_id != self._session_id:
60
+ raise AppError(CodigoError.SESION_INVALIDA, "session_id no coincide con el lease")
61
+ return await self._store._save(record)
62
+
63
+ def close(self) -> None:
64
+ self._active = False
65
+
66
+ def _ensure_active(self, fencing_token: int) -> None:
67
+ if not self._active or fencing_token != self._fencing_token:
68
+ raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
69
+
70
+
71
+ class MemorySessionStore:
72
+ """Store local con exclusión por sesión, CAS, TTL y LRU."""
73
+
74
+ def __init__(self, max_sessions: int = 1_000, ttl_seconds: float = 3_600) -> None:
75
+ if max_sessions < 1 or ttl_seconds <= 0:
76
+ raise AppError(CodigoError.CONFIG_INVALIDA, "límites de sesión inválidos")
77
+ self._max_sessions = max_sessions
78
+ self._ttl_seconds = ttl_seconds
79
+ self._entries: OrderedDict[str, _Entry] = OrderedDict()
80
+ self._locks: dict[str, _SessionLock] = {}
81
+ self._guard = asyncio.Lock()
82
+ self._closed = False
83
+ self._next_fencing_token = 1
84
+
85
+ async def __aenter__(self) -> Self:
86
+ self._ensure_open()
87
+ return self
88
+
89
+ async def __aexit__(self, *_: object) -> None:
90
+ await self.aclose()
91
+
92
+ @asynccontextmanager
93
+ async def acquire(
94
+ self,
95
+ session_id: str,
96
+ timeout_seconds: float,
97
+ ) -> AsyncIterator[SessionLease]:
98
+ self._ensure_open()
99
+ if not session_id or timeout_seconds <= 0:
100
+ raise AppError(CodigoError.CONFIG_INVALIDA, "adquisición de sesión inválida")
101
+
102
+ async with self._guard:
103
+ lock_entry = self._locks.setdefault(session_id, _SessionLock(asyncio.Lock()))
104
+ lock_entry.users += 1
105
+ fencing_token = self._next_fencing_token
106
+ self._next_fencing_token += 1
107
+
108
+ try:
109
+ await asyncio.wait_for(lock_entry.lock.acquire(), timeout_seconds)
110
+ except TimeoutError as error:
111
+ await self._release_lock_reference(session_id, lock_entry)
112
+ raise AppError(CodigoError.SESION_OCUPADA, f"sesión ocupada: {session_id}") from error
113
+ except BaseException:
114
+ await self._release_lock_reference(session_id, lock_entry)
115
+ raise
116
+
117
+ lease = _MemoryLease(self, session_id, fencing_token)
118
+ try:
119
+ yield lease
120
+ finally:
121
+ lease.close()
122
+ lock_entry.lock.release()
123
+ await self._release_lock_reference(session_id, lock_entry)
124
+
125
+ async def reset(self, session_id: str, timeout_seconds: float = 5.0) -> None:
126
+ async with self.acquire(session_id, timeout_seconds), self._guard:
127
+ self._entries.pop(session_id, None)
128
+
129
+ async def aclose(self) -> None:
130
+ async with self._guard:
131
+ self._closed = True
132
+ self._entries.clear()
133
+ self._locks.clear()
134
+
135
+ async def _load(self, session_id: str) -> SessionRecord | None:
136
+ async with self._guard:
137
+ self._ensure_open()
138
+ entry = self._entries.get(session_id)
139
+ if entry is None:
140
+ return None
141
+ if entry.expires_at <= time.monotonic():
142
+ del self._entries[session_id]
143
+ return None
144
+ entry.expires_at = time.monotonic() + self._ttl_seconds
145
+ self._entries.move_to_end(session_id)
146
+ return entry.record
147
+
148
+ async def _save(self, record: SessionRecord) -> SessionRecord:
149
+ async with self._guard:
150
+ self._ensure_open()
151
+ current = self._entries.get(record.session_id)
152
+ current_version = current.record.version if current else 0
153
+ if current_version != record.version:
154
+ raise AppError(
155
+ CodigoError.SESION_CONFLICTO,
156
+ f"versión esperada {record.version}, vigente {current_version}",
157
+ )
158
+
159
+ saved = record.model_copy(
160
+ update={
161
+ "version": record.version + 1,
162
+ "updated_at": datetime.now(UTC),
163
+ }
164
+ )
165
+ expires_at = time.monotonic() + self._ttl_seconds
166
+ self._entries[record.session_id] = _Entry(saved, expires_at)
167
+ self._entries.move_to_end(record.session_id)
168
+ while len(self._entries) > self._max_sessions:
169
+ self._entries.popitem(last=False)
170
+ return saved
171
+
172
+ def _ensure_open(self) -> None:
173
+ if self._closed:
174
+ raise AppError(CodigoError.RECURSO_CERRADO, "store de sesiones cerrado")
175
+
176
+ async def _release_lock_reference(
177
+ self,
178
+ session_id: str,
179
+ lock_entry: _SessionLock,
180
+ ) -> None:
181
+ async with self._guard:
182
+ lock_entry.users -= 1
183
+ current = self._locks.get(session_id)
184
+ if current is lock_entry and lock_entry.users == 0:
185
+ del self._locks[session_id]
186
+