synaptum 1.0.0rc1__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.
- synaptum/__init__.py +139 -0
- synaptum/agent/__init__.py +5 -0
- synaptum/agent/agent.py +524 -0
- synaptum/core/__init__.py +166 -0
- synaptum/core/codec.py +151 -0
- synaptum/core/errors.py +256 -0
- synaptum/core/events.py +322 -0
- synaptum/core/protocols.py +323 -0
- synaptum/core/types.py +616 -0
- synaptum/prompts/__init__.py +15 -0
- synaptum/prompts/providers.py +170 -0
- synaptum/prompts/template.py +111 -0
- synaptum/providers/__init__.py +18 -0
- synaptum/providers/axonium.py +405 -0
- synaptum/providers/base.py +156 -0
- synaptum/providers/openai_compatible.py +356 -0
- synaptum/py.typed +0 -0
- synaptum/run/__init__.py +17 -0
- synaptum/run/journal.py +167 -0
- synaptum/run/local.py +259 -0
- synaptum/run/sqlite.py +118 -0
- synaptum/run/transport.py +241 -0
- synaptum/schema/__init__.py +15 -0
- synaptum/schema/derive.py +173 -0
- synaptum/schema/protocol.py +188 -0
- synaptum/testing/__init__.py +24 -0
- synaptum/testing/fake.py +242 -0
- synaptum/testing/replay.py +180 -0
- synaptum/tools/__init__.py +5 -0
- synaptum/tools/decorator.py +188 -0
- synaptum-1.0.0rc1.dist-info/METADATA +247 -0
- synaptum-1.0.0rc1.dist-info/RECORD +33 -0
- synaptum-1.0.0rc1.dist-info/WHEEL +4 -0
synaptum/__init__.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Synaptum — framework de agentes y runtime durable, agnóstico al proveedor.
|
|
3
|
+
|
|
4
|
+
Es dueño de la *semántica* de ejecución: qué es un paso, dónde puede cortarse,
|
|
5
|
+
qué puede repetirse y cómo se re-deriva el contexto. El *sustrato* — dónde se
|
|
6
|
+
persiste, con qué retención y bajo qué política — pertenece al harness.
|
|
7
|
+
|
|
8
|
+
Estado: v1.0 en construcción. Fase 0 (contratos) en curso; ver ``roadmap.md``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .core import (
|
|
12
|
+
ALLOW,
|
|
13
|
+
SEAM_VERSION,
|
|
14
|
+
SUPPORT_WINDOW,
|
|
15
|
+
AbortError,
|
|
16
|
+
CallContext,
|
|
17
|
+
Checkpointer,
|
|
18
|
+
ConfigurationError,
|
|
19
|
+
Denied,
|
|
20
|
+
Gateway,
|
|
21
|
+
Hello,
|
|
22
|
+
InvalidToolCallError,
|
|
23
|
+
LimitExceeded,
|
|
24
|
+
NetworkError,
|
|
25
|
+
NoObjectGeneratedError,
|
|
26
|
+
ProviderError,
|
|
27
|
+
RequestTimeoutError,
|
|
28
|
+
RunState,
|
|
29
|
+
SeamVersionError,
|
|
30
|
+
SynaptumError,
|
|
31
|
+
ToolExecutionError,
|
|
32
|
+
Welcome,
|
|
33
|
+
negotiate,
|
|
34
|
+
retryable_for_status,
|
|
35
|
+
supported_versions,
|
|
36
|
+
AUTO,
|
|
37
|
+
ApprovalStep,
|
|
38
|
+
Audio,
|
|
39
|
+
ContentPart,
|
|
40
|
+
Decision,
|
|
41
|
+
DelegateStep,
|
|
42
|
+
Disposition,
|
|
43
|
+
Document,
|
|
44
|
+
Durability,
|
|
45
|
+
Event,
|
|
46
|
+
FinalStep,
|
|
47
|
+
Finish,
|
|
48
|
+
FinishReason,
|
|
49
|
+
Image,
|
|
50
|
+
Message,
|
|
51
|
+
ModelStep,
|
|
52
|
+
Phase,
|
|
53
|
+
ReasoningDelta,
|
|
54
|
+
ReasoningEnd,
|
|
55
|
+
ReasoningStart,
|
|
56
|
+
RedactedThinking,
|
|
57
|
+
Request,
|
|
58
|
+
Response,
|
|
59
|
+
ResponseFormat,
|
|
60
|
+
Risk,
|
|
61
|
+
Role,
|
|
62
|
+
StepEvent,
|
|
63
|
+
StreamEvent,
|
|
64
|
+
StreamStart,
|
|
65
|
+
Text,
|
|
66
|
+
TextDelta,
|
|
67
|
+
TextEnd,
|
|
68
|
+
TextStart,
|
|
69
|
+
Thinking,
|
|
70
|
+
ToolCall,
|
|
71
|
+
ToolCallDelta,
|
|
72
|
+
ToolCallEnd,
|
|
73
|
+
ToolCallStart,
|
|
74
|
+
ToolChoice,
|
|
75
|
+
ToolDefinition,
|
|
76
|
+
ToolResult,
|
|
77
|
+
ToolStep,
|
|
78
|
+
Usage,
|
|
79
|
+
b64,
|
|
80
|
+
dumps,
|
|
81
|
+
idempotency_key,
|
|
82
|
+
make_step_id,
|
|
83
|
+
to_jsonable,
|
|
84
|
+
)
|
|
85
|
+
from .core import UncertainEffect
|
|
86
|
+
from .core import __all__ as _core_all
|
|
87
|
+
from .agent import Agent, Limits, Session
|
|
88
|
+
from .run import (
|
|
89
|
+
Check,
|
|
90
|
+
HttpModel,
|
|
91
|
+
Journal,
|
|
92
|
+
LocalGateway,
|
|
93
|
+
MemoryCheckpointer,
|
|
94
|
+
Replay,
|
|
95
|
+
SqliteCheckpointer,
|
|
96
|
+
)
|
|
97
|
+
from .prompts import (
|
|
98
|
+
FilePrompts,
|
|
99
|
+
InMemoryPrompts,
|
|
100
|
+
PromptProvider,
|
|
101
|
+
PromptRegistry,
|
|
102
|
+
PromptTemplate,
|
|
103
|
+
fmt_dict,
|
|
104
|
+
fmt_list,
|
|
105
|
+
fmt_records,
|
|
106
|
+
)
|
|
107
|
+
from .providers import Provider
|
|
108
|
+
from .schema import Schema, schema_for
|
|
109
|
+
from .tools import Tool, json_schema_for, tool
|
|
110
|
+
|
|
111
|
+
__version__ = "1.0.0rc1"
|
|
112
|
+
__all__ = [
|
|
113
|
+
*_core_all, # ya trae UncertainEffect: repetirlo aquí lo duplicaba
|
|
114
|
+
"Agent",
|
|
115
|
+
"Limits",
|
|
116
|
+
"Session",
|
|
117
|
+
"Journal",
|
|
118
|
+
"MemoryCheckpointer",
|
|
119
|
+
"Replay",
|
|
120
|
+
"SqliteCheckpointer",
|
|
121
|
+
"HttpModel",
|
|
122
|
+
"LocalGateway",
|
|
123
|
+
"Check",
|
|
124
|
+
"Tool",
|
|
125
|
+
"tool",
|
|
126
|
+
"json_schema_for",
|
|
127
|
+
"Provider",
|
|
128
|
+
"PromptTemplate",
|
|
129
|
+
"PromptProvider",
|
|
130
|
+
"InMemoryPrompts",
|
|
131
|
+
"FilePrompts",
|
|
132
|
+
"PromptRegistry",
|
|
133
|
+
"fmt_dict",
|
|
134
|
+
"fmt_list",
|
|
135
|
+
"fmt_records",
|
|
136
|
+
"Schema",
|
|
137
|
+
"schema_for",
|
|
138
|
+
"__version__",
|
|
139
|
+
]
|
synaptum/agent/agent.py
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RM-19 · El bucle del agente como stream de eventos.
|
|
3
|
+
|
|
4
|
+
El bucle no es un ``while`` oculto: es un generador asíncrono que **cede el
|
|
5
|
+
control en cada frontera significativa**. Quien itera puede mirar, medir,
|
|
6
|
+
aprobar, interrumpir o guardar — sin que el bucle sepa quién está al otro lado::
|
|
7
|
+
|
|
8
|
+
async for step in agent.run(tarea, session=session):
|
|
9
|
+
match step:
|
|
10
|
+
case ModelStep(phase=Phase.COMPLETED, usage=u): ...
|
|
11
|
+
case ToolStep(phase=Phase.ATTEMPTED, risk=Risk.DESTRUCTIVE): ...
|
|
12
|
+
case FinalStep(output=salida): ...
|
|
13
|
+
|
|
14
|
+
Cuatro propiedades salen de esa forma, y ninguna otra estructura las da a la vez:
|
|
15
|
+
|
|
16
|
+
1. **El harness obtiene sus puntos de enganche** sin que Synaptum sepa que
|
|
17
|
+
existe. Aprobaciones, guardarraíles y métricas son consumidores del stream.
|
|
18
|
+
2. **Cada ``yield`` es una frontera de checkpoint natural.**
|
|
19
|
+
3. **Interrumpir es dejar de iterar**; reanudar es volver a llamar con el mismo
|
|
20
|
+
``run_id``.
|
|
21
|
+
4. **Probar es iterar una lista.**
|
|
22
|
+
|
|
23
|
+
Qué ejecuta el bucle y qué no
|
|
24
|
+
------------------------------
|
|
25
|
+
En modo gobernado, el bucle **no ejecuta nada**. Decide qué hacer y le pide al
|
|
26
|
+
``Gateway`` que lo haga, porque ahí es donde vive la credencial. Ni la llamada
|
|
27
|
+
al modelo ni la ejecución de una tool ocurren en este proceso.
|
|
28
|
+
|
|
29
|
+
Reanudación
|
|
30
|
+
-----------
|
|
31
|
+
Al empezar, el bucle carga el journal y recorre los pasos desde el principio.
|
|
32
|
+
Cada paso con resultado registrado se resuelve leyendo, no ejecutando — y así
|
|
33
|
+
**una inferencia ya pagada no se paga otra vez**. La ventana de contexto no se
|
|
34
|
+
almacena: se vuelve a derivar de los mismos resultados, en el mismo orden.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import time
|
|
41
|
+
from dataclasses import dataclass, field, replace
|
|
42
|
+
from typing import Any, AsyncIterator, Sequence
|
|
43
|
+
|
|
44
|
+
from ..core.errors import Denied, LimitExceeded, ProviderError, SynaptumError
|
|
45
|
+
from ..core.events import (
|
|
46
|
+
ApprovalStep,
|
|
47
|
+
Disposition,
|
|
48
|
+
FinalStep,
|
|
49
|
+
ModelStep,
|
|
50
|
+
Phase,
|
|
51
|
+
StepEvent,
|
|
52
|
+
ToolStep,
|
|
53
|
+
make_step_id,
|
|
54
|
+
)
|
|
55
|
+
from ..core.errors import NoObjectGeneratedError
|
|
56
|
+
from ..core.protocols import CallContext, Checkpointer, Gateway
|
|
57
|
+
from ..core.types import (
|
|
58
|
+
Message,
|
|
59
|
+
Request,
|
|
60
|
+
Response,
|
|
61
|
+
ResponseFormat,
|
|
62
|
+
Risk,
|
|
63
|
+
StreamEvent,
|
|
64
|
+
ToolCall,
|
|
65
|
+
ToolDefinition,
|
|
66
|
+
ToolResult,
|
|
67
|
+
Usage,
|
|
68
|
+
)
|
|
69
|
+
from ..schema.protocol import Schema, schema_for
|
|
70
|
+
from ..run.journal import Journal, MemoryCheckpointer, Replay
|
|
71
|
+
|
|
72
|
+
__all__ = ["Limits", "Session", "Agent"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── Límites — RM-27 ───────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class Limits:
|
|
79
|
+
"""Topes del bucle.
|
|
80
|
+
|
|
81
|
+
Son **corrección, no política**: evitan que un bucle mal formado no termine
|
|
82
|
+
nunca. Los límites de gasto pertenecen al harness y llegan por la costura
|
|
83
|
+
como ``Denied`` con ``terminate_run``.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
max_steps: int = 50
|
|
87
|
+
max_retries: int = 2
|
|
88
|
+
reserved_output: float = 0.25
|
|
89
|
+
"""Fracción de la ventana reservada para la salida. Informativa hasta que
|
|
90
|
+
entre el ensamblador de contexto (RM-32)."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Sesión ────────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
@dataclass(slots=True)
|
|
96
|
+
class Session:
|
|
97
|
+
"""Un run: su identidad, por dónde sale y dónde se recuerda.
|
|
98
|
+
|
|
99
|
+
Reanudar es construir una ``Session`` con el mismo ``run_id`` y el mismo
|
|
100
|
+
``checkpointer``. No hay nada más.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
run_id: str
|
|
104
|
+
gateway: Gateway
|
|
105
|
+
checkpointer: Checkpointer = field(default_factory=MemoryCheckpointer)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── Agente ────────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
class Agent:
|
|
111
|
+
"""Composición, no herencia. Un agente es su configuración más el bucle."""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
name: str,
|
|
116
|
+
*,
|
|
117
|
+
model: str,
|
|
118
|
+
instructions: Any = None,
|
|
119
|
+
tools: Sequence[Any] = (),
|
|
120
|
+
output: Any = None,
|
|
121
|
+
limits: Limits | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
self.name = name
|
|
124
|
+
self.model = model
|
|
125
|
+
# Acepta una cadena o cualquier cosa con `render()` — una plantilla
|
|
126
|
+
# versionada, sin que el bucle tenga que importar el sistema de prompts.
|
|
127
|
+
self.instructions: str | None = (
|
|
128
|
+
instructions.render() if hasattr(instructions, "render") else instructions
|
|
129
|
+
)
|
|
130
|
+
# Acepta ToolDefinition o cualquier objeto que la exponga — un `@tool`,
|
|
131
|
+
# sin que el bucle tenga que importar el decorador.
|
|
132
|
+
self.tools: tuple[ToolDefinition, ...] = tuple(
|
|
133
|
+
t.definition if hasattr(t, "definition") else t for t in tools
|
|
134
|
+
)
|
|
135
|
+
self.limits = limits or Limits()
|
|
136
|
+
self.output: Schema | None = schema_for(output) if output is not None else None
|
|
137
|
+
self._format = (
|
|
138
|
+
ResponseFormat(
|
|
139
|
+
kind="json_schema",
|
|
140
|
+
schema=self.output.json_schema(),
|
|
141
|
+
name=getattr(self.output, "name", "output"),
|
|
142
|
+
)
|
|
143
|
+
if self.output is not None
|
|
144
|
+
else None
|
|
145
|
+
)
|
|
146
|
+
self._by_name = {t.name: t for t in self.tools}
|
|
147
|
+
|
|
148
|
+
# ── Bucle ─────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
def run(self, task: str, *, session: Session) -> AsyncIterator[StepEvent]:
|
|
151
|
+
"""Ejecuta el agente cediendo cada paso.
|
|
152
|
+
|
|
153
|
+
La cancelación se propaga: cerrar el generador o cancelar la tarea que
|
|
154
|
+
lo consume interrumpe el paso en vuelo y vacía lo pendiente del journal.
|
|
155
|
+
|
|
156
|
+
**No es `async def`**, por el mismo motivo que `Gateway.stream_model`:
|
|
157
|
+
devuelve el iterador del bucle en vez de envolverlo. Un envoltorio que
|
|
158
|
+
hiciera `async for ... yield` parece inocuo y no lo es — al cerrarse
|
|
159
|
+
recibe el `GeneratorExit` y deja el generador de dentro abierto, así que
|
|
160
|
+
la cancelación llega cuando pase el recolector. Sin envoltorio, cerrar
|
|
161
|
+
esto **es** cerrar el bucle.
|
|
162
|
+
"""
|
|
163
|
+
return self._loop(task, session, stream=False)
|
|
164
|
+
|
|
165
|
+
def stream(
|
|
166
|
+
self, task: str, *, session: Session
|
|
167
|
+
) -> AsyncIterator[StepEvent | StreamEvent]:
|
|
168
|
+
"""Lo mismo, entregando además los fragmentos del modelo según llegan.
|
|
169
|
+
|
|
170
|
+
Es el mismo bucle y el mismo journal: lo único que cambia es que la
|
|
171
|
+
llamada al modelo sale por ``stream_model`` y sus eventos se ceden
|
|
172
|
+
intercalados entre la intención del paso y su resultado.
|
|
173
|
+
|
|
174
|
+
Va aparte de ``run`` y no como bandera porque **cambia el tipo de lo que
|
|
175
|
+
se cede**. Quien consume ``run`` recibe pasos y puede hacer `match` sobre
|
|
176
|
+
ellos sin una rama para lo que nunca va a llegar.
|
|
177
|
+
|
|
178
|
+
Dos cosas que conviene saber antes de usarlo:
|
|
179
|
+
|
|
180
|
+
* **Un paso que se reanuda no vuelve a emitir fragmentos.** Ya se pagó, y
|
|
181
|
+
reproducir sus tokens como si estuvieran ocurriendo sería teatro.
|
|
182
|
+
* **Un reintento vuelve a abrir el ciclo.** Los fragmentos ya entregados
|
|
183
|
+
no se retiran —se generaron y se pagaron—, así que un fallo a mitad
|
|
184
|
+
deja lo parcial y el reintento empieza con otro ``stream_start``.
|
|
185
|
+
"""
|
|
186
|
+
return self._loop(task, session, stream=True)
|
|
187
|
+
|
|
188
|
+
async def _loop(
|
|
189
|
+
self, task: str, session: Session, *, stream: bool
|
|
190
|
+
) -> AsyncIterator[Any]:
|
|
191
|
+
state = await session.checkpointer.load(session.run_id)
|
|
192
|
+
journal = Journal(session.checkpointer, session.run_id)
|
|
193
|
+
replay = Replay(state)
|
|
194
|
+
|
|
195
|
+
closed = replay.closed
|
|
196
|
+
if closed is not None:
|
|
197
|
+
# Un run que ya terminó devuelve lo que pasó, no lo intenta otra vez.
|
|
198
|
+
yield self._rehydrate(closed)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
messages: list[Message] = [Message.user(task)]
|
|
202
|
+
total = Usage.zero()
|
|
203
|
+
seq = 0
|
|
204
|
+
turns = 0
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
while True:
|
|
208
|
+
if turns >= self.limits.max_steps:
|
|
209
|
+
raise LimitExceeded("max_steps", self.limits.max_steps)
|
|
210
|
+
turns += 1
|
|
211
|
+
|
|
212
|
+
# ── Paso de modelo ────────────────────────────────────────────
|
|
213
|
+
step_id = make_step_id(seq, "model")
|
|
214
|
+
seq += 1
|
|
215
|
+
request = Request(
|
|
216
|
+
model=self.model,
|
|
217
|
+
system=self.instructions,
|
|
218
|
+
messages=tuple(messages),
|
|
219
|
+
tools=self.tools,
|
|
220
|
+
response_format=self._format,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Una llamada al modelo no tiene efecto externo más allá de su
|
|
224
|
+
# coste, así que repetirla tras una caída es caro pero correcto.
|
|
225
|
+
done = replay.resolve(step_id, idempotent=True)
|
|
226
|
+
if done is not None:
|
|
227
|
+
assert isinstance(done, ModelStep) and done.response is not None
|
|
228
|
+
response = done.response
|
|
229
|
+
yield done
|
|
230
|
+
else:
|
|
231
|
+
intent = ModelStep(
|
|
232
|
+
run_id=session.run_id, step_id=step_id, step_seq=seq - 1,
|
|
233
|
+
phase=Phase.ATTEMPTED, at=time.time(), request=request,
|
|
234
|
+
)
|
|
235
|
+
await journal.record(intent)
|
|
236
|
+
yield intent
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
if stream:
|
|
240
|
+
# El adaptador ya acumula la respuesta completa en el
|
|
241
|
+
# `Finish`: quien consumió los fragmentos no debería
|
|
242
|
+
# tener que reconstruirla.
|
|
243
|
+
response = None
|
|
244
|
+
fragments = self._stream_model(session, request, step_id)
|
|
245
|
+
try:
|
|
246
|
+
async for fragment in fragments:
|
|
247
|
+
if fragment.kind == "finish":
|
|
248
|
+
response = fragment.response
|
|
249
|
+
yield fragment
|
|
250
|
+
finally:
|
|
251
|
+
# Cerrar **aquí** y no dejarlo al recolector: si
|
|
252
|
+
# quien consume se va a mitad, el iterador de la
|
|
253
|
+
# costura tiene que cerrarse ya, porque cerrarlo
|
|
254
|
+
# es lo que para la generación arriba. Fiarlo al
|
|
255
|
+
# GC hace que la cancelación llegue tarde, o en
|
|
256
|
+
# otro momento cada vez — y el modelo sigue
|
|
257
|
+
# generando, y facturando, mientras tanto.
|
|
258
|
+
await fragments.aclose()
|
|
259
|
+
if response is None:
|
|
260
|
+
raise ProviderError(
|
|
261
|
+
"el stream terminó sin evento de cierre",
|
|
262
|
+
retryable=True,
|
|
263
|
+
)
|
|
264
|
+
else:
|
|
265
|
+
response = await self._call_model(session, request, step_id)
|
|
266
|
+
except Denied as denial:
|
|
267
|
+
async for event in self._close_denied(
|
|
268
|
+
denial, session, journal, seq, total,
|
|
269
|
+
step=ModelStep(
|
|
270
|
+
run_id=session.run_id, step_id=step_id, step_seq=seq - 1,
|
|
271
|
+
phase=Phase.COMPLETED, at=time.time(),
|
|
272
|
+
decision=denial.decision,
|
|
273
|
+
),
|
|
274
|
+
subject="llamada al modelo",
|
|
275
|
+
):
|
|
276
|
+
yield event
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
result = ModelStep(
|
|
280
|
+
run_id=session.run_id, step_id=step_id, step_seq=seq - 1,
|
|
281
|
+
phase=Phase.COMPLETED, at=time.time(),
|
|
282
|
+
response=response, usage=response.usage,
|
|
283
|
+
)
|
|
284
|
+
await journal.record(result)
|
|
285
|
+
yield result
|
|
286
|
+
|
|
287
|
+
# Una respuesta servida desde una clave de idempotencia **no se
|
|
288
|
+
# generó ahora**: su consumo describe la generación original, que
|
|
289
|
+
# ya se contó. Sumarlo otra vez no falla ni avisa — solo hace
|
|
290
|
+
# que el total del run sea mayor que lo que costó.
|
|
291
|
+
#
|
|
292
|
+
# El paso sí lo registra tal cual: el journal cuenta lo que el
|
|
293
|
+
# proveedor dijo, y el total cuenta lo que se pagó. Son cosas
|
|
294
|
+
# distintas y conviene que no se mezclen.
|
|
295
|
+
if not response.provider_metadata.get("idempotent_replay"):
|
|
296
|
+
total += response.usage
|
|
297
|
+
messages.append(response.message)
|
|
298
|
+
|
|
299
|
+
calls = response.tool_calls
|
|
300
|
+
if not calls:
|
|
301
|
+
break
|
|
302
|
+
|
|
303
|
+
# ── Pasos de herramienta ──────────────────────────────────────
|
|
304
|
+
results: list[ToolResult] = []
|
|
305
|
+
for call in calls:
|
|
306
|
+
step_id = make_step_id(seq, "tool")
|
|
307
|
+
seq += 1
|
|
308
|
+
spec = self._by_name.get(call.name)
|
|
309
|
+
risk = spec.risk if spec else Risk.READ
|
|
310
|
+
idempotent = spec.idempotent if spec else False
|
|
311
|
+
|
|
312
|
+
done = replay.resolve(step_id, idempotent=idempotent)
|
|
313
|
+
if done is not None:
|
|
314
|
+
assert isinstance(done, ToolStep) and done.result is not None
|
|
315
|
+
results.append(done.result)
|
|
316
|
+
yield done
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
intent = ToolStep(
|
|
320
|
+
run_id=session.run_id, step_id=step_id, step_seq=seq - 1,
|
|
321
|
+
phase=Phase.ATTEMPTED, at=time.time(),
|
|
322
|
+
call=call, risk=risk, idempotent=idempotent,
|
|
323
|
+
)
|
|
324
|
+
await journal.record(intent)
|
|
325
|
+
yield intent
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
outcome = await self._call_tool(session, call, step_id, spec)
|
|
329
|
+
except Denied as denial:
|
|
330
|
+
if denial.disposition is Disposition.DENY_STEP:
|
|
331
|
+
# El bucle puede intentar otra cosa: se le devuelve
|
|
332
|
+
# la negativa al modelo para que rectifique.
|
|
333
|
+
outcome = ToolResult.of(
|
|
334
|
+
call.id,
|
|
335
|
+
f"Denegado: {denial.decision.reason_code or 'política'}. "
|
|
336
|
+
f"{denial.decision.message}".strip(),
|
|
337
|
+
is_error=True,
|
|
338
|
+
)
|
|
339
|
+
else:
|
|
340
|
+
async for event in self._close_denied(
|
|
341
|
+
denial, session, journal, seq, total,
|
|
342
|
+
step=ToolStep(
|
|
343
|
+
run_id=session.run_id, step_id=step_id, step_seq=seq - 1,
|
|
344
|
+
phase=Phase.COMPLETED, at=time.time(),
|
|
345
|
+
call=call, risk=risk, idempotent=idempotent,
|
|
346
|
+
decision=denial.decision,
|
|
347
|
+
),
|
|
348
|
+
subject=f"herramienta '{call.name}'",
|
|
349
|
+
):
|
|
350
|
+
yield event
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
tool_result = ToolStep(
|
|
354
|
+
run_id=session.run_id, step_id=step_id, step_seq=seq - 1,
|
|
355
|
+
phase=Phase.COMPLETED, at=time.time(),
|
|
356
|
+
call=call, result=outcome, risk=risk, idempotent=idempotent,
|
|
357
|
+
)
|
|
358
|
+
await journal.record(tool_result)
|
|
359
|
+
yield tool_result
|
|
360
|
+
results.append(outcome)
|
|
361
|
+
|
|
362
|
+
messages.append(Message.tool_results(*results))
|
|
363
|
+
|
|
364
|
+
typed = self._final_output(messages[-1].text)
|
|
365
|
+
final = FinalStep(
|
|
366
|
+
run_id=session.run_id, step_id=make_step_id(seq, "final"), step_seq=seq,
|
|
367
|
+
phase=Phase.COMPLETED, at=time.time(),
|
|
368
|
+
output=self.output.dump(typed) if self.output is not None else typed,
|
|
369
|
+
usage=total,
|
|
370
|
+
meta={"replayed_steps": replay.replayed} if replay.replayed else {},
|
|
371
|
+
)
|
|
372
|
+
# El journal guarda la forma serializable; quien itera recibe el objeto.
|
|
373
|
+
# La salida tipada se **deriva**, no se almacena — lo mismo que el
|
|
374
|
+
# contexto, y por la misma razón: guardar las dos arriesga que
|
|
375
|
+
# discrepen.
|
|
376
|
+
await journal.record(final)
|
|
377
|
+
yield replace(final, output=typed)
|
|
378
|
+
finally:
|
|
379
|
+
# Se vacía también si alguien deja de iterar a mitad: lo diferido no
|
|
380
|
+
# puede quedarse en memoria cuando el run se interrumpe.
|
|
381
|
+
await journal.flush()
|
|
382
|
+
|
|
383
|
+
# ── Efectos, todos a través de la costura ─────────────────────────────────
|
|
384
|
+
|
|
385
|
+
async def _call_model(self, session: Session, request: Request, step_id: str) -> Response:
|
|
386
|
+
async def once() -> Response:
|
|
387
|
+
response = await session.gateway.invoke_model(
|
|
388
|
+
request, self._ctx(session, step_id)
|
|
389
|
+
)
|
|
390
|
+
# La validación entra **dentro** del reintento a propósito: un objeto
|
|
391
|
+
# mal formado es reintentable —el muestreo es estocástico— y la
|
|
392
|
+
# taxonomía de errores ya lo dice, así que basta con levantarlo aquí.
|
|
393
|
+
if self.output is not None and not response.tool_calls:
|
|
394
|
+
self._validate(response.message.text)
|
|
395
|
+
return response
|
|
396
|
+
|
|
397
|
+
return await self._with_retries(once)
|
|
398
|
+
|
|
399
|
+
def _stream_model(
|
|
400
|
+
self, session: Session, request: Request, step_id: str
|
|
401
|
+
) -> AsyncIterator[StreamEvent]:
|
|
402
|
+
"""Lo mismo por la costura de streaming.
|
|
403
|
+
|
|
404
|
+
**No es `async def` a propósito**, igual que `Gateway.stream_model`:
|
|
405
|
+
devuelve el iterador para que cerrarlo *sea* la señal de cancelación. Un
|
|
406
|
+
canal que se está cerrando no es sitio para mandar el aviso de que se
|
|
407
|
+
cierra.
|
|
408
|
+
|
|
409
|
+
Aquí no hay reintento envolviendo el generador: reintentar por dentro
|
|
410
|
+
obligaría a decidir qué hacer con los fragmentos ya cedidos, y la única
|
|
411
|
+
respuesta honesta —no se retiran— hace que el reintento sea visible de
|
|
412
|
+
todas formas. Que lo decida quien consume.
|
|
413
|
+
"""
|
|
414
|
+
return session.gateway.stream_model(request, self._ctx(session, step_id))
|
|
415
|
+
|
|
416
|
+
# ── Salida estructurada — SYN-16 ──────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
def _validate(self, text: str) -> Any:
|
|
419
|
+
"""Parsea y valida. Levanta ``NoObjectGeneratedError``, que es reintentable."""
|
|
420
|
+
assert self.output is not None
|
|
421
|
+
try:
|
|
422
|
+
data = json.loads(text)
|
|
423
|
+
except json.JSONDecodeError as broken:
|
|
424
|
+
raise NoObjectGeneratedError(
|
|
425
|
+
f"Se pidió salida estructurada y no volvió JSON: {broken}", raw=text
|
|
426
|
+
) from broken
|
|
427
|
+
return self.output.validate(data)
|
|
428
|
+
|
|
429
|
+
def _final_output(self, text: str) -> Any:
|
|
430
|
+
"""Lo que llega al ``FinalStep``: el objeto tipado, o el texto tal cual."""
|
|
431
|
+
return self._validate(text) if self.output is not None else text
|
|
432
|
+
|
|
433
|
+
def _rehydrate(self, closed: StepEvent) -> StepEvent:
|
|
434
|
+
"""Reconstruye la salida tipada de un run que ya estaba cerrado.
|
|
435
|
+
|
|
436
|
+
Sin esto, reanudar devolvería un diccionario donde la primera vuelta
|
|
437
|
+
devolvió un objeto — la misma llamada dando tipos distintos según
|
|
438
|
+
hubiera corrido antes o no.
|
|
439
|
+
"""
|
|
440
|
+
if self.output is None or getattr(closed, "output", None) is None:
|
|
441
|
+
return closed
|
|
442
|
+
return replace(closed, output=self.output.validate(closed.output))
|
|
443
|
+
|
|
444
|
+
async def _call_tool(
|
|
445
|
+
self, session: Session, call: ToolCall, step_id: str, spec: ToolDefinition | None
|
|
446
|
+
) -> ToolResult:
|
|
447
|
+
ctx = self._ctx(session, step_id)
|
|
448
|
+
risk = spec.risk if spec else Risk.READ
|
|
449
|
+
ref = spec.ref if spec else None
|
|
450
|
+
|
|
451
|
+
async def once() -> ToolResult:
|
|
452
|
+
return await session.gateway.invoke_tool(call, ctx, risk=risk, tool_ref=ref)
|
|
453
|
+
|
|
454
|
+
# Solo se reintenta lo que puede repetirse sin consecuencias.
|
|
455
|
+
if spec is not None and spec.idempotent:
|
|
456
|
+
return await self._with_retries(once)
|
|
457
|
+
return await once()
|
|
458
|
+
|
|
459
|
+
async def _with_retries(self, operation: Any) -> Any:
|
|
460
|
+
"""Reintenta mientras el error se declare reintentable.
|
|
461
|
+
|
|
462
|
+
La decisión no es del bucle: viaja en el tipo del error, que la trae de
|
|
463
|
+
quien habló con el proveedor.
|
|
464
|
+
"""
|
|
465
|
+
attempt = 0
|
|
466
|
+
while True:
|
|
467
|
+
try:
|
|
468
|
+
return await operation()
|
|
469
|
+
except SynaptumError as error:
|
|
470
|
+
if not error.retryable or attempt >= self.limits.max_retries:
|
|
471
|
+
raise
|
|
472
|
+
attempt += 1
|
|
473
|
+
|
|
474
|
+
def _ctx(self, session: Session, step_id: str) -> CallContext:
|
|
475
|
+
return CallContext(run_id=session.run_id, step_id=step_id)
|
|
476
|
+
|
|
477
|
+
# ── Cierre por denegación ─────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
async def _close_denied(
|
|
480
|
+
self,
|
|
481
|
+
denial: Denied,
|
|
482
|
+
session: Session,
|
|
483
|
+
journal: Journal,
|
|
484
|
+
seq: int,
|
|
485
|
+
total: Usage,
|
|
486
|
+
*,
|
|
487
|
+
step: StepEvent,
|
|
488
|
+
subject: str,
|
|
489
|
+
) -> AsyncIterator[StepEvent]:
|
|
490
|
+
"""Registra el desenlace del paso denegado y cierra como corresponda.
|
|
491
|
+
|
|
492
|
+
El ``RESULT`` del paso se escribe **con la decisión y sin efecto**. Es
|
|
493
|
+
lo que permite que una reanudación sepa que ahí no pasó nada, en vez de
|
|
494
|
+
encontrarse una intención huérfana y tratarla como el caso incierto.
|
|
495
|
+
|
|
496
|
+
``require_approval`` suspende: emite un ``ApprovalStep`` y termina el
|
|
497
|
+
stream **sin** cerrar el run. Volver a llamar con el mismo ``run_id``
|
|
498
|
+
retoma donde quedó. ``terminate_run`` cierra de verdad.
|
|
499
|
+
"""
|
|
500
|
+
await journal.record(step)
|
|
501
|
+
yield step
|
|
502
|
+
|
|
503
|
+
if denial.disposition is Disposition.REQUIRE_APPROVAL:
|
|
504
|
+
pause = ApprovalStep(
|
|
505
|
+
run_id=session.run_id, step_id=make_step_id(seq, "approval"), step_seq=seq,
|
|
506
|
+
phase=Phase.ATTEMPTED, at=time.time(),
|
|
507
|
+
subject=subject, decision=denial.decision,
|
|
508
|
+
)
|
|
509
|
+
await journal.record(pause)
|
|
510
|
+
yield pause
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
closing = FinalStep(
|
|
514
|
+
run_id=session.run_id, step_id=make_step_id(seq, "final"), step_seq=seq,
|
|
515
|
+
phase=Phase.COMPLETED, at=time.time(),
|
|
516
|
+
output=None, usage=total,
|
|
517
|
+
meta={
|
|
518
|
+
"disposition": denial.disposition.value,
|
|
519
|
+
"reason_code": denial.decision.reason_code,
|
|
520
|
+
"message": denial.decision.message,
|
|
521
|
+
},
|
|
522
|
+
)
|
|
523
|
+
await journal.record(closing)
|
|
524
|
+
yield closing
|