flowspec2 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. flowspec2/__init__.py +105 -0
  2. flowspec2/authoring/__init__.py +275 -0
  3. flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
  4. flowspec2/authoring/authoring-evidence.schema.json +455 -0
  5. flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
  6. flowspec2/authoring/benchmark.py +1409 -0
  7. flowspec2/authoring/corpus/await_correction.case.json +220 -0
  8. flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
  9. flowspec2/authoring/corpus/gated_derive.case.json +100 -0
  10. flowspec2/authoring/corpus/linear.case.json +55 -0
  11. flowspec2/authoring/corpus/manifest.json +31 -0
  12. flowspec2/authoring/corpus/subflow.case.json +66 -0
  13. flowspec2/authoring/corpus/terminal.case.json +94 -0
  14. flowspec2/authoring/corpus.py +307 -0
  15. flowspec2/authoring/ctk.py +836 -0
  16. flowspec2/authoring/detached_signature.py +120 -0
  17. flowspec2/authoring/evidence.py +311 -0
  18. flowspec2/authoring/evidence_signature.py +187 -0
  19. flowspec2/authoring/evidence_verification.py +229 -0
  20. flowspec2/authoring/gemini.py +215 -0
  21. flowspec2/authoring/operational-corpus.json +61 -0
  22. flowspec2/authoring/operational.py +956 -0
  23. flowspec2/authoring/operational_providers.py +167 -0
  24. flowspec2/authoring/presentation_review.py +1013 -0
  25. flowspec2/authoring/presentation_review_signature.py +305 -0
  26. flowspec2/authoring/projection.py +299 -0
  27. flowspec2/authoring/provider_prompt.py +83 -0
  28. flowspec2/backends/__init__.py +82 -0
  29. flowspec2/backends/http.py +189 -0
  30. flowspec2/checker.py +238 -0
  31. flowspec2/cli.py +789 -0
  32. flowspec2/cli_parser.py +345 -0
  33. flowspec2/clock.py +23 -0
  34. flowspec2/compat/__init__.py +47 -0
  35. flowspec2/compat/models.py +82 -0
  36. flowspec2/compat/open_workflow.py +616 -0
  37. flowspec2/compat/rasa.py +19 -0
  38. flowspec2/compat/rasa_export.py +876 -0
  39. flowspec2/compat/rasa_import.py +992 -0
  40. flowspec2/compat/rasa_shared.py +270 -0
  41. flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
  42. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
  43. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
  44. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
  45. flowspec2/compat/tool_profiles.py +149 -0
  46. flowspec2/compat/yaml.py +147 -0
  47. flowspec2/compiler.py +957 -0
  48. flowspec2/compiler_contracts.py +17 -0
  49. flowspec2/compiler_resume_contracts.py +594 -0
  50. flowspec2/compiler_schema_relations.py +1830 -0
  51. flowspec2/compiler_tool_contracts.py +245 -0
  52. flowspec2/compiler_value_contracts.py +447 -0
  53. flowspec2/derive.py +32 -0
  54. flowspec2/diagnostics.py +94 -0
  55. flowspec2/domains.py +489 -0
  56. flowspec2/experimental/__init__.py +57 -0
  57. flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
  58. flowspec2/experimental/v3-preview-loss-policy.json +161 -0
  59. flowspec2/experimental/v3_lowering.py +928 -0
  60. flowspec2/experimental/v3_preview.py +2460 -0
  61. flowspec2/flowspec-2.schema.json +635 -0
  62. flowspec2/interactive.py +300 -0
  63. flowspec2/ir.py +1459 -0
  64. flowspec2/json_codec.py +120 -0
  65. flowspec2/llm.py +366 -0
  66. flowspec2/models.py +121 -0
  67. flowspec2/nodes.py +1537 -0
  68. flowspec2/observability.py +151 -0
  69. flowspec2/predicates.py +89 -0
  70. flowspec2/profiles.py +118 -0
  71. flowspec2/py.typed +0 -0
  72. flowspec2/runtime.py +1059 -0
  73. flowspec2/schema.py +54 -0
  74. flowspec2/schema_contracts.py +203 -0
  75. flowspec2/semantic_derive_contracts.py +530 -0
  76. flowspec2/semantic_path_contracts.py +528 -0
  77. flowspec2/semantic_predicate_contracts.py +355 -0
  78. flowspec2/semantic_schema_contracts.py +137 -0
  79. flowspec2/semantic_source_contracts.py +21 -0
  80. flowspec2/semantic_state_contracts.py +450 -0
  81. flowspec2/semantic_support.py +40 -0
  82. flowspec2/semantics.py +410 -0
  83. flowspec2/state_migration.py +1034 -0
  84. flowspec2/subflows/__init__.py +1117 -0
  85. flowspec2/subflows/address.py +328 -0
  86. flowspec2/subflows/identification.py +1031 -0
  87. flowspec2/tools.py +1154 -0
  88. flowspec2-1.0.0.dist-info/METADATA +482 -0
  89. flowspec2-1.0.0.dist-info/RECORD +92 -0
  90. flowspec2-1.0.0.dist-info/WHEEL +4 -0
  91. flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
  92. flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
