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.
- cortex_agent_sdk/__init__.py +7 -0
- cortex_agent_sdk/agent.py +321 -0
- cortex_agent_sdk/engine.py +150 -0
- cortex_agent_sdk/errores/__init__.py +2 -0
- cortex_agent_sdk/errores/catalogo.py +35 -0
- cortex_agent_sdk/errores/excepcion.py +19 -0
- cortex_agent_sdk/gateway.py +23 -0
- cortex_agent_sdk/google/__init__.py +1 -0
- cortex_agent_sdk/history/__init__.py +2 -0
- cortex_agent_sdk/history/models.py +145 -0
- cortex_agent_sdk/history/pipeline.py +53 -0
- cortex_agent_sdk/history/transform.py +17 -0
- cortex_agent_sdk/hooks.py +102 -0
- cortex_agent_sdk/immutable.py +54 -0
- cortex_agent_sdk/lifecycle.py +67 -0
- cortex_agent_sdk/openai/__init__.py +2 -0
- cortex_agent_sdk/openai/engine.py +304 -0
- cortex_agent_sdk/openai/options.py +19 -0
- cortex_agent_sdk/postgres/__init__.py +1 -0
- cortex_agent_sdk/postgres/store.py +468 -0
- cortex_agent_sdk/py.typed +1 -0
- cortex_agent_sdk/redis/__init__.py +1 -0
- cortex_agent_sdk/redis/scripts.py +65 -0
- cortex_agent_sdk/redis/store.py +293 -0
- cortex_agent_sdk/results.py +29 -0
- cortex_agent_sdk/runtime.py +39 -0
- cortex_agent_sdk/sessions/__init__.py +4 -0
- cortex_agent_sdk/sessions/codec.py +27 -0
- cortex_agent_sdk/sessions/lease.py +85 -0
- cortex_agent_sdk/sessions/memory.py +186 -0
- cortex_agent_sdk/sessions/models.py +70 -0
- cortex_agent_sdk/sessions/store.py +38 -0
- cortex_agent_sdk/tools/__init__.py +1 -0
- cortex_agent_sdk/tools/contracts.py +152 -0
- cortex_agent_sdk/tools/decorators.py +15 -0
- cortex_agent_sdk/tools/execution.py +181 -0
- cortex_agent_sdk/tools/models.py +57 -0
- cortex_agent_sdk-0.0.1.dist-info/METADATA +192 -0
- cortex_agent_sdk-0.0.1.dist-info/RECORD +41 -0
- cortex_agent_sdk-0.0.1.dist-info/WHEEL +4 -0
- 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
|