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.
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/PKG-INFO +65 -10
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/README.md +60 -8
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/pyproject.toml +8 -3
- cortex_agent_sdk-0.4.0/src/cortex_agent_sdk/capabilities.py +165 -0
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/errores/catalogo.py +1 -0
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/redis/scripts.py +32 -3
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/redis/store.py +87 -20
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/sessions/memory.py +101 -15
- cortex_agent_sdk-0.4.0/src/cortex_agent_sdk/sessions/models.py +58 -0
- cortex_agent_sdk-0.3.0/src/cortex_agent_sdk/capabilities.py +0 -62
- cortex_agent_sdk-0.3.0/src/cortex_agent_sdk/sessions/models.py +0 -21
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/.gitignore +0 -0
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/LICENSE +0 -0
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/__init__.py +0 -0
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/errores/__init__.py +0 -0
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/errores/excepcion.py +0 -0
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/py.typed +0 -0
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/redis/__init__.py +0 -0
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/sessions/__init__.py +0 -0
- {cortex_agent_sdk-0.3.0 → cortex_agent_sdk-0.4.0}/src/cortex_agent_sdk/sessions/codec.py +0 -0
- {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
|
+
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
|
|
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
|
|
43
|
+
- Pydantic AI `>=2.27,<3`.
|
|
41
44
|
|
|
42
45
|
## Instalación
|
|
43
46
|
|
|
44
|
-
|
|
47
|
+
Solo sesiones en memoria y capabilities:
|
|
45
48
|
|
|
46
49
|
```bash
|
|
47
50
|
uv add cortex-agent-sdk
|
|
48
51
|
```
|
|
49
52
|
|
|
50
|
-
|
|
53
|
+
OpenAI y sesiones en memoria:
|
|
51
54
|
|
|
52
55
|
```bash
|
|
53
|
-
uv add "cortex-agent-sdk[
|
|
56
|
+
uv add "cortex-agent-sdk[openai]"
|
|
54
57
|
```
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
16
|
+
- Pydantic AI `>=2.27,<3`.
|
|
17
17
|
|
|
18
18
|
## Instalación
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
Solo sesiones en memoria y capabilities:
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
23
|
uv add cortex-agent-sdk
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
OpenAI y sesiones en memoria:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
uv add "cortex-agent-sdk[
|
|
29
|
+
uv add "cortex-agent-sdk[openai]"
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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(
|
|
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
|
-
(
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
return
|
|
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
|
-
|
|
262
|
-
result = await self._eval(
|
|
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(
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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) ->
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
self.
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|