cortex-agent-sdk 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. cortex_agent_sdk/__init__.py +7 -0
  2. cortex_agent_sdk/agent.py +321 -0
  3. cortex_agent_sdk/engine.py +150 -0
  4. cortex_agent_sdk/errores/__init__.py +2 -0
  5. cortex_agent_sdk/errores/catalogo.py +35 -0
  6. cortex_agent_sdk/errores/excepcion.py +19 -0
  7. cortex_agent_sdk/gateway.py +23 -0
  8. cortex_agent_sdk/google/__init__.py +1 -0
  9. cortex_agent_sdk/history/__init__.py +2 -0
  10. cortex_agent_sdk/history/models.py +145 -0
  11. cortex_agent_sdk/history/pipeline.py +53 -0
  12. cortex_agent_sdk/history/transform.py +17 -0
  13. cortex_agent_sdk/hooks.py +102 -0
  14. cortex_agent_sdk/immutable.py +54 -0
  15. cortex_agent_sdk/lifecycle.py +67 -0
  16. cortex_agent_sdk/openai/__init__.py +2 -0
  17. cortex_agent_sdk/openai/engine.py +304 -0
  18. cortex_agent_sdk/openai/options.py +19 -0
  19. cortex_agent_sdk/postgres/__init__.py +1 -0
  20. cortex_agent_sdk/postgres/store.py +468 -0
  21. cortex_agent_sdk/py.typed +1 -0
  22. cortex_agent_sdk/redis/__init__.py +1 -0
  23. cortex_agent_sdk/redis/scripts.py +65 -0
  24. cortex_agent_sdk/redis/store.py +293 -0
  25. cortex_agent_sdk/results.py +29 -0
  26. cortex_agent_sdk/runtime.py +39 -0
  27. cortex_agent_sdk/sessions/__init__.py +4 -0
  28. cortex_agent_sdk/sessions/codec.py +27 -0
  29. cortex_agent_sdk/sessions/lease.py +85 -0
  30. cortex_agent_sdk/sessions/memory.py +186 -0
  31. cortex_agent_sdk/sessions/models.py +70 -0
  32. cortex_agent_sdk/sessions/store.py +38 -0
  33. cortex_agent_sdk/tools/__init__.py +1 -0
  34. cortex_agent_sdk/tools/contracts.py +152 -0
  35. cortex_agent_sdk/tools/decorators.py +15 -0
  36. cortex_agent_sdk/tools/execution.py +181 -0
  37. cortex_agent_sdk/tools/models.py +57 -0
  38. cortex_agent_sdk-0.0.1.dist-info/METADATA +192 -0
  39. cortex_agent_sdk-0.0.1.dist-info/RECORD +41 -0
  40. cortex_agent_sdk-0.0.1.dist-info/WHEEL +4 -0
  41. cortex_agent_sdk-0.0.1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,304 @@
