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/nodes.py ADDED
@@ -0,0 +1,1537 @@
1
+ """Node templates + the shared compile-time context.
2
+
3
+ Every ``path`` step (and every spliced subflow node) becomes one async node
4
+ following the verified production pattern:
5
+
6
+ 1. honour ``skip_when`` / gate (skip-by-vacuity) and any pending correction;
7
+ 2. early-return if the slot is already filled (position is rediscovered, never
8
+ stored);
9
+ 3. if the slot arrived in ``payload`` this turn, validate it against the domain
10
+ model → **advance** (``agent_response=None``) or **re-ask** (with
11
+ ``error_message``, incrementing the attempt counter);
12
+ 4. otherwise set ``agent_response`` (description + ``payload_schema`` +
13
+ optional ``interactive``) and **pause**.
14
+
15
+ A node's router returns the next node id, ``END`` (pause), or the ``NEXT``
16
+ sentinel (the compiler resolves it to the following node in sequence).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import copy
22
+ import hashlib
23
+ import json
24
+ import logging
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime, timedelta
27
+ from typing import Any, Awaitable, Callable, Final, Optional, cast
28
+
29
+ from jsonschema import Draft202012Validator, FormatChecker
30
+ from langgraph.graph import END as LANGGRAPH_END
31
+ from pydantic import BaseModel
32
+
33
+ from .clock import UtcClock, utc_timestamp
34
+ from .derive import encode_derive_key
35
+ from .domains import make_slot_model
36
+ from .interactive import options_from_domain
37
+ from .models import (
38
+ CORRECTION_REQUESTED_INTERNAL_KEY,
39
+ CORRECTION_TARGETS_SCHEMA_KEY,
40
+ AgentResponse,
41
+ AwaitResumeProvenance,
42
+ ServiceState,
43
+ )
44
+ from .observability import SnowflakeIdGenerator, log_event
45
+ from .predicates import evaluate
46
+ from .tools import ToolRegistry
47
+
48
+ END: Final[str] = LANGGRAPH_END
49
+ NEXT = "__NEXT__" # router sentinel: "the next node in the flat sequence"
50
+ AUTO_FLOW_SKIPPED_PRESENTATIONS_PAYLOAD_KEY: Final[str] = (
51
+ "_flowspec2_auto_flow_skipped_presentations"
52
+ )
53
+ AUTO_FLOW_RESUME_TARGET_INTERNAL_KEY: Final[str] = "_flowspec2_auto_flow_resume_target"
54
+ FLOW_FINISHED_INTERNAL_KEY: Final[str] = "_flowspec2_flow_finished"
55
+ RESET_ON_NEXT_CALL_DATA_KEY: Final[str] = "_reset_on_next_call"
56
+ DEFAULT_AWAIT_EXTERNAL_TIMEOUT_SECONDS: Final[int] = 900
57
+ _CORRECTION_UNAVAILABLE_INTERNAL_KEY: Final[str] = "_flowspec2_correction_unavailable"
58
+ _MISSING = object()
59
+ _ASKED_SLOT_KEY: Final[str] = "_collection_asked_slot"
60
+ _ANSWERING_SLOT_KEY: Final[str] = "_collection_answering_slot"
61
+ _SLOT_PARTITIONS: Final[tuple[str, ...]] = ("data", "internal")
62
+
63
+ logger = logging.getLogger(__name__)
64
+
65
+ NodeFn = Callable[[ServiceState], Awaitable[ServiceState]]
66
+ RouterFn = Callable[[ServiceState], str]
67
+
68
+
69
+ def await_resume_contract_digest(resume_contract: dict[str, Any]) -> str:
70
+ """Return the canonical identity hosts persist beside a resume signal."""
71
+
72
+ canonical_contract = json.dumps(
73
+ resume_contract,
74
+ allow_nan=False,
75
+ ensure_ascii=False,
76
+ separators=(",", ":"),
77
+ sort_keys=True,
78
+ ).encode("utf-8")
79
+ return hashlib.sha256(canonical_contract).hexdigest()
80
+
81
+
82
+ @dataclass
83
+ class NodeDesc:
84
+ id: str
85
+ fn: NodeFn
86
+ router: RouterFn
87
+ targets: list[str] = field(default_factory=list) # literal target ids besides NEXT/END
88
+
89
+
90
+ @dataclass
91
+ class FlowContext:
92
+ """Everything the node factories close over, computed once at compile time."""
93
+
94
+ domains: dict[str, Any]
95
+ slots: dict[str, Any]
96
+ config: dict[str, Any]
97
+ tools: ToolRegistry
98
+ log_id_generator: SnowflakeIdGenerator
99
+ clock: UtcClock
100
+ flow_name: str
101
+ flow_revision: str
102
+ await_external: Optional[dict[str, Any]] = None
103
+ slot_models: dict[str, type[BaseModel]] = field(default_factory=dict)
104
+ await_external_nodes: set[str] = field(default_factory=set)
105
+ dependents: dict[str, set[str]] = field(
106
+ default_factory=dict
107
+ ) # slot -> slots that (transitively) require it
108
+ derive_readers: dict[str, list[str]] = field(
109
+ default_factory=dict
110
+ ) # slot -> derive writes-keys reading it
111
+ node_for_slot: dict[str, str] = field(default_factory=dict) # slot -> node id that collects it
112
+ slot_aux: dict[str, list[str]] = field(
113
+ default_factory=dict
114
+ ) # slot -> extra data keys to clear with it
115
+
116
+ def model_for(self, slot: str) -> type[BaseModel]:
117
+ if slot not in self.slot_models:
118
+ cfg = self.slots[slot]
119
+ self.slot_models[slot] = make_slot_model(
120
+ slot,
121
+ cfg["domain"],
122
+ self.domains,
123
+ nullable=cfg.get("nullable", False),
124
+ )
125
+ return self.slot_models[slot]
126
+
127
+ @property
128
+ def max_attempts(self) -> int:
129
+ return int(self.config.get("max_attempts", 3))
130
+
131
+
132
+ # ── shared helpers ───────────────────────────────────────────────────────────
133
+
134
+
135
+ def mark_flow_finished(state: ServiceState, *, reset_next: bool) -> None:
136
+ """Record a terminal lifecycle decision independently of response status."""
137
+
138
+ state.internal[FLOW_FINISHED_INTERNAL_KEY] = True
139
+ if reset_next:
140
+ state.data[RESET_ON_NEXT_CALL_DATA_KEY] = True
141
+
142
+
143
+ def _att_key(slot: str) -> str:
144
+ return f"_attempts_{slot}"
145
+
146
+
147
+ def _skipped_slot_key(slot: str) -> str:
148
+ return f"_slot_skipped:{slot}"
149
+
150
+
151
+ def mark_slot_skipped(state: ServiceState, slot: str) -> None:
152
+ """Persist the declared no-value state used for optional position recovery."""
153
+
154
+ state.internal[_skipped_slot_key(slot)] = True
155
+
156
+
157
+ def _slot_partition_name(ctx: FlowContext, slot: str) -> str:
158
+ partition_name = str(ctx.slots.get(slot, {}).get("persist", "data"))
159
+ if partition_name not in _SLOT_PARTITIONS:
160
+ raise ValueError(f"slots.{slot}.persist has unsupported partition {partition_name!r}")
161
+ return partition_name
162
+
163
+
164
+ def _slot_partition(state: ServiceState, slot: str, ctx: FlowContext) -> dict[str, Any]:
165
+ return cast(dict[str, Any], getattr(state, _slot_partition_name(ctx, slot)))
166
+
167
+
168
+ def _slot_is_persisted(state: ServiceState, slot: str, ctx: FlowContext) -> bool:
169
+ return slot in _slot_partition(state, slot, ctx)
170
+
171
+
172
+ def _slot_content(state: ServiceState, slot: str, ctx: FlowContext) -> Any:
173
+ return _slot_partition(state, slot, ctx).get(slot)
174
+
175
+
176
+ def _store_slot(state: ServiceState, slot: str, content: Any, ctx: FlowContext) -> None:
177
+ target_partition = _slot_partition(state, slot, ctx)
178
+ for partition_name in ("data", "internal"):
179
+ partition = cast(dict[str, Any], getattr(state, partition_name))
180
+ if partition is not target_partition:
181
+ partition.pop(slot, None)
182
+ target_partition[slot] = content
183
+ state.internal.pop(_skipped_slot_key(slot), None)
184
+
185
+
186
+ def _clear_slot(
187
+ state: ServiceState,
188
+ slot: str,
189
+ *,
190
+ clear_payload: bool = False,
191
+ ) -> None:
192
+ state.data.pop(slot, None)
193
+ state.internal.pop(slot, None)
194
+ if clear_payload:
195
+ state.payload.pop(slot, None)
196
+
197
+
198
+ def _payload_slot_input(
199
+ state: ServiceState,
200
+ slot: str,
201
+ payload_field: str,
202
+ ) -> tuple[bool, Any]:
203
+ slot_is_present = slot in state.payload
204
+ field_is_present = payload_field in state.payload
205
+ if (
206
+ payload_field != slot
207
+ and slot_is_present
208
+ and field_is_present
209
+ and state.payload[slot] != state.payload[payload_field]
210
+ ):
211
+ raise ValueError(
212
+ f"payload fields {slot!r} and {payload_field!r} contain conflicting values"
213
+ )
214
+ if field_is_present:
215
+ return True, state.payload[payload_field]
216
+ if slot_is_present:
217
+ return True, state.payload[slot]
218
+ return False, None
219
+
220
+
221
+ def _clear_payload_slot_input(state: ServiceState, slot: str, payload_field: str) -> None:
222
+ state.payload.pop(slot, None)
223
+ state.payload.pop(payload_field, None)
224
+
225
+
226
+ def inc_attempts(state: ServiceState, slot: str) -> int:
227
+ n = int(state.data.get(_att_key(slot), 0)) + 1
228
+ state.data[_att_key(slot)] = n
229
+ return n
230
+
231
+
232
+ def reset_attempts(state: ServiceState, slot: str) -> None:
233
+ state.data.pop(_att_key(slot), None)
234
+
235
+
236
+ def clear_cascade(
237
+ state: ServiceState,
238
+ slot: str,
239
+ ctx: FlowContext,
240
+ *,
241
+ clear_payload: bool = False,
242
+ ) -> None:
243
+ """Pop a slot + its transitive ``requires[]``-dependents + derives reading them."""
244
+ to_clear = {slot} | ctx.dependents.get(slot, set())
245
+ for s in to_clear:
246
+ _clear_slot(state, s, clear_payload=clear_payload)
247
+ reset_attempts(state, s)
248
+ state.internal.pop(_skipped_slot_key(s), None)
249
+ if state.internal.get(_ASKED_SLOT_KEY) == s:
250
+ state.internal.pop(_ASKED_SLOT_KEY, None)
251
+ if state.internal.get(_ANSWERING_SLOT_KEY) == s:
252
+ state.internal.pop(_ANSWERING_SLOT_KEY, None)
253
+ for aux in ctx.slot_aux.get(s, []):
254
+ state.data.pop(aux, None)
255
+ for derived in ctx.derive_readers.get(s, []):
256
+ if derived in ctx.slots:
257
+ _clear_slot(state, derived, clear_payload=clear_payload)
258
+ else:
259
+ state.data.pop(derived, None)
260
+
261
+
262
+ def _dig(obj: Any, path: str) -> Any:
263
+ parts = path.split(".")
264
+ if not parts or parts[0] != "result":
265
+ raise ValueError(f"terminal output path must use the result.* namespace: {path!r}")
266
+ parts = parts[1:]
267
+ cur = obj
268
+ for p in parts:
269
+ if not isinstance(cur, dict) or p not in cur:
270
+ raise KeyError(f"terminal output path does not exist: {path!r}")
271
+ cur = cur[p]
272
+ return cur
273
+
274
+
275
+ def _dig_binding(source: Any, path: str) -> Any:
276
+ current = source
277
+ for segment in path.split("."):
278
+ if isinstance(current, dict):
279
+ if segment not in current:
280
+ return _MISSING
281
+ current = current[segment]
282
+ elif isinstance(current, list) and segment.isdigit():
283
+ index = int(segment)
284
+ if index >= len(current):
285
+ return _MISSING
286
+ current = current[index]
287
+ else:
288
+ return _MISSING
289
+ return current
290
+
291
+
292
+ def _resolve_bindings(
293
+ bindings: dict[str, Any],
294
+ namespace: str,
295
+ source: Any,
296
+ ) -> dict[str, Any]:
297
+ """Resolve one bounded namespace into a private pending-write mapping.
298
+
299
+ Missing optional source paths produce no write. The compiler rejects every
300
+ other ``$`` namespace before graph construction, so runtime evaluation stays
301
+ deliberately smaller than an expression language.
302
+ """
303
+ prefix = f"${namespace}."
304
+ resolved: dict[str, Any] = {}
305
+ for target, binding in bindings.items():
306
+ if isinstance(binding, str) and binding.startswith("$"):
307
+ if not binding.startswith(prefix):
308
+ raise ValueError(f"{target!r} must use the {prefix} namespace")
309
+ mapped = _dig_binding(source, binding.removeprefix(prefix))
310
+ if mapped is _MISSING:
311
+ continue
312
+ resolved[target] = copy.deepcopy(mapped)
313
+ else:
314
+ resolved[target] = copy.deepcopy(binding)
315
+ return resolved
316
+
317
+
318
+ def _validate_state_writes(
319
+ ctx: FlowContext,
320
+ pending_writes: dict[str, Any],
321
+ ) -> dict[str, tuple[str, Any]]:
322
+ """Validate all declared-slot destinations without mutating state."""
323
+
324
+ validated_writes: dict[str, tuple[str, Any]] = {}
325
+ for state_key, pending_value in pending_writes.items():
326
+ if state_key not in ctx.slots:
327
+ validated_writes[state_key] = ("data", copy.deepcopy(pending_value))
328
+ continue
329
+ slot_definition = cast(dict[str, Any], ctx.slots[state_key])
330
+ validated_model = ctx.model_for(state_key).model_validate({state_key: pending_value})
331
+ validated_writes[state_key] = (
332
+ cast(str, slot_definition.get("persist", "data")),
333
+ copy.deepcopy(getattr(validated_model, state_key)),
334
+ )
335
+ return validated_writes
336
+
337
+
338
+ def _commit_state_writes(
339
+ state: ServiceState,
340
+ validated_writes: dict[str, tuple[str, Any]],
341
+ ) -> None:
342
+ for state_key, (partition_name, validated_value) in validated_writes.items():
343
+ target_partition = cast(dict[str, Any], getattr(state, partition_name))
344
+ for persistent_partition_name in ("data", "internal"):
345
+ persistent_partition = cast(dict[str, Any], getattr(state, persistent_partition_name))
346
+ if persistent_partition is not target_partition:
347
+ persistent_partition.pop(state_key, None)
348
+ target_partition[state_key] = validated_value
349
+
350
+
351
+ def _handle_node_error(
352
+ state: ServiceState,
353
+ exc: Exception,
354
+ *,
355
+ ctx: FlowContext,
356
+ operation: str,
357
+ ) -> ServiceState:
358
+ log_id = log_event(
359
+ logger,
360
+ logging.ERROR,
361
+ "Flow node failed",
362
+ operation=operation,
363
+ log_id_generator=ctx.log_id_generator,
364
+ context={"flow": state.service_name, "error_type": type(exc).__name__},
365
+ exc_info=True,
366
+ )
367
+ prior = state.agent_response or AgentResponse()
368
+ prior.error_message = str(exc)
369
+ prior.log_id = log_id
370
+ state.agent_response = prior
371
+ state.status = "error"
372
+ return state
373
+
374
+
375
+ # ── external suspend/resume ─────────────────────────────────────────────────
376
+
377
+
378
+ def make_await_external_node(
379
+ ctx: FlowContext,
380
+ capability: dict[str, Any],
381
+ step: Optional[dict[str, Any]] = None,
382
+ *,
383
+ default_router: Optional[RouterFn] = None,
384
+ additional_targets: Optional[list[str]] = None,
385
+ legacy_sent_data_key: Optional[str] = None,
386
+ waiting_description: Optional[str] = None,
387
+ ) -> NodeDesc:
388
+ """Build the singular out-of-band wait primitive.
389
+
390
+ The host delivers a resume token under ``resume_on`` or a recovery signal
391
+ under ``_external_event``. In particular, timeout is a host event; this node
392
+ does not own a clock or scheduler.
393
+ """
394
+ step = step or {}
395
+ node_id = step.get("step") or capability.get("step") or "await_external"
396
+ ctx.await_external_nodes.add(node_id)
397
+ resume_on = capability["resume_on"]
398
+ prompt = step.get("prompt") or capability.get("prompt") or {}
399
+ description = prompt.get("text", "Complete the external action to continue.")
400
+ interactive = step.get("interactive") or capability.get("interactive") or {}
401
+ on_resume = capability.get("on_resume") or {}
402
+ token_bindings = on_resume.get("set") or {}
403
+ enrichment = on_resume.get("enrich")
404
+ if isinstance(enrichment, str):
405
+ # Legacy documents declared only a best-effort tool name. Subflows may
406
+ # add compatibility inputs/outputs before calling this factory.
407
+ enrichment = {"tool": enrichment, "optional": True, "input": {}, "set": {}}
408
+ resume_contract = cast(dict[str, Any] | None, capability.get("resume"))
409
+ resume_contract_digest = (
410
+ await_resume_contract_digest(resume_contract) if resume_contract is not None else None
411
+ )
412
+ resume_schema_validator = (
413
+ Draft202012Validator(
414
+ cast(dict[str, Any], resume_contract["schema"]),
415
+ format_checker=FormatChecker(),
416
+ )
417
+ if resume_contract is not None
418
+ else None
419
+ )
420
+ correlation_reference = (
421
+ cast(str, resume_contract["correlation"]) if resume_contract is not None else None
422
+ )
423
+ timeout_transition = capability.get("timeout")
424
+ timeout_seconds = (
425
+ cast(int, capability.get("timeout_seconds", DEFAULT_AWAIT_EXTERNAL_TIMEOUT_SECONDS))
426
+ if timeout_transition is not None
427
+ else None
428
+ )
429
+ max_resends = cast(int | None, capability.get("max_resends"))
430
+
431
+ sent_internal_key = f"_await_external_sent:{node_id}"
432
+ completed_internal_key = f"_await_external_completed:{node_id}"
433
+ route_internal_key = f"_await_external_route:{node_id}"
434
+ resend_count_internal_key = f"_await_external_resend_count:{node_id}"
435
+
436
+ transitions = {
437
+ "timeout": capability.get("timeout"),
438
+ **(capability.get("recovery") or {}),
439
+ }
440
+ targets = list(additional_targets or [])
441
+ targets.extend(
442
+ transition["goto"]
443
+ for transition in transitions.values()
444
+ if transition is not None and transition["goto"] != "END"
445
+ )
446
+ targets = list(dict.fromkeys(targets))
447
+
448
+ def clear_sent(state: ServiceState) -> None:
449
+ state.internal.pop(sent_internal_key, None)
450
+ if legacy_sent_data_key:
451
+ state.data.pop(legacy_sent_data_key, None)
452
+
453
+ def mark_sent(state: ServiceState) -> None:
454
+ state.internal[sent_internal_key] = True
455
+ if legacy_sent_data_key:
456
+ state.data[legacy_sent_data_key] = True
457
+
458
+ def was_sent(state: ServiceState) -> bool:
459
+ return bool(
460
+ state.internal.get(sent_internal_key)
461
+ or (legacy_sent_data_key and state.data.get(legacy_sent_data_key))
462
+ )
463
+
464
+ def resend_count(state: ServiceState) -> int:
465
+ persisted_count = state.internal.get(resend_count_internal_key, 0)
466
+ if (
467
+ not isinstance(persisted_count, int)
468
+ or isinstance(persisted_count, bool)
469
+ or persisted_count < 0
470
+ ):
471
+ raise ValueError(f"await_external {node_id!r} has an invalid persisted resend count")
472
+ return persisted_count
473
+
474
+ def provenance_for(
475
+ correlation_value: str | int | float | bool | None = None,
476
+ *,
477
+ deadline: datetime | None = None,
478
+ ) -> AwaitResumeProvenance | None:
479
+ if (
480
+ resume_contract is None
481
+ or resume_contract_digest is None
482
+ or correlation_reference is None
483
+ ):
484
+ return None
485
+ return AwaitResumeProvenance(
486
+ step=node_id,
487
+ version=cast(str, resume_contract["version"]),
488
+ digest=resume_contract_digest,
489
+ correlation_path=correlation_reference,
490
+ correlation_value=correlation_value,
491
+ deadline=deadline,
492
+ )
493
+
494
+ def validate_resume_token(token: Any) -> str | int | float | bool | None:
495
+ if (
496
+ resume_contract is None
497
+ or resume_schema_validator is None
498
+ or correlation_reference is None
499
+ ):
500
+ return None
501
+ validation_errors = sorted(
502
+ resume_schema_validator.iter_errors(token),
503
+ key=lambda error: (
504
+ tuple(str(segment) for segment in error.absolute_path),
505
+ tuple(str(segment) for segment in error.absolute_schema_path),
506
+ ),
507
+ )
508
+ if validation_errors:
509
+ validation_error = validation_errors[0]
510
+ instance_path = "".join(f"[{segment!r}]" for segment in validation_error.absolute_path)
511
+ raise ValueError(
512
+ f"resume token{instance_path} does not satisfy contract "
513
+ f"{resume_contract['version']!r}: {validation_error.message}"
514
+ )
515
+ correlation_value = _dig_binding(
516
+ token,
517
+ correlation_reference.removeprefix("$token."),
518
+ )
519
+ if (
520
+ correlation_value is _MISSING
521
+ or correlation_value is None
522
+ or not isinstance(
523
+ correlation_value,
524
+ (str, int, float, bool),
525
+ )
526
+ ):
527
+ raise ValueError(
528
+ f"resume token correlation {correlation_reference!r} must resolve to a "
529
+ "non-null JSON scalar"
530
+ )
531
+ return correlation_value
532
+
533
+ def canonical_correlation(correlation_value: str | int | float | bool) -> str:
534
+ return json.dumps(
535
+ correlation_value,
536
+ allow_nan=False,
537
+ ensure_ascii=False,
538
+ separators=(",", ":"),
539
+ sort_keys=True,
540
+ )
541
+
542
+ async def node(state: ServiceState) -> ServiceState:
543
+ try:
544
+ state.internal.pop(route_internal_key, None)
545
+ payload = state.payload or {}
546
+ has_resume_token = resume_on in payload
547
+ has_external_event = "_external_event" in payload
548
+ if has_resume_token and has_external_event:
549
+ raise ValueError(
550
+ f"await_external {node_id!r} received both {resume_on!r} and _external_event"
551
+ )
552
+
553
+ if state.internal.get(completed_internal_key):
554
+ if has_resume_token and resume_contract is not None:
555
+ delivered_correlation = validate_resume_token(payload[resume_on])
556
+ assert delivered_correlation is not None
557
+ accepted_provenance = state.metadata.await_resume
558
+ same_correlation = (
559
+ accepted_provenance is not None
560
+ and accepted_provenance.step == node_id
561
+ and accepted_provenance.digest == resume_contract_digest
562
+ and accepted_provenance.correlation_value is not None
563
+ and canonical_correlation(accepted_provenance.correlation_value)
564
+ == canonical_correlation(delivered_correlation)
565
+ )
566
+ replay_policy = cast(
567
+ str,
568
+ resume_contract["duplicate" if same_correlation else "late"],
569
+ )
570
+ if replay_policy == "reject":
571
+ delivery_kind = "duplicate" if same_correlation else "late"
572
+ raise ValueError(
573
+ f"await_external {node_id!r} rejected {delivery_kind} resume delivery"
574
+ )
575
+ state.payload.pop(resume_on, None)
576
+ elif has_external_event:
577
+ raise ValueError(f"await_external {node_id!r} is already completed")
578
+ state.status = "progress"
579
+ state.agent_response = None
580
+ return state
581
+
582
+ if has_external_event:
583
+ external_event = payload.get("_external_event")
584
+ if not isinstance(external_event, str) or external_event not in transitions:
585
+ raise ValueError(
586
+ f"unsupported _external_event for {node_id!r}: {external_event!r}"
587
+ )
588
+ transition = transitions.get(external_event)
589
+ if transition is None:
590
+ raise ValueError(
591
+ f"_external_event {external_event!r} is not configured for {node_id!r}"
592
+ )
593
+ if external_event == "timeout":
594
+ persisted_deadline = (
595
+ state.metadata.await_resume.deadline
596
+ if state.metadata.await_resume is not None
597
+ else None
598
+ )
599
+ if persisted_deadline is None:
600
+ raise ValueError(
601
+ f"await_external {node_id!r} timeout has no persisted deadline"
602
+ )
603
+ if utc_timestamp(ctx.clock) < persisted_deadline:
604
+ raise ValueError(
605
+ f"await_external {node_id!r} rejected timeout before its deadline"
606
+ )
607
+ accepted_resend_count: int | None = None
608
+ if external_event == "resend" and max_resends is not None:
609
+ accepted_resend_count = resend_count(state)
610
+ if accepted_resend_count >= max_resends:
611
+ raise ValueError(f"await_external {node_id!r} exhausted its resend budget")
612
+ validated_transition_writes = _validate_state_writes(
613
+ ctx,
614
+ copy.deepcopy(transition.get("set") or {}),
615
+ )
616
+ state.payload.pop("_external_event", None)
617
+ clear_sent(state)
618
+ if external_event == "resend":
619
+ state.internal.pop(completed_internal_key, None)
620
+ if accepted_resend_count is not None:
621
+ state.internal[resend_count_internal_key] = accepted_resend_count + 1
622
+ else:
623
+ state.internal[completed_internal_key] = True
624
+ state.internal.pop(resend_count_internal_key, None)
625
+ _commit_state_writes(state, validated_transition_writes)
626
+ transition_target = transition["goto"]
627
+ state.internal[route_internal_key] = transition_target
628
+ if transition_target == "END":
629
+ mark_flow_finished(state, reset_next=True)
630
+ state.status = "completed"
631
+ state.agent_response = AgentResponse(
632
+ description={
633
+ "abort": "The external action was canceled.",
634
+ "timeout": "The external action deadline expired.",
635
+ }.get(external_event, "The external action ended.")
636
+ )
637
+ else:
638
+ state.status = "progress"
639
+ state.agent_response = None
640
+ return state
641
+
642
+ if has_resume_token:
643
+ token = payload[resume_on]
644
+ correlation_value = validate_resume_token(token)
645
+ token_writes = _resolve_bindings(token_bindings, "token", token)
646
+ validated_writes = _validate_state_writes(ctx, token_writes)
647
+ if enrichment:
648
+ tool_name = enrichment["tool"]
649
+ try:
650
+ tool_inputs = _resolve_bindings(
651
+ enrichment.get("input") or {}, "token", token
652
+ )
653
+ enrichment_result = await ctx.tools.call(tool_name, **tool_inputs)
654
+ if not isinstance(enrichment_result, dict):
655
+ raise TypeError(f"tool {tool_name!r} returned a non-object result")
656
+ enrichment_writes = _resolve_bindings(
657
+ enrichment.get("set") or {}, "result", enrichment_result
658
+ )
659
+ validated_writes = _validate_state_writes(
660
+ ctx,
661
+ {**token_writes, **enrichment_writes},
662
+ )
663
+ except Exception:
664
+ if not enrichment.get("optional", False):
665
+ raise
666
+ log_event(
667
+ logger,
668
+ logging.WARNING,
669
+ "Optional await_external enrichment failed",
670
+ operation=node_id,
671
+ log_id_generator=ctx.log_id_generator,
672
+ context={"tool": tool_name},
673
+ exc_info=True,
674
+ )
675
+ _commit_state_writes(state, validated_writes)
676
+ clear_sent(state)
677
+ state.internal[completed_internal_key] = True
678
+ state.internal.pop(resend_count_internal_key, None)
679
+ prior_provenance = state.metadata.await_resume
680
+ if (
681
+ correlation_value is not None
682
+ and (
683
+ accepted_provenance := provenance_for(
684
+ correlation_value,
685
+ deadline=(
686
+ prior_provenance.deadline if prior_provenance is not None else None
687
+ ),
688
+ )
689
+ )
690
+ is not None
691
+ ):
692
+ state.metadata.await_resume = accepted_provenance
693
+ state.status = "progress"
694
+ state.agent_response = None
695
+ return state
696
+
697
+ if was_sent(state):
698
+ state.status = "progress"
699
+ state.agent_response = AgentResponse(
700
+ description=waiting_description or description,
701
+ )
702
+ return state
703
+
704
+ mark_sent(state)
705
+ state.status = "progress"
706
+ host_marker = copy.deepcopy(interactive)
707
+ host_marker.setdefault("kind", capability["kind"])
708
+ host_marker.setdefault("field", resume_on)
709
+ host_marker.setdefault("out_of_band", True)
710
+ host_marker.setdefault("next_step", node_id)
711
+ host_marker["out_of_band_sent"] = True
712
+ if max_resends is not None:
713
+ host_marker["recovery"] = {
714
+ "event_field": "_external_event",
715
+ "events": [
716
+ event_name
717
+ for event_name, transition in transitions.items()
718
+ if transition is not None
719
+ ],
720
+ "remaining_resends": max(0, max_resends - resend_count(state)),
721
+ }
722
+ if resume_contract is not None and resume_contract_digest is not None:
723
+ deadline = (
724
+ utc_timestamp(ctx.clock) + timedelta(seconds=timeout_seconds)
725
+ if timeout_seconds is not None
726
+ else None
727
+ )
728
+ host_marker["resume_contract"] = {
729
+ "version": resume_contract["version"],
730
+ "digest": resume_contract_digest,
731
+ "correlation": resume_contract["correlation"],
732
+ "duplicate": resume_contract["duplicate"],
733
+ "late": resume_contract["late"],
734
+ }
735
+ if deadline is not None:
736
+ host_marker["resume_contract"]["deadline"] = deadline.isoformat()
737
+ state.metadata.await_resume = provenance_for(deadline=deadline)
738
+ state.agent_response = AgentResponse(
739
+ description=description,
740
+ interactive=host_marker,
741
+ )
742
+ return state
743
+ except Exception as exc: # noqa: BLE001
744
+ error_state = _handle_node_error(state, exc, ctx=ctx, operation=node_id)
745
+ if error_state.agent_response:
746
+ # A failed resume is still waiting on the same external action,
747
+ # but the host must not interpret the retained prior response as
748
+ # a fresh out-of-band send.
749
+ error_state.agent_response.interactive = None
750
+ return error_state
751
+
752
+ def router(state: ServiceState) -> str:
753
+ if transition_target := state.internal.pop(route_internal_key, None):
754
+ if not isinstance(transition_target, str):
755
+ raise TypeError(f"await_external {node_id!r} produced a non-string route target")
756
+ return END if transition_target == "END" else transition_target
757
+ if state.agent_response is not None:
758
+ return END
759
+ return default_router(state) if default_router else NEXT
760
+
761
+ return NodeDesc(id=node_id, fn=node, router=router, targets=targets)
762
+
763
+
764
+ # ── init / entry node ────────────────────────────────────────────────────────
765
+
766
+
767
+ def make_init_node(
768
+ ctx: FlowContext,
769
+ entry: Optional[dict[str, Any]],
770
+ service_seed: dict[str, Any],
771
+ *,
772
+ auto_flow_resume_targets: frozenset[str] = frozenset(),
773
+ ) -> NodeDesc:
774
+ async def node(state: ServiceState) -> ServiceState:
775
+ asked_slot = state.internal.pop(_ASKED_SLOT_KEY, None)
776
+ if asked_slot is None:
777
+ state.internal.pop(_ANSWERING_SLOT_KEY, None)
778
+ else:
779
+ state.internal[_ANSWERING_SLOT_KEY] = asked_slot
780
+ if service_seed:
781
+ state.data["service"] = copy.deepcopy(service_seed)
782
+ if entry and not state.internal.get("_entry_done"):
783
+ try:
784
+ result = await ctx.tools.call(entry["tool"])
785
+ if entry.get("writes"):
786
+ state.data[entry["writes"]] = result
787
+ except Exception as exc:
788
+ if entry.get("blocking"):
789
+ return _handle_node_error(
790
+ state,
791
+ exc,
792
+ ctx=ctx,
793
+ operation="__init__",
794
+ )
795
+ log_event(
796
+ logger,
797
+ logging.WARNING,
798
+ "Non-blocking entry tool failed",
799
+ operation="__init__",
800
+ log_id_generator=ctx.log_id_generator,
801
+ context={"tool": entry["tool"]},
802
+ exc_info=True,
803
+ )
804
+ state.internal["_entry_done"] = True
805
+ state.agent_response = None
806
+ return state
807
+
808
+ def router(state: ServiceState) -> str:
809
+ if state.agent_response is not None:
810
+ return END
811
+ resume_target = state.internal.pop(AUTO_FLOW_RESUME_TARGET_INTERNAL_KEY, None)
812
+ if resume_target is None:
813
+ return NEXT
814
+ if resume_target not in auto_flow_resume_targets:
815
+ raise ValueError(f"unexpected auto-flow resume target: {resume_target!r}")
816
+ return cast(str, resume_target)
817
+
818
+ return NodeDesc(
819
+ id="__init__",
820
+ fn=node,
821
+ router=router,
822
+ targets=sorted(auto_flow_resume_targets),
823
+ )
824
+
825
+
826
+ # ── collect (slot) node ──────────────────────────────────────────────────────
827
+
828
+
829
+ def _collection_payload_schema(
830
+ model: type[BaseModel], slot: str, *, required: bool
831
+ ) -> dict[str, Any]:
832
+ """Expose explicit null as the deterministic skip token for an optional slot."""
833
+
834
+ payload_schema = copy.deepcopy(model.model_json_schema())
835
+ if required:
836
+ return payload_schema
837
+
838
+ slot_schema = cast(dict[str, Any], payload_schema["properties"][slot])
839
+ slot_type = slot_schema.get("type")
840
+ any_of = slot_schema.get("anyOf")
841
+ allows_null = (
842
+ slot_type == "null"
843
+ or (isinstance(slot_type, list) and "null" in slot_type)
844
+ or (isinstance(any_of, list) and any(option.get("type") == "null" for option in any_of))
845
+ or slot_schema.get("const", _MISSING) is None
846
+ or None in slot_schema.get("enum", [])
847
+ )
848
+ if not allows_null:
849
+ if "const" in slot_schema:
850
+ constant = slot_schema.pop("const")
851
+ slot_schema["enum"] = [constant, None]
852
+ elif "enum" in slot_schema:
853
+ slot_schema["enum"] = [*slot_schema["enum"], None]
854
+ elif isinstance(slot_type, str):
855
+ slot_schema["type"] = [slot_type, "null"]
856
+ elif isinstance(slot_type, list):
857
+ slot_schema["type"] = [*slot_type, "null"]
858
+ elif isinstance(any_of, list):
859
+ any_of.append({"type": "null"})
860
+ else:
861
+ payload_schema["properties"][slot] = {
862
+ "anyOf": [slot_schema, {"type": "null"}],
863
+ }
864
+ slot_schema = payload_schema["properties"][slot]
865
+
866
+ updated_slot_type = slot_schema.get("type")
867
+ if isinstance(updated_slot_type, str):
868
+ slot_schema["type"] = [updated_slot_type, "null"]
869
+ elif isinstance(updated_slot_type, list) and "null" not in updated_slot_type:
870
+ slot_schema["type"] = [*updated_slot_type, "null"]
871
+
872
+ description = str(slot_schema.get("description", "")).strip()
873
+ skip_description = "Use null when the citizen chooses not to provide this optional value."
874
+ slot_schema["description"] = f"{description} {skip_description}".strip()
875
+ return payload_schema
876
+
877
+
878
+ def make_collect_node(
879
+ ctx: FlowContext, step: dict[str, Any], gate: Optional[dict[str, Any]]
880
+ ) -> NodeDesc:
881
+ node_id = step["id"]
882
+ slot = step["slot"]
883
+ slot_cfg = ctx.slots[slot]
884
+ model = ctx.model_for(slot)
885
+ prompt = (step.get("prompt") or {}).get("text", f"Provide {slot}.")
886
+ extract_hint = (step.get("prompt") or {}).get("extract_hint")
887
+ if extract_hint: # bake the hint into the schema description
888
+ ctx.slot_models[slot] = make_slot_model(
889
+ slot,
890
+ slot_cfg["domain"],
891
+ ctx.domains,
892
+ nullable=slot_cfg.get("nullable", False),
893
+ extract_hint=extract_hint,
894
+ )
895
+ model = ctx.slot_models[slot]
896
+ interactive = step.get("interactive")
897
+ payload_field = (interactive or {}).get("field", slot)
898
+ skip_when = step.get("skip_when")
899
+ required = bool(slot_cfg.get("required", False))
900
+ max_attempts = int(slot_cfg.get("max_attempts", ctx.max_attempts))
901
+ on_exhaust = slot_cfg.get("on_exhaust", "reask")
902
+ validated_default: Any = _MISSING
903
+ if on_exhaust == "default":
904
+ if "default" not in slot_cfg:
905
+ raise ValueError(f"slots.{slot}.default is required when on_exhaust is 'default'")
906
+ try:
907
+ default_model = model.model_validate({slot: slot_cfg["default"]})
908
+ except Exception as exc:
909
+ raise ValueError(f"slots.{slot}.default is invalid for its domain") from exc
910
+ validated_default = getattr(default_model, slot)
911
+ payload_schema = _collection_payload_schema(model, slot, required=required)
912
+ skipped_slot_key = _skipped_slot_key(slot)
913
+ fill_only_when_asked = bool(slot_cfg.get("fill_only_when_asked", False))
914
+ prefill_sources = frozenset(slot_cfg.get("prefill_sources", []) or [])
915
+ required_slots = tuple(slot_cfg.get("requires", []) or [])
916
+
917
+ def ask(state: ServiceState, error: Optional[str] = None) -> AgentResponse:
918
+ state.internal[_ASKED_SLOT_KEY] = slot
919
+ spec = (
920
+ options_from_domain(
921
+ {**interactive, "body": interactive.get("body", prompt)},
922
+ ctx.domains,
923
+ state=state,
924
+ config=ctx.config,
925
+ )
926
+ if interactive
927
+ else None
928
+ )
929
+ return AgentResponse(
930
+ description=prompt,
931
+ payload_schema=payload_schema,
932
+ error_message=error,
933
+ interactive=spec,
934
+ )
935
+
936
+ def exhaust(state: ServiceState) -> ServiceState:
937
+ if on_exhaust == "skip":
938
+ _clear_slot(state, slot, clear_payload=True)
939
+ _clear_payload_slot_input(state, slot, payload_field)
940
+ state.internal[skipped_slot_key] = True
941
+ reset_attempts(state, slot)
942
+ state.agent_response = None
943
+ elif on_exhaust == "default":
944
+ _store_slot(state, slot, validated_default, ctx)
945
+ state.internal.pop(skipped_slot_key, None)
946
+ reset_attempts(state, slot)
947
+ state.agent_response = None
948
+ elif on_exhaust == "handoff":
949
+ state.agent_response = AgentResponse(
950
+ description="I will transfer you to a support agent."
951
+ )
952
+ elif on_exhaust == "END":
953
+ mark_flow_finished(state, reset_next=True)
954
+ state.status = "completed"
955
+ log_id = log_event(
956
+ logger,
957
+ logging.WARNING,
958
+ "Flow stopped after collection attempts were exhausted",
959
+ operation=node_id,
960
+ log_id_generator=ctx.log_id_generator,
961
+ context={"flow": state.service_name, "slot": slot},
962
+ )
963
+ state.agent_response = AgentResponse(
964
+ description="I could not continue. Try again later.",
965
+ log_id=log_id,
966
+ )
967
+ else: # reask
968
+ state.agent_response = ask(state, error="maximum attempts reached; let us try again")
969
+ reset_attempts(state, slot)
970
+ return state
971
+
972
+ async def node(state: ServiceState) -> ServiceState:
973
+ try:
974
+ correction_requested = state.internal.get(CORRECTION_REQUESTED_INTERNAL_KEY) == slot
975
+ if correction_requested:
976
+ clear_cascade(state, slot, ctx)
977
+ state.internal.pop(CORRECTION_REQUESTED_INTERNAL_KEY, None)
978
+ skip_is_active = bool(skip_when and evaluate(skip_when, state, ctx.config))
979
+ gate_is_closed = bool(gate is not None and not evaluate(gate, state, ctx.config))
980
+ requirements_are_missing = any(
981
+ not _slot_is_persisted(state, required_slot, ctx)
982
+ for required_slot in required_slots
983
+ )
984
+ if correction_requested and (
985
+ skip_is_active or gate_is_closed or requirements_are_missing
986
+ ):
987
+ state.internal[_CORRECTION_UNAVAILABLE_INTERNAL_KEY] = slot
988
+ if skip_is_active:
989
+ state.agent_response = None
990
+ return state
991
+ if gate_is_closed:
992
+ state.agent_response = None # gated out → satisfied by vacuity
993
+ return state
994
+ if requirements_are_missing:
995
+ state.internal[skipped_slot_key] = True
996
+ state.agent_response = None
997
+ return state
998
+ if _slot_is_persisted(state, slot, ctx) or state.internal.get(skipped_slot_key):
999
+ state.agent_response = None
1000
+ return state
1001
+ payload_has_slot, payload_content = _payload_slot_input(state, slot, payload_field)
1002
+ if payload_has_slot:
1003
+ payload_source = state.payload.get("_source")
1004
+ answers_asked_slot = state.internal.get(_ANSWERING_SLOT_KEY) == slot
1005
+ permitted_prefill = payload_source in prefill_sources
1006
+ if fill_only_when_asked and not (answers_asked_slot or permitted_prefill):
1007
+ state.agent_response = ask(state)
1008
+ return state
1009
+ if payload_content is None and not required:
1010
+ _clear_slot(state, slot, clear_payload=True)
1011
+ _clear_payload_slot_input(state, slot, payload_field)
1012
+ state.internal[skipped_slot_key] = True
1013
+ reset_attempts(state, slot)
1014
+ state.agent_response = None
1015
+ return state
1016
+ try:
1017
+ validated = model.model_validate({slot: payload_content})
1018
+ _store_slot(state, slot, getattr(validated, slot), ctx)
1019
+ state.internal.pop(skipped_slot_key, None)
1020
+ reset_attempts(state, slot)
1021
+ state.agent_response = None
1022
+ return state
1023
+ except Exception as exc:
1024
+ if inc_attempts(state, slot) >= max_attempts:
1025
+ return exhaust(state)
1026
+ state.agent_response = ask(state, error=str(exc))
1027
+ return state
1028
+ state.agent_response = ask(state)
1029
+ return state
1030
+ except Exception as exc: # noqa: BLE001
1031
+ return _handle_node_error(state, exc, ctx=ctx, operation=node_id)
1032
+
1033
+ return NodeDesc(
1034
+ id=node_id, fn=node, router=lambda s: END if s.agent_response is not None else NEXT
1035
+ )
1036
+
1037
+
1038
+ # ── derive node ──────────────────────────────────────────────────────────────
1039
+
1040
+
1041
+ def make_derive_node(
1042
+ ctx: FlowContext,
1043
+ derive: dict[str, Any],
1044
+ *,
1045
+ node_id: str | None = None,
1046
+ ) -> NodeDesc:
1047
+ writes = derive["writes"]
1048
+ from_slots = derive["from"]
1049
+ lookup = derive["lookup"]
1050
+ default = derive.get("default")
1051
+ effective_node_id = node_id or f"derive_{writes}"
1052
+
1053
+ async def node(state: ServiceState) -> ServiceState:
1054
+ try:
1055
+ if writes in ctx.slots and _slot_is_persisted(state, writes, ctx):
1056
+ state.agent_response = None
1057
+ return state
1058
+ if writes not in ctx.slots and writes in state.data:
1059
+ state.agent_response = None
1060
+ return state
1061
+ key = encode_derive_key(_slot_content(state, source, ctx) for source in from_slots)
1062
+ value = lookup.get(key)
1063
+ if value is None and default is not None:
1064
+ if isinstance(default, str) and default.startswith("$from["):
1065
+ idx = int(default[len("$from[") : -1])
1066
+ value = _slot_content(state, from_slots[idx], ctx)
1067
+ else:
1068
+ value = default
1069
+ if value is not None:
1070
+ if writes in ctx.slots:
1071
+ model = ctx.model_for(writes)
1072
+ validated = model.model_validate({writes: value})
1073
+ _store_slot(state, writes, getattr(validated, writes), ctx)
1074
+ else:
1075
+ state.data[writes] = value
1076
+ state.agent_response = None
1077
+ return state
1078
+ except Exception as exc: # noqa: BLE001
1079
+ return _handle_node_error(state, exc, ctx=ctx, operation=effective_node_id)
1080
+
1081
+ return NodeDesc(id=effective_node_id, fn=node, router=lambda s: NEXT)
1082
+
1083
+
1084
+ # ── confirm nodes (summary / bool / hub) ─────────────────────────────────────
1085
+
1086
+
1087
+ @dataclass(frozen=True)
1088
+ class _ConfirmationPolicy:
1089
+ max_attempts: int
1090
+ on_exhaust: str
1091
+ default: Any = _MISSING
1092
+
1093
+
1094
+ def _confirmation_policy(
1095
+ ctx: FlowContext,
1096
+ slot: str,
1097
+ model: type[BaseModel],
1098
+ ) -> _ConfirmationPolicy:
1099
+ slot_definition = cast(dict[str, Any], ctx.slots[slot])
1100
+ on_exhaust = cast(str, slot_definition.get("on_exhaust", "reask"))
1101
+ validated_default: Any = _MISSING
1102
+ if on_exhaust == "default":
1103
+ if "default" not in slot_definition:
1104
+ raise ValueError(f"slots.{slot}.default is required when on_exhaust is 'default'")
1105
+ try:
1106
+ validated_model = model.model_validate({slot: slot_definition["default"]})
1107
+ except Exception as exc:
1108
+ raise ValueError(f"slots.{slot}.default is invalid for its domain") from exc
1109
+ validated_default = cast(bool, getattr(validated_model, slot))
1110
+ return _ConfirmationPolicy(
1111
+ max_attempts=int(slot_definition.get("max_attempts", ctx.max_attempts)),
1112
+ on_exhaust=on_exhaust,
1113
+ default=validated_default,
1114
+ )
1115
+
1116
+
1117
+ def _confirmation_failure_action(
1118
+ state: ServiceState,
1119
+ *,
1120
+ slot: str,
1121
+ node_id: str,
1122
+ policy: _ConfirmationPolicy,
1123
+ ctx: FlowContext,
1124
+ ) -> tuple[str, bool | None]:
1125
+ if inc_attempts(state, slot) < policy.max_attempts:
1126
+ return "reask", None
1127
+ reset_attempts(state, slot)
1128
+ if policy.on_exhaust == "reask":
1129
+ return "reask", None
1130
+ if policy.on_exhaust == "skip":
1131
+ _clear_slot(state, slot, clear_payload=True)
1132
+ state.internal[_skipped_slot_key(slot)] = True
1133
+ return "skip", None
1134
+ if policy.on_exhaust == "default":
1135
+ return "default", cast(bool, policy.default)
1136
+ if policy.on_exhaust == "handoff":
1137
+ state.agent_response = AgentResponse(description="I will transfer you to a support agent.")
1138
+ return "pause", None
1139
+ if policy.on_exhaust == "END":
1140
+ mark_flow_finished(state, reset_next=True)
1141
+ state.status = "completed"
1142
+ log_id = log_event(
1143
+ logger,
1144
+ logging.WARNING,
1145
+ "Flow stopped after confirmation attempts were exhausted",
1146
+ operation=node_id,
1147
+ log_id_generator=ctx.log_id_generator,
1148
+ context={"flow": state.service_name, "slot": slot},
1149
+ )
1150
+ state.agent_response = AgentResponse(
1151
+ description="I could not continue. Try again later.",
1152
+ log_id=log_id,
1153
+ )
1154
+ return "pause", None
1155
+ raise ValueError(f"slots.{slot}.on_exhaust has unsupported value {policy.on_exhaust!r}")
1156
+
1157
+
1158
+ def make_summary_confirm_node(ctx: FlowContext, step: dict[str, Any]) -> NodeDesc:
1159
+ node_id = step["id"]
1160
+ slot = step["confirm"]
1161
+ model = ctx.model_for(slot)
1162
+ field = (step.get("interactive") or {}).get("field", slot)
1163
+ interactive = step.get("interactive")
1164
+ prompt = (step.get("prompt") or {}).get("text", "Do you confirm?")
1165
+ skip_when = step.get("skip_when")
1166
+ reject_msg = (step.get("on_reject") or {}).get("end", "Understood. I will not continue.")
1167
+ policy = _confirmation_policy(ctx, slot, model)
1168
+
1169
+ def ask(state: ServiceState, error: str | None = None) -> AgentResponse:
1170
+ interactive_specification = (
1171
+ options_from_domain(
1172
+ {**interactive, "body": interactive.get("body", prompt)},
1173
+ ctx.domains,
1174
+ state=state,
1175
+ config=ctx.config,
1176
+ )
1177
+ if interactive
1178
+ else None
1179
+ )
1180
+ return AgentResponse(
1181
+ description=prompt,
1182
+ payload_schema=model.model_json_schema(),
1183
+ error_message=error,
1184
+ interactive=interactive_specification,
1185
+ )
1186
+
1187
+ def apply_confirmation(state: ServiceState, confirmation_value: bool) -> ServiceState:
1188
+ _store_slot(state, slot, confirmation_value, ctx)
1189
+ reset_attempts(state, slot)
1190
+ if confirmation_value:
1191
+ state.agent_response = None
1192
+ return state
1193
+ mark_flow_finished(state, reset_next=True)
1194
+ state.status = "completed"
1195
+ state.agent_response = AgentResponse(description=reject_msg)
1196
+ return state
1197
+
1198
+ async def node(state: ServiceState) -> ServiceState:
1199
+ try:
1200
+ if state.internal.get(_skipped_slot_key(slot)):
1201
+ state.agent_response = None
1202
+ return state
1203
+ skipped_presentations = state.payload.get(
1204
+ AUTO_FLOW_SKIPPED_PRESENTATIONS_PAYLOAD_KEY, ()
1205
+ )
1206
+ if (isinstance(skipped_presentations, list) and node_id in skipped_presentations) or (
1207
+ skip_when and evaluate(skip_when, state, ctx.config)
1208
+ ):
1209
+ _store_slot(state, slot, True, ctx) # implicitly confirmed (e.g. Flow submission)
1210
+ state.agent_response = None
1211
+ return state
1212
+ if _slot_is_persisted(state, slot, ctx) and _slot_content(state, slot, ctx) is True:
1213
+ state.agent_response = None
1214
+ return state
1215
+ payload_has_slot, raw = _payload_slot_input(state, slot, field)
1216
+ if payload_has_slot and raw is not None:
1217
+ try:
1218
+ validated = model.model_validate({slot: raw})
1219
+ confirmation_value = cast(bool, getattr(validated, slot))
1220
+ except Exception as exc:
1221
+ action, default_value = _confirmation_failure_action(
1222
+ state,
1223
+ slot=slot,
1224
+ node_id=node_id,
1225
+ policy=policy,
1226
+ ctx=ctx,
1227
+ )
1228
+ if action == "reask":
1229
+ state.agent_response = ask(state, str(exc))
1230
+ elif action == "default":
1231
+ apply_confirmation(state, cast(bool, default_value))
1232
+ elif action == "skip":
1233
+ state.agent_response = None
1234
+ return state
1235
+ return apply_confirmation(state, confirmation_value)
1236
+ state.agent_response = ask(state)
1237
+ return state
1238
+ except Exception as exc: # noqa: BLE001
1239
+ return _handle_node_error(state, exc, ctx=ctx, operation=node_id)
1240
+
1241
+ return NodeDesc(
1242
+ id=node_id, fn=node, router=lambda s: END if s.agent_response is not None else NEXT
1243
+ )
1244
+
1245
+
1246
+ def make_bool_confirm_node(
1247
+ ctx: FlowContext, step: dict[str, Any], gate: Optional[dict[str, Any]]
1248
+ ) -> NodeDesc:
1249
+ node_id = step["id"]
1250
+ slot = step["confirm"]
1251
+ model = ctx.model_for(slot)
1252
+ field = (step.get("interactive") or {}).get("field", slot)
1253
+ interactive = step.get("interactive")
1254
+ prompt = (step.get("prompt") or {}).get("text", "Do you confirm?")
1255
+ policy = _confirmation_policy(ctx, slot, model)
1256
+
1257
+ def ask(state: ServiceState, error: Optional[str] = None) -> AgentResponse:
1258
+ spec = (
1259
+ options_from_domain(
1260
+ {**interactive, "body": interactive.get("body", prompt)},
1261
+ ctx.domains,
1262
+ state=state,
1263
+ config=ctx.config,
1264
+ )
1265
+ if interactive
1266
+ else None
1267
+ )
1268
+ return AgentResponse(
1269
+ description=prompt,
1270
+ payload_schema=model.model_json_schema(),
1271
+ error_message=error,
1272
+ interactive=spec,
1273
+ )
1274
+
1275
+ async def node(state: ServiceState) -> ServiceState:
1276
+ try:
1277
+ if state.internal.get(_skipped_slot_key(slot)):
1278
+ state.agent_response = None
1279
+ return state
1280
+ if gate is not None and not evaluate(gate, state, ctx.config):
1281
+ state.agent_response = None
1282
+ return state
1283
+ if _slot_is_persisted(state, slot, ctx):
1284
+ state.agent_response = None
1285
+ return state
1286
+ payload_has_slot, raw = _payload_slot_input(state, slot, field)
1287
+ if payload_has_slot and raw is not None:
1288
+ try:
1289
+ validated = model.model_validate({slot: raw})
1290
+ _store_slot(state, slot, getattr(validated, slot), ctx)
1291
+ reset_attempts(state, slot)
1292
+ state.agent_response = None
1293
+ return state
1294
+ except Exception as exc:
1295
+ action, default_value = _confirmation_failure_action(
1296
+ state,
1297
+ slot=slot,
1298
+ node_id=node_id,
1299
+ policy=policy,
1300
+ ctx=ctx,
1301
+ )
1302
+ if action == "default":
1303
+ _store_slot(state, slot, cast(bool, default_value), ctx)
1304
+ state.agent_response = None
1305
+ return state
1306
+ if action == "skip":
1307
+ state.agent_response = None
1308
+ return state
1309
+ if action == "reask":
1310
+ state.agent_response = ask(state, error=str(exc))
1311
+ return state
1312
+ state.agent_response = ask(state)
1313
+ return state
1314
+ except Exception as exc: # noqa: BLE001
1315
+ return _handle_node_error(state, exc, ctx=ctx, operation=node_id)
1316
+
1317
+ return NodeDesc(
1318
+ id=node_id, fn=node, router=lambda s: END if s.agent_response is not None else NEXT
1319
+ )
1320
+
1321
+
1322
+ def make_hub_confirm_node(ctx: FlowContext, confirm: dict[str, Any]) -> NodeDesc:
1323
+ node_id = confirm["step"]
1324
+ slot = confirm["slot"]
1325
+ model = ctx.model_for(slot)
1326
+ field = (confirm.get("interactive") or {}).get("field", "confirmation")
1327
+ interactive = confirm.get("interactive")
1328
+ prompt = (confirm.get("prompt") or {}).get("text", "Do you confirm the information?")
1329
+ correctable = confirm["correctable"]
1330
+ on_confirm = cast(str | None, confirm.get("on_confirm"))
1331
+ policy = _confirmation_policy(ctx, slot, model)
1332
+
1333
+ def ask(state: ServiceState, description: Optional[str] = None) -> AgentResponse:
1334
+ spec = (
1335
+ options_from_domain(
1336
+ {**interactive, "body": interactive.get("body", prompt)},
1337
+ ctx.domains,
1338
+ state=state,
1339
+ config=ctx.config,
1340
+ )
1341
+ if interactive
1342
+ else None
1343
+ )
1344
+ payload_schema = model.model_json_schema()
1345
+ payload_schema[CORRECTION_TARGETS_SCHEMA_KEY] = copy.deepcopy(correctable)
1346
+ return AgentResponse(
1347
+ description=description or prompt,
1348
+ payload_schema=payload_schema,
1349
+ interactive=spec,
1350
+ )
1351
+
1352
+ async def node(state: ServiceState) -> ServiceState:
1353
+ try:
1354
+ unavailable_correction = state.internal.pop(_CORRECTION_UNAVAILABLE_INTERNAL_KEY, None)
1355
+ if state.internal.get(_skipped_slot_key(slot)):
1356
+ state.agent_response = None
1357
+ return state
1358
+ if _slot_is_persisted(state, slot, ctx) and _slot_content(state, slot, ctx) is True:
1359
+ state.agent_response = None
1360
+ return state
1361
+ payload = state.payload or {}
1362
+ payload_has_slot, raw = _payload_slot_input(state, slot, field)
1363
+ correction_target = payload.get("correction")
1364
+ if correction_target is not None:
1365
+ if isinstance(correction_target, str) and correction_target in correctable:
1366
+ target = correction_target
1367
+ clear_cascade(state, target, ctx, clear_payload=True)
1368
+ state.internal[CORRECTION_REQUESTED_INTERNAL_KEY] = target
1369
+ _clear_slot(state, slot, clear_payload=True)
1370
+ _clear_payload_slot_input(state, slot, field)
1371
+ state.payload.pop("correction", None)
1372
+ state.agent_response = None
1373
+ return state
1374
+ state.agent_response = ask(
1375
+ state,
1376
+ description="What would you like to correct? (" + ", ".join(correctable) + ")",
1377
+ )
1378
+ return state
1379
+ if payload_has_slot and raw is not None:
1380
+ try:
1381
+ validated = model.model_validate({slot: raw})
1382
+ confirmation_value = cast(bool, getattr(validated, slot))
1383
+ except Exception as exc:
1384
+ action, default_value = _confirmation_failure_action(
1385
+ state,
1386
+ slot=slot,
1387
+ node_id=node_id,
1388
+ policy=policy,
1389
+ ctx=ctx,
1390
+ )
1391
+ if action == "reask":
1392
+ state.agent_response = ask(state)
1393
+ state.agent_response.error_message = str(exc)
1394
+ return state
1395
+ elif action == "default":
1396
+ confirmation_value = cast(bool, default_value)
1397
+ elif action == "skip":
1398
+ state.agent_response = None
1399
+ return state
1400
+ else:
1401
+ return state
1402
+ reset_attempts(state, slot)
1403
+ if confirmation_value:
1404
+ _store_slot(state, slot, True, ctx)
1405
+ state.agent_response = None
1406
+ return state
1407
+ _store_slot(state, slot, False, ctx)
1408
+ state.agent_response = ask(
1409
+ state,
1410
+ description="What would you like to correct? (" + ", ".join(correctable) + ")",
1411
+ )
1412
+ return state
1413
+ state.agent_response = ask(
1414
+ state,
1415
+ description=(
1416
+ f"The field {unavailable_correction!r} does not apply to the current answers. "
1417
+ "Confirm the information or choose another field to correct."
1418
+ if unavailable_correction is not None
1419
+ else None
1420
+ ),
1421
+ )
1422
+ return state
1423
+ except Exception as exc: # noqa: BLE001
1424
+ return _handle_node_error(state, exc, ctx=ctx, operation=node_id)
1425
+
1426
+ def router(state: ServiceState) -> str:
1427
+ if state.internal.get(_skipped_slot_key(slot)):
1428
+ return on_confirm or NEXT
1429
+ if _slot_content(state, slot, ctx) is True:
1430
+ return on_confirm or NEXT
1431
+ correction_target = state.internal.get(CORRECTION_REQUESTED_INTERNAL_KEY)
1432
+ if correction_target and correction_target in ctx.node_for_slot:
1433
+ return ctx.node_for_slot[correction_target]
1434
+ return END
1435
+
1436
+ targets = ([on_confirm] if on_confirm is not None else []) + [
1437
+ ctx.node_for_slot[correctable_slot]
1438
+ for correctable_slot in correctable
1439
+ if correctable_slot in ctx.node_for_slot
1440
+ ]
1441
+ return NodeDesc(id=node_id, fn=node, router=router, targets=targets)
1442
+
1443
+
1444
+ # ── terminal node ────────────────────────────────────────────────────────────
1445
+
1446
+
1447
+ def make_terminal_node(ctx: FlowContext, terminal: dict[str, Any]) -> NodeDesc:
1448
+ node_id = terminal["step"]
1449
+ tool = terminal["tool"]
1450
+ idempotent = terminal.get("idempotent", False)
1451
+ input_map = terminal.get("input", [])
1452
+ outputs = terminal.get("outputs", {})
1453
+ outcomes = terminal.get("outcomes", {})
1454
+ success = outcomes.get("success", {})
1455
+ retryable = outcomes.get("retryable", {})
1456
+ fatal = outcomes.get("fatal", {})
1457
+ tool_identifier = ctx.tools.definition(tool).identifier
1458
+ operation_namespace = ToolRegistry.operation_namespace(
1459
+ ctx.flow_name,
1460
+ ctx.flow_revision,
1461
+ node_id,
1462
+ tool_identifier,
1463
+ )
1464
+
1465
+ async def node(state: ServiceState) -> ServiceState:
1466
+ try:
1467
+ inputs = {p["param"]: _slot_content(state, p["slot"], ctx) for p in input_map}
1468
+ result: dict[str, Any]
1469
+ if idempotent:
1470
+ idempotency_key = ToolRegistry.idempotency_key(
1471
+ state.user_id,
1472
+ operation_namespace,
1473
+ inputs,
1474
+ )
1475
+ result = await ctx.tools.call_idempotent(
1476
+ tool,
1477
+ idempotency_key,
1478
+ **inputs,
1479
+ )
1480
+ else:
1481
+ result = await ctx.tools.call(tool, **inputs)
1482
+
1483
+ status = result.get("status", "success")
1484
+ if status == "success":
1485
+ pending_state_writes = {
1486
+ output_key: copy.deepcopy(_dig(result, result_path))
1487
+ for output_key, result_path in outputs.items()
1488
+ }
1489
+ pending_state_writes.update(copy.deepcopy(success.get("set") or {}))
1490
+ state.data.update(pending_state_writes)
1491
+ mark_flow_finished(state, reset_next=bool(success.get("reset_next", True)))
1492
+ state.status = "completed"
1493
+ protocol = state.data.get("protocol_id", "")
1494
+ state.agent_response = AgentResponse(
1495
+ description=result.get("message", f"✅ Done! Protocol: {protocol}")
1496
+ )
1497
+ elif status == "retryable":
1498
+ # Preserve by default so the next turn re-fires this node. When
1499
+ # disabled, use the same deferred-reset lifecycle as terminal
1500
+ # success/fatal outcomes so the current error remains observable.
1501
+ if not retryable.get("preserve_state", True):
1502
+ mark_flow_finished(state, reset_next=True)
1503
+ state.status = "error"
1504
+ log_id = log_event(
1505
+ logger,
1506
+ logging.WARNING,
1507
+ "Terminal tool returned a retryable failure",
1508
+ operation=node_id,
1509
+ log_id_generator=ctx.log_id_generator,
1510
+ context={"flow": state.service_name, "tool": tool},
1511
+ )
1512
+ state.agent_response = AgentResponse(
1513
+ description="The system is temporarily unavailable. Try again shortly.",
1514
+ error_message=result.get("error"),
1515
+ log_id=log_id,
1516
+ )
1517
+ else: # fatal
1518
+ mark_flow_finished(state, reset_next=bool(fatal.get("reset_next", True)))
1519
+ state.status = "error"
1520
+ log_id = log_event(
1521
+ logger,
1522
+ logging.ERROR,
1523
+ "Terminal tool returned a fatal failure",
1524
+ operation=node_id,
1525
+ log_id_generator=ctx.log_id_generator,
1526
+ context={"flow": state.service_name, "tool": tool},
1527
+ )
1528
+ state.agent_response = AgentResponse(
1529
+ description=result.get("message", "The request could not be completed now."),
1530
+ error_message=result.get("error"),
1531
+ log_id=log_id,
1532
+ )
1533
+ return state
1534
+ except Exception as exc: # noqa: BLE001
1535
+ return _handle_node_error(state, exc, ctx=ctx, operation=node_id)
1536
+
1537
+ return NodeDesc(id=node_id, fn=node, router=lambda s: END)