cortex-agent-sdk 0.3.0__tar.gz → 0.4.0__tar.gz

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 (21) hide show
  1. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/PKG-INFO +65 -10
  2. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/README.md +60 -8
  3. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/pyproject.toml +8 -3
  4. cortex_agent_sdk-0.4.0/src/cortex_agent_sdk/capabilities.py +165 -0
  5. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/errores/catalogo.py +1 -0
  6. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/redis/scripts.py +32 -3
  7. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/redis/store.py +87 -20
  8. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/sessions/memory.py +101 -15
  9. cortex_agent_sdk-0.4.0/src/cortex_agent_sdk/sessions/models.py +58 -0
  10. cortex_agent_sdk-0.3.0/src/cortex_agent_sdk/capabilities.py +0 -62
  11. cortex_agent_sdk-0.3.0/src/cortex_agent_sdk/sessions/models.py +0 -21
  12. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/.gitignore +0 -0
  13. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/LICENSE +0 -0
  14. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/__init__.py +0 -0
  15. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/errores/__init__.py +0 -0
  16. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/errores/excepcion.py +0 -0
  17. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/py.typed +0 -0
  18. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/redis/__init__.py +0 -0
  19. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/sessions/__init__.py +0 -0
  20. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/sessions/codec.py +0 -0
  21. {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/sessions/store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortex-agent-sdk
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Sesiones y capacidades puntuales para agentes multiprovider con Pydantic AI
5
5
  Project-URL: Repository, https://github.com/epok200/cortex_agent_sdk
6
6
  Project-URL: Issues, https://github.com/epok200/cortex_agent_sdk/issues
@@ -16,8 +16,11 @@ Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Typing :: Typed
18
18
  Requires-Python: >=3.13
19
- Requires-Dist: pydantic-ai-slim[google,openai]<3,>=2.27
19
+ Requires-Dist: pydantic-ai-slim<3,>=2.27
20
+ Provides-Extra: google
21
+ Requires-Dist: pydantic-ai-slim[google]<3,>=2.27; extra == 'google'
20
22
  Provides-Extra: openai
23
+ Requires-Dist: pydantic-ai-slim[openai]<3,>=2.27; extra == 'openai'
21
24
  Provides-Extra: redis
22
25
  Requires-Dist: redis>=8.1.0; extra == 'redis'
23
26
  Description-Content-Type: text/markdown
@@ -37,30 +40,36 @@ opcionales construidas sobre sus hooks públicos.
37
40
  ## Requisitos
38
41
 
39
42
  - Python `>=3.13`.
40
- - Pydantic AI `>=2.27,<3`, con Google y OpenAI instalados por Cortex.
43
+ - Pydantic AI `>=2.27,<3`.
41
44
 
42
45
  ## Instalación
43
46
 
44
- Google, OpenAI y sesiones en memoria:
47
+ Solo sesiones en memoria y capabilities:
45
48
 
46
49
  ```bash
47
50
  uv add cortex-agent-sdk
48
51
  ```
49
52
 
50
- Google, OpenAI y Redis:
53
+ OpenAI y sesiones en memoria:
51
54
 
52
55
  ```bash
53
- uv add "cortex-agent-sdk[redis]"
56
+ uv add "cortex-agent-sdk[openai]"
54
57
  ```
55
58
 
56
- La instalación base incluye Google y OpenAI, además de los endpoints compatibles con OpenAI. Otros
57
- providers pueden agregarse desde los extras oficiales de Pydantic AI cuando un producto realmente
58
- los necesite. Cortex no implementa adapters paralelos.
59
+ OpenAI y Redis:
60
+
61
+ ```bash
62
+ uv add "cortex-agent-sdk[openai,redis]"
63
+ ```
64
+
65
+ Google se instala con el extra `google`. Cada producto elige únicamente sus providers. Cortex no
66
+ implementa adapters paralelos ni ofrece un extra que los instale todos.
59
67
 
60
68
  ## Uso
61
69
 
62
70
  El agente es el `Agent` nativo de Pydantic AI. El store entrega el historial bajo exclusión y lo
63
71
  guarda cuando `session.replace(...)` marca un resultado completo.
72
+ El siguiente ejemplo requiere el extra `openai`.
64
73
 
65
74
  ```python
66
75
  import asyncio
@@ -93,6 +102,7 @@ asyncio.run(main())
93
102
 
94
103
  - Si no se llama, el store no modifica el historial.
95
104
  - Si el bloque termina con una excepción, el store no guarda el reemplazo.
105
+ - Los checkpoints confirmados dentro del turno permanecen aunque una operación posterior falle.
96
106
  - Si guardar falla, la excepción se propaga.
97
107
 
98
108
  ## Redis
@@ -115,6 +125,47 @@ historial se serializa con `ModelMessagesTypeAdapter`, el formato público de Py
115
125
  Al cambiar desde el runtime anterior, usa un prefix nuevo. Los formatos no son compatibles y Cortex
116
126
  no intenta convertir el historial legacy.
117
127
 
128
+ ## Checkpoints de tools
129
+
130
+ `session_checkpoints` guarda el `ToolReturn` canónico al terminar cada `CallToolsNode`, antes de la
131
+ siguiente petición al modelo. Las tools con efectos se declaran por nombre para marcar el turno antes
132
+ de ejecutarlas:
133
+
134
+ ```python
135
+ from cortex_agent_sdk.capabilities import session_checkpoints
136
+
137
+ async with sessions.turn("usuario:42") as session:
138
+ result = await agent.run(
139
+ "Agenda la cita.",
140
+ message_history=session.messages,
141
+ conversation_id=session.session_id,
142
+ capabilities=[
143
+ session_checkpoints(
144
+ session,
145
+ effect_tools={"crear_evento", "mover_evento"},
146
+ )
147
+ ],
148
+ )
149
+ session.replace(result.all_messages())
150
+ ```
151
+
152
+ El marcador activo contiene únicamente nombre e ID de cada tool, nunca argumentos. Memory lo cambia
153
+ bajo su lock y Redis guarda el historial y elimina el marcador en una sola operación Lua. Si el
154
+ proceso se interrumpe después de comenzar un efecto y antes del checkpoint, el siguiente `turn()`
155
+ falla con `SESION_RECUPERACION_REQUERIDA` mientras el marcador siga vigente. Cada intento renueva su
156
+ TTL y, sin intentos, expira junto con la sesión al cumplir `ttl_seconds`.
157
+
158
+ Este marcador es un latch fail-closed, no una API de reconciliación. El consumidor debe comprobar o
159
+ reconciliar el efecto por sus propios medios y después llamar `reset()`. Cortex no conserva los
160
+ argumentos de la tool, no determina si la escritura externa ocurrió y no automatiza la recuperación.
161
+ El latch sólo se elimina cuando todas las tools de efecto marcadas devuelven un `ToolReturn` exitoso;
162
+ fallos, retries, denegaciones, interrupciones o resultados ausentes conservan el bloqueo.
163
+
164
+ Todos los procesos que comparten `key_prefix` deben entender el marcador `:active`. La versión
165
+ `0.3.1` lo ignora, por lo que no debe convivir mediante rolling deploy ni rollback con una versión
166
+ que use checkpoints bajo el mismo prefix. La migración requiere un namespace nuevo y un corte
167
+ coordinado de procesos.
168
+
118
169
  ## Endpoint compatible con OpenAI
119
170
 
120
171
  Pydantic AI puede conectarse directamente. Para un endpoint que no debe reintentar peticiones,
@@ -153,7 +204,10 @@ agent = Agent(
153
204
  ```
154
205
 
155
206
  La aplicación conserva la decisión sobre las tools elegibles. La capacidad no usa resultados
156
- fallidos, vacíos ni pertenecientes a otro run, y no reemplaza texto o nuevas llamadas del modelo.
207
+ fallidos, vacíos ni pertenecientes a otro run, y no reemplaza texto o nuevas llamadas del modelo. Si
208
+ la petición al provider falla después de un resultado elegible, devuelve ese resultado verificado,
209
+ siempre que el run no contenga fallos ni retries de tools. Sin un resultado elegible y limpio,
210
+ propaga intacta la excepción del provider. Los errores de tools no pasan por este fallback.
157
211
 
158
212
  ## Migración desde el runtime anterior
159
213
 
@@ -184,6 +238,7 @@ Pydantic AI.
184
238
  - `cortex_agent_sdk.sessions.MemorySessionStore`
185
239
  - `cortex_agent_sdk.redis.RedisSessionStore`
186
240
  - `cortex_agent_sdk.capabilities.last_tool_result_fallback`
241
+ - `cortex_agent_sdk.capabilities.session_checkpoints`
187
242
  - `cortex_agent_sdk.errores.AppError`
188
243
  - `cortex_agent_sdk.errores.CodigoError`
189
244
  - `cortex_agent_sdk.errores.Severidad`
@@ -13,30 +13,36 @@ opcionales construidas sobre sus hooks públicos.
13
13
  ## Requisitos
14
14
 
15
15
  - Python `>=3.13`.
16
- - Pydantic AI `>=2.27,<3`, con Google y OpenAI instalados por Cortex.
16
+ - Pydantic AI `>=2.27,<3`.
17
17
 
18
18
  ## Instalación
19
19
 
20
- Google, OpenAI y sesiones en memoria:
20
+ Solo sesiones en memoria y capabilities:
21
21
 
22
22
  ```bash
23
23
  uv add cortex-agent-sdk
24
24
  ```
25
25
 
26
- Google, OpenAI y Redis:
26
+ OpenAI y sesiones en memoria:
27
27
 
28
28
  ```bash
29
- uv add "cortex-agent-sdk[redis]"
29
+ uv add "cortex-agent-sdk[openai]"
30
30
  ```
31
31
 
32
- La instalación base incluye Google y OpenAI, además de los endpoints compatibles con OpenAI. Otros
33
- providers pueden agregarse desde los extras oficiales de Pydantic AI cuando un producto realmente
34
- los necesite. Cortex no implementa adapters paralelos.
32
+ OpenAI y Redis:
33
+
34
+ ```bash
35
+ uv add "cortex-agent-sdk[openai,redis]"
36
+ ```
37
+
38
+ Google se instala con el extra `google`. Cada producto elige únicamente sus providers. Cortex no
39
+ implementa adapters paralelos ni ofrece un extra que los instale todos.
35
40
 
36
41
  ## Uso
37
42
 
38
43
  El agente es el `Agent` nativo de Pydantic AI. El store entrega el historial bajo exclusión y lo
39
44
  guarda cuando `session.replace(...)` marca un resultado completo.
45
+ El siguiente ejemplo requiere el extra `openai`.
40
46
 
41
47
  ```python
42
48
  import asyncio
@@ -69,6 +75,7 @@ asyncio.run(main())
69
75
 
70
76
  - Si no se llama, el store no modifica el historial.
71
77
  - Si el bloque termina con una excepción, el store no guarda el reemplazo.
78
+ - Los checkpoints confirmados dentro del turno permanecen aunque una operación posterior falle.
72
79
  - Si guardar falla, la excepción se propaga.
73
80
 
74
81
  ## Redis
@@ -91,6 +98,47 @@ historial se serializa con `ModelMessagesTypeAdapter`, el formato público de Py
91
98
  Al cambiar desde el runtime anterior, usa un prefix nuevo. Los formatos no son compatibles y Cortex
92
99
  no intenta convertir el historial legacy.
93
100
 
101
+ ## Checkpoints de tools
102
+
103
+ `session_checkpoints` guarda el `ToolReturn` canónico al terminar cada `CallToolsNode`, antes de la
104
+ siguiente petición al modelo. Las tools con efectos se declaran por nombre para marcar el turno antes
105
+ de ejecutarlas:
106
+
107
+ ```python
108
+ from cortex_agent_sdk.capabilities import session_checkpoints
109
+
110
+ async with sessions.turn("usuario:42") as session:
111
+ result = await agent.run(
112
+ "Agenda la cita.",
113
+ message_history=session.messages,
114
+ conversation_id=session.session_id,
115
+ capabilities=[
116
+ session_checkpoints(
117
+ session,
118
+ effect_tools={"crear_evento", "mover_evento"},
119
+ )
120
+ ],
121
+ )
122
+ session.replace(result.all_messages())
123
+ ```
124
+
125
+ El marcador activo contiene únicamente nombre e ID de cada tool, nunca argumentos. Memory lo cambia
126
+ bajo su lock y Redis guarda el historial y elimina el marcador en una sola operación Lua. Si el
127
+ proceso se interrumpe después de comenzar un efecto y antes del checkpoint, el siguiente `turn()`
128
+ falla con `SESION_RECUPERACION_REQUERIDA` mientras el marcador siga vigente. Cada intento renueva su
129
+ TTL y, sin intentos, expira junto con la sesión al cumplir `ttl_seconds`.
130
+
131
+ Este marcador es un latch fail-closed, no una API de reconciliación. El consumidor debe comprobar o
132
+ reconciliar el efecto por sus propios medios y después llamar `reset()`. Cortex no conserva los
133
+ argumentos de la tool, no determina si la escritura externa ocurrió y no automatiza la recuperación.
134
+ El latch sólo se elimina cuando todas las tools de efecto marcadas devuelven un `ToolReturn` exitoso;
135
+ fallos, retries, denegaciones, interrupciones o resultados ausentes conservan el bloqueo.
136
+
137
+ Todos los procesos que comparten `key_prefix` deben entender el marcador `:active`. La versión
138
+ `0.3.1` lo ignora, por lo que no debe convivir mediante rolling deploy ni rollback con una versión
139
+ que use checkpoints bajo el mismo prefix. La migración requiere un namespace nuevo y un corte
140
+ coordinado de procesos.
141
+
94
142
  ## Endpoint compatible con OpenAI
95
143
 
96
144
  Pydantic AI puede conectarse directamente. Para un endpoint que no debe reintentar peticiones,
@@ -129,7 +177,10 @@ agent = Agent(
129
177
  ```
130
178
 
131
179
  La aplicación conserva la decisión sobre las tools elegibles. La capacidad no usa resultados
132
- fallidos, vacíos ni pertenecientes a otro run, y no reemplaza texto o nuevas llamadas del modelo.
180
+ fallidos, vacíos ni pertenecientes a otro run, y no reemplaza texto o nuevas llamadas del modelo. Si
181
+ la petición al provider falla después de un resultado elegible, devuelve ese resultado verificado,
182
+ siempre que el run no contenga fallos ni retries de tools. Sin un resultado elegible y limpio,
183
+ propaga intacta la excepción del provider. Los errores de tools no pasan por este fallback.
133
184
 
134
185
  ## Migración desde el runtime anterior
135
186
 
@@ -160,6 +211,7 @@ Pydantic AI.
160
211
  - `cortex_agent_sdk.sessions.MemorySessionStore`
161
212
  - `cortex_agent_sdk.redis.RedisSessionStore`
162
213
  - `cortex_agent_sdk.capabilities.last_tool_result_fallback`
214
+ - `cortex_agent_sdk.capabilities.session_checkpoints`
163
215
  - `cortex_agent_sdk.errores.AppError`
164
216
  - `cortex_agent_sdk.errores.CodigoError`
165
217
  - `cortex_agent_sdk.errores.Severidad`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cortex-agent-sdk"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Sesiones y capacidades puntuales para agentes multiprovider con Pydantic AI"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -17,7 +17,7 @@ classifiers = [
17
17
  "Typing :: Typed",
18
18
  ]
19
19
  dependencies = [
20
- "pydantic-ai-slim[google,openai]>=2.27,<3",
20
+ "pydantic-ai-slim>=2.27,<3",
21
21
  ]
22
22
 
23
23
  [project.urls]
@@ -25,7 +25,12 @@ Repository = "https://github.com/epok200/cortex_agent_sdk"
25
25
  Issues = "https://github.com/epok200/cortex_agent_sdk/issues"
26
26
 
27
27
  [project.optional-dependencies]
28
- openai = []
28
+ google = [
29
+ "pydantic-ai-slim[google]>=2.27,<3",
30
+ ]
31
+ openai = [
32
+ "pydantic-ai-slim[openai]>=2.27,<3",
33
+ ]
29
34
  redis = [
30
35
  "redis>=8.1.0",
31
36
  ]
@@ -0,0 +1,165 @@
1
+ """Capacidades opcionales para agentes Pydantic AI."""
2
+
3
+ from collections.abc import Collection
4
+ from dataclasses import replace
5
+ from typing import Any
6
+
7
+ from pydantic_ai import CallToolsNode, ModelRequestNode, ModelResponse, RunContext, TextPart
8
+ from pydantic_ai.capabilities.hooks import Hooks
9
+ from pydantic_ai.exceptions import ModelAPIError
10
+ from pydantic_ai.messages import ModelRequest, RetryPromptPart, ToolCallPart, ToolReturnPart
11
+ from pydantic_ai.models import ModelRequestContext
12
+
13
+ from cortex_agent_sdk.sessions import Session
14
+
15
+ __all__ = ["last_tool_result_fallback", "session_checkpoints"]
16
+
17
+
18
+ def last_tool_result_fallback(tool_names: str | Collection[str]) -> Hooks[Any]:
19
+ """Usa el último resultado elegible si el modelo termina sin texto."""
20
+ if isinstance(tool_names, str):
21
+ eligible_tools = frozenset((tool_names,))
22
+ else:
23
+ eligible_tools = frozenset(tool_names)
24
+
25
+ def use_last_tool_result(
26
+ ctx: RunContext[Any],
27
+ *,
28
+ request_context: ModelRequestContext,
29
+ response: ModelResponse,
30
+ ) -> ModelResponse:
31
+ if response.finish_reason not in {None, "stop"}:
32
+ return response
33
+ if response.text and response.text.strip():
34
+ return response
35
+ if response.tool_calls:
36
+ return response
37
+
38
+ result = _last_tool_result(request_context, ctx.run_id, eligible_tools)
39
+ if result is None:
40
+ return response
41
+ return replace(response, parts=[*response.parts, TextPart(result)])
42
+
43
+ def recover_provider_failure(
44
+ ctx: RunContext[Any],
45
+ *,
46
+ request_context: ModelRequestContext,
47
+ error: Exception,
48
+ ) -> ModelResponse:
49
+ if not isinstance(error, ModelAPIError):
50
+ raise error
51
+ result = _last_tool_result(request_context, ctx.run_id, eligible_tools)
52
+ if result is None:
53
+ raise error
54
+ return ModelResponse(parts=[TextPart(result)], finish_reason="stop")
55
+
56
+ return Hooks(
57
+ after_model_request=use_last_tool_result,
58
+ model_request_error=recover_provider_failure,
59
+ )
60
+
61
+
62
+ def session_checkpoints(
63
+ session: Session,
64
+ *,
65
+ effect_tools: str | Collection[str],
66
+ ) -> Hooks[Any]:
67
+ """Persiste tools completadas y bloquea efectos ambiguos."""
68
+ if isinstance(effect_tools, str):
69
+ effect_tool_names = frozenset((effect_tools,))
70
+ else:
71
+ effect_tool_names = frozenset(effect_tools)
72
+ pending_runs: set[str | None] = set()
73
+
74
+ async def checkpoint_node(
75
+ ctx: RunContext[Any],
76
+ *,
77
+ node: Any,
78
+ result: Any,
79
+ ) -> Any:
80
+ if isinstance(result, CallToolsNode):
81
+ effect_calls = tuple(
82
+ call
83
+ for call in result.model_response.tool_calls
84
+ if call.tool_name in effect_tool_names
85
+ )
86
+ if effect_calls:
87
+ await session.start_tool_turn(effect_calls)
88
+ pending_runs.add(ctx.run_id)
89
+
90
+ if isinstance(node, CallToolsNode) and node.model_response.tool_calls:
91
+ effect_calls = tuple(
92
+ call
93
+ for call in node.model_response.tool_calls
94
+ if call.tool_name in effect_tool_names
95
+ )
96
+ if effect_calls and not _effect_calls_succeeded(ctx, result, effect_calls):
97
+ return result
98
+ if effect_calls:
99
+ pending_runs.discard(ctx.run_id)
100
+ if ctx.run_id in pending_runs:
101
+ return result
102
+
103
+ messages = list(ctx.messages)
104
+ if isinstance(result, ModelRequestNode):
105
+ messages.append(result.request)
106
+ await session.checkpoint(messages)
107
+ pending_runs.discard(ctx.run_id)
108
+
109
+ return result
110
+
111
+ return Hooks(after_node_run=checkpoint_node)
112
+
113
+
114
+ def _effect_calls_succeeded(
115
+ ctx: RunContext[Any],
116
+ result: Any,
117
+ effect_calls: Collection[ToolCallPart],
118
+ ) -> bool:
119
+ if isinstance(result, ModelRequestNode):
120
+ request = result.request
121
+ else:
122
+ request = next(
123
+ (
124
+ message
125
+ for message in reversed(ctx.messages)
126
+ if isinstance(message, ModelRequest) and message.run_id == ctx.run_id
127
+ ),
128
+ None,
129
+ )
130
+ if request is None:
131
+ return False
132
+
133
+ successful = {
134
+ (part.tool_name, part.tool_call_id)
135
+ for part in request.parts
136
+ if isinstance(part, ToolReturnPart) and part.outcome == "success"
137
+ }
138
+ return all((call.tool_name, call.tool_call_id) in successful for call in effect_calls)
139
+
140
+
141
+ def _last_tool_result(
142
+ request_context: ModelRequestContext,
143
+ run_id: str | None,
144
+ tool_names: Collection[str],
145
+ ) -> str | None:
146
+ if run_id is None:
147
+ return None
148
+ candidate: str | None = None
149
+ for message in request_context.messages:
150
+ if not isinstance(message, ModelRequest) or message.run_id != run_id:
151
+ continue
152
+ for part in message.parts:
153
+ if isinstance(part, RetryPromptPart) and part.tool_name is not None:
154
+ return None
155
+ if not isinstance(part, ToolReturnPart):
156
+ continue
157
+ if part.outcome != "success":
158
+ return None
159
+ if (
160
+ part.tool_name in tool_names
161
+ and isinstance(part.content, str)
162
+ and part.content.strip()
163
+ ):
164
+ candidate = part.content
165
+ return candidate
@@ -14,5 +14,6 @@ class CodigoError(StrEnum):
14
14
  RECURSO_CERRADO = "RECURSO_CERRADO"
15
15
  SESION_OCUPADA = "SESION_OCUPADA"
16
16
  SESION_LEASE_PERDIDO = "SESION_LEASE_PERDIDO"
17
+ SESION_RECUPERACION_REQUERIDA = "SESION_RECUPERACION_REQUERIDA"
17
18
  SESION_INVALIDA = "SESION_INVALIDA"
18
19
  SESION_STORE_FALLO = "SESION_STORE_FALLO"
@@ -8,6 +8,9 @@ return 0
8
8
  RENEW = """
9
9
  if redis.call('get', KEYS[1]) == ARGV[1] then
10
10
  redis.call('pexpire', KEYS[1], ARGV[2])
11
+ if redis.call('exists', KEYS[2]) == 1 then
12
+ redis.call('pexpire', KEYS[2], ARGV[3])
13
+ end
11
14
  return 1
12
15
  end
13
16
  return 0
@@ -18,11 +21,14 @@ if redis.call('get', KEYS[1]) ~= ARGV[1] then
18
21
  return {0}
19
22
  end
20
23
  local payload = redis.call('get', KEYS[2])
24
+ local active = redis.call('get', KEYS[3])
21
25
  if payload then
22
26
  redis.call('pexpire', KEYS[2], ARGV[2])
23
- return {1, payload}
24
27
  end
25
- return {1}
28
+ if active then
29
+ redis.call('pexpire', KEYS[3], ARGV[2])
30
+ end
31
+ return {1, payload or false, active or false}
26
32
  """
27
33
 
28
34
  SAVE = """
@@ -33,16 +39,39 @@ redis.call('psetex', KEYS[2], ARGV[3], ARGV[2])
33
39
  return 1
34
40
  """
35
41
 
42
+ MARK_ACTIVE = """
43
+ if redis.call('get', KEYS[1]) ~= ARGV[1] then
44
+ return 0
45
+ end
46
+ if redis.call('exists', KEYS[2]) == 1 then
47
+ return -1
48
+ end
49
+ redis.call('psetex', KEYS[2], ARGV[3], ARGV[2])
50
+ return 1
51
+ """
52
+
53
+ CHECKPOINT = """
54
+ if redis.call('get', KEYS[1]) ~= ARGV[1] then
55
+ return 0
56
+ end
57
+ redis.call('psetex', KEYS[2], ARGV[3], ARGV[2])
58
+ redis.call('del', KEYS[3])
59
+ return 1
60
+ """
61
+
36
62
  RESET = """
37
63
  if redis.call('get', KEYS[1]) ~= ARGV[1] then
38
64
  return 0
39
65
  end
40
- redis.call('del', KEYS[2])
66
+ redis.call('del', KEYS[2], KEYS[3])
41
67
  return 1
42
68
  """
43
69
 
44
70
  RELEASE = """
45
71
  if redis.call('get', KEYS[1]) == ARGV[1] then
72
+ if redis.call('exists', KEYS[2]) == 1 then
73
+ redis.call('pexpire', KEYS[2], ARGV[2])
74
+ end
46
75
  redis.call('del', KEYS[1])
47
76
  return 1
48
77
  end
@@ -1,19 +1,21 @@
1
1
  import asyncio
2
2
  import time
3
- from collections.abc import AsyncIterator
3
+ from collections.abc import AsyncIterator, Sequence
4
4
  from contextlib import asynccontextmanager
5
5
  from dataclasses import dataclass
6
+ from functools import partial
6
7
  from hashlib import sha256
7
8
  from typing import Self, cast
8
9
  from uuid import uuid4
9
10
 
11
+ from pydantic_ai.messages import ModelMessage
10
12
  from redis.asyncio import Redis
11
13
  from redis.exceptions import RedisError
12
14
 
13
15
  from cortex_agent_sdk.errores import AppError, CodigoError
14
16
  from cortex_agent_sdk.redis import scripts
15
17
  from cortex_agent_sdk.sessions.codec import decode_messages, encode_messages
16
- from cortex_agent_sdk.sessions.models import Session
18
+ from cortex_agent_sdk.sessions.models import ActiveTurn, Session
17
19
 
18
20
  type _RedisArgument = str | bytes | int | float
19
21
 
@@ -22,6 +24,7 @@ type _RedisArgument = str | bytes | int | float
22
24
  class _RedisKeys:
23
25
  lease: str
24
26
  history: str
27
+ active: str
25
28
 
26
29
 
27
30
  class RedisSessionStore:
@@ -69,18 +72,35 @@ class RedisSessionStore:
69
72
  timeout_seconds: float = 5.0,
70
73
  ) -> AsyncIterator[Session]:
71
74
  async with self._lease(session_id, timeout_seconds) as lease:
72
- payload = await self._load(session_id, lease.owner)
75
+ payload, active_payload = await self._load(session_id, lease.owner)
76
+ if active_payload is not None:
77
+ raise AppError(
78
+ CodigoError.SESION_RECUPERACION_REQUERIDA,
79
+ f"turno incompleto en sesión {session_id}",
80
+ )
73
81
  messages = decode_messages(payload) if payload is not None else []
74
- session = Session(session_id, messages)
75
- yield session
76
- self._raise_renewal_failure(lease)
77
- if session.changed:
78
- await self._save(session_id, lease.owner, encode_messages(session.messages))
82
+ session = Session(
83
+ session_id,
84
+ messages,
85
+ _start_tool_turn=partial(self._mark_active, session_id, lease.owner),
86
+ _save_checkpoint=partial(self._checkpoint, session_id, lease.owner),
87
+ )
88
+ try:
89
+ yield session
90
+ self._raise_renewal_failure(lease)
91
+ if session.changed:
92
+ await self._save(session_id, lease.owner, encode_messages(session.messages))
93
+ finally:
94
+ session._detach()
79
95
 
80
96
  async def reset(self, session_id: str, timeout_seconds: float = 5.0) -> None:
81
97
  async with self._lease(session_id, timeout_seconds) as lease:
82
98
  keys = self._keys(session_id)
83
- result = await self._eval(scripts.RESET, (keys.lease, keys.history), lease.owner)
99
+ result = await self._eval(
100
+ scripts.RESET,
101
+ (keys.lease, keys.history, keys.active),
102
+ lease.owner,
103
+ )
84
104
  if int(cast(int, result)) != 1:
85
105
  raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
86
106
 
@@ -201,16 +221,17 @@ class RedisSessionStore:
201
221
  owner: str,
202
222
  owner_task: asyncio.Task,
203
223
  ) -> None:
204
- interval = self._lease_milliseconds / 3_000
224
+ interval = min(self._lease_milliseconds, self._ttl_milliseconds) / 3_000
225
+ keys = self._keys(session_id)
205
226
  try:
206
227
  while True:
207
228
  await asyncio.sleep(interval)
208
- lease_key = self._keys(session_id).lease
209
229
  result = await self._eval(
210
230
  scripts.RENEW,
211
- (lease_key,),
231
+ (keys.lease, keys.active),
212
232
  owner,
213
233
  self._lease_milliseconds,
234
+ self._ttl_milliseconds,
214
235
  )
215
236
  if int(cast(int, result)) != 1:
216
237
  raise AppError(
@@ -230,20 +251,20 @@ class RedisSessionStore:
230
251
  owner_task.cancel()
231
252
  raise failure from error
232
253
 
233
- async def _load(self, session_id: str, owner: str) -> bytes | None:
254
+ async def _load(self, session_id: str, owner: str) -> tuple[bytes | None, bytes | None]:
234
255
  keys = self._keys(session_id)
235
256
  raw = await self._eval(
236
257
  scripts.LOAD,
237
- (keys.lease, keys.history),
258
+ (keys.lease, keys.history, keys.active),
238
259
  owner,
239
260
  self._ttl_milliseconds,
240
261
  )
241
262
  result = cast(list[object], raw)
242
263
  if not result or int(cast(int, result[0])) != 1:
243
264
  raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
244
- if len(result) == 1:
245
- return None
246
- return cast(bytes, result[1])
265
+ history = cast(bytes | None, result[1])
266
+ active = cast(bytes | None, result[2])
267
+ return history, active
247
268
 
248
269
  async def _save(self, session_id: str, owner: str, payload: bytes) -> None:
249
270
  keys = self._keys(session_id)
@@ -257,9 +278,51 @@ class RedisSessionStore:
257
278
  if int(cast(int, result)) != 1:
258
279
  raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
259
280
 
281
+ async def _mark_active(
282
+ self,
283
+ session_id: str,
284
+ owner: str,
285
+ active_turn: ActiveTurn,
286
+ ) -> None:
287
+ keys = self._keys(session_id)
288
+ result = await self._eval(
289
+ scripts.MARK_ACTIVE,
290
+ (keys.lease, keys.active),
291
+ owner,
292
+ active_turn.model_dump_json().encode(),
293
+ self._ttl_milliseconds,
294
+ )
295
+ status = int(cast(int, result))
296
+ if status == -1:
297
+ raise AppError(CodigoError.SESION_INVALIDA, "la sesión ya tiene un turno activo")
298
+ if status != 1:
299
+ raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
300
+
301
+ async def _checkpoint(
302
+ self,
303
+ session_id: str,
304
+ owner: str,
305
+ messages: Sequence[ModelMessage],
306
+ ) -> None:
307
+ keys = self._keys(session_id)
308
+ result = await self._eval(
309
+ scripts.CHECKPOINT,
310
+ (keys.lease, keys.history, keys.active),
311
+ owner,
312
+ encode_messages(messages),
313
+ self._ttl_milliseconds,
314
+ )
315
+ if int(cast(int, result)) != 1:
316
+ raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
317
+
260
318
  async def _release(self, session_id: str, owner: str) -> None:
261
- lease_key = self._keys(session_id).lease
262
- result = await self._eval(scripts.RELEASE, (lease_key,), owner)
319
+ keys = self._keys(session_id)
320
+ result = await self._eval(
321
+ scripts.RELEASE,
322
+ (keys.lease, keys.active),
323
+ owner,
324
+ self._ttl_milliseconds,
325
+ )
263
326
  if int(cast(int, result)) != 1:
264
327
  raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
265
328
 
@@ -313,7 +376,11 @@ class RedisSessionStore:
313
376
  def _keys(self, session_id: str) -> _RedisKeys:
314
377
  digest = sha256(session_id.encode()).hexdigest()
315
378
  base = f"{self._key_prefix}:{{{digest}}}"
316
- return _RedisKeys(lease=f"{base}:lease", history=f"{base}:history")
379
+ return _RedisKeys(
380
+ lease=f"{base}:lease",
381
+ history=f"{base}:history",
382
+ active=f"{base}:active",
383
+ )
317
384
 
318
385
  def _validate_turn(self, session_id: str, timeout_seconds: float) -> None:
319
386
  if not session_id or timeout_seconds <= 0:
@@ -1,21 +1,23 @@
1
1
  import asyncio
2
2
  import time
3
3
  from collections import OrderedDict
4
- from collections.abc import AsyncIterator
4
+ from collections.abc import AsyncIterator, Sequence
5
5
  from contextlib import asynccontextmanager
6
6
  from dataclasses import dataclass
7
+ from functools import partial
7
8
  from typing import Self
8
9
 
9
10
  from pydantic_ai.messages import ModelMessage
10
11
 
11
12
  from cortex_agent_sdk.errores import AppError, CodigoError
12
13
  from cortex_agent_sdk.sessions.codec import decode_messages, encode_messages
13
- from cortex_agent_sdk.sessions.models import Session
14
+ from cortex_agent_sdk.sessions.models import ActiveTurn, Session
14
15
 
15
16
 
16
17
  @dataclass(slots=True)
17
18
  class _Entry:
18
19
  payload: bytes
20
+ active_turn: ActiveTurn | None
19
21
  expires_at: float
20
22
 
21
23
 
@@ -25,6 +27,11 @@ class _SessionLock:
25
27
  users: int = 0
26
28
 
27
29
 
30
+ @dataclass(slots=True)
31
+ class _TurnOwner:
32
+ active: bool = True
33
+
34
+
28
35
  class MemorySessionStore:
29
36
  """Sesiones locales con exclusión por turno, TTL y LRU."""
30
37
 
@@ -52,10 +59,28 @@ class MemorySessionStore:
52
59
  timeout_seconds: float = 5.0,
53
60
  ) -> AsyncIterator[Session]:
54
61
  async with self._lock(session_id, timeout_seconds):
55
- session = Session(session_id, await self._load(session_id))
56
- yield session
57
- if session.changed:
58
- await self._save(session_id, session.messages)
62
+ entry = await self._load(session_id)
63
+ if entry is not None and entry.active_turn is not None:
64
+ raise AppError(
65
+ CodigoError.SESION_RECUPERACION_REQUERIDA,
66
+ f"turno incompleto en sesión {session_id}",
67
+ )
68
+ messages = decode_messages(entry.payload) if entry is not None else []
69
+ owner = _TurnOwner()
70
+ session = Session(
71
+ session_id,
72
+ messages,
73
+ _start_tool_turn=partial(self._mark_active, session_id, owner),
74
+ _save_checkpoint=partial(self._checkpoint, session_id, owner),
75
+ )
76
+ try:
77
+ yield session
78
+ if session.changed:
79
+ await self._save(session_id, session.messages)
80
+ finally:
81
+ owner.active = False
82
+ session._detach()
83
+ await self._refresh_active(session_id)
59
84
 
60
85
  async def reset(self, session_id: str, timeout_seconds: float = 5.0) -> None:
61
86
  async with self._lock(session_id, timeout_seconds), self._guard:
@@ -70,28 +95,89 @@ class MemorySessionStore:
70
95
  self._closed = True
71
96
  self._entries.clear()
72
97
 
73
- async def _load(self, session_id: str) -> list[ModelMessage]:
98
+ async def _load(self, session_id: str) -> _Entry | None:
74
99
  async with self._guard:
75
100
  self._ensure_open()
76
101
  entry = self._entries.get(session_id)
77
102
  if entry is None:
78
- return []
103
+ return None
79
104
  if entry.expires_at <= time.monotonic():
80
105
  del self._entries[session_id]
81
- return []
106
+ return None
82
107
  entry.expires_at = time.monotonic() + self._ttl_seconds
83
108
  self._entries.move_to_end(session_id)
84
- return decode_messages(entry.payload)
109
+ return entry
85
110
 
86
111
  async def _save(self, session_id: str, messages: list[ModelMessage]) -> None:
87
112
  payload = encode_messages(messages)
88
113
  async with self._guard:
89
114
  self._ensure_open()
90
- expires_at = time.monotonic() + self._ttl_seconds
91
- self._entries[session_id] = _Entry(payload, expires_at)
92
- self._entries.move_to_end(session_id)
93
- while len(self._entries) > self._max_sessions:
94
- self._entries.popitem(last=False)
115
+ current = self._entries.get(session_id)
116
+ active_turn = current.active_turn if current is not None else None
117
+ self._write_entry(session_id, payload, active_turn)
118
+
119
+ async def _mark_active(
120
+ self,
121
+ session_id: str,
122
+ owner: _TurnOwner,
123
+ active_turn: ActiveTurn,
124
+ ) -> None:
125
+ async with self._guard:
126
+ self._ensure_open()
127
+ if not owner.active:
128
+ raise AppError(CodigoError.SESION_INVALIDA, "sesión fuera de su turno")
129
+ current = self._entries.get(session_id)
130
+ if current is not None and current.active_turn is not None:
131
+ raise AppError(CodigoError.SESION_INVALIDA, "la sesión ya tiene un turno activo")
132
+ payload = current.payload if current is not None else encode_messages([])
133
+ self._write_entry(session_id, payload, active_turn)
134
+
135
+ async def _checkpoint(
136
+ self,
137
+ session_id: str,
138
+ owner: _TurnOwner,
139
+ messages: Sequence[ModelMessage],
140
+ ) -> None:
141
+ payload = encode_messages(messages)
142
+ async with self._guard:
143
+ self._ensure_open()
144
+ if not owner.active:
145
+ raise AppError(CodigoError.SESION_INVALIDA, "sesión fuera de su turno")
146
+ self._write_entry(session_id, payload, None)
147
+
148
+ async def _refresh_active(self, session_id: str) -> None:
149
+ async with self._guard:
150
+ entry = self._entries.get(session_id)
151
+ if entry is not None and entry.active_turn is not None:
152
+ entry.expires_at = time.monotonic() + self._ttl_seconds
153
+
154
+ def _write_entry(
155
+ self,
156
+ session_id: str,
157
+ payload: bytes,
158
+ active_turn: ActiveTurn | None,
159
+ ) -> None:
160
+ now = time.monotonic()
161
+ expires_at = now + self._ttl_seconds
162
+ self._entries[session_id] = _Entry(payload, active_turn, expires_at)
163
+ self._entries.move_to_end(session_id)
164
+ for key, entry in tuple(self._entries.items()):
165
+ if entry.expires_at <= now and key not in self._locks:
166
+ del self._entries[key]
167
+ while len(self._entries) > self._max_sessions:
168
+ evictable = next(
169
+ (
170
+ key
171
+ for key, entry in self._entries.items()
172
+ if key != session_id
173
+ and key not in self._locks
174
+ and entry.active_turn is None
175
+ ),
176
+ None,
177
+ )
178
+ if evictable is None:
179
+ break
180
+ del self._entries[evictable]
95
181
 
96
182
  @asynccontextmanager
97
183
  async def _lock(
@@ -0,0 +1,58 @@
1
+ from collections.abc import Awaitable, Callable, Sequence
2
+ from dataclasses import dataclass, field
3
+
4
+ from pydantic import BaseModel, ConfigDict
5
+ from pydantic_ai.messages import ModelMessage, ToolCallPart
6
+
7
+ from cortex_agent_sdk.errores import AppError, CodigoError
8
+
9
+
10
+ class ActiveTurn(BaseModel):
11
+ model_config = ConfigDict(extra="forbid", frozen=True)
12
+
13
+ calls: tuple[tuple[str, str], ...]
14
+
15
+
16
+ type _StartToolTurn = Callable[[ActiveTurn], Awaitable[None]]
17
+ type _Checkpoint = Callable[[Sequence[ModelMessage]], Awaitable[None]]
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class Session:
22
+ """Historial aislado mientras un turno posee la sesión."""
23
+
24
+ session_id: str
25
+ messages: list[ModelMessage]
26
+ _changed: bool = field(default=False, init=False, repr=False)
27
+ _start_tool_turn: _StartToolTurn | None = field(default=None, repr=False)
28
+ _save_checkpoint: _Checkpoint | None = field(default=None, repr=False)
29
+
30
+ @property
31
+ def changed(self) -> bool:
32
+ return self._changed
33
+
34
+ def replace(self, messages: Sequence[ModelMessage]) -> None:
35
+ self.messages = list(messages)
36
+ self._changed = True
37
+
38
+ async def start_tool_turn(self, calls: Sequence[ToolCallPart]) -> None:
39
+ if self._start_tool_turn is None:
40
+ raise AppError(CodigoError.CONFIG_INVALIDA, "sesión sin persistencia inmediata")
41
+ if not calls:
42
+ raise AppError(CodigoError.SESION_INVALIDA, "turno de tools activo inválido")
43
+
44
+ identities = tuple((call.tool_name, call.tool_call_id) for call in calls)
45
+ active_turn = ActiveTurn(calls=identities)
46
+ await self._start_tool_turn(active_turn)
47
+
48
+ async def checkpoint(self, messages: Sequence[ModelMessage]) -> None:
49
+ if self._save_checkpoint is None:
50
+ raise AppError(CodigoError.CONFIG_INVALIDA, "sesión sin persistencia inmediata")
51
+
52
+ checkpoint = list(messages)
53
+ await self._save_checkpoint(checkpoint)
54
+ self.messages = checkpoint
55
+
56
+ def _detach(self) -> None:
57
+ self._start_tool_turn = None
58
+ self._save_checkpoint = None
@@ -1,62 +0,0 @@
1
- """Capacidades opcionales para agentes Pydantic AI."""
2
-
3
- from collections.abc import Collection
4
- from dataclasses import replace
5
- from typing import Any
6
-
7
- from pydantic_ai import ModelResponse, RunContext, TextPart
8
- from pydantic_ai.capabilities.hooks import Hooks
9
- from pydantic_ai.messages import ModelRequest, ToolReturnPart
10
- from pydantic_ai.models import ModelRequestContext
11
-
12
- __all__ = ["last_tool_result_fallback"]
13
-
14
-
15
- def last_tool_result_fallback(tool_names: str | Collection[str]) -> Hooks[Any]:
16
- """Usa el último resultado elegible si el modelo termina sin texto."""
17
- if isinstance(tool_names, str):
18
- eligible_tools = frozenset((tool_names,))
19
- else:
20
- eligible_tools = frozenset(tool_names)
21
-
22
- def use_last_tool_result(
23
- ctx: RunContext[Any],
24
- *,
25
- request_context: ModelRequestContext,
26
- response: ModelResponse,
27
- ) -> ModelResponse:
28
- if response.finish_reason not in {None, "stop"}:
29
- return response
30
- if response.text and response.text.strip():
31
- return response
32
- if response.tool_calls:
33
- return response
34
-
35
- result = _last_tool_result(request_context, ctx.run_id, eligible_tools)
36
- if result is None:
37
- return response
38
- return replace(response, parts=[*response.parts, TextPart(result)])
39
-
40
- return Hooks(after_model_request=use_last_tool_result)
41
-
42
-
43
- def _last_tool_result(
44
- request_context: ModelRequestContext,
45
- run_id: str | None,
46
- tool_names: Collection[str],
47
- ) -> str | None:
48
- if run_id is None:
49
- return None
50
- for message in reversed(request_context.messages):
51
- if not isinstance(message, ModelRequest) or message.run_id != run_id:
52
- continue
53
- for part in reversed(message.parts):
54
- if (
55
- isinstance(part, ToolReturnPart)
56
- and part.outcome == "success"
57
- and part.tool_name in tool_names
58
- and isinstance(part.content, str)
59
- and part.content.strip()
60
- ):
61
- return part.content
62
- return None
@@ -1,21 +0,0 @@
1
- from collections.abc import Sequence
2
- from dataclasses import dataclass, field
3
-
4
- from pydantic_ai.messages import ModelMessage
5
-
6
-
7
- @dataclass(slots=True)
8
- class Session:
9
- """Historial aislado mientras un turno posee la sesión."""
10
-
11
- session_id: str
12
- messages: list[ModelMessage]
13
- _changed: bool = field(default=False, init=False, repr=False)
14
-
15
- @property
16
- def changed(self) -> bool:
17
- return self._changed
18
-
19
- def replace(self, messages: Sequence[ModelMessage]) -> None:
20
- self.messages = list(messages)
21
- self._changed = True