readyagentsdev 0.8.2__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 (51) hide show
  1. readyagents/__init__.py +38 -0
  2. readyagents/__main__.py +4 -0
  3. readyagents/audit.py +67 -0
  4. readyagents/cli.py +1050 -0
  5. readyagents/config.py +264 -0
  6. readyagents/errors.py +129 -0
  7. readyagents/llm/__init__.py +11 -0
  8. readyagents/llm/anthropic_provider.py +72 -0
  9. readyagents/llm/base.py +57 -0
  10. readyagents/llm/cache.py +86 -0
  11. readyagents/llm/openai_compat.py +12 -0
  12. readyagents/llm/openai_provider.py +70 -0
  13. readyagents/llm/registry.py +112 -0
  14. readyagents/llm/resilience.py +179 -0
  15. readyagents/llm/tool_calls.py +286 -0
  16. readyagents/logging.py +162 -0
  17. readyagents/mcp/__init__.py +43 -0
  18. readyagents/mcp/builtin.py +674 -0
  19. readyagents/mcp/client.py +253 -0
  20. readyagents/mcp/http.py +585 -0
  21. readyagents/mcp/run_api.py +1077 -0
  22. readyagents/mcp/server.py +246 -0
  23. readyagents/notify.py +63 -0
  24. readyagents/packs/__init__.py +26 -0
  25. readyagents/packs/loader.py +157 -0
  26. readyagents/packs/protocol.py +55 -0
  27. readyagents/policy.py +127 -0
  28. readyagents/py.typed +1 -0
  29. readyagents/report.py +88 -0
  30. readyagents/scaffold.py +410 -0
  31. readyagents/secrets.py +120 -0
  32. readyagents/testing/__init__.py +17 -0
  33. readyagents/testing/eval.py +219 -0
  34. readyagents/testing/helpers.py +128 -0
  35. readyagents/testing/recorded.py +68 -0
  36. readyagents/tools/__init__.py +67 -0
  37. readyagents/workflow/__init__.py +3 -0
  38. readyagents/workflow/cancellation.py +88 -0
  39. readyagents/workflow/conditions.py +279 -0
  40. readyagents/workflow/engine.py +354 -0
  41. readyagents/workflow/nodes.py +944 -0
  42. readyagents/workflow/runner.py +375 -0
  43. readyagents/workflow/schema.py +287 -0
  44. readyagents/workflow/state.py +472 -0
  45. readyagents/workflow/structured.py +103 -0
  46. readyagents/workflow/templates.py +125 -0
  47. readyagentsdev-0.8.2.dist-info/METADATA +215 -0
  48. readyagentsdev-0.8.2.dist-info/RECORD +51 -0
  49. readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
  50. readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
  51. readyagentsdev-0.8.2.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,279 @@
