cortex-agent-sdk 0.0.2__tar.gz → 0.0.4__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 (46) hide show
  1. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/PKG-INFO +72 -14
  2. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/README.md +71 -13
  3. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/pyproject.toml +1 -1
  4. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/agent.py +5 -2
  5. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/engine.py +19 -15
  6. cortex_agent_sdk-0.0.4/src/cortex_agent_sdk/google/__init__.py +1 -0
  7. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/history/models.py +46 -37
  8. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/history/transform.py +2 -3
  9. cortex_agent_sdk-0.0.4/src/cortex_agent_sdk/immutable.py +75 -0
  10. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/openai/__init__.py +1 -0
  11. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/openai/engine.py +25 -17
  12. cortex_agent_sdk-0.0.4/src/cortex_agent_sdk/openai/models.py +57 -0
  13. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/redis/store.py +32 -17
  14. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/tools/contracts.py +139 -27
  15. cortex_agent_sdk-0.0.4/src/cortex_agent_sdk/tools/decorators.py +65 -0
  16. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/tools/execution.py +8 -7
  17. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/tools/models.py +14 -13
  18. cortex_agent_sdk-0.0.2/src/cortex_agent_sdk/google/__init__.py +0 -1
  19. cortex_agent_sdk-0.0.2/src/cortex_agent_sdk/immutable.py +0 -54
  20. cortex_agent_sdk-0.0.2/src/cortex_agent_sdk/tools/decorators.py +0 -15
  21. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/.gitignore +0 -0
  22. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/LICENSE +0 -0
  23. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/__init__.py +0 -0
  24. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/errores/__init__.py +0 -0
  25. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/errores/catalogo.py +0 -0
  26. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/errores/excepcion.py +0 -0
  27. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/gateway.py +0 -0
  28. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/history/__init__.py +0 -0
  29. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/history/pipeline.py +0 -0
  30. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/hooks.py +0 -0
  31. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/lifecycle.py +0 -0
  32. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/openai/options.py +0 -0
  33. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/postgres/__init__.py +0 -0
  34. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/postgres/store.py +0 -0
  35. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/py.typed +0 -0
  36. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/redis/__init__.py +0 -0
  37. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/redis/scripts.py +0 -0
  38. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/results.py +0 -0
  39. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/runtime.py +0 -0
  40. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/sessions/__init__.py +0 -0
  41. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/sessions/codec.py +0 -0
  42. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/sessions/lease.py +0 -0
  43. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/sessions/memory.py +0 -0
  44. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/sessions/models.py +0 -0
  45. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/sessions/store.py +0 -0
  46. {cortex_agent_sdk-0.0.2 → cortex_agent_sdk-0.0.4}/src/cortex_agent_sdk/tools/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortex-agent-sdk
3
- Version: 0.0.2
3
+ Version: 0.0.4
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
@@ -137,10 +137,51 @@ async def main() -> None:
137
137
  asyncio.run(main())
138
138
  ```
139
139
 
140
+ Los type hints son la vía recomendada para tools sencillas. Cortex infiere el contrato visible y
141
+ valida los argumentos antes de ejecutar la función; no hace falta escribir JSON Schema ni
142
+ `ToolSpec` manualmente.
143
+
144
+ ### Contratos Pydantic
145
+
146
+ Cuando los argumentos forman un contrato reutilizable o tienen validaciones entre campos, puede
147
+ usarse un `BaseModel` como fuente de verdad:
148
+
149
+ ```python
150
+ from datetime import datetime
151
+ from typing import Self
152
+
153
+ from pydantic import BaseModel, Field, model_validator
154
+
155
+ from cortex_agent_sdk import final_answer
156
+
157
+
158
+ class ReminderArgs(BaseModel):
159
+ message: str = Field(min_length=1, description="Texto del recordatorio.")
160
+ when: datetime | None = None
161
+ cron: str | None = None
162
+
163
+ @model_validator(mode="after")
164
+ def validate_mode(self) -> Self:
165
+ if (self.when is None) == (self.cron is None):
166
+ raise ValueError("se requiere exactamente when o cron")
167
+ return self
168
+
169
+
170
+ @final_answer(input_model=ReminderArgs)
171
+ async def schedule_reminder(args: ReminderArgs) -> str:
172
+ """Programa un recordatorio."""
173
+ return f"Recordatorio: {args.message}"
174
+ ```
175
+
176
+ Cortex genera el `ToolSpec` neutral a partir del modelo, rechaza propiedades adicionales y entrega a
177
+ la función una instancia ya validada. `ToolBinding(input_model=...)` ofrece la misma capacidad para
178
+ tools construidas en runtime. El `ToolSpec` manual continúa disponible como escape hatch cuando el
179
+ schema visible necesita construirse dinámicamente o requiere control de bajo nivel.
180
+
140
181
  ## Capacidades del alfa
141
182
 
142
183
  - Loop async acotado.
143
- - Tools async con schema inferido o explícito.
184
+ - Tools async con schema inferido, Pydantic explícito o `ToolSpec` manual.
144
185
  - Historial y sesiones en memoria, Redis o PostgreSQL.
145
186
  - Hooks locales.
146
187
  - Timeouts para providers y tools.
@@ -159,6 +200,7 @@ renovable, fencing token y compare-and-swap.
159
200
  Redis no requiere inicialización de schema:
160
201
 
161
202
  ```python
