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,145 @@
1
+ import base64
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
6
+
7
+ from cortex_agent_sdk.immutable import (
8
+ FrozenJsonValue,
9
+ FrozenProviderValue,
10
+ freeze_json,
11
+ freeze_provider,
12
+ thaw_json,
13
+ )
14
+
15
+ _BYTES_TAG = "$cortex.bytes"
16
+
17
+ ProviderValue = FrozenProviderValue
18
+
19
+
20
+ class ProviderState(BaseModel):
21
+ """Items nativos necesarios para continuar sin pérdida con un provider."""
22
+
23
+ model_config = ConfigDict(extra="forbid", frozen=True)
24
+
25
+ provider: str
26
+ items: tuple[Mapping[str, ProviderValue], ...]
27
+
28
+ @field_validator("items", mode="before")
29
+ @classmethod
30
+ def decode_bytes(cls, value: object) -> object:
31
+ return _decode_provider_bytes(value)
32
+
33
+ @field_validator("items", mode="after")
34
+ @classmethod
35
+ def freeze_items(
36
+ cls,
37
+ value: tuple[Mapping[str, ProviderValue], ...],
38
+ ) -> tuple[Mapping[str, ProviderValue], ...]:
39
+ return tuple(_freeze_provider_item(item) for item in value)
40
+
41
+ @field_serializer("items", when_used="json")
42
+ def encode_bytes(
43
+ self,
44
+ items: tuple[Mapping[str, ProviderValue], ...],
45
+ ) -> tuple[dict[str, object], ...]:
46
+ return tuple(
47
+ {key: _encode_provider_bytes(value) for key, value in item.items()} for item in items
48
+ )
49
+
50
+
51
+ class TextPart(BaseModel):
52
+ model_config = ConfigDict(extra="forbid", frozen=True)
53
+
54
+ type: Literal["text"] = "text"
55
+ text: str
56
+
57
+
58
+ class ToolCallPart(BaseModel):
59
+ model_config = ConfigDict(extra="forbid", frozen=True)
60
+
61
+ type: Literal["tool_call"] = "tool_call"
62
+ call_id: str
63
+ name: str
64
+ arguments: Mapping[str, JsonValue]
65
+
66
+ @field_validator("arguments", mode="after")
67
+ @classmethod
68
+ def freeze_arguments(
69
+ 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)
76
+
77
+ @field_serializer("arguments", when_used="json")
78
+ def serialize_arguments(
79
+ self,
80
+ value: Mapping[str, JsonValue],
81
+ ) -> dict[str, object]:
82
+ return {key: thaw_json(cast(FrozenJsonValue, item)) for key, item in value.items()}
83
+
84
+
85
+ class ToolResultPart(BaseModel):
86
+ model_config = ConfigDict(extra="forbid", frozen=True)
87
+
88
+ type: Literal["tool_result"] = "tool_result"
89
+ call_id: str
90
+ name: str
91
+ output: str
92
+ is_error: bool = False
93
+ error_code: str | None = None
94
+
95
+
96
+ Part = Annotated[TextPart | ToolCallPart | ToolResultPart, Field(discriminator="type")]
97
+
98
+
99
+ class Turn(BaseModel):
100
+ """Turno lógico persistible con extensión nativa opcional."""
101
+
102
+ model_config = ConfigDict(extra="forbid", frozen=True)
103
+
104
+ role: Literal["user", "assistant", "tool"]
105
+ parts: tuple[Part, ...]
106
+ provider_state: ProviderState | None = None
107
+
108
+ @classmethod
109
+ def user(cls, text: str) -> "Turn":
110
+ return cls(role="user", parts=(TextPart(text=text),))
111
+
112
+ @property
113
+ def text(self) -> str:
114
+ return "\n".join(part.text for part in self.parts if isinstance(part, TextPart))
115
+
116
+
117
+ def _encode_provider_bytes(value: ProviderValue) -> object:
118
+ if isinstance(value, bytes):
119
+ encoded = base64.b64encode(value).decode("ascii")
120
+ return {_BYTES_TAG: encoded}
121
+ if isinstance(value, tuple):
122
+ return [_encode_provider_bytes(item) for item in value]
123
+ if isinstance(value, Mapping):
124
+ return {key: _encode_provider_bytes(item) for key, item in value.items()}
125
+ return value
126
+
127
+
128
+ def _decode_provider_bytes(value: object) -> object:
129
+ if isinstance(value, list):
130
+ return [_decode_provider_bytes(item) for item in value]
131
+ if not isinstance(value, dict):
132
+ return value
133
+ if set(value) == {_BYTES_TAG} and isinstance(value[_BYTES_TAG], str):
134
+ return base64.b64decode(value[_BYTES_TAG], validate=True)
135
+ 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
+
@@ -0,0 +1,53 @@
1
+ from datetime import UTC, datetime
2
+
3
+ from cortex_agent_sdk.errores import AppError, CodigoError
4
+ from cortex_agent_sdk.history.models import Turn
5
+ from cortex_agent_sdk.history.transform import HistoryTransform, TransformContext
6
+
7
+
8
+ class HistoryPipeline:
9
+ def __init__(self, transform: HistoryTransform | None, max_turns: int) -> None:
10
+ self._transform = transform
11
+ self._max_turns = max_turns
12
+
13
+ async def prepare(
14
+ self,
15
+ history: tuple[Turn, ...],
16
+ session_id: str | None,
17
+ ) -> tuple[Turn, ...]:
18
+ transformed = history
19
+ if self._transform is not None:
20
+ context = TransformContext(
21
+ history=history,
22
+ max_turns=self._max_turns,
23
+ now=datetime.now(UTC),
24
+ session_id=session_id,
25
+ )
26
+ try:
27
+ transformed = tuple(await self._transform(context))
28
+ except AppError:
29
+ raise
30
+ except Exception as error:
31
+ raise AppError(
32
+ CodigoError.HISTORIAL_TRANSFORM_INVALIDO,
33
+ f"history_transform lanzó {type(error).__name__}",
34
+ ) from error
35
+ if not all(isinstance(turn, Turn) for turn in transformed):
36
+ raise AppError(
37
+ CodigoError.HISTORIAL_TRANSFORM_INVALIDO,
38
+ "history_transform devolvió un tipo inválido",
39
+ )
40
+ return _window_history(transformed, self._max_turns)
41
+
42
+ def window(self, history: tuple[Turn, ...]) -> tuple[Turn, ...]:
43
+ return _window_history(history, self._max_turns)
44
+
45
+
46
+ def _window_history(history: tuple[Turn, ...], max_turns: int) -> tuple[Turn, ...]:
47
+ if len(history) <= max_turns:
48
+ return history
49
+ start = len(history) - max_turns
50
+ while history[start].role == "tool" and start > 0:
51
+ start -= 1
52
+ return history[start:]
53
+
@@ -0,0 +1,17 @@
1
+ from collections.abc import Awaitable, Callable
2
+ from dataclasses import dataclass
3
+ from datetime import datetime
4
+
5
+ from cortex_agent_sdk.history.models import Turn
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class TransformContext:
10
+ history: tuple[Turn, ...]
11
+ max_turns: int
12
+ now: datetime
13
+ session_id: str | None
14
+
15
+
16
+ HistoryTransform = Callable[[TransformContext], Awaitable[tuple[Turn, ...] | list[Turn]]]
17
+
@@ -0,0 +1,102 @@
1
+ import asyncio
2
+ from collections.abc import Awaitable, Callable
3
+ from dataclasses import dataclass, replace
4
+ from typing import Never
5
+
6
+ from cortex_agent_sdk.engine import EngineRequest, EngineResult, ToolCall
7
+ from cortex_agent_sdk.errores import AppError, CodigoError
8
+ from cortex_agent_sdk.results import AgentResult
9
+ from cortex_agent_sdk.tools.execution import ToolOutcome
10
+
11
+ BeforeModelHook = Callable[[EngineRequest], Awaitable[EngineRequest | None]]
12
+ AfterModelHook = Callable[[EngineResult], Awaitable[EngineResult | None]]
13
+ BeforeToolHook = Callable[[ToolCall], Awaitable[ToolCall | None]]
14
+ AfterToolHook = Callable[[ToolOutcome], Awaitable[ToolOutcome | None]]
15
+ TurnFinishedHook = Callable[[AgentResult], Awaitable[None]]
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class AgentHooks:
20
+ before_model: tuple[BeforeModelHook, ...] = ()
21
+ after_model: tuple[AfterModelHook, ...] = ()
22
+ before_tool: tuple[BeforeToolHook, ...] = ()
23
+ after_tool: tuple[AfterToolHook, ...] = ()
24
+ turn_finished: tuple[TurnFinishedHook, ...] = ()
25
+
26
+
27
+ class HookChain:
28
+ def __init__(self, hooks: AgentHooks, timeout_seconds: float) -> None:
29
+ self._hooks = hooks
30
+ self._timeout_seconds = timeout_seconds
31
+
32
+ async def run_before_model(self, request: EngineRequest) -> EngineRequest:
33
+ current = request
34
+ for hook in self._hooks.before_model:
35
+ replacement = await self._call(hook(current), "before_model")
36
+ if replacement is not None:
37
+ if not isinstance(replacement, EngineRequest):
38
+ self._invalid("before_model")
39
+ try:
40
+ current = replace(replacement)
41
+ except (TypeError, ValueError):
42
+ self._invalid("before_model")
43
+ return current
44
+
45
+ async def run_after_model(self, result: EngineResult) -> EngineResult:
46
+ current = result
47
+ for hook in self._hooks.after_model:
48
+ replacement = await self._call(hook(current), "after_model")
49
+ if replacement is not None:
50
+ if not isinstance(replacement, EngineResult):
51
+ self._invalid("after_model")
52
+ try:
53
+ current = replace(replacement)
54
+ except (TypeError, ValueError):
55
+ self._invalid("after_model")
56
+ return current
57
+
58
+ async def run_before_tool(self, call: ToolCall) -> ToolCall:
59
+ current = call
60
+ for hook in self._hooks.before_tool:
61
+ replacement = await self._call(hook(current), "before_tool")
62
+ if replacement is not None:
63
+ if not isinstance(replacement, ToolCall):
64
+ self._invalid("before_tool")
65
+ try:
66
+ current = ToolCall.model_validate(replacement.model_dump(round_trip=True))
67
+ except ValueError:
68
+ self._invalid("before_tool")
69
+ return current
70
+
71
+ async def run_after_tool(self, outcome: ToolOutcome) -> ToolOutcome:
72
+ current = outcome
73
+ for hook in self._hooks.after_tool:
74
+ replacement = await self._call(hook(current), "after_tool")
75
+ if replacement is not None:
76
+ if not isinstance(replacement, ToolOutcome):
77
+ self._invalid("after_tool")
78
+ try:
79
+ current = replace(replacement)
80
+ except (TypeError, ValueError):
81
+ self._invalid("after_tool")
82
+ return current
83
+
84
+ async def run_turn_finished(self, result: AgentResult) -> None:
85
+ for hook in self._hooks.turn_finished:
86
+ await self._call(hook(result), "turn_finished")
87
+
88
+ async def _call(self, operation: Awaitable[object], name: str) -> object:
89
+ try:
90
+ async with asyncio.timeout(self._timeout_seconds):
91
+ return await operation
92
+ except TimeoutError as error:
93
+ raise AppError(CodigoError.HOOK_FALLO, f"timeout en {name}") from error
94
+ except Exception as error:
95
+ raise AppError(
96
+ CodigoError.HOOK_FALLO,
97
+ f"{name} lanzó {type(error).__name__}",
98
+ ) from error
99
+
100
+ def _invalid(self, name: str) -> Never:
101
+ raise AppError(CodigoError.HOOK_FALLO, f"retorno inválido en {name}")
102
+
@@ -0,0 +1,54 @@
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
+
@@ -0,0 +1,67 @@
1
+ import asyncio
2
+ from collections.abc import AsyncIterator, Awaitable, Callable
3
+ from contextlib import asynccontextmanager
4
+
5
+ from cortex_agent_sdk.errores import AppError, CodigoError
6
+
7
+
8
+ class AgentLifecycle:
9
+ """Coordina turnos activos y cierre ordenado del agente."""
10
+
11
+ def __init__(self) -> None:
12
+ self._active_runs = 0
13
+ self._accepting_runs = True
14
+ self._closed = False
15
+ self._guard = asyncio.Lock()
16
+ self._close_lock = asyncio.Lock()
17
+ self._drained = asyncio.Event()
18
+ self._drained.set()
19
+
20
+ async def ensure_open(self) -> None:
21
+ async with self._guard:
22
+ self._ensure_accepting()
23
+
24
+ @asynccontextmanager
25
+ async def run(self) -> AsyncIterator[None]:
26
+ async with self._guard:
27
+ self._ensure_accepting()
28
+ self._active_runs += 1
29
+ self._drained.clear()
30
+ try:
31
+ yield
32
+ finally:
33
+ async with self._guard:
34
+ self._active_runs -= 1
35
+ if self._active_runs == 0:
36
+ self._drained.set()
37
+
38
+ async def close(
39
+ self,
40
+ close_resources: Callable[[], Awaitable[None]],
41
+ timeout_seconds: float,
42
+ ) -> None:
43
+ async with self._close_lock:
44
+ async with self._guard:
45
+ if self._closed:
46
+ return
47
+ self._accepting_runs = False
48
+
49
+ try:
50
+ async with asyncio.timeout(timeout_seconds):
51
+ await self._drained.wait()
52
+ await close_resources()
53
+ except TimeoutError as error:
54
+ raise AppError(
55
+ CodigoError.RUNTIME_CIERRE_TIMEOUT,
56
+ "el agente no completó el drenado y cierre dentro del timeout",
57
+ ) from error
58
+
59
+ async with self._guard:
60
+ self._closed = True
61
+
62
+ def _ensure_accepting(self) -> None:
63
+ if self._closed:
64
+ raise AppError(CodigoError.RECURSO_CERRADO, "Agent cerrado")
65
+ if not self._accepting_runs:
66
+ raise AppError(CodigoError.RECURSO_CERRADO, "Agent en cierre ordenado")
67
+
@@ -0,0 +1,2 @@
1
+ from cortex_agent_sdk.openai.engine import OpenAIEngine
2
+ from cortex_agent_sdk.openai.options import OpenAIOptions