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.
- flowspec2/__init__.py +105 -0
- flowspec2/authoring/__init__.py +275 -0
- flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
- flowspec2/authoring/authoring-evidence.schema.json +455 -0
- flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
- flowspec2/authoring/benchmark.py +1409 -0
- flowspec2/authoring/corpus/await_correction.case.json +220 -0
- flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
- flowspec2/authoring/corpus/gated_derive.case.json +100 -0
- flowspec2/authoring/corpus/linear.case.json +55 -0
- flowspec2/authoring/corpus/manifest.json +31 -0
- flowspec2/authoring/corpus/subflow.case.json +66 -0
- flowspec2/authoring/corpus/terminal.case.json +94 -0
- flowspec2/authoring/corpus.py +307 -0
- flowspec2/authoring/ctk.py +836 -0
- flowspec2/authoring/detached_signature.py +120 -0
- flowspec2/authoring/evidence.py +311 -0
- flowspec2/authoring/evidence_signature.py +187 -0
- flowspec2/authoring/evidence_verification.py +229 -0
- flowspec2/authoring/gemini.py +215 -0
- flowspec2/authoring/operational-corpus.json +61 -0
- flowspec2/authoring/operational.py +956 -0
- flowspec2/authoring/operational_providers.py +167 -0
- flowspec2/authoring/presentation_review.py +1013 -0
- flowspec2/authoring/presentation_review_signature.py +305 -0
- flowspec2/authoring/projection.py +299 -0
- flowspec2/authoring/provider_prompt.py +83 -0
- flowspec2/backends/__init__.py +82 -0
- flowspec2/backends/http.py +189 -0
- flowspec2/checker.py +238 -0
- flowspec2/cli.py +789 -0
- flowspec2/cli_parser.py +345 -0
- flowspec2/clock.py +23 -0
- flowspec2/compat/__init__.py +47 -0
- flowspec2/compat/models.py +82 -0
- flowspec2/compat/open_workflow.py +616 -0
- flowspec2/compat/rasa.py +19 -0
- flowspec2/compat/rasa_export.py +876 -0
- flowspec2/compat/rasa_import.py +992 -0
- flowspec2/compat/rasa_shared.py +270 -0
- flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
- flowspec2/compat/tool_profiles.py +149 -0
- flowspec2/compat/yaml.py +147 -0
- flowspec2/compiler.py +957 -0
- flowspec2/compiler_contracts.py +17 -0
- flowspec2/compiler_resume_contracts.py +594 -0
- flowspec2/compiler_schema_relations.py +1830 -0
- flowspec2/compiler_tool_contracts.py +245 -0
- flowspec2/compiler_value_contracts.py +447 -0
- flowspec2/derive.py +32 -0
- flowspec2/diagnostics.py +94 -0
- flowspec2/domains.py +489 -0
- flowspec2/experimental/__init__.py +57 -0
- flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
- flowspec2/experimental/v3-preview-loss-policy.json +161 -0
- flowspec2/experimental/v3_lowering.py +928 -0
- flowspec2/experimental/v3_preview.py +2460 -0
- flowspec2/flowspec-2.schema.json +635 -0
- flowspec2/interactive.py +300 -0
- flowspec2/ir.py +1459 -0
- flowspec2/json_codec.py +120 -0
- flowspec2/llm.py +366 -0
- flowspec2/models.py +121 -0
- flowspec2/nodes.py +1537 -0
- flowspec2/observability.py +151 -0
- flowspec2/predicates.py +89 -0
- flowspec2/profiles.py +118 -0
- flowspec2/py.typed +0 -0
- flowspec2/runtime.py +1059 -0
- flowspec2/schema.py +54 -0
- flowspec2/schema_contracts.py +203 -0
- flowspec2/semantic_derive_contracts.py +530 -0
- flowspec2/semantic_path_contracts.py +528 -0
- flowspec2/semantic_predicate_contracts.py +355 -0
- flowspec2/semantic_schema_contracts.py +137 -0
- flowspec2/semantic_source_contracts.py +21 -0
- flowspec2/semantic_state_contracts.py +450 -0
- flowspec2/semantic_support.py +40 -0
- flowspec2/semantics.py +410 -0
- flowspec2/state_migration.py +1034 -0
- flowspec2/subflows/__init__.py +1117 -0
- flowspec2/subflows/address.py +328 -0
- flowspec2/subflows/identification.py +1031 -0
- flowspec2/tools.py +1154 -0
- flowspec2-1.0.0.dist-info/METADATA +482 -0
- flowspec2-1.0.0.dist-info/RECORD +92 -0
- flowspec2-1.0.0.dist-info/WHEEL +4 -0
- flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|