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,70 @@
1
+ from datetime import UTC, datetime
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+ from cortex_agent_sdk.errores import AppError, CodigoError
6
+ from cortex_agent_sdk.history.models import Turn
7
+
8
+
9
+ class ActiveTurn(BaseModel):
10
+ model_config = ConfigDict(extra="forbid", frozen=True)
11
+
12
+ turn_id: str
13
+ pending_call_ids: tuple[str, ...]
14
+ completed_call_ids: tuple[str, ...] = ()
15
+
16
+ def complete(self, call_id: str) -> "ActiveTurn":
17
+ if call_id not in self.pending_call_ids or call_id in self.completed_call_ids:
18
+ raise AppError(CodigoError.SESION_INVALIDA, f"call inesperada: {call_id}")
19
+ completed = (*self.completed_call_ids, call_id)
20
+ return self.model_copy(update={"completed_call_ids": completed})
21
+
22
+
23
+ class SessionRecord(BaseModel):
24
+ """Estado conversacional versionado y ligado a un engine."""
25
+
26
+ model_config = ConfigDict(extra="forbid", frozen=True)
27
+
28
+ session_id: str
29
+ provider: str
30
+ model: str
31
+ history: tuple[Turn, ...] = ()
32
+ active_turn: ActiveTurn | None = None
33
+ version: int = 0
34
+ created_at: datetime
35
+ updated_at: datetime
36
+
37
+ @classmethod
38
+ def create(cls, session_id: str, provider: str, model: str) -> "SessionRecord":
39
+ now = datetime.now(UTC)
40
+ return cls(
41
+ session_id=session_id,
42
+ provider=provider,
43
+ model=model,
44
+ created_at=now,
45
+ updated_at=now,
46
+ )
47
+
48
+ def with_history(self, history: tuple[Turn, ...]) -> "SessionRecord":
49
+ return self.model_copy(update={"history": history})
50
+
51
+ def start_turn(
52
+ self,
53
+ turn_id: str,
54
+ call_ids: tuple[str, ...],
55
+ history: tuple[Turn, ...],
56
+ ) -> "SessionRecord":
57
+ if self.active_turn is not None or not call_ids or len(call_ids) != len(set(call_ids)):
58
+ raise AppError(CodigoError.SESION_INVALIDA, "turno activo inválido")
59
+ active_turn = ActiveTurn(turn_id=turn_id, pending_call_ids=call_ids)
60
+ return self.model_copy(update={"history": history, "active_turn": active_turn})
61
+
62
+ def complete_call(self, call_id: str, history: tuple[Turn, ...]) -> "SessionRecord":
63
+ if self.active_turn is None:
64
+ return self.with_history(history)
65
+ active_turn = self.active_turn.complete(call_id)
66
+ return self.model_copy(update={"history": history, "active_turn": active_turn})
67
+
68
+ def finish_turn(self, history: tuple[Turn, ...]) -> "SessionRecord":
69
+ return self.model_copy(update={"history": history, "active_turn": None})
70
+
@@ -0,0 +1,38 @@
1
+ from contextlib import AbstractAsyncContextManager
2
+ from typing import Protocol, runtime_checkable
3
+
4
+ from cortex_agent_sdk.sessions.models import SessionRecord
5
+
6
+
7
+ @runtime_checkable
8
+ class SessionLease(Protocol):
9
+ @property
10
+ def session_id(self) -> str: ...
11
+
12
+ @property
13
+ def fencing_token(self) -> int: ...
14
+
15
+ @property
16
+ def renewal_interval_seconds(self) -> float | None: ...
17
+
18
+ async def verify_ownership(self, fencing_token: int) -> None: ...
19
+
20
+ async def renew(self, fencing_token: int) -> None: ...
21
+
22
+ async def load(self, fencing_token: int) -> SessionRecord | None: ...
23
+
24
+ async def save(self, record: SessionRecord, fencing_token: int) -> SessionRecord: ...
25
+
26
+
27
+ @runtime_checkable
28
+ class SessionStore(Protocol):
29
+ def acquire(
30
+ self,
31
+ session_id: str,
32
+ timeout_seconds: float,
33
+ ) -> AbstractAsyncContextManager[SessionLease]: ...
34
+
35
+ async def reset(self, session_id: str, timeout_seconds: float = 5.0) -> None: ...
36
+
37
+ async def aclose(self) -> None: ...
38
+
@@ -0,0 +1 @@
1
+ from cortex_agent_sdk.tools.models import Injected, ToolBinding, ToolSpec
@@ -0,0 +1,152 @@
1
+ import inspect
2
+ from collections.abc import Mapping
3
+ from dataclasses import dataclass
4
+ from types import MappingProxyType
5
+ from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints
6
+
7
+ from jsonschema import Draft202012Validator
8
+ from jsonschema.exceptions import SchemaError
9
+ from pydantic import BaseModel, ConfigDict, JsonValue, create_model
10
+
11
+ from cortex_agent_sdk.errores import AppError, CodigoError
12
+ from cortex_agent_sdk.immutable import FrozenJsonValue, thaw_json
13
+ from cortex_agent_sdk.tools.models import Injected, ToolBinding, ToolFunction, ToolSpec
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class ToolDefinition:
18
+ spec: ToolSpec
19
+ function: ToolFunction
20
+ input_model: type[BaseModel]
21
+ private_arguments: Mapping[str, object]
22
+ is_final_answer: bool
23
+ schema_validator: Draft202012Validator | None
24
+
25
+
26
+ def build_definition(item: ToolFunction | ToolBinding) -> ToolDefinition:
27
+ if isinstance(item, ToolBinding):
28
+ binding = item
29
+ else:
30
+ binding = ToolBinding(function=item)
31
+ if not getattr(item, "__cortex_tool__", False):
32
+ raise AppError(
33
+ CodigoError.CONFIG_INVALIDA,
34
+ f"{item.__name__} requiere @tool o ToolBinding",
35
+ )
36
+
37
+ function = inspect.unwrap(binding.function)
38
+ if not inspect.iscoroutinefunction(function):
39
+ raise AppError(CodigoError.CONFIG_INVALIDA, f"{function.__name__} debe ser async")
40
+
41
+ input_model, inferred_spec = _infer_contract(function, binding.private_arguments)
42
+ spec = binding.spec or inferred_spec
43
+ schema_validator = _explicit_validator(binding.spec, inferred_spec)
44
+ private_arguments = MappingProxyType(dict(binding.private_arguments))
45
+ is_final = bool(getattr(function, "__cortex_final_answer__", False))
46
+ return ToolDefinition(
47
+ spec,
48
+ binding.function,
49
+ input_model,
50
+ private_arguments,
51
+ is_final,
52
+ schema_validator,
53
+ )
54
+
55
+
56
+ def _infer_contract(
57
+ function: ToolFunction,
58
+ private_arguments: Mapping[str, object],
59
+ ) -> tuple[type[BaseModel], ToolSpec]:
60
+ signature = inspect.signature(function)
61
+ try:
62
+ annotations = get_type_hints(function, include_extras=True)
63
+ except (NameError, TypeError) as error:
64
+ raise AppError(
65
+ CodigoError.CONFIG_INVALIDA,
66
+ f"no se resolvieron tipos de {function.__name__}",
67
+ ) from error
68
+
69
+ fields: dict[str, Any] = {}
70
+ injected_names: set[str] = set()
71
+ for name, parameter in signature.parameters.items():
72
+ annotation = annotations.get(name, parameter.annotation)
73
+ if annotation is inspect.Signature.empty:
74
+ raise AppError(CodigoError.CONFIG_INVALIDA, f"falta tipo en {function.__name__}.{name}")
75
+ if parameter.kind in {parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD}:
76
+ raise AppError(CodigoError.CONFIG_INVALIDA, f"{function.__name__} no admite variádicos")
77
+ if _is_injected(annotation):
78
+ injected_names.add(name)
79
+ continue
80
+ default = parameter.default
81
+ if default is inspect.Signature.empty:
82
+ default = ...
83
+ fields[name] = (annotation, default)
84
+
85
+ if injected_names != set(private_arguments):
86
+ raise AppError(
87
+ CodigoError.CONFIG_INVALIDA,
88
+ f"inyección inválida en {function.__name__}",
89
+ )
90
+
91
+ config = ConfigDict(extra="forbid")
92
+ model_name = f"{function.__name__.title().replace('_', '')}Arguments"
93
+ input_model = create_model(model_name, __config__=config, **fields)
94
+ schema = input_model.model_json_schema()
95
+ schema.pop("title", None)
96
+ description = (inspect.getdoc(function) or "").split("\n", maxsplit=1)[0]
97
+ spec = ToolSpec(
98
+ name=function.__name__,
99
+ description=description,
100
+ parameters=schema,
101
+ )
102
+ return input_model, spec
103
+
104
+
105
+ def _is_injected(annotation: object) -> bool:
106
+ if get_origin(annotation) is not Annotated:
107
+ return False
108
+ return Injected in get_args(annotation)[1:]
109
+
110
+
111
+ def _explicit_validator(
112
+ explicit: ToolSpec | None,
113
+ inferred: ToolSpec,
114
+ ) -> Draft202012Validator | None:
115
+ if explicit is None:
116
+ return None
117
+ schema = _plain_schema(explicit)
118
+ inferred_schema = _plain_schema(inferred)
119
+ try:
120
+ Draft202012Validator.check_schema(schema)
121
+ except SchemaError as error:
122
+ raise AppError(
123
+ CodigoError.CONFIG_INVALIDA,
124
+ f"schema inválido en {explicit.name}",
125
+ ) from error
126
+
127
+ properties = schema.get("properties")
128
+ inferred_properties = inferred_schema.get("properties")
129
+ required = schema.get("required", [])
130
+ inferred_required = inferred_schema.get("required", [])
131
+ if not isinstance(properties, dict) or not isinstance(inferred_properties, dict):
132
+ raise AppError(CodigoError.CONFIG_INVALIDA, f"properties inválido en {explicit.name}")
133
+ explicit_fields = set(properties)
134
+ inferred_fields = set(inferred_properties)
135
+ if not explicit_fields <= inferred_fields:
136
+ raise AppError(CodigoError.CONFIG_INVALIDA, f"campos incompatibles en {explicit.name}")
137
+ if not isinstance(required, list) or not isinstance(inferred_required, list):
138
+ raise AppError(CodigoError.CONFIG_INVALIDA, f"required inválido en {explicit.name}")
139
+ explicit_required = set(required)
140
+ callable_required = set(inferred_required)
141
+ valid_required = explicit_required <= explicit_fields and callable_required <= explicit_required
142
+ if not valid_required:
143
+ raise AppError(CodigoError.CONFIG_INVALIDA, f"required incompatible en {explicit.name}")
144
+ return Draft202012Validator(
145
+ schema,
146
+ format_checker=Draft202012Validator.FORMAT_CHECKER,
147
+ )
148
+
149
+
150
+ def _plain_schema(spec: ToolSpec) -> dict[str, JsonValue]:
151
+ return {key: thaw_json(cast(FrozenJsonValue, value)) for key, value in spec.parameters.items()}
152
+
@@ -0,0 +1,15 @@
1
+ from cortex_agent_sdk.tools.models import ToolFunction
2
+
3
+
4
+ def tool(function: ToolFunction) -> ToolFunction:
5
+ """Marca un callable async para registrarlo en un Agent."""
6
+ function.__cortex_tool__ = True # type: ignore[attr-defined]
7
+ return function
8
+
9
+
10
+ def final_answer(function: ToolFunction) -> ToolFunction:
11
+ """Marca una tool cuyo str exitoso termina el turno."""
12
+ function.__cortex_tool__ = True # type: ignore[attr-defined]
13
+ function.__cortex_final_answer__ = True # type: ignore[attr-defined]
14
+ return function
15
+
@@ -0,0 +1,181 @@
1
+ import asyncio
2
+ import json
3
+ from collections.abc import Iterable, Mapping
4
+ from dataclasses import dataclass
5
+ from types import MappingProxyType
6
+ from typing import cast
7
+
8
+ from pydantic import JsonValue, TypeAdapter, ValidationError
9
+
10
+ from cortex_agent_sdk.engine import ToolCall
11
+ from cortex_agent_sdk.errores import AppError, CodigoError
12
+ from cortex_agent_sdk.history.models import ToolResultPart
13
+ from cortex_agent_sdk.immutable import FrozenJsonValue, thaw_json
14
+ from cortex_agent_sdk.tools.contracts import ToolDefinition, build_definition
15
+ from cortex_agent_sdk.tools.models import ToolBinding, ToolFunction, ToolSpec
16
+
17
+ _JSON_ADAPTER = TypeAdapter(JsonValue)
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ToolOutcome:
22
+ call: ToolCall
23
+ output: str
24
+ failed: bool
25
+ final_answer: bool
26
+ error_code: str | None = None
27
+
28
+ def __post_init__(self) -> None:
29
+ if not isinstance(self.call, ToolCall) or not isinstance(self.output, str):
30
+ raise TypeError("ToolOutcome inválido")
31
+ if not isinstance(self.failed, bool) or not isinstance(self.final_answer, bool):
32
+ raise TypeError("ToolOutcome inválido")
33
+ if self.error_code is not None and not isinstance(self.error_code, str):
34
+ raise TypeError("ToolOutcome inválido")
35
+
36
+ @classmethod
37
+ def skipped(cls, call: ToolCall) -> "ToolOutcome":
38
+ return _failed_outcome(
39
+ call,
40
+ CodigoError.TOOL_OMITIDA_POR_LIMITE,
41
+ "La herramienta se omitió porque se alcanzó el límite de fallas.",
42
+ )
43
+
44
+ def as_history_part(self) -> ToolResultPart:
45
+ return ToolResultPart(
46
+ call_id=self.call.call_id,
47
+ name=self.call.name,
48
+ output=self.output,
49
+ is_error=self.failed,
50
+ error_code=self.error_code,
51
+ )
52
+
53
+
54
+ class ToolSet:
55
+ def __init__(self, definitions: Mapping[str, ToolDefinition]) -> None:
56
+ self._definitions = MappingProxyType(dict(definitions))
57
+
58
+ @classmethod
59
+ def build(cls, tools: Iterable[ToolFunction | ToolBinding]) -> "ToolSet":
60
+ definitions: dict[str, ToolDefinition] = {}
61
+ for item in tools:
62
+ definition = build_definition(item)
63
+ if definition.spec.name in definitions:
64
+ raise AppError(
65
+ CodigoError.CONFIG_INVALIDA,
66
+ f"tool duplicada: {definition.spec.name}",
67
+ )
68
+ definitions[definition.spec.name] = definition
69
+ return cls(definitions)
70
+
71
+ @property
72
+ def specs(self) -> tuple[ToolSpec, ...]:
73
+ return tuple(definition.spec for definition in self._definitions.values())
74
+
75
+ def definition(self, name: str) -> ToolDefinition | None:
76
+ return self._definitions.get(name)
77
+
78
+ def is_final_answer(self, name: str) -> bool:
79
+ definition = self._definitions.get(name)
80
+ return definition is not None and definition.is_final_answer
81
+
82
+
83
+ class ToolExecutor:
84
+ def __init__(self, tools: ToolSet, timeout_seconds: float) -> None:
85
+ self._tools = tools
86
+ self._timeout_seconds = timeout_seconds
87
+
88
+ async def execute(self, call: ToolCall) -> ToolOutcome:
89
+ definition = self._tools.definition(call.name)
90
+ if definition is None:
91
+ return _failed_outcome(
92
+ call,
93
+ CodigoError.TOOL_NO_ENCONTRADA,
94
+ "La herramienta solicitada no existe.",
95
+ )
96
+
97
+ arguments = _validate_arguments(definition, call)
98
+ if isinstance(arguments, ToolOutcome):
99
+ return arguments
100
+
101
+ try:
102
+ async with asyncio.timeout(self._timeout_seconds):
103
+ result = await definition.function(**arguments)
104
+ except TimeoutError:
105
+ return _failed_outcome(
106
+ call,
107
+ CodigoError.TOOL_TIMEOUT,
108
+ "La herramienta agotó su tiempo de ejecución.",
109
+ definition.is_final_answer,
110
+ )
111
+ except AppError as error:
112
+ message = error.mensaje_seguro or "La herramienta no pudo completar la operación."
113
+ return _failed_outcome(call, error.codigo, message, definition.is_final_answer)
114
+ except Exception as error:
115
+ detail = f"{call.name} lanzó {type(error).__name__}"
116
+ raise AppError(
117
+ CodigoError.TOOL_FALLO,
118
+ detail,
119
+ mensaje_seguro="La herramienta falló de forma inesperada.",
120
+ ) from error
121
+
122
+ if definition.is_final_answer and not isinstance(result, str):
123
+ raise AppError(
124
+ CodigoError.FINAL_ANSWER_INVALIDA,
125
+ f"{call.name} debe devolver str",
126
+ )
127
+ output = _serialize_result(result, call.name)
128
+ return ToolOutcome(call, output, False, definition.is_final_answer)
129
+
130
+
131
+ def _validate_arguments(
132
+ definition: ToolDefinition,
133
+ call: ToolCall,
134
+ ) -> dict[str, object] | ToolOutcome:
135
+ public_input = {
136
+ key: thaw_json(cast(FrozenJsonValue, value)) for key, value in call.arguments.items()
137
+ }
138
+ if definition.schema_validator is not None:
139
+ schema_error = next(definition.schema_validator.iter_errors(public_input), None)
140
+ if schema_error is not None:
141
+ return _failed_outcome(
142
+ call,
143
+ CodigoError.TOOL_ARGUMENTOS_INVALIDOS,
144
+ "Los argumentos de la herramienta son inválidos.",
145
+ definition.is_final_answer,
146
+ )
147
+ try:
148
+ public_arguments = definition.input_model.model_validate(public_input).model_dump()
149
+ except ValidationError:
150
+ return _failed_outcome(
151
+ call,
152
+ CodigoError.TOOL_ARGUMENTOS_INVALIDOS,
153
+ "Los argumentos de la herramienta son inválidos.",
154
+ definition.is_final_answer,
155
+ )
156
+ return {**public_arguments, **definition.private_arguments}
157
+
158
+
159
+ def _serialize_result(result: object, tool_name: str) -> str:
160
+ if isinstance(result, str):
161
+ return result
162
+ try:
163
+ value = _JSON_ADAPTER.validate_python(result)
164
+ except ValidationError as error:
165
+ raise AppError(
166
+ CodigoError.TOOL_RESULTADO_INVALIDO,
167
+ f"{tool_name} devolvió un resultado no serializable",
168
+ ) from error
169
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
170
+
171
+
172
+ def _failed_outcome(
173
+ call: ToolCall,
174
+ code: CodigoError,
175
+ message: str,
176
+ is_final_answer: bool = False,
177
+ ) -> ToolOutcome:
178
+ payload = {"ok": False, "error": {"code": code.value, "message": message}}
179
+ output = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
180
+ return ToolOutcome(call, output, True, is_final_answer, code.value)
181
+
@@ -0,0 +1,57 @@
1
+ from collections.abc import Awaitable, Callable, Mapping
2
+ from dataclasses import dataclass, field
3
+ from typing import cast
4
+
5
+ from pydantic import BaseModel, ConfigDict, JsonValue, field_serializer, field_validator
6
+
7
+ from cortex_agent_sdk.immutable import FrozenJsonValue, freeze_json, thaw_json
8
+
9
+
10
+ class _InjectedMarker:
11
+ def __repr__(self) -> str:
12
+ return "Injected"
13
+
14
+
15
+ Injected = _InjectedMarker()
16
+
17
+
18
+ class ToolSpec(BaseModel):
19
+ """Schema neutral visible para el modelo."""
20
+
21
+ model_config = ConfigDict(extra="forbid", frozen=True)
22
+
23
+ name: str
24
+ description: str = ""
25
+ parameters: Mapping[str, JsonValue]
26
+ strict: bool = False
27
+
28
+ @field_validator("parameters", mode="after")
29
+ @classmethod
30
+ def freeze_parameters(
31
+ cls,
32
+ value: Mapping[str, JsonValue],
33
+ ) -> Mapping[str, JsonValue]:
34
+ frozen = freeze_json(value)
35
+ if not isinstance(frozen, Mapping):
36
+ raise TypeError("parameters debe ser un objeto")
37
+ return cast(Mapping[str, JsonValue], frozen)
38
+
39
+ @field_serializer("parameters", when_used="json")
40
+ def serialize_parameters(
41
+ self,
42
+ value: Mapping[str, JsonValue],
43
+ ) -> dict[str, object]:
44
+ return {key: thaw_json(cast(FrozenJsonValue, item)) for key, item in value.items()}
45
+
46
+
47
+ ToolFunction = Callable[..., Awaitable[object]]
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class ToolBinding:
52
+ """Une callable, schema opcional y argumentos privados."""
53
+
54
+ function: ToolFunction
55
+ spec: ToolSpec | None = None
56
+ private_arguments: Mapping[str, object] = field(default_factory=dict)
57
+