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.
- readyagents/__init__.py +38 -0
- readyagents/__main__.py +4 -0
- readyagents/audit.py +67 -0
- readyagents/cli.py +1050 -0
- readyagents/config.py +264 -0
- readyagents/errors.py +129 -0
- readyagents/llm/__init__.py +11 -0
- readyagents/llm/anthropic_provider.py +72 -0
- readyagents/llm/base.py +57 -0
- readyagents/llm/cache.py +86 -0
- readyagents/llm/openai_compat.py +12 -0
- readyagents/llm/openai_provider.py +70 -0
- readyagents/llm/registry.py +112 -0
- readyagents/llm/resilience.py +179 -0
- readyagents/llm/tool_calls.py +286 -0
- readyagents/logging.py +162 -0
- readyagents/mcp/__init__.py +43 -0
- readyagents/mcp/builtin.py +674 -0
- readyagents/mcp/client.py +253 -0
- readyagents/mcp/http.py +585 -0
- readyagents/mcp/run_api.py +1077 -0
- readyagents/mcp/server.py +246 -0
- readyagents/notify.py +63 -0
- readyagents/packs/__init__.py +26 -0
- readyagents/packs/loader.py +157 -0
- readyagents/packs/protocol.py +55 -0
- readyagents/policy.py +127 -0
- readyagents/py.typed +1 -0
- readyagents/report.py +88 -0
- readyagents/scaffold.py +410 -0
- readyagents/secrets.py +120 -0
- readyagents/testing/__init__.py +17 -0
- readyagents/testing/eval.py +219 -0
- readyagents/testing/helpers.py +128 -0
- readyagents/testing/recorded.py +68 -0
- readyagents/tools/__init__.py +67 -0
- readyagents/workflow/__init__.py +3 -0
- readyagents/workflow/cancellation.py +88 -0
- readyagents/workflow/conditions.py +279 -0
- readyagents/workflow/engine.py +354 -0
- readyagents/workflow/nodes.py +944 -0
- readyagents/workflow/runner.py +375 -0
- readyagents/workflow/schema.py +287 -0
- readyagents/workflow/state.py +472 -0
- readyagents/workflow/structured.py +103 -0
- readyagents/workflow/templates.py +125 -0
- readyagentsdev-0.8.2.dist-info/METADATA +215 -0
- readyagentsdev-0.8.2.dist-info/RECORD +51 -0
- readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
- readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
- 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
|