203
+ import asyncio
162
204
  import os
163
205
 
164
206
  from cortex_agent_sdk import Agent
@@ -166,26 +208,38 @@ from cortex_agent_sdk.openai import OpenAIEngine
166
208
  from cortex_agent_sdk.redis import RedisSessionStore
167
209
 
168
210
 
169
- store = RedisSessionStore(os.environ["REDIS_URL"])
170
- agent = Agent(
171
- OpenAIEngine("gpt-5.6-luna"),
172
- session_store=store,
173
- own_session_store=True,
174
- )
175
- result = await agent.run("Hola", session_id="producto:tenant:usuario")
176
- await agent.aclose()
211
+ async def main() -> None:
212
+ store = RedisSessionStore(os.environ["REDIS_URL"])
213
+ async with Agent(
214
+ OpenAIEngine("gpt-5.6-luna"),
215
+ session_store=store,
216
+ own_session_store=True,
217
+ ) as agent:
218
+ result = await agent.run("Hola", session_id="producto:tenant:usuario")
219
+ print(result.text)
220
+
221
+
222
+ asyncio.run(main())
177
223
  ```
178
224
 
179
225
  PostgreSQL exige crear su tabla de forma explícita una vez:
180
226
 
181
227
  ```python
228
+ import asyncio
182
229
  import os
183
230
 
184
231
  from cortex_agent_sdk.postgres import PostgresSessionStore
185
232
 
186
233
 
187
- store = PostgresSessionStore(os.environ["POSTGRES_URL"])
188
- await store.setup()
234
+ async def main() -> None:
235
+ store = PostgresSessionStore(os.environ["POSTGRES_URL"])
236
+ try:
237
+ await store.setup()
238
+ finally:
239
+ await store.aclose()
240
+
241
+
242
+ asyncio.run(main())
189
243
  ```
190
244
 
191
245
  Una tarea periódica puede ejecutar `await store.cleanup_expired()` para vaciar historiales vencidos
@@ -193,8 +247,12 @@ que nunca volvieron a solicitarse. El row mínimo permanece para conservar el fe
193
247
 
194
248
  `Agent.aclose()` hace un cierre ordenado: deja de aceptar turnos nuevos, espera los turnos activos y
195
249
  después cierra los recursos que posee. El límite se configura con
196
- `AgentOptions.shutdown_timeout_seconds`. Si vence, el SDK no cancela el turno ni cierra conexiones;
197
- regresa `RUNTIME_CIERRE_TIMEOUT` para que la aplicación pueda reintentar el cierre.
250
+ `AgentOptions.shutdown_timeout_seconds` y cubre tanto el drenado como el cierre físico. Si vence
251
+ mientras hay un turno activo, el SDK no lo cancela ni empieza a cerrar recursos. Si vence durante el
252
+ cierre físico, algunos recursos podrían haberse cerrado ya. El timeout es un presupuesto de cierre,
253
+ no una garantía estricta de tiempo de pared: un finalizador que resista la cancelación puede retrasar
254
+ el retorno para no abandonar recursos a medias. Al excederlo regresa `RUNTIME_CIERRE_TIMEOUT` y una
255
+ segunda llamada a `aclose()` reintenta lo pendiente.
198
256
 
199
257
  Una sesión dañada o deliberadamente descartada se elimina mediante
200
258
  `await agent.reset_session(session_id)`.
@@ -103,10 +103,51 @@ async def main() -> None:
103
103
  asyncio.run(main())
104
104
  ```
