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