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 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
+ ]
@@ -0,0 +1,5 @@
1
+ """El bucle del agente."""
2
+
3
+ from .agent import Agent, Limits, Session
4
+
5
+ __all__ = ["Agent", "Limits", "Session"]
@@ -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