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/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)
|