105
105
 
106
+ Los type hints son la vía recomendada para tools sencillas. Cortex infiere el contrato visible y
107
+ valida los argumentos antes de ejecutar la función; no hace falta escribir JSON Schema ni
108
+ `ToolSpec` manualmente.
109
+
110
+ ### Contratos Pydantic
111
+
112
+ Cuando los argumentos forman un contrato reutilizable o tienen validaciones entre campos, puede
113
+ usarse un `BaseModel` como fuente de verdad:
114
+
115
+ ```python
116
+ from datetime import datetime
117
+ from typing import Self
118
+
119
+ from pydantic import BaseModel, Field, model_validator
120
+
121
+ from cortex_agent_sdk import final_answer
122
+
123
+
124
+ class ReminderArgs(BaseModel):
125
+ message: str = Field(min_length=1, description="Texto del recordatorio.")
126
+ when: datetime | None = None
127
+ cron: str | None = None
128
+
129
+ @model_validator(mode="after")
130
+ def validate_mode(self) -> Self:
131
+ if (self.when is None) == (self.cron is None):
132
+ raise ValueError("se requiere exactamente when o cron")
133
+ return self
134
+
135
+
136
+ @final_answer(input_model=ReminderArgs)
137
+ async def schedule_reminder(args: ReminderArgs) -> str:
138
+ """Programa un recordatorio."""
139
+ return f"Recordatorio: {args.message}"
140
+ ```
141
+
142
+ Cortex genera el `ToolSpec` neutral a partir del modelo, rechaza propiedades adicionales y entrega a
143
+ la función una instancia ya validada. `ToolBinding(input_model=...)` ofrece la misma capacidad para
144
+ tools construidas en runtime. El `ToolSpec` manual continúa disponible como escape hatch cuando el
145
+ schema visible necesita construirse dinámicamente o requiere control de bajo nivel.
146
+
106
147
  ## Capacidades del alfa
107
148
 
108
149
  - Loop async acotado.
109
- - Tools async con schema inferido o explícito.
150
+ - Tools async con schema inferido, Pydantic explícito o `ToolSpec` manual.
110
151
  - Historial y sesiones en memoria, Redis o PostgreSQL.
111
152
  - Hooks locales.
112
153
  - Timeouts para providers y tools.
@@ -125,6 +166,7 @@ renovable, fencing token y compare-and-swap.
125
166
  Redis no requiere inicialización de schema:
126
167
 
127
168
  ```python
169
+ import asyncio
128
170
  import os
129
171
 
130
172
  from cortex_agent_sdk import Agent
@@ -132,26 +174,38 @@ from cortex_agent_sdk.openai import OpenAIEngine
132
174
  from cortex_agent_sdk.redis import RedisSessionStore
133
175
 
134
176
 
135
- store = RedisSessionStore(os.environ["REDIS_URL"])
136
- agent = Agent(
137
- OpenAIEngine("gpt-5.6-luna"),
138
- session_store=store,
139
- own_session_store=True,
140
- )
141
- result = await agent.run("Hola", session_id="producto:tenant:usuario")
142
- await agent.aclose()
177
+ async def main() -> None:
178
+ store = RedisSessionStore(os.environ["REDIS_URL"])
179
+ async with Agent(
180
+ OpenAIEngine("gpt-5.6-luna"),
181
+ session_store=store,
182
+ own_session_store=True,
183
+ ) as agent:
184
+ result = await agent.run("Hola", session_id="producto:tenant:usuario")
185
+ print(result.text)
186
+
187
+
188
+ asyncio.run(main())
143
189
  ```
144
190
 
145
191
  PostgreSQL exige crear su tabla de forma explícita una vez:
146
192
 