1
+ """Boolean conditions over existing comparison atoms. No Python eval()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from readyagents.errors import TemplateError, WorkflowError
10
+ from readyagents.workflow.templates import interpolate, lookup
11
+
12
+ _COMPARE = re.compile(
13
+ r"^\s*(.+?)\s*(==|!=|>=|<=|>|<|contains|startswith|endswith)\s*(.+?)\s*$",
14
+ re.DOTALL,
15
+ )
16
+ _BOOL_OPS = re.compile(r"\b(and|or|not)\b", re.IGNORECASE)
17
+
18
+
19
+ def evaluate_condition(expr: str, mapping: Mapping[str, Any]) -> bool:
20
+ """Evaluate a small boolean of comparisons / truthy paths. No `eval()`."""
21
+ text = (expr or "").strip()
22
+ if not text:
23
+ return False
24
+ try:
25
+ return _eval_or(text, mapping)
26
+ except WorkflowError:
27
+ raise
28
+ except TemplateError:
29
+ raise
30
+
31
+
32
+ def _split_top(expr: str, op: str) -> list[str] | None:
33
+ parts: list[str] = []
34
+ buf: list[str] = []
35
+ depth = 0
36
+ quote: str | None = None
37
+ i = 0
38
+ token = f" {op} "
39
+ lower = expr
40
+ while i < len(lower):
41
+ ch = expr[i]
42
+ if quote:
43
+ buf.append(ch)
44
+ if ch == quote:
45
+ quote = None
46
+ i += 1
47
+ continue
48
+ if ch in {'"', "'"}:
49
+ quote = ch
50
+ buf.append(ch)
51
+ i += 1
52
+ continue
53
+ if ch == "(":
54
+ depth += 1
55
+ buf.append(ch)
56
+ i += 1
57
+ continue
58
+ if ch == ")":
59
+ depth -= 1
60
+ if depth < 0:
61
+ raise WorkflowError("Unbalanced ')' in condition")
62
+ buf.append(ch)
63
+ i += 1
64
+ continue
65
+ if depth == 0 and expr[i : i + len(token)].lower() == token:
66
+ piece = "".join(buf).strip()
67
+ if not piece:
68
+ raise WorkflowError(f"Empty operand for '{op}'")
69
+ parts.append(piece)
70
+ buf = []
71
+ i += len(token)
72
+ continue
73
+ buf.append(ch)
74
+ i += 1
75
+ if quote:
76
+ raise WorkflowError("Unterminated quote in condition")
77
+ if depth != 0:
78
+ raise WorkflowError("Unbalanced '(' in condition")
79
+ tail = "".join(buf).strip()
80
+ if parts:
81
+ if not tail:
82
+ raise WorkflowError(f"Empty operand for '{op}'")
83
+ parts.append(tail)
84
+ return parts
85
+ return None
86
+
87
+
88
+ def _eval_or(expr: str, mapping: Mapping[str, Any]) -> bool:
89
+ parts = _split_top(expr, "or")
90
+ if parts is None:
91
+ return _eval_and(expr, mapping)
92
+ return any(_eval_and(part, mapping) for part in parts)
93
+
94
+
95
+ def _eval_and(expr: str, mapping: Mapping[str, Any]) -> bool:
96
+ parts = _split_top(expr, "and")
97
+ if parts is None:
98
+ return _eval_not(expr, mapping)
99
+ return all(_eval_not(part, mapping) for part in parts)
100
+
101
+
102
+ def _eval_not(expr: str, mapping: Mapping[str, Any]) -> bool:
103
+ text = expr.strip()
104
+ if text.lower().startswith("not ") or text.lower() == "not":
105
+ rest = text[3:].strip()
106
+ if not rest:
107
+ raise WorkflowError("Empty operand for 'not'")
108
+ return not _eval_not(rest, mapping)
109
+ if text.startswith("(") and text.endswith(")"):
110
+ inner = text[1:-1].strip()
111
+ if _balanced_outer_parens(text):
112
+ return _eval_or(inner, mapping)
113
+ return _eval_atom(text, mapping)
114
+
115
+
116
+ def _balanced_outer_parens(text: str) -> bool:
117
+ if not (text.startswith("(") and text.endswith(")")):
118
+ return False
119
+ depth = 0
120
+ quote: str | None = None
121
+ for i, ch in enumerate(text):
122
+ if quote:
123
+ if ch == quote:
124
+ quote = None
125
+ continue
126
+ if ch in {'"', "'"}:
127
+ quote = ch
128
+ continue
129
+ if ch == "(":
130
+ depth += 1
131
+ elif ch == ")":
132
+ depth -= 1
133
+ if depth == 0 and i != len(text) - 1:
134
+ return False
135
+ return depth == 0
136
+
137
+
138
+ def _eval_atom(expr: str, mapping: Mapping[str, Any]) -> bool:
139
+ text = expr.strip()
140
+ if _BOOL_OPS.search(text) and not _quoted_only_ops(text):
141
+ raise WorkflowError(f"Could not parse condition '{expr}'")
142
+ match = _COMPARE.match(text)
143
+ if match:
144
+ left_raw, op, right_raw = match.group(1), match.group(2), match.group(3)
145
+ if not _is_single_operand(left_raw) or not _is_single_operand(right_raw):
146
+ raise WorkflowError(f"Could not parse condition '{expr}'")
147
+ if _BOOL_OPS.search(left_raw) or _BOOL_OPS.search(right_raw):
148
+ if not (_quoted_only_ops(left_raw) and _quoted_only_ops(right_raw)):
149
+ raise WorkflowError(f"Could not parse condition '{expr}'")
150
+ left = _atom(left_raw, mapping)
151
+ right = _atom(right_raw, mapping)
152
+ return _compare(left, op, right)
153
+ interpolated = interpolate(text, mapping) if "{{" in text else text
154
+ try:
155
+ value: Any = (
156
+ lookup(mapping, interpolated)
157
+ if interpolated.isidentifier() or "." in interpolated
158
+ else interpolated
159
+ )
160
+ except TemplateError:
161
+ value = interpolated
162
+ if isinstance(value, str):
163
+ lowered = value.strip().lower()
164
+ if lowered in {"true", "yes", "1"}:
165
+ return True
166
+ if lowered in {"false", "no", "0", ""}:
167
+ return False
168
+ return bool(value)
169
+
170
+
171
+ def _is_single_operand(raw: str) -> bool:
172
+ text = raw.strip()
173
+ if not text:
174
+ return False
175
+ if (text.startswith('"') and text.endswith('"')) or (
176
+ text.startswith("'") and text.endswith("'")
177
+ ):
178
+ return True
179
+ if "{{" in text:
180
+ return text.count("{{") == 1 and text.count("}}") == 1
181
+ if text.lower() in {"true", "false", "null", "none"}:
182
+ return True
183
+ if text.replace("_", "").replace(".", "").replace("-", "").isalnum():
184
+ return True
185
+ try:
186
+ float(text)
187
+ return True
188
+ except ValueError:
189
+ return False
190
+
191
+
192
+ def _quoted_only_ops(text: str) -> bool:
193
+ """True if any and/or/not tokens sit only inside quotes."""
194
+ quote: str | None = None
195
+ i = 0
196
+ lower = text.lower()
197
+ while i < len(text):
198
+ ch = text[i]
199
+ if quote:
200
+ if ch == quote:
201
+ quote = None
202
+ i += 1
203
+ continue
204
+ if ch in {'"', "'"}:
205
+ quote = ch
206
+ i += 1
207
+ continue
208
+ for word in ("and", "or", "not"):
209
+ n = len(word)
210
+ if lower[i : i + n] == word:
211
+ before = text[i - 1] if i else " "
212
+ after = text[i + n] if i + n < len(text) else " "
213
+ if not before.isalnum() and before != "_" and not after.isalnum() and after != "_":
214
+ return False
215
+ i += 1
216
+ return True
217
+
218
+
219
+ def _atom(raw: str, mapping: Mapping[str, Any]) -> Any:
220
+ text = raw.strip()
221
+ quoted = (text.startswith('"') and text.endswith('"')) or (
222
+ text.startswith("'") and text.endswith("'")
223
+ )
224
+ if quoted:
225
+ return interpolate(text[1:-1], mapping)
226
+ if "{{" in text:
227
+ return interpolate(text, mapping)
228
+ lowered = text.lower()
229
+ if lowered == "true":
230
+ return True
231
+ if lowered == "false":
232
+ return False
233
+ if lowered in {"null", "none"}:
234
+ return None
235
+ try:
236
+ if text.isdigit() or (text.startswith("-") and text[1:].isdigit()):
237
+ return int(text)
238
+ return float(text)
239
+ except ValueError:
240
+ pass
241
+ try:
242
+ return lookup(mapping, text)
243
+ except TemplateError:
244
+ return interpolate(text, mapping) if "{{" in text else text
245
+
246
+
247
+ def _compare(left: Any, op: str, right: Any) -> bool:
248
+ if op == "==":
249
+ return _norm(left) == _norm(right)
250
+ if op == "!=":
251
+ return _norm(left) != _norm(right)
252
+ if op in {">", "<", ">=", "<="}:
253
+ try:
254
+ lf, rf = float(left), float(right)
255
+ except (TypeError, ValueError):
256
+ lf, rf = str(left), str(right)
257
+ if op == ">":
258
+ return lf > rf
259
+ if op == "<":
260
+ return lf < rf
261
+ if op == ">=":
262
+ return lf >= rf
263
+ return lf <= rf
264
+ left_s, right_s = str(left), str(right)
265
+ if op == "contains":
266
+ return right_s in left_s
267
+ if op == "startswith":
268
+ return left_s.startswith(right_s)
269
+ if op == "endswith":
270
+ return left_s.endswith(right_s)
271
+ return False
272
+
273
+
274
+ def _norm(value: Any) -> Any:
275
+ if isinstance(value, bool):
276
+ return value
277
+ if isinstance(value, str):
278
+ return value.strip()
279
+ return value
@@ -0,0 +1,354 @@
1
+ """Execute a workflow graph with retries, timeouts, and branching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from readyagents.errors import (
9
+ ApprovalRequired,
10
+ AuthorizationError,
11
+ BudgetExceeded,
12
+ CancellationRequested,
13
+ CircuitOpen,
14
+ ReadyAgentsError,
15
+ WorkflowError,
16
+ )
17
+ from readyagents.logging import get_logger, log_event
18
+ from readyagents.workflow.nodes import (
19
+ ExecutionContext,
20
+ evaluate_condition,
21
+ execute_node_with_policy,
22
+ )
23
+ from readyagents.workflow.schema import NodeSpec, NodeType, WorkflowSpec
24
+ from readyagents.workflow.state import RunState, utc_now
25
+
26
+ log = get_logger("engine")
27
+
28
+ _MAX_STEPS = 500
29
+ _TERMINAL_STATUSES = frozenset({"cancelled", "succeeded", "failed", "paused"})
30
+
31
+
32
+ def run_workflow(
33
+ workflow: WorkflowSpec,
34
+ inputs: Mapping[str, Any],
35
+ ctx: ExecutionContext,
36
+ *,
37
+ metadata: Mapping[str, Any] | None = None,
38
+ state: RunState | None = None,
39
+ run_id: str | None = None,
40
+ ) -> RunState:
41
+ nodes = workflow.node_map()
42
+ if state is None:
43
+ state = RunState.start(workflow.name, inputs, metadata=metadata, run_id=run_id)
44
+ current = workflow.start or workflow.nodes[0].id
45
+ seen: set[str] = set()
46
+ elif _is_fresh_start(state):
47
+ current = workflow.start or workflow.nodes[0].id
48
+ seen = set()
49
+ state.status = "running"
50
+ state.finished_at = None
51
+ if inputs:
52
+ state.inputs.update(dict(inputs))
53
+ if metadata:
54
+ state.metadata.update(dict(metadata))
55
+ else:
56
+ current, seen = _resume_cursor(workflow, state)
57
+ if inputs:
58
+ state.inputs.update(dict(inputs))
59
+ if metadata:
60
+ state.metadata.update(dict(metadata))
61
+
62
+ if ctx.usage_state is None:
63
+ ctx.usage_state = state
64
+ _arm_cancellation_persist(ctx, state)
65
+ _persist(ctx, state)
66
+ log_event(
67
+ log,
68
+ "run_start",
69
+ "run %s status=%s",
70
+ state.run_id,
71
+ state.status,
72
+ run_id=state.run_id,
73
+ node_id="-",
74
+ status=state.status,
75
+ )
76
+ if ctx.auditor is not None:
77
+ ctx.auditor("run_started", run_id=state.run_id, workflow=workflow.name, actor=ctx.actor)
78
+
79
+ steps = 0
80
+ try:
81
+ _raise_if_cancelled(ctx, state)
82
+ while current:
83
+ steps += 1
84
+ if steps > _MAX_STEPS:
85
+ raise WorkflowError(f"Workflow exceeded {_MAX_STEPS} steps (possible cycle)")
86
+ if current in seen:
87
+ raise WorkflowError(f"Cycle detected at node '{current}'")
88
+ if current not in nodes:
89
+ raise WorkflowError(f"Unknown node '{current}'")
90
+ node = nodes[current]
91
+ seen.add(current)
92
+ log_event(
93
+ log,
94
+ "node_start",
95
+ "node %s (%s)",
96
+ node.id,
97
+ node.type,
98
+ run_id=state.run_id,
99
+ node_id=node.id,
100
+ )
101
+ _raise_if_cancelled(ctx, state)
102
+ try:
103
+ _execute_with_policy(node, state, ctx)
104
+ except CancellationRequested:
105
+ raise
106
+ except ApprovalRequired:
107
+ raise
108
+ except ReadyAgentsError:
109
+ _raise_if_cancelled(ctx, state)
110
+ raise
111
+ _raise_if_cancelled(ctx, state)
112
+ state.pending_node = None
113
+ _persist(ctx, state)
114
+ if ctx.auditor is not None:
115
+ ctx.auditor(
116
+ "node_ok",
117
+ run_id=state.run_id,
118
+ node_id=node.id,
119
+ node_type=str(node.type),
120
+ actor=ctx.actor,
121
+ )
122
+ current = _next_node(workflow, node, state)
123
+ _raise_if_cancelled(ctx, state)
124
+ state.pending_node = None
125
+ state.pending = None
126
+ state.finish("succeeded")
127
+ _persist(ctx, state)
128
+ if ctx.auditor is not None:
129
+ ctx.auditor("run_finished", run_id=state.run_id, status="succeeded", actor=ctx.actor)
130
+ except KeyboardInterrupt:
131
+ state.pending_node = current
132
+ state.pending = {
133
+ "node_id": current,
134
+ "type": str(nodes[current].type) if current in nodes else "?",
135
+ "error": "cancelled",
136
+ }
137
+ state.finish("cancelled")
138
+ _persist(ctx, state)
139
+ raise
140
+ except CancellationRequested as exc:
141
+ _finalize_cancelled(ctx, state, current, nodes)
142
+ exc.state = state
143
+ if not exc.run_id:
144
+ exc.run_id = state.run_id
145
+ raise
146
+ except ApprovalRequired as exc:
147
+ state.pending_node = current
148
+ paused = nodes.get(current) if current else None
149
+ state.pending = {
150
+ "node_id": exc.node_id,
151
+ "type": "approval",
152
+ "prompt": exc.prompt,
153
+ "then": getattr(paused, "then", None),
154
+ "else": getattr(paused, "else_", None),
155
+ "resume": f"readyagents resume {state.run_id} --approve {exc.node_id}",
156
+ "decide": (
157
+ f"readyagents decide {state.run_id} --node {exc.node_id} --decision approve"
158
+ ),
159
+ }
160
+ state.finish("paused")
161
+ _persist(ctx, state)
162
+ exc.state = state
163
+ if ctx.auditor is not None:
164
+ ctx.auditor(
165
+ "paused",
166
+ run_id=state.run_id,
167
+ node_id=exc.node_id,
168
+ actor=ctx.actor,
169
+ )
170
+ _notify_pause(ctx, exc, state)
171
+ raise
172
+ except ReadyAgentsError as exc:
173
+ state.take_node_usage()
174
+ state.pending_node = current
175
+ state.record_error(
176
+ current or "?",
177
+ str(nodes[current].type) if current in nodes else "?",
178
+ str(exc),
179
+ )
180
+ state.pending = {
181
+ "node_id": current,
182
+ "type": str(nodes[current].type) if current in nodes else "?",
183
+ "error": str(exc),
184
+ }
185
+ state.finish("failed")
186
+ _persist(ctx, state)
187
+ exc.state = state
188
+ if not exc.run_id:
189
+ exc.run_id = state.run_id
190
+ if ctx.auditor is not None:
191
+ ctx.auditor(
192
+ "run_finished",
193
+ run_id=state.run_id,
194
+ status="failed",
195
+ node_id=current,
196
+ actor=ctx.actor,
197
+ )
198
+ raise
199
+ return state
200
+
201
+
202
+ def _resume_cursor(workflow: WorkflowSpec, state: RunState) -> tuple[str | None, set[str]]:
203
+ if state.status == "succeeded":
204
+ raise WorkflowError(
205
+ f"Run {state.run_id} already succeeded. "
206
+ f"Use 'readyagents runs replay {state.run_id}' to start a new run."
207
+ )
208
+ completed = {r.node_id for r in state.results if r.status == "ok"}
209
+ current = state.pending_node
210
+ if not current:
211
+ for result in reversed(state.results):
212
+ if result.status == "error":
213
+ current = result.node_id
214
+ break
215
+ if not current and state.results:
216
+ last_ok = next((r for r in reversed(state.results) if r.status == "ok"), None)
217
+ if last_ok and last_ok.node_id in workflow.node_map():
218
+ current = _next_node(workflow, workflow.node_map()[last_ok.node_id], state)
219
+ if current:
220
+ state.results = [
221
+ r for r in state.results if not (r.node_id == current and r.status == "error")
222
+ ]
223
+ if current not in completed:
224
+ state.node_outputs.pop(current, None)
225
+ state.pending_node = None
226
+ state.pending = None
227
+ state.status = "running"
228
+ state.finished_at = None
229
+ return current, completed
230
+
231
+
232
+ def _is_fresh_start(state: RunState) -> bool:
233
+ return state.status in {"queued", "running"} and not state.results and not state.pending_node
234
+
235
+
236
+ def _raise_if_cancelled(ctx: ExecutionContext, state: RunState) -> None:
237
+ if ctx.cancellation is None:
238
+ return
239
+ ctx.cancellation.raise_if_requested(run_id=state.run_id)
240
+
241
+
242
+ def _arm_cancellation_persist(ctx: ExecutionContext, state: RunState) -> None:
243
+ token = ctx.cancellation
244
+ if token is None:
245
+ return
246
+ token.clear_listeners()
247
+ token.add_listener(lambda: _persist(ctx, state))
248
+
249
+
250
+ def _finalize_cancelled(
251
+ ctx: ExecutionContext,
252
+ state: RunState,
253
+ current: str | None,
254
+ nodes: Mapping[str, NodeSpec],
255
+ ) -> None:
256
+ with ctx._persist_lock:
257
+ already = state.status == "cancelled"
258
+ if not already:
259
+ state.pending_node = current
260
+ state.pending = {
261
+ "node_id": current,
262
+ "type": str(nodes[current].type) if current in nodes else "?",
263
+ "error": "cancelled",
264
+ }
265
+ state.finish("cancelled")
266
+ if already:
267
+ return
268
+ _persist(ctx, state)
269
+ if ctx.auditor is not None:
270
+ ctx.auditor("run_finished", run_id=state.run_id, status="cancelled", actor=ctx.actor)
271
+
272
+
273
+ def _persist(ctx: ExecutionContext, state: RunState) -> None:
274
+ token = ctx.cancellation
275
+ with ctx._persist_lock:
276
+ if token is not None and token.is_requested() and state.status not in _TERMINAL_STATUSES:
277
+ state.status = "cancel_requested"
278
+ if ctx.on_persist is None:
279
+ return
280
+ ctx.on_persist(state)
281
+
282
+
283
+ def _notify_pause(ctx: ExecutionContext, exc: ApprovalRequired, state: RunState) -> None:
284
+ if ctx.on_pause is None:
285
+ return
286
+ try:
287
+ ctx.on_pause(exc, state)
288
+ except Exception as notify_exc: # noqa: BLE001
289
+ log.warning(
290
+ "pause notify failed: %s",
291
+ notify_exc,
292
+ extra={"run_id": state.run_id, "node_id": exc.node_id, "event": "pause_notify_error"},
293
+ )
294
+
295
+
296
+ def _execute_with_policy(node: NodeSpec, state: RunState, ctx: ExecutionContext) -> None:
297
+ started = utc_now()
298
+ try:
299
+ output, attempt = execute_node_with_policy(node, state, ctx)
300
+ except (BudgetExceeded, AuthorizationError, CircuitOpen):
301
+ state.take_node_usage()
302
+ raise
303
+ usage = state.take_node_usage()
304
+ rounds = list(ctx.last_tool_rounds or [])
305
+ ctx.last_tool_rounds = []
306
+ state.record(
307
+ node.id,
308
+ output,
309
+ node_type=str(node.type),
310
+ output_key=node.output_key,
311
+ attempts=attempt,
312
+ started_at=started,
313
+ finished_at=utc_now(),
314
+ usage=usage,
315
+ tool_rounds=rounds,
316
+ )
317
+
318
+
319
+ def _uses_explicit_routing(workflow: WorkflowSpec) -> bool:
320
+ if workflow.edges:
321
+ return True
322
+ return any(n.next or n.then or n.else_ for n in workflow.nodes)
323
+
324
+
325
+ def _next_node(workflow: WorkflowSpec, node: NodeSpec, state: RunState) -> str | None:
326
+ if str(node.type) in {NodeType.condition.value, NodeType.approval.value}:
327
+ output = state.node_outputs.get(node.id) or {}
328
+ nxt = output.get("next") if isinstance(output, dict) else None
329
+ return nxt
330
+
331
+ edges = [e for e in workflow.edges if e.from_ == node.id]
332
+ if edges:
333
+ default = None
334
+ ns = state.mapping()
335
+ for edge in edges:
336
+ if edge.when is None:
337
+ default = edge.to
338
+ continue
339
+ if evaluate_condition(edge.when, ns):
340
+ return edge.to
341
+ return default
342
+
343
+ if node.next:
344
+ return node.next
345
+
346
+ # List order only for purely sequential workflows (no next/edges/branches).
347
+ if _uses_explicit_routing(workflow):
348
+ return None
349
+
350
+ ids = [n.id for n in workflow.nodes]
351
+ idx = ids.index(node.id)
352
+ if idx + 1 < len(ids):
353
+ return ids[idx + 1]
354
+ return None