cortex-agent-sdk 0.0.2__tar.gz → 0.0.3__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.0.2 → cortex_agent_sdk-0.0.3}/PKG-INFO +30 -13
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/README.md +29 -12
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/pyproject.toml +1 -1
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/agent.py +5 -2
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/engine.py +19 -15
- cortex_agent_sdk-0.0.3/src/cortex_agent_sdk/google/__init__.py +1 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/history/models.py +46 -37
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/history/transform.py +2 -3
- cortex_agent_sdk-0.0.3/src/cortex_agent_sdk/immutable.py +75 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/openai/__init__.py +1 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/openai/engine.py +25 -17
- cortex_agent_sdk-0.0.3/src/cortex_agent_sdk/openai/models.py +57 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/redis/store.py +32 -17
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/tools/contracts.py +3 -4
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/tools/execution.py +2 -6
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/tools/models.py +12 -12
- cortex_agent_sdk-0.0.2/src/cortex_agent_sdk/google/__init__.py +0 -1
- cortex_agent_sdk-0.0.2/src/cortex_agent_sdk/immutable.py +0 -54
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/.gitignore +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/LICENSE +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/__init__.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/errores/__init__.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/errores/catalogo.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/errores/excepcion.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/gateway.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/history/__init__.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/history/pipeline.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/hooks.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/lifecycle.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/openai/options.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/postgres/__init__.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/postgres/store.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/py.typed +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/redis/__init__.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/redis/scripts.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/results.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/runtime.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/sessions/__init__.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/sessions/codec.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/sessions/lease.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/sessions/memory.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/sessions/models.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/sessions/store.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/tools/__init__.py +0 -0
- {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.3}/src/cortex_agent_sdk/tools/decorators.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cortex-agent-sdk
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
4
4
|
Summary: SDK async y multiproveedor para construir agentes con control explícito
|
|
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
|
|
@@ -159,6 +159,7 @@ renovable, fencing token y compare-and-swap.
|
|
|
159
159
|
Redis no requiere inicialización de schema:
|
|
160
160
|
|
|
161
161
|
```python
|
|
162
|
+
import asyncio
|
|
162
163
|
import os
|
|
163
164
|
|
|
164
165
|
from cortex_agent_sdk import Agent
|
|
@@ -166,26 +167,38 @@ from cortex_agent_sdk.openai import OpenAIEngine
|
|
|
166
167
|
from cortex_agent_sdk.redis import RedisSessionStore
|
|
167
168
|
|
|
168
169
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
await agent.
|
|
170
|
+
async def main() -> None:
|
|
171
|
+
store = RedisSessionStore(os.environ["REDIS_URL"])
|
|
172
|
+
async with Agent(
|
|
173
|
+
OpenAIEngine("gpt-5.6-luna"),
|
|
174
|
+
session_store=store,
|
|
175
|
+
own_session_store=True,
|
|
176
|
+
) as agent:
|
|
177
|
+
result = await agent.run("Hola", session_id="producto:tenant:usuario")
|
|
178
|
+
print(result.text)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
asyncio.run(main())
|
|
177
182
|
```
|
|
178
183
|
|
|
179
184
|
PostgreSQL exige crear su tabla de forma explícita una vez:
|
|
180
185
|
|
|
181
186
|
```python
|
|
187
|
+
import asyncio
|
|
182
188
|
import os
|
|
183
189
|
|
|
184
190
|
from cortex_agent_sdk.postgres import PostgresSessionStore
|
|
185
191
|
|
|
186
192
|
|
|
187
|
-
|
|
188
|
-
|
|
193
|
+
async def main() -> None:
|
|
194
|
+
store = PostgresSessionStore(os.environ["POSTGRES_URL"])
|
|
195
|
+
try:
|
|
196
|
+
await store.setup()
|
|
197
|
+
finally:
|
|
198
|
+
await store.aclose()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
asyncio.run(main())
|
|
189
202
|
```
|
|
190
203
|
|
|
191
204
|
Una tarea periódica puede ejecutar `await store.cleanup_expired()` para vaciar historiales vencidos
|
|
@@ -193,8 +206,12 @@ que nunca volvieron a solicitarse. El row mínimo permanece para conservar el fe
|
|
|
193
206
|
|
|
194
207
|
`Agent.aclose()` hace un cierre ordenado: deja de aceptar turnos nuevos, espera los turnos activos y
|
|
195
208
|
después cierra los recursos que posee. El límite se configura con
|
|
196
|
-
`AgentOptions.shutdown_timeout_seconds
|
|
197
|
-
|
|
209
|
+
`AgentOptions.shutdown_timeout_seconds` y cubre tanto el drenado como el cierre físico. Si vence
|
|
210
|
+
mientras hay un turno activo, el SDK no lo cancela ni empieza a cerrar recursos. Si vence durante el
|
|
211
|
+
cierre físico, algunos recursos podrían haberse cerrado ya. El timeout es un presupuesto de cierre,
|
|
212
|
+
no una garantía estricta de tiempo de pared: un finalizador que resista la cancelación puede retrasar
|
|
213
|
+
el retorno para no abandonar recursos a medias. Al excederlo regresa `RUNTIME_CIERRE_TIMEOUT` y una
|
|
214
|
+
segunda llamada a `aclose()` reintenta lo pendiente.
|
|
198
215
|
|
|
199
216
|
Una sesión dañada o deliberadamente descartada se elimina mediante
|
|
200
217
|
`await agent.reset_session(session_id)`.
|
|
@@ -125,6 +125,7 @@ renovable, fencing token y compare-and-swap.
|
|
|
125
125
|
Redis no requiere inicialización de schema:
|
|
126
126
|
|
|
127
127
|
```python
|
|
128
|
+
import asyncio
|
|
128
129
|
import os
|
|
129
130
|
|
|
130
131
|
from cortex_agent_sdk import Agent
|
|
@@ -132,26 +133,38 @@ from cortex_agent_sdk.openai import OpenAIEngine
|
|
|
132
133
|
from cortex_agent_sdk.redis import RedisSessionStore
|
|
133
134
|
|
|
134
135
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
await agent.
|
|
136
|
+
async def main() -> None:
|
|
137
|
+
store = RedisSessionStore(os.environ["REDIS_URL"])
|
|
138
|
+
async with Agent(
|
|
139
|
+
OpenAIEngine("gpt-5.6-luna"),
|
|
140
|
+
session_store=store,
|
|
141
|
+
own_session_store=True,
|
|
142
|
+
) as agent:
|
|
143
|
+
result = await agent.run("Hola", session_id="producto:tenant:usuario")
|
|
144
|
+
print(result.text)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
asyncio.run(main())
|
|
143
148
|
```
|
|
144
149
|
|
|
145
150
|
PostgreSQL exige crear su tabla de forma explícita una vez:
|
|
146
151
|
|
|
147
152
|
```python
|
|
153
|
+
import asyncio
|
|
148
154
|
import os
|
|
149
155
|
|
|
150
156
|
from cortex_agent_sdk.postgres import PostgresSessionStore
|
|
151
157
|
|
|
152
158
|
|
|
153
|
-
|
|
154
|
-
|
|
159
|
+
async def main() -> None:
|
|
160
|
+
store = PostgresSessionStore(os.environ["POSTGRES_URL"])
|
|
161
|
+
try:
|
|
162
|
+
await store.setup()
|
|
163
|
+
finally:
|
|
164
|
+
await store.aclose()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
asyncio.run(main())
|
|
155
168
|
```
|
|
156
169
|
|
|
157
170
|
Una tarea periódica puede ejecutar `await store.cleanup_expired()` para vaciar historiales vencidos
|
|
@@ -159,8 +172,12 @@ que nunca volvieron a solicitarse. El row mínimo permanece para conservar el fe
|
|
|
159
172
|
|
|
160
173
|
`Agent.aclose()` hace un cierre ordenado: deja de aceptar turnos nuevos, espera los turnos activos y
|
|
161
174
|
después cierra los recursos que posee. El límite se configura con
|
|
162
|
-
`AgentOptions.shutdown_timeout_seconds
|
|
163
|
-
|
|
175
|
+
`AgentOptions.shutdown_timeout_seconds` y cubre tanto el drenado como el cierre físico. Si vence
|
|
176
|
+
mientras hay un turno activo, el SDK no lo cancela ni empieza a cerrar recursos. Si vence durante el
|
|
177
|
+
cierre físico, algunos recursos podrían haberse cerrado ya. El timeout es un presupuesto de cierre,
|
|
178
|
+
no una garantía estricta de tiempo de pared: un finalizador que resista la cancelación puede retrasar
|
|
179
|
+
el retorno para no abandonar recursos a medias. Al excederlo regresa `RUNTIME_CIERRE_TIMEOUT` y una
|
|
180
|
+
segunda llamada a `aclose()` reintenta lo pendiente.
|
|
164
181
|
|
|
165
182
|
Una sesión dañada o deliberadamente descartada se elimina mediante
|
|
166
183
|
`await agent.reset_session(session_id)`.
|
|
@@ -43,7 +43,10 @@ class Agent:
|
|
|
43
43
|
self._options = options or AgentOptions()
|
|
44
44
|
self._history = HistoryPipeline(history_transform, self._options.max_history_turns)
|
|
45
45
|
self._hooks = HookChain(hooks or AgentHooks(), self._options.hook_timeout_seconds)
|
|
46
|
-
|
|
46
|
+
if session_store is None:
|
|
47
|
+
self._session_store = MemorySessionStore()
|
|
48
|
+
else:
|
|
49
|
+
self._session_store = session_store
|
|
47
50
|
self._own_engine = own_engine
|
|
48
51
|
self._own_session_store = session_store is None or own_session_store
|
|
49
52
|
self._lifecycle = AgentLifecycle()
|
|
@@ -65,7 +68,7 @@ class Agent:
|
|
|
65
68
|
instructions: str | None = None,
|
|
66
69
|
) -> AgentResult:
|
|
67
70
|
async with self._lifecycle.run():
|
|
68
|
-
if not text:
|
|
71
|
+
if not text.strip():
|
|
69
72
|
raise AppError(CodigoError.CONFIG_INVALIDA, "text no puede estar vacío")
|
|
70
73
|
|
|
71
74
|
tool_set = ToolSet.build((*self._static_tools, *tuple(tools)))
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
from collections.abc import Mapping
|
|
2
2
|
from dataclasses import dataclass
|
|
3
3
|
from types import MappingProxyType
|
|
4
|
-
from typing import Protocol,
|
|
4
|
+
from typing import Protocol, runtime_checkable
|
|
5
5
|
|
|
6
6
|
from pydantic import BaseModel, ConfigDict, JsonValue, field_serializer, field_validator
|
|
7
7
|
|
|
8
8
|
from cortex_agent_sdk.history.models import ToolCallPart, Turn
|
|
9
|
-
from cortex_agent_sdk.immutable import
|
|
9
|
+
from cortex_agent_sdk.immutable import JsonObject, freeze_json_object, thaw_json_object
|
|
10
10
|
from cortex_agent_sdk.tools.models import ToolSpec
|
|
11
11
|
|
|
12
12
|
|
|
@@ -15,25 +15,22 @@ class ToolCall(BaseModel):
|
|
|
15
15
|
|
|
16
16
|
call_id: str
|
|
17
17
|
name: str
|
|
18
|
-
arguments:
|
|
18
|
+
arguments: JsonObject
|
|
19
19
|
|
|
20
20
|
@field_validator("arguments", mode="after")
|
|
21
21
|
@classmethod
|
|
22
22
|
def freeze_arguments(
|
|
23
23
|
cls,
|
|
24
|
-
value:
|
|
25
|
-
) ->
|
|
26
|
-
|
|
27
|
-
if not isinstance(frozen, Mapping):
|
|
28
|
-
raise TypeError("arguments debe ser un objeto")
|
|
29
|
-
return cast(Mapping[str, JsonValue], frozen)
|
|
24
|
+
value: JsonObject,
|
|
25
|
+
) -> JsonObject:
|
|
26
|
+
return freeze_json_object(value)
|
|
30
27
|
|
|
31
28
|
@field_serializer("arguments", when_used="json")
|
|
32
29
|
def serialize_arguments(
|
|
33
30
|
self,
|
|
34
|
-
value:
|
|
35
|
-
) -> dict[str,
|
|
36
|
-
return
|
|
31
|
+
value: JsonObject,
|
|
32
|
+
) -> dict[str, JsonValue]:
|
|
33
|
+
return thaw_json_object(value)
|
|
37
34
|
|
|
38
35
|
|
|
39
36
|
@dataclass(frozen=True, slots=True)
|
|
@@ -117,7 +114,7 @@ class EngineResult:
|
|
|
117
114
|
isinstance(call, ToolCall) for call in self.tool_calls
|
|
118
115
|
)
|
|
119
116
|
valid_structured = self.structured is None or isinstance(self.structured, BaseModel)
|
|
120
|
-
if not isinstance(self.turn, Turn) or not valid_calls:
|
|
117
|
+
if not isinstance(self.turn, Turn) or self.turn.role != "assistant" or not valid_calls:
|
|
121
118
|
raise TypeError("EngineResult inválido")
|
|
122
119
|
turn_calls = tuple(part for part in self.turn.parts if isinstance(part, ToolCallPart))
|
|
123
120
|
aligned_calls = len(turn_calls) == len(self.tool_calls) and all(
|
|
@@ -128,7 +125,15 @@ class EngineResult:
|
|
|
128
125
|
)
|
|
129
126
|
if not aligned_calls:
|
|
130
127
|
raise TypeError("tool_calls no coincide con el turno")
|
|
131
|
-
|
|
128
|
+
valid_identity = (
|
|
129
|
+
isinstance(self.provider, str)
|
|
130
|
+
and bool(self.provider)
|
|
131
|
+
and isinstance(self.model, str)
|
|
132
|
+
and bool(self.model)
|
|
133
|
+
)
|
|
134
|
+
provider_state = self.turn.provider_state
|
|
135
|
+
state_matches = provider_state is None or provider_state.provider == self.provider
|
|
136
|
+
if not isinstance(self.usage, Usage) or not valid_identity or not state_matches:
|
|
132
137
|
raise TypeError("EngineResult inválido")
|
|
133
138
|
if self.stop_reason is not None and not isinstance(self.stop_reason, str):
|
|
134
139
|
raise TypeError("stop_reason inválido")
|
|
@@ -147,4 +152,3 @@ class ModelEngine(Protocol):
|
|
|
147
152
|
async def generate(self, request: EngineRequest) -> EngineResult: ...
|
|
148
153
|
|
|
149
154
|
async def aclose(self) -> None: ...
|
|
150
|
-
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Espacio reservado para el futuro engine de Google."""
|
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
from collections.abc import Mapping
|
|
3
|
-
from typing import Annotated, Literal,
|
|
4
|
-
|
|
5
|
-
from pydantic import
|
|
3
|
+
from typing import Annotated, Literal, Self
|
|
4
|
+
|
|
5
|
+
from pydantic import (
|
|
6
|
+
BaseModel,
|
|
7
|
+
ConfigDict,
|
|
8
|
+
Field,
|
|
9
|
+
JsonValue,
|
|
10
|
+
field_serializer,
|
|
11
|
+
field_validator,
|
|
12
|
+
model_validator,
|
|
13
|
+
)
|
|
6
14
|
|
|
7
15
|
from cortex_agent_sdk.immutable import (
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
JsonObject,
|
|
17
|
+
ProviderItem,
|
|
18
|
+
ProviderValue,
|
|
19
|
+
freeze_json_object,
|
|
20
|
+
freeze_provider_item,
|
|
21
|
+
thaw_json_object,
|
|
13
22
|
)
|
|
14
23
|
|
|
15
24
|
_BYTES_TAG = "$cortex.bytes"
|
|
16
25
|
|
|
17
|
-
ProviderValue = FrozenProviderValue
|
|
18
|
-
|
|
19
26
|
|
|
20
27
|
class ProviderState(BaseModel):
|
|
21
28
|
"""Items nativos necesarios para continuar sin pérdida con un provider."""
|
|
@@ -23,7 +30,7 @@ class ProviderState(BaseModel):
|
|
|
23
30
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
24
31
|
|
|
25
32
|
provider: str
|
|
26
|
-
items: tuple[
|
|
33
|
+
items: tuple[ProviderItem, ...]
|
|
27
34
|
|
|
28
35
|
@field_validator("items", mode="before")
|
|
29
36
|
@classmethod
|
|
@@ -34,14 +41,14 @@ class ProviderState(BaseModel):
|
|
|
34
41
|
@classmethod
|
|
35
42
|
def freeze_items(
|
|
36
43
|
cls,
|
|
37
|
-
value: tuple[
|
|
38
|
-
) -> tuple[
|
|
39
|
-
return tuple(
|
|
44
|
+
value: tuple[ProviderItem, ...],
|
|
45
|
+
) -> tuple[ProviderItem, ...]:
|
|
46
|
+
return tuple(freeze_provider_item(item) for item in value)
|
|
40
47
|
|
|
41
48
|
@field_serializer("items", when_used="json")
|
|
42
49
|
def encode_bytes(
|
|
43
50
|
self,
|
|
44
|
-
items: tuple[
|
|
51
|
+
items: tuple[ProviderItem, ...],
|
|
45
52
|
) -> tuple[dict[str, object], ...]:
|
|
46
53
|
return tuple(
|
|
47
54
|
{key: _encode_provider_bytes(value) for key, value in item.items()} for item in items
|
|
@@ -61,25 +68,22 @@ class ToolCallPart(BaseModel):
|
|
|
61
68
|
type: Literal["tool_call"] = "tool_call"
|
|
62
69
|
call_id: str
|
|
63
70
|
name: str
|
|
64
|
-
arguments:
|
|
71
|
+
arguments: JsonObject
|
|
65
72
|
|
|
66
73
|
@field_validator("arguments", mode="after")
|
|
67
74
|
@classmethod
|
|
68
75
|
def freeze_arguments(
|
|
69
76
|
cls,
|
|
70
|
-
value:
|
|
71
|
-
) ->
|
|
72
|
-
|
|
73
|
-
if not isinstance(frozen, Mapping):
|
|
74
|
-
raise TypeError("arguments debe ser un objeto")
|
|
75
|
-
return cast(Mapping[str, JsonValue], frozen)
|
|
77
|
+
value: JsonObject,
|
|
78
|
+
) -> JsonObject:
|
|
79
|
+
return freeze_json_object(value)
|
|
76
80
|
|
|
77
81
|
@field_serializer("arguments", when_used="json")
|
|
78
82
|
def serialize_arguments(
|
|
79
83
|
self,
|
|
80
|
-
value:
|
|
81
|
-
) -> dict[str,
|
|
82
|
-
return
|
|
84
|
+
value: JsonObject,
|
|
85
|
+
) -> dict[str, JsonValue]:
|
|
86
|
+
return thaw_json_object(value)
|
|
83
87
|
|
|
84
88
|
|
|
85
89
|
class ToolResultPart(BaseModel):
|
|
@@ -93,7 +97,7 @@ class ToolResultPart(BaseModel):
|
|
|
93
97
|
error_code: str | None = None
|
|
94
98
|
|
|
95
99
|
|
|
96
|
-
|
|
100
|
+
HistoryPart = Annotated[TextPart | ToolCallPart | ToolResultPart, Field(discriminator="type")]
|
|
97
101
|
|
|
98
102
|
|
|
99
103
|
class Turn(BaseModel):
|
|
@@ -102,9 +106,24 @@ class Turn(BaseModel):
|
|
|
102
106
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
103
107
|
|
|
104
108
|
role: Literal["user", "assistant", "tool"]
|
|
105
|
-
parts: tuple[
|
|
109
|
+
parts: tuple[HistoryPart, ...]
|
|
106
110
|
provider_state: ProviderState | None = None
|
|
107
111
|
|
|
112
|
+
@model_validator(mode="after")
|
|
113
|
+
def validate_role_contract(self) -> Self:
|
|
114
|
+
if self.role == "user":
|
|
115
|
+
valid_parts = all(isinstance(part, TextPart) for part in self.parts)
|
|
116
|
+
elif self.role == "assistant":
|
|
117
|
+
valid_parts = all(isinstance(part, TextPart | ToolCallPart) for part in self.parts)
|
|
118
|
+
else:
|
|
119
|
+
valid_parts = all(isinstance(part, ToolResultPart) for part in self.parts)
|
|
120
|
+
|
|
121
|
+
if not valid_parts:
|
|
122
|
+
raise ValueError(f"parts incompatibles con role={self.role}")
|
|
123
|
+
if self.role != "assistant" and self.provider_state is not None:
|
|
124
|
+
raise ValueError("provider_state requiere role=assistant")
|
|
125
|
+
return self
|
|
126
|
+
|
|
108
127
|
@classmethod
|
|
109
128
|
def user(cls, text: str) -> "Turn":
|
|
110
129
|
return cls(role="user", parts=(TextPart(text=text),))
|
|
@@ -133,13 +152,3 @@ def _decode_provider_bytes(value: object) -> object:
|
|
|
133
152
|
if set(value) == {_BYTES_TAG} and isinstance(value[_BYTES_TAG], str):
|
|
134
153
|
return base64.b64decode(value[_BYTES_TAG], validate=True)
|
|
135
154
|
return {key: _decode_provider_bytes(item) for key, item in value.items()}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def _freeze_provider_item(
|
|
139
|
-
value: Mapping[str, ProviderValue],
|
|
140
|
-
) -> Mapping[str, ProviderValue]:
|
|
141
|
-
frozen = freeze_provider(value)
|
|
142
|
-
if not isinstance(frozen, Mapping):
|
|
143
|
-
raise TypeError("provider item debe ser un objeto")
|
|
144
|
-
return frozen
|
|
145
|
-
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from collections.abc import Awaitable, Callable
|
|
1
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
2
2
|
from dataclasses import dataclass
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
|
|
@@ -13,5 +13,4 @@ class TransformContext:
|
|
|
13
13
|
session_id: str | None
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
HistoryTransform = Callable[[TransformContext], Awaitable[
|
|
17
|
-
|
|
16
|
+
HistoryTransform = Callable[[TransformContext], Awaitable[Sequence[Turn]]]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from types import MappingProxyType
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from pydantic import JsonValue
|
|
6
|
+
|
|
7
|
+
type FrozenJsonValue = (
|
|
8
|
+
bool | int | float | str | tuple[FrozenJsonValue, ...] | Mapping[str, FrozenJsonValue] | None
|
|
9
|
+
)
|
|
10
|
+
type JsonInputObject = Mapping[str, JsonValue]
|
|
11
|
+
type FrozenJsonObject = Mapping[str, FrozenJsonValue]
|
|
12
|
+
type JsonObject = JsonInputObject | FrozenJsonObject
|
|
13
|
+
type ProviderValue = (
|
|
14
|
+
bool
|
|
15
|
+
| int
|
|
16
|
+
| float
|
|
17
|
+
| str
|
|
18
|
+
| bytes
|
|
19
|
+
| tuple[ProviderValue, ...]
|
|
20
|
+
| Mapping[str, ProviderValue]
|
|
21
|
+
| None
|
|
22
|
+
)
|
|
23
|
+
type ProviderItem = Mapping[str, ProviderValue]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def freeze_json_object(value: JsonObject) -> FrozenJsonObject:
|
|
27
|
+
frozen = {key: _freeze_json(item) for key, item in value.items()}
|
|
28
|
+
return MappingProxyType(frozen)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def thaw_json_object(value: JsonObject) -> dict[str, JsonValue]:
|
|
32
|
+
return {key: _thaw_json(item) for key, item in value.items()}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def freeze_provider_item(value: ProviderItem) -> ProviderItem:
|
|
36
|
+
frozen = {key: _freeze_provider(item) for key, item in value.items()}
|
|
37
|
+
return MappingProxyType(frozen)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def thaw_provider_item(value: ProviderItem) -> dict[str, object]:
|
|
41
|
+
return {key: _thaw_provider(item) for key, item in value.items()}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _freeze_json(value: JsonValue | FrozenJsonValue) -> FrozenJsonValue:
|
|
45
|
+
if isinstance(value, list | tuple):
|
|
46
|
+
return tuple(_freeze_json(item) for item in value)
|
|
47
|
+
if isinstance(value, Mapping):
|
|
48
|
+
frozen = {key: _freeze_json(item) for key, item in value.items()}
|
|
49
|
+
return MappingProxyType(frozen)
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _thaw_json(value: JsonValue | FrozenJsonValue) -> JsonValue:
|
|
54
|
+
if isinstance(value, list | tuple):
|
|
55
|
+
return [_thaw_json(item) for item in value]
|
|
56
|
+
if isinstance(value, Mapping):
|
|
57
|
+
return {key: _thaw_json(item) for key, item in value.items()}
|
|
58
|
+
return cast(JsonValue, value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _freeze_provider(value: ProviderValue) -> ProviderValue:
|
|
62
|
+
if isinstance(value, tuple):
|
|
63
|
+
return tuple(_freeze_provider(item) for item in value)
|
|
64
|
+
if isinstance(value, Mapping):
|
|
65
|
+
frozen = {key: _freeze_provider(item) for key, item in value.items()}
|
|
66
|
+
return MappingProxyType(frozen)
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _thaw_provider(value: ProviderValue) -> object:
|
|
71
|
+
if isinstance(value, tuple):
|
|
72
|
+
return [_thaw_provider(item) for item in value]
|
|
73
|
+
if isinstance(value, Mapping):
|
|
74
|
+
return {key: _thaw_provider(item) for key, item in value.items()}
|
|
75
|
+
return value
|
|
@@ -11,24 +11,24 @@ from cortex_agent_sdk.engine import EngineRequest, EngineResult, ToolCall, Usage
|
|
|
11
11
|
from cortex_agent_sdk.errores import AppError, CodigoError
|
|
12
12
|
from cortex_agent_sdk.gateway import OpenAICompatibleGateway
|
|
13
13
|
from cortex_agent_sdk.history.models import (
|
|
14
|
+
HistoryPart,
|
|
14
15
|
ProviderState,
|
|
15
|
-
ProviderValue,
|
|
16
16
|
TextPart,
|
|
17
17
|
ToolCallPart,
|
|
18
18
|
ToolResultPart,
|
|
19
19
|
Turn,
|
|
20
20
|
)
|
|
21
21
|
from cortex_agent_sdk.immutable import (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
thaw_provider,
|
|
22
|
+
ProviderItem,
|
|
23
|
+
thaw_json_object,
|
|
24
|
+
thaw_provider_item,
|
|
26
25
|
)
|
|
26
|
+
from cortex_agent_sdk.openai.models import OpenAIModels
|
|
27
27
|
from cortex_agent_sdk.openai.options import OpenAIOptions
|
|
28
28
|
from cortex_agent_sdk.tools.models import ToolSpec
|
|
29
29
|
|
|
30
30
|
_JSON_OBJECT = TypeAdapter(dict[str, JsonValue])
|
|
31
|
-
_PROVIDER_OBJECT = TypeAdapter(
|
|
31
|
+
_PROVIDER_OBJECT = TypeAdapter(ProviderItem)
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
class OpenAIEngine:
|
|
@@ -52,9 +52,13 @@ class OpenAIEngine:
|
|
|
52
52
|
|
|
53
53
|
self._model = model
|
|
54
54
|
self._options = options or OpenAIOptions()
|
|
55
|
-
|
|
55
|
+
if client is None:
|
|
56
|
+
self._client = self._create_client(api_key, gateway)
|
|
57
|
+
else:
|
|
58
|
+
self._client = client
|
|
56
59
|
self._owns_client = client is None
|
|
57
60
|
self._closed = False
|
|
61
|
+
self._models = OpenAIModels(self._client, self._ensure_open)
|
|
58
62
|
|
|
59
63
|
@property
|
|
60
64
|
def provider(self) -> str:
|
|
@@ -64,17 +68,19 @@ class OpenAIEngine:
|
|
|
64
68
|
def model(self) -> str:
|
|
65
69
|
return self._model
|
|
66
70
|
|
|
71
|
+
@property
|
|
72
|
+
def models(self) -> OpenAIModels:
|
|
73
|
+
return self._models
|
|
74
|
+
|
|
67
75
|
async def __aenter__(self) -> Self:
|
|
68
|
-
|
|
69
|
-
raise AppError(CodigoError.RECURSO_CERRADO, "OpenAIEngine cerrado")
|
|
76
|
+
self._ensure_open()
|
|
70
77
|
return self
|
|
71
78
|
|
|
72
79
|
async def __aexit__(self, *_: object) -> None:
|
|
73
80
|
await self.aclose()
|
|
74
81
|
|
|
75
82
|
async def generate(self, request: EngineRequest) -> EngineResult:
|
|
76
|
-
|
|
77
|
-
raise AppError(CodigoError.RECURSO_CERRADO, "OpenAIEngine cerrado")
|
|
83
|
+
self._ensure_open()
|
|
78
84
|
|
|
79
85
|
payload = cast(dict[str, Any], self._build_payload(request))
|
|
80
86
|
try:
|
|
@@ -130,6 +136,10 @@ class OpenAIEngine:
|
|
|
130
136
|
) from error
|
|
131
137
|
self._closed = True
|
|
132
138
|
|
|
139
|
+
def _ensure_open(self) -> None:
|
|
140
|
+
if self._closed:
|
|
141
|
+
raise AppError(CodigoError.RECURSO_CERRADO, "OpenAIEngine cerrado")
|
|
142
|
+
|
|
133
143
|
def _create_client(
|
|
134
144
|
self,
|
|
135
145
|
api_key: str | None,
|
|
@@ -205,7 +215,7 @@ def _build_input(history: tuple[Turn, ...]) -> list[ResponseInputItemParam]:
|
|
|
205
215
|
"historial contiene estado de otro provider",
|
|
206
216
|
)
|
|
207
217
|
items.extend(
|
|
208
|
-
cast(ResponseInputItemParam,
|
|
218
|
+
cast(ResponseInputItemParam, thaw_provider_item(item))
|
|
209
219
|
for item in turn.provider_state.items
|
|
210
220
|
)
|
|
211
221
|
continue
|
|
@@ -226,9 +236,7 @@ def _build_input(history: tuple[Turn, ...]) -> list[ResponseInputItemParam]:
|
|
|
226
236
|
|
|
227
237
|
|
|
228
238
|
def _tool_payload(spec: ToolSpec) -> dict[str, object]:
|
|
229
|
-
parameters =
|
|
230
|
-
key: thaw_json(cast(FrozenJsonValue, value)) for key, value in spec.parameters.items()
|
|
231
|
-
}
|
|
239
|
+
parameters = thaw_json_object(spec.parameters)
|
|
232
240
|
return {
|
|
233
241
|
"type": "function",
|
|
234
242
|
"name": spec.name,
|
|
@@ -255,8 +263,8 @@ def _parse_tool_calls(output: list[object]) -> tuple[ToolCall, ...]:
|
|
|
255
263
|
return tuple(calls)
|
|
256
264
|
|
|
257
265
|
|
|
258
|
-
def _build_parts(text: str, calls: tuple[ToolCall, ...]):
|
|
259
|
-
parts: list[
|
|
266
|
+
def _build_parts(text: str, calls: tuple[ToolCall, ...]) -> tuple[HistoryPart, ...]:
|
|
267
|
+
parts: list[HistoryPart] = []
|
|
260
268
|
if text:
|
|
261
269
|
parts.append(TextPart(text=text))
|
|
262
270
|
parts.extend(
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
import openai
|
|
5
|
+
from openai import AsyncOpenAI
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
|
7
|
+
|
|
8
|
+
from cortex_agent_sdk.errores import AppError, CodigoError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenAIModel(BaseModel):
|
|
12
|
+
"""Modelo disponible según el catálogo nativo de OpenAI."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
created: int
|
|
18
|
+
object: Literal["model"]
|
|
19
|
+
owned_by: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_OPENAI_MODELS = TypeAdapter(tuple[OpenAIModel, ...])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OpenAIModels:
|
|
26
|
+
"""Consulta el catálogo con el mismo cliente que usa el engine."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, client: AsyncOpenAI, ensure_open: Callable[[], None]) -> None:
|
|
29
|
+
self._client = client
|
|
30
|
+
self._ensure_open = ensure_open
|
|
31
|
+
|
|
32
|
+
async def list(self) -> tuple[OpenAIModel, ...]:
|
|
33
|
+
self._ensure_open()
|
|
34
|
+
try:
|
|
35
|
+
response = await self._client.models.list()
|
|
36
|
+
except openai.APITimeoutError as error:
|
|
37
|
+
raise AppError(CodigoError.PROVIDER_TIMEOUT, "OpenAI agotó el timeout") from error
|
|
38
|
+
except openai.APIStatusError as error:
|
|
39
|
+
request_id = getattr(error, "request_id", None)
|
|
40
|
+
detail = f"OpenAI respondió {error.status_code}, request_id={request_id}"
|
|
41
|
+
raise AppError(CodigoError.PROVIDER_FALLO, detail) from error
|
|
42
|
+
except openai.APIConnectionError as error:
|
|
43
|
+
raise AppError(CodigoError.PROVIDER_FALLO, "falló la conexión con OpenAI") from error
|
|
44
|
+
except openai.APIError as error:
|
|
45
|
+
detail = f"OpenAI SDK lanzó {type(error).__name__}"
|
|
46
|
+
raise AppError(CodigoError.PROVIDER_FALLO, detail) from error
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
return _OPENAI_MODELS.validate_python(
|
|
50
|
+
response.data,
|
|
51
|
+
from_attributes=True,
|
|
52
|
+
)
|
|
53
|
+
except ValidationError as error:
|
|
54
|
+
raise AppError(
|
|
55
|
+
CodigoError.PROVIDER_RESPUESTA_INVALIDA,
|
|
56
|
+
"OpenAI devolvió un catálogo de modelos inválido",
|
|
57
|
+
) from error
|
|
@@ -2,6 +2,7 @@ import asyncio
|
|
|
2
2
|
import time
|
|
3
3
|
from collections.abc import AsyncIterator
|
|
4
4
|
from contextlib import asynccontextmanager, suppress
|
|
5
|
+
from dataclasses import dataclass
|
|
5
6
|
from datetime import UTC, datetime
|
|
6
7
|
from hashlib import sha256
|
|
7
8
|
from typing import Self, cast
|
|
@@ -19,6 +20,13 @@ from cortex_agent_sdk.sessions.store import SessionLease
|
|
|
19
20
|
type _RedisArgument = str | bytes | int | float
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class _RedisKeys:
|
|
25
|
+
lease: str
|
|
26
|
+
record: str
|
|
27
|
+
fence: str
|
|
28
|
+
|
|
29
|
+
|
|
22
30
|
class _RedisLease:
|
|
23
31
|
def __init__(
|
|
24
32
|
self,
|
|
@@ -95,7 +103,10 @@ class RedisSessionStore:
|
|
|
95
103
|
if not key_prefix or min(durations) <= 0:
|
|
96
104
|
raise AppError(CodigoError.CONFIG_INVALIDA, "configuración de Redis inválida")
|
|
97
105
|
|
|
98
|
-
|
|
106
|
+
if client is None:
|
|
107
|
+
self._client = Redis.from_url(cast(str, url), decode_responses=False)
|
|
108
|
+
else:
|
|
109
|
+
self._client = client
|
|
99
110
|
self._owns_client = client is None
|
|
100
111
|
self._key_prefix = key_prefix.rstrip(":")
|
|
101
112
|
self._ttl_milliseconds = _milliseconds(ttl_seconds)
|
|
@@ -138,9 +149,10 @@ class RedisSessionStore:
|
|
|
138
149
|
|
|
139
150
|
async def reset(self, session_id: str, timeout_seconds: float = 5.0) -> None:
|
|
140
151
|
async with self.acquire(session_id, timeout_seconds) as lease:
|
|
152
|
+
keys = self._keys(session_id)
|
|
141
153
|
result = await self._eval(
|
|
142
154
|
scripts.RESET,
|
|
143
|
-
|
|
155
|
+
(keys.lease, keys.record),
|
|
144
156
|
self._lease_value(lease),
|
|
145
157
|
)
|
|
146
158
|
if int(cast(int, result)) != 1:
|
|
@@ -166,7 +178,7 @@ class RedisSessionStore:
|
|
|
166
178
|
self._closed = True
|
|
167
179
|
|
|
168
180
|
async def _acquire(self, session_id: str, owner: str, timeout_seconds: float) -> int:
|
|
169
|
-
|
|
181
|
+
keys = self._keys(session_id)
|
|
170
182
|
deadline = time.monotonic() + timeout_seconds
|
|
171
183
|
while True:
|
|
172
184
|
remaining = deadline - time.monotonic()
|
|
@@ -174,7 +186,7 @@ class RedisSessionStore:
|
|
|
174
186
|
raise AppError(CodigoError.SESION_OCUPADA, f"sesión ocupada: {session_id}")
|
|
175
187
|
result = await self._eval(
|
|
176
188
|
scripts.ACQUIRE,
|
|
177
|
-
(
|
|
189
|
+
(keys.lease, keys.fence),
|
|
178
190
|
owner,
|
|
179
191
|
self._lease_milliseconds,
|
|
180
192
|
timeout_seconds=remaining,
|
|
@@ -188,16 +200,16 @@ class RedisSessionStore:
|
|
|
188
200
|
await asyncio.sleep(min(self._retry_interval_seconds, remaining))
|
|
189
201
|
|
|
190
202
|
async def _verify(self, session_id: str, lease_value: str) -> None:
|
|
191
|
-
|
|
192
|
-
result = await self._eval(scripts.VERIFY, (
|
|
203
|
+
keys = self._keys(session_id)
|
|
204
|
+
result = await self._eval(scripts.VERIFY, (keys.lease,), lease_value)
|
|
193
205
|
if int(cast(int, result)) != 1:
|
|
194
206
|
raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
|
|
195
207
|
|
|
196
208
|
async def _renew(self, session_id: str, lease_value: str) -> None:
|
|
197
|
-
|
|
209
|
+
keys = self._keys(session_id)
|
|
198
210
|
result = await self._eval(
|
|
199
211
|
scripts.RENEW,
|
|
200
|
-
(
|
|
212
|
+
(keys.lease,),
|
|
201
213
|
lease_value,
|
|
202
214
|
self._lease_milliseconds,
|
|
203
215
|
)
|
|
@@ -205,10 +217,10 @@ class RedisSessionStore:
|
|
|
205
217
|
raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
|
|
206
218
|
|
|
207
219
|
async def _load(self, session_id: str, lease_value: str) -> SessionRecord | None:
|
|
208
|
-
|
|
220
|
+
keys = self._keys(session_id)
|
|
209
221
|
raw = await self._eval(
|
|
210
222
|
scripts.LOAD,
|
|
211
|
-
(
|
|
223
|
+
(keys.lease, keys.record),
|
|
212
224
|
lease_value,
|
|
213
225
|
self._ttl_milliseconds,
|
|
214
226
|
)
|
|
@@ -226,10 +238,10 @@ class RedisSessionStore:
|
|
|
226
238
|
"updated_at": datetime.now(UTC),
|
|
227
239
|
}
|
|
228
240
|
)
|
|
229
|
-
|
|
241
|
+
keys = self._keys(record.session_id)
|
|
230
242
|
result = await self._eval(
|
|
231
243
|
scripts.SAVE,
|
|
232
|
-
(
|
|
244
|
+
(keys.lease, keys.record),
|
|
233
245
|
lease_value,
|
|
234
246
|
record.version,
|
|
235
247
|
saved.version,
|
|
@@ -244,8 +256,8 @@ class RedisSessionStore:
|
|
|
244
256
|
raise AppError(CodigoError.SESION_LEASE_PERDIDO, "ownership de sesión perdido")
|
|
245
257
|
|
|
246
258
|
async def _release(self, session_id: str, lease_value: str) -> None:
|
|
247
|
-
|
|
248
|
-
await self._eval(scripts.RELEASE, (
|
|
259
|
+
keys = self._keys(session_id)
|
|
260
|
+
await self._eval(scripts.RELEASE, (keys.lease,), lease_value)
|
|
249
261
|
|
|
250
262
|
async def _eval(
|
|
251
263
|
self,
|
|
@@ -270,10 +282,14 @@ class RedisSessionStore:
|
|
|
270
282
|
f"Redis lanzó {type(error).__name__}",
|
|
271
283
|
) from error
|
|
272
284
|
|
|
273
|
-
def _keys(self, session_id: str) ->
|
|
285
|
+
def _keys(self, session_id: str) -> _RedisKeys:
|
|
274
286
|
digest = sha256(session_id.encode()).hexdigest()
|
|
275
287
|
base = f"{self._key_prefix}:{{{digest}}}"
|
|
276
|
-
return
|
|
288
|
+
return _RedisKeys(
|
|
289
|
+
lease=f"{base}:lease",
|
|
290
|
+
record=f"{base}:record",
|
|
291
|
+
fence=f"{base}:fence",
|
|
292
|
+
)
|
|
277
293
|
|
|
278
294
|
def _lease_value(self, lease: SessionLease) -> str:
|
|
279
295
|
if not isinstance(lease, _RedisLease):
|
|
@@ -290,4 +306,3 @@ def _milliseconds(seconds: float) -> int:
|
|
|
290
306
|
if value < 1:
|
|
291
307
|
raise AppError(CodigoError.CONFIG_INVALIDA, "duración Redis demasiado pequeña")
|
|
292
308
|
return value
|
|
293
|
-
|
|
@@ -2,14 +2,14 @@ import inspect
|
|
|
2
2
|
from collections.abc import Mapping
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from types import MappingProxyType
|
|
5
|
-
from typing import Annotated, Any,
|
|
5
|
+
from typing import Annotated, Any, get_args, get_origin, get_type_hints
|
|
6
6
|
|
|
7
7
|
from jsonschema import Draft202012Validator
|
|
8
8
|
from jsonschema.exceptions import SchemaError
|
|
9
9
|
from pydantic import BaseModel, ConfigDict, JsonValue, create_model
|
|
10
10
|
|
|
11
11
|
from cortex_agent_sdk.errores import AppError, CodigoError
|
|
12
|
-
from cortex_agent_sdk.immutable import
|
|
12
|
+
from cortex_agent_sdk.immutable import thaw_json_object
|
|
13
13
|
from cortex_agent_sdk.tools.models import Injected, ToolBinding, ToolFunction, ToolSpec
|
|
14
14
|
|
|
15
15
|
|
|
@@ -148,5 +148,4 @@ def _explicit_validator(
|
|
|
148
148
|
|
|
149
149
|
|
|
150
150
|
def _plain_schema(spec: ToolSpec) -> dict[str, JsonValue]:
|
|
151
|
-
return
|
|
152
|
-
|
|
151
|
+
return thaw_json_object(spec.parameters)
|
|
@@ -3,14 +3,13 @@ import json
|
|
|
3
3
|
from collections.abc import Iterable, Mapping
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
from types import MappingProxyType
|
|
6
|
-
from typing import cast
|
|
7
6
|
|
|
8
7
|
from pydantic import JsonValue, TypeAdapter, ValidationError
|
|
9
8
|
|
|
10
9
|
from cortex_agent_sdk.engine import ToolCall
|
|
11
10
|
from cortex_agent_sdk.errores import AppError, CodigoError
|
|
12
11
|
from cortex_agent_sdk.history.models import ToolResultPart
|
|
13
|
-
from cortex_agent_sdk.immutable import
|
|
12
|
+
from cortex_agent_sdk.immutable import thaw_json_object
|
|
14
13
|
from cortex_agent_sdk.tools.contracts import ToolDefinition, build_definition
|
|
15
14
|
from cortex_agent_sdk.tools.models import ToolBinding, ToolFunction, ToolSpec
|
|
16
15
|
|
|
@@ -132,9 +131,7 @@ def _validate_arguments(
|
|
|
132
131
|
definition: ToolDefinition,
|
|
133
132
|
call: ToolCall,
|
|
134
133
|
) -> dict[str, object] | ToolOutcome:
|
|
135
|
-
public_input =
|
|
136
|
-
key: thaw_json(cast(FrozenJsonValue, value)) for key, value in call.arguments.items()
|
|
137
|
-
}
|
|
134
|
+
public_input = thaw_json_object(call.arguments)
|
|
138
135
|
if definition.schema_validator is not None:
|
|
139
136
|
schema_error = next(definition.schema_validator.iter_errors(public_input), None)
|
|
140
137
|
if schema_error is not None:
|
|
@@ -178,4 +175,3 @@ def _failed_outcome(
|
|
|
178
175
|
payload = {"ok": False, "error": {"code": code.value, "message": message}}
|
|
179
176
|
output = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
180
177
|
return ToolOutcome(call, output, True, is_final_answer, code.value)
|
|
181
|
-
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
from collections.abc import Awaitable, Callable, Mapping
|
|
2
2
|
from dataclasses import dataclass, field
|
|
3
|
-
from
|
|
3
|
+
from types import MappingProxyType
|
|
4
4
|
|
|
5
5
|
from pydantic import BaseModel, ConfigDict, JsonValue, field_serializer, field_validator
|
|
6
6
|
|
|
7
|
-
from cortex_agent_sdk.immutable import
|
|
7
|
+
from cortex_agent_sdk.immutable import JsonObject, freeze_json_object, thaw_json_object
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class _InjectedMarker:
|
|
@@ -22,26 +22,23 @@ class ToolSpec(BaseModel):
|
|
|
22
22
|
|
|
23
23
|
name: str
|
|
24
24
|
description: str = ""
|
|
25
|
-
parameters:
|
|
25
|
+
parameters: JsonObject
|
|
26
26
|
strict: bool = False
|
|
27
27
|
|
|
28
28
|
@field_validator("parameters", mode="after")
|
|
29
29
|
@classmethod
|
|
30
30
|
def freeze_parameters(
|
|
31
31
|
cls,
|
|
32
|
-
value:
|
|
33
|
-
) ->
|
|
34
|
-
|
|
35
|
-
if not isinstance(frozen, Mapping):
|
|
36
|
-
raise TypeError("parameters debe ser un objeto")
|
|
37
|
-
return cast(Mapping[str, JsonValue], frozen)
|
|
32
|
+
value: JsonObject,
|
|
33
|
+
) -> JsonObject:
|
|
34
|
+
return freeze_json_object(value)
|
|
38
35
|
|
|
39
36
|
@field_serializer("parameters", when_used="json")
|
|
40
37
|
def serialize_parameters(
|
|
41
38
|
self,
|
|
42
|
-
value:
|
|
43
|
-
) -> dict[str,
|
|
44
|
-
return
|
|
39
|
+
value: JsonObject,
|
|
40
|
+
) -> dict[str, JsonValue]:
|
|
41
|
+
return thaw_json_object(value)
|
|
45
42
|
|
|
46
43
|
|
|
47
44
|
ToolFunction = Callable[..., Awaitable[object]]
|
|
@@ -55,3 +52,6 @@ class ToolBinding:
|
|
|
55
52
|
spec: ToolSpec | None = None
|
|
56
53
|
private_arguments: Mapping[str, object] = field(default_factory=dict)
|
|
57
54
|
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
frozen_arguments = MappingProxyType(dict(self.private_arguments))
|
|
57
|
+
object.__setattr__(self, "private_arguments", frozen_arguments)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Espacio reservado para el engine de Google posterior a 0.0.1."""
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
from collections.abc import Mapping
|
|
2
|
-
from types import MappingProxyType
|
|
3
|
-
from typing import cast
|
|
4
|
-
|
|
5
|
-
from pydantic import JsonValue
|
|
6
|
-
|
|
7
|
-
type FrozenJsonValue = (
|
|
8
|
-
bool | int | float | str | tuple[FrozenJsonValue, ...] | Mapping[str, FrozenJsonValue] | None
|
|
9
|
-
)
|
|
10
|
-
type FrozenProviderValue = (
|
|
11
|
-
bool
|
|
12
|
-
| int
|
|
13
|
-
| float
|
|
14
|
-
| str
|
|
15
|
-
| bytes
|
|
16
|
-
| tuple[FrozenProviderValue, ...]
|
|
17
|
-
| Mapping[str, FrozenProviderValue]
|
|
18
|
-
| None
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def freeze_json(value: JsonValue | Mapping[str, JsonValue]) -> FrozenJsonValue:
|
|
23
|
-
if isinstance(value, list):
|
|
24
|
-
return tuple(freeze_json(item) for item in value)
|
|
25
|
-
if isinstance(value, Mapping):
|
|
26
|
-
frozen = {key: freeze_json(item) for key, item in value.items()}
|
|
27
|
-
return MappingProxyType(frozen)
|
|
28
|
-
return value
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def thaw_json(value: FrozenJsonValue) -> JsonValue:
|
|
32
|
-
if isinstance(value, tuple):
|
|
33
|
-
return [thaw_json(item) for item in value]
|
|
34
|
-
if isinstance(value, Mapping):
|
|
35
|
-
return {key: thaw_json(item) for key, item in value.items()}
|
|
36
|
-
return cast(JsonValue, value)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def freeze_provider(value: FrozenProviderValue) -> FrozenProviderValue:
|
|
40
|
-
if isinstance(value, tuple):
|
|
41
|
-
return tuple(freeze_provider(item) for item in value)
|
|
42
|
-
if isinstance(value, Mapping):
|
|
43
|
-
frozen = {key: freeze_provider(item) for key, item in value.items()}
|
|
44
|
-
return MappingProxyType(frozen)
|
|
45
|
-
return value
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def thaw_provider(value: FrozenProviderValue) -> object:
|
|
49
|
-
if isinstance(value, tuple):
|
|
50
|
-
return [thaw_provider(item) for item in value]
|
|
51
|
-
if isinstance(value, Mapping):
|
|
52
|
-
return {key: thaw_provider(item) for key, item in value.items()}
|
|
53
|
-
return value
|
|
54
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|