147
193
  ```python
194
+ import asyncio
148
195
  import os
149
196
 
150
197
  from cortex_agent_sdk.postgres import PostgresSessionStore
151
198
 
152
199
 
153
- store = PostgresSessionStore(os.environ["POSTGRES_URL"])
154
- await store.setup()
200
+ async def main() -> None:
201
+ store = PostgresSessionStore(os.environ["POSTGRES_URL"])
202
+ try:
203
+ await store.setup()
204
+ finally:
205
+ await store.aclose()
206
+
207
+
208
+ asyncio.run(main())
155
209
  ```
156
210
 
157
211
  Una tarea periódica puede ejecutar `await store.cleanup_expired()` para vaciar historiales vencidos
@@ -159,8 +213,12 @@ que nunca volvieron a solicitarse. El row mínimo permanece para conservar el fe
159
213
 
160
214
  `Agent.aclose()` hace un cierre ordenado: deja de aceptar turnos nuevos, espera los turnos activos y
161
215
  después cierra los recursos que posee. El límite se configura con
162
- `AgentOptions.shutdown_timeout_seconds`. Si vence, el SDK no cancela el turno ni cierra conexiones;
163
- regresa `RUNTIME_CIERRE_TIMEOUT` para que la aplicación pueda reintentar el cierre.
216
+ `AgentOptions.shutdown_timeout_seconds` y cubre tanto el drenado como el cierre físico. Si vence
217
+ mientras hay un turno activo, el SDK no lo cancela ni empieza a cerrar recursos. Si vence durante el
218
+ cierre físico, algunos recursos podrían haberse cerrado ya. El timeout es un presupuesto de cierre,
219
+ no una garantía estricta de tiempo de pared: un finalizador que resista la cancelación puede retrasar
220
+ el retorno para no abandonar recursos a medias. Al excederlo regresa `RUNTIME_CIERRE_TIMEOUT` y una
221
+ segunda llamada a `aclose()` reintenta lo pendiente.
164
222
 
165
223
  Una sesión dañada o deliberadamente descartada se elimina mediante
166
224
  `await agent.reset_session(session_id)`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cortex-agent-sdk"
3
- version = "0.0.2"
3
+ version = "0.0.4"
4
4
  description = "SDK async y multiproveedor para construir agentes con control explícito"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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
- self._session_store = session_store or MemorySessionStore()
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, cast, runtime_checkable
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 FrozenJsonValue, freeze_json, thaw_json
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: Mapping[str, JsonValue]
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: Mapping[str, JsonValue],
25
- ) -> Mapping[str, JsonValue]:
26
- frozen = freeze_json(value)
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: Mapping[str, JsonValue],
35
- ) -> dict[str, object]:
36
- return {key: thaw_json(cast(FrozenJsonValue, item)) for key, item in value.items()}
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
- if not isinstance(self.usage, Usage) or not self.provider or not self.model:
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, cast
4
-
5
- from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_serializer, field_validator
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
- FrozenJsonValue,
9
- FrozenProviderValue,
10
- freeze_json,
11
- freeze_provider,
12
- thaw_json,
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[Mapping[str, ProviderValue], ...]
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[Mapping[str, ProviderValue], ...],
38
- ) -> tuple[Mapping[str, ProviderValue], ...]:
39
- return tuple(_freeze_provider_item(item) for item in value)
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[Mapping[str, ProviderValue], ...],
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: Mapping[str, JsonValue]
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: Mapping[str, JsonValue],
71
- ) -> Mapping[str, JsonValue]:
72
- frozen = freeze_json(value)
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: Mapping[str, JsonValue],
81
- ) -> dict[str, object]:
82
- return {key: thaw_json(cast(FrozenJsonValue, item)) for key, item in value.items()}
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
- Part = Annotated[TextPart | ToolCallPart | ToolResultPart, Field(discriminator="type")]
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[Part, ...]
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[tuple[Turn, ...] | list[Turn]]]
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
@@ -1,2 +1,3 @@
1
1
  from cortex_agent_sdk.openai.engine import OpenAIEngine
2
+ from cortex_agent_sdk.openai.models import OpenAIModel, OpenAIModels
2
3
  from cortex_agent_sdk.openai.options import OpenAIOptions