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,375 @@
|
|
|
1
|
+
"""Load a workflow file, execute it, persist the run record."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from readyagents.audit import audit_dir_for, make_auditor
|
|
14
|
+
from readyagents.config import Settings, get_settings
|
|
15
|
+
from readyagents.errors import ConfigError, WorkflowError
|
|
16
|
+
from readyagents.llm.base import LLMProvider
|
|
17
|
+
from readyagents.llm.cache import LLMCache
|
|
18
|
+
from readyagents.llm.resilience import CircuitBreaker, usd_to_micros
|
|
19
|
+
from readyagents.logging import configure_logging, get_logger
|
|
20
|
+
from readyagents.notify import post_json
|
|
21
|
+
from readyagents.packs.loader import (
|
|
22
|
+
collect_pack_authorizers,
|
|
23
|
+
collect_pack_nodes,
|
|
24
|
+
collect_pack_secrets,
|
|
25
|
+
collect_pack_tools,
|
|
26
|
+
discover_packs,
|
|
27
|
+
)
|
|
28
|
+
from readyagents.policy import redactor_from_settings, resolve_authorizer
|
|
29
|
+
from readyagents.tools import ToolRegistry, default_registry
|
|
30
|
+
from readyagents.workflow.cancellation import CancellationToken
|
|
31
|
+
from readyagents.workflow.engine import run_workflow
|
|
32
|
+
from readyagents.workflow.nodes import ExecutionContext
|
|
33
|
+
from readyagents.workflow.schema import WorkflowSpec, validate_required_inputs
|
|
34
|
+
from readyagents.workflow.state import RunState, load_decision_file, load_run, persist_run
|
|
35
|
+
|
|
36
|
+
log = get_logger("runner")
|
|
37
|
+
|
|
38
|
+
_APPROVE = {"approve", "approved", "yes", "true", "accept", "ok"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_workflow(path: Path | str) -> WorkflowSpec:
|
|
42
|
+
file = Path(path)
|
|
43
|
+
if not file.is_file():
|
|
44
|
+
raise ConfigError(f"Workflow file not found: {file}")
|
|
45
|
+
text = file.read_text(encoding="utf-8")
|
|
46
|
+
try:
|
|
47
|
+
if file.suffix.lower() in {".json"}:
|
|
48
|
+
data = json.loads(text)
|
|
49
|
+
else:
|
|
50
|
+
data = yaml.safe_load(text)
|
|
51
|
+
except (json.JSONDecodeError, yaml.YAMLError) as exc:
|
|
52
|
+
raise WorkflowError(f"Could not parse {file}: {exc}") from exc
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
raise WorkflowError(f"Workflow {file} must be a mapping")
|
|
55
|
+
try:
|
|
56
|
+
return WorkflowSpec.model_validate(data)
|
|
57
|
+
except ValidationError as exc:
|
|
58
|
+
raise WorkflowError(_format_validation(file, exc)) from exc
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _format_validation(path: Path, exc: ValidationError) -> str:
|
|
62
|
+
lines = [f"Invalid workflow {path}:"]
|
|
63
|
+
for err in exc.errors():
|
|
64
|
+
loc = ".".join(str(p) for p in err.get("loc", ()))
|
|
65
|
+
lines.append(f" - {loc}: {err.get('msg')}")
|
|
66
|
+
return "\n".join(lines)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def confine_under(raw: str | Path, root: Path, *, what: str) -> Path:
|
|
70
|
+
"""Resolve `raw` and refuse anything outside `root` (symlink-aware)."""
|
|
71
|
+
root = Path(root).resolve()
|
|
72
|
+
text = str(raw).strip()
|
|
73
|
+
if not text or "\x00" in text:
|
|
74
|
+
raise ConfigError(f"{what} must be a path under {root}")
|
|
75
|
+
candidate = Path(text)
|
|
76
|
+
if not candidate.is_absolute():
|
|
77
|
+
candidate = root / candidate
|
|
78
|
+
resolved = candidate.resolve()
|
|
79
|
+
if not resolved.is_relative_to(root):
|
|
80
|
+
raise ConfigError(
|
|
81
|
+
f"{what} is outside the workspace: {raw} "
|
|
82
|
+
f"(resolved to {resolved}, must stay under {root})"
|
|
83
|
+
)
|
|
84
|
+
return resolved
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def merge_inputs(workflow: WorkflowSpec, overrides: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
88
|
+
merged = dict(workflow.input_defaults())
|
|
89
|
+
if overrides:
|
|
90
|
+
merged.update(overrides)
|
|
91
|
+
validate_required_inputs(workflow, merged)
|
|
92
|
+
return merged
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run_workflow_file(
|
|
96
|
+
path: Path | str,
|
|
97
|
+
*,
|
|
98
|
+
inputs: Mapping[str, Any] | None = None,
|
|
99
|
+
dry_run: bool = False,
|
|
100
|
+
settings: Settings | None = None,
|
|
101
|
+
llm: LLMProvider | None = None,
|
|
102
|
+
persist: bool = True,
|
|
103
|
+
extra_tools: ToolRegistry | None = None,
|
|
104
|
+
extra_packs: Sequence[Any] | None = None,
|
|
105
|
+
decisions: Mapping[str, str] | None = None,
|
|
106
|
+
resume_state: RunState | None = None,
|
|
107
|
+
actor: str | None = None,
|
|
108
|
+
authorizer: Any | None = None,
|
|
109
|
+
secrets: Any | None = None,
|
|
110
|
+
decision_file: Path | str | None = None,
|
|
111
|
+
on_pause: Any | None = None,
|
|
112
|
+
no_cache: bool = False,
|
|
113
|
+
run_id: str | None = None,
|
|
114
|
+
initial_state: RunState | None = None,
|
|
115
|
+
cancellation: CancellationToken | None = None,
|
|
116
|
+
) -> RunState:
|
|
117
|
+
settings = settings or get_settings()
|
|
118
|
+
workflow = load_workflow(path)
|
|
119
|
+
if initial_state is not None and resume_state is not None:
|
|
120
|
+
raise WorkflowError("initial_state and resume_state are mutually exclusive")
|
|
121
|
+
merged_decisions: dict[str, str] = {}
|
|
122
|
+
if decision_file:
|
|
123
|
+
merged_decisions.update(load_decision_file(decision_file))
|
|
124
|
+
if decisions:
|
|
125
|
+
merged_decisions.update(
|
|
126
|
+
{str(k): str(v).strip().lower() for k, v in dict(decisions).items()}
|
|
127
|
+
)
|
|
128
|
+
if resume_state is not None:
|
|
129
|
+
merged = dict(resume_state.inputs)
|
|
130
|
+
if inputs:
|
|
131
|
+
merged.update(inputs)
|
|
132
|
+
validate_required_inputs(workflow, merged)
|
|
133
|
+
elif initial_state is not None:
|
|
134
|
+
overrides = dict(initial_state.inputs)
|
|
135
|
+
if inputs:
|
|
136
|
+
overrides.update(inputs)
|
|
137
|
+
merged = merge_inputs(workflow, overrides)
|
|
138
|
+
else:
|
|
139
|
+
merged = merge_inputs(workflow, inputs)
|
|
140
|
+
|
|
141
|
+
source_path = Path(path).resolve()
|
|
142
|
+
workflow_dir = source_path.parent
|
|
143
|
+
if settings.workspace is not None:
|
|
144
|
+
root = settings.workspace_path()
|
|
145
|
+
else:
|
|
146
|
+
root = workflow_dir
|
|
147
|
+
declared = (workflow.workspace or "").strip()
|
|
148
|
+
workspace = confine_under(declared, root, what="workspace") if declared else root
|
|
149
|
+
allow_http = bool(workflow.allow_http or settings.allow_http)
|
|
150
|
+
|
|
151
|
+
tools = default_registry(allow_http=allow_http, workspace=workspace)
|
|
152
|
+
packs = list(discover_packs())
|
|
153
|
+
if extra_packs:
|
|
154
|
+
packs.extend(list(extra_packs))
|
|
155
|
+
tools.merge(collect_pack_tools(packs))
|
|
156
|
+
if extra_tools:
|
|
157
|
+
tools.merge(extra_tools)
|
|
158
|
+
|
|
159
|
+
pack_secrets = list(collect_pack_secrets(packs))
|
|
160
|
+
if secrets is not None:
|
|
161
|
+
from readyagents.secrets import as_backends
|
|
162
|
+
|
|
163
|
+
pack_secrets = as_backends(secrets) + pack_secrets
|
|
164
|
+
pack_authorizers = list(collect_pack_authorizers(packs))
|
|
165
|
+
if authorizer is not None:
|
|
166
|
+
pack_authorizers = [authorizer, *pack_authorizers]
|
|
167
|
+
resolved_authorizer = resolve_authorizer(pack_authorizers)
|
|
168
|
+
resolved_actor = actor if actor is not None else settings.actor
|
|
169
|
+
action = "resume" if resume_state is not None else "run"
|
|
170
|
+
resource = resume_state.run_id if resume_state is not None else workflow.name
|
|
171
|
+
resolved_authorizer.check(resolved_actor, action, resource)
|
|
172
|
+
for node_id, decision in merged_decisions.items():
|
|
173
|
+
gate = "approve" if str(decision).strip().lower() in _APPROVE else "reject"
|
|
174
|
+
resolved_authorizer.check(resolved_actor, gate, node_id)
|
|
175
|
+
|
|
176
|
+
redact_on = bool(settings.redact if workflow.redact is None else workflow.redact)
|
|
177
|
+
redactor = redactor_from_settings(
|
|
178
|
+
enabled=redact_on,
|
|
179
|
+
patterns=settings.redact_pattern_list(),
|
|
180
|
+
literals=settings.redact_literal_list(),
|
|
181
|
+
)
|
|
182
|
+
if redactor is not None:
|
|
183
|
+
configure_logging(settings.log_level, fmt=settings.log_format, redactor=redactor)
|
|
184
|
+
|
|
185
|
+
mcp = None
|
|
186
|
+
if workflow.mcp_servers and not dry_run:
|
|
187
|
+
from readyagents.mcp.client import MCPClient
|
|
188
|
+
|
|
189
|
+
mcp = MCPClient(workflow.mcp_servers, workspace)
|
|
190
|
+
tools.merge(mcp.tools())
|
|
191
|
+
|
|
192
|
+
runs_dir = settings.runs_dir()
|
|
193
|
+
auditor = None
|
|
194
|
+
if persist:
|
|
195
|
+
auditor = make_auditor(audit_dir_for(settings.home_path()), redactor=redactor)
|
|
196
|
+
|
|
197
|
+
def _save(state: RunState) -> None:
|
|
198
|
+
persist_run(state, runs_dir, redactor=redactor)
|
|
199
|
+
|
|
200
|
+
budget = workflow.budget
|
|
201
|
+
if budget and budget.max_tokens is not None:
|
|
202
|
+
budget_tokens = budget.max_tokens
|
|
203
|
+
else:
|
|
204
|
+
budget_tokens = settings.max_tokens
|
|
205
|
+
budget_cost = (
|
|
206
|
+
usd_to_micros(budget.max_cost_usd)
|
|
207
|
+
if budget and budget.max_cost_usd is not None
|
|
208
|
+
else usd_to_micros(settings.max_cost_usd)
|
|
209
|
+
)
|
|
210
|
+
circuit_spec = workflow.circuit
|
|
211
|
+
breaker = CircuitBreaker(
|
|
212
|
+
failure_threshold=(
|
|
213
|
+
circuit_spec.failure_threshold if circuit_spec else settings.circuit_failure_threshold
|
|
214
|
+
),
|
|
215
|
+
cooldown_seconds=(
|
|
216
|
+
circuit_spec.cooldown_seconds if circuit_spec else settings.circuit_cooldown_seconds
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
cache_enabled = bool(settings.llm_cache if workflow.cache_llm is None else workflow.cache_llm)
|
|
220
|
+
if no_cache:
|
|
221
|
+
cache_enabled = False
|
|
222
|
+
llm_cache = LLMCache(settings.cache_dir()) if cache_enabled else None
|
|
223
|
+
fallback = list(workflow.fallback_models or []) + settings.fallback_model_list()
|
|
224
|
+
pause_url = workflow.on_pause_url or settings.pause_notify_url
|
|
225
|
+
|
|
226
|
+
def _pause(exc: Any, state: RunState) -> None:
|
|
227
|
+
if on_pause is not None:
|
|
228
|
+
on_pause(exc, state)
|
|
229
|
+
if pause_url:
|
|
230
|
+
payload = {
|
|
231
|
+
"event": "approval_required",
|
|
232
|
+
"run_id": state.run_id,
|
|
233
|
+
"node_id": getattr(exc, "node_id", state.pending_node),
|
|
234
|
+
"prompt": getattr(exc, "prompt", ""),
|
|
235
|
+
"resume": (
|
|
236
|
+
f"readyagents resume {state.run_id} "
|
|
237
|
+
f"--approve {getattr(exc, 'node_id', state.pending_node)}"
|
|
238
|
+
),
|
|
239
|
+
}
|
|
240
|
+
try:
|
|
241
|
+
post_json(pause_url, payload)
|
|
242
|
+
except Exception as notify_exc: # noqa: BLE001
|
|
243
|
+
log.warning("pause webhook failed: %s", notify_exc)
|
|
244
|
+
|
|
245
|
+
ctx = ExecutionContext(
|
|
246
|
+
workflow,
|
|
247
|
+
tools,
|
|
248
|
+
dry_run=dry_run,
|
|
249
|
+
llm=llm,
|
|
250
|
+
default_model=workflow.default_model or settings.default_model,
|
|
251
|
+
extra_handlers=collect_pack_nodes(packs),
|
|
252
|
+
decisions=merged_decisions,
|
|
253
|
+
on_persist=_save if persist else None,
|
|
254
|
+
workflow_dir=source_path.parent,
|
|
255
|
+
circuit_breaker=breaker,
|
|
256
|
+
llm_cache=llm_cache,
|
|
257
|
+
budget_tokens=budget_tokens,
|
|
258
|
+
budget_cost_micros=budget_cost,
|
|
259
|
+
secrets=pack_secrets or None,
|
|
260
|
+
authorizer=resolved_authorizer,
|
|
261
|
+
actor=resolved_actor,
|
|
262
|
+
redactor=redactor,
|
|
263
|
+
auditor=auditor,
|
|
264
|
+
on_pause=_pause if (on_pause is not None or pause_url) else None,
|
|
265
|
+
fallback_models=fallback,
|
|
266
|
+
cache_llm=cache_enabled,
|
|
267
|
+
usage_state=resume_state,
|
|
268
|
+
cancellation=cancellation,
|
|
269
|
+
)
|
|
270
|
+
metadata = {
|
|
271
|
+
"source": str(source_path),
|
|
272
|
+
"allow_http": allow_http,
|
|
273
|
+
"dry_run": dry_run,
|
|
274
|
+
"workspace": str(workspace),
|
|
275
|
+
"actor": resolved_actor,
|
|
276
|
+
}
|
|
277
|
+
try:
|
|
278
|
+
state = run_workflow(
|
|
279
|
+
workflow,
|
|
280
|
+
merged,
|
|
281
|
+
ctx,
|
|
282
|
+
metadata=metadata,
|
|
283
|
+
state=resume_state if resume_state is not None else initial_state,
|
|
284
|
+
run_id=run_id,
|
|
285
|
+
)
|
|
286
|
+
finally:
|
|
287
|
+
if mcp is not None:
|
|
288
|
+
mcp.close()
|
|
289
|
+
return state
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def resume_run(
|
|
293
|
+
run_id: str,
|
|
294
|
+
*,
|
|
295
|
+
settings: Settings | None = None,
|
|
296
|
+
path: Path | str | None = None,
|
|
297
|
+
inputs: Mapping[str, Any] | None = None,
|
|
298
|
+
dry_run: bool = False,
|
|
299
|
+
persist: bool = True,
|
|
300
|
+
extra_tools: ToolRegistry | None = None,
|
|
301
|
+
extra_packs: Sequence[Any] | None = None,
|
|
302
|
+
decisions: Mapping[str, str] | None = None,
|
|
303
|
+
llm: LLMProvider | None = None,
|
|
304
|
+
actor: str | None = None,
|
|
305
|
+
authorizer: Any | None = None,
|
|
306
|
+
secrets: Any | None = None,
|
|
307
|
+
decision_file: Path | str | None = None,
|
|
308
|
+
on_pause: Any | None = None,
|
|
309
|
+
no_cache: bool = False,
|
|
310
|
+
cancellation: CancellationToken | None = None,
|
|
311
|
+
) -> RunState:
|
|
312
|
+
settings = settings or get_settings()
|
|
313
|
+
state = load_run(settings.runs_dir(), run_id)
|
|
314
|
+
source = path or state.metadata.get("source")
|
|
315
|
+
if not source:
|
|
316
|
+
raise ConfigError(f"Run {state.run_id} has no stored workflow path. Pass --workflow PATH.")
|
|
317
|
+
return run_workflow_file(
|
|
318
|
+
source,
|
|
319
|
+
inputs=inputs,
|
|
320
|
+
dry_run=dry_run,
|
|
321
|
+
settings=settings,
|
|
322
|
+
llm=llm,
|
|
323
|
+
persist=persist,
|
|
324
|
+
extra_tools=extra_tools,
|
|
325
|
+
extra_packs=extra_packs,
|
|
326
|
+
decisions=decisions,
|
|
327
|
+
resume_state=state,
|
|
328
|
+
actor=actor,
|
|
329
|
+
authorizer=authorizer,
|
|
330
|
+
secrets=secrets,
|
|
331
|
+
decision_file=decision_file,
|
|
332
|
+
on_pause=on_pause,
|
|
333
|
+
no_cache=no_cache,
|
|
334
|
+
cancellation=cancellation,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def replay_run(
|
|
339
|
+
run_id: str,
|
|
340
|
+
*,
|
|
341
|
+
settings: Settings | None = None,
|
|
342
|
+
persist: bool = True,
|
|
343
|
+
dry_run: bool = False,
|
|
344
|
+
extra_tools: ToolRegistry | None = None,
|
|
345
|
+
extra_packs: Sequence[Any] | None = None,
|
|
346
|
+
decisions: Mapping[str, str] | None = None,
|
|
347
|
+
llm: LLMProvider | None = None,
|
|
348
|
+
actor: str | None = None,
|
|
349
|
+
authorizer: Any | None = None,
|
|
350
|
+
secrets: Any | None = None,
|
|
351
|
+
decision_file: Path | str | None = None,
|
|
352
|
+
no_cache: bool = False,
|
|
353
|
+
) -> RunState:
|
|
354
|
+
"""Start a new run with the stored workflow path and inputs."""
|
|
355
|
+
settings = settings or get_settings()
|
|
356
|
+
previous = load_run(settings.runs_dir(), run_id)
|
|
357
|
+
source = previous.metadata.get("source")
|
|
358
|
+
if not source:
|
|
359
|
+
raise ConfigError(f"Run {previous.run_id} has no stored workflow path. Cannot replay.")
|
|
360
|
+
return run_workflow_file(
|
|
361
|
+
source,
|
|
362
|
+
inputs=previous.inputs,
|
|
363
|
+
dry_run=dry_run,
|
|
364
|
+
settings=settings,
|
|
365
|
+
llm=llm,
|
|
366
|
+
persist=persist,
|
|
367
|
+
extra_tools=extra_tools,
|
|
368
|
+
extra_packs=extra_packs,
|
|
369
|
+
decisions=decisions,
|
|
370
|
+
actor=actor,
|
|
371
|
+
authorizer=authorizer,
|
|
372
|
+
secrets=secrets,
|
|
373
|
+
decision_file=decision_file,
|
|
374
|
+
no_cache=no_cache,
|
|
375
|
+
)
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Pydantic models for YAML/JSON workflow definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
from readyagents.errors import WorkflowError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NodeType(StrEnum):
|
|
14
|
+
agent = "agent"
|
|
15
|
+
tool = "tool"
|
|
16
|
+
condition = "condition"
|
|
17
|
+
transform = "transform"
|
|
18
|
+
approval = "approval"
|
|
19
|
+
parallel = "parallel"
|
|
20
|
+
include = "include"
|
|
21
|
+
foreach = "foreach"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RetrySpec(BaseModel):
|
|
25
|
+
model_config = ConfigDict(extra="forbid")
|
|
26
|
+
|
|
27
|
+
max_attempts: int = Field(default=1, ge=1, le=20)
|
|
28
|
+
backoff_seconds: float = Field(default=1.0, ge=0)
|
|
29
|
+
backoff_multiplier: float = Field(default=2.0, ge=1.0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BudgetSpec(BaseModel):
|
|
33
|
+
model_config = ConfigDict(extra="forbid")
|
|
34
|
+
|
|
35
|
+
max_tokens: int | None = Field(default=None, ge=0)
|
|
36
|
+
max_cost_usd: float | None = Field(default=None, ge=0)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CircuitSpec(BaseModel):
|
|
40
|
+
model_config = ConfigDict(extra="forbid")
|
|
41
|
+
|
|
42
|
+
failure_threshold: int = Field(default=3, ge=1)
|
|
43
|
+
cooldown_seconds: float = Field(default=60.0, ge=0)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MCPServerSpec(BaseModel):
|
|
47
|
+
model_config = ConfigDict(extra="forbid")
|
|
48
|
+
|
|
49
|
+
command: str
|
|
50
|
+
args: list[str] = Field(default_factory=list)
|
|
51
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
52
|
+
cwd: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NodeSpec(BaseModel):
|
|
56
|
+
"""One node in the workflow graph."""
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
59
|
+
|
|
60
|
+
id: str = Field(min_length=1)
|
|
61
|
+
type: str
|
|
62
|
+
timeout_seconds: float | None = Field(default=None, gt=0)
|
|
63
|
+
retry: RetrySpec | None = None
|
|
64
|
+
next: str | None = None
|
|
65
|
+
output_key: str | None = None
|
|
66
|
+
description: str | None = None
|
|
67
|
+
|
|
68
|
+
# agent
|
|
69
|
+
prompt: str | None = None
|
|
70
|
+
system: str | None = None
|
|
71
|
+
model: str | None = None
|
|
72
|
+
|
|
73
|
+
# tool
|
|
74
|
+
tool: str | None = None
|
|
75
|
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
# condition
|
|
78
|
+
when: str | None = None
|
|
79
|
+
then: str | None = None
|
|
80
|
+
else_: str | None = Field(default=None, alias="else")
|
|
81
|
+
|
|
82
|
+
# transform
|
|
83
|
+
template: str | None = None
|
|
84
|
+
source: str | None = None
|
|
85
|
+
json_path: str | None = None
|
|
86
|
+
parse_json: bool = False
|
|
87
|
+
|
|
88
|
+
# parallel
|
|
89
|
+
branches: list[NodeSpec] = Field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
# include (sub-workflow)
|
|
92
|
+
path: str | None = None
|
|
93
|
+
call_inputs: dict[str, Any] = Field(default_factory=dict, alias="inputs")
|
|
94
|
+
|
|
95
|
+
# agent extras
|
|
96
|
+
fallback_models: list[str] = Field(default_factory=list)
|
|
97
|
+
output_schema: dict[str, Any] | None = None
|
|
98
|
+
cache: bool | None = None
|
|
99
|
+
tools: list[str] = Field(default_factory=list)
|
|
100
|
+
max_tool_rounds: int | None = Field(default=None, ge=1, le=20)
|
|
101
|
+
|
|
102
|
+
# foreach
|
|
103
|
+
items: str | None = None
|
|
104
|
+
max_items: int | None = Field(default=None, ge=1, le=100)
|
|
105
|
+
body: NodeSpec | None = None
|
|
106
|
+
|
|
107
|
+
@field_validator("id")
|
|
108
|
+
@classmethod
|
|
109
|
+
def _id_token(cls, value: str) -> str:
|
|
110
|
+
if not value.replace("_", "").replace("-", "").isalnum():
|
|
111
|
+
raise ValueError(f"Invalid node id '{value}' (use letters, numbers, _ or -)")
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
@field_validator("type")
|
|
115
|
+
@classmethod
|
|
116
|
+
def _type_token(cls, value: str) -> str:
|
|
117
|
+
cleaned = value.strip().lower()
|
|
118
|
+
if not cleaned.replace("_", "").replace("-", "").isalnum():
|
|
119
|
+
raise ValueError(f"Invalid node type '{value}'")
|
|
120
|
+
return cleaned
|
|
121
|
+
|
|
122
|
+
@field_validator("tools")
|
|
123
|
+
@classmethod
|
|
124
|
+
def _tools_tokens(cls, value: list[str]) -> list[str]:
|
|
125
|
+
cleaned: list[str] = []
|
|
126
|
+
seen: set[str] = set()
|
|
127
|
+
for raw in value:
|
|
128
|
+
name = str(raw).strip()
|
|
129
|
+
if not name:
|
|
130
|
+
raise ValueError("tool names must be non-empty")
|
|
131
|
+
token = name.replace("_", "").replace("-", "").replace(".", "")
|
|
132
|
+
if not token.isalnum():
|
|
133
|
+
raise ValueError(f"Invalid tool name '{name}' (use letters, numbers, _, -, .)")
|
|
134
|
+
if name in seen:
|
|
135
|
+
raise ValueError(f"Duplicate tool name '{name}'")
|
|
136
|
+
seen.add(name)
|
|
137
|
+
cleaned.append(name)
|
|
138
|
+
return cleaned
|
|
139
|
+
|
|
140
|
+
@model_validator(mode="after")
|
|
141
|
+
def _type_fields(self) -> NodeSpec:
|
|
142
|
+
t = self.type
|
|
143
|
+
if t != NodeType.agent.value and self.tools:
|
|
144
|
+
raise ValueError(f"Node '{self.id}': 'tools' is only valid on agent nodes")
|
|
145
|
+
if t == NodeType.agent.value and not self.prompt:
|
|
146
|
+
raise ValueError(f"Node '{self.id}': agent nodes require 'prompt'")
|
|
147
|
+
if t == NodeType.tool.value and not self.tool:
|
|
148
|
+
raise ValueError(f"Node '{self.id}': tool nodes require 'tool'")
|
|
149
|
+
if t == NodeType.condition.value:
|
|
150
|
+
if not self.when:
|
|
151
|
+
raise ValueError(f"Node '{self.id}': condition nodes require 'when'")
|
|
152
|
+
if not self.then and not self.else_:
|
|
153
|
+
raise ValueError(f"Node '{self.id}': condition nodes require 'then' and/or 'else'")
|
|
154
|
+
if t == NodeType.transform.value:
|
|
155
|
+
if self.template is None and not self.json_path and not self.parse_json:
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"Node '{self.id}': transform nodes require "
|
|
158
|
+
"'template', 'json_path', or parse_json"
|
|
159
|
+
)
|
|
160
|
+
if t == NodeType.approval.value:
|
|
161
|
+
if not self.prompt:
|
|
162
|
+
raise ValueError(f"Node '{self.id}': approval nodes require 'prompt'")
|
|
163
|
+
if not self.then and not self.else_ and not self.next:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"Node '{self.id}': approval nodes require 'then', 'else', or 'next'"
|
|
166
|
+
)
|
|
167
|
+
if t == NodeType.parallel.value and not self.branches:
|
|
168
|
+
raise ValueError(f"Node '{self.id}': parallel nodes require 'branches'")
|
|
169
|
+
if t == NodeType.include.value and not self.path:
|
|
170
|
+
raise ValueError(f"Node '{self.id}': include nodes require 'path'")
|
|
171
|
+
if t == NodeType.foreach.value:
|
|
172
|
+
if not self.items:
|
|
173
|
+
raise ValueError(f"Node '{self.id}': foreach nodes require 'items'")
|
|
174
|
+
if self.body is None:
|
|
175
|
+
raise ValueError(f"Node '{self.id}': foreach nodes require 'body'")
|
|
176
|
+
if self.body.type == NodeType.foreach.value:
|
|
177
|
+
raise ValueError(f"Node '{self.id}': nested foreach is not supported")
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class EdgeSpec(BaseModel):
|
|
182
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
183
|
+
|
|
184
|
+
from_: str = Field(alias="from")
|
|
185
|
+
to: str
|
|
186
|
+
when: str | None = None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class WorkflowSpec(BaseModel):
|
|
190
|
+
"""A validated workflow document."""
|
|
191
|
+
|
|
192
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
193
|
+
|
|
194
|
+
name: str
|
|
195
|
+
version: str = "1"
|
|
196
|
+
description: str | None = None
|
|
197
|
+
inputs: dict[str, Any] = Field(default_factory=dict)
|
|
198
|
+
required_inputs: list[str] = Field(default_factory=list)
|
|
199
|
+
start: str | None = None
|
|
200
|
+
nodes: list[NodeSpec]
|
|
201
|
+
edges: list[EdgeSpec] = Field(default_factory=list)
|
|
202
|
+
mcp_servers: dict[str, MCPServerSpec] = Field(default_factory=dict)
|
|
203
|
+
allow_http: bool = False
|
|
204
|
+
workspace: str | None = None
|
|
205
|
+
default_model: str | None = None
|
|
206
|
+
budget: BudgetSpec | None = None
|
|
207
|
+
fallback_models: list[str] = Field(default_factory=list)
|
|
208
|
+
circuit: CircuitSpec | None = None
|
|
209
|
+
on_pause_url: str | None = None
|
|
210
|
+
cache_llm: bool | None = None
|
|
211
|
+
redact: bool | None = None
|
|
212
|
+
|
|
213
|
+
@model_validator(mode="after")
|
|
214
|
+
def _graph(self) -> WorkflowSpec:
|
|
215
|
+
if not self.nodes:
|
|
216
|
+
raise ValueError("Workflow must declare at least one node")
|
|
217
|
+
ids = [n.id for n in self.nodes]
|
|
218
|
+
if len(ids) != len(set(ids)):
|
|
219
|
+
raise ValueError("Duplicate node ids")
|
|
220
|
+
known = set(ids)
|
|
221
|
+
start = self.start or self.nodes[0].id
|
|
222
|
+
if start not in known:
|
|
223
|
+
raise ValueError(f"start node '{start}' does not exist")
|
|
224
|
+
self.start = start
|
|
225
|
+
for node in self.nodes:
|
|
226
|
+
for ref in (node.next, node.then, node.else_):
|
|
227
|
+
if ref is not None and ref not in known:
|
|
228
|
+
raise ValueError(f"Node '{node.id}' references unknown node '{ref}'")
|
|
229
|
+
if node.branches:
|
|
230
|
+
branch_ids = [b.id for b in node.branches]
|
|
231
|
+
if len(branch_ids) != len(set(branch_ids)):
|
|
232
|
+
raise ValueError(f"Node '{node.id}': duplicate parallel branch ids")
|
|
233
|
+
for edge in self.edges:
|
|
234
|
+
if edge.from_ not in known:
|
|
235
|
+
raise ValueError(f"Edge from unknown node '{edge.from_}'")
|
|
236
|
+
if edge.to not in known:
|
|
237
|
+
raise ValueError(f"Edge to unknown node '{edge.to}'")
|
|
238
|
+
self._assert_acyclic()
|
|
239
|
+
return self
|
|
240
|
+
|
|
241
|
+
def _assert_acyclic(self) -> None:
|
|
242
|
+
graph: dict[str, list[str]] = {node.id: [] for node in self.nodes}
|
|
243
|
+
for node in self.nodes:
|
|
244
|
+
for ref in (node.next, node.then, node.else_):
|
|
245
|
+
if ref is not None:
|
|
246
|
+
graph[node.id].append(ref)
|
|
247
|
+
for edge in self.edges:
|
|
248
|
+
graph[edge.from_].append(edge.to)
|
|
249
|
+
|
|
250
|
+
visiting: set[str] = set()
|
|
251
|
+
done: set[str] = set()
|
|
252
|
+
|
|
253
|
+
def dfs(nid: str) -> None:
|
|
254
|
+
visiting.add(nid)
|
|
255
|
+
for nxt in graph[nid]:
|
|
256
|
+
if nxt in visiting:
|
|
257
|
+
raise ValueError(f"Cycle detected at node '{nxt}'")
|
|
258
|
+
if nxt not in done and nxt in graph:
|
|
259
|
+
dfs(nxt)
|
|
260
|
+
visiting.remove(nid)
|
|
261
|
+
done.add(nid)
|
|
262
|
+
|
|
263
|
+
for nid in graph:
|
|
264
|
+
if nid not in done:
|
|
265
|
+
dfs(nid)
|
|
266
|
+
|
|
267
|
+
def node_map(self) -> dict[str, NodeSpec]:
|
|
268
|
+
return {n.id: n for n in self.nodes}
|
|
269
|
+
|
|
270
|
+
def input_defaults(self) -> dict[str, Any]:
|
|
271
|
+
defaults: dict[str, Any] = {}
|
|
272
|
+
for key, raw in self.inputs.items():
|
|
273
|
+
if isinstance(raw, dict) and "default" in raw:
|
|
274
|
+
defaults[key] = raw["default"]
|
|
275
|
+
else:
|
|
276
|
+
defaults[key] = raw
|
|
277
|
+
return defaults
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def validate_required_inputs(workflow: WorkflowSpec, provided: dict[str, Any]) -> None:
|
|
281
|
+
missing = [name for name in workflow.required_inputs if name not in provided]
|
|
282
|
+
if missing:
|
|
283
|
+
example = " ".join(f"--input {name}=..." for name in missing)
|
|
284
|
+
raise WorkflowError(f"Missing required inputs: {', '.join(missing)}. Pass {example}.")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
NodeSpec.model_rebuild()
|