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