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,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
|
+
|