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,944 @@
1
+ """Execute individual node types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ from collections.abc import Callable, Mapping
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from readyagents.errors import (
13
+ ApprovalRequired,
14
+ AuthorizationError,
15
+ BudgetExceeded,
16
+ CancellationRequested,
17
+ CircuitOpen,
18
+ LLMError,
19
+ NodeError,
20
+ ReadyAgentsError,
21
+ TemplateError,
22
+ ToolError,
23
+ WorkflowError,
24
+ )
25
+ from readyagents.llm.base import CompletionResult, LLMProvider, Message, ToolCall
26
+ from readyagents.llm.cache import LLMCache
27
+ from readyagents.llm.registry import get_provider
28
+ from readyagents.llm.resilience import (
29
+ check_budget,
30
+ model_candidates,
31
+ model_id_for,
32
+ normalize_usage,
33
+ raise_exhausted,
34
+ usage_nonzero,
35
+ )
36
+ from readyagents.llm.tool_calls import spec_from_tool
37
+ from readyagents.logging import get_logger, log_event
38
+ from readyagents.tools import ToolRegistry
39
+ from readyagents.workflow.cancellation import CancellationToken, cancellable_sleep
40
+ from readyagents.workflow.schema import NodeSpec, NodeType, WorkflowSpec
41
+ from readyagents.workflow.state import RunState
42
+ from readyagents.workflow.structured import validate_structured_output
43
+ from readyagents.workflow.templates import interpolate, interpolate_value, lookup, resolve_path
44
+
45
+ log = get_logger("nodes")
46
+
47
+ _APPROVE_VALUES = {"approve", "approved", "yes", "true", "accept", "ok"}
48
+ _REJECT_VALUES = {"reject", "rejected", "deny", "denied", "no", "false"}
49
+ _MAX_INCLUDE_DEPTH = 8
50
+ _MAX_PARALLEL = 8
51
+ _DEFAULT_MAX_FOREACH = 32
52
+ _HARD_MAX_FOREACH = 100
53
+ _FOREACH_META = "_foreach"
54
+ _INCLUDE_META = "_include"
55
+ _PARALLEL_META = "_parallel"
56
+ # Dry-run still walks the graph; these tools must not hit the network or disk.
57
+ _DRY_RUN_STUB_TOOLS = frozenset({"http_get", "write_file"})
58
+ _DEFAULT_MAX_TOOL_ROUNDS = 8
59
+ _HARD_MAX_TOOL_ROUNDS = 20
60
+
61
+
62
+ class ExecutionContext:
63
+ def __init__(
64
+ self,
65
+ workflow: WorkflowSpec,
66
+ tools: ToolRegistry,
67
+ *,
68
+ dry_run: bool = False,
69
+ llm: LLMProvider | None = None,
70
+ default_model: str | None = None,
71
+ extra_handlers: Mapping[str, Any] | None = None,
72
+ decisions: Mapping[str, str] | None = None,
73
+ on_persist: Callable[[RunState], None] | None = None,
74
+ workflow_dir: Path | None = None,
75
+ include_depth: int = 0,
76
+ circuit_breaker: Any | None = None,
77
+ llm_cache: LLMCache | None = None,
78
+ budget_tokens: int | None = None,
79
+ budget_cost_micros: int | None = None,
80
+ secrets: Any | None = None,
81
+ authorizer: Any | None = None,
82
+ actor: str | None = None,
83
+ redactor: Any | None = None,
84
+ auditor: Callable[..., None] | None = None,
85
+ on_pause: Callable[..., None] | None = None,
86
+ fallback_models: list[str] | None = None,
87
+ cache_llm: bool = False,
88
+ usage_state: RunState | None = None,
89
+ cancellation: CancellationToken | None = None,
90
+ ) -> None:
91
+ self.workflow = workflow
92
+ self.tools = tools
93
+ self.dry_run = dry_run
94
+ self.llm = llm
95
+ self.default_model = default_model or workflow.default_model
96
+ self.extra_handlers = dict(extra_handlers or {})
97
+ self.decisions = {str(k): str(v).strip().lower() for k, v in dict(decisions or {}).items()}
98
+ self.on_persist = on_persist
99
+ self.workflow_dir = Path(workflow_dir) if workflow_dir else Path.cwd()
100
+ self.include_depth = include_depth
101
+ self.circuit_breaker = circuit_breaker
102
+ self.llm_cache = llm_cache
103
+ self.budget_tokens = budget_tokens
104
+ self.budget_cost_micros = budget_cost_micros
105
+ self.secrets = secrets
106
+ self.authorizer = authorizer
107
+ self.actor = actor
108
+ self.redactor = redactor
109
+ self.auditor = auditor
110
+ self.on_pause = on_pause
111
+ self.fallback_models = list(fallback_models or [])
112
+ self.cache_llm = cache_llm
113
+ self.usage_state = usage_state
114
+ self.cancellation = cancellation
115
+ self.last_tool_rounds: list[dict[str, Any]] = []
116
+ self._persist_lock = threading.RLock()
117
+
118
+ def decision_for(self, node_id: str) -> str | None:
119
+ value = self.decisions.get(node_id)
120
+ return value if value else None
121
+
122
+ def child(
123
+ self,
124
+ workflow: WorkflowSpec,
125
+ *,
126
+ workflow_dir: Path,
127
+ include_depth: int,
128
+ on_persist: Callable[[RunState], None] | None = None,
129
+ ) -> ExecutionContext:
130
+ return ExecutionContext(
131
+ workflow,
132
+ self.tools,
133
+ dry_run=self.dry_run,
134
+ llm=self.llm,
135
+ default_model=self.default_model or workflow.default_model,
136
+ extra_handlers=self.extra_handlers,
137
+ decisions=self.decisions,
138
+ on_persist=on_persist,
139
+ workflow_dir=workflow_dir,
140
+ include_depth=include_depth,
141
+ circuit_breaker=self.circuit_breaker,
142
+ llm_cache=self.llm_cache,
143
+ budget_tokens=self.budget_tokens,
144
+ budget_cost_micros=self.budget_cost_micros,
145
+ secrets=self.secrets,
146
+ authorizer=self.authorizer,
147
+ actor=self.actor,
148
+ redactor=self.redactor,
149
+ auditor=self.auditor,
150
+ on_pause=self.on_pause,
151
+ fallback_models=self.fallback_models,
152
+ cache_llm=self.cache_llm,
153
+ usage_state=self.usage_state,
154
+ cancellation=self.cancellation,
155
+ )
156
+
157
+
158
+ def execute_node(node: NodeSpec, state: RunState, ctx: ExecutionContext) -> Any:
159
+ kind = node.type if isinstance(node.type, str) else str(node.type)
160
+ handler = ctx.extra_handlers.get(kind)
161
+ if handler is not None:
162
+ return handler.execute(node, state, ctx)
163
+
164
+ if kind == NodeType.agent.value:
165
+ return _run_agent(node, state, ctx)
166
+ if kind == NodeType.tool.value:
167
+ return _run_tool(node, state, ctx)
168
+ if kind == NodeType.transform.value:
169
+ return _run_transform(node, state, ctx)
170
+ if kind == NodeType.condition.value:
171
+ return _run_condition(node, state, ctx)
172
+ if kind == NodeType.approval.value:
173
+ return _run_approval(node, state, ctx)
174
+ if kind == NodeType.parallel.value:
175
+ return _run_parallel(node, state, ctx)
176
+ if kind == NodeType.include.value:
177
+ return _run_include(node, state, ctx)
178
+ if kind == NodeType.foreach.value:
179
+ return _run_foreach(node, state, ctx)
180
+ known = ", ".join(t.value for t in NodeType)
181
+ raise WorkflowError(
182
+ f"Unsupported node type '{node.type}' on node '{node.id}'. "
183
+ f"Known types: {known}. Packs may register extra types via readyagents.packs."
184
+ )
185
+
186
+
187
+ def _run_agent(node: NodeSpec, state: RunState, ctx: ExecutionContext) -> Any:
188
+ ns = state.mapping()
189
+ prompt = interpolate(node.prompt or "", ns)
190
+ system = interpolate(node.system, ns) if node.system else None
191
+ ctx.last_tool_rounds = []
192
+ allowlist = list(node.tools or [])
193
+ tool_specs = _resolve_agent_tool_specs(node, ctx, allowlist) if allowlist else None
194
+ if ctx.dry_run:
195
+ preview = prompt if not system else f"[system]\n{system}\n[user]\n{prompt}"
196
+ tools_line = f" tools={','.join(allowlist)}" if allowlist else ""
197
+ estimated = _estimate_tokens(prompt, system or "")
198
+ _account_usage(state, ctx, {"estimated_tokens": estimated})
199
+ return f"[dry-run]{tools_line}\n{preview}\n[estimated_tokens={estimated}]"
200
+ messages: list[Message] = []
201
+ if system:
202
+ messages.append(Message(role="system", content=system))
203
+ messages.append(Message(role="user", content=prompt))
204
+ result = _complete_agent(node, state, ctx, messages, tools=tool_specs)
205
+ if tool_specs:
206
+ result = _agent_tool_loop(node, state, ctx, messages, result, allowlist, tool_specs)
207
+ if node.output_schema:
208
+ return validate_structured_output(result.text, node.output_schema, node_id=node.id)
209
+ return result.text
210
+
211
+
212
+ def _resolve_agent_tool_specs(
213
+ node: NodeSpec, ctx: ExecutionContext, allowlist: list[str]
214
+ ) -> list[dict[str, Any]]:
215
+ specs: list[dict[str, Any]] = []
216
+ missing: list[str] = []
217
+ for name in allowlist:
218
+ try:
219
+ tool = ctx.tools.get(name)
220
+ except ToolError:
221
+ missing.append(name)
222
+ continue
223
+ specs.append(spec_from_tool(tool))
224
+ if missing:
225
+ known = ", ".join(ctx.tools.names()) or "(none)"
226
+ raise NodeError(
227
+ node.id,
228
+ f"unknown tool(s): {', '.join(missing)}. Available: {known}",
229
+ )
230
+ return specs
231
+
232
+
233
+ def _tool_round_cap(node: NodeSpec) -> int:
234
+ raw = node.max_tool_rounds if node.max_tool_rounds is not None else _DEFAULT_MAX_TOOL_ROUNDS
235
+ return max(1, min(int(raw), _HARD_MAX_TOOL_ROUNDS))
236
+
237
+
238
+ def _tool_result_content(value: Any) -> str:
239
+ if isinstance(value, str):
240
+ return value
241
+ try:
242
+ return json.dumps(value, ensure_ascii=False, default=str)
243
+ except TypeError:
244
+ return str(value)
245
+
246
+
247
+ _TRACE_LIMIT = 400
248
+
249
+
250
+ def _truncate_trace(value: Any, limit: int = _TRACE_LIMIT) -> str:
251
+ text = value if isinstance(value, str) else _tool_result_content(value)
252
+ if len(text) > limit:
253
+ return text[: limit - 1] + "…"
254
+ return text
255
+
256
+
257
+ def _invoke_agent_tool(node: NodeSpec, ctx: ExecutionContext, call: ToolCall) -> Any:
258
+ name = call.name
259
+ args = call.arguments if isinstance(call.arguments, dict) else {}
260
+ if ctx.dry_run and name in _DRY_RUN_STUB_TOOLS:
261
+ return f"[dry-run] {name} {args}"
262
+ tool = ctx.tools.get(name)
263
+ return tool.run(**args)
264
+
265
+
266
+ def _agent_tool_loop(
267
+ node: NodeSpec,
268
+ state: RunState,
269
+ ctx: ExecutionContext,
270
+ messages: list[Message],
271
+ result: CompletionResult,
272
+ allowlist: list[str],
273
+ tool_specs: list[dict[str, Any]],
274
+ ) -> CompletionResult:
275
+ allowed = set(allowlist)
276
+ cap = _tool_round_cap(node)
277
+ rounds = 0
278
+ while result.tool_calls:
279
+ rounds += 1
280
+ if rounds > cap:
281
+ raise NodeError(node.id, f"tool-use exceeded max_tool_rounds={cap}")
282
+ for call in result.tool_calls:
283
+ if call.name not in allowed:
284
+ raise NodeError(
285
+ node.id,
286
+ f"model requested tool '{call.name}' which is not on the allowlist",
287
+ )
288
+ messages.append(
289
+ Message(
290
+ role="assistant",
291
+ content=result.text or "",
292
+ tool_calls=list(result.tool_calls),
293
+ )
294
+ )
295
+ for call in result.tool_calls:
296
+ log_event(
297
+ log,
298
+ "agent_tool_call",
299
+ "agent tool %s",
300
+ call.name,
301
+ run_id=state.run_id,
302
+ node_id=node.id,
303
+ tool=call.name,
304
+ )
305
+ try:
306
+ output = _invoke_agent_tool(node, ctx, call)
307
+ except ToolError as exc:
308
+ err_text = str(exc)
309
+ ctx.last_tool_rounds.append(
310
+ {
311
+ "name": call.name,
312
+ "status": "error",
313
+ "error": _truncate_trace(err_text),
314
+ }
315
+ )
316
+ messages.append(
317
+ Message(
318
+ role="tool",
319
+ content=_tool_result_content({"error": err_text}),
320
+ tool_call_id=call.id,
321
+ name=call.name,
322
+ )
323
+ )
324
+ continue
325
+ ctx.last_tool_rounds.append(
326
+ {
327
+ "name": call.name,
328
+ "status": "ok",
329
+ "output": _truncate_trace(output),
330
+ }
331
+ )
332
+ messages.append(
333
+ Message(
334
+ role="tool",
335
+ content=_tool_result_content(output),
336
+ tool_call_id=call.id,
337
+ name=call.name,
338
+ )
339
+ )
340
+ result = _complete_agent(node, state, ctx, messages, tools=tool_specs)
341
+ return result
342
+
343
+
344
+ def _account_usage(state: RunState, ctx: ExecutionContext, usage: Mapping[str, Any]) -> None:
345
+ cleaned = {str(k): v for k, v in dict(usage).items()}
346
+ state.note_node_usage(cleaned, rollup=True)
347
+ sink = ctx.usage_state
348
+ if sink is not None and sink is not state:
349
+ sink.add_usage(**cleaned)
350
+
351
+
352
+ def _complete_agent(
353
+ node: NodeSpec,
354
+ state: RunState,
355
+ ctx: ExecutionContext,
356
+ messages: list[Message],
357
+ tools: list[dict[str, Any]] | None = None,
358
+ ) -> CompletionResult:
359
+ explicit = bool(node.model)
360
+ primary = node.model or ctx.default_model
361
+ candidates = model_candidates(primary, node.fallback_models, ctx.fallback_models)
362
+ if not candidates:
363
+ candidates = [primary or "mock"]
364
+ use_cache = bool(ctx.cache_llm if node.cache is None else node.cache)
365
+ last_error: BaseException | None = None
366
+ skipped: list[str] = []
367
+ tried: list[str] = []
368
+ for ref in candidates:
369
+ breaker = ctx.circuit_breaker
370
+ if breaker is not None and not breaker.allow(ref):
371
+ log_event(
372
+ log,
373
+ "circuit_open",
374
+ "circuit open, skip %s",
375
+ ref,
376
+ run_id=state.run_id,
377
+ node_id=node.id,
378
+ model=ref,
379
+ )
380
+ skipped.append(ref)
381
+ continue
382
+ cache_hit = None
383
+ if use_cache and ctx.llm_cache is not None:
384
+ cache_key = ctx.llm_cache.key(ref, messages, tools=tools)
385
+ cache_hit = ctx.llm_cache.get(cache_key)
386
+ if cache_hit is not None:
387
+ _account_usage(state, ctx, {"cache_hits": 1})
388
+ log_event(
389
+ log,
390
+ "cache_hit",
391
+ "llm cache hit model=%s",
392
+ ref,
393
+ run_id=state.run_id,
394
+ node_id=node.id,
395
+ model=ref,
396
+ )
397
+ return cache_hit
398
+ check_budget(
399
+ (ctx.usage_state or state).usage,
400
+ max_tokens=ctx.budget_tokens,
401
+ max_cost_micros=ctx.budget_cost_micros,
402
+ )
403
+ try:
404
+ if ctx.llm is not None:
405
+ provider = ctx.llm
406
+ model_id = model_id_for(ref)
407
+ else:
408
+ provider, model_id = get_provider(
409
+ ref,
410
+ implicit=not explicit,
411
+ secrets=ctx.secrets,
412
+ )
413
+ tried.append(ref)
414
+ result = provider.complete(messages, model=model_id, tools=tools)
415
+ except LLMError as exc:
416
+ last_error = exc
417
+ if breaker is not None:
418
+ breaker.record_failure(ref)
419
+ log_event(
420
+ log,
421
+ "llm_error",
422
+ "model %s failed: %s",
423
+ ref,
424
+ exc,
425
+ run_id=state.run_id,
426
+ node_id=node.id,
427
+ model=ref,
428
+ )
429
+ continue
430
+ if breaker is not None:
431
+ breaker.record_success(ref)
432
+ usage = normalize_usage(result.usage, model=result.model or model_id_for(ref))
433
+ if usage_nonzero(usage):
434
+ _account_usage(state, ctx, usage)
435
+ if use_cache and ctx.llm_cache is not None:
436
+ ctx.llm_cache.put(ctx.llm_cache.key(ref, messages, tools=tools), result)
437
+ if tried and tried[0] != ref:
438
+ log_event(
439
+ log,
440
+ "llm_fallback",
441
+ "fell back to %s",
442
+ ref,
443
+ run_id=state.run_id,
444
+ node_id=node.id,
445
+ model=ref,
446
+ )
447
+ return result
448
+ if skipped and not tried:
449
+ raise CircuitOpen(skipped[0])
450
+ raise_exhausted(tried, skipped, last_error)
451
+ raise LLMError("No LLM model was available") # pragma: no cover
452
+
453
+
454
+ def _run_tool(node: NodeSpec, state: RunState, ctx: ExecutionContext) -> Any:
455
+ name = node.tool or ""
456
+ args = interpolate_value(node.arguments, state.mapping())
457
+ if not isinstance(args, dict):
458
+ raise NodeError(node.id, "tool arguments must be a mapping")
459
+ if ctx.dry_run and name in _DRY_RUN_STUB_TOOLS:
460
+ return f"[dry-run] {name} {args}"
461
+ tool = ctx.tools.get(name)
462
+ return tool.run(**args)
463
+
464
+
465
+ def _run_transform(node: NodeSpec, state: RunState, ctx: ExecutionContext) -> Any:
466
+ ns = state.mapping()
467
+ value: Any
468
+ if node.template is not None:
469
+ value = interpolate(node.template, ns)
470
+ elif node.source:
471
+ value = lookup(ns, node.source)
472
+ else:
473
+ if not state.results:
474
+ raise TemplateError("transform has no source and no prior node output")
475
+ value = state.node_outputs[state.results[-1].node_id]
476
+
477
+ if node.parse_json:
478
+ if isinstance(value, str):
479
+ if ctx.dry_run and value.lstrip().startswith("[dry-run]"):
480
+ value = {"dry_run": True}
481
+ else:
482
+ value = _parse_json_lenient(value)
483
+ if node.json_path:
484
+ if isinstance(value, str):
485
+ try:
486
+ value = json.loads(value)
487
+ except json.JSONDecodeError as exc:
488
+ raise ToolError(f"transform json_path: value is not JSON: {exc}") from exc
489
+ value = resolve_path(value, node.json_path)
490
+ return value
491
+
492
+
493
+ def _parse_json_lenient(text: str) -> Any:
494
+ stripped = text.strip()
495
+ try:
496
+ return json.loads(stripped)
497
+ except json.JSONDecodeError:
498
+ start = stripped.find("{")
499
+ end = stripped.rfind("}")
500
+ if start != -1 and end != -1 and end > start:
501
+ try:
502
+ return json.loads(stripped[start : end + 1])
503
+ except json.JSONDecodeError:
504
+ pass
505
+ start = stripped.find("[")
506
+ end = stripped.rfind("]")
507
+ if start != -1 and end != -1 and end > start:
508
+ return json.loads(stripped[start : end + 1])
509
+ raise ToolError("transform parse_json: could not parse JSON from text") from None
510
+
511
+
512
+ def _run_condition(node: NodeSpec, state: RunState, ctx: ExecutionContext) -> dict[str, Any]:
513
+ matched = evaluate_condition(node.when or "", state.mapping())
514
+ nxt = node.then if matched else node.else_
515
+ return {"matched": matched, "next": nxt}
516
+
517
+
518
+ def _run_approval(node: NodeSpec, state: RunState, ctx: ExecutionContext) -> dict[str, Any]:
519
+ ns = state.mapping()
520
+ prompt = interpolate(node.prompt or f"Approve node '{node.id}'?", ns)
521
+ raw = ctx.decision_for(node.id)
522
+ if raw is None:
523
+ raise ApprovalRequired(node.id, state.run_id, prompt, state=state)
524
+ if raw in _APPROVE_VALUES:
525
+ approved = True
526
+ elif raw in _REJECT_VALUES:
527
+ approved = False
528
+ else:
529
+ raise NodeError(
530
+ node.id,
531
+ f"unknown decision '{raw}' (use approve or reject)",
532
+ )
533
+ action = "approve" if approved else "reject"
534
+ if ctx.authorizer is not None:
535
+ ctx.authorizer.check(ctx.actor, action, node.id)
536
+ if ctx.auditor is not None:
537
+ ctx.auditor(
538
+ "decision",
539
+ run_id=state.run_id,
540
+ node_id=node.id,
541
+ decision=action,
542
+ actor=ctx.actor,
543
+ )
544
+ if approved:
545
+ nxt = node.then or node.next
546
+ else:
547
+ nxt = node.else_
548
+ return {
549
+ "approved": approved,
550
+ "decision": action,
551
+ "prompt": prompt,
552
+ "next": nxt,
553
+ "actor": ctx.actor,
554
+ }
555
+
556
+
557
+ def _estimate_tokens(*texts: str) -> int:
558
+ total = sum(len(t or "") for t in texts)
559
+ return max(1, total // 4)
560
+
561
+
562
+ def execute_node_with_policy(
563
+ node: NodeSpec, state: RunState, ctx: ExecutionContext
564
+ ) -> tuple[Any, int]:
565
+ """Run a node with timeout_seconds / retry. Does not record the result."""
566
+ retry = node.retry
567
+ attempts = retry.max_attempts if retry else 1
568
+ backoff = retry.backoff_seconds if retry else 1.0
569
+ multiplier = retry.backoff_multiplier if retry else 2.0
570
+ last_error: BaseException | None = None
571
+
572
+ for attempt in range(1, attempts + 1):
573
+ if ctx.cancellation is not None:
574
+ ctx.cancellation.raise_if_requested(run_id=state.run_id)
575
+ try:
576
+ return _call_with_timeout(node, state, ctx), attempt
577
+ except CancellationRequested:
578
+ raise
579
+ except ApprovalRequired:
580
+ raise
581
+ except (BudgetExceeded, AuthorizationError, CircuitOpen):
582
+ raise
583
+ except ReadyAgentsError as exc:
584
+ last_error = exc
585
+ log.warning(
586
+ "Node %s attempt %s/%s failed: %s",
587
+ node.id,
588
+ attempt,
589
+ attempts,
590
+ exc,
591
+ extra={"run_id": state.run_id, "node_id": node.id},
592
+ )
593
+ if attempt >= attempts:
594
+ break
595
+ cancellable_sleep(backoff * (multiplier ** (attempt - 1)), ctx.cancellation)
596
+ except Exception as exc: # noqa: BLE001
597
+ last_error = exc
598
+ log.warning(
599
+ "Node %s attempt %s/%s failed: %s",
600
+ node.id,
601
+ attempt,
602
+ attempts,
603
+ exc,
604
+ extra={"run_id": state.run_id, "node_id": node.id},
605
+ )
606
+ if attempt >= attempts:
607
+ break
608
+ cancellable_sleep(backoff * (multiplier ** (attempt - 1)), ctx.cancellation)
609
+
610
+ if isinstance(last_error, NodeError) and last_error.node_id == node.id:
611
+ raise last_error
612
+ message = str(last_error) if last_error else "unknown error"
613
+ raise NodeError(node.id, message, cause=last_error) from last_error
614
+
615
+
616
+ def _call_with_timeout(node: NodeSpec, state: RunState, ctx: ExecutionContext) -> Any:
617
+ if not node.timeout_seconds:
618
+ return execute_node(node, state, ctx)
619
+
620
+ box: dict[str, Any] = {}
621
+
622
+ def _worker() -> None:
623
+ try:
624
+ box["value"] = execute_node(node, state, ctx)
625
+ except BaseException as exc: # noqa: BLE001
626
+ box["error"] = exc
627
+
628
+ thread = threading.Thread(
629
+ target=_worker,
630
+ name=f"readyagents-node-{node.id}",
631
+ daemon=True,
632
+ )
633
+ thread.start()
634
+ thread.join(node.timeout_seconds)
635
+ if thread.is_alive():
636
+ raise NodeError(node.id, f"timed out after {node.timeout_seconds}s")
637
+ if "error" in box:
638
+ raise box["error"]
639
+ return box["value"]
640
+
641
+
642
+ def _foreach_cap(node: NodeSpec) -> int:
643
+ raw = node.max_items if node.max_items is not None else _DEFAULT_MAX_FOREACH
644
+ return max(1, min(int(raw), _HARD_MAX_FOREACH))
645
+
646
+
647
+ def _foreach_items(node: NodeSpec, state: RunState) -> list[Any]:
648
+ ns = state.mapping()
649
+ raw: Any
650
+ try:
651
+ raw = lookup(ns, node.items or "")
652
+ except TemplateError:
653
+ text = interpolate(node.items or "", ns)
654
+ try:
655
+ raw = json.loads(text)
656
+ except json.JSONDecodeError:
657
+ raw = text
658
+ if isinstance(raw, str):
659
+ stripped = raw.strip()
660
+ if stripped.startswith("[") or stripped.startswith("{"):
661
+ try:
662
+ raw = json.loads(stripped)
663
+ except json.JSONDecodeError:
664
+ pass
665
+ if not isinstance(raw, list):
666
+ raise NodeError(node.id, f"foreach items must be a list (got {type(raw).__name__})")
667
+ return raw
668
+
669
+
670
+ def _run_foreach(node: NodeSpec, state: RunState, ctx: ExecutionContext) -> list[Any]:
671
+ body = node.body
672
+ if body is None:
673
+ raise NodeError(node.id, "foreach nodes require 'body'")
674
+ items = _foreach_items(node, state)
675
+ cap = _foreach_cap(node)
676
+ if len(items) > cap:
677
+ raise NodeError(node.id, f"foreach exceeded max_items={cap}")
678
+ bucket = state.metadata.setdefault(_FOREACH_META, {})
679
+ if not isinstance(bucket, dict):
680
+ bucket = {}
681
+ state.metadata[_FOREACH_META] = bucket
682
+ prior = bucket.get(node.id) if isinstance(bucket.get(node.id), list) else []
683
+ outputs: list[Any] = []
684
+ for row in prior:
685
+ if isinstance(row, dict) and row.get("status") == "ok":
686
+ outputs.append(row.get("output"))
687
+ start = len(outputs)
688
+ for index, item in enumerate(items):
689
+ if index < start:
690
+ continue
691
+ child = RunState.start(
692
+ state.workflow_name,
693
+ {**state.inputs, "item": item, "index": index},
694
+ metadata=state.metadata,
695
+ run_id=state.run_id,
696
+ )
697
+ child.node_outputs = dict(state.node_outputs)
698
+ child.output_keys = dict(state.output_keys)
699
+ child.node_outputs["item"] = item
700
+ child.output_keys["item"] = item
701
+ child.node_outputs["index"] = index
702
+ child.output_keys["index"] = index
703
+ item_ctx = ctx
704
+ prev_rounds = item_ctx.last_tool_rounds
705
+ item_ctx.last_tool_rounds = []
706
+ try:
707
+ output, _attempt = execute_node_with_policy(body, child, item_ctx)
708
+ except ApprovalRequired:
709
+ bucket[node.id] = [
710
+ {"index": i, "status": "ok", "output": outputs[i]} for i in range(len(outputs))
711
+ ]
712
+ if ctx.on_persist is not None:
713
+ ctx.on_persist(state)
714
+ raise
715
+ except CancellationRequested:
716
+ bucket[node.id] = [
717
+ {"index": i, "status": "ok", "output": outputs[i]} for i in range(len(outputs))
718
+ ]
719
+ if ctx.on_persist is not None:
720
+ ctx.on_persist(state)
721
+ raise
722
+ except Exception:
723
+ bucket[node.id] = [
724
+ {"index": i, "status": "ok", "output": outputs[i]} for i in range(len(outputs))
725
+ ]
726
+ if ctx.on_persist is not None:
727
+ ctx.on_persist(state)
728
+ raise
729
+ finally:
730
+ item_ctx.last_tool_rounds = prev_rounds
731
+ outputs.append(output)
732
+ bucket[node.id] = [
733
+ {"index": i, "status": "ok", "output": outputs[i]} for i in range(len(outputs))
734
+ ]
735
+ if ctx.on_persist is not None:
736
+ ctx.on_persist(state)
737
+ return outputs
738
+
739
+
740
+ def _meta_bucket(state: RunState, key: str) -> dict[str, Any]:
741
+ bucket = state.metadata.get(key)
742
+ if not isinstance(bucket, dict):
743
+ bucket = {}
744
+ state.metadata[key] = bucket
745
+ return bucket
746
+
747
+
748
+ def _include_child_state(state: RunState, node_id: str) -> RunState | None:
749
+ bucket = state.metadata.get(_INCLUDE_META)
750
+ if not isinstance(bucket, dict):
751
+ return None
752
+ entry = bucket.get(node_id)
753
+ if not isinstance(entry, dict):
754
+ return None
755
+ record = entry.get("run")
756
+ if not isinstance(record, Mapping):
757
+ return None
758
+ child = RunState.from_record(record)
759
+ if child.status == "succeeded":
760
+ return None
761
+ return child
762
+
763
+
764
+ def _persist_include_child(
765
+ parent: RunState, node_id: str, child: RunState, ctx: ExecutionContext
766
+ ) -> None:
767
+ _meta_bucket(parent, _INCLUDE_META)[node_id] = {"run": child.to_record()}
768
+ if ctx.on_persist is not None:
769
+ ctx.on_persist(parent)
770
+
771
+
772
+ def _clear_include_child(state: RunState, node_id: str) -> None:
773
+ bucket = state.metadata.get(_INCLUDE_META)
774
+ if not isinstance(bucket, dict):
775
+ return
776
+ bucket.pop(node_id, None)
777
+ if not bucket:
778
+ state.metadata.pop(_INCLUDE_META, None)
779
+
780
+
781
+ def _parallel_prior(state: RunState, node_id: str) -> dict[str, Any]:
782
+ bucket = state.metadata.get(_PARALLEL_META)
783
+ if not isinstance(bucket, dict):
784
+ return {}
785
+ prior = bucket.get(node_id)
786
+ return dict(prior) if isinstance(prior, dict) else {}
787
+
788
+
789
+ def _persist_parallel_ok(
790
+ state: RunState, node_id: str, collected: Mapping[str, Any], ctx: ExecutionContext
791
+ ) -> None:
792
+ _meta_bucket(state, _PARALLEL_META)[node_id] = dict(collected)
793
+ if ctx.on_persist is not None:
794
+ ctx.on_persist(state)
795
+
796
+
797
+ def _run_parallel(node: NodeSpec, state: RunState, ctx: ExecutionContext) -> dict[str, Any]:
798
+ branches = list(node.branches or [])
799
+ if not branches:
800
+ raise NodeError(node.id, "parallel nodes require 'branches'")
801
+ prior = _parallel_prior(state, node.id)
802
+ collected: dict[str, Any] = {}
803
+ remaining: list[NodeSpec] = []
804
+ for branch in branches:
805
+ if branch.id in prior:
806
+ collected[branch.id] = prior[branch.id]
807
+ else:
808
+ remaining.append(branch)
809
+
810
+ def _one(branch: NodeSpec) -> tuple[str, Any]:
811
+ try:
812
+ output, _attempt = execute_node_with_policy(branch, state, ctx)
813
+ return branch.id, output
814
+ except ApprovalRequired:
815
+ raise
816
+ except CancellationRequested:
817
+ raise
818
+ except NodeError:
819
+ raise
820
+ except Exception as exc: # noqa: BLE001
821
+ raise NodeError(
822
+ branch.id,
823
+ f"parallel branch '{branch.id}' failed: {exc}",
824
+ cause=exc if isinstance(exc, BaseException) else None,
825
+ ) from exc
826
+
827
+ first_error: BaseException | None = None
828
+ if remaining:
829
+ workers = max(1, min(_MAX_PARALLEL, len(remaining)))
830
+ with ThreadPoolExecutor(max_workers=workers) as pool:
831
+ futures = [pool.submit(_one, branch) for branch in remaining]
832
+ for fut in as_completed(futures):
833
+ try:
834
+ branch_id, output = fut.result()
835
+ except Exception as exc: # noqa: BLE001
836
+ if first_error is None:
837
+ first_error = exc
838
+ continue
839
+ collected[branch_id] = output
840
+ if first_error is not None:
841
+ _persist_parallel_ok(state, node.id, collected, ctx)
842
+ raise first_error
843
+ _persist_parallel_ok(state, node.id, collected, ctx)
844
+ ordered = {branch.id: collected[branch.id] for branch in branches}
845
+ # Branch execute_node calls accumulate on state._last_node_usage (thread-safe).
846
+ # The engine's take_node_usage then stores the merged total on this node.
847
+ return ordered
848
+
849
+
850
+ def _run_include(node: NodeSpec, state: RunState, ctx: ExecutionContext) -> Any:
851
+ if ctx.include_depth >= _MAX_INCLUDE_DEPTH:
852
+ raise WorkflowError(
853
+ f"include depth exceeded ({_MAX_INCLUDE_DEPTH}). Check for cycles in sub-workflows."
854
+ )
855
+ raw_path = interpolate(node.path or "", state.mapping())
856
+ if not raw_path:
857
+ raise NodeError(node.id, "include nodes require 'path'")
858
+ candidate = _confine_include_path(raw_path, ctx.workflow_dir, node.id)
859
+ if not candidate.is_file():
860
+ raise NodeError(
861
+ node.id,
862
+ f"included workflow not found: {candidate} "
863
+ f"(resolved from path '{raw_path}' relative to {ctx.workflow_dir})",
864
+ )
865
+
866
+ from readyagents.workflow.engine import run_workflow
867
+ from readyagents.workflow.runner import load_workflow, merge_inputs
868
+
869
+ spec = load_workflow(candidate)
870
+ nested_in = interpolate_value(node.call_inputs, state.mapping())
871
+ if nested_in is None:
872
+ nested_in = {}
873
+ if not isinstance(nested_in, dict):
874
+ raise NodeError(node.id, "include inputs must be a mapping")
875
+ merged = merge_inputs(spec, nested_in)
876
+
877
+ def _persist_child(child_state: RunState) -> None:
878
+ _persist_include_child(state, node.id, child_state, ctx)
879
+
880
+ nested_ctx = ctx.child(
881
+ spec,
882
+ workflow_dir=candidate.parent,
883
+ include_depth=ctx.include_depth + 1,
884
+ on_persist=_persist_child,
885
+ )
886
+ nested_state = _include_child_state(state, node.id)
887
+ try:
888
+ nested = run_workflow(
889
+ spec,
890
+ merged,
891
+ nested_ctx,
892
+ metadata={"source": str(candidate), "included_by": node.id},
893
+ state=nested_state,
894
+ )
895
+ except ApprovalRequired as exc:
896
+ # Parent run is what was persisted. Keep the child's node id so
897
+ # `resume --approve <child-id>` works; rewrite run_id to the parent.
898
+ raise ApprovalRequired(
899
+ exc.node_id,
900
+ state.run_id,
901
+ exc.prompt,
902
+ state=state,
903
+ ) from exc
904
+ if nested.status == "paused":
905
+ raise ApprovalRequired(
906
+ nested.pending_node or node.id,
907
+ state.run_id,
908
+ f"Nested workflow '{spec.name}' is waiting for approval.",
909
+ state=state,
910
+ )
911
+ if nested.status != "succeeded":
912
+ raise NodeError(node.id, f"included workflow '{spec.name}' {nested.status}")
913
+ _clear_include_child(state, node.id)
914
+ # Nested agents already add_usage onto ctx.usage_state (the parent run).
915
+ # Record nested totals on this include node without rolling them up again.
916
+ already_on_parent = ctx.usage_state is state
917
+ state.note_node_usage(nested.usage, rollup=not already_on_parent)
918
+ return nested.output_keys or nested.node_outputs
919
+
920
+
921
+ def _confine_include_path(raw_path: str, workflow_dir: Path, node_id: str) -> Path:
922
+ """Resolve an include path and refuse anything outside the parent workflow dir."""
923
+ root = Path(workflow_dir).resolve()
924
+ text = str(raw_path).strip()
925
+ if not text or "\x00" in text:
926
+ raise NodeError(node_id, "include nodes require a path under the parent workflow directory")
927
+ candidate = Path(text)
928
+ if not candidate.is_absolute():
929
+ candidate = root / candidate
930
+ resolved = candidate.resolve()
931
+ if not resolved.is_relative_to(root):
932
+ raise NodeError(
933
+ node_id,
934
+ f"included workflow is outside the parent workflow directory: {raw_path} "
935
+ f"(resolved to {resolved}, must stay under {root})",
936
+ )
937
+ return resolved
938
+
939
+
940
+ def evaluate_condition(expr: str, mapping: Mapping[str, Any]) -> bool:
941
+ """Evaluate a small boolean of comparisons / truthy paths. No `eval()`."""
942
+ from readyagents.workflow.conditions import evaluate_condition as eval_bool
943
+
944
+ return eval_bool(expr, mapping)