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.
- cortex_agent_sdk/__init__.py +7 -0
- cortex_agent_sdk/agent.py +321 -0
- cortex_agent_sdk/engine.py +150 -0
- cortex_agent_sdk/errores/__init__.py +2 -0
- cortex_agent_sdk/errores/catalogo.py +35 -0
- cortex_agent_sdk/errores/excepcion.py +19 -0
- cortex_agent_sdk/gateway.py +23 -0
- cortex_agent_sdk/google/__init__.py +1 -0
- cortex_agent_sdk/history/__init__.py +2 -0
- cortex_agent_sdk/history/models.py +145 -0
- cortex_agent_sdk/history/pipeline.py +53 -0
- cortex_agent_sdk/history/transform.py +17 -0
- cortex_agent_sdk/hooks.py +102 -0
- cortex_agent_sdk/immutable.py +54 -0
- cortex_agent_sdk/lifecycle.py +67 -0
- cortex_agent_sdk/openai/__init__.py +2 -0
- cortex_agent_sdk/openai/engine.py +304 -0
- cortex_agent_sdk/openai/options.py +19 -0
- cortex_agent_sdk/postgres/__init__.py +1 -0
- cortex_agent_sdk/postgres/store.py +468 -0
- cortex_agent_sdk/py.typed +1 -0
- cortex_agent_sdk/redis/__init__.py +1 -0
- cortex_agent_sdk/redis/scripts.py +65 -0
- cortex_agent_sdk/redis/store.py +293 -0
- cortex_agent_sdk/results.py +29 -0
- cortex_agent_sdk/runtime.py +39 -0
- cortex_agent_sdk/sessions/__init__.py +4 -0
- cortex_agent_sdk/sessions/codec.py +27 -0
- cortex_agent_sdk/sessions/lease.py +85 -0
- cortex_agent_sdk/sessions/memory.py +186 -0
- cortex_agent_sdk/sessions/models.py +70 -0
- cortex_agent_sdk/sessions/store.py +38 -0
- cortex_agent_sdk/tools/__init__.py +1 -0
- cortex_agent_sdk/tools/contracts.py +152 -0
- cortex_agent_sdk/tools/decorators.py +15 -0
- cortex_agent_sdk/tools/execution.py +181 -0
- cortex_agent_sdk/tools/models.py +57 -0
- cortex_agent_sdk-0.0.1.dist-info/METADATA +192 -0
- cortex_agent_sdk-0.0.1.dist-info/RECORD +41 -0
- cortex_agent_sdk-0.0.1.dist-info/WHEEL +4 -0
- 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,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
|
+
|