flowspec2/runtime.py ADDED
@@ -0,0 +1,1059 @@
1
+ """FlowRuntime — load · validate · compile · execute a flowspec/2 document.
2
+
3
+ ``execute`` mirrors the production ``base_workflow.execute``: it injects the
4
+ turn's payload, applies the reset semantics (post-success / empty-payload), runs
5
+ the compiled graph until it pauses or completes, then rebuilds the
6
+ ``AgentResponse`` (propagating ``interactive`` and correlated ``log_id`` values).
7
+ Two tool-layer concerns live *above* the graph: the ``auto_flow`` pre-graph
8
+ short-circuit (send a WhatsApp Flow and return ``flow_sent`` without entering
9
+ the graph) and the ``alias_map`` fan-out applied to Flow submissions.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import base64
16
+ import copy
17
+ import json
18
+ import logging
19
+ from dataclasses import dataclass
20
+ from datetime import datetime, timedelta
21
+ from typing import Any, Awaitable, Callable, Final, Optional, cast
22
+
23
+ from jsonschema import Draft202012Validator, FormatChecker
24
+
25
+ from .clock import UtcClock, system_utc_now, utc_timestamp
26
+ from .compiler import CompiledFlow, compile_flow
27
+ from .interactive import build_flow
28
+ from .ir import FlowIR, build_flow_ir
29
+ from .json_codec import validate_json_value
30
+ from .models import AgentResponse, ServiceMetadata, ServiceState
31
+ from .nodes import (
32
+ AUTO_FLOW_RESUME_TARGET_INTERNAL_KEY,
33
+ AUTO_FLOW_SKIPPED_PRESENTATIONS_PAYLOAD_KEY,
34
+ FLOW_FINISHED_INTERNAL_KEY,
35
+ RESET_ON_NEXT_CALL_DATA_KEY,
36
+ await_resume_contract_digest,
37
+ mark_flow_finished,
38
+ mark_slot_skipped,
39
+ )
40
+ from .observability import SnowflakeIdGenerator, default_log_id_generator, log_event
41
+ from .predicates import evaluate
42
+ from .profiles import FlowProfile, reference_profile
43
+ from .schema import load_flow
44
+ from .semantics import FlowLinkError, semantic_diagnostics
45
+ from .subflows import SubflowRegistry
46
+ from .tools import ToolRegistry
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+ _AUTO_FLOW_PENDING_INTERNAL_KEY: Final[str] = "_flowspec2_auto_flow_pending"
51
+ _AUTO_FLOW_DEADLINE_INTERNAL_KEY: Final[str] = "_flowspec2_auto_flow_deadline"
52
+ _AUTO_FLOW_RESEND_COUNT_INTERNAL_KEY: Final[str] = "_flowspec2_auto_flow_resend_count"
53
+ _AUTO_FLOW_EVENT_PAYLOAD_KEY: Final[str] = "_auto_flow_event"
54
+ _AUTO_FLOW_EVENTS: Final[tuple[str, ...]] = ("cancel", "resend", "fallback", "timeout")
55
+
56
+
57
+ @dataclass
58
+ class _UserExecutionLock:
59
+ """One event-loop lock retained while callers hold or await a user lease."""
60
+
61
+ lock: asyncio.Lock
62
+ lease_count: int = 0
63
+
64
+
65
+ def _encode_prefill_token(prefill: dict[str, Any]) -> str:
66
+ canonical_prefill = json.dumps(
67
+ prefill,
68
+ allow_nan=False,
69
+ ensure_ascii=False,
70
+ separators=(",", ":"),
71
+ sort_keys=True,
72
+ ).encode()
73
+ raw = base64.urlsafe_b64encode(canonical_prefill).decode()
74
+ return f"v1:{raw}"
75
+
76
+
77
+ def _alias_value_key(value: Any) -> str:
78
+ """Match JSON-authored value-map keys for string and non-string scalars."""
79
+
80
+ if isinstance(value, str):
81
+ return value
82
+ return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
83
+
84
+
85
+ def _reset_execution_state(state: ServiceState) -> None:
86
+ state.data = {}
87
+ state.internal = {}
88
+ state.status = "progress"
89
+ state.agent_response = None
90
+ state.metadata.await_resume = None
91
+
92
+
93
+ def _empty_payload_action(document: dict[str, Any], *, previously_saved: bool) -> str:
94
+ terminal = document.get("terminal") or {}
95
+ empty_payload = terminal.get("empty_payload") or {}
96
+ lifecycle_stage = "in_progress" if previously_saved else "never_saved"
97
+ default_action = "ignore" if previously_saved else "reset"
98
+ action = empty_payload.get(lifecycle_stage, default_action)
99
+ if action not in {"reset", "ignore"}:
100
+ raise ValueError(f"terminal.empty_payload.{lifecycle_stage} must be 'reset' or 'ignore'")
101
+ return str(action)
102
+
103
+
104
+ class FlowRuntime:
105
+ def __init__(
106
+ self,
107
+ doc: dict[str, Any],
108
+ *,
109
+ tools: Optional[ToolRegistry] = None,
110
+ subflows: Optional[SubflowRegistry] = None,
111
+ log_id_generator: Optional[SnowflakeIdGenerator] = None,
112
+ clock: Optional[UtcClock] = None,
113
+ validate: bool = True,
114
+ ) -> None:
115
+ private_doc = copy.deepcopy(doc)
116
+ self.log_id_generator = log_id_generator or default_log_id_generator()
117
+ self.clock = clock if clock is not None else system_utc_now
118
+ self.profile: FlowProfile = reference_profile(tools=tools, subflows=subflows)
119
+ self.ir: FlowIR = build_flow_ir(
120
+ private_doc,
121
+ profile=self.profile,
122
+ validate=validate,
123
+ )
124
+ self._doc = self.ir.to_document()
125
+ self._state_schema_validator = Draft202012Validator(
126
+ self.ir.state_schema(),
127
+ format_checker=FormatChecker(),
128
+ )
129
+ self._profile_exposed_slots = frozenset(
130
+ exposed_slot
131
+ for use_definition in self._doc.get("uses", [])
132
+ for exposed_slot in (
133
+ self.profile.subflows.definition(use_definition["ref"]).exposed_slots or ()
134
+ )
135
+ )
136
+ if validate and (
137
+ linking_diagnostics := semantic_diagnostics(self._doc, profile=self.profile)
138
+ ):
139
+ raise FlowLinkError(linking_diagnostics)
140
+ self.flow = self._doc["flow"]
141
+ self.compiled: CompiledFlow = compile_flow(
142
+ self._doc,
143
+ tools=self.profile.tools,
144
+ subflows=self.profile.subflows,
145
+ log_id_generator=self.log_id_generator,
146
+ clock=self.clock,
147
+ validate_semantics=validate,
148
+ )
149
+ self._tool_binding_revisions = {
150
+ required_capability.removeprefix("tool:"): self.profile.tools.binding_revision(
151
+ required_capability.removeprefix("tool:")
152
+ )
153
+ for required_capability in self.ir.required_capabilities
154
+ if required_capability.startswith("tool:")
155
+ }
156
+ self._store: dict[str, ServiceState] = {}
157
+ self._user_execution_locks: dict[str, _UserExecutionLock] = {}
158
+ self._as_tool_event_loop: asyncio.AbstractEventLoop | None = None
159
+
160
+ @property
161
+ def doc(self) -> dict[str, Any]:
162
+ """Return an owned copy of the immutable runtime source snapshot."""
163
+
164
+ return copy.deepcopy(self._doc)
165
+
166
+ @classmethod
167
+ def from_path(cls, path: str, **kwargs: Any) -> "FlowRuntime":
168
+ return cls(load_flow(path), **kwargs)
169
+
170
+ def new_state(self, user_id: str, data: Optional[dict[str, Any]] = None) -> ServiceState:
171
+ seeded_data = copy.deepcopy(data or {})
172
+ seeded_internal: dict[str, Any] = {}
173
+ for state_key in tuple(seeded_data):
174
+ if state_key not in self.compiled.ctx.slots:
175
+ continue
176
+ slot_definition = cast(dict[str, Any], self.compiled.ctx.slots[state_key])
177
+ validated_model = self.compiled.ctx.model_for(state_key).model_validate(
178
+ {state_key: seeded_data.pop(state_key)}
179
+ )
180
+ validated_value = copy.deepcopy(getattr(validated_model, state_key))
181
+ partition_name = cast(str, slot_definition.get("persist", "data"))
182
+ if partition_name == "data":
183
+ seeded_data[state_key] = validated_value
184
+ elif partition_name == "internal":
185
+ seeded_internal[state_key] = validated_value
186
+ else:
187
+ raise ValueError(
188
+ f"slots.{state_key}.persist has unsupported partition {partition_name!r}"
189
+ )
190
+ state = ServiceState(
191
+ user_id=user_id,
192
+ service_name=self.flow,
193
+ data=seeded_data,
194
+ internal=seeded_internal,
195
+ metadata=ServiceMetadata.from_clock(
196
+ self.clock,
197
+ flow_version=self.ir.version,
198
+ flow_ir_digest=self.ir.digest,
199
+ dependency_digest=self.ir.dependency_digest,
200
+ profile_digest=self.ir.profile_digest,
201
+ ),
202
+ )
203
+ self._validate_execution_state(state)
204
+ return state
205
+
206
+ def _retain_user_execution_lock(self, user_id: str) -> _UserExecutionLock:
207
+ running_event_loop = asyncio.get_running_loop()
208
+ if (
209
+ self._as_tool_event_loop is not None
210
+ and self._as_tool_event_loop is not running_event_loop
211
+ ):
212
+ raise RuntimeError(
213
+ "one FlowRuntime.as_tool() adapter cannot execute concurrently across event loops"
214
+ )
215
+ self._as_tool_event_loop = running_event_loop
216
+ execution_lock = self._user_execution_locks.get(user_id)
217
+ if execution_lock is None:
218
+ execution_lock = _UserExecutionLock(lock=asyncio.Lock())
219
+ self._user_execution_locks[user_id] = execution_lock
220
+ execution_lock.lease_count += 1
221
+ return execution_lock
222
+
223
+ def _release_user_execution_lock(
224
+ self,
225
+ user_id: str,
226
+ execution_lock: _UserExecutionLock,
227
+ ) -> None:
228
+ execution_lock.lease_count -= 1
229
+ if execution_lock.lease_count < 0:
230
+ raise RuntimeError(f"negative execution-lock lease count for user {user_id!r}")
231
+ if execution_lock.lease_count == 0:
232
+ registered_lock = self._user_execution_locks.pop(user_id, None)
233
+ if registered_lock is not execution_lock:
234
+ raise RuntimeError(f"execution-lock registry changed for user {user_id!r}")
235
+ if not self._user_execution_locks:
236
+ self._as_tool_event_loop = None
237
+
238
+ # ── auto_flow (pre-graph) ────────────────────────────────────────────────
239
+
240
+ def _should_send_flow(
241
+ self, af: dict[str, Any], state: ServiceState, payload: dict[str, Any]
242
+ ) -> bool:
243
+ source = af.get("on_submit_source", "whatsapp_flow")
244
+ if payload.get("_source") == source:
245
+ return False # this turn IS the Flow submission
246
+ if state.internal.get(_AUTO_FLOW_PENDING_INTERNAL_KEY):
247
+ return False # the same external submission is still pending
248
+ if state.internal.get("_started"):
249
+ return False # mid-flow (e.g. a correction cleared the gating slot) — never re-send
250
+ return bool(evaluate(af["send_when"], state, self.compiled.ctx.config))
251
+
252
+ def _flow_sent_state(self, af: dict[str, Any], state: ServiceState) -> ServiceState:
253
+ def prefill_value(slot_name: str) -> Any:
254
+ slot_definition = cast(dict[str, Any], self._doc.get("slots", {}).get(slot_name, {}))
255
+ partition_name = cast(str, slot_definition.get("persist", "data"))
256
+ partition = cast(dict[str, Any], getattr(state, partition_name))
257
+ return partition.get(slot_name)
258
+
259
+ prefill = {
260
+ k: prefill_value(k) for k in af.get("prefill_from", []) if prefill_value(k) is not None
261
+ }
262
+ token = _encode_prefill_token(prefill)
263
+ envelope = build_flow(
264
+ flow_id=af["meta_flow_ref"],
265
+ body="To continue, complete the form below. 📋",
266
+ flow_token=token,
267
+ )
268
+ recovery = cast(dict[str, Any], af["recovery"])
269
+ timestamp = utc_timestamp(self.clock)
270
+ deadline = state.internal.get(_AUTO_FLOW_DEADLINE_INTERNAL_KEY)
271
+ if deadline is None:
272
+ deadline = (
273
+ timestamp + timedelta(seconds=cast(int, recovery["timeout_seconds"]))
274
+ ).isoformat()
275
+ state.internal[_AUTO_FLOW_DEADLINE_INTERNAL_KEY] = deadline
276
+ resend_count = cast(int, state.internal.get(_AUTO_FLOW_RESEND_COUNT_INTERNAL_KEY, 0))
277
+ remaining_resends = max(0, cast(int, recovery["max_resends"]) - resend_count)
278
+ state.internal[_AUTO_FLOW_PENDING_INTERNAL_KEY] = True
279
+ state.payload = {}
280
+ state.agent_response = AgentResponse(
281
+ service_name=self.flow,
282
+ description="I sent you a form to complete. 📋",
283
+ interactive={
284
+ "flow": True,
285
+ "envelope": envelope,
286
+ "flow_token": token,
287
+ "status": "flow_sent",
288
+ "recovery": {
289
+ "event_field": _AUTO_FLOW_EVENT_PAYLOAD_KEY,
290
+ "events": list(_AUTO_FLOW_EVENTS),
291
+ "deadline": deadline,
292
+ "remaining_resends": remaining_resends,
293
+ },
294
+ },
295
+ data=state.data,
296
+ )
297
+ state.metadata.saved = True
298
+ state.metadata.updated_at = timestamp
299
+ return state
300
+
301
+ def _flow_waiting_state(self, state: ServiceState) -> ServiceState:
302
+ state.payload = {}
303
+ state.status = "progress"
304
+ state.agent_response = AgentResponse(
305
+ service_name=self.flow,
306
+ description="I am waiting for the form I sent. 📋",
307
+ payload_schema={
308
+ "type": "object",
309
+ "additionalProperties": False,
310
+ "properties": {_AUTO_FLOW_EVENT_PAYLOAD_KEY: {"enum": list(_AUTO_FLOW_EVENTS)}},
311
+ "required": [_AUTO_FLOW_EVENT_PAYLOAD_KEY],
312
+ },
313
+ data=state.data,
314
+ )
315
+ state.metadata.saved = True
316
+ state.metadata.touch(self.clock)
317
+ return state
318
+
319
+ @staticmethod
320
+ def _clear_auto_flow_pending(state: ServiceState) -> None:
321
+ state.internal.pop(_AUTO_FLOW_PENDING_INTERNAL_KEY, None)
322
+ state.internal.pop(_AUTO_FLOW_DEADLINE_INTERNAL_KEY, None)
323
+ state.internal.pop(_AUTO_FLOW_RESEND_COUNT_INTERNAL_KEY, None)
324
+
325
+ def _auto_flow_has_expired(self, state: ServiceState) -> bool:
326
+ deadline_value = state.internal.get(_AUTO_FLOW_DEADLINE_INTERNAL_KEY)
327
+ if not isinstance(deadline_value, str):
328
+ raise ValueError("pending auto_flow state has no valid deadline")
329
+ deadline = datetime.fromisoformat(deadline_value)
330
+ if deadline.tzinfo is None or deadline.utcoffset() is None:
331
+ raise ValueError("pending auto_flow deadline must include a timezone")
332
+ return utc_timestamp(self.clock) >= deadline
333
+
334
+ def _apply_auto_flow_fallback(
335
+ self,
336
+ auto_flow: dict[str, Any],
337
+ state: ServiceState,
338
+ ) -> None:
339
+ recovery = cast(dict[str, Any], auto_flow["recovery"])
340
+ fallback_flow = {**auto_flow, "resume_at": recovery["fallback_at"]}
341
+ state.payload = {}
342
+ self._prepare_auto_flow_submission(
343
+ fallback_flow,
344
+ state,
345
+ state.payload,
346
+ frozenset(),
347
+ )
348
+ self._clear_auto_flow_pending(state)
349
+ state.internal["_started"] = True
350
+ state.status = "progress"
351
+ state.agent_response = None
352
+
353
+ def _apply_auto_flow_end(self, state: ServiceState, event: str) -> ServiceState:
354
+ self._clear_auto_flow_pending(state)
355
+ state.payload = {}
356
+ mark_flow_finished(state, reset_next=True)
357
+ state.status = "completed"
358
+ state.agent_response = AgentResponse(
359
+ service_name=self.flow,
360
+ description=(
361
+ "The form was canceled." if event == "cancel" else "The form deadline expired."
362
+ ),
363
+ data=state.data,
364
+ )
365
+ state.metadata.saved = True
366
+ state.metadata.touch(self.clock)
367
+ return state
368
+
369
+ def _apply_auto_flow_event(
370
+ self,
371
+ auto_flow: dict[str, Any],
372
+ state: ServiceState,
373
+ event: str,
374
+ ) -> ServiceState | None:
375
+ if event not in _AUTO_FLOW_EVENTS:
376
+ raise ValueError(f"unsupported auto_flow event: {event!r}")
377
+ recovery = cast(dict[str, Any], auto_flow["recovery"])
378
+ if event == "resend":
379
+ resend_count = cast(
380
+ int,
381
+ state.internal.get(_AUTO_FLOW_RESEND_COUNT_INTERNAL_KEY, 0),
382
+ )
383
+ if resend_count >= cast(int, recovery["max_resends"]):
384
+ event = "timeout"
385
+ else:
386
+ state.internal[_AUTO_FLOW_RESEND_COUNT_INTERNAL_KEY] = resend_count + 1
387
+ return self._flow_sent_state(auto_flow, state)
388
+ action = "fallback" if event == "fallback" else recovery[event]
389
+ if action == "END":
390
+ return self._apply_auto_flow_end(state, event)
391
+ if action != "fallback":
392
+ raise ValueError(f"auto_flow event {event!r} has unsupported action {action!r}")
393
+ self._apply_auto_flow_fallback(auto_flow, state)
394
+ return None
395
+
396
+ @staticmethod
397
+ def _apply_alias_map(
398
+ alias_map: dict[str, Any], payload: dict[str, Any]
399
+ ) -> tuple[dict[str, Any], frozenset[str]]:
400
+ mapped_payload = dict(payload)
401
+ mapped_slots: set[str] = set()
402
+
403
+ def bind(slot: str, slot_value: Any) -> None:
404
+ if slot in mapped_payload and mapped_payload[slot] != slot_value:
405
+ raise ValueError(f"auto_flow aliases assign conflicting values to slot {slot!r}")
406
+ mapped_payload[slot] = copy.deepcopy(slot_value)
407
+ mapped_slots.add(slot)
408
+
409
+ for field, mapping in alias_map.items():
410
+ if field not in mapped_payload:
411
+ continue
412
+ value = mapped_payload[field]
413
+ value_keyed = all(isinstance(candidate, dict) for candidate in mapping.values())
414
+ if value_keyed:
415
+ selected_mapping = mapping.get(_alias_value_key(value))
416
+ if isinstance(selected_mapping, dict):
417
+ for slot, configured_value in selected_mapping.items():
418
+ bind(slot, value if configured_value == "$value" else configured_value)
419
+ else:
420
+ for slot, configured_value in mapping.items():
421
+ bind(slot, value if configured_value == "$value" else configured_value)
422
+ return mapped_payload, frozenset(mapped_slots)
423
+
424
+ def _prepare_auto_flow_submission(
425
+ self,
426
+ auto_flow: dict[str, Any],
427
+ state: ServiceState,
428
+ payload: dict[str, Any],
429
+ mapped_slots: frozenset[str],
430
+ ) -> None:
431
+ """Validate mapped values and atomically seed state before the resume jump."""
432
+
433
+ context = self.compiled.ctx
434
+ pending_values: dict[str, Any] = {}
435
+ source = cast(str, auto_flow.get("on_submit_source", "whatsapp_flow"))
436
+ for slot in sorted(mapped_slots):
437
+ if slot not in context.slots:
438
+ raise ValueError(f"auto_flow alias writes unknown slot {slot!r}")
439
+ slot_definition = cast(dict[str, Any], context.slots[slot])
440
+ if slot_definition.get("fill_only_when_asked") and source not in set(
441
+ slot_definition.get("prefill_sources", [])
442
+ ):
443
+ raise ValueError(
444
+ f"auto_flow source {source!r} is not allowed to prefill slot {slot!r}"
445
+ )
446
+ validated_model = context.model_for(slot).model_validate({slot: payload[slot]})
447
+ pending_values[slot] = copy.deepcopy(getattr(validated_model, slot))
448
+
449
+ resume_target = auto_flow.get("resume_at")
450
+ if resume_target is None:
451
+ self._commit_auto_flow_values(state, payload, pending_values)
452
+ return
453
+ compiled_document = self.compiled.doc
454
+ matching_indexes = [
455
+ path_index
456
+ for path_index, path_step in enumerate(compiled_document["path"])
457
+ if path_step.get("id") == resume_target
458
+ ]
459
+ if len(matching_indexes) != 1:
460
+ raise ValueError(
461
+ "auto_flow.resume_at must reference exactly one native path step: "
462
+ f"{resume_target!r}"
463
+ )
464
+
465
+ prefix = compiled_document["path"][: matching_indexes[0]]
466
+ skipped_prefix_slots: list[str] = []
467
+ preview_state = state.model_copy(deep=True)
468
+ preview_state.payload = copy.deepcopy(payload)
469
+ self._commit_auto_flow_values(preview_state, preview_state.payload, pending_values)
470
+ override_gates = cast(
471
+ dict[str, Any],
472
+ cast(dict[str, Any], compiled_document.get("overrides", {})).get("gates", {}),
473
+ )
474
+ for path_step in prefix:
475
+ slot = path_step.get("slot")
476
+ confirmation_slot = path_step.get("confirm")
477
+ if slot is not None:
478
+ slot_definition = cast(dict[str, Any], context.slots[slot])
479
+ gate = path_step.get("ask_when") or override_gates.get(path_step.get("id"))
480
+ is_satisfied_by_vacuity = bool(
481
+ path_step.get("skip_when")
482
+ and evaluate(path_step["skip_when"], preview_state, context.config)
483
+ ) or bool(gate is not None and not evaluate(gate, preview_state, context.config))
484
+ if is_satisfied_by_vacuity:
485
+ skipped_prefix_slots.append(cast(str, slot))
486
+ continue
487
+ if slot_definition.get("required") and not self._slot_available(
488
+ state, cast(str, slot), pending_values
489
+ ):
490
+ raise ValueError(
491
+ f"auto_flow submission cannot resume past required slot {slot!r}"
492
+ )
493
+ if not slot_definition.get("required") and not self._slot_available(
494
+ state, cast(str, slot), pending_values
495
+ ):
496
+ skipped_prefix_slots.append(cast(str, slot))
497
+ elif confirmation_slot is not None:
498
+ confirmation_gate = path_step.get("ask_when") or override_gates.get(
499
+ path_step.get("id")
500
+ )
501
+ confirmation_is_vacuous = bool(
502
+ path_step.get("skip_when")
503
+ and evaluate(path_step["skip_when"], preview_state, context.config)
504
+ ) or bool(
505
+ confirmation_gate is not None
506
+ and not evaluate(confirmation_gate, preview_state, context.config)
507
+ )
508
+ if confirmation_is_vacuous:
509
+ skipped_prefix_slots.append(cast(str, confirmation_slot))
510
+ elif not self._slot_available(state, cast(str, confirmation_slot), pending_values):
511
+ raise ValueError(
512
+ "auto_flow submission cannot resume past unanswered confirmation "
513
+ f"{confirmation_slot!r}"
514
+ )
515
+
516
+ self._commit_auto_flow_values(state, payload, pending_values)
517
+ for skipped_slot in skipped_prefix_slots:
518
+ mark_slot_skipped(state, skipped_slot)
519
+ state.internal[AUTO_FLOW_RESUME_TARGET_INTERNAL_KEY] = resume_target
520
+
521
+ def _slot_available(
522
+ self,
523
+ state: ServiceState,
524
+ slot: str,
525
+ pending_values: dict[str, Any],
526
+ ) -> bool:
527
+ if slot in pending_values:
528
+ return True
529
+ slot_definition = cast(dict[str, Any], self.compiled.ctx.slots[slot])
530
+ partition_name = cast(str, slot_definition.get("persist", "data"))
531
+ return slot in cast(dict[str, Any], getattr(state, partition_name))
532
+
533
+ def _commit_auto_flow_values(
534
+ self,
535
+ state: ServiceState,
536
+ payload: dict[str, Any],
537
+ pending_values: dict[str, Any],
538
+ ) -> None:
539
+ for slot, validated_value in pending_values.items():
540
+ slot_definition = cast(dict[str, Any], self.compiled.ctx.slots[slot])
541
+ partition_name = cast(str, slot_definition.get("persist", "data"))
542
+ target_partition = cast(dict[str, Any], getattr(state, partition_name))
543
+ for persistent_partition_name in ("data", "internal"):
544
+ persistent_partition = cast(
545
+ dict[str, Any], getattr(state, persistent_partition_name)
546
+ )
547
+ if persistent_partition is not target_partition:
548
+ persistent_partition.pop(slot, None)
549
+ target_partition[slot] = validated_value
550
+ payload[slot] = copy.deepcopy(validated_value)
551
+
552
+ def _validate_execution_state(self, state: ServiceState) -> None:
553
+ """Validate and normalize restored persistent slots before any effect runs."""
554
+
555
+ if state.service_name != self.flow:
556
+ raise ValueError(
557
+ f"state service_name {state.service_name!r} does not match runtime flow {self.flow!r}"
558
+ )
559
+ if not state.user_id.strip():
560
+ raise ValueError("state user_id must be non-empty")
561
+ for tool_name, compiled_binding_revision in self._tool_binding_revisions.items():
562
+ current_binding_revision = self.profile.tools.binding_revision(tool_name)
563
+ if current_binding_revision != compiled_binding_revision:
564
+ raise ValueError(
565
+ f"tool binding {tool_name!r} changed after this runtime was compiled"
566
+ )
567
+ expected_provenance = {
568
+ "flow_version": self.ir.version,
569
+ "flow_ir_digest": self.ir.digest,
570
+ "dependency_digest": self.ir.dependency_digest,
571
+ "profile_digest": self.ir.profile_digest,
572
+ }
573
+ for metadata_field, expected_value in expected_provenance.items():
574
+ restored_value = getattr(state.metadata, metadata_field)
575
+ if restored_value != expected_value:
576
+ raise ValueError(
577
+ f"state metadata {metadata_field} {restored_value!r} does not match "
578
+ f"runtime contract {expected_value!r}"
579
+ )
580
+ if await_resume_provenance := state.metadata.await_resume:
581
+ await_definition = cast(
582
+ dict[str, Any],
583
+ cast(dict[str, Any], self._doc.get("capabilities", {})).get("await_external", {}),
584
+ )
585
+ resume_contract = cast(dict[str, Any] | None, await_definition.get("resume"))
586
+ if resume_contract is None:
587
+ raise ValueError("state metadata await_resume has no typed runtime resume contract")
588
+ compiled_await_definition = self.compiled.ctx.await_external or await_definition
589
+ expected_resume_provenance = {
590
+ "step": compiled_await_definition["step"],
591
+ "version": resume_contract["version"],
592
+ "digest": await_resume_contract_digest(resume_contract),
593
+ "correlation_path": resume_contract["correlation"],
594
+ }
595
+ for provenance_field, expected_value in expected_resume_provenance.items():
596
+ restored_value = getattr(await_resume_provenance, provenance_field)
597
+ if restored_value != expected_value:
598
+ raise ValueError(
599
+ f"state metadata await_resume.{provenance_field} "
600
+ f"{restored_value!r} does not match runtime contract "
601
+ f"{expected_value!r}"
602
+ )
603
+ persisted_deadline = await_resume_provenance.deadline
604
+ if await_definition.get("timeout") is not None:
605
+ if persisted_deadline is None:
606
+ raise ValueError("state metadata await_resume.deadline is missing")
607
+ if persisted_deadline.utcoffset() is None:
608
+ raise ValueError("state metadata await_resume.deadline must include a timezone")
609
+ elif persisted_deadline is not None:
610
+ raise ValueError(
611
+ "state metadata await_resume.deadline exists without a runtime timeout contract"
612
+ )
613
+
614
+ for exposed_slot in self._profile_exposed_slots:
615
+ present_partitions = [
616
+ partition_name
617
+ for partition_name in ("data", "internal", "payload")
618
+ if exposed_slot in cast(dict[str, Any], getattr(state, partition_name))
619
+ ]
620
+ if unexpected_partitions := [
621
+ partition_name for partition_name in present_partitions if partition_name != "data"
622
+ ]:
623
+ raise ValueError(
624
+ f"restored profile slot {exposed_slot!r} belongs in 'data', "
625
+ f"not {unexpected_partitions!r}"
626
+ )
627
+
628
+ pending_values: dict[tuple[str, str], Any] = {}
629
+ for slot_name, slot_definition_value in self.compiled.ctx.slots.items():
630
+ slot_definition = cast(dict[str, Any], slot_definition_value)
631
+ expected_partition_name = cast(str, slot_definition.get("persist", "data"))
632
+ if expected_partition_name not in {"data", "internal"}:
633
+ raise ValueError(
634
+ f"slots.{slot_name}.persist has unsupported partition "
635
+ f"{expected_partition_name!r}"
636
+ )
637
+ present_partitions = [
638
+ partition_name
639
+ for partition_name in ("data", "internal", "payload")
640
+ if slot_name in cast(dict[str, Any], getattr(state, partition_name))
641
+ ]
642
+ unexpected_partitions = [
643
+ partition_name
644
+ for partition_name in present_partitions
645
+ if partition_name != expected_partition_name
646
+ ]
647
+ if unexpected_partitions:
648
+ raise ValueError(
649
+ f"restored slot {slot_name!r} belongs in {expected_partition_name!r}, "
650
+ f"not {unexpected_partitions!r}"
651
+ )
652
+ if expected_partition_name not in present_partitions:
653
+ continue
654
+ expected_partition = cast(dict[str, Any], getattr(state, expected_partition_name))
655
+ validated_model = self.compiled.ctx.model_for(slot_name).model_validate(
656
+ {slot_name: expected_partition[slot_name]}
657
+ )
658
+ pending_values[(expected_partition_name, slot_name)] = copy.deepcopy(
659
+ getattr(validated_model, slot_name)
660
+ )
661
+
662
+ state_partitions = {
663
+ "data": copy.deepcopy(state.data),
664
+ "internal": copy.deepcopy(state.internal),
665
+ "payload": copy.deepcopy(state.payload),
666
+ }
667
+ for (partition_name, slot_name), validated_value in pending_values.items():
668
+ state_partitions[partition_name][slot_name] = copy.deepcopy(validated_value)
669
+ validate_json_value(state_partitions, boundary="restored flow state")
670
+ state_validation_errors = sorted(
671
+ self._state_schema_validator.iter_errors(state_partitions),
672
+ key=lambda error: (
673
+ tuple(str(segment) for segment in error.absolute_path),
674
+ tuple(str(segment) for segment in error.absolute_schema_path),
675
+ ),
676
+ )
677
+ if state_validation_errors:
678
+ state_validation_error = state_validation_errors[0]
679
+ instance_path = "".join(
680
+ f"[{segment!r}]" for segment in state_validation_error.absolute_path
681
+ )
682
+ raise ValueError(f"restored state{instance_path}: {state_validation_error.message}")
683
+ for (partition_name, slot_name), validated_value in pending_values.items():
684
+ cast(dict[str, Any], getattr(state, partition_name))[slot_name] = validated_value
685
+
686
+ def _completed_resume_delivery(
687
+ self,
688
+ state: ServiceState,
689
+ payload: dict[str, Any],
690
+ ) -> ServiceState | None:
691
+ """Apply replay/late policy before lifecycle reset can erase wait markers."""
692
+
693
+ if not (
694
+ state.data.get(RESET_ON_NEXT_CALL_DATA_KEY)
695
+ or state.internal.get(FLOW_FINISHED_INTERNAL_KEY)
696
+ ):
697
+ return None
698
+ await_definition = cast(
699
+ dict[str, Any],
700
+ cast(dict[str, Any], self._doc.get("capabilities", {})).get("await_external", {}),
701
+ )
702
+ resume_contract = cast(dict[str, Any] | None, await_definition.get("resume"))
703
+ resume_on = await_definition.get("resume_on")
704
+ if resume_contract is None or not isinstance(resume_on, str) or resume_on not in payload:
705
+ return None
706
+ if set(payload) != {resume_on}:
707
+ raise ValueError(f"completed await_external delivery must contain only {resume_on!r}")
708
+
709
+ token = payload[resume_on]
710
+ token_validator = Draft202012Validator(
711
+ cast(dict[str, Any], resume_contract["schema"]),
712
+ format_checker=FormatChecker(),
713
+ )
714
+ token_errors = sorted(
715
+ token_validator.iter_errors(token),
716
+ key=lambda error: (
717
+ tuple(str(segment) for segment in error.absolute_path),
718
+ tuple(str(segment) for segment in error.absolute_schema_path),
719
+ ),
720
+ )
721
+ if token_errors:
722
+ token_error = token_errors[0]
723
+ raise ValueError(
724
+ f"completed resume token does not satisfy contract: {token_error.message}"
725
+ )
726
+ correlation_reference = cast(str, resume_contract["correlation"])
727
+ correlation_value: Any = token
728
+ for path_segment in correlation_reference.removeprefix("$token.").split("."):
729
+ if not isinstance(correlation_value, dict) or path_segment not in correlation_value:
730
+ raise ValueError(
731
+ f"completed resume token correlation {correlation_reference!r} is missing"
732
+ )
733
+ correlation_value = correlation_value[path_segment]
734
+ if correlation_value is None or not isinstance(correlation_value, (str, int, float, bool)):
735
+ raise ValueError(
736
+ f"completed resume token correlation {correlation_reference!r} must be a "
737
+ "non-null JSON scalar"
738
+ )
739
+ accepted_correlation = (
740
+ state.metadata.await_resume.correlation_value
741
+ if state.metadata.await_resume is not None
742
+ else None
743
+ )
744
+ canonical_delivered_correlation = json.dumps(
745
+ correlation_value,
746
+ allow_nan=False,
747
+ ensure_ascii=False,
748
+ separators=(",", ":"),
749
+ sort_keys=True,
750
+ )
751
+ canonical_accepted_correlation = (
752
+ json.dumps(
753
+ accepted_correlation,
754
+ allow_nan=False,
755
+ ensure_ascii=False,
756
+ separators=(",", ":"),
757
+ sort_keys=True,
758
+ )
759
+ if accepted_correlation is not None
760
+ else None
761
+ )
762
+ same_correlation = (
763
+ canonical_accepted_correlation is not None
764
+ and canonical_delivered_correlation == canonical_accepted_correlation
765
+ )
766
+ delivery_kind = "duplicate" if same_correlation else "late"
767
+ delivery_policy = cast(str, resume_contract[delivery_kind])
768
+ compiled_await_definition = self.compiled.ctx.await_external or await_definition
769
+ if delivery_policy == "reject":
770
+ raise ValueError(
771
+ f"await_external {compiled_await_definition['step']!r} rejected "
772
+ f"{delivery_kind} resume delivery"
773
+ )
774
+ return self._finalize_response(state)
775
+
776
+ # ── execute ──────────────────────────────────────────────────────────────
777
+
778
+ async def execute(
779
+ self, state: ServiceState, payload: Optional[dict[str, Any]] = None
780
+ ) -> ServiceState:
781
+ try:
782
+ self._validate_execution_state(state)
783
+ except Exception as exc: # noqa: BLE001
784
+ log_id = log_event(
785
+ logger,
786
+ logging.ERROR,
787
+ "Restored flow state failed validation",
788
+ operation="restore_state_validation",
789
+ log_id_generator=self.log_id_generator,
790
+ context={"flow": self.flow, "error_type": type(exc).__name__},
791
+ exc_info=True,
792
+ )
793
+ state.status = "error"
794
+ state.agent_response = AgentResponse(
795
+ description="I could not restore this service session.",
796
+ error_message=str(exc),
797
+ log_id=log_id,
798
+ )
799
+ return self._finalize_response(state)
800
+
801
+ payload = dict(payload or {})
802
+ try:
803
+ validate_json_value(payload, boundary="flow payload")
804
+ except Exception as exc: # noqa: BLE001
805
+ log_id = log_event(
806
+ logger,
807
+ logging.WARNING,
808
+ "Flow payload failed strict JSON validation",
809
+ operation="payload_validation",
810
+ log_id_generator=self.log_id_generator,
811
+ context={"flow": self.flow, "error_type": type(exc).__name__},
812
+ exc_info=True,
813
+ )
814
+ state.status = "error"
815
+ state.agent_response = AgentResponse(
816
+ description="I could not validate the data submitted in this turn.",
817
+ error_message=str(exc),
818
+ log_id=log_id,
819
+ )
820
+ return self._finalize_response(state)
821
+ payload.pop(AUTO_FLOW_SKIPPED_PRESENTATIONS_PAYLOAD_KEY, None)
822
+ state.internal.pop(AUTO_FLOW_RESUME_TARGET_INTERNAL_KEY, None)
823
+
824
+ try:
825
+ if completed_delivery_state := self._completed_resume_delivery(state, payload):
826
+ return completed_delivery_state
827
+ except Exception as exc: # noqa: BLE001
828
+ log_id = log_event(
829
+ logger,
830
+ logging.WARNING,
831
+ "Completed external-resume delivery failed validation",
832
+ operation="await_external_replay",
833
+ log_id_generator=self.log_id_generator,
834
+ context={"flow": self.flow, "error_type": type(exc).__name__},
835
+ exc_info=True,
836
+ )
837
+ state.status = "error"
838
+ state.agent_response = AgentResponse(
839
+ description="I could not process the external action result.",
840
+ error_message=str(exc),
841
+ log_id=log_id,
842
+ )
843
+ return self._finalize_response(state)
844
+
845
+ # Reset semantics run FIRST so the auto_flow gate and the graph both see a
846
+ # clean slate after a completed/aborted run (post-success reset) or a fresh
847
+ # never-saved start. Mirrors base_workflow.execute, hoisted above the
848
+ # tool-layer auto_flow check.
849
+ if state.data.get(RESET_ON_NEXT_CALL_DATA_KEY):
850
+ _reset_execution_state(state)
851
+ elif state.internal.get(FLOW_FINISHED_INTERNAL_KEY):
852
+ return self._finalize_response(state)
853
+ elif (
854
+ not payload
855
+ and _empty_payload_action(self._doc, previously_saved=state.metadata.saved) == "reset"
856
+ ):
857
+ _reset_execution_state(state)
858
+
859
+ state.payload = copy.deepcopy(payload)
860
+ af = self._doc.get("auto_flow")
861
+ recovered_from_pending_flow = False
862
+ if af:
863
+ try:
864
+ pending_flow = bool(state.internal.get(_AUTO_FLOW_PENDING_INTERNAL_KEY))
865
+ explicit_event_present = _AUTO_FLOW_EVENT_PAYLOAD_KEY in payload
866
+ explicit_event = payload.get(_AUTO_FLOW_EVENT_PAYLOAD_KEY)
867
+ if explicit_event_present and not pending_flow:
868
+ raise ValueError("auto_flow events require a pending form")
869
+ if pending_flow:
870
+ recovery_event: str | None = None
871
+ if explicit_event_present:
872
+ if not isinstance(explicit_event, str):
873
+ raise ValueError("auto_flow event must be a string")
874
+ if set(payload) != {_AUTO_FLOW_EVENT_PAYLOAD_KEY}:
875
+ raise ValueError(
876
+ "auto_flow event payload cannot include unrelated fields"
877
+ )
878
+ recovery_event = explicit_event
879
+ elif self._auto_flow_has_expired(state):
880
+ recovery_event = "timeout"
881
+ payload = {}
882
+ state.payload = {}
883
+ if recovery_event is not None:
884
+ recovery_state = self._apply_auto_flow_event(
885
+ af,
886
+ state,
887
+ recovery_event,
888
+ )
889
+ if recovery_state is not None:
890
+ return recovery_state
891
+ payload = {}
892
+ recovered_from_pending_flow = True
893
+ except Exception as exc: # noqa: BLE001
894
+ log_id = log_event(
895
+ logger,
896
+ logging.WARNING,
897
+ "Auto-flow recovery failed validation",
898
+ operation="auto_flow_recovery",
899
+ log_id_generator=self.log_id_generator,
900
+ context={"flow": self.flow, "error_type": type(exc).__name__},
901
+ exc_info=True,
902
+ )
903
+ state.status = "error"
904
+ state.agent_response = AgentResponse(
905
+ description="I could not process the form action.",
906
+ error_message=str(exc),
907
+ log_id=log_id,
908
+ )
909
+ return self._finalize_response(state)
910
+ if af and not recovered_from_pending_flow and self._should_send_flow(af, state, payload):
911
+ return self._flow_sent_state(af, state)
912
+ if (
913
+ af
914
+ and not recovered_from_pending_flow
915
+ and state.internal.get(_AUTO_FLOW_PENDING_INTERNAL_KEY)
916
+ and payload.get("_source") != af.get("on_submit_source", "whatsapp_flow")
917
+ ):
918
+ return self._flow_waiting_state(state)
919
+ if af and payload.get("_source") == af.get("on_submit_source", "whatsapp_flow"):
920
+ try:
921
+ payload, aliased_slots = self._apply_alias_map(
922
+ af.get("alias_map", {}) or {}, payload
923
+ )
924
+ identity_slots = frozenset(payload) & frozenset(self.compiled.ctx.slots)
925
+ mapped_slots = aliased_slots | identity_slots
926
+ state.payload = payload
927
+ self._prepare_auto_flow_submission(af, state, payload, mapped_slots)
928
+ self._clear_auto_flow_pending(state)
929
+ except Exception as exc: # noqa: BLE001
930
+ log_id = log_event(
931
+ logger,
932
+ logging.WARNING,
933
+ "Auto-flow submission failed validation",
934
+ operation="auto_flow_submission",
935
+ log_id_generator=self.log_id_generator,
936
+ context={"flow": self.flow, "error_type": type(exc).__name__},
937
+ exc_info=True,
938
+ )
939
+ state.status = "error"
940
+ state.agent_response = AgentResponse(
941
+ description="I could not validate the submitted form.",
942
+ error_message=str(exc),
943
+ log_id=log_id,
944
+ )
945
+ return self._finalize_response(state)
946
+
947
+ state.payload = payload
948
+ state.internal["_started"] = (
949
+ True # the graph is about to run; corrections now route through it
950
+ )
951
+ result = await self.compiled.graph.ainvoke(state)
952
+ final = result if isinstance(result, ServiceState) else ServiceState(**result)
953
+
954
+ return self._finalize_response(final)
955
+
956
+ def _finalize_response(self, final: ServiceState) -> ServiceState:
957
+ """Apply the stable caller-facing envelope to a graph or lifecycle result."""
958
+
959
+ if final.agent_response is None:
960
+ final.status = "completed"
961
+ final.agent_response = AgentResponse(
962
+ service_name=self.flow,
963
+ description="Service completed successfully.",
964
+ data=final.data,
965
+ )
966
+
967
+ ar = final.agent_response
968
+ assert ar is not None # set above when the graph ended without one
969
+ if ar.log_id is None and (final.status == "error" or ar.error_message is not None):
970
+ ar.log_id = log_event(
971
+ logger,
972
+ logging.ERROR if final.status == "error" else logging.WARNING,
973
+ "Flow response contains an error",
974
+ operation="execute",
975
+ log_id_generator=self.log_id_generator,
976
+ context={
977
+ "flow": self.flow,
978
+ "status": final.status,
979
+ "error_message": ar.error_message or ar.description,
980
+ },
981
+ )
982
+ final.payload = {}
983
+ final.internal.pop(AUTO_FLOW_RESUME_TARGET_INTERNAL_KEY, None)
984
+ final.agent_response = AgentResponse(
985
+ service_name=self.flow,
986
+ error_message=ar.error_message,
987
+ log_id=ar.log_id,
988
+ description=ar.description,
989
+ payload_schema=ar.payload_schema,
990
+ data=final.data,
991
+ interactive=ar.interactive,
992
+ )
993
+ final.metadata.saved = True
994
+ final.metadata.touch(self.clock)
995
+ return final
996
+
997
+ # ── callable-tool adapter (the multi_step_service entry point) ────────────
998
+
999
+ def as_tool(
1000
+ self,
1001
+ ) -> Callable[
1002
+ [str, str, Optional[dict[str, Any]]],
1003
+ Awaitable[dict[str, Any]],
1004
+ ]:
1005
+ """Return an async ``(service_name, user_id, payload) -> dict`` callable.
1006
+
1007
+ Mirrors the production ``multi_step_service`` MCP tool: it loads/saves the
1008
+ per-user state and returns the agent-facing dict (description +
1009
+ payload_schema + status + optional interactive/error/log ID). Calls for
1010
+ one user are serialized across every adapter returned by this runtime;
1011
+ calls for distinct users remain concurrent. The lock and state store are
1012
+ in-memory contracts scoped to one ``FlowRuntime`` and one event loop at a
1013
+ time. A multi-process host or a host with multiple runtime instances must
1014
+ provide transactional shared persistence and distributed per-user
1015
+ serialization outside this reference adapter.
1016
+
1017
+ ``out_of_band``/flow sends surface as ``status: interactive_sent`` so an
1018
+ outer agent ends the turn. Returned structured values are owned copies,
1019
+ so caller mutation cannot alter the saved state.
1020
+ """
1021
+
1022
+ async def multi_step_service(
1023
+ service_name: str, user_id: str, payload: Optional[dict[str, Any]] = None
1024
+ ) -> dict[str, Any]:
1025
+ if service_name != self.flow:
1026
+ raise ValueError(
1027
+ f"service_name {service_name!r} does not match runtime flow {self.flow!r}"
1028
+ )
1029
+
1030
+ execution_lock = self._retain_user_execution_lock(user_id)
1031
+ try:
1032
+ async with execution_lock.lock:
1033
+ state = self._store.get(user_id) or self.new_state(user_id)
1034
+ state = await self.execute(state, payload or {})
1035
+ agent_response = state.agent_response or AgentResponse()
1036
+ output: dict[str, Any] = {
1037
+ "status": state.status,
1038
+ "description": agent_response.description,
1039
+ "payload_schema": copy.deepcopy(agent_response.payload_schema),
1040
+ "data": copy.deepcopy(state.data),
1041
+ }
1042
+ if agent_response.error_message is not None:
1043
+ output["error_message"] = agent_response.error_message
1044
+ if agent_response.log_id is not None:
1045
+ output["log_id"] = agent_response.log_id
1046
+ if agent_response.interactive:
1047
+ output["interactive"] = copy.deepcopy(agent_response.interactive)
1048
+ if (
1049
+ agent_response.interactive.get("out_of_band_sent")
1050
+ or agent_response.interactive.get("status") == "flow_sent"
1051
+ ):
1052
+ output["status"] = "interactive_sent"
1053
+ validate_json_value(output, boundary="flow tool output")
1054
+ self._store[user_id] = state
1055
+ return output
1056
+ finally:
1057
+ self._release_user_execution_lock(user_id, execution_lock)
1058
+
1059
+ return multi_step_service