1
+ import asyncio
2
+ import json
3
+ from typing import Any, Self, cast
4
+
5
+ import openai
6
+ from openai import AsyncOpenAI
7
+ from openai.types.responses import ResponseFunctionToolCall, ResponseInputItemParam
8
+ from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
9
+
10
+ from cortex_agent_sdk.engine import EngineRequest, EngineResult, ToolCall, Usage
11
+ from cortex_agent_sdk.errores import AppError, CodigoError
12
+ from cortex_agent_sdk.gateway import TalosGateway
13
+ from cortex_agent_sdk.history.models import (
14
+ ProviderState,
15
+ ProviderValue,
16
+ TextPart,
17
+ ToolCallPart,
18
+ ToolResultPart,
19
+ Turn,
20
+ )
21
+ from cortex_agent_sdk.immutable import (
22
+ FrozenJsonValue,
23
+ FrozenProviderValue,
24
+ thaw_json,
25
+ thaw_provider,
26
+ )
27
+ from cortex_agent_sdk.openai.options import OpenAIOptions
28
+ from cortex_agent_sdk.tools.models import ToolSpec
29
+
30
+ _JSON_OBJECT = TypeAdapter(dict[str, JsonValue])
31
+ _PROVIDER_OBJECT = TypeAdapter(dict[str, ProviderValue])
32
+
33
+
34
+ class OpenAIEngine:
35
+ """Engine de una inferencia por llamada sobre OpenAI Responses."""
36
+
37
+ def __init__(
38
+ self,
39
+ model: str,
40
+ *,
41
+ api_key: str | None = None,
42
+ gateway: TalosGateway | None = None,
43
+ options: OpenAIOptions | None = None,
44
+ client: AsyncOpenAI | None = None,
45
+ ) -> None:
46
+ if not model:
47
+ raise AppError(CodigoError.CONFIG_INVALIDA, "model no puede estar vacío")
48
+ if client is not None and (api_key is not None or gateway is not None):
49
+ raise AppError(CodigoError.CONFIG_INVALIDA, "client excluye api_key y gateway")
50
+ if gateway is not None and api_key is not None:
51
+ raise AppError(CodigoError.CONFIG_INVALIDA, "gateway excluye api_key")
52
+
53
+ self._model = model
54
+ self._options = options or OpenAIOptions()
55
+ self._client = client or self._create_client(api_key, gateway)
56
+ self._owns_client = client is None
57
+ self._closed = False
58
+
59
+ @property
60
+ def provider(self) -> str:
61
+ return "openai"
62
+
63
+ @property
64
+ def model(self) -> str:
65
+ return self._model
66
+
67
+ async def __aenter__(self) -> Self:
68
+ if self._closed:
69
+ raise AppError(CodigoError.RECURSO_CERRADO, "OpenAIEngine cerrado")
70
+ return self
71
+
72
+ async def __aexit__(self, *_: object) -> None:
73
+ await self.aclose()
74
+
75
+ async def generate(self, request: EngineRequest) -> EngineResult:
76
+ if self._closed:
77
+ raise AppError(CodigoError.RECURSO_CERRADO, "OpenAIEngine cerrado")
78
+
79
+ payload = cast(dict[str, Any], self._build_payload(request))
80
+ try:
81
+ response = await self._client.responses.create(**payload)
82
+ except openai.APITimeoutError as error:
83
+ raise AppError(CodigoError.PROVIDER_TIMEOUT, "OpenAI agotó el timeout") from error
84
+ except openai.APIStatusError as error:
85
+ request_id = getattr(error, "request_id", None)
86
+ detail = f"OpenAI respondió {error.status_code}, request_id={request_id}"
87
+ raise AppError(CodigoError.PROVIDER_FALLO, detail) from error
88
+ except openai.APIConnectionError as error:
89
+ raise AppError(CodigoError.PROVIDER_FALLO, "falló la conexión con OpenAI") from error
90
+ except openai.APIError as error:
91
+ raise AppError(
92
+ CodigoError.PROVIDER_FALLO,
93
+ f"OpenAI SDK lanzó {type(error).__name__}",
94
+ ) from error
95
+
96
+ tool_calls = _parse_tool_calls(response.output)
97
+ provider_items = tuple(
98
+ _PROVIDER_OBJECT.validate_python(item.model_dump(mode="json", exclude_none=True))
99
+ for item in response.output
100
+ )
101
+ parts = _build_parts(response.output_text, tool_calls)
102
+ turn = Turn(
103
+ role="assistant",
104
+ parts=parts,
105
+ provider_state=ProviderState(provider="openai", items=provider_items),
106
+ )
107
+ structured = _parse_structured(response.output_text, request.response_model)
108
+ return EngineResult(
109
+ turn=turn,
110
+ tool_calls=tool_calls,
111
+ usage=_parse_usage(response.usage),
112
+ provider="openai",
113
+ model=response.model,
114
+ stop_reason=_stop_reason(response),
115
+ raw=response,
116
+ structured=structured,
117
+ )
118
+
119
+ async def aclose(self) -> None:
120
+ if self._closed:
121
+ return
122
+ if self._owns_client:
123
+ try:
124
+ async with asyncio.timeout(self._options.timeout_seconds):
125
+ await self._client.close()
126
+ except TimeoutError as error:
127
+ raise AppError(
128
+ CodigoError.RUNTIME_CIERRE_TIMEOUT,
129
+ "OpenAIEngine no cerró su cliente dentro del timeout",
130
+ ) from error
131
+ self._closed = True
132
+
133
+ def _create_client(
134
+ self,
135
+ api_key: str | None,
136
+ gateway: TalosGateway | None,
137
+ ) -> AsyncOpenAI:
138
+ if gateway is not None:
139
+ return AsyncOpenAI(
140
+ api_key=gateway.project_token.get_secret_value(),
141
+ base_url=gateway.openai_base_url,
142
+ max_retries=0,
143
+ timeout=self._options.timeout_seconds,
144
+ )
145
+ return AsyncOpenAI(
146
+ api_key=api_key,
147
+ max_retries=self._options.max_retries,
148
+ timeout=self._options.timeout_seconds,
149
+ )
150
+
151
+ def _build_payload(self, request: EngineRequest) -> dict[str, object]:
152
+ payload: dict[str, object] = {
153
+ "model": self._model,
154
+ "input": _build_input(request.history),
155
+ "instructions": request.instructions,
156
+ "max_output_tokens": self._options.max_output_tokens,
157
+ "store": False,
158
+ "parallel_tool_calls": self._options.parallel_tool_calls,
159
+ }
160
+ if request.tools:
161
+ payload["tools"] = [_tool_payload(spec) for spec in request.tools]
162
+ if request.metadata:
163
+ payload["metadata"] = dict(request.metadata)
164
+
165
+ reasoning = self._reasoning_payload()
166
+ if reasoning:
167
+ payload["reasoning"] = reasoning
168
+
169
+ text = self._text_payload(request.response_model)
170
+ if text:
171
+ payload["text"] = text
172
+ if self._options.temperature is not None:
173
+ payload["temperature"] = self._options.temperature
174
+ return payload
175
+
176
+ def _reasoning_payload(self) -> dict[str, str]:
177
+ reasoning: dict[str, str] = {}
178
+ if self._options.reasoning_effort is not None:
179
+ reasoning["effort"] = self._options.reasoning_effort
180
+ if self._options.reasoning_context is not None:
181
+ reasoning["context"] = self._options.reasoning_context
182
+ return reasoning
183
+
184
+ def _text_payload(self, response_model: type[BaseModel] | None) -> dict[str, object]:
185
+ text: dict[str, object] = {}
186
+ if self._options.text_verbosity is not None:
187
+ text["verbosity"] = self._options.text_verbosity
188
+ if response_model is not None:
189
+ text["format"] = {
190
+ "type": "json_schema",
191
+ "name": response_model.__name__,
192
+ "schema": response_model.model_json_schema(),
193
+ "strict": True,
194
+ }
195
+ return text
196
+
197
+
198
+ def _build_input(history: tuple[Turn, ...]) -> list[ResponseInputItemParam]:
199
+ items: list[ResponseInputItemParam] = []
200
+ for turn in history:
201
+ if turn.provider_state is not None:
202
+ if turn.provider_state.provider != "openai":
203
+ raise AppError(
204
+ CodigoError.PROVIDER_RESPUESTA_INVALIDA,
205
+ "historial contiene estado de otro provider",
206
+ )
207
+ items.extend(
208
+ cast(ResponseInputItemParam, thaw_provider(cast(FrozenProviderValue, item)))
209
+ for item in turn.provider_state.items
210
+ )
211
+ continue
212
+ if turn.role in {"user", "assistant"}:
213
+ item = {"role": turn.role, "content": turn.text}
214
+ items.append(cast(ResponseInputItemParam, item))
215
+ continue
216
+ for part in turn.parts:
217
+ if not isinstance(part, ToolResultPart):
218
+ continue
219
+ item = {
220
+ "type": "function_call_output",
221
+ "call_id": part.call_id,
222
+ "output": part.output,
223
+ }
224
+ items.append(cast(ResponseInputItemParam, item))
225
+ return items
226
+
227
+
228
+ 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
+ }
232
+ return {
233
+ "type": "function",
234
+ "name": spec.name,
235
+ "description": spec.description,
236
+ "parameters": parameters,
237
+ "strict": spec.strict,
238
+ }
239
+
240
+
241
+ def _parse_tool_calls(output: list[object]) -> tuple[ToolCall, ...]:
242
+ calls: list[ToolCall] = []
243
+ for item in output:
244
+ if not isinstance(item, ResponseFunctionToolCall):
245
+ continue
246
+ try:
247
+ arguments = json.loads(item.arguments)
248
+ arguments = _JSON_OBJECT.validate_python(arguments)
249
+ except (json.JSONDecodeError, ValidationError) as error:
250
+ raise AppError(
251
+ CodigoError.PROVIDER_RESPUESTA_INVALIDA,
252
+ f"argumentos inválidos en call {item.call_id}",
253
+ ) from error
254
+ calls.append(ToolCall(call_id=item.call_id, name=item.name, arguments=arguments))
255
+ return tuple(calls)
256
+
257
+
258
+ def _build_parts(text: str, calls: tuple[ToolCall, ...]):
259
+ parts: list[TextPart | ToolCallPart] = []
260
+ if text:
261
+ parts.append(TextPart(text=text))
262
+ parts.extend(
263
+ ToolCallPart(
264
+ call_id=call.call_id,
265
+ name=call.name,
266
+ arguments=call.arguments,
267
+ )
268
+ for call in calls
269
+ )
270
+ return tuple(parts)
271
+
272
+
273
+ def _parse_structured(text: str, model: type[BaseModel] | None) -> BaseModel | None:
274
+ if model is None or not text:
275
+ return None
276
+ try:
277
+ return model.model_validate_json(text)
278
+ except ValidationError as error:
279
+ raise AppError(
280
+ CodigoError.PROVIDER_RESPUESTA_INVALIDA,
281
+ "structured output no validó contra el contrato",
282
+ ) from error
283
+
284
+
285
+ def _parse_usage(usage) -> Usage:
286
+ if usage is None:
287
+ return Usage()
288
+ input_details = getattr(usage, "input_tokens_details", None)
289
+ output_details = getattr(usage, "output_tokens_details", None)
290
+ return Usage(
291
+ input_tokens=usage.input_tokens,
292
+ output_tokens=usage.output_tokens,
293
+ reasoning_tokens=getattr(output_details, "reasoning_tokens", 0) or 0,
294
+ cached_tokens=getattr(input_details, "cached_tokens", 0) or 0,
295
+ cache_write_tokens=getattr(input_details, "cache_write_tokens", 0) or 0,
296
+ )
297
+
298
+
299
+ def _stop_reason(response) -> str | None:
300
+ incomplete = getattr(response, "incomplete_details", None)
301
+ if incomplete is not None:
302
+ return incomplete.reason
303
+ return response.status
304
+
@@ -0,0 +1,19 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class OpenAIOptions(BaseModel):
7
+ """Opciones cohesionadas de Responses API."""
8
+
9
+ model_config = ConfigDict(extra="forbid", frozen=True)
10
+
11
+ max_output_tokens: int = Field(default=1_024, gt=0)
12
+ timeout_seconds: float = Field(default=90.0, gt=0)
13
+ max_retries: int = Field(default=2, ge=0)
14
+ parallel_tool_calls: bool = False
15
+ reasoning_effort: Literal["none", "low", "medium", "high", "xhigh", "max"] | None = None
16
+ reasoning_context: Literal["auto", "current_turn", "all_turns"] | None = None
17
+ text_verbosity: Literal["low", "medium", "high"] | None = None
18
+ temperature: float | None = Field(default=None, ge=0, le=2)
19
+
@@ -0,0 +1 @@
1
+ from cortex_agent_sdk.postgres.store import PostgresSessionStore