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