diff --git a/CHANGELOG.md b/CHANGELOG.md index f48037f5..5e33311f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 without replacing tool-based output, weakening schema validation, or changing authored system prompts. +### Changed + +- **Step definitions split into concrete models with a discriminated union** + (#517) — the single monolithic `AgentDef` that carried every step type's + fields is now a set of focused Pydantic models: `AgentDef` (provider-backed + LLM agents only), `HumanGateStepDef`, `QuestionsStepDef`, `ScriptStepDef`, + `MCPStepDef`, `WaitStepDef`, `SetStepDef`, `TerminateStepDef`, and + `WorkflowStepDef`, united by the static `StepDef` union discriminated on + `type`. Every model owns exactly the fields meaningful for its kind with + `extra="forbid"`, so a misplaced field is rejected next to its step instead + of being silently ignored, and the published JSON Schema now exposes a + `oneOf` + `discriminator` mapping with per-variant + `additionalProperties: false` for editors and tooling. Compatibility: + workflow YAML is unchanged — an omitted `type` or an explicit `type: null` + still loads as an LLM agent (now canonicalized to `type: "agent"` at parse + time) — and `AgentDef(...)` keeps working for LLM agents, including + `AgentDef(type=None, ...)`. What changed: constructing a non-LLM step + programmatically via `AgentDef(type="script", ...)` no longer works — use + the named step class (e.g. `ScriptStepDef(...)`) — and a field that belongs + to a different step type now fails with Pydantic's standard + `extra_forbidden` error rather than the previous custom + `" agents cannot have ''"` messages. Human gates additionally + lost the inert `model` field and can no longer appear as a `for_each` + inline agent (concurrent iterations would compete for one interactive + channel; route to a gate from the group's `routes:` instead). All classes + are exported from `conductor.config` and `conductor.config.schema`. + ## [0.1.37](https://github.com/microsoft/conductor/compare/v0.1.36...v0.1.37) - 2026-09-09 ### Added diff --git a/src/conductor/cli/app.py b/src/conductor/cli/app.py index dd82fc31..aef6882a 100644 --- a/src/conductor/cli/app.py +++ b/src/conductor/cli/app.py @@ -968,6 +968,7 @@ def show( try: from conductor.config.loader import load_config as load_workflow_config + from conductor.config.schema import RoutableStepBase config = load_workflow_config(workflow_path) except Exception as e: @@ -1015,9 +1016,12 @@ def show( agent_table.add_column("Routes") for agent in config.agents: - agent_type = agent.type or "agent" - routes = ", ".join(r.to + (f" (when {r.when})" if r.when else "") for r in agent.routes) - agent_table.add_row(agent.name, agent_type, agent.description or "-", routes or "-") + agent_routes = ( + ", ".join(r.to + (f" (when {r.when})" if r.when else "") for r in agent.routes) + if isinstance(agent, RoutableStepBase) + else "" + ) + agent_table.add_row(agent.name, agent.type, agent.description or "-", agent_routes or "-") # Include parallel groups for pg in config.parallel: diff --git a/src/conductor/cli/plugin.py b/src/conductor/cli/plugin.py index 0214f72f..9f88721a 100644 --- a/src/conductor/cli/plugin.py +++ b/src/conductor/cli/plugin.py @@ -228,6 +228,7 @@ def _list_enabled_plugins(config, workflow_path: Path, sources) -> None: # noqa different builds, so they are reported — and their component counts computed — separately rather than one silently standing in for both. """ + from conductor.config.schema import AgentDef, StepDef from conductor.plugins.errors import PluginError from conductor.plugins.manifest import PluginFlavor from conductor.plugins.registry import resolve_plugins @@ -246,8 +247,8 @@ def _list_enabled_plugins(config, workflow_path: Path, sources) -> None: # noqa groups: dict[tuple[tuple[tuple[str, bool, bool, bool], ...], PluginFlavor | None], list[str]] groups = {} - def _record(name: str, agent) -> None: # noqa: ANN001 - if agent.type not in (None, "agent"): + def _record(name: str, agent: StepDef) -> None: + if not isinstance(agent, AgentDef): return entries = agent.plugins if agent.plugins is not None else config.workflow.runtime.plugins if not entries: diff --git a/src/conductor/cli/run.py b/src/conductor/cli/run.py index b8eeaacf..daf28505 100644 --- a/src/conductor/cli/run.py +++ b/src/conductor/cli/run.py @@ -25,6 +25,7 @@ from rich.text import Text from conductor.config.loader import load_config +from conductor.config.schema import AgentDef from conductor.console import MarkupFreeConsole, join, make_console, styled from conductor.engine.workflow import ExecutionPlan, WorkflowEngine from conductor.exceptions import WorkflowTerminated @@ -2409,7 +2410,9 @@ async def run_workflow_async( plugin_marketplaces = await _prefetch_plugin_sources(config, workflow_path) # Check if workflow uses multiple providers (has per-agent provider overrides) - uses_multi_provider = any(agent.provider is not None for agent in config.agents) + uses_multi_provider = any( + isinstance(agent, AgentDef) and agent.provider is not None for agent in config.agents + ) if uses_multi_provider: verbose_log("Multi-provider mode: agents use different providers", style="cyan") @@ -2801,7 +2804,6 @@ def _find_agent(self, name: str) -> Any: return next((a for a in self.config.agents if a.name == name), None) # Use a real WorkflowEngine but with a mock provider - from conductor.config.schema import AgentDef from conductor.providers.base import AgentOutput, AgentProvider class _MockProvider(AgentProvider, abstract=True): diff --git a/src/conductor/cli/validate.py b/src/conductor/cli/validate.py index b0d6033a..a074af03 100644 --- a/src/conductor/cli/validate.py +++ b/src/conductor/cli/validate.py @@ -408,7 +408,9 @@ def display_validation_success( for_each_group_count = len(config.for_each) # Count conditional routes - conditional_route_count = sum(1 for a in config.agents for r in a.routes if r.when) + conditional_route_count = sum( + 1 for a in config.agents for r in getattr(a, "routes", []) if r.when + ) # Determine workflow patterns patterns = [] @@ -419,7 +421,7 @@ def display_validation_success( agent_names = [a.name for a in config.agents] has_loop = False for i, agent in enumerate(config.agents): - for route in agent.routes: + for route in getattr(agent, "routes", []): if route.to in agent_names: target_idx = agent_names.index(route.to) if target_idx <= i: @@ -483,13 +485,14 @@ def display_validation_success( for agent in config.agents: agent_type = agent.type or "agent" model = ( - agent.model + getattr(agent, "model", None) or config.workflow.runtime.default_model or Text.from_markup("[dim]default[/dim]") ) - if agent.routes: - route_targets = [r.to for r in agent.routes] + routes = getattr(agent, "routes", []) + if routes: + route_targets = [r.to for r in routes] routes_str = ", ".join(route_targets[:3]) if len(route_targets) > 3: routes_str += f" (+{len(route_targets) - 3} more)" diff --git a/src/conductor/config/__init__.py b/src/conductor/config/__init__.py index c9fd17dd..27798ade 100644 --- a/src/conductor/config/__init__.py +++ b/src/conductor/config/__init__.py @@ -16,14 +16,25 @@ ContextConfig, DialogConfig, GateOption, + HumanGateStepDef, InputDef, LimitsConfig, + MCPStepDef, OutputField, + QuestionsStepDef, + RoutableStepBase, RouteDef, RuntimeConfig, + ScriptStepDef, + SetStepDef, + StepBase, + StepDef, + TerminateStepDef, ValidatorConfig, + WaitStepDef, WorkflowConfig, WorkflowDef, + WorkflowStepDef, ) from conductor.config.validator import validate_workflow_config @@ -40,13 +51,24 @@ "DialogConfig", "GateOption", "InputDef", + "HumanGateStepDef", "LimitsConfig", "OutputField", + "MCPStepDef", + "QuestionsStepDef", "RouteDef", "RuntimeConfig", + "RoutableStepBase", + "ScriptStepDef", + "SetStepDef", + "StepBase", + "StepDef", + "TerminateStepDef", "ValidatorConfig", "WorkflowConfig", "WorkflowDef", + "WorkflowStepDef", + "WaitStepDef", # Validator "validate_workflow_config", ] diff --git a/src/conductor/config/schema.py b/src/conductor/config/schema.py index 7f3d9254..266efe92 100644 --- a/src/conductor/config/schema.py +++ b/src/conductor/config/schema.py @@ -13,6 +13,7 @@ import regex from pydantic import ( BaseModel, + BeforeValidator, ConfigDict, Field, SecretStr, @@ -343,110 +344,6 @@ def validate_dotted_source(v: str) -> str: return v -class ForEachDef(BaseModel): - """Definition for a dynamic parallel (for-each) agent group. - - For-each groups spawn N parallel agent instances at runtime based on - an array resolved from workflow context (e.g., a previous agent's output). - - Example: - ```yaml - for_each: - - name: analyzers - type: for_each - source: finder.output.kpis - as: kpi - max_concurrent: 5 - agent: - model: opus-4.5 - prompt: "Analyze {{ kpi.kpi_id }}" - output: - success: { type: boolean } - ``` - """ - - model_config = ConfigDict(extra="forbid") - - name: str - """Unique identifier for this for-each group.""" - - description: str | None = None - """Human-readable description.""" - - type: Literal["for_each"] - """Discriminator for union types in routing.""" - - source: str - """Reference to array in context (e.g., 'finder.output.kpis'). - Must resolve to a list at runtime. Uses dotted path notation.""" - - as_: str = Field(..., serialization_alias="as", validation_alias="as") - """Loop variable name (e.g., 'kpi'). - Accessible in templates as {{ kpi }}. - Note: Uses as_ internally to avoid Python keyword conflict. - Pydantic aliases ensure YAML uses 'as' while Python uses 'as_'.""" - - agent: AgentDef - """Inline agent definition used as template for each item. - Each instance gets a copy with loop variables injected into context.""" - - max_concurrent: int = 10 - """Maximum number of concurrent executions per batch. - Items are processed in sequential batches of this size. - Default: 10 (prevents unbounded parallelism).""" - - failure_mode: Literal["fail_fast", "continue_on_error", "all_or_nothing"] = "fail_fast" - """Failure handling strategy: - - fail_fast: Stop on first error, raise immediately - - continue_on_error: Continue all items, fail only if ALL fail - - all_or_nothing: Continue all items, fail if ANY fail""" - - key_by: str | None = None - """Optional: Path to extract key from each item for dict-based outputs. - Example: 'kpi.kpi_id' → outputs becomes {kpi_id: {...}, ...} - instead of [{...}, ...]. Enables key-based access: outputs["KPI123"].""" - - routes: list[RouteDef] = Field(default_factory=list) - """Routing rules evaluated after for-each execution. - Routes have access to aggregated outputs via {{ analyzers.outputs }}.""" - - @field_validator("as_") - @classmethod - def validate_loop_variable(cls, v: str) -> str: - """Ensure loop variable doesn't conflict with reserved names. - - Reserved names: workflow, context, output, _index, _key - These are reserved for workflow internals. - """ - reserved = {"workflow", "context", "output", "_index", "_key"} - if v in reserved: - raise ValueError( - f"Loop variable '{v}' conflicts with reserved name. Reserved names: {reserved}" - ) - # Also validate it's a valid Python identifier - if not v.isidentifier(): - raise ValueError(f"Loop variable '{v}' must be a valid Python identifier") - return v - - @field_validator("source") - @classmethod - def validate_source_format(cls, v: str) -> str: - """Validate source reference format (agent_name.output.field).""" - return validate_dotted_source(v) - - @field_validator("max_concurrent") - @classmethod - def validate_max_concurrent(cls, v: int) -> int: - """Ensure max_concurrent is reasonable.""" - if v < 1: - raise ValueError("max_concurrent must be at least 1") - if v > 100: - raise ValueError( - "max_concurrent cannot exceed 100 (consider batching for larger arrays)" - ) - return v - - class GateOption(BaseModel): """Option presented in a human gate.""" @@ -1171,1622 +1068,519 @@ def _validate_skill_entries(entries: list[str]) -> list[str]: """ -class AgentDef(BaseModel): - """Definition for a single agent in the workflow. - - A single Pydantic model covers all step kinds. The ``type`` field - discriminates between them: - - - ``agent`` (default): LLM-backed agent. Requires ``prompt``; supports - ``model``, ``provider``, ``tools``, ``output``, ``reasoning``, ``retry``, - ``dialog``, and ``timeout_seconds``. - - ``human_gate``: Pause for user decision. Requires ``prompt`` and - ``options``. - - ``script``: Shell command step. Requires ``command``; supports - ``args``, ``env``, ``working_dir``, ``timeout``. Output is always - ``{stdout, stderr, exit_code}`` with parsed-JSON keys merged on top - when ``stdout`` is valid JSON. - - ``mcp``: Direct MCP tool call (no LLM). Requires ``server`` (a name - from ``runtime.mcp_servers``) and ``tool``; supports ``arguments``, - ``output``, ``routes``, and ``timeout``. Both ``server`` and ``tool`` - must be literal — Jinja2 templates are rejected at load time. - - ``workflow``: Sub-workflow black-box step. Requires ``workflow:`` - (path or registry reference); supports ``input_mapping`` and - ``max_depth``. - - ``terminate``: Explicit terminal step. Requires ``status`` (``success`` - | ``failed``) and ``reason``; supports optional ``output_template``. - Reaching one ends the workflow immediately (no routes evaluated - after) and surfaces in the CLI exit code / dashboard / event log as - a distinct, intentional outcome — distinguishable from a generic - crash via ``is_explicit: true`` on the emitted lifecycle event. - - Per-type field forbidden-lists are enforced in - :meth:`validate_agent_type`. Cross-cutting structural rules (e.g., - terminate steps cannot appear as parallel-group members or as a - for_each inline agent) are enforced in - :func:`conductor.config.validator.validate_workflow_config`. - """ +class StepBase(BaseModel): + """Common identity and input fields for executable workflow steps.""" model_config = ConfigDict(extra="forbid") name: str - """Unique identifier for this agent.""" - description: str | None = None - """Human-readable description of agent's purpose.""" - - type: ( - Literal[ - "agent", - "human_gate", - "mcp", - "questions", - "script", - "set", - "terminate", - "wait", - "workflow", - ] - | None - ) = None - """Agent type. Defaults to 'agent' if not specified.""" - - provider: ProviderName | None = None - """Provider override for this agent. - - If None (default), the agent uses the workflow.runtime.provider. - When specified, this agent will use a different provider than - the workflow default, enabling multi-provider workflows. - - Example: - provider: claude # Use Claude for this agent - provider: hermes # Use Hermes Agent for this agent - """ - - model: str | None = None - """Model identifier. - - Examples: - - GitHub Copilot: 'claude-sonnet-4', 'gpt-4', etc. - - Claude (recommended default): 'claude-3-5-sonnet-latest' (stable, auto-updates) - - Claude 4.5 Series (newest): 'claude-sonnet-4-5-20250929' - - Claude 4 Series: 'claude-sonnet-4-20250514' - - Claude 3.7 Series: 'claude-3-7-sonnet-20250219' - - Claude 3.5 Series: 'claude-3-5-sonnet-20241022' - - Claude 3 Series (legacy): 'claude-3-opus-20240229', 'claude-3-sonnet-20240229', - 'claude-3-haiku-20240307' - - Supports environment variables: ${MODEL:-default_value} - Supports Jinja2 templates: {{ workflow.input.model_name }} - """ + input: list[str] = Field(default_factory=list) - context_tier: ContextTier | str | None = None - """Context-window tier for models that support it (Copilot provider only). - Set ``context_tier: long_context`` to pin a heavy-reasoning agent to the - model's long-context (e.g. 1M-token) window. ``default`` selects the - standard tier; ``None`` sends no value (provider default). +class RoutableStepBase(StepBase): + """Common fields for workflow steps that route after execution.""" - Falls back to ``runtime.default_context_tier`` when unset. Composes - independently with ``reasoning`` — an agent may set both. + routes: list[RouteDef] = Field(default_factory=list) - Only the Copilot provider forwards this today (maps to the SDK's - ``create_session`` ``context_tier`` param). Other providers ignore it. - Only applies to provider-backed agents (type='agent' or None). +def _normalize_step_type(value: Any) -> Any: + if isinstance(value, dict) and value.get("type") is None: + return {**value, "type": "agent"} + return value - Supports Jinja2 templates: a ``{{ workflow.input.tier }}`` value is - accepted at load time and resolved + validated at runtime (mirrors - ``model`` and the ``reasoning.effort`` handling). A *literal* value must - be one of :data:`~conductor.providers.context_tier.ContextTier`. - Example YAML:: - - context_tier: long_context +def _preserve_file_string(value: Any, handler: ValidatorFunctionWrapHandler) -> Any: + """Keep a ``FileString`` (``!file``-loaded prompt) uncoerced so the renderer + can resolve relative ``{% include %}`` paths against its source file.""" + if isinstance(value, FileString): + return value + return handler(value) + + +def _require_step_type_in_schema(schema: dict[str, Any]) -> None: + """Tighten the published JSON Schema so non-LLM steps require an explicit ``type``. + + Runtime parsing supplies each variant's discriminator default itself (and + routes untagged mappings to ``AgentDef`` via the union's before-validator), + but a generated ``oneOf`` schema has no such normalization: with every + variant's ``type`` defaulted, an untagged agent mapping matches several + branches at once and ``oneOf`` rejects it. Requiring the discriminator on + every non-LLM branch restores exactly-one-match for those payloads. This is + a schema-only tightening — runtime types and constructor defaults are + unchanged, so programmatic construction without an explicit ``type`` keeps + working. Applied at class level, the tightening also shapes each variant's + standalone and serialization schemas; that is deliberate, since a dumped + instance always carries its canonical ``type``. + """ + required = schema.setdefault("required", []) + if "type" not in required: + required.append("type") + + +def _agent_step_type_in_schema(schema: dict[str, Any]) -> None: + """Widen the LLM branch's ``type`` to the three forms the loader accepts. + + ``_normalize_step_type`` maps an omitted or explicit ``null`` ``type`` to + ``"agent"`` at runtime; the published schema must admit the same inputs or + schema-aware tooling (editors, external linters) rejects workflows that + Conductor runs without complaint. + """ + properties = schema.get("properties") + if isinstance(properties, dict) and "type" in properties: + properties["type"] = { + "anyOf": [{"const": "agent"}, {"type": "null"}], + "default": "agent", + "title": "Type", + } - Templated:: - context_tier: "{{ workflow.input.tier }}" - """ +class AgentDef(RoutableStepBase): + """Provider-backed LLM agent definition.""" - input: list[str] = Field(default_factory=list) - """Context dependencies. Format: 'agent_name.output' or 'workflow.input.param'. - Suffix with '?' for optional dependencies.""" + model_config = ConfigDict(extra="forbid", json_schema_extra=_agent_step_type_in_schema) + type: Literal["agent"] = "agent" + provider: ProviderName | None = None + model: str | None = None + context_tier: ContextTier | str | None = None tools: list[str] | None = None - """Tools available to this agent. None = all, [] = none.""" - system_prompt: str | None = None - """System message for the agent (always included).""" - prompt: str = "" - """User prompt template (Jinja2).""" - output: dict[str, OutputField] | None = None - """Expected output schema for validation.""" - output_mode: Literal["raw", "envelope"] | None = None - """Controls how the provider handles this agent's response. - - - ``raw``: The provider skips schema instruction injection and JSON - extraction entirely. The model's response is wrapped as - ``{"result": ""}``. Incompatible with ``output:`` — if - both are set, validation raises an error. - - ``envelope``: Explicit opt-in to the default structured-output - pipeline. Equivalent to the current behavior when ``output:`` is - declared. - - ``None`` (default): Infer behavior from whether ``output:`` is - declared (backward compatible). - - Only valid on provider-backed agents (type is ``None`` / omitted). - Script, human_gate, and workflow agents cannot set ``output_mode``. - """ - - routes: list[RouteDef] = Field(default_factory=list) - """Routing rules evaluated in order after execution.""" - - options: list[GateOption] | None = None - """Options for human_gate type agents.""" - - questions: list[QuestionDef] | None = None - """Inline questions for ``type: questions`` agents. - - Mutually exclusive with ``source``; exactly one is required. - """ - - source: str | None = None - """Dotted path to an array of questions (``type: questions`` only). - - Same convention as ``ForEachDef.source`` (e.g. - ``architect.output.open_questions``), including its format validation. - Entries may be plain strings or objects matching :class:`QuestionDef`. - """ - - allow_back: bool | None = None - """Whether the user can revisit the previous question (questions type). - - Tri-state so an explicit value is distinguishable from the default, which - is what lets the schema reject these flags on other step types. Defaults - to True; resolve via ``executor.questions.NavFlags``. - """ - - allow_skip: bool | None = None - """Whether individual questions can be skipped (questions type). Defaults to True.""" - - allow_skip_all: bool | None = None - """Whether the remaining questions can be skipped at once (questions type). - - Defaults to True. - """ - - allow_abort: bool | None = None - """Whether the user can abandon the node entirely (questions type). - - Defaults to False because it routes away from the normal flow; enabling it - without an ``abort_route`` ends the workflow. - """ - - abort_route: str | None = None - """Where to route when the user aborts (questions type). Defaults to ``$end``.""" - - command: str | None = None - """Command to execute (required for script type). Supports Jinja2 templating.""" - - args: list[str] = Field(default_factory=list) - """Command-line arguments for script type. Each supports Jinja2 templating.""" - - env: dict[str, str] = Field(default_factory=dict) - """Environment variables for script subprocess.""" - working_dir: str | None = None - """Working directory for the script subprocess OR a provider-backed agent - session and its MCP servers. - - On ``type: script`` steps it sets the subprocess cwd. On provider-backed - LLM agents it is resolved by the engine (Jinja-rendered, then relative - paths resolve against the workflow file's directory) and applied to the - provider session cwd and all of the agent's stdio MCP servers. Falls back - to ``runtime.working_dir`` when unset on the agent. Rejected on - wait/set/terminate/human_gate/workflow step types. - """ - settings_dir: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] | None = ( None ) - """Directory whose ``.claude/skills`` this agent may use, and whose tree the - model's built-in file tools may read. - - Both halves of that first line are deliberate: this carries the *skills* - third of a Claude Code ``project`` settings tier and nothing else of it, - and it widens the model's filesystem access unconditionally. Details below. - - ``claude-agent-sdk`` only -- a provider that cannot apply it refuses it - both at ``conductor validate`` and at run time, rather than dropping it - silently (``conductor run`` never calls the static validator). Resolved - by the engine exactly like :attr:`working_dir` (Jinja-rendered, - ``~``-expanded, made absolute against the workflow file's directory, - ``normpath``-normalised, existence-checked), then forwarded to the SDK as - ``ClaudeAgentOptions.add_dirs``. Rejected on - wait/set/terminate/script/human_gate/questions/workflow step types. - - **Two effects, and only one of them is conditional.** Skill discovery - requires ``runtime.provider.setting_sources`` to enable the ``project`` - tier; both ``conductor validate`` and the run itself warn when this field - is set without it, since the skills half is then a no-op and - ``conductor run`` never calls the static validator. The *filesystem* grant is - unconditional: ``add_dirs``' own SDK contract is "additional directories - Claude can access beyond the current working directory", so naming a - directory here widens the model's built-in ``Read``/``Edit``/``Bash`` - tools to that tree regardless of any settings tier. It does **not** widen - what a filesystem MCP server permits -- that stays cwd alone, which is why - this field exists. - - That grant is currently latent rather than reachable from a workflow: - Conductor runs this provider either with the full ``claude_code`` preset - under ``bypassPermissions`` (``tools:`` omitted), where reads already - succeed everywhere, or with ``tools: []``, where the model holds at most - the ``Skill`` loader and no file tool at all. So it is a property of the - SDK contract to design against rather than an exposure today. Point it - only at a directory the agent may read. - - It exists because ``working_dir`` was doing two unrelated jobs. The CLI - supports MCP Roots and advertises exactly one root — its cwd — so a - filesystem MCP server discards the directories in its own argv and - permits cwd alone. That makes cwd the *only* handle on what an agent can - read, while it is simultaneously the directory the ``project`` settings - tier resolves against. Narrowing cwd onto a target repository to pick up - that repository's skills therefore also narrowed the MCP root below any - sibling path the step still had to read, and widening it back lost the - repository's conventions. - - ``settings_dir`` splits them: the *skills* of every directory named here - are discovered and invocable regardless of cwd, so cwd can stay wide - enough to contain everything the agent must read. - - The split is not total, and the remainder is deliberate. A directory - named here contributes its ``.claude/skills`` and nothing else — not - ``CLAUDE.md``, not ``.claude/rules/*.md``, not ``.claude/settings.json`` - (so no ``env`` and no ``hooks``), not ``.claude/agents``, all of which - continue to follow cwd. This field is the *skills* portion of a project - tier, not a cwd-independent way to load one: instructions, rules and - hooks still require ``working_dir`` pointed at the directory. - - So the two fields do not compose into "everything, anywhere". An agent - needing a target repository's rules *and* a cwd wide enough for its MCP - servers cannot have both from these fields alone -- one directory cannot - be simultaneously narrow and wide. ``settings_dir`` recovers the skills; - the rest is a caller-side trade. - - Example — a judge reviewing a target repository while reading artifacts - from a sibling directory:: - - agents: - - name: judge - settings_dir: "{{ setup_worktree.output.worktree_path }}" - # No working_dir: cwd stays the launch directory, which contains - # both the worktree and the artifacts the judge must read. - """ - - stdin: str | None = None - """Payload written to the script subprocess's stdin (script type only). - - A Jinja2 string template rendered against the workflow context and written - to the child process's stdin as UTF-8. Use this to hand large structured - payloads to scripts without hitting OS command-line length limits (notably - Windows's ~32 KB command-line cap): - - - JSON: ``stdin: "{{ upstream.output.evaluations | tojson }}"`` — the - built-in ``tojson`` filter emits valid JSON. - - Arbitrary text: ``stdin: "{{ diff }}"``. - - Semantics: - - - Omitted (``None``) — the child inherits the parent's stdin (the - unchanged legacy behavior). - - Present (any string, including ``""``) — stdin is piped; an explicit - empty string sends immediate EOF. - - Orthogonal to ``args`` — when both are set, ``args`` are still passed on - the command line and ``stdin`` is piped. - """ - - timeout: int | None = None - """Per-call timeout in seconds (script subprocess or MCP tool call).""" - - server: str | None = None - """MCP server name to call (required for ``type='mcp'`` steps). - - Must name a server declared in ``workflow.runtime.mcp_servers``. Never - Jinja2-rendered — a template is rejected at load time (see - :meth:`validate_mcp_fields_are_literal`), because static validation of - the server/tool pair is only possible on literal values. - """ - - tool: str | None = None - """Tool name to invoke on the MCP server (required for ``type='mcp'`` steps). - - Never Jinja2-rendered — a template is rejected at load time for the same - reason as :attr:`server`. - """ - - arguments: dict[str, Any] | None = None - """Optional argument mapping passed to the MCP tool (``type='mcp'`` only). - - String values (at any nesting depth) are Jinja2-rendered recursively - against the workflow context before the call; other JSON scalars pass - through unchanged. ``None`` calls the tool with no arguments. - """ - - duration: str | int | float | None = None - """Duration to pause for ``type='wait'`` steps. - - Accepts: - - Plain ``int`` or ``float`` — interpreted as seconds. - - String with a unit suffix: ``ms``, ``s``, ``m``, ``h`` - (e.g. ``"500ms"``, ``"60s"``, ``"2.5m"``, ``"1h"``). - - A Jinja2 template that renders to one of the above - (e.g. ``"{{ workflow.input.poll_interval_seconds }}s"``). - - The resolved duration must be greater than 0 and no more than 24h. - Templated durations defer literal validation to runtime. - """ - - reason: str | None = None - """Optional human-readable reason shown in the dashboard for ``type='wait'`` steps.""" - - value: str | None = None - """Jinja2 expression bound into context (required for single-binding 'set' type). - - The rendered string is auto-coerced to a typed value (see ``output_type``). - The result is stored under ``.output``. - - Example:: - - value: "{{ workflow.input.org }}/{{ workflow.input.repo }}" - """ - - values: dict[str, str] | None = None - """Named Jinja2 expressions bound into context (for multi-binding 'set' type). - - Each value is rendered against the *original* pre-step context — bindings - cannot reference one another within the same step. Chain multiple ``set`` - steps if you need ordered dependencies. - - Each binding is auto-coerced to a typed value (see ``output_type`` for the - detection rules). The result is stored as a dict under - ``.output.``. - - Example:: - - values: - is_breaking: "{{ research.output.severity in ['high', 'critical'] }}" - target_branch: "{{ workflow.input.branch or 'main' }}" - """ - - output_type: ( - Literal["auto", "string", "number", "integer", "boolean", "list", "dict"] | None - ) = None - """Override type detection for a single-binding 'set' step. - - Only valid with ``value:``. For ``values:``, every binding uses - ``auto`` detection; per-key ``output_type`` is not supported. - - - ``auto`` / unset: render the template and run ``yaml.safe_load`` on the - result; fall back to the raw string on parse failure. Empty/whitespace-only - rendered strings become ``""`` (not ``None``). - - ``string``: keep the raw rendered string. - - ``number``: try ``int`` then ``float``; raise on failure. - - ``integer``: ``int``; raise on failure. - - ``boolean``: case-insensitive ``true``/``false``/``1``/``0``/``yes``/``no``. - - ``list`` / ``dict``: parse via YAML and assert the type. - """ - - workflow: str | None = None - """Path to sub-workflow YAML file (required for type='workflow'). - - The path is resolved relative to the parent workflow file. - Sub-workflows run as black boxes — their internal agents are not - visible to the parent workflow. - - Example: - workflow: ./research-pipeline.yaml - """ - - input_mapping: dict[str, str] | None = None - """Optional mapping of sub-workflow input names to Jinja2 expressions. - - Each key is a sub-workflow input parameter name. Each value is a Jinja2 - template expression evaluated against the parent workflow's context. - - When present, the rendered values are passed as the sub-workflow's inputs - instead of forwarding the parent's workflow.input.* values. - - Only valid for type='workflow' agents. - - Example:: - - input_mapping: - work_item_id: "{{ task_manager.output.current_issue_id }}" - title: "{{ task_manager.output.current_issue_title }}" - """ - - max_depth: int | None = Field(None, ge=1, le=10) - """Per-agent sub-workflow depth limit. - - Overrides the global MAX_SUBWORKFLOW_DEPTH (10) with a tighter bound. - Only valid for type='workflow' agents. Useful for self-referential - workflows to set an explicit recursion limit. - - Example:: - - max_depth: 3 # Allow at most 3 levels of recursion - """ - timeout_seconds: float | None = Field(None, ge=1.0) - """Hard wall-clock timeout for this agent's execution in seconds. - - When set, the engine wraps the entire agent execution in - ``asyncio.wait_for()``. If exceeded, raises ``AgentTimeoutError`` - which is handled by existing error semantics (``fail_fast``, - ``continue_on_error``). - - The effective timeout is ``min(timeout_seconds, remaining_workflow_timeout)`` - so agent timeouts never exceed the workflow-level limit. - - Only applies to provider-backed agents (not script, human_gate, - or workflow types). This is a hard cancellation — unlike - ``max_session_seconds`` which checks between provider iterations. - - Because this is a hard cancellation, in-flight provider sessions, - MCP tool calls, and HTTP connections receive ``CancelledError`` - mid-flight and may not get a clean shutdown. External state (e.g., - partially-written files, open MCP tool handles) may be left - inconsistent. - - Note: Agent-level timeouts are non-retryable. The retry policy - operates inside the provider and is cancelled along with the agent. - - Example:: - - timeout_seconds: 120 # Cancel agent after 2 minutes - """ - max_session_seconds: float | None = Field(None, ge=1.0) - """Maximum wall-clock duration for this agent's session in seconds. - - Overrides the workflow-level runtime.max_session_seconds for this agent. - Only applies to provider-backed agents (not script or human_gate). - - Example: A source-gathering agent that should finish in ~60s can set - max_session_seconds: 60 instead of using the default timeout. - """ - max_agent_iterations: int | None = Field(None, ge=1, le=500) - """Maximum tool-use iterations for this agent execution. - - Overrides the workflow-level runtime.max_agent_iterations for this agent. - Only applies to provider-backed agents (not script or human_gate). - - Example: A complex coding agent that needs many tool calls can set - max_agent_iterations: 200 instead of using the default limit. - """ - session_key: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] | None = ( None ) - """Continue one provider session across every execution sharing this key. - - Keyed executions resume the same session instead of starting cold, so a - loop-back keeps what the agent already read and a later agent inherits an - earlier one's conversation. Default (``None``) starts a fresh session each - time; the map is checkpointed, so continuity survives ``conductor resume``. - - A static label, never Jinja2-rendered — ``{{ ... }}`` is rejected (see - :meth:`validate_session_key_is_literal`). Requires a provider declaring - ``session_continuity`` (only ``claude-agent-sdk`` today); the validator - also rejects a key shared by concurrent executions. - - Example YAML:: - - - name: analyze - session_key: investigation - """ - retry: RetryPolicy | None = None - """Per-agent retry policy for transient failures. - - When set, the provider wraps agent execution in a retry loop with - the specified backoff strategy. Only applies to provider-backed agents - (not script or human_gate). - - Example YAML:: - - retry: - max_attempts: 3 - backoff: exponential - delay_seconds: 2 - retry_on: - - provider_error - - timeout - """ - dialog: DialogConfig | None = None - """Optional dialog mode configuration. - - When set, enables this agent to conditionally pause after execution - and enter a free-form conversation with the user. A lightweight - evaluator LLM call uses the trigger_prompt to decide whether dialog - should be triggered based on the agent's output. - - Only applies to provider-backed agents (type='agent' or None). + reasoning: ReasoningConfig | None = None + validator: ValidatorConfig | None = None + sandbox: SandboxConfig | None = None + skills: list[str] | None = None + plugins: list[PluginDef] | None = None - Example YAML:: + @model_validator(mode="before") + @classmethod + def normalize_type(cls, value: Any) -> Any: + return _normalize_step_type(value) - dialog: - trigger_prompt: | - Enter dialog if the agent is uncertain about the user's - intent or needs clarification on ambiguous requirements. - """ + @field_validator("prompt", "system_prompt", mode="wrap") + @classmethod + def preserve_file_string(cls, value: Any, handler: ValidatorFunctionWrapHandler) -> Any: + return _preserve_file_string(value, handler) - reasoning: ReasoningConfig | None = None - """Optional reasoning / extended-thinking effort for this agent. + @field_validator("session_key") + @classmethod + def validate_session_key(cls, value: str | None) -> str | None: + if value is not None and is_jinja_template(value): + raise ValueError( + f"session_key {value!r} looks like a Jinja2 template, but session_key is " + "never rendered — it would be used verbatim as a single literal key shared " + "by every execution. Use a static label." + ) + return value - When set, the provider configures its reasoning capability: + @field_validator("skills") + @classmethod + def validate_skills(cls, value: list[str] | None) -> list[str] | None: + return value if value is None else _validate_skill_entries(value) - - Copilot: passes ``reasoning_effort`` to ``create_session``. - - Claude: enables ``thinking`` with a budget mapped from the effort - level (low=2k, medium=8k, high=16k, xhigh=32k, max=59904 tokens). + @field_validator("plugins", mode="before") + @classmethod + def coerce_plugins(cls, value: Any) -> Any: + return _coerce_plugin_entries(value) - Falls back to ``runtime.default_reasoning_effort`` when unset. + @field_validator("plugins") + @classmethod + def validate_plugins(cls, value: list[PluginDef] | None) -> list[PluginDef] | None: + return value if value is None else _validate_plugin_entries(value) - Only applies to provider-backed agents (type='agent' or None). + @model_validator(mode="after") + def validate_agent(self) -> AgentDef: + if ( + self.context_tier is not None + and not is_jinja_template(self.context_tier) + and self.context_tier not in get_args(ContextTier) + ): + raise ValueError( + f"context_tier must be one of {list(get_args(ContextTier))} " + f"or a '{{{{ ... }}}}' template (got {self.context_tier!r})" + ) + if self.output_mode == "raw" and self.output: + raise ValueError( + "output_mode 'raw' is incompatible with output schema; remove the output: " + "block or use output_mode: envelope" + ) + return self - Example YAML:: + def effective_output_schema(self) -> dict[str, OutputField] | None: + if self.output and self.output_mode != "raw": + return self.output + return None - reasoning: - effort: high - """ - validator: ValidatorConfig | None = None - """Optional semantic output validation with retry-once. +class HumanGateStepDef(RoutableStepBase): + """Human decision gate definition.""" - When set, the engine runs a second LLM call after this agent completes, - checking the output against ``validator.criteria``. On failure the - primary agent is re-run once with the validator's feedback appended. + model_config = ConfigDict(extra="forbid", json_schema_extra=_require_step_type_in_schema) - Distinct from ``retry:`` (transient failures, same prompt) and - ``output:`` (shape validation). Only applies to provider-backed agents - (type='agent' or None). Works in the main loop, parallel groups, and - for-each loops. + type: Literal["human_gate"] = "human_gate" + prompt: str + options: list[GateOption] - Example YAML:: + @field_validator("prompt", mode="wrap") + @classmethod + def preserve_prompt_file_string(cls, value: Any, handler: ValidatorFunctionWrapHandler) -> Any: + return _preserve_file_string(value, handler) - validator: - criteria: | - Verify every issue has an actionable suggestion and no - function names are fabricated. - max_retries: 1 - """ + @model_validator(mode="after") + def validate_gate(self) -> HumanGateStepDef: + if not self.options: + raise ValueError("human_gate agents require 'options'") + if not self.prompt: + raise ValueError("human_gate agents require 'prompt'") + return self - sandbox: SandboxConfig | None = None - """Optional per-agent override block for the ``aca`` sandbox provider. - Only meaningful when this agent's effective provider is ``aca``; see - :class:`SandboxConfig`. Only applies to provider-backed agents (type is - ``None`` / omitted). +class QuestionsStepDef(RoutableStepBase): + """Interactive questions step definition.""" - Example YAML:: + model_config = ConfigDict(extra="forbid", json_schema_extra=_require_step_type_in_schema) - sandbox: - identifier_scope: item - working_dir: /workspace - """ + type: Literal["questions"] = "questions" + prompt: str = "" + questions: list[QuestionDef] | None = None + source: str | None = None + allow_back: bool | None = None + allow_skip: bool | None = None + allow_skip_all: bool | None = None + allow_abort: bool | None = None + abort_route: str | None = None - skills: list[str] | None = None - r"""Opt this agent into a list of skills. - - Each entry is either a **registered built-in name** (e.g. - ``conductor``) or a **filesystem path**. An entry is treated as a - path when it starts with ``.`` or ``~``, or contains ``/`` or ``\``; - everything else must be a built-in name, so a bare name can never be - shadowed by a same-named local directory. - - A path may point at either granularity: - - * a **skill directory** — one containing ``SKILL.md`` - * a **skills root** — a directory of skill directories, which - expands to every immediate child containing a ``SKILL.md`` - - Relative paths resolve against the workflow file's directory - (consistent with ``working_dir``), so a skill can be versioned - alongside the workflow with no per-developer install step. - - Skill paths are trusted input: a ``SKILL.md`` is injected into the - agent's context, but the same workflow file can already declare - ``type: script`` steps running arbitrary shell, so no additional - allowlist applies. - - The agent receives that skill's content via whichever mechanism the - provider supports natively: - - * **Copilot** — skill directories are passed to the SDK session via - ``skill_directories``; the model discovers and loads skill content - as relevant (progressive disclosure, token-efficient). - * **Claude Agent SDK** — the Claude Code plugin that owns the skill is - registered on the session and the skill is enabled by its - ``:`` name, so the CLI loads only the ``SKILL.md`` - frontmatter up front. Skills the workflow did not declare are - filtered out of the model's listing instead of being inherited - from the machine. The SDK has no bare skill-directory surface, so - a path skill that is not inside a Claude Code plugin is rejected. - * **Claude** — ``SKILL.md`` plus ``references/*.md`` is eagerly - injected into the agent's rendered prompt, wrapped in - ```` tags. There is no native skill surface on - the Anthropic API without adopting the container/code-execution - beta. Injected size is bounded by ``runtime.skill_injection``. - - Tri-state semantics via list presence: - - * ``None`` (omitted): inherit from ``workflow.runtime.skills`` - * ``[]`` (empty list): explicit none — overrides any workflow - default - * ``[name, ...]``: explicit set — overrides any workflow default - - Skills built into Conductor today: - - * ``conductor`` — comprehensive knowledge of Conductor's YAML - schema, execution model, authoring patterns, and CLI commands. - Enables agents to evaluate, improve, debug, or generate Conductor - workflows. - - Every resolved skill's ``SKILL.md`` must have valid YAML frontmatter - declaring ``name`` and ``description``; both Copilot and Claude Code - skip an unparseable skill in silence, so Conductor fails loudly - instead. - - Only applies to provider-backed agents (type='agent' or None). + @field_validator("prompt", mode="wrap") + @classmethod + def preserve_prompt_file_string(cls, value: Any, handler: ValidatorFunctionWrapHandler) -> Any: + return _preserve_file_string(value, handler) - Example YAML:: + @model_validator(mode="after") + def validate_source(self) -> QuestionsStepDef: + if not self.questions and not self.source: + raise ValueError("questions agents require either 'questions' or 'source'") + if self.questions and self.source: + raise ValueError( + "questions agents cannot set both 'questions' and 'source' (use one or the other)" + ) + if self.abort_route is not None and not self.allow_abort: + raise ValueError( + "questions agents cannot set 'abort_route' without 'allow_abort: true'" + ) + if self.source is not None: + validate_dotted_source(self.source) + return self - agents: - - name: workflow_reviewer - skills: - - conductor # built-in - - ./team-skills/acme-widgets # versioned with the workflow - prompt: "Review this workflow for correctness..." - """ - plugins: list[PluginDef] | None = None - """Opt this agent into whole plugins. +class ScriptStepDef(RoutableStepBase): + """Subprocess-backed script step definition.""" - A plugin is the unit a user actually installs, and it ships up to - three things Conductor can use: ``skills/``, ``agents/*.agent.md``, - and MCP servers. Enabling the plugin brings all three by default, so - a skill whose instructions dispatch to ``prs:code-reviewer`` or call - an ``ado`` MCP tool finds them there. + model_config = ConfigDict(extra="forbid", json_schema_extra=_require_step_type_in_schema) - Each entry is either an **installed plugin name** or a **filesystem - path** — the same syntactic rule as ``skills:``. Entries take a - string shorthand or an object with per-component switches; see - :class:`PluginDef`. + type: Literal["script"] = "script" + output: dict[str, OutputField] | None = None + command: str + args: list[str] = Field(default_factory=list) + env: dict[str, str] = Field(default_factory=dict) + working_dir: str | None = None + stdin: str | None = None + timeout: int | None = Field(None, gt=0) - Tri-state semantics via list presence, matching :attr:`skills`: + @field_validator("command") + @classmethod + def validate_command(cls, value: str) -> str: + if not value: + raise ValueError("script agents require 'command'") + return value - * ``None`` (omitted): inherit from ``workflow.runtime.plugins`` - * ``[]`` (empty list): explicit none — overrides any workflow default - * ``[entry, ...]``: explicit set — overrides any workflow default + @model_validator(mode="before") + @classmethod + def require_command(cls, value: Any) -> Any: + if isinstance(value, dict) and not value.get("command"): + raise ValueError("script agents require 'command'") + return value - Requires a provider with a native skill and subagent surface - (``copilot``, ``claude-agent-sdk``). Providers that reach skills by - injecting their text into the prompt have nowhere to put a subagent - or an MCP server, so a plugin there would load partially — exactly - the failure this field exists to remove — and is rejected instead. + @model_validator(mode="after") + def validate_command_present(self) -> ScriptStepDef: + """Re-assert the command invariant when an existing instance is revalidated. + + Pydantic skips field and before-model validators for an already-built + instance (e.g. a ``model_copy`` result nested inside ``WorkflowConfig``), + so the checks above never fire on that path; this after-model validator + does, keeping a mutated copy from pushing an empty command past + ``WorkflowConfig.model_validate``. + """ + if not self.command: + raise ValueError("script agents require 'command'") + return self - Plugins are never discovered. Nothing is registered because it - happened to be installed; a plugin is loaded only because a workflow - named it, and a missing one is an error rather than quietly less - capability. - Only applies to provider-backed agents (type='agent' or None). +class MCPStepDef(RoutableStepBase): + """Direct MCP tool-call step definition.""" - Example YAML:: + model_config = ConfigDict(extra="forbid", json_schema_extra=_require_step_type_in_schema) - agents: - - name: reviewer - plugins: - - prs # everything the plugin ships - - name: ado - mcp: false # skills and agents only - prompt: "Review this pull request..." - """ + type: Literal["mcp"] = "mcp" + output: dict[str, OutputField] | None = None + timeout: int | None = Field(None, gt=0) + server: str + tool: str + arguments: dict[str, Any] | None = None - status: Literal["success", "failed"] | None = None - """Outcome status for ``type: terminate`` steps. + @model_validator(mode="before") + @classmethod + def require_target(cls, value: Any) -> Any: + if isinstance(value, dict): + if not value.get("server"): + raise ValueError("mcp agents require 'server'") + if not value.get("tool"): + raise ValueError("mcp agents require 'tool'") + return value - ``success`` ends the workflow cleanly (exit code 0, dashboard ✅, - ``workflow_completed`` event with ``is_explicit: true``). ``failed`` - ends the workflow as an explicit error (non-zero exit code, dashboard - ❌, ``workflow_failed`` event with ``is_explicit: true``). Required - for ``type: terminate``; forbidden on all other step types. + @field_validator("server", "tool") + @classmethod + def validate_name(cls, value: str, info: ValidationInfo) -> str: + if not value: + raise ValueError(f"mcp agents require '{info.field_name}'") + if is_jinja_template(value): + raise ValueError( + f"{info.field_name} {value!r} looks like a Jinja2 template, but " + f"{info.field_name} is never rendered — static validation of the server/tool " + "pair requires a literal value. Use a static name." + ) + return value - Example YAML:: + @model_validator(mode="after") + def validate_target_present(self) -> MCPStepDef: + """Re-assert the required-target invariant on instance revalidation. - type: terminate - status: failed - reason: "Upstream service returned unprocessable data" - """ + Pydantic skips field and before-model validators for an already-built + instance (e.g. a ``model_copy`` result nested inside ``WorkflowConfig``), + so the checks above never fire on that path; this after-model validator + does. + """ + if not self.server: + raise ValueError("mcp agents require 'server'") + if not self.tool: + raise ValueError("mcp agents require 'tool'") + return self - reason: str | None = None - """Termination reason for ``type: terminate`` steps (Jinja2-rendered). - Surfaced in the ``workflow_completed`` / ``workflow_failed`` event as - ``termination_reason`` and stored in the step's context entry. Required - for ``type: terminate``; forbidden on all other step types. +def _check_wait_duration(value: Any) -> None: + """Parse ``value`` as a wait duration and enforce ``0 < d <= 24h``. + + Shared by ``WaitStepDef``'s field validator (first-pass, mapping input) and + its after-model validator (revalidation of an existing instance) so both + paths enforce the same rule. Templated durations (containing ``{{``) defer + all literal validation to runtime. + """ + if isinstance(value, bool): + raise ValueError(f"duration must be a number or duration string, not boolean: {value!r}") + if isinstance(value, str) and "{{" in value: + return + try: + seconds = parse_duration(value) + except ValueError as exc: + raise ValueError(f"wait duration is invalid: {exc}") from exc + if seconds <= 0: + raise ValueError(f"wait duration must be > 0 seconds (got {seconds!r})") + if seconds > MAX_WAIT_DURATION_SECONDS: + raise ValueError( + f"wait duration {seconds!r}s exceeds the 24h cap " + f"({MAX_WAIT_DURATION_SECONDS}s); reconsider using " + "'limits.timeout_seconds' instead" + ) - Supports Jinja2 templating against accumulated context. - Example YAML:: +class WaitStepDef(RoutableStepBase): + """Cancellable delay step definition.""" - reason: "{{ precheck.output.reason }}" - """ + model_config = ConfigDict(extra="forbid", json_schema_extra=_require_step_type_in_schema) - output_template: dict[str, str] | None = None - """Optional final-output mapping for ``type: terminate`` steps. + type: Literal["wait"] = "wait" + duration: str | int | float + reason: str | None = None - When present, *replaces* the workflow-level ``output:`` mapping for - this termination path. Each value is a Jinja2 expression evaluated - against the accumulated context (including the terminate step's own - ``status`` / ``reason``). When omitted, the workflow-level ``output:`` - mapping is rendered as usual. + @field_validator("duration", mode="before") + @classmethod + def validate_duration(cls, value: Any) -> Any: + _check_wait_duration(value) + return value - Each rendered value is then passed through the engine's JSON-coercion - helper before being placed in the final output dict: literal strings - ``"true"`` / ``"false"`` become Python booleans, numeric strings become - ``int`` / ``float``, and strings that parse as JSON objects/arrays are - deserialised. This matches the behaviour of workflow-level ``output:`` - and route output transforms, but it means the example below produces - ``{"aborted": True, "stage": "precheck", ...}`` — not all-string values. - Quote with backslashes if you genuinely want the literal text ``"true"``. + @model_validator(mode="after") + def validate_duration_value(self) -> WaitStepDef: + """Re-assert the duration bounds on instance revalidation. - Forbidden on all step types other than ``terminate``. + Pydantic skips field validators for an already-built instance (e.g. a + ``model_copy`` result nested inside ``WorkflowConfig``), so a mutated + copy carrying an out-of-bounds duration would otherwise sail through + config validation. + """ + _check_wait_duration(self.duration) + return self - Example YAML:: - output_template: - aborted: "true" # rendered to Python True - stage: precheck - reason: "{{ precheck.output.reason }}" - """ +class SetStepDef(RoutableStepBase): + """Context-binding step definition.""" - @field_validator("timeout") - @classmethod - def validate_timeout(cls, v: int | None) -> int | None: - """Ensure timeout is positive if set.""" - if v is not None and v <= 0: - raise ValueError("timeout must be a positive integer") - return v + model_config = ConfigDict(extra="forbid", json_schema_extra=_require_step_type_in_schema) - @field_validator("server", "tool", mode="before") - @classmethod - def validate_mcp_fields_are_literal(cls, v: Any, info: ValidationInfo) -> Any: - """Reject a Jinja2 template in ``server`` / ``tool`` (type: mcp steps). + type: Literal["set"] = "set" + output: dict[str, OutputField] | None = None + value: str | None = None + values: dict[str, str] | None = None + output_type: ( + Literal["auto", "string", "number", "integer", "boolean", "list", "dict"] | None + ) = None - Neither field is ever rendered, and static validation of the - server/tool pair (declared server exists, tool is on its allowlist) - is only possible on literal values — a template would defer that - check entirely to runtime. - """ - if isinstance(v, str) and ("{{" in v or "{%" in v): + @model_validator(mode="after") + def validate_bindings(self) -> SetStepDef: + if (self.value is None) == (self.values is None): + raise ValueError("set agents require exactly one of 'value' or 'values'") + if self.values is not None and self.output_type is not None: raise ValueError( - f"{info.field_name} {v!r} looks like a Jinja2 template, but " - f"{info.field_name} is never rendered — static validation of the " - f"server/tool pair requires a literal value. Use a static name." + "set agents with 'values:' cannot have 'output_type' " + "(it only applies to single 'value:'; per-key typing is not yet supported)" ) - return v + return self - @field_validator("session_key") - @classmethod - def validate_session_key_is_literal(cls, v: str | None) -> str | None: - """Reject a Jinja2 template in ``session_key``. - The field is never rendered, so ``"item-{{ _key }}"`` would become one - literal key shared by every iteration rather than the per-item key the - author intended. - """ - if v is not None and ("{{" in v or "{%" in v): - raise ValueError( - f"session_key {v!r} looks like a Jinja2 template, but session_key is " - f"never rendered — it would be used verbatim as a single literal key " - f"shared by every execution. Use a static label." - ) - return v +class TerminateStepDef(StepBase): + """Explicit terminal outcome step definition.""" - @field_validator("skills") - @classmethod - def validate_skills(cls, v: list[str] | None) -> list[str] | None: - """Validate ``skills:`` entry shape and built-in names. - - Unknown built-in names surface at load time as before. Path - entries need the workflow file's directory to resolve, so they - are only shape-checked here — see :func:`_validate_skill_entries`. - Empty lists are allowed (explicit opt-out). - """ - if v is None: - return v - return _validate_skill_entries(v) + model_config = ConfigDict(extra="forbid", json_schema_extra=_require_step_type_in_schema) - @field_validator("plugins", mode="before") - @classmethod - def coerce_plugins(cls, v: Any) -> Any: - """Expand ``- prs`` string shorthands into ``{name: prs}``.""" - return _coerce_plugin_entries(v) + type: Literal["terminate"] = "terminate" + status: Literal["success", "failed"] + reason: str + output_template: dict[str, str] | None = None - @field_validator("plugins") + @field_validator("reason") @classmethod - def validate_plugins(cls, v: list[PluginDef] | None) -> list[PluginDef] | None: - """Reject duplicate ``plugins:`` entries. + def validate_reason(cls, value: str) -> str: + if not value.strip(): + raise ValueError("terminate agents require a non-empty 'reason'") + return value - Nothing else can be checked here: unlike a built-in skill name, - a plugin name is only resolvable against installed roots or the - workflow file's directory, neither of which the schema has. - Empty lists are allowed (explicit opt-out). + @model_validator(mode="after") + def validate_termination(self) -> TerminateStepDef: + """Re-assert the status/reason invariants on instance revalidation. + + Pydantic skips field validators — including the ``Literal`` check on + ``status`` — for an already-built instance (e.g. a ``model_copy`` + result nested inside ``WorkflowConfig``), so the checks above never + fire on that path; this after-model validator does. Both checks are + written defensively: the stored values never passed through coercion, + so they may be ``None`` or otherwise wrongly typed. """ - if v is None: - return v - return _validate_plugin_entries(v) + if self.status not in ("success", "failed"): + raise ValueError("terminate agents require 'status' (must be 'success' or 'failed')") + if not isinstance(self.reason, str) or not self.reason.strip(): + raise ValueError("terminate agents require a non-empty 'reason'") + return self - @field_validator("duration", mode="before") - @classmethod - def reject_bool_duration(cls, v: Any) -> Any: - """Reject boolean values for ``duration`` before Pydantic coerces them to int. - Pydantic v2 coerces ``True``/``False`` to ``1``/``0`` when the union - accepts ``int``. Catch it pre-coercion so a YAML ``duration: true`` is - rejected with a clear message instead of silently becoming a 1-second - wait. - """ - if isinstance(v, bool): - raise ValueError(f"duration must be a number or duration string, not boolean: {v!r}") - return v +class WorkflowStepDef(RoutableStepBase): + """Nested workflow step definition.""" - @field_validator("prompt", mode="wrap") + model_config = ConfigDict(extra="forbid", json_schema_extra=_require_step_type_in_schema) + + type: Literal["workflow"] = "workflow" + output: dict[str, OutputField] | None = None + workflow: str + input_mapping: dict[str, str] | None = None + max_depth: int | None = Field(None, ge=1, le=10) + + @model_validator(mode="before") @classmethod - def preserve_prompt_file_str(cls, value: Any, handler: ValidatorFunctionWrapHandler) -> Any: - """Preserve FileString subclass on validation for the prompt field.""" - if isinstance(value, FileString): - return value - return handler(value) + def require_workflow(cls, value: Any) -> Any: + if isinstance(value, dict) and not value.get("workflow"): + raise ValueError("workflow agents require 'workflow' path") + return value - @field_validator("system_prompt", mode="wrap") + @field_validator("workflow") @classmethod - def preserve_system_prompt_file_str( - cls, value: Any, handler: ValidatorFunctionWrapHandler - ) -> Any: - """Preserve FileString subclass on validation for the system_prompt field.""" - if isinstance(value, FileString): - return value - return handler(value) + def validate_workflow(cls, value: str) -> str: + if not value: + raise ValueError("workflow agents require 'workflow' path") + return value @model_validator(mode="after") - def validate_agent_type(self) -> AgentDef: - """Ensure agent has required fields for its type.""" - # Fields exclusive to ``type: terminate`` — reject if set on any - # other type. This is enforced before the per-type branches so the - # error message clearly names the conflict. - # - # NOTE: ``reason`` is intentionally NOT in this list because it is - # shared with ``type: wait`` (which uses it as an optional dashboard - # label, vs. terminate's required Jinja2-rendered message). The wait - # PR's cross-rejection block at the end of this method enforces - # "not allowed on anything except wait OR terminate" for ``reason``. - if self.type != "terminate": - for field_name in ("status", "output_template"): - if getattr(self, field_name) is not None: - raise ValueError( - f"'{self.type or 'agent'}' agents cannot have '{field_name}' " - "(only 'terminate' agents support this field)" - ) - - # Field exclusive to ``type: script`` — reject if set on any other - # type. No per-type branch below inspects ``stdin``, so this single - # guard is the sole rejection path for every non-script type. It - # mirrors the terminate-exclusive guard above so the message names the - # conflict; being a standalone guard (rather than a per-branch check) - # it also covers ``agent`` / ``human_gate``, which have no - # ``command``/``args`` branch. - if self.type != "script" and self.stdin is not None: - raise ValueError( - f"'{self.type or 'agent'}' agents cannot have 'stdin' " - "(only 'script' agents support this field)" - ) - - # Fields exclusive to ``type: mcp`` — a standalone guard, like the - # terminate/script/questions ones above, so it also covers types with - # no branch of their own (the ``mcp`` branch below only rejects - # fields, it cannot reject its own required ones on other types). - if self.type != "mcp": - for field_name in ("server", "tool", "arguments"): - if getattr(self, field_name) is not None: - raise ValueError( - f"'{self.type or 'agent'}' agents cannot have '{field_name}' " - "(only 'mcp' agents support this field)" - ) - - # Fields exclusive to ``type: questions``. A standalone guard, like the - # terminate/script ones above, so it also covers types with no branch - # of their own. The nav flags are tri-state (``bool | None``) precisely - # so an explicit value is distinguishable here — with a plain ``bool`` - # default, a value equal to that default is indistinguishable from a - # field the user never wrote. - if self.type != "questions": - for field_name in ( - "questions", - "source", - "allow_back", - "allow_skip", - "allow_skip_all", - "allow_abort", - "abort_route", - ): - if getattr(self, field_name) is not None: - raise ValueError( - f"'{self.type or 'agent'}' agents cannot have '{field_name}' " - "(only 'questions' agents support this field)" - ) - - if self.type == "human_gate": - if not self.options: - raise ValueError("human_gate agents require 'options'") - if not self.prompt: - raise ValueError("human_gate agents require 'prompt'") - if self.input_mapping is not None: - raise ValueError("human_gate agents cannot have 'input_mapping'") - if self.dialog is not None: - raise ValueError("human_gate agents cannot have 'dialog'") - if self.validator is not None: - raise ValueError("human_gate agents cannot have 'validator'") - if self.sandbox is not None: - raise ValueError("human_gate agents cannot have 'sandbox'") - if self.max_depth is not None: - raise ValueError("human_gate agents cannot have 'max_depth'") - if self.reasoning is not None: - raise ValueError("human_gate agents cannot have 'reasoning'") - if self.context_tier is not None: - raise ValueError("human_gate agents cannot have 'context_tier'") - if self.skills is not None: - raise ValueError("human_gate agents cannot have 'skills'") - if self.plugins is not None: - raise ValueError("human_gate agents cannot have 'plugins'") - if self.timeout_seconds is not None: - raise ValueError("human_gate agents cannot have 'timeout_seconds'") - if self.value is not None: - raise ValueError("human_gate agents cannot have 'value' (only 'set' agents do)") - if self.values is not None: - raise ValueError("human_gate agents cannot have 'values' (only 'set' agents do)") - if self.output_type is not None: - raise ValueError( - "human_gate agents cannot have 'output_type' (only 'set' agents do)" - ) - if self.output_mode is not None: - raise ValueError("human_gate agents cannot have 'output_mode'") - if self.working_dir: - raise ValueError("human_gate agents cannot have 'working_dir'") - if self.settings_dir is not None: - raise ValueError("human_gate agents cannot have 'settings_dir'") - if self.session_key is not None: - raise ValueError("human_gate agents cannot have 'session_key'") - elif self.type == "questions": - if not self.questions and not self.source: - raise ValueError("questions agents require either 'questions' or 'source'") - if self.questions and self.source: - raise ValueError( - "questions agents cannot set both 'questions' and 'source' " - "(use one or the other)" - ) - if self.options is not None: - raise ValueError( - "questions agents cannot have 'options' (only 'human_gate' agents do); " - "per-question choices go in 'questions[].choices'" - ) - if self.abort_route is not None and not self.allow_abort: - raise ValueError( - "questions agents cannot set 'abort_route' without 'allow_abort: true'" - ) - if self.source is not None: - validate_dotted_source(self.source) - if self.input_mapping is not None: - raise ValueError("questions agents cannot have 'input_mapping'") - if self.dialog is not None: - raise ValueError("questions agents cannot have 'dialog'") - if self.validator is not None: - raise ValueError("questions agents cannot have 'validator'") - if self.sandbox is not None: - raise ValueError("questions agents cannot have 'sandbox'") - if self.max_depth is not None: - raise ValueError("questions agents cannot have 'max_depth'") - if self.reasoning is not None: - raise ValueError("questions agents cannot have 'reasoning'") - if self.context_tier is not None: - raise ValueError("questions agents cannot have 'context_tier'") - if self.skills is not None: - raise ValueError("questions agents cannot have 'skills'") - if self.plugins is not None: - raise ValueError("questions agents cannot have 'plugins'") - if self.timeout_seconds is not None: - raise ValueError("questions agents cannot have 'timeout_seconds'") - if self.model: - raise ValueError("questions agents cannot have 'model' (no provider is invoked)") - if self.provider: - raise ValueError("questions agents cannot have 'provider'") - if self.tools is not None: - raise ValueError("questions agents cannot have 'tools'") - if self.output: - raise ValueError( - "questions agents cannot have 'output' (the answer shape is fixed)" - ) - if self.value is not None: - raise ValueError("questions agents cannot have 'value' (only 'set' agents do)") - if self.values is not None: - raise ValueError("questions agents cannot have 'values' (only 'set' agents do)") - if self.output_type is not None: - raise ValueError( - "questions agents cannot have 'output_type' (only 'set' agents do)" - ) - if self.output_mode is not None: - raise ValueError("questions agents cannot have 'output_mode'") - if self.working_dir: - raise ValueError("questions agents cannot have 'working_dir'") - if self.settings_dir is not None: - raise ValueError("questions agents cannot have 'settings_dir'") - if self.session_key is not None: - raise ValueError("questions agents cannot have 'session_key'") - elif self.type == "script": - if not self.command: - raise ValueError("script agents require 'command'") - if self.prompt: - raise ValueError("script agents cannot have 'prompt'") - if self.provider: - raise ValueError("script agents cannot have 'provider'") - if self.model: - raise ValueError("script agents cannot have 'model'") - if self.tools is not None: - raise ValueError("script agents cannot have 'tools'") - if self.system_prompt: - raise ValueError("script agents cannot have 'system_prompt'") - if self.options: - raise ValueError("script agents cannot have 'options'") - if self.max_session_seconds: - raise ValueError("script agents cannot have 'max_session_seconds'") - if self.max_agent_iterations is not None: - raise ValueError("script agents cannot have 'max_agent_iterations'") - if self.session_key is not None: - raise ValueError("script agents cannot have 'session_key'") - if self.retry is not None: - raise ValueError("script agents cannot have 'retry'") - if self.input_mapping is not None: - raise ValueError("script agents cannot have 'input_mapping'") - if self.dialog is not None: - raise ValueError("script agents cannot have 'dialog'") - if self.validator is not None: - raise ValueError("script agents cannot have 'validator'") - if self.sandbox is not None: - raise ValueError("script agents cannot have 'sandbox'") - if self.settings_dir is not None: - raise ValueError("script agents cannot have 'settings_dir'") - if self.max_depth is not None: - raise ValueError("script agents cannot have 'max_depth'") - if self.reasoning is not None: - raise ValueError("script agents cannot have 'reasoning'") - if self.context_tier is not None: - raise ValueError("script agents cannot have 'context_tier'") - if self.skills is not None: - raise ValueError("script agents cannot have 'skills'") - if self.plugins is not None: - raise ValueError("script agents cannot have 'plugins'") - if self.timeout_seconds is not None: - raise ValueError( - "script agents cannot have 'timeout_seconds' " - "(use 'timeout' for script-specific timeouts)" - ) - if self.value is not None: - raise ValueError("script agents cannot have 'value' (only 'set' agents do)") - if self.values is not None: - raise ValueError("script agents cannot have 'values' (only 'set' agents do)") - if self.output_type is not None: - raise ValueError("script agents cannot have 'output_type' (only 'set' agents do)") - if self.output_mode is not None: - raise ValueError("script agents cannot have 'output_mode'") - elif self.type == "workflow": - if not self.workflow: - raise ValueError("workflow agents require 'workflow' path") - if self.prompt: - raise ValueError("workflow agents cannot have 'prompt'") - if self.provider: - raise ValueError("workflow agents cannot have 'provider'") - if self.model: - raise ValueError("workflow agents cannot have 'model'") - if self.tools is not None: - raise ValueError("workflow agents cannot have 'tools'") - if self.system_prompt: - raise ValueError("workflow agents cannot have 'system_prompt'") - if self.options: - raise ValueError("workflow agents cannot have 'options'") - if self.command: - raise ValueError("workflow agents cannot have 'command'") - if self.max_session_seconds: - raise ValueError("workflow agents cannot have 'max_session_seconds'") - if self.max_agent_iterations is not None: - raise ValueError("workflow agents cannot have 'max_agent_iterations'") - if self.session_key is not None: - raise ValueError("workflow agents cannot have 'session_key'") - if self.retry is not None: - raise ValueError("workflow agents cannot have 'retry'") - if self.dialog is not None: - raise ValueError("workflow agents cannot have 'dialog'") - if self.validator is not None: - raise ValueError("workflow agents cannot have 'validator'") - if self.sandbox is not None: - raise ValueError("workflow agents cannot have 'sandbox'") - if self.timeout_seconds is not None: - raise ValueError("workflow agents cannot have 'timeout_seconds'") - if self.value is not None: - raise ValueError("workflow agents cannot have 'value' (only 'set' agents do)") - if self.values is not None: - raise ValueError("workflow agents cannot have 'values' (only 'set' agents do)") - if self.output_type is not None: - raise ValueError("workflow agents cannot have 'output_type' (only 'set' agents do)") - if self.output_mode is not None: - raise ValueError("workflow agents cannot have 'output_mode'") - if self.working_dir: - raise ValueError("workflow agents cannot have 'working_dir'") - if self.settings_dir is not None: - raise ValueError("workflow agents cannot have 'settings_dir'") - elif self.type == "mcp": - # Required fields. - if not self.server: - raise ValueError("mcp agents require 'server'") - if not self.tool: - raise ValueError("mcp agents require 'tool'") - # Field matrix for ``type: mcp`` — every AgentDef field is - # accounted for below so future fields cannot silently leak: - # ALLOWED (no check): name, description, type, input, output, - # routes, timeout (per-call seconds — unlike wait/set, - # an MCP call has no other timeout knob), server, tool, - # arguments - # FORBIDDEN (checked here): prompt, system_prompt, provider, - # model, tools, reasoning, context_tier, skills, plugins, - # validator, dialog, sandbox, session_key, - # max_agent_iterations, max_session_seconds, output_mode, - # retry, timeout_seconds, command, args, env, working_dir, - # settings_dir, options, workflow, input_mapping, max_depth, - # value, values, output_type - # COVERED BY STANDALONE GUARDS (no check needed here): - # stdin (script guard above), duration + reason - # (wait/terminate guard at the bottom of this method), - # status + output_template (terminate guard above), - # questions/source/allow_*/abort_route (questions guard - # above), server/tool/arguments on non-mcp types (guard - # above) - if self.prompt: - raise ValueError("mcp agents cannot have 'prompt'") - if self.provider: - raise ValueError("mcp agents cannot have 'provider'") - if self.model: - raise ValueError("mcp agents cannot have 'model'") - if self.tools is not None: - raise ValueError("mcp agents cannot have 'tools'") - if self.system_prompt: - raise ValueError("mcp agents cannot have 'system_prompt'") - if self.options: - raise ValueError("mcp agents cannot have 'options'") - if self.command: - raise ValueError("mcp agents cannot have 'command'") - if self.args: - raise ValueError("mcp agents cannot have 'args'") - if self.env: - raise ValueError("mcp agents cannot have 'env'") - if self.working_dir: - raise ValueError("mcp agents cannot have 'working_dir'") - if self.settings_dir is not None: - raise ValueError("mcp agents cannot have 'settings_dir'") - if self.workflow: - raise ValueError("mcp agents cannot have 'workflow'") - if self.input_mapping is not None: - raise ValueError("mcp agents cannot have 'input_mapping'") - if self.max_depth is not None: - raise ValueError("mcp agents cannot have 'max_depth'") - if self.max_session_seconds: - raise ValueError("mcp agents cannot have 'max_session_seconds'") - if self.max_agent_iterations is not None: - raise ValueError("mcp agents cannot have 'max_agent_iterations'") - if self.session_key is not None: - raise ValueError("mcp agents cannot have 'session_key'") - if self.retry is not None: - raise ValueError("mcp agents cannot have 'retry'") - if self.dialog is not None: - raise ValueError("mcp agents cannot have 'dialog'") - if self.validator is not None: - raise ValueError("mcp agents cannot have 'validator'") - if self.sandbox is not None: - raise ValueError("mcp agents cannot have 'sandbox'") - if self.reasoning is not None: - raise ValueError("mcp agents cannot have 'reasoning'") - if self.context_tier is not None: - raise ValueError("mcp agents cannot have 'context_tier'") - if self.skills is not None: - raise ValueError("mcp agents cannot have 'skills'") - if self.plugins is not None: - raise ValueError("mcp agents cannot have 'plugins'") - if self.timeout_seconds is not None: - raise ValueError( - "mcp agents cannot have 'timeout_seconds' (use 'timeout' for mcp call timeouts)" - ) - if self.output_mode is not None: - raise ValueError("mcp agents cannot have 'output_mode'") - if self.value is not None: - raise ValueError("mcp agents cannot have 'value' (only 'set' agents do)") - if self.values is not None: - raise ValueError("mcp agents cannot have 'values' (only 'set' agents do)") - if self.output_type is not None: - raise ValueError("mcp agents cannot have 'output_type' (only 'set' agents do)") - elif self.type == "wait": - if self.duration is None: - raise ValueError("wait agents require 'duration'") - if self.prompt: - raise ValueError("wait agents cannot have 'prompt'") - if self.provider: - raise ValueError("wait agents cannot have 'provider'") - if self.model: - raise ValueError("wait agents cannot have 'model'") - if self.tools is not None: - raise ValueError("wait agents cannot have 'tools'") - if self.system_prompt: - raise ValueError("wait agents cannot have 'system_prompt'") - if self.options: - raise ValueError("wait agents cannot have 'options'") - if self.command: - raise ValueError("wait agents cannot have 'command'") - if self.args: - raise ValueError("wait agents cannot have 'args'") - if self.env: - raise ValueError("wait agents cannot have 'env'") - if self.working_dir: - raise ValueError("wait agents cannot have 'working_dir'") - if self.settings_dir is not None: - raise ValueError("wait agents cannot have 'settings_dir'") - if self.timeout is not None: - raise ValueError("wait agents cannot have 'timeout'") - if self.workflow: - raise ValueError("wait agents cannot have 'workflow'") - if self.input_mapping is not None: - raise ValueError("wait agents cannot have 'input_mapping'") - if self.max_depth is not None: - raise ValueError("wait agents cannot have 'max_depth'") - if self.max_session_seconds: - raise ValueError("wait agents cannot have 'max_session_seconds'") - if self.max_agent_iterations is not None: - raise ValueError("wait agents cannot have 'max_agent_iterations'") - if self.session_key is not None: - raise ValueError("wait agents cannot have 'session_key'") - if self.retry is not None: - raise ValueError("wait agents cannot have 'retry'") - if self.dialog is not None: - raise ValueError("wait agents cannot have 'dialog'") - if self.validator is not None: - raise ValueError("wait agents cannot have 'validator'") - if self.sandbox is not None: - raise ValueError("wait agents cannot have 'sandbox'") - if self.reasoning is not None: - raise ValueError("wait agents cannot have 'reasoning'") - if self.context_tier is not None: - raise ValueError("wait agents cannot have 'context_tier'") - if self.skills is not None: - raise ValueError("wait agents cannot have 'skills'") - if self.plugins is not None: - raise ValueError("wait agents cannot have 'plugins'") - if self.timeout_seconds is not None: - raise ValueError("wait agents cannot have 'timeout_seconds'") - if self.output is not None: - raise ValueError( - "wait agents cannot have 'output' (output is fixed: {'waited_seconds': float})" - ) - if self.value is not None: - raise ValueError("wait agents cannot have 'value' (only 'set' agents do)") - if self.values is not None: - raise ValueError("wait agents cannot have 'values' (only 'set' agents do)") - if self.output_type is not None: - raise ValueError("wait agents cannot have 'output_type' (only 'set' agents do)") - if self.output_mode is not None: - raise ValueError("wait agents cannot have 'output_mode'") - self._validate_wait_duration() - elif self.type == "set": - if (self.value is None) == (self.values is None): - raise ValueError("set agents require exactly one of 'value' or 'values'") - if self.values is not None and self.output_type is not None: - raise ValueError( - "set agents with 'values:' cannot have 'output_type' " - "(it only applies to single 'value:'; per-key typing is not yet supported)" - ) - if self.prompt: - raise ValueError("set agents cannot have 'prompt'") - if self.provider: - raise ValueError("set agents cannot have 'provider'") - if self.model: - raise ValueError("set agents cannot have 'model'") - if self.tools is not None: - raise ValueError("set agents cannot have 'tools'") - if self.system_prompt: - raise ValueError("set agents cannot have 'system_prompt'") - if self.options: - raise ValueError("set agents cannot have 'options'") - if self.command: - raise ValueError("set agents cannot have 'command'") - if self.args: - raise ValueError("set agents cannot have 'args'") - if self.env: - raise ValueError("set agents cannot have 'env'") - if self.working_dir: - raise ValueError("set agents cannot have 'working_dir'") - if self.settings_dir is not None: - raise ValueError("set agents cannot have 'settings_dir'") - if self.timeout is not None: - raise ValueError("set agents cannot have 'timeout'") - if self.workflow: - raise ValueError("set agents cannot have 'workflow'") - if self.input_mapping is not None: - raise ValueError("set agents cannot have 'input_mapping'") - if self.max_depth is not None: - raise ValueError("set agents cannot have 'max_depth'") - if self.max_session_seconds is not None: - raise ValueError("set agents cannot have 'max_session_seconds'") - if self.max_agent_iterations is not None: - raise ValueError("set agents cannot have 'max_agent_iterations'") - if self.session_key is not None: - raise ValueError("set agents cannot have 'session_key'") - if self.retry is not None: - raise ValueError("set agents cannot have 'retry'") - if self.dialog is not None: - raise ValueError("set agents cannot have 'dialog'") - if self.validator is not None: - raise ValueError("set agents cannot have 'validator'") - if self.sandbox is not None: - raise ValueError("set agents cannot have 'sandbox'") - if self.reasoning is not None: - raise ValueError("set agents cannot have 'reasoning'") - if self.context_tier is not None: - raise ValueError("set agents cannot have 'context_tier'") - if self.skills is not None: - raise ValueError("set agents cannot have 'skills'") - if self.plugins is not None: - raise ValueError("set agents cannot have 'plugins'") - if self.timeout_seconds is not None: - raise ValueError("set agents cannot have 'timeout_seconds'") - if self.duration is not None: - raise ValueError("set agents cannot have 'duration' (only 'wait' agents do)") - if self.output_mode is not None: - raise ValueError("set agents cannot have 'output_mode'") - elif self.type == "terminate": - # Required fields - if self.status is None: - raise ValueError( - "terminate agents require 'status' (must be 'success' or 'failed')" - ) - if not self.reason or not self.reason.strip(): - raise ValueError("terminate agents require a non-empty 'reason'") - # Routing and per-step machinery are meaningless on a terminal - # step — the engine ends the workflow as soon as it dispatches. - if self.routes: - raise ValueError( - "terminate agents cannot have 'routes' " - "(reaching a terminate step ends the workflow immediately)" - ) - if self.tools is not None: - raise ValueError("terminate agents cannot have 'tools'") - if self.output is not None: - raise ValueError( - "terminate agents cannot have 'output' " - "(use 'output_template' to override the workflow's final output)" - ) - if self.prompt: - raise ValueError("terminate agents cannot have 'prompt'") - if self.model: - raise ValueError("terminate agents cannot have 'model'") - if self.provider: - raise ValueError("terminate agents cannot have 'provider'") - if self.system_prompt: - raise ValueError("terminate agents cannot have 'system_prompt'") - if self.command: - raise ValueError("terminate agents cannot have 'command'") - if self.args: - raise ValueError("terminate agents cannot have 'args'") - if self.env: - raise ValueError("terminate agents cannot have 'env'") - if self.working_dir: - raise ValueError("terminate agents cannot have 'working_dir'") - if self.settings_dir is not None: - raise ValueError("terminate agents cannot have 'settings_dir'") - if self.timeout is not None: - raise ValueError("terminate agents cannot have 'timeout'") - if self.timeout_seconds is not None: - raise ValueError("terminate agents cannot have 'timeout_seconds'") - if self.max_session_seconds is not None: - raise ValueError("terminate agents cannot have 'max_session_seconds'") - if self.max_agent_iterations is not None: - raise ValueError("terminate agents cannot have 'max_agent_iterations'") - if self.session_key is not None: - raise ValueError("terminate agents cannot have 'session_key'") - if self.max_depth is not None: - raise ValueError("terminate agents cannot have 'max_depth'") - if self.retry is not None: - raise ValueError("terminate agents cannot have 'retry'") - if self.dialog is not None: - raise ValueError("terminate agents cannot have 'dialog'") - if self.validator is not None: - raise ValueError("terminate agents cannot have 'validator'") - if self.sandbox is not None: - raise ValueError("terminate agents cannot have 'sandbox'") - if self.reasoning is not None: - raise ValueError("terminate agents cannot have 'reasoning'") - if self.context_tier is not None: - raise ValueError("terminate agents cannot have 'context_tier'") - if self.skills is not None: - raise ValueError("terminate agents cannot have 'skills'") - if self.plugins is not None: - raise ValueError("terminate agents cannot have 'plugins'") - if self.workflow: - raise ValueError("terminate agents cannot have 'workflow'") - if self.input_mapping is not None: - raise ValueError("terminate agents cannot have 'input_mapping'") - if self.options: - raise ValueError("terminate agents cannot have 'options'") - # Cross-rejection with sibling step types: terminate has its own - # `reason` so we do NOT reject it (the `if self.type not in ...` - # block at the bottom of this method handles the - # other-type-rejection for `reason`). But these are exclusive to - # other step types and must not leak in. - if self.value is not None: - raise ValueError("terminate agents cannot have 'value' (only 'set' agents do)") - if self.values is not None: - raise ValueError("terminate agents cannot have 'values' (only 'set' agents do)") - if self.output_type is not None: - raise ValueError( - "terminate agents cannot have 'output_type' (only 'set' agents do)" - ) - if self.duration is not None: - raise ValueError("terminate agents cannot have 'duration' (only 'wait' agents do)") - if self.output_mode is not None: - raise ValueError("terminate agents cannot have 'output_mode'") - else: - # Regular agent or human_gate — input_mapping is not valid - if self.input_mapping is not None: - raise ValueError( - f"'{self.type or 'agent'}' agents cannot have 'input_mapping' " - "(only workflow agents support input_mapping)" - ) - if self.max_depth is not None: - raise ValueError( - f"'{self.type or 'agent'}' agents cannot have 'max_depth' " - "(only workflow agents support max_depth)" - ) - if self.value is not None: - raise ValueError( - f"'{self.type or 'agent'}' agents cannot have 'value' " - "(only 'set' agents support value)" - ) - if self.values is not None: - raise ValueError( - f"'{self.type or 'agent'}' agents cannot have 'values' " - "(only 'set' agents support values)" - ) - if self.output_type is not None: - raise ValueError( - f"'{self.type or 'agent'}' agents cannot have 'output_type' " - "(only 'set' agents support output_type)" - ) - # #262: regular agents may carry a literal or templated - # context_tier; validate the literal here and defer templates to - # runtime. (reasoning.effort is validated on ReasoningConfig.) - self._validate_context_tier() - if self.type == "workflow" and self.reasoning is not None: - raise ValueError("workflow agents cannot have 'reasoning'") - if self.type == "workflow" and self.context_tier is not None: - raise ValueError("workflow agents cannot have 'context_tier'") - if self.type == "workflow" and self.skills is not None: - raise ValueError("workflow agents cannot have 'skills'") - if self.type == "workflow" and self.plugins is not None: - raise ValueError("workflow agents cannot have 'plugins'") - - # Wait-only fields are forbidden on every other type. ``reason`` is - # shared with ``type: terminate`` (which has its own required-non- - # empty semantics enforced earlier), so it is rejected on every - # non-wait, non-terminate type with a message naming both owners. - if self.type != "wait": - if self.duration is not None: - raise ValueError( - f"'{self.type or 'agent'}' agents cannot have 'duration' " - "(only wait agents support duration)" - ) - if self.type != "terminate" and self.reason is not None: - raise ValueError( - f"'{self.type or 'agent'}' agents cannot have 'reason' " - "(only 'terminate' and 'wait' agents support this field)" - ) - if self.output_mode == "raw" and self.output: - raise ValueError( - "output_mode 'raw' is incompatible with output schema; " - "remove the output: block or use output_mode: envelope" - ) - return self + def validate_workflow_path(self) -> WorkflowStepDef: + """Re-assert the workflow-path invariant on instance revalidation. - def effective_output_schema(self) -> dict[str, OutputField] | None: - """Return the structured-output schema providers should enforce, or None. - - Centralizes the rule shared by every provider: an agent has an - effective output schema only when ``output:`` is a non-empty mapping - *and* ``output_mode`` is not ``raw``. An empty ``output: {}`` is - treated as "no schema" so all providers agree (Copilot previously - used a truthiness check while Claude used an ``is not None`` check, - diverging on the empty-dict case). + Pydantic skips field and before-model validators for an already-built + instance (e.g. a ``model_copy`` result nested inside ``WorkflowConfig``), + so the checks above never fire on that path; this after-model validator + does. """ - if self.output and self.output_mode != "raw": - return self.output - return None + if not self.workflow: + raise ValueError("workflow agents require 'workflow' path") + return self - def _validate_context_tier(self) -> None: - """Validate ``context_tier`` for a regular (provider-backed) agent. - An unset (``None``) or templated value (detected by - :func:`~conductor.templating.is_jinja_template`, matching ``{{`` or - ``{%``) defers all literal validation to runtime (rendered + validated - in :mod:`conductor.executor.agent`, alongside ``model``); a - non-templated value must be a valid - :data:`~conductor.providers.context_tier.ContextTier` literal. - - This differs from :meth:`_validate_wait_duration` on two counts: that - method matches only ``{{``, and it does not defer ``None``. - - Non-agent step types reject ``context_tier`` outright via their own - ``is not None`` checks in :meth:`validate_agent_type` (a template - string is still "not None"), so this helper is only dispatched from - the regular-agent branch. - """ - value = self.context_tier - if value is None or is_jinja_template(value): - return - if value not in get_args(ContextTier): - raise ValueError( - f"context_tier must be one of {list(get_args(ContextTier))} " - f"or a '{{{{ ... }}}}' template (got {value!r})" - ) +StepDef = Annotated[ + AgentDef + | HumanGateStepDef + | QuestionsStepDef + | ScriptStepDef + | MCPStepDef + | WaitStepDef + | SetStepDef + | TerminateStepDef + | WorkflowStepDef, + Field(discriminator="type"), + BeforeValidator(_normalize_step_type), +] - def _validate_wait_duration(self) -> None: - """Validate ``duration`` for a ``wait`` agent. - Templated durations (containing ``{{``) defer all literal - validation to runtime; for everything else we parse the value - and enforce ``0 < d <= MAX_WAIT_DURATION_SECONDS``. - - Note: Booleans are already rejected pre-coercion by the - :meth:`reject_bool_duration` ``mode="before"`` field validator, - so this method never sees ``True``/``False``. - """ - value = self.duration +class ForEachDef(BaseModel): + """Dynamic parallel execution group definition.""" - if isinstance(value, str) and "{{" in value: - return + model_config = ConfigDict(extra="forbid") - try: - seconds = parse_duration(value) # type: ignore[arg-type] - except ValueError as exc: - raise ValueError(f"wait duration is invalid: {exc}") from exc + name: str + description: str | None = None + type: Literal["for_each"] + source: str + as_: str = Field(..., serialization_alias="as", validation_alias="as") + agent: StepDef + max_concurrent: int = Field(default=10, ge=1, le=100) + failure_mode: Literal["fail_fast", "continue_on_error", "all_or_nothing"] = "fail_fast" + key_by: str | None = None + routes: list[RouteDef] = Field(default_factory=list) - if seconds <= 0: - raise ValueError(f"wait duration must be > 0 seconds (got {seconds!r})") - if seconds > MAX_WAIT_DURATION_SECONDS: + @field_validator("as_") + @classmethod + def validate_loop_variable(cls, value: str) -> str: + reserved = {"workflow", "context", "output", "_index", "_key"} + if value in reserved: raise ValueError( - f"wait duration {seconds!r}s exceeds the 24h cap " - f"({MAX_WAIT_DURATION_SECONDS}s); reconsider using " - "'limits.timeout_seconds' instead" + f"Loop variable '{value}' conflicts with reserved name. Reserved names: {reserved}" ) + if not value.isidentifier(): + raise ValueError(f"Loop variable '{value}' must be a valid Python identifier") + return value + + @field_validator("source") + @classmethod + def validate_source(cls, value: str) -> str: + return validate_dotted_source(value) class MCPServerDef(BaseModel): @@ -4077,7 +2871,7 @@ class WorkflowConfig(BaseModel): tools: list[str] = Field(default_factory=list) """Tools available to agents in this workflow.""" - agents: list[AgentDef] + agents: list[StepDef] """Agent definitions.""" parallel: list[ParallelGroup] = Field(default_factory=list) @@ -4106,7 +2900,7 @@ def validate_references(self) -> WorkflowConfig: # Validate route targets exist for agent in self.agents: - for route in agent.routes: + for route in getattr(agent, "routes", []): if route.to != "$end" and route.to not in all_names: raise ValueError( f"Agent '{agent.name}' routes to unknown agent, " @@ -4157,8 +2951,9 @@ def validate_root_level_output_required(self) -> WorkflowConfig: root output dict of an agent definition. """ for agent in self.agents: - if agent.output: - for field_name, field in agent.output.items(): + output = getattr(agent, "output", None) + if output: + for field_name, field in output.items(): if not field.required: raise ValueError( f"Agent '{agent.name}' output field '{field_name}': " @@ -4167,8 +2962,9 @@ def validate_root_level_output_required(self) -> WorkflowConfig: ) for for_each_group in self.for_each: agent = for_each_group.agent - if agent.output: - for field_name, field in agent.output.items(): + output = getattr(agent, "output", None) + if output: + for field_name, field in output.items(): if not field.required: raise ValueError( f"Agent '{agent.name}' output field '{field_name}': " diff --git a/src/conductor/config/validator.py b/src/conductor/config/validator.py index 853243f2..a1d8a7a7 100644 --- a/src/conductor/config/validator.py +++ b/src/conductor/config/validator.py @@ -10,11 +10,19 @@ import re from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple, TypeGuard import jinja2 from jinja2 import Environment, meta, nodes +from conductor.config.schema import ( + AgentDef, + HumanGateStepDef, + QuestionsStepDef, + RoutableStepBase, + TerminateStepDef, + WorkflowStepDef, +) from conductor.exceptions import ConfigurationError from conductor.plugins.errors import PluginError, PluginSourceUnavailableError from conductor.plugins.manifest import PluginFlavor @@ -37,7 +45,7 @@ from conductor.templating import is_jinja_template if TYPE_CHECKING: - from conductor.config.schema import AgentDef, WorkflowConfig + from conductor.config.schema import StepDef, WorkflowConfig from conductor.plugins.registry import ResolvedPlugin from conductor.skills import ResolvedSkill @@ -244,9 +252,10 @@ def _names(entries: Any) -> set[str]: referenced = _names(config.workflow.runtime.plugins) for agent in config.agents: - referenced |= _names(agent.plugins) + if _is_llm_agent(agent): + referenced |= _names(agent.plugins) for group in config.for_each: - if group.agent is not None: + if _is_llm_agent(group.agent): referenced |= _names(group.agent.plugins) return referenced @@ -299,11 +308,12 @@ def validate_workflow_config( # Validate each agent for agent in config.agents: # Validate route targets - allow routing to agents and parallel groups - agent_errors = _validate_agent_routes(agent.name, agent.routes, all_names) + agent_routes = agent.routes if isinstance(agent, RoutableStepBase) else [] + agent_errors = _validate_agent_routes(agent.name, agent_routes, all_names) errors.extend(agent_errors) # Validate human_gate has options - if agent.type == "human_gate": + if isinstance(agent, HumanGateStepDef): if not agent.options: errors.append(f"Agent '{agent.name}' is a human_gate but has no options defined") else: @@ -319,7 +329,7 @@ def validate_workflow_config( # only after the human has worked through the node, so an unknown # target must not wait until then to surface. if ( - agent.type == "questions" + isinstance(agent, QuestionsStepDef) and agent.abort_route is not None and agent.abort_route != "$end" and agent.abort_route not in all_names @@ -342,19 +352,16 @@ def validate_workflow_config( warnings.extend(input_warnings) # Validate tool references (skip for script, set, and wait agents — they don't use tools) - if agent.tools is not None and agent.tools and agent.type not in ("script", "set", "wait"): - tool_errors = _validate_tool_references(agent.name, agent.tools, set(config.tools)) + agent_tools = getattr(agent, "tools", None) + if agent_tools and agent.type not in ("script", "set", "wait"): + tool_errors = _validate_tool_references(agent.name, agent_tools, set(config.tools)) errors.extend(tool_errors) # Warn when an LLM agent has system_prompt but no (non-empty) prompt. # Omitting `prompt:` leaves the user-authored task prompt empty, which # almost always means dynamic, must-execute content belongs in `prompt:` # alongside the persona/methodology in `system_prompt:`. - if ( - agent.type in (None, "agent") - and agent.system_prompt - and not (agent.prompt and agent.prompt.strip()) - ): + if _is_llm_agent(agent) and agent.system_prompt and not agent.prompt.strip(): warnings.append( f"Agent '{agent.name}' defines `system_prompt` but no `prompt` " "(or only whitespace). " @@ -372,29 +379,37 @@ def validate_workflow_config( # Validate for_each groups: reject step types that can't be used inline for for_each_group in config.for_each: - if for_each_group.agent.type == "script": + inline_agent = for_each_group.agent + if inline_agent.type == "script": errors.append( f"For-each group '{for_each_group.name}' uses a script step as its " "inline agent. Script steps cannot be used in for_each groups." ) - if for_each_group.agent.type == "wait": + if inline_agent.type == "wait": errors.append( f"For-each group '{for_each_group.name}' uses a wait step as its " "inline agent. Wait steps cannot be used in for_each groups." ) - if for_each_group.agent.type == "terminate": + if inline_agent.type == "terminate": errors.append( f"For-each group '{for_each_group.name}' uses a terminate step as its " "inline agent. Terminate steps cannot run inside a for_each iteration; " "route to a terminate step from the for_each group's routes instead." ) - if for_each_group.agent.type == "questions": + if inline_agent.type == "questions": errors.append( f"For-each group '{for_each_group.name}' uses a questions step as its " "inline agent. Concurrent iterations would compete for one terminal and " "one dashboard prompt slot; route to a questions step from the for_each " "group's routes instead." ) + if isinstance(inline_agent, HumanGateStepDef): + errors.append( + f"For-each group '{for_each_group.name}' uses a human gate as its " + "inline agent. Concurrent iterations would compete for one interactive " + "gate channel; route to a human gate from the for_each group's routes " + "instead." + ) # Validate sub-workflow references (local paths and registry refs). # Skipped when workflow_path is not provided — relative paths cannot be @@ -679,11 +694,14 @@ def _validate_mcp_steps(config: WorkflowConfig) -> list[str]: errors: list[str] = [] servers = config.workflow.runtime.mcp_servers - # (agent, enclosing for_each group name or None) - mcp_agents: list[tuple[AgentDef, str | None]] = [ - (agent, None) for agent in config.agents if agent.type == "mcp" + from conductor.config.schema import MCPStepDef + + mcp_agents: list[tuple[MCPStepDef, str | None]] = [ + (agent, None) for agent in config.agents if isinstance(agent, MCPStepDef) ] - mcp_agents += [(fe.agent, fe.name) for fe in config.for_each if fe.agent.type == "mcp"] + mcp_agents.extend( + (fe.agent, fe.name) for fe in config.for_each if isinstance(fe.agent, MCPStepDef) + ) for agent, for_each_group in mcp_agents: label = ( @@ -691,10 +709,6 @@ def _validate_mcp_steps(config: WorkflowConfig) -> list[str]: if for_each_group is None else f"Agent '{agent.name}' in for-each group '{for_each_group}'" ) - if agent.server is None or agent.tool is None: - # Schema validation already rejects mcp agents without - # server/tool; this guard only narrows the types below. - continue server_def = servers.get(agent.server) if server_def is None: available = ", ".join(sorted(servers)) or "(none declared)" @@ -817,7 +831,8 @@ def _validate_parallel_groups(config: WorkflowConfig) -> list[str]: agent = agents_by_name[agent_name] # PE-2.3: Validate parallel agents have no routes - if agent.routes: + routes = getattr(agent, "routes", []) + if routes: errors.append( f"Agent '{agent_name}' in parallel group '{pg.name}' cannot have routes. " "Agents within parallel groups must not define their own routing logic." @@ -947,19 +962,19 @@ def _build_routing_graph(config: WorkflowConfig) -> dict[str, list[tuple[str, bo graph: dict[str, list[tuple[str, bool]]] = {} for agent in config.agents: # Terminate steps end the workflow; treat them as sinks with no edges. - if agent.type == "terminate": + if isinstance(agent, TerminateStepDef): graph[agent.name] = [] continue edges: list[tuple[str, bool]] = [] - if agent.routes: + if isinstance(agent, RoutableStepBase) and agent.routes: for route in agent.routes: edges.append((route.to, route.when is not None)) - elif agent.type == "human_gate" and agent.options: + elif isinstance(agent, HumanGateStepDef) and agent.options: for option in agent.options: edges.append((option.route, True)) # An abort route is a conditional edge like any other; without it an # agent reachable only via abort is invisible to path analysis. - if agent.type == "questions" and agent.allow_abort: + if isinstance(agent, QuestionsStepDef) and agent.allow_abort: edges.append((agent.abort_route or "$end", True)) graph[agent.name] = edges for pg in config.parallel: @@ -1302,7 +1317,9 @@ def _validate_output_path_coverage(config: WorkflowConfig) -> list[str]: # not consume the workflow `output:` mapping and would produce spurious # "not reached" warnings. overriding_terminators = { - a.name for a in config.agents if a.type == "terminate" and a.output_template is not None + a.name + for a in config.agents + if isinstance(a, TerminateStepDef) and a.output_template is not None } paths = [p for p in paths if not p or p[-1] not in overriding_terminators] @@ -1354,9 +1371,7 @@ def _collect_argument_strings(label: str, value: Any) -> list[tuple[str, str]]: return collected -def _collect_template_strings( - agent: AgentDef, -) -> list[tuple[str, str]]: +def _collect_template_strings(agent: StepDef) -> list[tuple[str, str]]: """Collect all Jinja2 template strings from an agent definition. Returns: @@ -1364,16 +1379,20 @@ def _collect_template_strings( """ templates: list[tuple[str, str]] = [] - if agent.prompt: - templates.append((f"agent '{agent.name}' prompt", agent.prompt)) - if agent.system_prompt: - templates.append((f"agent '{agent.name}' system_prompt", agent.system_prompt)) - if agent.command: - templates.append((f"agent '{agent.name}' command", agent.command)) - for i, arg in enumerate(agent.args): + prompt = getattr(agent, "prompt", None) + if prompt: + templates.append((f"agent '{agent.name}' prompt", prompt)) + system_prompt = getattr(agent, "system_prompt", None) + if system_prompt: + templates.append((f"agent '{agent.name}' system_prompt", system_prompt)) + command = getattr(agent, "command", None) + if command: + templates.append((f"agent '{agent.name}' command", command)) + for i, arg in enumerate(getattr(agent, "args", [])): templates.append((f"agent '{agent.name}' args[{i}]", arg)) - if agent.working_dir: - templates.append((f"agent '{agent.name}' working_dir", agent.working_dir)) + working_dir = getattr(agent, "working_dir", None) + if working_dir: + templates.append((f"agent '{agent.name}' working_dir", working_dir)) # getattr for the same reason the 'set' bindings below use it: duck-typed # test fixtures predate this field and would raise on direct access. settings_dir = getattr(agent, "settings_dir", None) @@ -1415,19 +1434,19 @@ def _collect_template_strings( # `SimpleNamespace`-style stubs are forward-compat tests like # `TestInputMappingTemplateCollection`) never set `type="terminate"`, so # they don't enter this branch and don't need the `getattr` fallback. - from conductor.config.schema import AgentDef as _AgentDef + from conductor.config.schema import TerminateStepDef - if isinstance(agent, _AgentDef) and agent.type == "terminate": - if agent.reason is not None: - templates.append((f"agent '{agent.name}' reason", agent.reason)) + if isinstance(agent, TerminateStepDef): + templates.append((f"agent '{agent.name}' reason", agent.reason)) if agent.output_template: for key, expr in agent.output_template.items(): templates.append((f"agent '{agent.name}' output_template.{key}", expr)) # Questions steps: text/hint/choices are Jinja2-rendered by the engine, so # bad refs must fail at validate-time like every other rendered field. - if isinstance(agent, _AgentDef) and agent.questions: - for i, question in enumerate(agent.questions): + questions = getattr(agent, "questions", None) + if questions: + for i, question in enumerate(questions): templates.append((f"agent '{agent.name}' questions[{i}].text", question.text)) if question.hint: templates.append((f"agent '{agent.name}' questions[{i}].hint", question.hint)) @@ -1491,11 +1510,11 @@ def _validate_subworkflow_refs( # Collect all (agent_name, workflow_ref, context_label) tuples to validate. candidates: list[tuple[str, str, str]] = [] for agent in config.agents: - if agent.type == "workflow" and agent.workflow: + if isinstance(agent, WorkflowStepDef) and agent.workflow: candidates.append((agent.name, agent.workflow, f"agent '{agent.name}'")) for fe in config.for_each: agent = fe.agent - if agent.type == "workflow" and agent.workflow: + if isinstance(agent, WorkflowStepDef) and agent.workflow: candidates.append( (agent.name, agent.workflow, f"for_each group '{fe.name}' agent '{agent.name}'") ) @@ -1661,7 +1680,7 @@ def _validate_template_references( is_explicit = config.workflow.context.mode == "explicit" # Collect all agents including for-each inline agents. - all_agents: list[tuple[AgentDef, set[str]]] = [] + all_agents: list[tuple[StepDef, set[str]]] = [] for agent in config.agents: all_agents.append((agent, all_names)) for fe in config.for_each: @@ -1910,15 +1929,9 @@ def _validate_template_references( # Provider capability cross-checks (issue #241) # --------------------------------------------------------------------------- -# Agent types that drive a provider. All other types (human_gate, questions, -# script, set, terminate, wait, workflow) do not invoke a provider directly and -# are skipped by every capability check. -_LLM_AGENT_TYPES = frozenset({None, "agent"}) - -def _is_llm_agent(agent: AgentDef) -> bool: - """True iff this agent invokes a provider (vs. human_gate, script, etc.).""" - return agent.type in _LLM_AGENT_TYPES +def _is_llm_agent(agent: StepDef) -> TypeGuard[AgentDef]: + return isinstance(agent, AgentDef) def _project_tier_enabled(config: WorkflowConfig, agent: AgentDef) -> bool: @@ -2899,7 +2912,7 @@ def _effective_working_dir(agent: AgentDef) -> str | None: claimed: dict[tuple[str, str | None], str] = {} for member_name in pg.agents: member = agent_by_name.get(member_name) - if member is None or member.session_key is None: + if member is None or not _is_llm_agent(member) or member.session_key is None: continue slot = (member.session_key, _effective_working_dir(member)) first = claimed.get(slot) @@ -2916,16 +2929,19 @@ def _effective_working_dir(agent: AgentDef) -> str | None: claimed[slot] = member_name for fe in config.for_each: - if fe.max_concurrent <= 1 or fe.agent.session_key is None: + session_key = getattr(fe.agent, "session_key", None) + if fe.max_concurrent <= 1 or session_key is None: continue # A per-item working_dir gives each iteration its own session, so only # a directory shared by every iteration is unsafe. + if not _is_llm_agent(fe.agent): + continue working_dir = _effective_working_dir(fe.agent) or "" if _references_loop_variable(working_dir, fe.as_): continue errors.append( f"For-each group '{fe.name}' has max_concurrent={fe.max_concurrent} " - f"and declares session_key: '{fe.agent.session_key}' without a " + f"and declares session_key: '{session_key}' without a " f"per-item working_dir. Every iteration would resume one session " f"concurrently — set max_concurrent: 1, remove the session_key, or " f"give each item its own working_dir." diff --git a/src/conductor/engine/workflow.py b/src/conductor/engine/workflow.py index 51d4c630..cc3354ba 100644 --- a/src/conductor/engine/workflow.py +++ b/src/conductor/engine/workflow.py @@ -21,6 +21,18 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Literal +from conductor.config.schema import ( + AgentDef, + HumanGateStepDef, + MCPStepDef, + QuestionsStepDef, + RoutableStepBase, + ScriptStepDef, + SetStepDef, + TerminateStepDef, + WaitStepDef, + WorkflowStepDef, +) from conductor.duration import parse_duration from conductor.engine.checkpoint import CheckpointManager, CheckpointTrigger from conductor.engine.context import WorkflowContext @@ -89,10 +101,10 @@ from collections.abc import Coroutine, Mapping from conductor.config.schema import ( - AgentDef, ForEachDef, ParallelGroup, ProviderName, + StepDef, WorkflowConfig, ) from conductor.interrupt.listener import KeyboardListener @@ -1317,7 +1329,7 @@ async def _build_static_subworkflow_topology( agents_out: list[dict[str, Any]] = [] for a in sub_config.agents: entry: dict[str, Any] = {"name": a.name, "type": a.type or "agent"} - if a.type == "workflow" and a.workflow: + if isinstance(a, WorkflowStepDef): entry["subworkflow"] = await self._build_static_subworkflow_topology( a.workflow, a.name, next_base_dir, depth + 1, next_visited ) @@ -1336,12 +1348,12 @@ async def _build_static_subworkflow_topology( "routes": [ {"from": a.name, "to": r.to, "when": r.when} for a in sub_config.agents - for r in a.routes + for r in getattr(a, "routes", []) ] + [ {"from": a.name, "to": o.route, "when": f"selection == '{o.value}'"} for a in sub_config.agents - if a.type == "human_gate" and a.options + if isinstance(a, HumanGateStepDef) for o in a.options ] + [ @@ -1404,7 +1416,7 @@ async def build_workflow_started_data(self) -> dict[str, Any]: def _provider_for(agent_name: str) -> str: for agent in self.config.agents: if agent.name == agent_name: - return agent.provider or default_provider_name + return getattr(agent, "provider", None) or default_provider_name return default_provider_name def _record_provider(name: str) -> None: @@ -1458,9 +1470,9 @@ def _record_provider(name: str) -> None: # badge appears for for_each-only experimental providers. _record_provider(default_provider_name) for a in self.config.agents: - _record_provider(a.provider or default_provider_name) + _record_provider(getattr(a, "provider", None) or default_provider_name) for fe in self.config.for_each: - _record_provider(fe.agent.provider or default_provider_name) + _record_provider(getattr(fe.agent, "provider", None) or default_provider_name) # Base dir for eager sub-workflow resolution (relative `workflow:` # paths are resolved against the parent workflow file's directory). @@ -1476,17 +1488,23 @@ def _record_provider(name: str) -> None: entry: dict[str, Any] = { "name": a.name, "type": a.type or "agent", - "model": a.model, + "model": getattr(a, "model", None), # Provider that this agent will actually use at runtime # — populated for every agent (including non-LLM types # for consistency; consumers can filter on `type`). "provider_name": _provider_for(a.name), "reasoning_effort": ( - a.reasoning.effort if a.reasoning is not None else default_effort + a.reasoning.effort + if isinstance(a, AgentDef) and a.reasoning is not None + else default_effort + ), + "context_tier": ( + a.context_tier + if isinstance(a, AgentDef) and a.context_tier is not None + else default_tier ), - "context_tier": (a.context_tier if a.context_tier is not None else default_tier), } - if a.type == "workflow" and a.workflow: + if isinstance(a, WorkflowStepDef): # Eagerly resolve the sub-workflow's topology so the # dashboard can render it (and let the user expand it) # before the engine ever reaches this step. Best-effort: @@ -1528,7 +1546,7 @@ def _record_provider(name: str) -> None: "when": r.when, } for a in self.config.agents - for r in a.routes + for r in getattr(a, "routes", []) ] + [ { @@ -1537,7 +1555,7 @@ def _record_provider(name: str) -> None: "when": f"selection == '{o.value}'", } for a in self.config.agents - if a.type == "human_gate" and a.options + if isinstance(a, HumanGateStepDef) for o in a.options ] + [ @@ -1720,7 +1738,7 @@ async def _get_executor_for_agent(self, agent: AgentDef) -> AgentExecutor: suggestion="Provide either a provider or registry to WorkflowEngine", ) - async def _execute_script(self, agent: AgentDef, context: dict[str, Any]) -> ScriptOutput: + async def _execute_script(self, agent: ScriptStepDef, context: dict[str, Any]) -> ScriptOutput: """Execute a script step with workflow-level timeout enforcement. Args: @@ -1738,7 +1756,7 @@ async def _execute_script(self, agent: AgentDef, context: dict[str, Any]) -> Scr operation_name=f"script '{agent.name}'", ) - async def _execute_wait(self, agent: AgentDef, context: dict[str, Any]) -> WaitOutput: + async def _execute_wait(self, agent: WaitStepDef, context: dict[str, Any]) -> WaitOutput: """Execute a wait step with workflow-level timeout enforcement. The wait races ``asyncio.sleep`` against the engine's @@ -1764,7 +1782,7 @@ async def _execute_wait(self, agent: AgentDef, context: dict[str, Any]) -> WaitO operation_name=f"wait '{agent.name}'", ) - async def _run_set_step(self, agent: AgentDef, agent_context: dict[str, Any]) -> SetOutput: + async def _run_set_step(self, agent: SetStepDef, agent_context: dict[str, Any]) -> SetOutput: """Execute a set step end-to-end with full event + validation parity. Shared between the main dispatch loop, parallel groups, and @@ -1852,7 +1870,7 @@ async def _run_set_step(self, agent: AgentDef, agent_context: dict[str, Any]) -> async def _run_mcp_step( self, - agent: AgentDef, + agent: MCPStepDef, agent_context: dict[str, Any], *, event_fields: Mapping[str, Any] | None = None, @@ -2179,7 +2197,7 @@ def _write_mcp_diagnostic( return path async def _invoke_mcp_interruptible( - self, agent: AgentDef, invocation: Coroutine[Any, Any, dict[str, Any]] + self, agent: MCPStepDef, invocation: Coroutine[Any, Any, dict[str, Any]] ) -> dict[str, Any]: """Race an mcp slot/connect/call invocation against a user Stop. @@ -2218,7 +2236,7 @@ async def _invoke_mcp_interruptible( def _validate_script_output_schema( self, - agent: AgentDef, + agent: ScriptStepDef, parsed_json: Any, json_parse_error: Exception | None, output_content: dict[str, Any], @@ -2331,7 +2349,7 @@ async def _execute_with_agent_timeout( def _build_subworkflow_inputs( self, - agent: AgentDef, + agent: WorkflowStepDef, context: dict[str, Any], ) -> dict[str, Any]: """Build sub-workflow inputs from an agent's input_mapping or defaults. @@ -2532,7 +2550,7 @@ async def _resolve_subworkflow_path_uncached( async def _execute_subworkflow( self, - agent: AgentDef, + agent: WorkflowStepDef, context: dict[str, Any], slot_key: str | None = None, ) -> dict[str, Any]: @@ -2665,7 +2683,7 @@ async def _execute_subworkflow( async def _execute_subworkflow_with_inputs( self, - agent: AgentDef, + agent: WorkflowStepDef, sub_inputs: dict[str, Any], slot_key: str | None = None, ) -> tuple[dict[str, Any], WorkflowUsage]: @@ -2892,7 +2910,7 @@ async def _run_child_engine( self, child_engine: WorkflowEngine, sub_inputs: dict[str, Any], - agent: AgentDef, + agent: WorkflowStepDef, ) -> dict[str, Any]: """Run a child sub-workflow engine and convert child-level termination. @@ -3720,7 +3738,7 @@ async def _resume_listener(self) -> None: if self._keyboard_listener is not None: await self._keyboard_listener.resume() - async def _run_questions_step(self, agent: AgentDef) -> dict[str, Any]: + async def _run_questions_step(self, agent: QuestionsStepDef) -> dict[str, Any]: """Present a set of questions to a human and collect their answers. Runs the whole cursor loop inside one engine step (issue #376). @@ -3832,7 +3850,7 @@ def _render(text: str) -> str: async def _run_questions_loop( self, - agent: AgentDef, + agent: QuestionsStepDef, order: list[questions_mod.ResolvedQuestion], records: dict[str, questions_mod.AnswerRecord], intro: str | None, @@ -4058,7 +4076,7 @@ def _next_prompt_id() -> str: def _store_questions_progress( self, - agent: AgentDef, + agent: QuestionsStepDef, records: dict[str, questions_mod.AnswerRecord], order: list[questions_mod.ResolvedQuestion], ) -> None: @@ -4142,7 +4160,7 @@ def _restore_question_records( async def _handle_gate_with_web( self, - agent: AgentDef, + agent: HumanGateStepDef, agent_context: dict[str, Any], ) -> GateResult: """Handle a ``human_gate``, mapping the shared prompt back onto routes. @@ -5312,16 +5330,17 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]: ) # Resolve working_dir / settings_dir for provider-backed LLM agents - # (type None/"agent"). wait/set/terminate/human_gate/ + # (type "agent"). wait/set/terminate/human_gate/ # workflow are schema-rejected from declaring one, and # script resolves its own in ScriptExecutor. - is_llm_agent = agent.type in (None, "agent") - resolved_agent = ( - self._resolve_agent_working_dir(agent, agent_context) - if is_llm_agent - else agent + resolved_agent: AgentDef | None = None + if isinstance(agent, AgentDef): + resolved_agent = self._resolve_agent_working_dir(agent, agent_context) + event_provider = ( + self._provider_name_for(resolved_agent) + if resolved_agent is not None + else self.config.workflow.runtime.provider.name ) - event_provider = self._provider_name_for(resolved_agent) # Only an LLM agent has a context window to report, and # asking for one *constructs the provider* — an SDK @@ -5335,15 +5354,15 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]: started_payload: dict[str, Any] = { "agent_name": agent.name, "iteration": agent_execution_count, - "agent_type": agent.type or "agent", + "agent_type": agent.type, "provider": event_provider, "context_window_max": ( await self._get_context_window_for_agent(resolved_agent) - if is_llm_agent + if resolved_agent is not None else None ), } - if is_llm_agent: + if resolved_agent is not None: started_payload["working_dir"] = resolved_agent.working_dir # Emitted alongside working_dir because it is a # trust decision: settings_dir loads another @@ -5364,7 +5383,7 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]: # is a terminate ends immediately on dispatch). The # engine ends the workflow on this branch — no routes # evaluated after. - if agent.type == "terminate": + if isinstance(agent, TerminateStepDef): terminate_elapsed = _time.time() - _workflow_start # Render the reason against context first so the # rendered value is available to output_template @@ -5474,7 +5493,7 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]: ) # Handle human gates - if agent.type == "human_gate": + if isinstance(agent, HumanGateStepDef): # Build context for the gate prompt agent_context = self.context.get_for_template() @@ -5552,7 +5571,7 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]: # Handle questions steps. N human prompts inside ONE # engine step, so the cursor can move backwards and the # node costs 1 iteration rather than 2N. - if agent.type == "questions": + if isinstance(agent, QuestionsStepDef): questions_output = await self._run_questions_step(agent) self.context.store(agent.name, questions_output) self.limits.record_execution(agent.name) @@ -5589,7 +5608,7 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]: continue # Handle script steps - if agent.type == "script": + if isinstance(agent, ScriptStepDef): _script_start = _time.time() # Count how many times this specific script has been executed @@ -5731,7 +5750,7 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]: continue # Handle wait steps - if agent.type == "wait": + if isinstance(agent, WaitStepDef): _wait_start = _time.time() wait_execution_count = ( @@ -5866,7 +5885,7 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]: # Handle set steps. Pure context transformations: # render, coerce, validate, emit, route. - if agent.type == "set": + if isinstance(agent, SetStepDef): set_output = await self._run_set_step(agent, agent_context) self.context.store(agent.name, set_output.value) self.limits.record_execution(agent.name) @@ -5917,7 +5936,7 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]: # result envelope (with merged structured keys) lands # in context like set/script outputs, so routing on # e.g. ``output.is_error`` works unchanged. - if agent.type == "mcp": + if isinstance(agent, MCPStepDef): try: mcp_envelope = await self._run_mcp_step( agent, agent_context, allow_interrupt=True @@ -5996,7 +6015,7 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]: continue # Handle sub-workflow steps - if agent.type == "workflow": + if isinstance(agent, WorkflowStepDef): _sub_start = _time.time() sub_execution_count = ( @@ -6086,6 +6105,13 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]: # agent_started. Subsequent model_copy(update={...}) calls # inside AgentExecutor merge, so the resolved working_dir # survives to the provider. + # Every non-LLM variant returned or continued above, so + # only a provider-backed LLM agent reaches this point. + if not isinstance(agent, AgentDef) or resolved_agent is None: + raise ExecutionError( + f"Step '{agent.name}' of type {agent.type!r} cannot be " + "executed as a provider-backed agent" + ) _agent_start = _time.time() executor = await self._get_executor_for_agent(resolved_agent) guidance_section = self.context.get_guidance_prompt_section() @@ -6789,7 +6815,7 @@ async def _drain_iteration_limit_losers(pending: set[asyncio.Task[Any]]) -> None exc_info=True, ) - def _find_agent(self, name: str) -> AgentDef | None: + def _find_agent(self, name: str) -> StepDef | None: """Find agent by name. Args: @@ -7102,7 +7128,7 @@ async def _execute_parallel_group(self, parallel_group: ParallelGroup) -> Parall ) agents.append(agent) - async def execute_single_agent(agent: AgentDef) -> tuple[str, Any]: + async def execute_single_agent(agent: StepDef) -> tuple[str, Any]: """Execute a single agent with the context snapshot. Returns: @@ -7129,7 +7155,7 @@ async def execute_single_agent(agent: AgentDef) -> tuple[str, Any]: # emit set_started/set_completed/set_failed via _run_set_step # so the dashboard renders set nodes consistently with the # linear path. - if agent.type == "set": + if isinstance(agent, SetStepDef): set_output = await self._run_set_step(agent, agent_context) _agent_elapsed = _time.time() - _agent_start self._emit( @@ -7155,7 +7181,7 @@ async def execute_single_agent(agent: AgentDef) -> tuple[str, Any]: # the set branch above: no `parallel_agent_started` (that # event is LLM-only), and `parallel_agent_completed` carries # no `output` field — the no-values policy for step events. - if agent.type == "mcp": + if isinstance(agent, MCPStepDef): mcp_envelope = await self._run_mcp_step( agent, agent_context, @@ -7181,6 +7207,13 @@ async def execute_single_agent(agent: AgentDef) -> tuple[str, Any]: # Resolve working_dir / settings_dir for provider-backed LLM agents against # this agent's own (pre-group snapshot) context. `set` steps # returned above; other types in a parallel group are LLM agents. + # The static validator rejects every other variant in parallel + # groups, but a directly-constructed engine skips it. + if not isinstance(agent, AgentDef): + raise ExecutionError( + f"Step '{agent.name}' of type {agent.type!r} cannot execute " + "in a parallel group" + ) resolved_agent = self._resolve_agent_working_dir(agent, agent_context) # LLM-only per-member start event: emitted only here (after the @@ -7532,6 +7565,17 @@ async def _execute_for_each_group(self, for_each_group: ForEachDef) -> ForEachGr # Resolve the source array from context items = self._resolve_array_reference(for_each_group.source) + # Reject unsupported inline variants before the empty-array early + # return below can let them pass silently. The static validator + # rejects these, but a directly-constructed engine skips it; the + # per-item guard in execute_single_item stays as a second line. + inline_agent = for_each_group.agent + if not isinstance(inline_agent, (AgentDef, WorkflowStepDef, SetStepDef, MCPStepDef)): + raise ExecutionError( + f"Step of type {inline_agent.type!r} cannot execute " + f"as the inline agent of for-each group '{for_each_group.name}'" + ) + # Handle empty arrays gracefully if not items: logger.debug( @@ -7606,7 +7650,7 @@ async def execute_single_item(item: Any, index: int, key: str) -> tuple[str, Any ) # Execute agent — sub-workflow or regular - if for_each_group.agent.type == "workflow": + if isinstance(for_each_group.agent, WorkflowStepDef): # Build sub-workflow inputs using shared helper (consistent # JSON-parse-with-fallback across all sub-workflow paths) sub_inputs = self._build_subworkflow_inputs(for_each_group.agent, agent_context) @@ -7684,7 +7728,7 @@ async def execute_single_item(item: Any, index: int, key: str) -> tuple[str, Any # emit set_started/set_completed/set_failed via _run_set_step # so the dashboard renders per-item set nodes consistently # with the linear path. - if for_each_group.agent.type == "set": + if isinstance(for_each_group.agent, SetStepDef): set_output = await self._run_set_step(for_each_group.agent, agent_context) _item_elapsed = _time.time() - _item_start self._emit( @@ -7707,7 +7751,7 @@ async def execute_single_item(item: Any, index: int, key: str) -> tuple[str, Any # field on for_each_item_completed — deliberate divergence # from the set branch above: mcp events follow the no-values # policy, the envelope is data for routing, never for events. - if for_each_group.agent.type == "mcp": + if isinstance(for_each_group.agent, MCPStepDef): mcp_envelope = await self._run_mcp_step( for_each_group.agent, agent_context, @@ -7736,8 +7780,17 @@ async def execute_single_item(item: Any, index: int, key: str) -> tuple[str, Any # lines) can attribute interleaved output to a specific # for-each iteration. The original AgentDef is untouched — # only this iteration's copy carries the qualified name. - qualified_agent = for_each_group.agent.model_copy( - update={"name": f"{for_each_group.agent.name}[{key}]"} + # The static validator limits inline for-each agents to + # workflow/set/mcp/LLM steps, but a directly-constructed + # engine skips it — reject anything else explicitly. + inline_agent = for_each_group.agent + if not isinstance(inline_agent, AgentDef): + raise ExecutionError( + f"Step of type {inline_agent.type!r} cannot execute " + f"as the inline agent of for-each group '{for_each_group.name}'" + ) + qualified_agent = inline_agent.model_copy( + update={"name": f"{inline_agent.name}[{key}]"} ) # Resolve working_dir / settings_dir AFTER loop variables were injected into @@ -8062,7 +8115,7 @@ def _get_next_agent(self, agent: AgentDef, output: dict[str, Any]) -> str: result = self._evaluate_routes(agent, output) return result.target - def _evaluate_routes(self, agent: AgentDef, output: dict[str, Any]) -> RouteResult: + def _evaluate_routes(self, agent: RoutableStepBase, output: dict[str, Any]) -> RouteResult: """Evaluate routes using the Router. Uses the Router to evaluate routing rules and determine the next agent. @@ -8162,7 +8215,7 @@ def _build_final_output( return result - def _build_terminate_output(self, agent: AgentDef) -> dict[str, Any]: + def _build_terminate_output(self, agent: TerminateStepDef) -> dict[str, Any]: """Build the final output for a ``type: terminate`` step. When ``agent.output_template`` is set, render its entries against the @@ -8410,7 +8463,7 @@ def _trace_path( routes_info = [] route_targets = [] - if agent.routes: + if isinstance(agent, RoutableStepBase) and agent.routes: for route in agent.routes: routes_info.append( { @@ -8420,7 +8473,7 @@ def _trace_path( } ) route_targets.append(route.to) - elif agent.options: + elif isinstance(agent, HumanGateStepDef) and agent.options: # Human gate with options for option in agent.options: routes_info.append( @@ -8436,8 +8489,8 @@ def _trace_path( # Build step step = ExecutionStep( agent_name=agent_name, - agent_type=agent.type or "agent", - model=agent.model, + agent_type=agent.type, + model=agent.model if isinstance(agent, AgentDef) else None, routes=routes_info, is_loop_target=False, # Will be updated after traversal ) diff --git a/src/conductor/executor/agent.py b/src/conductor/executor/agent.py index e9cfeb85..14fb1f0c 100644 --- a/src/conductor/executor/agent.py +++ b/src/conductor/executor/agent.py @@ -660,8 +660,6 @@ def _resolve_skills_for_agent(self, agent: AgentDef) -> list[ResolvedSkill]: SkillManifestError: If an explicit entry's ``SKILL.md`` is missing, unparseable, or incomplete. """ - if agent.type not in (None, "agent"): - return [] overridden = agent.skills is not None # Repeat the ``is not None`` rather than reusing ``overridden``: the # type checker does not narrow through an intermediate boolean. @@ -715,8 +713,6 @@ def _resolve_plugins_for_agent(self, agent: AgentDef) -> list[ResolvedPlugin]: PluginError: If an entry cannot be resolved, or a resolved plugin is unusable. """ - if agent.type not in (None, "agent"): - return [] entries = list(agent.plugins) if agent.plugins is not None else list(self._workflow_plugins) if not entries: return [] diff --git a/src/conductor/executor/mcp_step.py b/src/conductor/executor/mcp_step.py index 76366334..e57aef6b 100644 --- a/src/conductor/executor/mcp_step.py +++ b/src/conductor/executor/mcp_step.py @@ -43,7 +43,7 @@ from conductor.executor.template import TemplateRenderer if TYPE_CHECKING: - from conductor.config.schema import AgentDef + from conductor.config.schema import MCPStepDef from conductor.mcp.manager import MCPManager logger = logging.getLogger(__name__) @@ -156,7 +156,7 @@ def __init__(self) -> None: async def execute( self, - agent: AgentDef, + agent: MCPStepDef, agent_context: dict[str, Any], manager: MCPManager, ) -> dict[str, Any]: @@ -179,7 +179,7 @@ async def execute( RuntimeError: Call failure or malformed structured content — propagated from the manager. """ - # Guaranteed by AgentDef.validate_agent_type (config/schema.py) for + # Guaranteed by MCPStepDef.validate_agent_type (config/schema.py) for # type == "mcp": both fields are required and non-empty. assert agent.server is not None assert agent.tool is not None diff --git a/src/conductor/executor/questions.py b/src/conductor/executor/questions.py index e12af913..e8380e27 100644 --- a/src/conductor/executor/questions.py +++ b/src/conductor/executor/questions.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: from collections.abc import Callable - from conductor.config.schema import AgentDef, QuestionDef + from conductor.config.schema import QuestionDef, QuestionsStepDef logger = logging.getLogger(__name__) @@ -128,7 +128,7 @@ class NavFlags: abort: bool = False @classmethod - def resolve(cls, agent: AgentDef) -> NavFlags: + def resolve(cls, agent: QuestionsStepDef) -> NavFlags: """Apply defaults to a node's declared navigation flags. Args: @@ -286,7 +286,7 @@ def _render(text: str) -> str: def build_prompt( - agent: AgentDef, + agent: QuestionsStepDef, question: ResolvedQuestion, *, nav: NavFlags, @@ -366,7 +366,7 @@ def build_prompt( def build_review_prompt( - agent: AgentDef, + agent: QuestionsStepDef, records: dict[str, AnswerRecord], order: list[ResolvedQuestion], *, diff --git a/src/conductor/executor/script.py b/src/conductor/executor/script.py index bf0b0e0d..52653add 100644 --- a/src/conductor/executor/script.py +++ b/src/conductor/executor/script.py @@ -29,7 +29,7 @@ def _verbose_log(message: str, style: str = "dim") -> None: if TYPE_CHECKING: - from conductor.config.schema import AgentDef + from conductor.config.schema import ScriptStepDef @dataclass @@ -69,7 +69,7 @@ def __init__(self) -> None: async def execute( self, - agent: AgentDef, + agent: ScriptStepDef, context: dict[str, Any], ) -> ScriptOutput: """Execute a script step. diff --git a/src/conductor/executor/set_step.py b/src/conductor/executor/set_step.py index 2b6b9aec..e8e05a41 100644 --- a/src/conductor/executor/set_step.py +++ b/src/conductor/executor/set_step.py @@ -42,7 +42,7 @@ from conductor.executor.template import TemplateRenderer if TYPE_CHECKING: - from conductor.config.schema import AgentDef + from conductor.config.schema import SetStepDef logger = logging.getLogger(__name__) @@ -84,7 +84,7 @@ def render_set_value_repr(value: Any) -> str: # Literal alias for the effective output type label. Mirrors the schema's -# ``AgentDef.output_type`` enumeration so callers (engine event payloads, +# ``SetStepDef.output_type`` enumeration so callers (engine event payloads, # dashboard, JSONL log) can narrow on the same set of strings. SetOutputType = Literal["auto", "string", "number", "integer", "boolean", "list", "dict"] @@ -130,7 +130,7 @@ class SetExecutor: def __init__(self) -> None: self.renderer = TemplateRenderer() - def execute(self, agent: AgentDef, context: dict[str, Any]) -> SetOutput: + def execute(self, agent: SetStepDef, context: dict[str, Any]) -> SetOutput: """Render and coerce the step's bindings. Args: @@ -149,7 +149,7 @@ def execute(self, agent: AgentDef, context: dict[str, Any]) -> SetOutput: (undefined variable, syntax error, etc.) — propagated from the renderer. """ - # Both branches are guaranteed by ``AgentDef.validate_agent_type`` + # Both branches are guaranteed by ``SetStepDef.validate_agent_type`` # (config/schema.py) — exactly one of value / values is non-None # when type == "set". if agent.values is not None: diff --git a/src/conductor/executor/wait.py b/src/conductor/executor/wait.py index 0e65e3f1..38fe2126 100644 --- a/src/conductor/executor/wait.py +++ b/src/conductor/executor/wait.py @@ -35,7 +35,7 @@ def _verbose_log(message: str, style: str = "dim") -> None: if TYPE_CHECKING: - from conductor.config.schema import AgentDef + from conductor.config.schema import WaitStepDef @dataclass @@ -79,7 +79,7 @@ def __init__(self) -> None: async def execute( self, - agent: AgentDef, + agent: WaitStepDef, context: dict[str, Any], interrupt_event: asyncio.Event | None = None, ) -> WaitOutput: diff --git a/src/conductor/gates/human.py b/src/conductor/gates/human.py index 8e9cbf35..dbf6e43b 100644 --- a/src/conductor/gates/human.py +++ b/src/conductor/gates/human.py @@ -26,7 +26,7 @@ from collections.abc import Callable from pathlib import Path - from conductor.config.schema import AgentDef, GateOption + from conductor.config.schema import GateOption, HumanGateStepDef MULTILINE_SENTINEL = "." @@ -138,7 +138,7 @@ def _runner() -> None: return await future -def option_for_value(agent: AgentDef, value: str) -> GateOption: +def option_for_value(agent: HumanGateStepDef, value: str) -> GateOption: """Map a response value back to a gate agent's declared option. Lives at module level because routing belongs to whoever owns the @@ -296,7 +296,7 @@ def __init__( async def handle_gate( self, - agent: AgentDef, + agent: HumanGateStepDef, context: dict[str, Any], base_dir: Path | None = None, ) -> GateResult: @@ -335,7 +335,7 @@ async def handle_gate( def build_gate_prompt( self, - agent: AgentDef, + agent: HumanGateStepDef, context: dict[str, Any], base_dir: Path | None = None, ) -> GatePrompt: diff --git a/src/conductor/mcp/serve/introspect.py b/src/conductor/mcp/serve/introspect.py index 02175bfb..987fbf94 100644 --- a/src/conductor/mcp/serve/introspect.py +++ b/src/conductor/mcp/serve/introspect.py @@ -30,6 +30,7 @@ from typing import TYPE_CHECKING, Any from conductor.config.loader import load_config +from conductor.config.schema import RoutableStepBase from conductor.fleet.records import RunRecord from conductor.fleet.summary import derive_step_detail from conductor.mcp.serve.runs import RunLookup, read_event_log_events, resolve_run @@ -420,8 +421,8 @@ def _build_plan_tree(config: WorkflowConfig) -> dict[str, Any]: nodes.append( { "name": agent.name, - "type": agent.type or "agent", - "routes": _route_dicts(agent.routes), + "type": agent.type, + "routes": _route_dicts(agent.routes) if isinstance(agent, RoutableStepBase) else [], } ) for group in config.parallel: diff --git a/src/conductor/providers/claude.py b/src/conductor/providers/claude.py index dc631715..3476828c 100644 --- a/src/conductor/providers/claude.py +++ b/src/conductor/providers/claude.py @@ -1014,7 +1014,6 @@ async def execute_dialog_turn( name="dialog_agent", model=resolved_model, prompt="", - max_depth=None, timeout_seconds=None, max_session_seconds=None, max_agent_iterations=None, diff --git a/src/conductor/providers/openai.py b/src/conductor/providers/openai.py index ee414e28..d6fb8abd 100644 --- a/src/conductor/providers/openai.py +++ b/src/conductor/providers/openai.py @@ -845,7 +845,6 @@ async def execute_dialog_turn( name="dialog_agent", model=resolved_model, prompt="", - max_depth=None, timeout_seconds=None, max_session_seconds=None, max_agent_iterations=None, diff --git a/tests/test_agent_iteration_limits.py b/tests/test_agent_iteration_limits.py index 15d1cae2..5a93e07c 100644 --- a/tests/test_agent_iteration_limits.py +++ b/tests/test_agent_iteration_limits.py @@ -17,7 +17,7 @@ import pytest from pydantic import ValidationError -from conductor.config.schema import AgentDef, OutputField, RuntimeConfig +from conductor.config.schema import AgentDef, OutputField, RuntimeConfig, ScriptStepDef from conductor.exceptions import ProviderError from conductor.providers.copilot import CopilotProvider from conductor.providers.factory import create_provider @@ -84,16 +84,17 @@ def test_rejects_over_500(self) -> None: def test_script_agent_rejects_max_agent_iterations(self) -> None: with pytest.raises(ValidationError, match="max_agent_iterations"): - AgentDef( + ScriptStepDef( name="test", - type="script", command="echo hi", max_agent_iterations=10, ) def test_script_agent_without_max_agent_iterations_ok(self) -> None: - agent = AgentDef(name="test", type="script", command="echo hi") - assert agent.max_agent_iterations is None + # Requirement: a script step constructs without any LLM iteration field — + # max_agent_iterations is owned by AgentDef alone after the step-model split. + agent = ScriptStepDef(name="test", command="echo hi") + assert not hasattr(agent, "max_agent_iterations") # --------------------------------------------------------------------------- diff --git a/tests/test_cli/test_web_kill_checkpoint.py b/tests/test_cli/test_web_kill_checkpoint.py index b16ddf48..20ba5896 100644 --- a/tests/test_cli/test_web_kill_checkpoint.py +++ b/tests/test_cli/test_web_kill_checkpoint.py @@ -17,11 +17,11 @@ from conductor.cli.run import _run_with_stop_signal from conductor.config.schema import ( - AgentDef, ContextConfig, LimitsConfig, RouteDef, RuntimeConfig, + WaitStepDef, WorkflowConfig, WorkflowDef, ) @@ -52,9 +52,8 @@ def _wait_workflow() -> WorkflowConfig: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WaitStepDef( name="pause", - type="wait", duration="30s", routes=[RouteDef(to="$end")], ), diff --git a/tests/test_config/test_agent_type_matrix.py b/tests/test_config/test_agent_type_matrix.py index 0d6c8d50..0f88d179 100644 --- a/tests/test_config/test_agent_type_matrix.py +++ b/tests/test_config/test_agent_type_matrix.py @@ -10,7 +10,80 @@ import pytest from pydantic import ValidationError as PydanticValidationError -from conductor.config.schema import AgentDef, GateOption +from conductor.config.schema import ( + AgentDef, + GateOption, + HumanGateStepDef, + QuestionsStepDef, + ScriptStepDef, + SetStepDef, + StepBase, + TerminateStepDef, + WaitStepDef, + WorkflowConfig, + WorkflowStepDef, +) + + +class TestStaticStepUnion: + """Workflow parsing produces concrete variants and publishes their discriminator.""" + + def test_workflow_config_stores_concrete_step_models(self) -> None: + # Requirement: parsed workflow steps retain their named runtime variant types. + config = WorkflowConfig.model_validate( + { + "workflow": {"name": "typed", "entry_point": "write"}, + "agents": [ + {"name": "write", "prompt": "Write", "routes": [{"to": "pause"}]}, + {"name": "pause", "type": "wait", "duration": "1s"}, + ], + } + ) + + assert type(config.agents[0]) is AgentDef + assert type(config.agents[1]) is WaitStepDef + + def test_null_llm_type_is_canonicalized(self) -> None: + # Requirement: an explicit YAML null discriminator remains compatible with LLM steps. + config = WorkflowConfig.model_validate( + { + "workflow": {"name": "typed", "entry_point": "write"}, + "agents": [{"name": "write", "type": None, "prompt": "Write"}], + } + ) + + assert type(config.agents[0]) is AgentDef + assert config.agents[0].type == "agent" + + def test_workflow_json_schema_exposes_static_discriminator(self) -> None: + # Requirement: tooling can select a step schema through JSON Schema oneOf metadata. + agents_schema = WorkflowConfig.model_json_schema()["properties"]["agents"]["items"] + + assert agents_schema["discriminator"]["propertyName"] == "type" + assert set(agents_schema["discriminator"]["mapping"]) == { + "agent", + "human_gate", + "mcp", + "questions", + "script", + "set", + "terminate", + "wait", + "workflow", + } + assert len(agents_schema["oneOf"]) == 9 + + def test_variant_rejects_fields_owned_by_another_step(self) -> None: + # Requirement: each concrete model forbids fields owned by sibling variants + # via extra="forbid" (standard extra_forbidden error). + with pytest.raises(PydanticValidationError) as exc_info: + ScriptStepDef.model_validate( + {"name": "run", "command": "echo", "prompt": "not allowed"} + ) + assert any( + e["loc"] == ("prompt",) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) class TestSessionKeyTypeMatrix: @@ -29,47 +102,38 @@ def test_empty_session_key_rejected(self) -> None: AgentDef(name="llm", prompt="hi", session_key="") @pytest.mark.parametrize( - "kwargs,match", + "step_class,valid_kwargs", [ + (ScriptStepDef, {"name": "sc", "command": "ls"}), ( - {"name": "sc", "type": "script", "command": "ls"}, - "script agents cannot have 'session_key'", - ), - ( - {"name": "q", "type": "questions", "questions": [{"id": "q1", "text": "Why?"}]}, - "questions agents cannot have 'session_key'", - ), - ( - {"name": "w", "type": "wait", "duration": "1s"}, - "wait agents cannot have 'session_key'", - ), - ( - {"name": "s", "type": "set", "value": "1"}, - "set agents cannot have 'session_key'", - ), - ( - {"name": "t", "type": "terminate", "status": "success", "reason": "done"}, - "terminate agents cannot have 'session_key'", + QuestionsStepDef, + {"name": "q", "questions": [{"id": "q1", "text": "Why?"}]}, ), + (WaitStepDef, {"name": "w", "duration": "1s"}), + (SetStepDef, {"name": "s", "value": "1"}), + (TerminateStepDef, {"name": "t", "status": "success", "reason": "done"}), ( + HumanGateStepDef, { "name": "g", - "type": "human_gate", "prompt": "Pick", "options": [GateOption(label="Yes", value="yes", route="$end")], }, - "human_gate agents cannot have 'session_key'", - ), - ( - {"name": "wf", "type": "workflow", "workflow": "./sub.yaml"}, - "workflow agents cannot have 'session_key'", ), + (WorkflowStepDef, {"name": "wf", "workflow": "./sub.yaml"}), ], ids=["script", "questions", "wait", "set", "terminate", "human_gate", "workflow"], ) - def test_session_key_rejected(self, kwargs: dict, match: str) -> None: - with pytest.raises(PydanticValidationError, match=match): - AgentDef(**kwargs, session_key="investigation") # type: ignore[arg-type] + def test_session_key_rejected(self, step_class: type[StepBase], valid_kwargs: dict) -> None: + # Requirement: session_key is an LLM-agent-only field; every sibling variant + # forbids it via extra="forbid" (standard extra_forbidden error, not a + # per-type custom message). + with pytest.raises(PydanticValidationError) as exc_info: + step_class.model_validate({**valid_kwargs, "session_key": "investigation"}) + assert any( + e["loc"] == ("session_key",) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) class TestSessionKeyLiteral: diff --git a/tests/test_config/test_dialog_schema.py b/tests/test_config/test_dialog_schema.py index a7f86d0e..3336d414 100644 --- a/tests/test_config/test_dialog_schema.py +++ b/tests/test_config/test_dialog_schema.py @@ -8,8 +8,10 @@ from conductor.config.schema import ( AgentDef, DialogConfig, - GateOption, + HumanGateStepDef, RouteDef, + ScriptStepDef, + WorkflowStepDef, ) @@ -54,34 +56,49 @@ def test_agent_without_dialog(self) -> None: def test_human_gate_cannot_have_dialog(self) -> None: """Test that human_gate agents cannot have dialog config.""" - with pytest.raises(ValidationError, match="human_gate agents cannot have 'dialog'"): - AgentDef( - name="gate", - type="human_gate", - prompt="Choose an option", - options=[GateOption(label="Continue", value="continue", route="next")], - dialog=DialogConfig(trigger_prompt="test"), + with pytest.raises(ValidationError) as exc_info: + HumanGateStepDef.model_validate( + { + "name": "gate", + "prompt": "Choose an option", + "options": [{"label": "Continue", "value": "continue", "route": "next"}], + "dialog": {"trigger_prompt": "test"}, + } ) + assert any( + e["loc"] == ("dialog",) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) def test_script_cannot_have_dialog(self) -> None: """Test that script agents cannot have dialog config.""" - with pytest.raises(ValidationError, match="script agents cannot have 'dialog'"): - AgentDef( - name="runner", - type="script", - command="echo hello", - dialog=DialogConfig(trigger_prompt="test"), + with pytest.raises(ValidationError) as exc_info: + ScriptStepDef.model_validate( + { + "name": "runner", + "command": "echo hello", + "dialog": {"trigger_prompt": "test"}, + } ) + assert any( + e["loc"] == ("dialog",) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) def test_workflow_cannot_have_dialog(self) -> None: """Test that workflow agents cannot have dialog config.""" - with pytest.raises(ValidationError, match="workflow agents cannot have 'dialog'"): - AgentDef( - name="sub", - type="workflow", - workflow="./sub.yaml", - dialog=DialogConfig(trigger_prompt="test"), + with pytest.raises(ValidationError) as exc_info: + WorkflowStepDef.model_validate( + { + "name": "sub", + "workflow": "./sub.yaml", + "dialog": {"trigger_prompt": "test"}, + } ) + assert any( + e["loc"] == ("dialog",) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) def test_agent_with_dialog_and_routes(self) -> None: """Test that agents with dialog can also have routes.""" diff --git a/tests/test_config/test_instructions.py b/tests/test_config/test_instructions.py index fb094138..75f0af20 100644 --- a/tests/test_config/test_instructions.py +++ b/tests/test_config/test_instructions.py @@ -424,13 +424,13 @@ async def test_subworkflow_inherits_parent_preamble(self, tmp_path: Path) -> Non import textwrap from conductor.config.schema import ( - AgentDef, ContextConfig, LimitsConfig, RouteDef, RuntimeConfig, WorkflowConfig, WorkflowDef, + WorkflowStepDef, ) from conductor.engine.workflow import WorkflowEngine from conductor.providers.copilot import CopilotProvider @@ -469,9 +469,8 @@ async def test_subworkflow_inherits_parent_preamble(self, tmp_path: Path) -> Non limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="step", - type="workflow", workflow="sub.yaml", routes=[RouteDef(to="$end")], ), @@ -509,13 +508,13 @@ async def test_subworkflow_merges_own_instructions(self, tmp_path: Path) -> None from conductor.config.instructions import _wrap_preamble from conductor.config.schema import ( - AgentDef, ContextConfig, LimitsConfig, RouteDef, RuntimeConfig, WorkflowConfig, WorkflowDef, + WorkflowStepDef, ) from conductor.engine.workflow import WorkflowEngine from conductor.providers.copilot import CopilotProvider @@ -556,9 +555,8 @@ async def test_subworkflow_merges_own_instructions(self, tmp_path: Path) -> None limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="step", - type="workflow", workflow="sub.yaml", routes=[RouteDef(to="$end")], ), diff --git a/tests/test_config/test_mcp_step_schema.py b/tests/test_config/test_mcp_step_schema.py index c497c7c2..74ebf5fc 100644 --- a/tests/test_config/test_mcp_step_schema.py +++ b/tests/test_config/test_mcp_step_schema.py @@ -1,26 +1,28 @@ """Tests for ``type: mcp`` step schema validation. Tests cover: -- Valid mcp agent definitions (minimal + full) -- Required server/tool validation -- The full forbidden-field matrix (every LLM and sibling-step field) +- Valid mcp step definitions (minimal + full) +- Required server/tool validation (variant-owned custom messages kept) +- The full forbidden-field matrix (every LLM and sibling-step field), now + surfaced as Pydantic's standard ``extra_forbidden`` error under the + concrete step-model architecture (issue #517) - Literal-only server/tool (Jinja templates rejected at load time) - timeout acceptance (unlike wait/set steps) - server/tool/arguments rejection on all other step types -Field matrix under test (requirement: every AgentDef field must be -allow / forbid / covered-by-standalone-guard for ``type: mcp``): +Field matrix under test (requirement: every field foreign to ``type: mcp`` +must be rejected with ``extra_forbidden``; MCPStepDef itself only declares +name, description, input, routes, output, timeout, server, tool, arguments): - ALLOWED: name, description, input, output, routes, timeout, server, tool, arguments -- FORBIDDEN: prompt, system_prompt, provider, model, tools, reasoning, - context_tier, skills, plugins, validator, dialog, sandbox, session_key, - max_agent_iterations, max_session_seconds, output_mode, retry, - timeout_seconds, command, args, env, working_dir, options, workflow, - input_mapping, max_depth, value, values, output_type -- COVERED BY STANDALONE GUARDS: stdin (script guard), duration + reason - (wait/terminate guard), status + output_template (terminate guard), - questions/source/allow_*/abort_route (questions guard), server/tool/ - arguments on non-mcp types (mcp-exclusive guard) +- FORBIDDEN (extra_forbidden): prompt, system_prompt, provider, model, + tools, reasoning, context_tier, skills, plugins, validator, dialog, + sandbox, session_key, max_agent_iterations, max_session_seconds, + output_mode, retry, timeout_seconds, command, args, env, working_dir, + settings_dir, options, workflow, input_mapping, max_depth, value, values, + output_type, stdin, duration, reason, status, output_template +- CUSTOM MESSAGES (variant-owned invariants): missing/empty server/tool + ("mcp agents require ..."), Jinja in server/tool ("never rendered") """ from __future__ import annotations @@ -30,22 +32,41 @@ import pytest from pydantic import ValidationError -from conductor.config.schema import AgentDef, GateOption, OutputField, RouteDef +from conductor.config.schema import ( + AgentDef, + GateOption, + MCPStepDef, + OutputField, + RouteDef, + ScriptStepDef, + WaitStepDef, + WorkflowConfig, +) + + +def _mcp_step(**overrides: Any) -> MCPStepDef: + """Build a valid minimal mcp step, applying overrides.""" + kwargs: dict[str, Any] = {"name": "lookup", "server": "docs", "tool": "search"} + kwargs.update(overrides) + return MCPStepDef(**kwargs) -def _mcp_agent(**overrides: Any) -> AgentDef: - """Build a valid minimal mcp agent, applying overrides.""" - kwargs: dict[str, Any] = {"name": "lookup", "type": "mcp", "server": "docs", "tool": "search"} - kwargs.update(overrides) - return AgentDef(**kwargs) +def _mcp_forbidden(field_name: str, value: Any) -> None: + """Assert a foreign field is rejected on an mcp step with extra_forbidden.""" + with pytest.raises(ValidationError) as exc_info: + MCPStepDef.model_validate({"name": "x", "server": "s", "tool": "t", field_name: value}) + assert any( + e["loc"] == (field_name,) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) -class TestMcpAgentDefValid: - """Tests for valid mcp type AgentDef construction.""" +class TestMcpStepDefValid: + """Tests for valid mcp step construction.""" def test_valid_minimal_mcp_step(self) -> None: """Requirement: a minimal type: mcp step needs only server and tool.""" - agent = _mcp_agent() + agent = _mcp_step() assert agent.type == "mcp" assert agent.server == "docs" assert agent.tool == "search" @@ -54,7 +75,7 @@ def test_valid_minimal_mcp_step(self) -> None: def test_valid_mcp_step_with_all_allowed_fields(self) -> None: """Requirement: output, routes, input, timeout, description, arguments are allowed.""" - agent = _mcp_agent( + agent = _mcp_step( description="Look up docs", arguments={"query": "{{ workflow.input.q }}"}, input=["prep.output"], @@ -68,123 +89,137 @@ def test_valid_mcp_step_with_all_allowed_fields(self) -> None: def test_mcp_step_timeout_accepted(self) -> None: """Requirement: timeout is allowed on mcp steps (unlike wait/set which forbid it).""" - agent = _mcp_agent(timeout=30) + agent = _mcp_step(timeout=30) assert agent.timeout == 30 def test_mcp_step_output_accepted(self) -> None: """Requirement: mcp steps may declare an output schema like script steps.""" - agent = _mcp_agent(output={"result": OutputField(type="string")}) + agent = _mcp_step(output={"result": OutputField(type="string")}) assert agent.output is not None def test_mcp_arguments_allow_jinja_templates(self) -> None: """Requirement: arguments ARE rendered recursively, so Jinja is allowed there.""" - agent = _mcp_agent(arguments={"q": "{{ searcher.output.query }}", "n": 5}) + agent = _mcp_step(arguments={"q": "{{ searcher.output.query }}", "n": 5}) assert agent.arguments == {"q": "{{ searcher.output.query }}", "n": 5} -class TestMcpAgentDefRequiredFields: - """Tests for required server/tool fields.""" +class TestMcpStepDefRequiredFields: + """Tests for required server/tool fields (variant-owned custom messages).""" def test_mcp_without_server_raises(self) -> None: """Requirement: mcp steps require 'server'.""" with pytest.raises(ValidationError, match="mcp agents require 'server'"): - AgentDef(name="bad", type="mcp", tool="search") + MCPStepDef(name="bad", tool="search") def test_mcp_with_empty_server_raises(self) -> None: """Requirement: an empty server string is rejected as missing.""" with pytest.raises(ValidationError, match="mcp agents require 'server'"): - AgentDef(name="bad", type="mcp", server="", tool="search") + MCPStepDef(name="bad", server="", tool="search") def test_mcp_without_tool_raises(self) -> None: """Requirement: mcp steps require 'tool'.""" with pytest.raises(ValidationError, match="mcp agents require 'tool'"): - AgentDef(name="bad", type="mcp", server="docs") + MCPStepDef(name="bad", server="docs") def test_mcp_with_empty_tool_raises(self) -> None: """Requirement: an empty tool string is rejected as missing.""" with pytest.raises(ValidationError, match="mcp agents require 'tool'"): - AgentDef(name="bad", type="mcp", server="docs", tool="") + MCPStepDef(name="bad", server="docs", tool="") -# Requirement: each LLM-only or sibling-step field must be rejected on mcp steps. -# field name -> (kwarg value, regex fragment matching the error message). -_FORBIDDEN_FIELDS: list[tuple[str, Any, str]] = [ +# Requirement: each LLM-only or sibling-step field must be rejected on mcp +# steps with Pydantic's standard extra_forbidden error (no custom messages). +# field name -> kwarg value +_FORBIDDEN_FIELDS: list[tuple[str, Any]] = [ # LLM fields - ("prompt", "do something", r"'prompt'"), - ("system_prompt", "You are...", r"'system_prompt'"), - ("provider", "copilot", r"'provider'"), - ("model", "gpt-4", r"'model'"), - ("tools", ["web_search"], r"'tools'"), - ("reasoning", {"effort": "high"}, r"'reasoning'"), - ("context_tier", "long_context", r"'context_tier'"), - ("skills", ["conductor"], r"'skills'"), - ("plugins", ["prs"], r"'plugins'"), - ("validator", {"criteria": "must be good"}, r"'validator'"), - ("dialog", {"trigger_prompt": "pause if unsure"}, r"'dialog'"), - ("sandbox", {"identifier_scope": "item"}, r"'sandbox'"), - ("session_key", "my-key", r"'session_key'"), - ("max_agent_iterations", 5, r"'max_agent_iterations'"), - ("max_session_seconds", 60.0, r"'max_session_seconds'"), - ("output_mode", "raw", r"'output_mode'"), - ("retry", {"max_attempts": 2}, r"'retry'"), - ( - "timeout_seconds", - 30.0, - r"'timeout_seconds'.*use 'timeout'", - ), # mirrors the script branch message + ("prompt", "do something"), + ("system_prompt", "You are..."), + ("provider", "copilot"), + ("model", "gpt-4"), + ("tools", ["web_search"]), + ("reasoning", {"effort": "high"}), + ("context_tier", "long_context"), + ("skills", ["conductor"]), + ("plugins", ["prs"]), + ("validator", {"criteria": "must be good"}), + ("dialog", {"trigger_prompt": "pause if unsure"}), + ("sandbox", {"identifier_scope": "item"}), + ("session_key", "my-key"), + ("max_agent_iterations", 5), + ("max_session_seconds", 60.0), + ("output_mode", "raw"), + ("retry", {"max_attempts": 2}), + ("timeout_seconds", 30.0), # mcp uses 'timeout', not the LLM 'timeout_seconds' # Sibling-step fields - ("command", "echo", r"'command'"), - ("args", ["a"], r"'args'"), - ("env", {"A": "b"}, r"'env'"), - ("working_dir", "/tmp", r"'working_dir'"), - ("settings_dir", "/tmp", r"'settings_dir'"), - ("options", [GateOption(label="OK", value="ok", route="$end")], r"'options'"), - ("workflow", "sub.yaml", r"'workflow'"), - ("input_mapping", {"a": "{{ b }}"}, r"'input_mapping'"), - ("max_depth", 2, r"'max_depth'"), - ("value", "{{ 1 }}", r"'value'"), - ("values", {"a": "{{ 1 }}"}, r"'values'"), - ("output_type", "auto", r"'output_type'"), + ("command", "echo"), + ("args", ["a"]), + ("env", {"A": "b"}), + ("working_dir", "/tmp"), + ("settings_dir", "/tmp"), + ("options", [GateOption(label="OK", value="ok", route="$end")]), + ("workflow", "sub.yaml"), + ("input_mapping", {"a": "{{ b }}"}), + ("max_depth", 2), + ("value", "{{ 1 }}"), + ("values", {"a": "{{ 1 }}"}), + ("output_type", "auto"), ] -class TestMcpAgentDefForbiddenFields: - """Parameterized matrix: every forbidden field is rejected with a named error.""" +class TestMcpStepDefForbiddenFields: + """Parameterized matrix: every foreign field is rejected as extra_forbidden.""" - @pytest.mark.parametrize(("field_name", "value", "message"), _FORBIDDEN_FIELDS) - def test_mcp_forbidden_field_raises(self, field_name: str, value: Any, message: str) -> None: + @pytest.mark.parametrize(("field_name", "value"), _FORBIDDEN_FIELDS) + def test_mcp_forbidden_field_raises(self, field_name: str, value: Any) -> None: """Requirement: mcp steps cannot set LLM-only or sibling-step fields.""" - with pytest.raises(ValidationError, match=message): - _mcp_agent(**{field_name: value}) + _mcp_forbidden(field_name, value) + + def test_mcp_forbidden_field_surfaces_through_workflow_config(self) -> None: + """Requirement: a foreign field on an mcp step is rejected inside a workflow's + agents list too, with the discriminated-union loc ending at the field.""" + with pytest.raises(ValidationError) as exc_info: + WorkflowConfig.model_validate( + { + "workflow": {"name": "wf", "entry_point": "lookup"}, + "agents": [ + { + "name": "lookup", + "type": "mcp", + "server": "docs", + "tool": "search", + "prompt": "do something", + } + ], + } + ) + assert any( + e["loc"][-1] == "prompt" and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) def test_mcp_with_stdin_raises(self) -> None: - """Requirement: stdin is rejected via the standalone script guard.""" - with pytest.raises(ValidationError, match="'stdin'"): - _mcp_agent(stdin="payload") + """Requirement: stdin belongs to script steps only; it is extra_forbidden on mcp.""" + _mcp_forbidden("stdin", "payload") def test_mcp_with_duration_raises(self) -> None: - """Requirement: duration is rejected via the wait-only guard at method bottom.""" - with pytest.raises(ValidationError, match="'duration'"): - _mcp_agent(duration=5) + """Requirement: duration belongs to wait steps only; it is extra_forbidden on mcp.""" + _mcp_forbidden("duration", 5) def test_mcp_with_reason_raises(self) -> None: - """Requirement: reason is rejected (only wait/terminate support it).""" - with pytest.raises(ValidationError, match="'reason'"): - _mcp_agent(reason="because") + """Requirement: reason belongs to wait/terminate steps only; extra_forbidden on mcp.""" + _mcp_forbidden("reason", "because") def test_mcp_with_status_raises(self) -> None: - """Requirement: status is rejected via the terminate-exclusive guard.""" - with pytest.raises(ValidationError, match="'status'"): - _mcp_agent(status="success") + """Requirement: status belongs to terminate steps only; it is extra_forbidden on mcp.""" + _mcp_forbidden("status", "success") def test_mcp_with_output_template_raises(self) -> None: - """Requirement: output_template is rejected via the terminate-exclusive guard.""" - with pytest.raises(ValidationError, match="'output_template'"): - _mcp_agent(output_template={"a": "b"}) + """Requirement: output_template belongs to terminate steps only; extra_forbidden on mcp.""" + _mcp_forbidden("output_template", {"a": "b"}) class TestMcpFieldsLiteralOnly: - """Tests for the literal-only server/tool contract.""" + """Tests for the literal-only server/tool contract (variant-owned custom messages).""" @pytest.mark.parametrize( "template", ["{{ workflow.input.server }}", "{% if x %}docs{% endif %}"] @@ -192,7 +227,7 @@ class TestMcpFieldsLiteralOnly: def test_jinja_in_server_rejected(self, template: str) -> None: """Requirement: server is never rendered — Jinja templates are rejected at load time.""" with pytest.raises(ValidationError, match="never rendered"): - AgentDef(name="bad", type="mcp", server=template, tool="search") + MCPStepDef(name="bad", server=template, tool="search") @pytest.mark.parametrize( "template", ["{{ workflow.input.tool }}", "{% if x %}search{% endif %}"] @@ -200,27 +235,39 @@ def test_jinja_in_server_rejected(self, template: str) -> None: def test_jinja_in_tool_rejected(self, template: str) -> None: """Requirement: tool is never rendered — Jinja templates are rejected at load time.""" with pytest.raises(ValidationError, match="never rendered"): - AgentDef(name="bad", type="mcp", server="docs", tool=template) + MCPStepDef(name="bad", server="docs", tool=template) class TestMcpFieldsForbiddenOnOtherTypes: - """server/tool/arguments are exclusive to type: mcp.""" + """server/tool/arguments are exclusive to type: mcp — extra_forbidden elsewhere.""" @pytest.mark.parametrize("field_name", ["server", "tool", "arguments"]) def test_mcp_fields_rejected_on_script(self, field_name: str) -> None: - """Requirement: server/tool/arguments on a script step raise the mcp-exclusive error.""" + """Requirement: server/tool/arguments on a script step raise extra_forbidden.""" value: Any = {"server": "docs", "tool": "search"}.get(field_name, {"q": "x"}) - with pytest.raises(ValidationError, match=f"cannot have '{field_name}'"): - AgentDef(name="bad", type="script", command="echo", **{field_name: value}) + with pytest.raises(ValidationError) as exc_info: + ScriptStepDef.model_validate({"name": "bad", "command": "echo", field_name: value}) + assert any( + e["loc"] == (field_name,) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) @pytest.mark.parametrize("field_name", ["server", "tool", "arguments"]) def test_mcp_fields_rejected_on_regular_agent(self, field_name: str) -> None: - """Requirement: server/tool/arguments on an LLM agent raise the mcp-exclusive error.""" + """Requirement: server/tool/arguments on an LLM agent raise extra_forbidden.""" value: Any = {"server": "docs", "tool": "search"}.get(field_name, {"q": "x"}) - with pytest.raises(ValidationError, match=f"cannot have '{field_name}'"): - AgentDef(name="bad", prompt="hello", **{field_name: value}) + with pytest.raises(ValidationError) as exc_info: + AgentDef.model_validate({"name": "bad", "prompt": "hello", field_name: value}) + assert any( + e["loc"] == (field_name,) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) def test_mcp_fields_rejected_on_wait(self) -> None: - """Requirement: server on a wait step is rejected even though wait has its own branch.""" - with pytest.raises(ValidationError, match="cannot have 'server'"): - AgentDef(name="bad", type="wait", duration=5, server="docs") + """Requirement: server on a wait step is extra_forbidden.""" + with pytest.raises(ValidationError) as exc_info: + WaitStepDef.model_validate({"name": "bad", "duration": 5, "server": "docs"}) + assert any( + e["loc"] == ("server",) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) diff --git a/tests/test_config/test_mcp_step_validation.py b/tests/test_config/test_mcp_step_validation.py index 9a8d87ed..be176045 100644 --- a/tests/test_config/test_mcp_step_validation.py +++ b/tests/test_config/test_mcp_step_validation.py @@ -22,6 +22,7 @@ ForEachDef, InputDef, MCPServerDef, + MCPStepDef, ParallelGroup, RouteDef, RuntimeConfig, @@ -67,9 +68,8 @@ def _mcp_agent( tool: str = "do_thing", arguments: dict[str, object] | None = None, ) -> AgentDef: - return AgentDef( + return MCPStepDef( name=name, - type="mcp", server=server, tool=tool, arguments=arguments, diff --git a/tests/test_config/test_output_mode.py b/tests/test_config/test_output_mode.py index 1e5ce3b0..6a05e9dd 100644 --- a/tests/test_config/test_output_mode.py +++ b/tests/test_config/test_output_mode.py @@ -5,7 +5,28 @@ import pytest from pydantic import ValidationError -from conductor.config.schema import AgentDef, OutputField +from conductor.config.schema import ( + AgentDef, + HumanGateStepDef, + OutputField, + ScriptStepDef, + SetStepDef, + TerminateStepDef, + WaitStepDef, + WorkflowStepDef, +) + + +def _assert_extra_forbidden(exc_info: pytest.ExceptionInfo[ValidationError], field: str) -> None: + """Assert the error is Pydantic's standard extra_forbidden on ``field``. + + The step-model split (issue #517) removed the per-type custom + "cannot have ''" messages; the contract is now the schema-level + ``extra="forbid"`` error on the foreign field. + """ + assert any( + e["loc"] == (field,) and e["type"] == "extra_forbidden" for e in exc_info.value.errors() + ) class TestOutputModeValidation: @@ -39,68 +60,53 @@ def test_raw_with_output_raises_validation_error(self) -> None: ) def test_raw_on_script_raises_validation_error(self) -> None: - """output_mode on script agent type is rejected.""" - with pytest.raises(ValidationError, match="script agents cannot have 'output_mode'"): - AgentDef( - name="a", - type="script", - command="echo hi", - output_mode="raw", - ) + """output_mode on a script step is rejected via extra="forbid".""" + with pytest.raises(ValidationError) as exc_info: + ScriptStepDef.model_validate({"name": "a", "command": "echo hi", "output_mode": "raw"}) + _assert_extra_forbidden(exc_info, "output_mode") def test_raw_on_human_gate_raises_validation_error(self) -> None: - """output_mode on human_gate agent type is rejected.""" - from conductor.config.schema import GateOption - - with pytest.raises(ValidationError, match="human_gate agents cannot have 'output_mode'"): - AgentDef( - name="a", - type="human_gate", - prompt="Choose", - options=[GateOption(value="yes", label="Yes", route="next")], - output_mode="raw", + """output_mode on a human_gate step is rejected via extra="forbid".""" + with pytest.raises(ValidationError) as exc_info: + HumanGateStepDef.model_validate( + { + "name": "a", + "prompt": "Choose", + "options": [ + {"value": "yes", "label": "Yes", "route": "next"}, + ], + "output_mode": "raw", + } ) + _assert_extra_forbidden(exc_info, "output_mode") def test_raw_on_workflow_raises_validation_error(self) -> None: - """output_mode on workflow agent type is rejected.""" - with pytest.raises(ValidationError, match="workflow agents cannot have 'output_mode'"): - AgentDef( - name="a", - type="workflow", - workflow="sub.yaml", - output_mode="raw", + """output_mode on a workflow (sub-workflow) step is rejected via extra="forbid".""" + with pytest.raises(ValidationError) as exc_info: + WorkflowStepDef.model_validate( + {"name": "a", "workflow": "sub.yaml", "output_mode": "raw"} ) + _assert_extra_forbidden(exc_info, "output_mode") def test_raw_on_wait_raises_validation_error(self) -> None: - """output_mode on wait agent type is rejected.""" - with pytest.raises(ValidationError, match="wait agents cannot have 'output_mode'"): - AgentDef( - name="a", - type="wait", - duration=60, - output_mode="raw", - ) + """output_mode on a wait step is rejected via extra="forbidden".""" + with pytest.raises(ValidationError) as exc_info: + WaitStepDef.model_validate({"name": "a", "duration": 60, "output_mode": "raw"}) + _assert_extra_forbidden(exc_info, "output_mode") def test_raw_on_set_raises_validation_error(self) -> None: - """output_mode on set agent type is rejected.""" - with pytest.raises(ValidationError, match="set agents cannot have 'output_mode'"): - AgentDef( - name="a", - type="set", - value="42", - output_mode="raw", - ) + """output_mode on a set step is rejected via extra="forbid".""" + with pytest.raises(ValidationError) as exc_info: + SetStepDef.model_validate({"name": "a", "value": "42", "output_mode": "raw"}) + _assert_extra_forbidden(exc_info, "output_mode") def test_raw_on_terminate_raises_validation_error(self) -> None: - """output_mode on terminate agent type is rejected.""" - with pytest.raises(ValidationError, match="terminate agents cannot have 'output_mode'"): - AgentDef( - name="a", - type="terminate", - status="success", - reason="done", - output_mode="raw", + """output_mode on a terminate step is rejected via extra="forbid".""" + with pytest.raises(ValidationError) as exc_info: + TerminateStepDef.model_validate( + {"name": "a", "status": "success", "reason": "done", "output_mode": "raw"} ) + _assert_extra_forbidden(exc_info, "output_mode") def test_none_with_output_is_valid(self) -> None: """output_mode=None (default) with output schema is valid — backward compat.""" diff --git a/tests/test_config/test_parallel_validation.py b/tests/test_config/test_parallel_validation.py index 87e8d61b..371a9287 100644 --- a/tests/test_config/test_parallel_validation.py +++ b/tests/test_config/test_parallel_validation.py @@ -6,6 +6,7 @@ from conductor.config.schema import ( AgentDef, + HumanGateStepDef, ParallelGroup, WorkflowConfig, WorkflowDef, @@ -305,10 +306,8 @@ def test_human_gate_in_parallel_group_rejected(self) -> None: config = WorkflowConfig( workflow=WorkflowDef(name="test", entry_point="parallel1"), agents=[ - AgentDef( + HumanGateStepDef( name="gate1", - type="human_gate", - model="gpt-4", prompt="Choose", options=[ GateOption(label="Yes", value="yes", route="$end"), @@ -371,10 +370,8 @@ def test_human_gate_route_to_parallel_group(self) -> None: config = WorkflowConfig( workflow=WorkflowDef(name="test", entry_point="gate1"), agents=[ - AgentDef( + HumanGateStepDef( name="gate1", - type="human_gate", - model="gpt-4", prompt="Choose", options=[ GateOption(label="Parallel", value="parallel", route="parallel1"), diff --git a/tests/test_config/test_provider_settings_aca.py b/tests/test_config/test_provider_settings_aca.py index fcd52cf2..0abffac5 100644 --- a/tests/test_config/test_provider_settings_aca.py +++ b/tests/test_config/test_provider_settings_aca.py @@ -13,9 +13,16 @@ from conductor.config.schema import ( AgentDef, + HumanGateStepDef, ProviderSettings, RuntimeConfig, SandboxConfig, + ScriptStepDef, + SetStepDef, + StepDef, + TerminateStepDef, + WaitStepDef, + WorkflowStepDef, ) @@ -311,40 +318,36 @@ def test_sandbox_allowed_on_agent_via_dict(self) -> None: assert agent.sandbox.identifier_scope == "workflow" @pytest.mark.parametrize( - "kwargs,match", + ("model", "fields"), [ + (ScriptStepDef, {"name": "s", "command": "ls"}), ( - {"name": "s", "type": "script", "command": "ls"}, - "script agents cannot have 'sandbox'", - ), - ( + HumanGateStepDef, { "name": "g", - "type": "human_gate", "prompt": "Pick", "options": [{"label": "Yes", "value": "yes", "route": "$end"}], }, - "human_gate agents cannot have 'sandbox'", - ), - ( - {"name": "set", "type": "set", "value": "1"}, - "set agents cannot have 'sandbox'", - ), - ( - {"name": "w", "type": "wait", "duration": "1s"}, - "wait agents cannot have 'sandbox'", - ), - ( - {"name": "t", "type": "terminate", "status": "success", "reason": "done"}, - "terminate agents cannot have 'sandbox'", - ), - ( - {"name": "wf", "type": "workflow", "workflow": "./sub.yaml"}, - "workflow agents cannot have 'sandbox'", ), + (SetStepDef, {"name": "set", "value": "1"}), + (WaitStepDef, {"name": "w", "duration": "1s"}), + (TerminateStepDef, {"name": "t", "status": "success", "reason": "done"}), + (WorkflowStepDef, {"name": "wf", "workflow": "./sub.yaml"}), ], ids=["script", "human_gate", "set", "wait", "terminate", "workflow"], ) - def test_sandbox_rejected_on_non_provider_types(self, kwargs: dict, match: str) -> None: - with pytest.raises(PydanticValidationError, match=match): - AgentDef.model_validate({**kwargs, "sandbox": {"identifier_scope": "agent"}}) + def test_sandbox_rejected_on_non_provider_types( + self, model: type[StepDef], fields: dict + ) -> None: + """Every non-provider variant refuses ``sandbox:`` via ``extra="forbid"``. + + The per-type "cannot have 'sandbox'" messages were removed in the + step-model split; the contract is now Pydantic's standard + extra_forbidden error on the foreign field. + """ + with pytest.raises(PydanticValidationError) as exc_info: + model.model_validate({**fields, "sandbox": {"identifier_scope": "agent"}}) + assert any( + e["loc"] == ("sandbox",) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) diff --git a/tests/test_config/test_questions_validation.py b/tests/test_config/test_questions_validation.py index d43eb8be..843a40e9 100644 --- a/tests/test_config/test_questions_validation.py +++ b/tests/test_config/test_questions_validation.py @@ -7,10 +7,14 @@ from conductor.config.schema import ( AgentDef, + HumanGateStepDef, OutputField, ParallelGroup, QuestionDef, + QuestionsStepDef, RouteDef, + ScriptStepDef, + StepDef, WorkflowConfig, WorkflowDef, ) @@ -18,10 +22,17 @@ from conductor.exceptions import ConfigurationError -def _questions(**kwargs) -> AgentDef: +def _questions(**kwargs) -> QuestionsStepDef: """Build a questions node with a single inline question by default.""" kwargs.setdefault("questions", [QuestionDef(text="Why?")]) - return AgentDef(name="ask", type="questions", **kwargs) + return QuestionsStepDef(name="ask", **kwargs) + + +def _assert_extra_forbidden(exc_info: pytest.ExceptionInfo, field: str) -> None: + """The step-model split rejects foreign fields with a plain extra_forbidden.""" + assert any( + e["loc"] == (field,) and e["type"] == "extra_forbidden" for e in exc_info.value.errors() + ) class TestQuestionsSchema: @@ -30,22 +41,24 @@ class TestQuestionsSchema: def test_requires_questions_or_source(self) -> None: """A node with no question source has nothing to ask.""" with pytest.raises(ValidationError, match="require either 'questions' or 'source'"): - AgentDef(name="ask", type="questions") + QuestionsStepDef(name="ask") def test_rejects_both_questions_and_source(self) -> None: """Two sources of truth would be ambiguous.""" with pytest.raises(ValidationError, match="cannot set both"): - AgentDef( + QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="a")], source="x.output.y", ) def test_rejects_gate_options(self) -> None: """Per-question choices live on the question, not the node.""" - with pytest.raises(ValidationError, match="cannot have 'options'"): - _questions(options=[]) + with pytest.raises(ValidationError) as exc_info: + QuestionsStepDef.model_validate( + {"name": "ask", "questions": [{"text": "Why?"}], "options": []} + ) + _assert_extra_forbidden(exc_info, "options") def test_rejects_abort_route_without_allow_abort(self) -> None: """An abort route that can never be taken is a silent no-op.""" @@ -67,17 +80,20 @@ def test_rejects_abort_route_without_allow_abort(self) -> None: ) def test_rejects_provider_only_fields(self, field: str, value: object) -> None: """No provider is invoked, so provider-shaped config must not be accepted.""" - with pytest.raises(ValidationError, match=f"cannot have '{field}'"): - _questions(**{field: value}) + with pytest.raises(ValidationError) as exc_info: + QuestionsStepDef.model_validate( + {"name": "ask", "questions": [{"text": "Why?"}], field: value} + ) + _assert_extra_forbidden(exc_info, field) def test_source_must_be_a_dotted_path(self) -> None: """`source` inherits ForEachDef's format enforcement, not just its name.""" with pytest.raises(ValidationError, match="Invalid source format"): - AgentDef(name="ask", type="questions", source="architect") + QuestionsStepDef(name="ask", source="architect") def test_valid_source_is_accepted(self) -> None: """A well-formed dotted path passes.""" - agent = AgentDef(name="ask", type="questions", source="architect.output.open_questions") + agent = QuestionsStepDef(name="ask", source="architect.output.open_questions") assert agent.source == "architect.output.open_questions" @@ -86,25 +102,31 @@ class TestQuestionsFieldsRejectedElsewhere: """The questions-only fields must not be silently ignored on other types.""" def test_source_rejected_on_a_provider_agent(self) -> None: - """`source` is a new AgentDef field; nothing else reads it.""" - with pytest.raises(ValidationError, match="cannot have 'source'"): - AgentDef(name="a", model="gpt-4", prompt="p", source="x.output.y") + """`source` is owned by questions/for-each steps; nothing else reads it.""" + with pytest.raises(ValidationError) as exc_info: + AgentDef.model_validate( + {"name": "a", "model": "gpt-4", "prompt": "p", "source": "x.output.y"} + ) + _assert_extra_forbidden(exc_info, "source") def test_nav_flag_rejected_on_a_script(self) -> None: """Tri-state flags exist so an explicit value is catchable here.""" - with pytest.raises(ValidationError, match="cannot have 'allow_back'"): - AgentDef(name="s", type="script", command="ls", allow_back=False) + with pytest.raises(ValidationError) as exc_info: + ScriptStepDef.model_validate({"name": "s", "command": "ls", "allow_back": False}) + _assert_extra_forbidden(exc_info, "allow_back") def test_questions_list_rejected_on_a_gate(self) -> None: """A human_gate has options, not questions.""" - with pytest.raises(ValidationError, match="cannot have 'questions'"): - AgentDef( - name="g", - type="human_gate", - prompt="p", - options=[{"label": "a", "value": "a", "route": "$end"}], - questions=[QuestionDef(text="q")], + with pytest.raises(ValidationError) as exc_info: + HumanGateStepDef.model_validate( + { + "name": "g", + "prompt": "p", + "options": [{"label": "a", "value": "a", "route": "$end"}], + "questions": [{"text": "q"}], + } ) + _assert_extra_forbidden(exc_info, "questions") class TestQuestionDefSchema: @@ -129,7 +151,7 @@ def test_free_text_defaults_to_multiline(self) -> None: class TestQuestionsCrossReferences: """Workflow-level validation.""" - def _config(self, agent: AgentDef, **kwargs) -> WorkflowConfig: + def _config(self, agent: StepDef, **kwargs) -> WorkflowConfig: return WorkflowConfig( workflow=WorkflowDef(name="w", entry_point="ask"), agents=[ diff --git a/tests/test_config/test_schema.py b/tests/test_config/test_schema.py index 224d8fca..d71bf849 100644 --- a/tests/test_config/test_schema.py +++ b/tests/test_config/test_schema.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from conductor.config.schema import ( AgentDef, @@ -11,18 +11,40 @@ ContextConfig, ForEachDef, GateOption, + HumanGateStepDef, InputDef, LimitsConfig, OutputField, ReasoningConfig, RouteDef, RuntimeConfig, + ScriptStepDef, + SetStepDef, + StepDef, + TerminateStepDef, ToolOutputConfig, ValidatorConfig, + WaitStepDef, WorkflowConfig, WorkflowDef, + WorkflowStepDef, ) +_STEP_DEF_ADAPTER = TypeAdapter(StepDef) + + +def _assert_extra_forbidden(exc_info: pytest.ExceptionInfo[ValidationError], field: str) -> None: + """Assert the validation errors contain a standard extra_forbidden error for `field`. + + Concrete step models use ``extra="forbid"`` to reject fields owned by sibling + variants, so the error is Pydantic's standard ``extra_forbidden`` type with the + field name as its location — asserted structurally, not by message text. + """ + errors = exc_info.value.errors() + assert any(err["loc"] == (field,) and err["type"] == "extra_forbidden" for err in errors), ( + f"Expected extra_forbidden error for {field!r}, got: {errors}" + ) + class TestInputDef: """Tests for InputDef model.""" @@ -550,7 +572,7 @@ def test_minimal_agent(self) -> None: agent = AgentDef(name="agent1", model="gpt-4", prompt="Hello") assert agent.name == "agent1" assert agent.model == "gpt-4" - assert agent.type is None + assert agent.type == "agent" assert agent.routes == [] assert agent.input == [] @@ -575,9 +597,8 @@ def test_agent_with_all_fields(self) -> None: def test_human_gate_with_options(self) -> None: """Test human_gate agent with options.""" - agent = AgentDef( + agent = HumanGateStepDef( name="gate1", - type="human_gate", prompt="Choose an option:", options=[ GateOption(label="Yes", value="yes", route="next"), @@ -590,15 +611,14 @@ def test_human_gate_with_options(self) -> None: def test_human_gate_without_options_raises(self) -> None: """Test that human_gate without options raises ValidationError.""" with pytest.raises(ValidationError) as exc_info: - AgentDef(name="gate1", type="human_gate", prompt="Choose:") + HumanGateStepDef(name="gate1", prompt="Choose:") assert "options" in str(exc_info.value) def test_human_gate_without_prompt_raises(self) -> None: """Test that human_gate without prompt raises ValidationError.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( + HumanGateStepDef( name="gate1", - type="human_gate", options=[GateOption(label="Ok", value="ok", route="next")], ) assert "prompt" in str(exc_info.value) @@ -642,9 +662,8 @@ def test_rejects_negative(self) -> None: def test_rejected_on_script_agent(self) -> None: """Test that script agents cannot have max_session_seconds.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( + ScriptStepDef( name="s", - type="script", command="echo hello", max_session_seconds=60.0, ) @@ -1430,7 +1449,10 @@ def test_max_concurrent_validation(self) -> None: agent=AgentDef(name="a", model="gpt-4", prompt="test"), max_concurrent=0, ) - assert "must be at least 1" in str(exc_info.value) + assert any( + e["loc"] == ("max_concurrent",) and e["type"] == "greater_than_equal" + for e in exc_info.value.errors() + ) # Too high with pytest.raises(ValidationError) as exc_info: @@ -1442,7 +1464,10 @@ def test_max_concurrent_validation(self) -> None: agent=AgentDef(name="a", model="gpt-4", prompt="test"), max_concurrent=101, ) - assert "cannot exceed 100" in str(exc_info.value) + assert any( + e["loc"] == ("max_concurrent",) and e["type"] == "less_than_equal" + for e in exc_info.value.errors() + ) # Valid range for valid_max in [1, 10, 50, 100]: @@ -1681,7 +1706,7 @@ def test_reasoning_accepts_reasoning_config_instance(self) -> None: def test_default_agent_type_accepts_reasoning(self) -> None: """Test that default (None) agent type accepts reasoning.""" agent = AgentDef(name="a", model="gpt-4", prompt="test", reasoning={"effort": "medium"}) - assert agent.type is None + assert agent.type == "agent" assert agent.reasoning is not None assert agent.reasoning.effort == "medium" @@ -1700,36 +1725,39 @@ def test_explicit_agent_type_accepts_reasoning(self) -> None: def test_human_gate_with_reasoning_raises(self) -> None: """Test that human_gate agents cannot have reasoning.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="gate1", - type="human_gate", - prompt="Choose:", - options=[GateOption(label="Ok", value="ok", route="next")], - reasoning={"effort": "low"}, + HumanGateStepDef.model_validate( + { + "name": "gate1", + "prompt": "Choose:", + "options": [GateOption(label="Ok", value="ok", route="next")], + "reasoning": {"effort": "low"}, + } ) - assert "human_gate agents cannot have 'reasoning'" in str(exc_info.value) + _assert_extra_forbidden(exc_info, "reasoning") def test_script_with_reasoning_raises(self) -> None: """Test that script agents cannot have reasoning.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="s", - type="script", - command="echo hello", - reasoning={"effort": "high"}, + ScriptStepDef.model_validate( + { + "name": "s", + "command": "echo hello", + "reasoning": {"effort": "high"}, + } ) - assert "script agents cannot have 'reasoning'" in str(exc_info.value) + _assert_extra_forbidden(exc_info, "reasoning") def test_workflow_with_reasoning_raises(self) -> None: """Test that workflow agents cannot have reasoning.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="w", - type="workflow", - workflow="./sub.yaml", - reasoning={"effort": "medium"}, + WorkflowStepDef.model_validate( + { + "name": "w", + "workflow": "./sub.yaml", + "reasoning": {"effort": "medium"}, + } ) - assert "workflow agents cannot have 'reasoning'" in str(exc_info.value) + _assert_extra_forbidden(exc_info, "reasoning") class TestAgentDefReasoningTemplating: @@ -1792,14 +1820,15 @@ def test_human_gate_with_templated_reasoning_still_raises(self) -> None: A template string is still "not None", so the per-type ban applies. """ with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="gate1", - type="human_gate", - prompt="Choose:", - options=[GateOption(label="Ok", value="ok", route="next")], - reasoning={"effort": "{{ workflow.input.eff }}"}, + HumanGateStepDef.model_validate( + { + "name": "gate1", + "prompt": "Choose:", + "options": [GateOption(label="Ok", value="ok", route="next")], + "reasoning": {"effort": "{{ workflow.input.eff }}"}, + } ) - assert "human_gate agents cannot have 'reasoning'" in str(exc_info.value) + _assert_extra_forbidden(exc_info, "reasoning") class TestAgentDefValidator: @@ -1913,70 +1942,76 @@ def test_unknown_validator_field_rejected(self) -> None: def test_human_gate_with_validator_raises(self) -> None: """Test that human_gate agents cannot have validator.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="gate1", - type="human_gate", - prompt="Choose:", - options=[GateOption(label="Ok", value="ok", route="next")], - validator={"criteria": "Check"}, + HumanGateStepDef.model_validate( + { + "name": "gate1", + "prompt": "Choose:", + "options": [GateOption(label="Ok", value="ok", route="next")], + "validator": {"criteria": "Check"}, + } ) - assert "human_gate agents cannot have 'validator'" in str(exc_info.value) + _assert_extra_forbidden(exc_info, "validator") def test_script_with_validator_raises(self) -> None: """Test that script agents cannot have validator.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="s", - type="script", - command="echo hello", - validator={"criteria": "Check"}, + ScriptStepDef.model_validate( + { + "name": "s", + "command": "echo hello", + "validator": {"criteria": "Check"}, + } ) - assert "script agents cannot have 'validator'" in str(exc_info.value) + _assert_extra_forbidden(exc_info, "validator") def test_workflow_with_validator_raises(self) -> None: """Test that workflow agents cannot have validator.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="w", - type="workflow", - workflow="./sub.yaml", - validator={"criteria": "Check"}, + WorkflowStepDef.model_validate( + { + "name": "w", + "workflow": "./sub.yaml", + "validator": {"criteria": "Check"}, + } ) - assert "workflow agents cannot have 'validator'" in str(exc_info.value) + _assert_extra_forbidden(exc_info, "validator") def test_wait_with_validator_raises(self) -> None: """Test that wait agents cannot have validator.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="w", - type="wait", - duration="5s", - validator={"criteria": "Check"}, + WaitStepDef.model_validate( + { + "name": "w", + "duration": "5s", + "validator": {"criteria": "Check"}, + } ) - assert "wait agents cannot have 'validator'" in str(exc_info.value) + _assert_extra_forbidden(exc_info, "validator") def test_set_with_validator_raises(self) -> None: """Test that set agents cannot have validator.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="s", - type="set", - value="{{ workflow.input.x }}", - validator={"criteria": "Check"}, + SetStepDef.model_validate( + { + "name": "s", + "value": "{{ workflow.input.x }}", + "validator": {"criteria": "Check"}, + } ) - assert "set agents cannot have 'validator'" in str(exc_info.value) + _assert_extra_forbidden(exc_info, "validator") def test_terminate_with_validator_raises(self) -> None: """Test that terminate agents cannot have validator.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="t", - type="terminate", - status="success", - reason="done", - validator={"criteria": "Check"}, + TerminateStepDef.model_validate( + { + "name": "t", + "status": "success", + "reason": "done", + "validator": {"criteria": "Check"}, + } ) - assert "terminate agents cannot have 'validator'" in str(exc_info.value) + _assert_extra_forbidden(exc_info, "validator") class TestRuntimeConfigDefaultReasoningEffort: @@ -2085,60 +2120,66 @@ def test_context_tier_composes_with_reasoning(self) -> None: def test_human_gate_with_context_tier_raises(self) -> None: """Test that human_gate agents cannot have context_tier.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="g", - type="human_gate", - prompt="Approve?", - options=[GateOption(label="Ok", value="ok", route="next")], - context_tier="long_context", + HumanGateStepDef.model_validate( + { + "name": "g", + "prompt": "Approve?", + "options": [GateOption(label="Ok", value="ok", route="next")], + "context_tier": "long_context", + } ) - assert "human_gate agents cannot have 'context_tier'" in str(exc_info.value) + _assert_extra_forbidden(exc_info, "context_tier") def test_script_with_context_tier_raises(self) -> None: """Test that script agents cannot have context_tier.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="s", - type="script", - command="echo hi", - context_tier="long_context", + ScriptStepDef.model_validate( + { + "name": "s", + "command": "echo hi", + "context_tier": "long_context", + } ) - assert "script agents cannot have 'context_tier'" in str(exc_info.value) + _assert_extra_forbidden(exc_info, "context_tier") def test_workflow_with_context_tier_raises(self) -> None: """Test that workflow agents cannot have context_tier.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="w", - type="workflow", - workflow="./sub.yaml", - context_tier="long_context", + WorkflowStepDef.model_validate( + { + "name": "w", + "workflow": "./sub.yaml", + "context_tier": "long_context", + } ) - assert "workflow agents cannot have 'context_tier'" in str(exc_info.value) + _assert_extra_forbidden(exc_info, "context_tier") def test_wait_with_context_tier_raises(self) -> None: """Test that wait agents cannot have context_tier.""" with pytest.raises(ValidationError) as exc_info: - AgentDef(name="w", type="wait", duration="1s", context_tier="long_context") - assert "wait agents cannot have 'context_tier'" in str(exc_info.value) + WaitStepDef.model_validate( + {"name": "w", "duration": "1s", "context_tier": "long_context"} + ) + _assert_extra_forbidden(exc_info, "context_tier") def test_set_with_context_tier_raises(self) -> None: """Test that set agents cannot have context_tier.""" with pytest.raises(ValidationError) as exc_info: - AgentDef(name="s", type="set", value="42", context_tier="long_context") - assert "set agents cannot have 'context_tier'" in str(exc_info.value) + SetStepDef.model_validate({"name": "s", "value": "42", "context_tier": "long_context"}) + _assert_extra_forbidden(exc_info, "context_tier") def test_terminate_with_context_tier_raises(self) -> None: """Test that terminate agents cannot have context_tier.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="t", - type="terminate", - status="success", - reason="done", - context_tier="long_context", + TerminateStepDef.model_validate( + { + "name": "t", + "status": "success", + "reason": "done", + "context_tier": "long_context", + } ) - assert "terminate agents cannot have 'context_tier'" in str(exc_info.value) + _assert_extra_forbidden(exc_info, "context_tier") class TestAgentDefContextTierTemplating: @@ -2188,14 +2229,15 @@ def test_human_gate_with_templated_context_tier_still_raises(self) -> None: A template string is still "not None", so the per-type ban applies. """ with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="g", - type="human_gate", - prompt="Approve?", - options=[GateOption(label="Ok", value="ok", route="next")], - context_tier="{{ workflow.input.tier }}", + HumanGateStepDef.model_validate( + { + "name": "g", + "prompt": "Approve?", + "options": [GateOption(label="Ok", value="ok", route="next")], + "context_tier": "{{ workflow.input.tier }}", + } ) - assert "human_gate agents cannot have 'context_tier'" in str(exc_info.value) + _assert_extra_forbidden(exc_info, "context_tier") class TestRuntimeConfigDefaultContextTier: @@ -2366,16 +2408,15 @@ class TestTerminateAgent: """ def test_valid_terminate_success(self) -> None: - a = AgentDef(name="ok", type="terminate", status="success", reason="done") + a = TerminateStepDef(name="ok", status="success", reason="done") assert a.type == "terminate" assert a.status == "success" assert a.reason == "done" assert a.output_template is None def test_valid_terminate_failed_with_output_template(self) -> None: - a = AgentDef( + a = TerminateStepDef( name="abort", - type="terminate", status="failed", reason="Refusing to run on unsafe input", output_template={"result": "aborted", "reason": "{{ precheck.output.reason }}"}, @@ -2388,29 +2429,28 @@ def test_valid_terminate_failed_with_output_template(self) -> None: def test_missing_status_rejected(self) -> None: with pytest.raises(ValidationError) as exc_info: - AgentDef(name="x", type="terminate", reason="needed") + TerminateStepDef(name="x", reason="needed") assert "status" in str(exc_info.value).lower() def test_missing_reason_rejected(self) -> None: with pytest.raises(ValidationError) as exc_info: - AgentDef(name="x", type="terminate", status="success") + TerminateStepDef(name="x", status="success") assert "reason" in str(exc_info.value).lower() def test_empty_reason_rejected(self) -> None: with pytest.raises(ValidationError) as exc_info: - AgentDef(name="x", type="terminate", status="success", reason=" ") + TerminateStepDef(name="x", status="success", reason=" ") assert "reason" in str(exc_info.value).lower() def test_invalid_status_rejected(self) -> None: with pytest.raises(ValidationError): - AgentDef(name="x", type="terminate", status="maybe", reason="x") + TerminateStepDef(name="x", status="maybe", reason="x") def test_routes_rejected_on_terminate(self) -> None: """Terminate ends the workflow; outbound routes would be unreachable.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( + TerminateStepDef( name="x", - type="terminate", status="success", reason="r", routes=[RouteDef(to="$end")], @@ -2419,15 +2459,14 @@ def test_routes_rejected_on_terminate(self) -> None: def test_tools_rejected_on_terminate(self) -> None: with pytest.raises(ValidationError) as exc_info: - AgentDef(name="x", type="terminate", status="success", reason="r", tools=["foo"]) + TerminateStepDef(name="x", status="success", reason="r", tools=["foo"]) assert "tools" in str(exc_info.value).lower() def test_output_rejected_on_terminate(self) -> None: """`output:` is for agent schemas; terminate uses `output_template:` instead.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( + TerminateStepDef( name="x", - type="terminate", status="success", reason="r", output={"k": OutputField(type="string")}, @@ -2436,24 +2475,23 @@ def test_output_rejected_on_terminate(self) -> None: def test_prompt_rejected_on_terminate(self) -> None: with pytest.raises(ValidationError) as exc_info: - AgentDef(name="x", type="terminate", status="success", reason="r", prompt="hi") + TerminateStepDef(name="x", status="success", reason="r", prompt="hi") assert "prompt" in str(exc_info.value).lower() def test_model_rejected_on_terminate(self) -> None: with pytest.raises(ValidationError) as exc_info: - AgentDef(name="x", type="terminate", status="success", reason="r", model="claude") + TerminateStepDef(name="x", status="success", reason="r", model="claude") assert "model" in str(exc_info.value).lower() def test_command_rejected_on_terminate(self) -> None: with pytest.raises(ValidationError) as exc_info: - AgentDef(name="x", type="terminate", status="success", reason="r", command="echo") + TerminateStepDef(name="x", status="success", reason="r", command="echo") assert "command" in str(exc_info.value).lower() def test_workflow_rejected_on_terminate(self) -> None: with pytest.raises(ValidationError) as exc_info: - AgentDef( + TerminateStepDef( name="x", - type="terminate", status="success", reason="r", workflow="./sub.yaml", @@ -2489,14 +2527,14 @@ def test_terminate_fields_rejected_on_regular_agent(self, forbidden_field: str) def test_terminate_fields_rejected_on_other_step_types( self, step_type: str, forbidden_field: str, field_value: object ) -> None: - """The terminate-only-fields guard must trip for every non-terminate type - and every terminate-exclusive field — not just `status`. - - Earlier iteration of this test only varied ``step_type`` and asserted on - ``status``. A bug in ``validate_agent_type`` that, say, rejected only - ``status`` on ``script`` agents but silently accepted ``reason`` and - ``output_template`` would have slipped through. Cross-product the - parametrisation so every (step_type, terminate-field) pair is exercised. + """The terminate-only fields must trip on every non-terminate variant. + + Validated through the ``StepDef`` union so the concrete variant — not + the LLM ``AgentDef`` — is what rejects the foreign field. The baseline + payload is asserted valid first: without that, the rejection below + could be caused by the variant simply not recognizing the payload at + all, and would keep passing even if the variant started accepting + terminate-only fields tomorrow. """ payload: dict[str, object] = {"name": "a", "type": step_type} if step_type == "script": @@ -2505,17 +2543,23 @@ def test_terminate_fields_rejected_on_other_step_types( payload["workflow"] = "./sub.yaml" elif step_type == "human_gate": payload["prompt"] = "Pick" - payload["options"] = [GateOption(value="x", label="X", route="$end")] + payload["options"] = [{"value": "x", "label": "X", "route": "$end"}] + + _STEP_DEF_ADAPTER.validate_python(payload) + payload[forbidden_field] = field_value with pytest.raises(ValidationError) as exc_info: - AgentDef.model_validate(payload) - assert forbidden_field in str(exc_info.value) + _STEP_DEF_ADAPTER.validate_python(payload) + errors = exc_info.value.errors() + assert any( + err["type"] == "extra_forbidden" and err["loc"] == (step_type, forbidden_field) + for err in errors + ), f"Expected extra_forbidden at ({step_type!r}, {forbidden_field!r}), got: {errors}" def test_input_allowed_on_terminate(self) -> None: """Terminate steps may declare context inputs to drive Jinja rendering.""" - a = AgentDef( + a = TerminateStepDef( name="x", - type="terminate", status="success", reason="{{ precheck.output.reason }}", input=["precheck.output"], @@ -2528,9 +2572,8 @@ class TestScriptStdinField: def test_stdin_accepted_on_script(self) -> None: """A script step may declare a stdin payload template.""" - agent = AgentDef( + agent = ScriptStepDef( name="s", - type="script", command="cat", stdin="{{ upstream.output.evaluations | tojson }}", ) @@ -2538,35 +2581,38 @@ def test_stdin_accepted_on_script(self) -> None: def test_stdin_empty_string_accepted_on_script(self) -> None: """An explicit empty stdin is valid (pipes immediate EOF), distinct from omission.""" - agent = AgentDef(name="s", type="script", command="cat", stdin="") + agent = ScriptStepDef(name="s", command="cat", stdin="") assert agent.stdin == "" def test_stdin_defaults_to_none(self) -> None: """Omitting stdin leaves it None (legacy inherit-stdin behavior).""" - agent = AgentDef(name="s", type="script", command="echo") + agent = ScriptStepDef(name="s", command="echo") assert agent.stdin is None @pytest.mark.parametrize( - "step_type", - ["agent", "human_gate", "set", "wait", "terminate", "workflow"], + "step_class,valid_kwargs", + [ + (AgentDef, {"name": "a"}), + ( + HumanGateStepDef, + { + "prompt": "Pick", + "options": [GateOption(value="x", label="X", route="$end")], + }, + ), + (SetStepDef, {"value": "{{ 1 }}"}), + (WaitStepDef, {"duration": "1s"}), + (TerminateStepDef, {"status": "success", "reason": "r"}), + (WorkflowStepDef, {"workflow": "./sub.yaml"}), + ], + ids=["agent", "human_gate", "set", "wait", "terminate", "workflow"], ) - def test_stdin_rejected_on_non_script_types(self, step_type: str) -> None: - """The script-exclusive guard trips for every non-script step type.""" - payload: dict[str, object] = {"name": "a", "type": step_type, "stdin": "data"} - if step_type == "human_gate": - payload["prompt"] = "Pick" - payload["options"] = [GateOption(value="x", label="X", route="$end")] - elif step_type == "set": - payload["value"] = "{{ 1 }}" - elif step_type == "wait": - payload["duration"] = "1s" - elif step_type == "terminate": - payload["status"] = "success" - payload["reason"] = "r" - elif step_type == "workflow": - payload["workflow"] = "./sub.yaml" + def test_stdin_rejected_on_non_script_types(self, step_class: type, valid_kwargs: dict) -> None: + """The script-exclusive guard trips for every non-script step type. + + ``stdin`` is a ScriptStepDef-only field; every sibling variant rejects it + via extra="forbid" (standard extra_forbidden error). + """ with pytest.raises(ValidationError) as exc_info: - AgentDef.model_validate(payload) - message = str(exc_info.value) - assert "stdin" in message - assert "only 'script' agents support this field" in message + step_class.model_validate({"name": "a", "stdin": "data", **valid_kwargs}) + _assert_extra_forbidden(exc_info, "stdin") diff --git a/tests/test_config/test_script_schema.py b/tests/test_config/test_script_schema.py index 6b8992bd..e0fbff93 100644 --- a/tests/test_config/test_script_schema.py +++ b/tests/test_config/test_script_schema.py @@ -11,17 +11,19 @@ from __future__ import annotations import pytest -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from conductor.config.schema import ( AgentDef, ForEachDef, GateOption, + HumanGateStepDef, LimitsConfig, OutputField, ParallelGroup, RouteDef, RuntimeConfig, + ScriptStepDef, WorkflowConfig, WorkflowDef, ) @@ -29,12 +31,36 @@ from conductor.exceptions import ConfigurationError +def _assert_extra_forbidden(model_cls: type[BaseModel], payload: dict, field: str) -> None: + """Assert that ``field`` is rejected as an extra (foreign) field on ``model_cls``. + + Step variants declare ``extra="forbid"``, so a field owned by another variant + fails with Pydantic's standard ``extra_forbidden`` error at ``field``'s location. + """ + with pytest.raises(ValidationError) as exc_info: + model_cls.model_validate(payload) + assert any( + error["loc"] == (field,) and error["type"] == "extra_forbidden" + for error in exc_info.value.errors() + ) + + +def _assert_timeout_not_positive(timeout: int) -> None: + """Assert that a non-positive ``timeout`` fails the schema's ``gt=0`` bound.""" + with pytest.raises(ValidationError) as exc_info: + ScriptStepDef(name="bad", command="echo", timeout=timeout) + assert any( + error["loc"] == ("timeout",) and error["type"] == "greater_than" + for error in exc_info.value.errors() + ) + + class TestScriptAgentDef: """Tests for script type AgentDef validation.""" def test_valid_script_agent(self) -> None: """Test creating a valid script agent.""" - agent = AgentDef(name="run_tests", type="script", command="pytest") + agent = ScriptStepDef(name="run_tests", command="pytest") assert agent.type == "script" assert agent.command == "pytest" assert agent.args == [] @@ -44,9 +70,8 @@ def test_valid_script_agent(self) -> None: def test_valid_script_agent_with_all_fields(self) -> None: """Test creating a script agent with all optional fields.""" - agent = AgentDef( + agent = ScriptStepDef( name="build", - type="script", command="make", args=["build", "--verbose"], env={"CI": "true"}, @@ -61,9 +86,8 @@ def test_valid_script_agent_with_all_fields(self) -> None: def test_script_agent_with_routes(self) -> None: """Test script agent with routes validates correctly.""" - agent = AgentDef( + agent = ScriptStepDef( name="check", - type="script", command="echo", args=["hello"], routes=[ @@ -76,32 +100,44 @@ def test_script_agent_with_routes(self) -> None: def test_script_without_command_raises(self) -> None: """Test that script agent without command raises ValidationError.""" with pytest.raises(ValidationError, match="script agents require 'command'"): - AgentDef(name="bad", type="script") + ScriptStepDef(name="bad") def test_script_with_empty_command_raises(self) -> None: """Test that script agent with empty command raises ValidationError.""" with pytest.raises(ValidationError, match="script agents require 'command'"): - AgentDef(name="bad", type="script", command="") + ScriptStepDef(name="bad", command="") def test_script_with_prompt_raises(self) -> None: - """Test that script agent with prompt raises ValidationError.""" - with pytest.raises(ValidationError, match="script agents cannot have 'prompt'"): - AgentDef(name="bad", type="script", command="echo", prompt="hello") + """Test that script agent with prompt raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + ScriptStepDef, + {"name": "bad", "command": "echo", "prompt": "hello"}, + "prompt", + ) def test_script_with_provider_raises(self) -> None: - """Test that script agent with provider raises ValidationError.""" - with pytest.raises(ValidationError, match="script agents cannot have 'provider'"): - AgentDef(name="bad", type="script", command="echo", provider="copilot") + """Test that script agent with provider raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + ScriptStepDef, + {"name": "bad", "command": "echo", "provider": "copilot"}, + "provider", + ) def test_script_with_model_raises(self) -> None: - """Test that script agent with model raises ValidationError.""" - with pytest.raises(ValidationError, match="script agents cannot have 'model'"): - AgentDef(name="bad", type="script", command="echo", model="gpt-4") + """Test that script agent with model raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + ScriptStepDef, + {"name": "bad", "command": "echo", "model": "gpt-4"}, + "model", + ) def test_script_with_tools_raises(self) -> None: - """Test that script agent with tools raises ValidationError.""" - with pytest.raises(ValidationError, match="script agents cannot have 'tools'"): - AgentDef(name="bad", type="script", command="echo", tools=["web_search"]) + """Test that script agent with tools raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + ScriptStepDef, + {"name": "bad", "command": "echo", "tools": ["web_search"]}, + "tools", + ) def test_script_with_output_accepted(self) -> None: """Script agents may declare an `output:` schema at config time (issue #118). @@ -110,9 +146,8 @@ def test_script_with_output_accepted(self) -> None: of the JSON stdout against the schema is exercised in ``tests/test_engine/test_script_workflow.py::TestScriptOutputSchema``. """ - agent = AgentDef( + agent = ScriptStepDef( name="detector", - type="script", command="python", args=["-c", "import json; print(json.dumps({'route': 'planning'}))"], output={ @@ -132,9 +167,8 @@ def test_script_with_empty_output_accepted(self) -> None: is exercised in ``test_engine/test_script_workflow.py::test_empty_schema_requires_json_object``. """ - agent = AgentDef( + agent = ScriptStepDef( name="probe", - type="script", command="echo", args=["{}"], output={}, @@ -142,29 +176,32 @@ def test_script_with_empty_output_accepted(self) -> None: assert agent.output == {} def test_script_with_system_prompt_raises(self) -> None: - """Test that script agent with system_prompt raises ValidationError.""" - with pytest.raises(ValidationError, match="script agents cannot have 'system_prompt'"): - AgentDef(name="bad", type="script", command="echo", system_prompt="You are...") + """Test that script agent with system_prompt raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + ScriptStepDef, + {"name": "bad", "command": "echo", "system_prompt": "You are..."}, + "system_prompt", + ) def test_script_with_options_raises(self) -> None: - """Test that script agent with options raises ValidationError.""" - with pytest.raises(ValidationError, match="script agents cannot have 'options'"): - AgentDef( - name="bad", - type="script", - command="echo", - options=[GateOption(label="OK", value="ok", route="$end")], - ) + """Test that script agent with options raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + ScriptStepDef, + { + "name": "bad", + "command": "echo", + "options": [GateOption(label="OK", value="ok", route="$end")], + }, + "options", + ) def test_timeout_rejects_zero(self) -> None: - """Test that timeout=0 raises ValidationError.""" - with pytest.raises(ValidationError, match="timeout must be a positive integer"): - AgentDef(name="bad", type="script", command="echo", timeout=0) + """Test that timeout=0 raises ValidationError (fails the gt=0 bound).""" + _assert_timeout_not_positive(0) def test_timeout_rejects_negative(self) -> None: - """Test that negative timeout raises ValidationError.""" - with pytest.raises(ValidationError, match="timeout must be a positive integer"): - AgentDef(name="bad", type="script", command="echo", timeout=-5) + """Test that negative timeout raises ValidationError (fails the gt=0 bound).""" + _assert_timeout_not_positive(-5) class TestScriptBackwardCompatibility: @@ -173,8 +210,8 @@ class TestScriptBackwardCompatibility: def test_regular_agent_still_works(self) -> None: """Test that a regular agent definition is unaffected.""" agent = AgentDef(name="test", prompt="hello") - assert agent.type is None - assert agent.command is None + assert agent.type == "agent" + assert agent.prompt == "hello" def test_explicit_agent_type_still_works(self) -> None: """Test that explicit type='agent' still works.""" @@ -183,9 +220,8 @@ def test_explicit_agent_type_still_works(self) -> None: def test_human_gate_still_works(self) -> None: """Test that human_gate type is unaffected.""" - agent = AgentDef( + agent = HumanGateStepDef( name="gate", - type="human_gate", prompt="Choose:", options=[GateOption(label="Yes", value="yes", route="$end")], ) @@ -206,7 +242,7 @@ def test_script_in_parallel_group_raises(self) -> None: ), agents=[ AgentDef(name="agent_a", prompt="do something"), - AgentDef(name="script_b", type="script", command="echo"), + ScriptStepDef(name="script_b", command="echo"), ], parallel=[ ParallelGroup( @@ -240,9 +276,8 @@ def test_script_in_for_each_raises(self) -> None: type="for_each", source="setup.output.items", **{"as": "item"}, - agent=AgentDef( + agent=ScriptStepDef( name="runner", - type="script", command="echo", ), ), @@ -265,9 +300,8 @@ def test_script_at_entry_point_validates(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="setup", - type="script", command="echo", args=["hello"], routes=[RouteDef(to="$end")], @@ -288,9 +322,8 @@ def test_script_with_routes_to_agents(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="checker", - type="script", command="test", args=["-f", "output.txt"], routes=[ diff --git a/tests/test_config/test_set_schema.py b/tests/test_config/test_set_schema.py index f4587ab9..117d196e 100644 --- a/tests/test_config/test_set_schema.py +++ b/tests/test_config/test_set_schema.py @@ -1,16 +1,18 @@ """Tests for 'set' type schema validation. Covers: -- Valid single-value and multi-values set agent definitions +- Valid single-value and multi-values set step definitions - Mutual exclusion of value/values (both forbidden, neither forbidden) - output_type only valid on single value (forbidden on values) -- Every forbidden field rejected on set type -- value/values/output_type rejected on non-set types +- Every field owned by another variant rejected via extra_forbidden +- value/values/output_type rejected on non-set variants - Cross-validator (set in entry point, parallel groups, for_each) """ from __future__ import annotations +from typing import Literal + import pytest from pydantic import ValidationError @@ -18,33 +20,45 @@ AgentDef, ForEachDef, GateOption, + HumanGateStepDef, LimitsConfig, OutputField, ParallelGroup, RetryPolicy, RouteDef, RuntimeConfig, + ScriptStepDef, + SetStepDef, WorkflowConfig, WorkflowDef, + WorkflowStepDef, ) from conductor.config.validator import validate_workflow_config from conductor.exceptions import ConfigurationError +SetOutputType = Literal["auto", "string", "number", "integer", "boolean", "list", "dict"] + + +def _assert_extra_forbidden(exc_info: pytest.ExceptionInfo[ValidationError], field: str) -> None: + """Assert a variant-owned-by-sibling field failed with extra_forbidden on that field.""" + assert any( + e["loc"] == (field,) and e["type"] == "extra_forbidden" for e in exc_info.value.errors() + ) + class TestSetAgentDefValidConfigs: """Valid set-type agent definitions.""" def test_valid_single_value(self) -> None: - agent = AgentDef(name="compute", type="set", value="{{ workflow.input.org }}") + agent = SetStepDef(name="compute", value="{{ workflow.input.org }}") assert agent.type == "set" assert agent.value == "{{ workflow.input.org }}" assert agent.values is None assert agent.output_type is None def test_valid_multi_values(self) -> None: - agent = AgentDef( + agent = SetStepDef( name="derive", - type="set", values={ "is_breaking": "{{ true }}", "target_branch": "main", @@ -55,32 +69,38 @@ def test_valid_multi_values(self) -> None: assert len(agent.values) == 2 def test_valid_with_output_type_on_single(self) -> None: - for ot in ("auto", "string", "number", "integer", "boolean", "list", "dict"): - agent = AgentDef(name="x", type="set", value="42", output_type=ot) # type: ignore[arg-type] + output_types: list[SetOutputType] = [ + "auto", + "string", + "number", + "integer", + "boolean", + "list", + "dict", + ] + for ot in output_types: + agent = SetStepDef(name="x", value="42", output_type=ot) assert agent.output_type == ot def test_valid_with_routes(self) -> None: - agent = AgentDef( + agent = SetStepDef( name="flag", - type="set", value="{{ true }}", routes=[RouteDef(to="$end")], ) assert len(agent.routes) == 1 def test_valid_with_input_declarations(self) -> None: - agent = AgentDef( + agent = SetStepDef( name="combine", - type="set", value="{{ research.output.summary }}", input=["research.output"], ) assert agent.input == ["research.output"] def test_valid_with_output_schema(self) -> None: - agent = AgentDef( + agent = SetStepDef( name="flags", - type="set", values={"ok": "{{ true }}"}, output={"ok": OutputField(type="boolean")}, ) @@ -91,103 +111,114 @@ class TestSetAgentDefMutualExclusion: """value: / values: mutual exclusion.""" def test_neither_value_nor_values_rejected(self) -> None: + # Variant-owned invariant: exactly one of value/values is required. with pytest.raises(ValidationError, match="exactly one of 'value' or 'values'"): - AgentDef(name="bad", type="set") + SetStepDef(name="bad") def test_both_value_and_values_rejected(self) -> None: + # Variant-owned invariant: value and values are mutually exclusive. with pytest.raises(ValidationError, match="exactly one of 'value' or 'values'"): - AgentDef(name="bad", type="set", value="1", values={"a": "2"}) + SetStepDef(name="bad", value="1", values={"a": "2"}) def test_output_type_with_values_rejected(self) -> None: + # Variant-owned invariant: output_type only applies to a single value. with pytest.raises(ValidationError, match="output_type"): - AgentDef( + SetStepDef( name="bad", - type="set", values={"a": "1"}, output_type="string", ) def test_output_type_with_value_accepted(self) -> None: - agent = AgentDef(name="ok", type="set", value="1", output_type="integer") + agent = SetStepDef(name="ok", value="1", output_type="integer") assert agent.output_type == "integer" class TestSetAgentDefForbiddenFields: - """Fields forbidden on set type.""" + """Fields owned by other step variants must be rejected on set (extra_forbidden).""" @pytest.mark.parametrize( - "field,value,err", + "field,value", [ - ("prompt", "hi", "cannot have 'prompt'"), - ("provider", "copilot", "cannot have 'provider'"), - ("model", "gpt-4", "cannot have 'model'"), - ("tools", ["web_search"], "cannot have 'tools'"), - ("system_prompt", "you are", "cannot have 'system_prompt'"), - ( - "options", - [GateOption(label="OK", value="ok", route="$end")], - "cannot have 'options'", - ), - ("command", "echo", "cannot have 'command'"), - ("args", ["x"], "cannot have 'args'"), - ("env", {"K": "v"}, "cannot have 'env'"), - ("working_dir", "/tmp", "cannot have 'working_dir'"), - ("settings_dir", "/tmp", "cannot have 'settings_dir'"), - ("timeout", 5, "cannot have 'timeout'"), - ("workflow", "x.yaml", "cannot have 'workflow'"), - ("input_mapping", {"a": "1"}, "cannot have 'input_mapping'"), - ("max_depth", 2, "cannot have 'max_depth'"), - ("max_session_seconds", 10.0, "cannot have 'max_session_seconds'"), - ("max_agent_iterations", 5, "cannot have 'max_agent_iterations'"), - ("retry", RetryPolicy(max_attempts=2), "cannot have 'retry'"), - ("timeout_seconds", 5.0, "cannot have 'timeout_seconds'"), + ("prompt", "hi"), + ("provider", "copilot"), + ("model", "gpt-4"), + ("tools", ["web_search"]), + ("system_prompt", "you are"), + ("options", [GateOption(label="OK", value="ok", route="$end")]), + ("command", "echo"), + ("args", ["x"]), + ("env", {"K": "v"}), + ("working_dir", "/tmp"), + ("settings_dir", "/tmp"), + ("timeout", 5), + ("workflow", "x.yaml"), + ("input_mapping", {"a": "1"}), + ("max_depth", 2), + ("max_session_seconds", 10.0), + ("max_agent_iterations", 5), + ("retry", RetryPolicy(max_attempts=2)), + ("timeout_seconds", 5.0), ], ) - def test_forbidden_field_rejected(self, field: str, value: object, err: str) -> None: - with pytest.raises(ValidationError, match=err): - AgentDef(name="bad", type="set", value="x", **{field: value}) # type: ignore[arg-type] + def test_forbidden_field_rejected(self, field: str, value: object) -> None: + # extra="forbid": a field belonging to another variant fails on that field. + with pytest.raises(ValidationError) as exc_info: + SetStepDef.model_validate({"name": "bad", "value": "x", field: value}) + _assert_extra_forbidden(exc_info, field) class TestSetFieldsOnOtherTypes: - """value/values/output_type rejected on non-set types.""" + """value/values/output_type are set-only — other variants must reject them.""" def test_value_on_default_agent_rejected(self) -> None: - with pytest.raises(ValidationError, match="cannot have 'value'"): - AgentDef(name="bad", value="x") + with pytest.raises(ValidationError) as exc_info: + AgentDef.model_validate({"name": "bad", "value": "x"}) + _assert_extra_forbidden(exc_info, "value") def test_values_on_default_agent_rejected(self) -> None: - with pytest.raises(ValidationError, match="cannot have 'values'"): - AgentDef(name="bad", values={"a": "1"}) + with pytest.raises(ValidationError) as exc_info: + AgentDef.model_validate({"name": "bad", "values": {"a": "1"}}) + _assert_extra_forbidden(exc_info, "values") def test_output_type_on_default_agent_rejected(self) -> None: - with pytest.raises(ValidationError, match="cannot have 'output_type'"): - AgentDef(name="bad", output_type="string") + with pytest.raises(ValidationError) as exc_info: + AgentDef.model_validate({"name": "bad", "output_type": "string"}) + _assert_extra_forbidden(exc_info, "output_type") def test_value_on_script_rejected(self) -> None: - with pytest.raises(ValidationError, match="cannot have 'value'"): - AgentDef(name="bad", type="script", command="echo", value="x") + with pytest.raises(ValidationError) as exc_info: + ScriptStepDef.model_validate({"name": "bad", "command": "echo", "value": "x"}) + _assert_extra_forbidden(exc_info, "value") def test_values_on_script_rejected(self) -> None: - with pytest.raises(ValidationError, match="cannot have 'values'"): - AgentDef(name="bad", type="script", command="echo", values={"a": "1"}) + with pytest.raises(ValidationError) as exc_info: + ScriptStepDef.model_validate({"name": "bad", "command": "echo", "values": {"a": "1"}}) + _assert_extra_forbidden(exc_info, "values") def test_output_type_on_script_rejected(self) -> None: - with pytest.raises(ValidationError, match="cannot have 'output_type'"): - AgentDef(name="bad", type="script", command="echo", output_type="string") + with pytest.raises(ValidationError) as exc_info: + ScriptStepDef.model_validate( + {"name": "bad", "command": "echo", "output_type": "string"} + ) + _assert_extra_forbidden(exc_info, "output_type") def test_value_on_human_gate_rejected(self) -> None: - with pytest.raises(ValidationError, match="cannot have 'value'"): - AgentDef( - name="bad", - type="human_gate", - prompt="?", - options=[GateOption(label="OK", value="ok", route="$end")], - value="x", + with pytest.raises(ValidationError) as exc_info: + HumanGateStepDef.model_validate( + { + "name": "bad", + "prompt": "?", + "options": [GateOption(label="OK", value="ok", route="$end")], + "value": "x", + } ) + _assert_extra_forbidden(exc_info, "value") def test_value_on_workflow_rejected(self) -> None: - with pytest.raises(ValidationError, match="cannot have 'value'"): - AgentDef(name="bad", type="workflow", workflow="x.yaml", value="x") + with pytest.raises(ValidationError) as exc_info: + WorkflowStepDef.model_validate({"name": "bad", "workflow": "x.yaml", "value": "x"}) + _assert_extra_forbidden(exc_info, "value") class TestSetWorkflowConfig: @@ -202,9 +233,8 @@ def test_set_at_entry_point_validates(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="compute", - type="set", value="{{ true }}", routes=[RouteDef(to="$end")], ), @@ -222,9 +252,8 @@ def test_set_routes_to_agent(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="flag", - type="set", value="{{ true }}", routes=[RouteDef(to="downstream")], ), @@ -245,7 +274,7 @@ def test_set_in_parallel_group_allowed(self) -> None: ), agents=[ AgentDef(name="real", prompt="hi"), - AgentDef(name="bind", type="set", value="{{ workflow.input.x }}"), + SetStepDef(name="bind", value="{{ workflow.input.x }}"), ], parallel=[ ParallelGroup(name="grp", agents=["real", "bind"], routes=[RouteDef(to="$end")]), @@ -270,9 +299,8 @@ def test_set_in_for_each_allowed(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="setup", - type="set", values={"items": "{{ [1, 2, 3] }}"}, routes=[RouteDef(to="loop")], ), @@ -283,9 +311,8 @@ def test_set_in_for_each_allowed(self) -> None: type="for_each", source="setup.output.items", **{"as": "item"}, - agent=AgentDef( + agent=SetStepDef( name="binder", - type="set", value="item-{{ item }}", ), routes=[RouteDef(to="$end")], @@ -306,9 +333,8 @@ def test_set_cannot_depend_on_sibling_in_parallel_group(self) -> None: ), agents=[ AgentDef(name="sibling", prompt="hi"), - AgentDef( + SetStepDef( name="bind", - type="set", value="{{ sibling.output.summary }}", ), ], @@ -325,16 +351,16 @@ def test_set_cannot_depend_on_sibling_in_parallel_group(self) -> None: class TestSetBackwardCompatibility: - """Existing types still work.""" + """Existing variants still work alongside set steps.""" def test_default_agent_unchanged(self) -> None: + # Missing/null type normalizes to a plain LLM agent. a = AgentDef(name="x", prompt="hi") - assert a.type is None - assert a.value is None - assert a.values is None - assert a.output_type is None + assert a.type == "agent" + assert a.prompt == "hi" def test_script_unchanged(self) -> None: - a = AgentDef(name="x", type="script", command="echo") - assert a.value is None - assert a.values is None + # Script steps still construct independently of the set variant. + a = ScriptStepDef(name="x", command="echo") + assert a.type == "script" + assert a.command == "echo" diff --git a/tests/test_config/test_settings_dir_schema.py b/tests/test_config/test_settings_dir_schema.py index 9e6a0956..63ddb2c4 100644 --- a/tests/test_config/test_settings_dir_schema.py +++ b/tests/test_config/test_settings_dir_schema.py @@ -22,12 +22,20 @@ from conductor.config.schema import ( AgentDef, GateOption, + HumanGateStepDef, OutputField, ProviderSettings, + QuestionsStepDef, RouteDef, RuntimeConfig, + ScriptStepDef, + SetStepDef, + StepDef, + TerminateStepDef, + WaitStepDef, WorkflowConfig, WorkflowDef, + WorkflowStepDef, ) from conductor.config.validator import validate_workflow_config from conductor.exceptions import ConfigurationError @@ -74,31 +82,58 @@ class TestSettingsDirRejectedOnNonProviderSteps: """ @pytest.mark.parametrize( - ("kwargs",), + ("model", "fields"), [ - ({"type": "wait", "duration": "1s"},), - ({"type": "set", "value": "x"},), - ({"type": "terminate", "status": "success", "reason": "done"},), - ({"type": "script", "command": "echo hi"},), - ({"type": "workflow", "workflow": "child.yaml"},), - ({"type": "questions", "questions": [{"id": "a", "text": "x"}]},), + (WaitStepDef, {"duration": "1s"}), + (SetStepDef, {"value": "x"}), + (TerminateStepDef, {"status": "success", "reason": "done"}), + (ScriptStepDef, {"command": "echo hi"}), + (WorkflowStepDef, {"workflow": "child.yaml"}), + (QuestionsStepDef, {"questions": [{"id": "a", "text": "x"}]}), ( + HumanGateStepDef, { - "type": "human_gate", "prompt": "ok?", "options": [GateOption(label="OK", value="ok", route="$end")], }, ), ], + ids=["wait", "set", "terminate", "script", "workflow", "questions", "human_gate"], ) - def test_rejected(self, kwargs: dict) -> None: - with pytest.raises(ValidationError, match="cannot have 'settings_dir'"): - AgentDef(name="bad", settings_dir="/repo", **kwargs) + def test_rejected(self, model: type[StepDef], fields: dict) -> None: + """Each non-provider variant refuses the field via ``extra="forbid"``. + + The per-type "cannot have 'settings_dir'" messages were removed in the + step-model split; the contract is now Pydantic's standard + extra_forbidden error on the foreign field. + """ + with pytest.raises(ValidationError) as exc_info: + model.model_validate({"name": "bad", **fields, "settings_dir": "/repo"}) + assert any( + e["loc"] == ("settings_dir",) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) def test_error_names_the_step_type(self) -> None: - """So the message says which step to fix, not merely that one is wrong.""" - with pytest.raises(ValidationError, match="wait agents cannot have 'settings_dir'"): - AgentDef(name="bad", type="wait", duration="1s", settings_dir="/repo") + """The failure must attribute the error to the offending step. + + Without the old custom messages, attribution comes from the schema + error location, which carries the step's index, its variant tag, and + the field — here ``('agents', 0, 'wait', 'settings_dir')``. + """ + with pytest.raises(ValidationError) as exc_info: + WorkflowConfig.model_validate( + { + "workflow": {"name": "w", "entry_point": "bad"}, + "agents": [ + {"type": "wait", "name": "bad", "duration": "1s", "settings_dir": "/repo"} + ], + } + ) + assert any( + e["loc"] == ("agents", 0, "wait", "settings_dir") and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) class TestSettingsDirValidation: @@ -114,21 +149,22 @@ class TestSettingsDirValidation: @staticmethod def _config(provider: object, settings_dir: str, tmp_path: Path) -> WorkflowConfig: + agents: list[StepDef] = [ + AgentDef( + name="a", + prompt="hi", + settings_dir=settings_dir, + output={"r": OutputField(type="string")}, + routes=[RouteDef(to="$end")], + ) + ] return WorkflowConfig( workflow=WorkflowDef( name="w", entry_point="a", runtime=RuntimeConfig(provider=provider), # type: ignore[arg-type] ), - agents=[ - AgentDef( - name="a", - prompt="hi", - settings_dir=settings_dir, - output={"r": OutputField(type="string")}, - routes=[RouteDef(to="$end")], - ) - ], + agents=agents, output={"r": "{{ a.output.r }}"}, ) @@ -211,8 +247,12 @@ def test_a_blank_value_cannot_bypass_a_step_type_rejection(self) -> None: With truthiness guards and no schema constraint, ``settings_dir=""`` was accepted on a ``wait`` step despite the documented rejection. """ - with pytest.raises(ValidationError): - AgentDef(name="w", type="wait", duration="1s", settings_dir="") + with pytest.raises(ValidationError) as exc_info: + WaitStepDef.model_validate({"name": "w", "duration": "1s", "settings_dir": ""}) + assert any( + e["loc"] == ("settings_dir",) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) class TestProjectTierWarningCauses: @@ -230,22 +270,23 @@ class TestProjectTierWarningCauses: @staticmethod def _warn(provider: object, agent_extra: dict, tmp_path: Path) -> str | None: + agents: list[StepDef] = [ + AgentDef( + name="a", + prompt="hi", + settings_dir=str(tmp_path), + output={"r": OutputField(type="string")}, + routes=[RouteDef(to="$end")], + **agent_extra, + ) + ] config = WorkflowConfig( workflow=WorkflowDef( name="w", entry_point="a", runtime=RuntimeConfig(provider=provider), # type: ignore[arg-type] ), - agents=[ - AgentDef( - name="a", - prompt="hi", - settings_dir=str(tmp_path), - output={"r": OutputField(type="string")}, - routes=[RouteDef(to="$end")], - **agent_extra, - ) - ], + agents=agents, output={"r": "{{ a.output.r }}"}, ) hits = [w for w in validate_workflow_config(config) if "settings_dir" in w] diff --git a/tests/test_config/test_step_def_union.py b/tests/test_config/test_step_def_union.py new file mode 100644 index 00000000..bb6897fd --- /dev/null +++ b/tests/test_config/test_step_def_union.py @@ -0,0 +1,452 @@ +"""Regression coverage for the static ``StepDef`` discriminated union (issue #517). + +Covers the boundaries the per-variant schema test files do not: the published +JSON Schema shape, ``ForEachDef.agent`` parsing, serialization round-trips, +and legacy ``type`` shorthand canonicalization. +""" + +from __future__ import annotations + +import jsonschema +import pytest +from pydantic import TypeAdapter +from pydantic import ValidationError as PydanticValidationError + +from conductor.config.loader import load_config_string +from conductor.config.schema import ( + AgentDef, + ForEachDef, + HumanGateStepDef, + MCPStepDef, + QuestionsStepDef, + ScriptStepDef, + SetStepDef, + StepDef, + TerminateStepDef, + WaitStepDef, + WorkflowConfig, + WorkflowStepDef, +) + +_STEP_CLASSES = ( + AgentDef, + HumanGateStepDef, + QuestionsStepDef, + ScriptStepDef, + MCPStepDef, + WaitStepDef, + SetStepDef, + TerminateStepDef, + WorkflowStepDef, +) + +_STEP_TAGS = { + "agent", + "human_gate", + "questions", + "script", + "mcp", + "wait", + "set", + "terminate", + "workflow", +} + + +def _workflow_payload(agents: list[dict]) -> dict: + return { + "workflow": {"name": "typed", "entry_point": agents[0]["name"]}, + "agents": agents, + } + + +class TestJsonSchemaShape: + """The published JSON Schema must expose the union, not the old monolith.""" + + def test_agents_items_use_one_of_with_discriminator(self) -> None: + # Requirement: schema consumers select a variant via discriminator metadata. + items = WorkflowConfig.model_json_schema()["properties"]["agents"]["items"] + + assert len(items["oneOf"]) == 9 + discriminator = items["discriminator"] + assert discriminator["propertyName"] == "type" + assert set(discriminator["mapping"]) == _STEP_TAGS + + def test_discriminator_mapping_points_at_named_variants(self) -> None: + # Requirement: every tag maps to the $def of its concrete step model. + schema = WorkflowConfig.model_json_schema() + mapping = schema["properties"]["agents"]["items"]["discriminator"]["mapping"] + + for ref in mapping.values(): + assert ref.startswith("#/$defs/") + assert ref.removeprefix("#/$defs/") in schema["$defs"] + + def test_every_variant_def_forbids_additional_properties(self) -> None: + # Requirement: variant-local fields are enforced in the published schema. + schema = WorkflowConfig.model_json_schema() + mapping = schema["properties"]["agents"]["items"]["discriminator"]["mapping"] + + for ref in mapping.values(): + variant_schema = schema["$defs"][ref.removeprefix("#/$defs/")] + assert variant_schema["additionalProperties"] is False + + def test_terminate_def_has_no_routes(self) -> None: + # Requirement: terminate steps cannot route; the schema must not offer it. + schema = WorkflowConfig.model_json_schema() + terminate_schema = schema["$defs"]["TerminateStepDef"] + + assert "routes" not in terminate_schema["properties"] + + def test_for_each_agent_uses_same_union(self) -> None: + # Requirement: ForEachDef.agent parses through the identical StepDef union. + schema = WorkflowConfig.model_json_schema() + for_each_agent = schema["$defs"]["ForEachDef"]["properties"]["agent"] + + assert len(for_each_agent["oneOf"]) == 9 + assert for_each_agent["discriminator"]["propertyName"] == "type" + + +class TestForEachAgentParsing: + """``ForEachDef.agent`` accepts the executable inline variants.""" + + @pytest.mark.parametrize( + ("agent_payload", "expected_class"), + [ + ({"name": "w", "prompt": "Do {{ item }}"}, AgentDef), + ({"name": "w", "type": "workflow", "workflow": "./sub.yaml"}, WorkflowStepDef), + ({"name": "w", "type": "set", "value": "{{ item }}"}, SetStepDef), + ( + {"name": "w", "type": "mcp", "server": "docs", "tool": "search"}, + MCPStepDef, + ), + ], + ids=["agent", "workflow", "set", "mcp"], + ) + def test_inline_variants_parse(self, agent_payload: dict, expected_class: type) -> None: + # Requirement: for-each inline agents keep working for every supported kind. + group = ForEachDef.model_validate( + { + "name": "loop", + "type": "for_each", + "source": "finder.output.items", + "as": "item", + "agent": agent_payload, + } + ) + + assert type(group.agent) is expected_class + + def test_inline_agent_without_type_is_llm(self) -> None: + # Requirement: an inline agent omitting ``type`` is a provider-backed agent. + group = ForEachDef.model_validate( + { + "name": "loop", + "type": "for_each", + "source": "finder.output.items", + "as": "item", + "agent": {"name": "w", "prompt": "Do {{ item }}"}, + } + ) + + assert isinstance(group.agent, AgentDef) + assert group.agent.type == "agent" + + +class TestLegacyTypeCanonicalization: + """Omitted and explicit-null ``type`` remain valid shorthands for LLM agents.""" + + def test_yaml_without_type_loads_as_agent(self) -> None: + # Requirement: pre-#517 YAML with no ``type`` keeps loading, canonicalized. + config = load_config_string( + """ +workflow: + name: legacy + entry_point: write +agents: + - name: write + prompt: "Write" +""" + ) + + assert type(config.agents[0]) is AgentDef + assert config.agents[0].type == "agent" + + def test_yaml_with_null_type_loads_as_agent(self) -> None: + # Requirement: an explicit ``type: null`` stays compatible, canonicalized. + config = load_config_string( + """ +workflow: + name: legacy + entry_point: write +agents: + - name: write + type: null + prompt: "Write" +""" + ) + + assert type(config.agents[0]) is AgentDef + assert config.agents[0].type == "agent" + + def test_direct_constructor_accepts_none_type(self) -> None: + # Requirement: programmatic ``AgentDef(type=None)`` keeps working. + agent = AgentDef(name="write", type=None, prompt="Write") # type: ignore[arg-type] + + assert agent.type == "agent" + + def test_step_adapter_normalizes_null_type(self) -> None: + # Requirement: the shared StepDef boundary applies the same normalization. + step = TypeAdapter(StepDef).validate_python({"name": "write", "type": None}) + + assert type(step) is AgentDef + assert step.type == "agent" + + def test_unknown_type_is_union_tag_invalid(self) -> None: + # Requirement: an unrecognized discriminator is a schema error, not a guess. + with pytest.raises(PydanticValidationError) as exc_info: + WorkflowConfig.model_validate(_workflow_payload([{"name": "x", "type": "bogus"}])) + + assert any(e["type"] == "union_tag_invalid" for e in exc_info.value.errors()) + + +class TestSerializationRoundTrip: + """``model_dump(exclude_none=True)`` of each variant revalidates identically.""" + + @pytest.mark.parametrize( + "step", + [ + AgentDef(name="write", prompt="Write"), + HumanGateStepDef( + name="gate", + prompt="Pick", + options=[{"label": "Yes", "value": "yes", "route": "$end"}], + ), + QuestionsStepDef(name="ask", questions=[{"id": "q1", "text": "Why?"}]), + ScriptStepDef(name="run", command="echo hi"), + MCPStepDef(name="call", server="docs", tool="search"), + WaitStepDef(name="pause", duration="1s"), + SetStepDef(name="bind", value="1"), + TerminateStepDef(name="stop", status="success", reason="done"), + WorkflowStepDef(name="child", workflow="./sub.yaml"), + ], + ids=lambda step: step.type, + ) + def test_dump_revalidates_through_workflow_config(self, step: StepDef) -> None: + # Requirement: serialized steps round-trip through the union boundary. + dumped = step.model_dump(exclude_none=True) + config = WorkflowConfig.model_validate( + { + "workflow": {"name": "rt", "entry_point": step.name}, + "agents": [dumped], + } + ) + + assert type(config.agents[0]) is type(step) + assert config.agents[0].type == step.type + + def test_llm_dump_carries_canonical_type(self) -> None: + # Requirement: the canonical discriminator survives serialization. + dumped = AgentDef(name="write", prompt="Write").model_dump(exclude_none=True) + + assert dumped["type"] == "agent" + + +class TestPublishedSchemaAcceptsLoaderForms: + """The generated JSON Schema must accept exactly what the loader accepts. + + ``_normalize_step_type`` maps an omitted or explicit-null ``type`` to + ``agent`` at runtime, but ``model_json_schema()`` output reaches editors and + external linters without that normalization: with every variant's ``type`` + defaulted, an untagged agent mapping matched several ``oneOf`` branches and + was rejected, and an explicit ``type: null`` matched none. The schema is + customized so non-LLM branches require their explicit discriminator while + the LLM branch keeps accepting all three forms. + """ + + @pytest.mark.parametrize("type_form", ["omitted", "null", "agent"]) + def test_llm_agent_type_forms_validate(self, type_form: str) -> None: + # Requirement: all three accepted LLM ``type`` forms pass the published schema. + agent: dict[str, object] = {"name": "a", "prompt": "Do the task"} + if type_form != "omitted": + agent["type"] = None if type_form == "null" else "agent" + payload = _workflow_payload([agent]) + + jsonschema.validate(payload, WorkflowConfig.model_json_schema()) + # Runtime parity: the loader must keep accepting the same payload. + config = WorkflowConfig.model_validate(payload) + assert type(config.agents[0]) is AgentDef + + @pytest.mark.parametrize("type_form", ["omitted", "null", "agent"]) + def test_inline_for_each_agent_type_forms_validate(self, type_form: str) -> None: + # Requirement: inline for-each agents get the same three-form compatibility. + agent: dict[str, object] = {"name": "w", "prompt": "Do {{ item }}"} + if type_form != "omitted": + agent["type"] = None if type_form == "null" else "agent" + payload = { + "workflow": {"name": "typed", "entry_point": "loop"}, + "agents": [], + "for_each": [ + { + "name": "loop", + "type": "for_each", + "source": "workflow.input.items", + "as": "item", + "agent": agent, + } + ], + } + + jsonschema.validate(payload, WorkflowConfig.model_json_schema()) + config = WorkflowConfig.model_validate(payload) + assert type(config.for_each[0].agent) is AgentDef + + @pytest.mark.parametrize( + "agent_payload", + [ + {"name": "s", "type": "script", "command": "echo hi"}, + {"name": "w", "type": "wait", "duration": "5s"}, + {"name": "b", "type": "set", "value": "1"}, + ], + ids=["script", "wait", "set"], + ) + def test_tagged_non_llm_variants_validate(self, agent_payload: dict) -> None: + # Requirement: requiring the discriminator does not break tagged workflows. + payload = _workflow_payload([agent_payload]) + + jsonschema.validate(payload, WorkflowConfig.model_json_schema()) + + def test_untagged_mapping_with_foreign_fields_rejected(self) -> None: + # Requirement: schema and loader agree that an untagged script-looking + # mapping is not a script step — it routes to the LLM branch and fails + # there on the foreign field. + payload = _workflow_payload([{"name": "s", "command": "echo hi"}]) + + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(payload, WorkflowConfig.model_json_schema()) + with pytest.raises(PydanticValidationError): + WorkflowConfig.model_validate(payload) + + def test_non_llm_variant_defs_require_type(self) -> None: + # Requirement: the oneOf ambiguity fix is a required discriminator on + # every non-LLM branch, not a dropped default anywhere else. + schema = WorkflowConfig.model_json_schema() + + for tag, ref in schema["properties"]["agents"]["items"]["discriminator"]["mapping"].items(): + variant_schema = schema["$defs"][ref.removeprefix("#/$defs/")] + if tag == "agent": + assert "type" not in variant_schema["required"] + else: + assert "type" in variant_schema["required"] + + +class TestInstanceRevalidation: + """Invalid ``model_copy`` results must not slip past config validation. + + Pydantic skips field and before-model validators when an existing instance + is revalidated as a nested value (e.g. inside ``WorkflowConfig.agents``); + the per-variant after-model validators are what keep a mutated copy from + pushing invalid execution config through that boundary. + """ + + @pytest.mark.parametrize( + ("step", "update", "match"), + [ + ( + ScriptStepDef(name="s", command="echo ok"), + {"command": ""}, + "command", + ), + (WaitStepDef(name="w", duration="5s"), {"duration": "25h"}, "duration"), + ( + TerminateStepDef(name="t", status="success", reason="done"), + {"reason": " "}, + "reason", + ), + ( + TerminateStepDef(name="t", status="success", reason="done"), + {"reason": None}, + "reason", + ), + ( + TerminateStepDef(name="t", status="success", reason="done"), + {"status": None}, + "status", + ), + ( + TerminateStepDef(name="t", status="success", reason="done"), + {"status": "bogus"}, + "status", + ), + ( + WorkflowStepDef(name="wf", workflow="./sub.yaml"), + {"workflow": ""}, + "workflow", + ), + (MCPStepDef(name="m", server="srv", tool="search"), {"server": ""}, "server"), + (MCPStepDef(name="m", server="srv", tool="search"), {"tool": ""}, "tool"), + ], + ids=[ + "script-empty-command", + "wait-over-cap-duration", + "terminate-blank-reason", + "terminate-none-reason", + "terminate-none-status", + "terminate-unknown-status", + "workflow-empty-path", + "mcp-empty-server", + "mcp-empty-tool", + ], + ) + def test_invalid_copy_rejected_by_workflow_config( + self, step: StepDef, update: dict, match: str + ) -> None: + # Requirement: a mutated copy fails WorkflowConfig validation exactly as + # the equivalent mapping input does. + copied = step.model_copy(update=update) + + with pytest.raises(PydanticValidationError, match=match): + WorkflowConfig.model_validate( + {"workflow": {"name": "t", "entry_point": step.name}, "agents": [copied]} + ) + + @pytest.mark.parametrize( + ("copied", "match"), + [ + ( + ScriptStepDef(name="s", command="echo ok").model_copy(update={"command": ""}), + "command", + ), + ( + TerminateStepDef(name="t", status="success", reason="done").model_copy( + update={"status": None} + ), + "status", + ), + ], + ids=["script-empty-command", "terminate-none-status"], + ) + def test_invalid_copy_rejected_by_for_each_def(self, copied: StepDef, match: str) -> None: + # Requirement: the same boundary holds for inline for-each agents. + with pytest.raises(PydanticValidationError, match=match): + ForEachDef.model_validate( + { + "name": "loop", + "type": "for_each", + "source": "workflow.input.items", + "as": "item", + "agent": copied, + } + ) + + def test_valid_copy_accepted(self) -> None: + # Requirement: revalidation re-checks invariants without rejecting + # legitimate copies — programmatic workflow building keeps working. + copied = ScriptStepDef(name="s", command="echo ok").model_copy( + update={"command": "echo ok2"} + ) + + config = WorkflowConfig.model_validate( + {"workflow": {"name": "t", "entry_point": "s"}, "agents": [copied]} + ) + + assert type(config.agents[0]) is ScriptStepDef diff --git a/tests/test_config/test_validator.py b/tests/test_config/test_validator.py index 586e78ff..271fe96b 100644 --- a/tests/test_config/test_validator.py +++ b/tests/test_config/test_validator.py @@ -11,11 +11,15 @@ ContextConfig, ForEachDef, GateOption, + HumanGateStepDef, InputDef, ParallelGroup, RouteDef, + ScriptStepDef, + TerminateStepDef, WorkflowConfig, WorkflowDef, + WorkflowStepDef, ) from conductor.config.validator import ( INPUT_REF_PATTERN, @@ -156,9 +160,8 @@ def test_valid_human_gate(self) -> None: config = WorkflowConfig( workflow=WorkflowDef(name="test", entry_point="gate1"), agents=[ - AgentDef( + HumanGateStepDef( name="gate1", - type="human_gate", prompt="Choose:", options=[ GateOption(label="Yes", value="yes", route="agent2"), @@ -181,9 +184,8 @@ def test_gate_option_invalid_route(self) -> None: config = WorkflowConfig( workflow=WorkflowDef(name="test", entry_point="gate1"), agents=[ - AgentDef( + HumanGateStepDef( name="gate1", - type="human_gate", prompt="Choose:", options=[ GateOption(label="Yes", value="yes", route="nonexistent"), @@ -613,9 +615,8 @@ def test_human_gate_conditional_paths(self) -> None: config = WorkflowConfig( workflow=WorkflowDef(name="test", entry_point="gate"), agents=[ - AgentDef( + HumanGateStepDef( name="gate", - type="human_gate", prompt="Choose:", options=[ GateOption(label="Approve", value="yes", route="agent_a"), @@ -975,9 +976,8 @@ def test_stale_agent_ref_in_script_args_errors(self) -> None: config = WorkflowConfig( workflow=WorkflowDef(name="t", entry_point="step"), agents=[ - AgentDef( + ScriptStepDef( name="step", - type="script", command="echo", args=["{{ ghost.output.value }}"], routes=[RouteDef(to="$end")], @@ -991,9 +991,8 @@ def test_stale_agent_ref_in_command_errors(self) -> None: config = WorkflowConfig( workflow=WorkflowDef(name="t", entry_point="step"), agents=[ - AgentDef( + ScriptStepDef( name="step", - type="script", command="run-{{ ghost.output }}", routes=[RouteDef(to="$end")], ), @@ -1006,9 +1005,8 @@ def test_stale_agent_ref_in_working_dir_errors(self) -> None: config = WorkflowConfig( workflow=WorkflowDef(name="t", entry_point="step"), agents=[ - AgentDef( + ScriptStepDef( name="step", - type="script", command="echo", working_dir="/tmp/{{ ghost.output }}", routes=[RouteDef(to="$end")], @@ -1217,9 +1215,8 @@ def test_script_agents_skipped_in_explicit_mode(self) -> None: input={"topic": InputDef(type="string")}, ), agents=[ - AgentDef( + ScriptStepDef( name="step", - type="script", command="echo", args=["{{ workflow.input.topic }}"], routes=[RouteDef(to="$end")], @@ -1246,9 +1243,8 @@ def test_script_agent_undeclared_output_ref_warns(self) -> None: ), agents=[ _agent_with_prompt("producer", "make it", routes=[RouteDef(to="consumer")]), - AgentDef( + ScriptStepDef( name="consumer", - type="script", command="echo", args=["{{ producer.output.text }}"], # Notably absent: input=["producer.output"] @@ -1274,9 +1270,8 @@ def test_script_agent_undeclared_workflow_input_no_warning(self) -> None: input={"topic": InputDef(type="string")}, ), agents=[ - AgentDef( + ScriptStepDef( name="step", - type="script", command="echo", args=["{{ workflow.input.topic }}"], routes=[RouteDef(to="$end")], @@ -1304,9 +1299,8 @@ def test_subworkflow_input_mapping_undeclared_output_ref_warns(self) -> None: ), agents=[ _agent_with_prompt("producer", "make it", routes=[RouteDef(to="child")]), - AgentDef( + WorkflowStepDef( name="child", - type="workflow", workflow="./child.yaml", input_mapping={"data": "{{ producer.output.value }}"}, # Notably absent: input=["producer.output"] @@ -1327,9 +1321,8 @@ def test_subworkflow_undeclared_workflow_input_no_warning(self) -> None: input={"topic": InputDef(type="string")}, ), agents=[ - AgentDef( + WorkflowStepDef( name="child", - type="workflow", workflow="./child.yaml", input_mapping={"data": "{{ workflow.input.topic }}"}, routes=[RouteDef(to="$end")], @@ -1359,9 +1352,8 @@ def test_human_gate_prompt_explicit_mode_no_warning(self) -> None: ), agents=[ _agent_with_prompt("producer", "make it", routes=[RouteDef(to="gate")]), - AgentDef( + HumanGateStepDef( name="gate", - type="human_gate", prompt=( "Topic: {{ workflow.input.topic }}. " "Producer said: {{ producer.output.text }}. Continue?" @@ -1643,9 +1635,8 @@ def test_field_precision_in_sub_workflow_input_mapping_warns(self) -> None: ), agents=[ _agent_with_prompt("producer", "make it", routes=[RouteDef(to="child")]), - AgentDef( + WorkflowStepDef( name="child", - type="workflow", workflow="./child.yaml", input_mapping={"data": "{{ producer.output.bar }}"}, input=["producer.output.foo"], @@ -2025,9 +2016,8 @@ def _make_config(self, workflow_ref: str) -> WorkflowConfig: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow=workflow_ref, routes=[RouteDef(to="$end")], ), @@ -2232,9 +2222,8 @@ def test_for_each_workflow_agent_ref_validated(self, tmp_path: Path) -> None: type="for_each", source="loader.output.items", **{"as": "item"}, - agent=AgentDef( + agent=WorkflowStepDef( name="worker", - type="workflow", workflow="missing@team-a#v1.0.0", routes=[RouteDef(to="$end")], ), @@ -2331,9 +2320,8 @@ def test_circular_subworkflow_ref_detected(self, tmp_path: Path) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="./a.yaml", routes=[RouteDef(to="$end")], ), @@ -2427,9 +2415,8 @@ def test_circular_subworkflow_via_case_variant_path(self, tmp_path: Path) -> Non limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="A.yaml", # same file as a.yaml on case-insensitive FS routes=[RouteDef(to="$end")], ), @@ -2513,9 +2500,8 @@ def test_validation_depth_limit_emits_warning(self, tmp_path: Path) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="./a0.yaml", routes=[RouteDef(to="$end")], ), @@ -2769,9 +2755,8 @@ def test_routes_can_target_terminate(self) -> None: prompt="check", routes=[RouteDef(to="abort"), RouteDef(to="$end")], ), - AgentDef( + TerminateStepDef( name="abort", - type="terminate", status="failed", reason="nope", ), @@ -2784,9 +2769,8 @@ def test_terminate_can_be_entry_point(self) -> None: config = WorkflowConfig( workflow=WorkflowDef(name="t", entry_point="goodbye"), agents=[ - AgentDef( + TerminateStepDef( name="goodbye", - type="terminate", status="success", reason="nothing to do", ), @@ -2802,9 +2786,8 @@ def test_terminate_rejected_inside_parallel_group(self) -> None: workflow=WorkflowDef(name="t", entry_point="group"), agents=[ AgentDef(name="a", model="gpt-4", prompt="x"), - AgentDef( + TerminateStepDef( name="b", - type="terminate", status="failed", reason="nope", ), @@ -2825,9 +2808,8 @@ def test_terminate_rejected_as_for_each_inline_agent(self) -> None: "type": "for_each", "source": "workflow.input.items", "as": "item", - "agent": AgentDef( + "agent": TerminateStepDef( name="bail", - type="terminate", status="failed", reason="r", ), @@ -2869,9 +2851,8 @@ def test_output_template_skips_workflow_output_coverage(self) -> None: prompt="x", routes=[RouteDef(to="bail"), RouteDef(to="writer")], ), - AgentDef( + TerminateStepDef( name="bail", - type="terminate", status="failed", reason="nope", output_template={"result": "aborted"}, @@ -2892,6 +2873,43 @@ def test_output_template_skips_workflow_output_coverage(self) -> None: f"unexpected coverage warning: {warnings!r}" ) + def test_empty_output_template_still_counts_as_override(self) -> None: + """An explicit ``output_template: {}`` is an override, not a fallback. + + The override check must distinguish ``None`` from an empty mapping: + the engine renders ``{}`` as the final output for that path, so the + workflow-level ``output:`` is never consulted and no coverage warning + may fire for steps only reachable on other paths. + """ + config = WorkflowConfig( + workflow=WorkflowDef(name="t", entry_point="check"), + agents=[ + AgentDef( + name="check", + model="gpt-4", + prompt="x", + routes=[RouteDef(to="bail"), RouteDef(to="writer")], + ), + TerminateStepDef( + name="bail", + status="failed", + reason="nope", + output_template={}, + ), + AgentDef( + name="writer", + model="gpt-4", + prompt="x", + routes=[RouteDef(to="$end")], + ), + ], + output={"result": "{{ writer.output.result }}"}, + ) + warnings = validate_workflow_config(config) + assert not any("writer" in w and "not run on all paths" in w for w in warnings), ( + f"unexpected coverage warning: {warnings!r}" + ) + def test_output_template_with_fallback_still_warns(self) -> None: """A terminate path WITHOUT `output_template` falls back to workflow.output — it should be analyzed for coverage like any normal terminal path.""" @@ -2904,9 +2922,8 @@ def test_output_template_with_fallback_still_warns(self) -> None: prompt="x", routes=[RouteDef(to="bail"), RouteDef(to="writer")], ), - AgentDef( + TerminateStepDef( name="bail", - type="terminate", status="failed", reason="nope", ), @@ -2935,9 +2952,8 @@ def test_reason_template_validated(self) -> None: prompt="x", routes=[RouteDef(to="bail"), RouteDef(to="$end")], ), - AgentDef( + TerminateStepDef( name="bail", - type="terminate", status="failed", reason="{{ ghost.output.value }}", ), @@ -2957,9 +2973,8 @@ def test_output_template_template_validated(self) -> None: prompt="x", routes=[RouteDef(to="bail"), RouteDef(to="$end")], ), - AgentDef( + TerminateStepDef( name="bail", - type="terminate", status="failed", reason="halt", output_template={"r": "{{ ghost.output.value }}"}, diff --git a/tests/test_config/test_validator_capabilities.py b/tests/test_config/test_validator_capabilities.py index 4794c4bd..f1069cfb 100644 --- a/tests/test_config/test_validator_capabilities.py +++ b/tests/test_config/test_validator_capabilities.py @@ -9,11 +9,15 @@ from conductor.config.schema import ( AgentDef, ForEachDef, + HumanGateStepDef, MCPServerDef, OutputField, ParallelGroup, ReasoningConfig, RuntimeConfig, + ScriptStepDef, + SetStepDef, + StepDef, WorkflowConfig, WorkflowDef, ) @@ -46,7 +50,7 @@ def _caps(**overrides: object) -> ProviderCapabilities: def _build_workflow( *, - agents: list[AgentDef], + agents: list[StepDef], parallel: list[ParallelGroup] | None = None, for_each: list[ForEachDef] | None = None, mcp_servers: dict[str, MCPServerDef] | None = None, @@ -80,7 +84,7 @@ def _build_workflow( def _for_each_workflow( *, - inline: AgentDef, + inline: StepDef, tools: list[str] | None = None, mcp_servers: dict[str, MCPServerDef] | None = None, skills: list[str] | None = None, @@ -477,7 +481,7 @@ class TestForEachInlineToolsCrossCheck: def _for_each_config( self, *, - inline: AgentDef, + inline: StepDef, tools: list[str] | None = None, ) -> WorkflowConfig: # The entry agent opts out with ``tools: []`` so only the inline agent @@ -499,7 +503,7 @@ def _for_each_config( def _for_each_mcp_config( self, *, - inline: AgentDef, + inline: StepDef, mcp_servers: dict[str, MCPServerDef] | None = None, ) -> WorkflowConfig: # The entry agent OMITS ``tools:`` (and there is no workflow-level @@ -858,9 +862,8 @@ def test_script_agent_skipped_even_with_unsupported_provider(self, patch_caps: A patch_caps({"copilot": _caps(mcp_tools=False, concurrent_safe=False)}) config = _build_workflow( agents=[ - AgentDef( + ScriptStepDef( name="a", - type="script", command="echo hi", ) ], @@ -875,9 +878,8 @@ def test_human_gate_skipped(self, patch_caps: Any) -> None: patch_caps({"copilot": _caps(reasoning_effort=None)}) config = _build_workflow( agents=[ - AgentDef( + HumanGateStepDef( name="gate", - type="human_gate", prompt="Approve?", options=[ GateOption(label="OK", value="ok", route="$end"), @@ -1388,7 +1390,7 @@ class TestForEachInlineWorkflowLevelInheritance: def _inheritance_config( self, *, - inline: AgentDef, + inline: StepDef, runtime: RuntimeConfig, ) -> WorkflowConfig: # ``entry`` overrides to a capable provider so, WITHOUT the fix, the @@ -1477,17 +1479,21 @@ def test_inline_inherits_capable_default_passes(self, patch_caps: Any) -> None: ) validate_workflow_config(config) # no raise - def test_inline_non_llm_human_gate_skipped(self, patch_caps: Any) -> None: - """A non-LLM (human_gate) inline agent must be SKIPPED by the - ``_is_llm_agent`` filter — even on an incapable default provider that - declares ``mcp_servers``, ``max_session_seconds``, AND + def test_inline_non_llm_set_step_skipped(self, patch_caps: Any) -> None: + """A non-LLM inline step must be SKIPPED by the ``_is_llm_agent`` + filter — even on an incapable default provider that declares + ``mcp_servers``, ``max_session_seconds``, AND ``default_reasoning_effort`` that an LLM inline agent WOULD inherit and fail on. Guards the inline ``_is_llm_agent`` guard (feeding ``all_llm_agents`` and the for_each per-agent loop) against a future refactor that drops it and spuriously fail-validates the workflow. - """ - from conductor.config.schema import GateOption + Uses a ``set`` step as the inline agent: under the step-model + architecture a human gate is no longer permitted as a for_each inline + agent at all (concurrent iterations would compete for one interactive + gate channel), so the non-LLM skip is exercised through the remaining + non-LLM inline-able variant. + """ patch_caps( { # Default provider is incapable on all three inherited axes; @@ -1497,15 +1503,7 @@ def test_inline_non_llm_human_gate_skipped(self, patch_caps: Any) -> None: } ) config = self._inheritance_config( - inline=AgentDef( - name="gate", - type="human_gate", - prompt="Approve {{ item }}?", - options=[ - GateOption(label="OK", value="ok", route="$end"), - GateOption(label="No", value="no", route="$end"), - ], - ), + inline=SetStepDef(name="derive", value="{{ item }}"), runtime=RuntimeConfig( provider="copilot", default_reasoning_effort="high", @@ -1513,7 +1511,7 @@ def test_inline_non_llm_human_gate_skipped(self, patch_caps: Any) -> None: mcp_servers={"docs": MCPServerDef(command="docs-server")}, ), ) - validate_workflow_config(config) # must not raise — human_gate is skipped + validate_workflow_config(config) # must not raise — set step is skipped class TestWorkingDirCrossCheck: @@ -1634,7 +1632,7 @@ def test_script_agent_working_dir_skipped(self, patch_caps: Any) -> None: capability gate must not fire for them even with working_dir set.""" patch_caps({"copilot": _caps(working_dir=False)}) config = _build_workflow( - agents=[AgentDef(name="s", type="script", command="ls", working_dir="/tmp")], + agents=[ScriptStepDef(name="s", command="ls", working_dir="/tmp")], ) validate_workflow_config(config) # no raise @@ -1656,7 +1654,7 @@ class TestAcaRealCapabilitiesCrossCheck: def _aca_workflow( self, *, - agents: list[AgentDef], + agents: list[StepDef], mcp_servers: dict[str, MCPServerDef] | None = None, ) -> WorkflowConfig: from conductor.config.schema import ProviderSettings @@ -1770,7 +1768,7 @@ class TestClaudeAgentSdkRealCapabilitiesCrossCheck: def _sdk_workflow( self, *, - agents: list[AgentDef], + agents: list[StepDef], mcp_servers: dict[str, MCPServerDef] | None = None, working_dir: str | None = None, ) -> WorkflowConfig: @@ -1953,7 +1951,7 @@ class TestAcaSkillsRealCapabilities: literal), so it is set via ``runtime.provider``. """ - def _aca_workflow(self, *, agents: list[AgentDef], skills: list[str] | None = None): + def _aca_workflow(self, *, agents: list[StepDef], skills: list[str] | None = None): from conductor.config.schema import ProviderSettings runtime_kwargs: dict[str, Any] = { diff --git a/tests/test_config/test_wait_schema.py b/tests/test_config/test_wait_schema.py index d5d7a173..51d5149d 100644 --- a/tests/test_config/test_wait_schema.py +++ b/tests/test_config/test_wait_schema.py @@ -1,17 +1,20 @@ """Tests for ``type: wait`` schema validation. Covers: -- Valid wait agent definitions (literal and templated durations). +- Valid wait step definitions (literal and templated durations). - Required ``duration`` field. -- Forbidden fields on wait agents. +- Fields owned by other variants rejected on wait (extra_forbidden). - Duration bounds (> 0 and <= 24h). - Boolean duration rejection (pre-coercion). - Reject wait inside parallel groups and as for-each inline agents. -- Reject ``duration`` and ``reason`` on non-wait agents. +- Reject ``duration`` and ``reason`` on non-wait variants. +- working_dir allowed on LLM agents and script steps, rejected elsewhere. """ from __future__ import annotations +from collections.abc import Callable + import pytest from pydantic import ValidationError as PydanticValidationError @@ -19,19 +22,35 @@ AgentDef, ForEachDef, GateOption, + HumanGateStepDef, OutputField, ParallelGroup, RouteDef, RuntimeConfig, + ScriptStepDef, + SetStepDef, + StepDef, + TerminateStepDef, + WaitStepDef, WorkflowConfig, WorkflowDef, + WorkflowStepDef, ) from conductor.config.validator import validate_workflow_config from conductor.exceptions import ConfigurationError +def _assert_extra_forbidden( + exc_info: pytest.ExceptionInfo[PydanticValidationError], field: str +) -> None: + """Assert a variant-owned-by-sibling field failed with extra_forbidden on that field.""" + assert any( + e["loc"] == (field,) and e["type"] == "extra_forbidden" for e in exc_info.value.errors() + ) + + def _make_workflow( - *agents: AgentDef, + *agents: StepDef, parallel: list[ParallelGroup] | None = None, for_each: list[ForEachDef] | None = None, ) -> WorkflowConfig: @@ -54,67 +73,73 @@ class TestValidWait: """Wait agents accept duration as int/float/string or Jinja template.""" def test_int_seconds(self) -> None: - a = AgentDef(name="w", type="wait", duration=60) + a = WaitStepDef(name="w", duration=60) assert a.type == "wait" assert a.duration == 60 def test_float_seconds(self) -> None: - a = AgentDef(name="w", type="wait", duration=1.5) + a = WaitStepDef(name="w", duration=1.5) assert a.duration == 1.5 def test_string_seconds(self) -> None: - a = AgentDef(name="w", type="wait", duration="60s") + a = WaitStepDef(name="w", duration="60s") assert a.duration == "60s" def test_string_minutes(self) -> None: - AgentDef(name="w", type="wait", duration="5m") + WaitStepDef(name="w", duration="5m") def test_string_milliseconds(self) -> None: - AgentDef(name="w", type="wait", duration="500ms") + WaitStepDef(name="w", duration="500ms") def test_string_hours(self) -> None: - AgentDef(name="w", type="wait", duration="1h") + WaitStepDef(name="w", duration="1h") def test_24h_cap_inclusive(self) -> None: # Exactly 24h is allowed. - AgentDef(name="w", type="wait", duration="24h") + WaitStepDef(name="w", duration="24h") def test_templated_duration_deferred(self) -> None: # Templates are not parsed at schema time. - a = AgentDef(name="w", type="wait", duration="{{ workflow.input.x }}s") + a = WaitStepDef(name="w", duration="{{ workflow.input.x }}s") assert a.duration == "{{ workflow.input.x }}s" def test_templated_garbage_deferred(self) -> None: # Even nonsense after the template is OK at schema time. - AgentDef(name="w", type="wait", duration="{{ x }}-not-a-duration") + WaitStepDef(name="w", duration="{{ x }}-not-a-duration") def test_optional_reason(self) -> None: - a = AgentDef(name="w", type="wait", duration="1s", reason="hello") + a = WaitStepDef(name="w", duration="1s", reason="hello") assert a.reason == "hello" class TestWaitRequiresDuration: def test_missing_duration(self) -> None: - with pytest.raises(PydanticValidationError, match="require 'duration'"): - AgentDef(name="w", type="wait") + # duration is a required field on the wait variant. + with pytest.raises(PydanticValidationError) as exc_info: + WaitStepDef(name="w") + assert any( + e["loc"] == ("duration",) and e["type"] == "missing" for e in exc_info.value.errors() + ) class TestWaitDurationBounds: def test_zero_rejected(self) -> None: + # Variant-owned invariant: duration must be positive. with pytest.raises(PydanticValidationError, match="must be > 0"): - AgentDef(name="w", type="wait", duration=0) + WaitStepDef(name="w", duration=0) def test_negative_rejected(self) -> None: with pytest.raises(PydanticValidationError): - AgentDef(name="w", type="wait", duration=-1) + WaitStepDef(name="w", duration=-1) def test_over_24h_rejected(self) -> None: + # Variant-owned invariant: duration is capped at 24h. with pytest.raises(PydanticValidationError, match="24h cap"): - AgentDef(name="w", type="wait", duration="25h") + WaitStepDef(name="w", duration="25h") def test_just_over_24h_rejected(self) -> None: with pytest.raises(PydanticValidationError, match="24h cap"): - AgentDef(name="w", type="wait", duration=86401) + WaitStepDef(name="w", duration=86401) class TestWaitDurationBool: @@ -122,98 +147,107 @@ def test_true_rejected(self) -> None: # Booleans must be rejected pre-coercion. Pydantic v2 would # otherwise accept True as int 1. with pytest.raises(PydanticValidationError, match="boolean"): - AgentDef(name="w", type="wait", duration=True) + WaitStepDef(name="w", duration=True) def test_false_rejected(self) -> None: with pytest.raises(PydanticValidationError, match="boolean"): - AgentDef(name="w", type="wait", duration=False) + WaitStepDef(name="w", duration=False) class TestWaitForbiddenFields: - """Fields that don't make sense for wait must be rejected.""" + """Fields owned by other step variants must be rejected on wait (extra_forbidden).""" @pytest.mark.parametrize( - "field,value,match", + "field,value", [ - ("prompt", "x", "'prompt'"), - ("provider", "copilot", "'provider'"), - ("model", "claude-haiku-4.5", "'model'"), - ("system_prompt", "x", "'system_prompt'"), - ("command", "ls", "'command'"), - ("working_dir", "/tmp", "'working_dir'"), - ("timeout", 5, "'timeout'"), - ("workflow", "./sub.yaml", "'workflow'"), - ("max_session_seconds", 30.0, "'max_session_seconds'"), - ("max_agent_iterations", 5, "'max_agent_iterations'"), - ("timeout_seconds", 10.0, "'timeout_seconds'"), + ("prompt", "x"), + ("provider", "copilot"), + ("model", "claude-haiku-4.5"), + ("system_prompt", "x"), + ("command", "ls"), + ("working_dir", "/tmp"), + ("timeout", 5), + ("workflow", "./sub.yaml"), + ("max_session_seconds", 30.0), + ("max_agent_iterations", 5), + ("timeout_seconds", 10.0), ], ) - def test_forbidden(self, field: str, value: object, match: str) -> None: - kwargs = {"name": "w", "type": "wait", "duration": "1s", field: value} - with pytest.raises(PydanticValidationError, match=match): - AgentDef(**kwargs) # type: ignore[arg-type] + def test_forbidden(self, field: str, value: object) -> None: + # extra="forbid": a field belonging to another variant fails on that field. + with pytest.raises(PydanticValidationError) as exc_info: + WaitStepDef.model_validate({"name": "w", "duration": "1s", field: value}) + _assert_extra_forbidden(exc_info, field) def test_tools_list_rejected(self) -> None: - with pytest.raises(PydanticValidationError, match="'tools'"): - AgentDef(name="w", type="wait", duration="1s", tools=["foo"]) + with pytest.raises(PydanticValidationError) as exc_info: + WaitStepDef.model_validate({"name": "w", "duration": "1s", "tools": ["foo"]}) + _assert_extra_forbidden(exc_info, "tools") def test_options_rejected(self) -> None: - with pytest.raises(PydanticValidationError, match="'options'"): - AgentDef( - name="w", - type="wait", - duration="1s", - options=[GateOption(label="x", value="x", route="$end")], + with pytest.raises(PydanticValidationError) as exc_info: + WaitStepDef.model_validate( + { + "name": "w", + "duration": "1s", + "options": [GateOption(label="x", value="x", route="$end")], + } ) + _assert_extra_forbidden(exc_info, "options") def test_args_rejected(self) -> None: - with pytest.raises(PydanticValidationError, match="'args'"): - AgentDef(name="w", type="wait", duration="1s", args=["x"]) + with pytest.raises(PydanticValidationError) as exc_info: + WaitStepDef.model_validate({"name": "w", "duration": "1s", "args": ["x"]}) + _assert_extra_forbidden(exc_info, "args") def test_env_rejected(self) -> None: - with pytest.raises(PydanticValidationError, match="'env'"): - AgentDef(name="w", type="wait", duration="1s", env={"FOO": "bar"}) + with pytest.raises(PydanticValidationError) as exc_info: + WaitStepDef.model_validate({"name": "w", "duration": "1s", "env": {"FOO": "bar"}}) + _assert_extra_forbidden(exc_info, "env") def test_input_mapping_rejected(self) -> None: - with pytest.raises(PydanticValidationError, match="'input_mapping'"): - AgentDef(name="w", type="wait", duration="1s", input_mapping={"x": "y"}) + with pytest.raises(PydanticValidationError) as exc_info: + WaitStepDef.model_validate({"name": "w", "duration": "1s", "input_mapping": {"x": "y"}}) + _assert_extra_forbidden(exc_info, "input_mapping") def test_max_depth_rejected(self) -> None: - with pytest.raises(PydanticValidationError, match="'max_depth'"): - AgentDef(name="w", type="wait", duration="1s", max_depth=2) + with pytest.raises(PydanticValidationError) as exc_info: + WaitStepDef.model_validate({"name": "w", "duration": "1s", "max_depth": 2}) + _assert_extra_forbidden(exc_info, "max_depth") def test_output_rejected(self) -> None: - with pytest.raises(PydanticValidationError, match="'output'"): - AgentDef( - name="w", - type="wait", - duration="1s", - output={"x": {"type": "string"}}, + with pytest.raises(PydanticValidationError) as exc_info: + WaitStepDef.model_validate( + {"name": "w", "duration": "1s", "output": {"x": {"type": "string"}}} ) + _assert_extra_forbidden(exc_info, "output") class TestWaitFieldsOnOtherTypes: - """duration/reason are wait-only — other types must reject them.""" + """duration/reason are wait-only — other variants must reject them.""" def test_duration_on_plain_agent(self) -> None: - with pytest.raises(PydanticValidationError, match="'duration'"): - AgentDef(name="a", duration="1s", prompt="hi", model="x") + with pytest.raises(PydanticValidationError) as exc_info: + AgentDef.model_validate({"name": "a", "duration": "1s", "prompt": "hi"}) + _assert_extra_forbidden(exc_info, "duration") def test_reason_on_plain_agent(self) -> None: - with pytest.raises(PydanticValidationError, match="'reason'"): - AgentDef(name="a", reason="x", prompt="hi", model="x") + with pytest.raises(PydanticValidationError) as exc_info: + AgentDef.model_validate({"name": "a", "reason": "x", "prompt": "hi"}) + _assert_extra_forbidden(exc_info, "reason") def test_duration_on_script(self) -> None: - with pytest.raises(PydanticValidationError, match="'duration'"): - AgentDef(name="s", type="script", command="ls", duration="1s") + with pytest.raises(PydanticValidationError) as exc_info: + ScriptStepDef.model_validate({"name": "s", "command": "ls", "duration": "1s"}) + _assert_extra_forbidden(exc_info, "duration") class TestWaitInParallelOrForEach: """Wait steps cannot be used in parallel groups or for-each groups.""" def test_reject_wait_in_parallel(self) -> None: - wait = AgentDef(name="w", type="wait", duration="1s", routes=[RouteDef(to="$end")]) - other = AgentDef(name="o", type="wait", duration="1s", routes=[RouteDef(to="$end")]) + wait = WaitStepDef(name="w", duration="1s", routes=[RouteDef(to="$end")]) + other = WaitStepDef(name="o", duration="1s", routes=[RouteDef(to="$end")]) config = _make_workflow( wait, other, @@ -223,7 +257,7 @@ def test_reject_wait_in_parallel(self) -> None: validate_workflow_config(config) def test_reject_wait_in_for_each(self) -> None: - wait = AgentDef(name="w", type="wait", duration="1s", routes=[RouteDef(to="$end")]) + wait = WaitStepDef(name="w", duration="1s", routes=[RouteDef(to="$end")]) # An entry-point agent + a producer agent (so the for-each # source resolves to a real agent reference). entry = AgentDef( @@ -250,7 +284,7 @@ class TestWaitValidationViaWorkflow: """Smoke test: a workflow containing only a wait step validates.""" def test_minimal_wait_workflow(self) -> None: - wait = AgentDef(name="w", type="wait", duration="100ms", routes=[RouteDef(to="$end")]) + wait = WaitStepDef(name="w", duration="100ms", routes=[RouteDef(to="$end")]) config = _make_workflow(wait) # Should not raise. validate_workflow_config(config) @@ -260,49 +294,59 @@ class TestWorkingDirTypeMatrix: """Requirement: ``working_dir`` is allowed on provider-backed LLM agents and script steps, and rejected on wait/set/terminate/human_gate/workflow types.""" - @pytest.mark.parametrize( - "kwargs", - [ - {"name": "llm", "prompt": "hi"}, - {"name": "script", "type": "script", "command": "ls"}, - ], - ids=["llm_agent", "script_step"], - ) - def test_working_dir_allowed(self, kwargs: dict) -> None: - agent = AgentDef(**kwargs, working_dir="/repo") # type: ignore[arg-type] + def test_working_dir_allowed_on_llm_agent(self) -> None: + agent = AgentDef(name="llm", prompt="hi", working_dir="/repo") + assert agent.working_dir == "/repo" + + def test_working_dir_allowed_on_script_step(self) -> None: + agent = ScriptStepDef(name="script", command="ls", working_dir="/repo") assert agent.working_dir == "/repo" @pytest.mark.parametrize( - "kwargs,match", + "build", [ - ( - {"name": "w", "type": "wait", "duration": "1s"}, - "wait agents cannot have 'working_dir'", + pytest.param( + lambda: WaitStepDef.model_validate( + {"name": "w", "duration": "1s", "working_dir": "/repo"} + ), + id="wait", ), - ( - {"name": "s", "type": "set", "value": "1"}, - "set agents cannot have 'working_dir'", + pytest.param( + lambda: SetStepDef.model_validate( + {"name": "s", "value": "1", "working_dir": "/repo"} + ), + id="set", ), - ( - {"name": "t", "type": "terminate", "status": "success", "reason": "done"}, - "terminate agents cannot have 'working_dir'", + pytest.param( + lambda: TerminateStepDef.model_validate( + {"name": "t", "status": "success", "reason": "done", "working_dir": "/repo"} + ), + id="terminate", ), - ( - { - "name": "g", - "type": "human_gate", - "prompt": "Pick", - "options": [GateOption(label="Yes", value="yes", route="$end")], - }, - "human_gate agents cannot have 'working_dir'", + pytest.param( + lambda: HumanGateStepDef.model_validate( + { + "name": "g", + "prompt": "Pick", + "options": [GateOption(label="Yes", value="yes", route="$end")], + "working_dir": "/repo", + } + ), + id="human_gate", ), - ( - {"name": "wf", "type": "workflow", "workflow": "./sub.yaml"}, - "workflow agents cannot have 'working_dir'", + pytest.param( + lambda: WorkflowStepDef.model_validate( + {"name": "wf", "workflow": "./sub.yaml", "working_dir": "/repo"} + ), + id="workflow", ), ], - ids=["wait", "set", "terminate", "human_gate", "workflow"], ) - def test_working_dir_rejected(self, kwargs: dict, match: str) -> None: - with pytest.raises(PydanticValidationError, match=match): - AgentDef(**kwargs, working_dir="/repo") # type: ignore[arg-type] + def test_working_dir_rejected(self, build: Callable[[], object]) -> None: + # extra="forbid": working_dir is not a field of these variants. + with pytest.raises(PydanticValidationError) as exc_info: + build() + assert any( + e["loc"][-1] == "working_dir" and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) diff --git a/tests/test_config/test_workflow_type_schema.py b/tests/test_config/test_workflow_type_schema.py index a1ad66dd..230418fe 100644 --- a/tests/test_config/test_workflow_type_schema.py +++ b/tests/test_config/test_workflow_type_schema.py @@ -10,39 +10,55 @@ from __future__ import annotations import pytest -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from conductor.config.schema import ( AgentDef, ForEachDef, GateOption, + HumanGateStepDef, LimitsConfig, OutputField, ParallelGroup, RetryPolicy, RouteDef, RuntimeConfig, + ScriptStepDef, WorkflowConfig, WorkflowDef, + WorkflowStepDef, ) from conductor.config.validator import validate_workflow_config from conductor.exceptions import ConfigurationError +def _assert_extra_forbidden(model_cls: type[BaseModel], payload: dict, field: str) -> None: + """Assert that ``field`` is rejected as an extra (foreign) field on ``model_cls``. + + Step variants declare ``extra="forbid"``, so a field owned by another variant + fails with Pydantic's standard ``extra_forbidden`` error at ``field``'s location. + """ + with pytest.raises(ValidationError) as exc_info: + model_cls.model_validate(payload) + assert any( + error["loc"] == (field,) and error["type"] == "extra_forbidden" + for error in exc_info.value.errors() + ) + + class TestWorkflowAgentDef: """Tests for workflow type AgentDef validation.""" def test_valid_workflow_agent(self) -> None: """Test creating a valid workflow agent.""" - agent = AgentDef(name="sub_wf", type="workflow", workflow="./sub.yaml") + agent = WorkflowStepDef(name="sub_wf", workflow="./sub.yaml") assert agent.type == "workflow" assert agent.workflow == "./sub.yaml" def test_valid_workflow_agent_with_routes(self) -> None: """Test workflow agent with routes validates correctly.""" - agent = AgentDef( + agent = WorkflowStepDef( name="sub_wf", - type="workflow", workflow="./sub.yaml", routes=[ RouteDef(to="next_agent", when="{{ output.result == 'done' }}"), @@ -53,9 +69,8 @@ def test_valid_workflow_agent_with_routes(self) -> None: def test_valid_workflow_agent_with_input(self) -> None: """Test workflow agent with input declarations.""" - agent = AgentDef( + agent = WorkflowStepDef( name="sub_wf", - type="workflow", workflow="./sub.yaml", input=["workflow.input.topic"], ) @@ -63,9 +78,8 @@ def test_valid_workflow_agent_with_input(self) -> None: def test_valid_workflow_agent_with_output(self) -> None: """Test workflow agent with output schema.""" - agent = AgentDef( + agent = WorkflowStepDef( name="sub_wf", - type="workflow", workflow="./sub.yaml", output={"findings": OutputField(type="string")}, ) @@ -74,76 +88,100 @@ def test_valid_workflow_agent_with_output(self) -> None: def test_workflow_without_path_raises(self) -> None: """Test that workflow agent without workflow path raises ValidationError.""" with pytest.raises(ValidationError, match="workflow agents require 'workflow' path"): - AgentDef(name="bad", type="workflow") + WorkflowStepDef(name="bad") def test_workflow_with_empty_path_raises(self) -> None: """Test that workflow agent with empty path raises ValidationError.""" with pytest.raises(ValidationError, match="workflow agents require 'workflow' path"): - AgentDef(name="bad", type="workflow", workflow="") + WorkflowStepDef(name="bad", workflow="") def test_workflow_with_prompt_raises(self) -> None: - """Test that workflow agent with prompt raises ValidationError.""" - with pytest.raises(ValidationError, match="workflow agents cannot have 'prompt'"): - AgentDef(name="bad", type="workflow", workflow="./s.yaml", prompt="hello") + """Test that workflow agent with prompt raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + WorkflowStepDef, + {"name": "bad", "workflow": "./s.yaml", "prompt": "hello"}, + "prompt", + ) def test_workflow_with_provider_raises(self) -> None: - """Test that workflow agent with provider raises ValidationError.""" - with pytest.raises(ValidationError, match="workflow agents cannot have 'provider'"): - AgentDef(name="bad", type="workflow", workflow="./s.yaml", provider="copilot") + """Test that workflow agent with provider raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + WorkflowStepDef, + {"name": "bad", "workflow": "./s.yaml", "provider": "copilot"}, + "provider", + ) def test_workflow_with_model_raises(self) -> None: - """Test that workflow agent with model raises ValidationError.""" - with pytest.raises(ValidationError, match="workflow agents cannot have 'model'"): - AgentDef(name="bad", type="workflow", workflow="./s.yaml", model="gpt-4") + """Test that workflow agent with model raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + WorkflowStepDef, + {"name": "bad", "workflow": "./s.yaml", "model": "gpt-4"}, + "model", + ) def test_workflow_with_tools_raises(self) -> None: - """Test that workflow agent with tools raises ValidationError.""" - with pytest.raises(ValidationError, match="workflow agents cannot have 'tools'"): - AgentDef(name="bad", type="workflow", workflow="./s.yaml", tools=["web_search"]) + """Test that workflow agent with tools raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + WorkflowStepDef, + {"name": "bad", "workflow": "./s.yaml", "tools": ["web_search"]}, + "tools", + ) def test_workflow_with_system_prompt_raises(self) -> None: - """Test that workflow agent with system_prompt raises ValidationError.""" - with pytest.raises(ValidationError, match="workflow agents cannot have 'system_prompt'"): - AgentDef(name="bad", type="workflow", workflow="./s.yaml", system_prompt="You are...") + """Test that workflow agent with system_prompt raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + WorkflowStepDef, + {"name": "bad", "workflow": "./s.yaml", "system_prompt": "You are..."}, + "system_prompt", + ) def test_workflow_with_options_raises(self) -> None: - """Test that workflow agent with options raises ValidationError.""" - with pytest.raises(ValidationError, match="workflow agents cannot have 'options'"): - AgentDef( - name="bad", - type="workflow", - workflow="./s.yaml", - options=[GateOption(label="OK", value="ok", route="$end")], - ) + """Test that workflow agent with options raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + WorkflowStepDef, + { + "name": "bad", + "workflow": "./s.yaml", + "options": [GateOption(label="OK", value="ok", route="$end")], + }, + "options", + ) def test_workflow_with_command_raises(self) -> None: - """Test that workflow agent with command raises ValidationError.""" - with pytest.raises(ValidationError, match="workflow agents cannot have 'command'"): - AgentDef(name="bad", type="workflow", workflow="./s.yaml", command="echo") + """Test that workflow agent with command raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + WorkflowStepDef, + {"name": "bad", "workflow": "./s.yaml", "command": "echo"}, + "command", + ) def test_workflow_with_max_session_seconds_raises(self) -> None: """Test that workflow agent with max_session_seconds raises ValidationError.""" - with pytest.raises( - ValidationError, match="workflow agents cannot have 'max_session_seconds'" - ): - AgentDef(name="bad", type="workflow", workflow="./s.yaml", max_session_seconds=60.0) + _assert_extra_forbidden( + WorkflowStepDef, + {"name": "bad", "workflow": "./s.yaml", "max_session_seconds": 60.0}, + "max_session_seconds", + ) def test_workflow_with_max_agent_iterations_raises(self) -> None: """Test that workflow agent with max_agent_iterations raises ValidationError.""" - with pytest.raises( - ValidationError, match="workflow agents cannot have 'max_agent_iterations'" - ): - AgentDef(name="bad", type="workflow", workflow="./s.yaml", max_agent_iterations=100) + _assert_extra_forbidden( + WorkflowStepDef, + {"name": "bad", "workflow": "./s.yaml", "max_agent_iterations": 100}, + "max_agent_iterations", + ) def test_workflow_with_retry_raises(self) -> None: - """Test that workflow agent with retry raises ValidationError.""" - with pytest.raises(ValidationError, match="workflow agents cannot have 'retry'"): - AgentDef( - name="bad", - type="workflow", - workflow="./s.yaml", - retry=RetryPolicy(max_attempts=3), - ) + """Test that workflow agent with retry raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + WorkflowStepDef, + { + "name": "bad", + "workflow": "./s.yaml", + "retry": RetryPolicy(max_attempts=3), + }, + "retry", + ) class TestWorkflowBackwardCompatibility: @@ -152,19 +190,18 @@ class TestWorkflowBackwardCompatibility: def test_regular_agent_still_works(self) -> None: """Test that a regular agent definition is unaffected.""" agent = AgentDef(name="test", prompt="hello") - assert agent.type is None - assert agent.workflow is None + assert agent.type == "agent" + assert agent.prompt == "hello" def test_script_agent_still_works(self) -> None: """Test that script agent is unaffected.""" - agent = AgentDef(name="test", type="script", command="echo") + agent = ScriptStepDef(name="test", command="echo") assert agent.type == "script" def test_human_gate_still_works(self) -> None: """Test that human_gate type is unaffected.""" - agent = AgentDef( + agent = HumanGateStepDef( name="gate", - type="human_gate", prompt="Choose:", options=[GateOption(label="Yes", value="yes", route="$end")], ) @@ -185,7 +222,7 @@ def test_workflow_in_parallel_group_raises(self) -> None: ), agents=[ AgentDef(name="agent_a", prompt="do something"), - AgentDef(name="sub_wf", type="workflow", workflow="./sub.yaml"), + WorkflowStepDef(name="sub_wf", workflow="./sub.yaml"), ], parallel=[ ParallelGroup( @@ -219,9 +256,8 @@ def test_workflow_in_for_each_validates(self) -> None: type="for_each", source="setup.output.items", **{"as": "item"}, - agent=AgentDef( + agent=WorkflowStepDef( name="runner", - type="workflow", workflow="./sub.yaml", ), ), @@ -245,9 +281,8 @@ def test_workflow_at_entry_point_validates(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="./sub.yaml", routes=[RouteDef(to="$end")], ), @@ -267,9 +302,8 @@ def test_workflow_with_routes_to_agents(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="./sub.yaml", routes=[ RouteDef(to="processor"), @@ -292,9 +326,8 @@ class TestInputMapping: def test_valid_input_mapping(self) -> None: """Test that input_mapping is accepted on workflow agents.""" - agent = AgentDef( + agent = WorkflowStepDef( name="sub_wf", - type="workflow", workflow="./sub.yaml", input_mapping={ "work_item_id": "{{ intake.output.epic_id }}", @@ -306,37 +339,34 @@ def test_valid_input_mapping(self) -> None: def test_workflow_without_input_mapping(self) -> None: """Test that workflow agents work without input_mapping (backward compat).""" - agent = AgentDef(name="sub_wf", type="workflow", workflow="./sub.yaml") + agent = WorkflowStepDef(name="sub_wf", workflow="./sub.yaml") assert agent.input_mapping is None def test_input_mapping_on_regular_agent_raises(self) -> None: - """Test that input_mapping on a regular agent raises ValidationError.""" - with pytest.raises(ValidationError, match="input_mapping"): - AgentDef( - name="regular", - prompt="do something", - input_mapping={"key": "{{ value }}"}, - ) + """Test that input_mapping on a regular agent raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + AgentDef, + {"name": "regular", "prompt": "do something", "input_mapping": {"key": "{{ value }}"}}, + "input_mapping", + ) def test_input_mapping_on_human_gate_raises(self) -> None: - """Test that input_mapping on a human_gate raises ValidationError.""" - with pytest.raises(ValidationError, match="input_mapping"): - AgentDef( - name="gate", - type="human_gate", - prompt="Choose", - options=[ - GateOption(label="Yes", value="yes", route="next"), - ], - input_mapping={"key": "{{ value }}"}, - ) + """Test that input_mapping on a human_gate raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + HumanGateStepDef, + { + "name": "gate", + "prompt": "Choose", + "options": [GateOption(label="Yes", value="yes", route="next")], + "input_mapping": {"key": "{{ value }}"}, + }, + "input_mapping", + ) def test_input_mapping_on_script_raises(self) -> None: - """Test that input_mapping on a script agent raises ValidationError.""" - with pytest.raises(ValidationError, match="input_mapping"): - AgentDef( - name="script", - type="script", - command="echo hi", - input_mapping={"key": "{{ value }}"}, - ) + """Test that input_mapping on a script agent raises ValidationError (extra_forbidden).""" + _assert_extra_forbidden( + ScriptStepDef, + {"name": "script", "command": "echo hi", "input_mapping": {"key": "{{ value }}"}}, + "input_mapping", + ) diff --git a/tests/test_engine/test_agent_timeout.py b/tests/test_engine/test_agent_timeout.py index baadf94d..8f550051 100644 --- a/tests/test_engine/test_agent_timeout.py +++ b/tests/test_engine/test_agent_timeout.py @@ -19,14 +19,16 @@ AgentDef, ContextConfig, ForEachDef, - GateOption, + HumanGateStepDef, LimitsConfig, OutputField, ParallelGroup, RouteDef, RuntimeConfig, + ScriptStepDef, WorkflowConfig, WorkflowDef, + WorkflowStepDef, ) from conductor.engine.workflow import WorkflowEngine from conductor.events import WorkflowEvent, WorkflowEventEmitter @@ -103,37 +105,52 @@ def test_timeout_seconds_must_be_positive(self) -> None: def test_script_agent_rejects_timeout_seconds(self) -> None: """Script agents must use 'timeout', not 'timeout_seconds'.""" - with pytest.raises(ValueError, match="script agents cannot have 'timeout_seconds'"): - AgentDef( - name="script1", - type="script", - command="echo hello", - timeout_seconds=30.0, - routes=[RouteDef(to="$end")], + with pytest.raises(ValueError) as exc_info: + ScriptStepDef.model_validate( + { + "name": "script1", + "command": "echo hello", + "timeout_seconds": 30.0, + "routes": [{"to": "$end"}], + } ) + assert any( + e["loc"] == ("timeout_seconds",) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) def test_human_gate_rejects_timeout_seconds(self) -> None: """Human gate agents cannot have timeout_seconds.""" - with pytest.raises(ValueError, match="human_gate agents cannot have 'timeout_seconds'"): - AgentDef( - name="gate1", - type="human_gate", - prompt="Choose", - options=[GateOption(label="Yes", value="yes", route="$end")], - timeout_seconds=30.0, - routes=[RouteDef(to="$end")], + with pytest.raises(ValueError) as exc_info: + HumanGateStepDef.model_validate( + { + "name": "gate1", + "prompt": "Choose", + "options": [{"label": "Yes", "value": "yes", "route": "$end"}], + "timeout_seconds": 30.0, + "routes": [{"to": "$end"}], + } ) + assert any( + e["loc"] == ("timeout_seconds",) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) def test_workflow_agent_rejects_timeout_seconds(self) -> None: """Workflow agents cannot have timeout_seconds.""" - with pytest.raises(ValueError, match="workflow agents cannot have 'timeout_seconds'"): - AgentDef( - name="sub1", - type="workflow", - workflow="./sub.yaml", - timeout_seconds=30.0, - routes=[RouteDef(to="$end")], + with pytest.raises(ValueError) as exc_info: + WorkflowStepDef.model_validate( + { + "name": "sub1", + "workflow": "./sub.yaml", + "timeout_seconds": 30.0, + "routes": [{"to": "$end"}], + } ) + assert any( + e["loc"] == ("timeout_seconds",) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) # --------------------------------------------------------------------------- diff --git a/tests/test_engine/test_budget.py b/tests/test_engine/test_budget.py index f5fc0d1e..84b7a80f 100644 --- a/tests/test_engine/test_budget.py +++ b/tests/test_engine/test_budget.py @@ -24,6 +24,7 @@ RuntimeConfig, WorkflowConfig, WorkflowDef, + WorkflowStepDef, ) from conductor.engine.limits import LimitEnforcer from conductor.engine.workflow import WorkflowEngine @@ -477,9 +478,8 @@ def _make_subworkflow_parent_config( ), ), agents=[ - AgentDef( + WorkflowStepDef( name="delegate", - type="workflow", workflow="child.yaml", routes=[RouteDef(to="$end")], ), diff --git a/tests/test_engine/test_context_window_events.py b/tests/test_engine/test_context_window_events.py index 064fb01e..0e9f0c44 100644 --- a/tests/test_engine/test_context_window_events.py +++ b/tests/test_engine/test_context_window_events.py @@ -20,6 +20,7 @@ ParallelGroup, RouteDef, RuntimeConfig, + WaitStepDef, WorkflowConfig, WorkflowDef, ) @@ -791,9 +792,8 @@ async def test_a_wait_step_never_resolves_a_provider(self) -> None: limits=LimitsConfig(max_iterations=5), ), agents=[ - AgentDef( + WaitStepDef( name="pause", - type="wait", duration="1ms", routes=[RouteDef(to="$end")], ), diff --git a/tests/test_engine/test_event_emission.py b/tests/test_engine/test_event_emission.py index 76ee22c2..73818ed7 100644 --- a/tests/test_engine/test_event_emission.py +++ b/tests/test_engine/test_event_emission.py @@ -17,6 +17,7 @@ ContextConfig, ForEachDef, GateOption, + HumanGateStepDef, LimitsConfig, OutputField, ParallelGroup, @@ -24,6 +25,8 @@ ReasoningConfig, RouteDef, RuntimeConfig, + ScriptStepDef, + SetStepDef, WorkflowConfig, WorkflowDef, ) @@ -793,9 +796,8 @@ async def test_script_started_and_completed(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="run_echo", - type="script", command=sys.executable, args=["-c", "print('hello')"], routes=[RouteDef(to="$end")], @@ -834,9 +836,8 @@ async def test_script_failed_emitted(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="bad_script", - type="script", command="nonexistent_command_xyz_12345", routes=[RouteDef(to="$end")], ), @@ -1093,8 +1094,8 @@ async def test_parallel_set_completion_reports_zero_token_breakdown(self) -> Non limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef(name="left", type="set", value="left"), - AgentDef(name="right", type="set", value="right"), + SetStepDef(name="left", value="left"), + SetStepDef(name="right", value="right"), ], parallel=[ ParallelGroup( @@ -1355,9 +1356,8 @@ async def test_gate_presented_and_resolved(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + HumanGateStepDef( name="reviewer", - type="human_gate", prompt="Do you approve?", options=[ GateOption( @@ -1412,9 +1412,8 @@ async def test_gate_resolved_to_end(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + HumanGateStepDef( name="gate", - type="human_gate", prompt="Continue?", options=[ GateOption( @@ -1458,9 +1457,8 @@ async def test_gate_event_ordering(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + HumanGateStepDef( name="gate", - type="human_gate", prompt="Approve?", options=[ GateOption( diff --git a/tests/test_engine/test_mcp_step_groups.py b/tests/test_engine/test_mcp_step_groups.py index e67800a3..837e0dc4 100644 --- a/tests/test_engine/test_mcp_step_groups.py +++ b/tests/test_engine/test_mcp_step_groups.py @@ -38,10 +38,12 @@ ForEachDef, LimitsConfig, MCPServerDef, + MCPStepDef, OutputField, ParallelGroup, RouteDef, RuntimeConfig, + SetStepDef, WorkflowConfig, WorkflowDef, ) @@ -100,9 +102,8 @@ def _runtime( def _mcp_agent(name: str, server: str, arguments: dict[str, Any] | None = None) -> AgentDef: - return AgentDef( + return MCPStepDef( name=name, - type="mcp", server=server, tool="echo", arguments=arguments or {"q": "hello"}, @@ -148,7 +149,7 @@ async def test_mcp_member_completes_with_group_events(self) -> None: ), agents=[ _mcp_agent("call", "srv"), - AgentDef(name="flag", type="set", value="ready", routes=[]), + SetStepDef(name="flag", value="ready", routes=[]), ], parallel=[ ParallelGroup(name="grp", agents=["call", "flag"], routes=[RouteDef(to="$end")]) @@ -557,7 +558,7 @@ async def test_output_schema_mismatch_in_group_member_leaks_no_values(self) -> N context=ContextConfig(mode="accumulate"), limits=LimitsConfig(max_iterations=10), ), - agents=[mcp, AgentDef(name="flag", type="set", value="ready", routes=[])], + agents=[mcp, SetStepDef(name="flag", value="ready", routes=[])], parallel=[ ParallelGroup(name="grp", agents=["call", "flag"], routes=[RouteDef(to="$end")]) ], diff --git a/tests/test_engine/test_mcp_step_workflow.py b/tests/test_engine/test_mcp_step_workflow.py index c899a148..6e3af5ca 100644 --- a/tests/test_engine/test_mcp_step_workflow.py +++ b/tests/test_engine/test_mcp_step_workflow.py @@ -39,13 +39,14 @@ import pytest from conductor.config.schema import ( - AgentDef, ContextConfig, LimitsConfig, MCPServerDef, + MCPStepDef, OutputField, RouteDef, RuntimeConfig, + SetStepDef, WorkflowConfig, WorkflowDef, ) @@ -120,9 +121,8 @@ def _mcp_workflow(*, arguments: dict[str, Any] | None = None) -> WorkflowConfig: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + MCPStepDef( name="call", - type="mcp", server="srv", tool="echo", arguments=arguments or {"q": "hello"}, @@ -151,17 +151,15 @@ async def test_mcp_only_workflow_completes_without_llm(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + MCPStepDef( name="first", - type="mcp", server="srv", tool="echo", arguments={"q": "hello"}, routes=[RouteDef(to="second")], ), - AgentDef( + MCPStepDef( name="second", - type="mcp", server="srv", tool="echo", arguments={"q": "follow-up-{{ first.output.answer }}"}, @@ -221,15 +219,13 @@ async def test_route_on_is_error_branches_both_ways(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="flag", - type="set", values={"q": "{{ workflow.input.q }}"}, routes=[RouteDef(to="call")], ), - AgentDef( + MCPStepDef( name="call", - type="mcp", server="srv", tool="echo", arguments={"q": "{{ flag.output.q }}"}, @@ -238,15 +234,13 @@ async def test_route_on_is_error_branches_both_ways(self) -> None: RouteDef(to="on_ok"), ], ), - AgentDef( + SetStepDef( name="on_error", - type="set", value="error-path", routes=[RouteDef(to="$end")], ), - AgentDef( + SetStepDef( name="on_ok", - type="set", value="ok-path", routes=[RouteDef(to="$end")], ), @@ -430,9 +424,8 @@ async def test_unknown_server_names_available_servers(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + MCPStepDef( name="call", - type="mcp", server="ghost", tool="echo", routes=[RouteDef(to="$end")], @@ -469,9 +462,8 @@ async def test_templated_working_dir_value_leaks_no_values(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + MCPStepDef( name="call", - type="mcp", server="srv", tool="echo", routes=[RouteDef(to="$end")], @@ -518,9 +510,8 @@ async def test_mixed_wildcard_tools_list_allows_any_tool(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + MCPStepDef( name="call", - type="mcp", server="srv", tool="echo", routes=[RouteDef(to="$end")], @@ -940,15 +931,13 @@ async def test_arguments_render_workflow_inputs_and_declared_outputs(self) -> No limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="prep", - type="set", value="from-prep", routes=[RouteDef(to="call")], ), - AgentDef( + MCPStepDef( name="call", - type="mcp", server="srv", tool="echo", input=["prep.output"], diff --git a/tests/test_engine/test_periodic_checkpoint.py b/tests/test_engine/test_periodic_checkpoint.py index 916d32c8..2297a9a5 100644 --- a/tests/test_engine/test_periodic_checkpoint.py +++ b/tests/test_engine/test_periodic_checkpoint.py @@ -31,6 +31,9 @@ LimitsConfig, RouteDef, RuntimeConfig, + ScriptStepDef, + SetStepDef, + TerminateStepDef, WorkflowConfig, WorkflowDef, ) @@ -53,9 +56,8 @@ def _runtime_with_checkpoint(checkpoint: CheckpointConfig) -> RuntimeConfig: def _script(name: str, text: str, to: str) -> AgentDef: """A script step that prints *text* and routes to *to*.""" - return AgentDef( + return ScriptStepDef( name=name, - type="script", command=sys.executable, args=["-c", f"print({text!r})"], routes=[RouteDef(to=to)], @@ -228,9 +230,8 @@ async def test_failure_retains_periodic_checkpoints(self, tmp_path: Path) -> Non # Last step is a set step that raises at render time (division by zero), # forcing a runtime failure after periodic checkpoints were saved. - boom = AgentDef( + boom = SetStepDef( name="step3", - type="set", value="{{ 1 // 0 }}", routes=[RouteDef(to="$end")], ) @@ -261,9 +262,8 @@ async def test_resume_from_periodic_checkpoint_continues_forward(self, tmp_path: def counter_step(name: str, path: Path, to: str) -> AgentDef: code = f"open({str(path)!r}, 'a').write('x')" - return AgentDef( + return ScriptStepDef( name=name, - type="script", command=sys.executable, args=["-c", code], routes=[RouteDef(to=to)], @@ -421,7 +421,7 @@ async def test_rotation_keeps_keep_last_during_run(self, tmp_path: Path) -> None # 4 successful steps then a failing set step (so success-cleanup does # NOT run and we can observe the rotated periodic checkpoints on disk). - boom = AgentDef(name="boom", type="set", value="{{ 1 // 0 }}", routes=[RouteDef(to="$end")]) + boom = SetStepDef(name="boom", value="{{ 1 // 0 }}", routes=[RouteDef(to="$end")]) config = WorkflowConfig( workflow=WorkflowDef( name="periodic-ckpt", @@ -514,8 +514,8 @@ async def test_resume_to_success_cleans_periodic(self, tmp_path: Path) -> None: # First run: fail at the end so periodic checkpoints persist. boom_cfg = _three_step_config( CheckpointConfig(every_agent=True), - last_step=AgentDef( - name="step3", type="set", value="{{ 1 // 0 }}", routes=[RouteDef(to="$end")] + last_step=SetStepDef( + name="step3", value="{{ 1 // 0 }}", routes=[RouteDef(to="$end")] ), ) engine = _make_engine(boom_cfg, wf, WorkflowEventEmitter()) @@ -553,7 +553,7 @@ async def test_failed_terminate_cleans_periodic(self, tmp_path: Path) -> None: # step1 -> step2 -> terminate(failed). Explicit failed terminate is # non-resumable, so its periodic checkpoints must be cleaned up. - terminate = AgentDef(name="stop", type="terminate", status="failed", reason="done") + terminate = TerminateStepDef(name="stop", status="failed", reason="done") config = WorkflowConfig( workflow=WorkflowDef( name="periodic-ckpt", diff --git a/tests/test_engine/test_questions.py b/tests/test_engine/test_questions.py index 816a9698..0d0f765a 100644 --- a/tests/test_engine/test_questions.py +++ b/tests/test_engine/test_questions.py @@ -16,6 +16,7 @@ InputDef, OutputField, QuestionDef, + QuestionsStepDef, RouteDef, WorkflowConfig, WorkflowDef, @@ -71,9 +72,8 @@ class TestQuestionsCursor: @pytest.mark.asyncio async def test_answers_are_collected_in_order(self) -> None: """Each answer lands under its own key.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="First?"), QuestionDef(text="Second?")], routes=[RouteDef(to="after")], ) @@ -102,9 +102,8 @@ async def test_back_clears_the_revisited_answer(self) -> None: This is the whole point of keying answers instead of concatenating a transcript string — the loop this replaces could not express it. """ - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="First?"), QuestionDef(text="Second?")], routes=[RouteDef(to="after")], ) @@ -138,9 +137,8 @@ async def test_back_from_the_last_question_is_reachable(self) -> None: """Without the closing review, answering the last question would end the node instantly and Back would be unusable exactly where a user is most likely to want it.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="Only?")], routes=[RouteDef(to="after")], ) @@ -163,9 +161,8 @@ async def test_back_from_the_last_question_is_reachable(self) -> None: @pytest.mark.asyncio async def test_review_is_skipped_when_back_is_disabled(self) -> None: """The review only exists to keep Back reachable.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="Only?")], allow_back=False, routes=[RouteDef(to="after")], @@ -184,9 +181,8 @@ async def test_review_is_skipped_when_back_is_disabled(self) -> None: @pytest.mark.asyncio async def test_multiline_free_text_is_preserved(self) -> None: """Newlines inside an answer survive into the context.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="Why?")], routes=[RouteDef(to="after")], ) @@ -208,9 +204,8 @@ async def test_multiline_free_text_is_preserved(self) -> None: @pytest.mark.asyncio async def test_skip_all_marks_remaining_and_stops_prompting(self) -> None: """Skip-all must not keep asking.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text=f"Q{i}?") for i in range(4)], routes=[RouteDef(to="after")], ) @@ -234,9 +229,8 @@ async def test_skip_all_marks_remaining_and_stops_prompting(self) -> None: @pytest.mark.asyncio async def test_choice_selection_records_provenance(self) -> None: """A selected suggestion is recorded as a choice, not free text.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="Server or client?", choices=["Server", "Client"])], routes=[RouteDef(to="after")], ) @@ -262,9 +256,8 @@ class TestQuestionsRequired: @pytest.mark.asyncio async def test_required_question_rejects_empty_free_text(self) -> None: """An empty answer re-presents the question instead of being accepted.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="Why?", required=True)], routes=[RouteDef(to="after")], ) @@ -287,9 +280,8 @@ async def test_required_question_rejects_empty_free_text(self) -> None: @pytest.mark.asyncio async def test_required_question_cannot_be_skipped(self) -> None: """Skip is refused with a reason rather than silently recording a skip.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="Why?", required=True)], routes=[RouteDef(to="after")], ) @@ -312,9 +304,8 @@ async def test_required_question_cannot_be_skipped(self) -> None: @pytest.mark.asyncio async def test_required_with_default_can_be_skipped(self) -> None: """A default means the question has an answer, so skipping is safe.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="Why?", required=True, default="fallback")], routes=[RouteDef(to="after")], ) @@ -343,9 +334,8 @@ async def test_skip_gates_never_prompts(self) -> None: ``options[0]`` for a question is the *upstream agent's* first suggested answer, so auto-selecting it would feed invented input back as real. """ - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="Server or client?", choices=["Server", "Client"])], routes=[RouteDef(to="after")], ) @@ -364,9 +354,8 @@ async def test_skip_gates_never_prompts(self) -> None: @pytest.mark.asyncio async def test_skip_gates_uses_declared_defaults(self) -> None: """A default is author-declared, so it is safe to apply unattended.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="Why?", default="declared")], routes=[RouteDef(to="after")], ) @@ -383,9 +372,8 @@ class TestQuestionsSource: @pytest.mark.asyncio async def test_source_of_plain_strings(self) -> None: """The existing ``array of string`` shape needs no producer changes.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", source="seed.output.open_questions", routes=[RouteDef(to="after")], ) @@ -427,9 +415,8 @@ async def test_source_of_plain_strings(self) -> None: @pytest.mark.asyncio async def test_source_of_objects_offers_choices(self) -> None: """Objects let the upstream agent propose candidate answers.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", source="seed.output.open_questions", routes=[RouteDef(to="after")], ) @@ -469,9 +456,8 @@ async def test_source_of_objects_offers_choices(self) -> None: @pytest.mark.asyncio async def test_malformed_source_entry_raises(self) -> None: """A bad entry fails loudly rather than being silently dropped.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", source="seed.output.open_questions", routes=[RouteDef(to="after")], ) @@ -505,9 +491,8 @@ class TestQuestionsDurability: async def test_progress_is_committed_after_each_answer(self) -> None: """Context carries partial answers mid-node, which is what a checkpoint serializes (``WorkflowContext.to_dict``).""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="First?"), QuestionDef(text="Second?")], routes=[RouteDef(to="after")], ) @@ -533,9 +518,8 @@ async def _resolve(self, gate_prompt): @pytest.mark.asyncio async def test_resume_continues_at_the_first_unanswered_question(self) -> None: """A restored node must not re-ask what the human already answered.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[ QuestionDef(text="First?"), QuestionDef(text="Second?"), @@ -592,9 +576,8 @@ async def test_resume_drops_answers_to_removed_questions(self) -> None: The removed entry is marked skipped so it is observable: without the filter it would inflate ``skipped_count``. """ - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(id="kept", text="Kept?")], routes=[RouteDef(to="after")], ) @@ -635,9 +618,8 @@ async def test_resume_drops_answers_to_removed_questions(self) -> None: @pytest.mark.asyncio async def test_resume_rejects_an_unknown_answer_source(self) -> None: """A hand-edited checkpoint must not smuggle an unknown source through.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(id="q", text="Q?"), QuestionDef(id="q2", text="Q2?")], routes=[RouteDef(to="after")], ) @@ -673,9 +655,8 @@ async def test_loop_back_asks_the_new_questions(self) -> None: """Ids default to positional q1..qN, so a second pass over a different question set would otherwise inherit the first pass's answers and report answers the human never gave.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", source="architect.output.open_questions", routes=[RouteDef(to="architect")], ) @@ -735,9 +716,8 @@ async def test_mid_node_progress_does_not_inflate_execution_history(self) -> Non current_iteration, which downstream prompts, the interrupt panel, and the dashboard's synthetic replay all read. """ - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text=f"Q{i}?") for i in range(4)], allow_back=False, routes=[RouteDef(to="after")], @@ -766,9 +746,8 @@ class TestQuestionsSourceIsNotATemplate: async def test_jinja_in_a_model_authored_question_is_shown_verbatim(self, text: str) -> None: """These are ordinary questions for a developer tool; rendering them would abort the step just as the human was about to be asked.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", source="seed.output.open_questions", allow_back=False, routes=[RouteDef(to="after")], @@ -802,9 +781,8 @@ async def test_jinja_in_a_model_authored_question_is_shown_verbatim(self, text: @pytest.mark.asyncio async def test_inline_question_text_is_still_rendered(self) -> None: """Author-written questions keep full template support.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="Ship {{ workflow.input.topic }}?")], allow_back=False, routes=[RouteDef(to="after")], @@ -826,9 +804,8 @@ class TestQuestionsRoutingAndCost: @pytest.mark.asyncio async def test_node_costs_one_iteration_regardless_of_count(self) -> None: """One step, not 2N — the reason this is a node and not a loop.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text=f"Q{i}?") for i in range(5)], routes=[RouteDef(to="after")], ) @@ -850,9 +827,8 @@ async def test_abort_routes_to_the_declared_route(self) -> None: Uses a distinct third agent: pointing abort_route at ``$end`` would match the fallback and prove nothing. """ - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="Why?")], allow_abort=True, abort_route="rescue", @@ -881,9 +857,8 @@ async def test_abort_routes_to_the_declared_route(self) -> None: @pytest.mark.asyncio async def test_abort_without_a_route_ends_the_workflow(self) -> None: """`allow_abort` with no abort_route falls back to $end.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="Why?")], allow_abort=True, routes=[RouteDef(to="after")], @@ -900,9 +875,8 @@ async def test_abort_without_a_route_ends_the_workflow(self) -> None: @pytest.mark.asyncio async def test_routes_evaluate_against_the_output(self) -> None: """Conditional routes can branch on whether anyone answered.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="Why?")], routes=[ RouteDef(to="after", when="{{ ask.output.answered_any }}"), @@ -926,9 +900,8 @@ async def test_routes_evaluate_against_the_output(self) -> None: async def test_every_prompt_carries_a_distinct_prompt_id(self) -> None: """All prompts share the node name, so the token is the only thing that stops a late click resolving a later question.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="First?"), QuestionDef(text="Second?")], routes=[RouteDef(to="after")], ) @@ -958,9 +931,8 @@ class TestQuestionsGateEvents: @pytest.mark.asyncio async def test_every_presented_gate_is_resolved(self) -> None: - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="First?"), QuestionDef(text="Second?")], routes=[RouteDef(to="after")], ) @@ -998,9 +970,8 @@ def _record_emit(self, event_type, data=None, **kwargs): @pytest.mark.asyncio async def test_resolution_carries_the_selected_value(self) -> None: """The closing event names what was chosen, matching human_gate's.""" - agent = AgentDef( + agent = QuestionsStepDef( name="ask", - type="questions", questions=[QuestionDef(text="Only?")], routes=[RouteDef(to="after")], ) diff --git a/tests/test_engine/test_resume.py b/tests/test_engine/test_resume.py index 02365025..db296649 100644 --- a/tests/test_engine/test_resume.py +++ b/tests/test_engine/test_resume.py @@ -449,6 +449,84 @@ def mock_handler(agent, prompt, context): class TestFullRoundTrip: """Test the complete flow: run → fail → checkpoint → resume → success.""" + @pytest.mark.asyncio + async def test_round_trip_with_legacy_yaml_without_type(self, tmp_path: Path) -> None: + # Requirement: a pre-#517 workflow file whose LLM agents declare no + # ``type`` runs, checkpoints, and — reloaded by the resume path — + # still parses every agent as a canonical ``AgentDef(type="agent")``. + from conductor.config.loader import load_config + + wf_path = _write_workflow( + tmp_path, + """\ +workflow: + name: legacy-roundtrip + entry_point: planner +agents: + - name: planner + model: gpt-4 + prompt: "Plan: {{ workflow.input.topic }}" + output: + plan: { type: string } + routes: + - to: researcher + - name: researcher + model: gpt-4 + prompt: "Research: {{ planner.output.plan }}" + output: + findings: { type: string } + routes: + - to: $end +output: + findings: "{{ researcher.output.findings }}" +""", + ) + config = load_config(wf_path) + assert all(type(a) is AgentDef and a.type == "agent" for a in config.agents) + + calls = {"researcher": 0} + + def failing_handler(agent, prompt, context): + if agent.name == "planner": + return {"plan": "research AI topics"} + calls["researcher"] += 1 + if calls["researcher"] == 1: + raise ProviderError("Temporary network error") + return {"findings": "comprehensive findings"} + + provider = CopilotProvider(mock_handler=failing_handler) + engine = WorkflowEngine(config, provider, workflow_path=wf_path) + + with ( + patch.object(CheckpointManager, "get_checkpoints_dir", return_value=tmp_path), + pytest.raises(ProviderError, match="Temporary network"), + ): + await engine.run({"topic": "AI"}) + + checkpoint_path = engine._last_checkpoint_path + assert checkpoint_path is not None + cp = CheckpointManager.load_checkpoint(checkpoint_path) + + # The resume CLI path re-parses the workflow file from disk. + reloaded = load_config(wf_path) + assert all(type(a) is AgentDef and a.type == "agent" for a in reloaded.agents) + + engine2 = WorkflowEngine(reloaded, provider, workflow_path=wf_path) + engine2.set_context(WorkflowContext.from_dict(cp.context)) + engine2.set_limits( + LimitEnforcer.from_dict( + cp.limits, + timeout_seconds=config.workflow.limits.timeout_seconds, + budget_usd=config.workflow.limits.budget_usd, + budget_mode=config.workflow.limits.budget_mode, + ) + ) + + with patch.object(CheckpointManager, "get_checkpoints_dir", return_value=tmp_path): + result = await engine2.resume(cp.current_agent) + + assert result["findings"] == "comprehensive findings" + @pytest.mark.asyncio async def test_round_trip_checkpoint_and_resume(self, tmp_path: Path) -> None: """Full round-trip: run fails, checkpoint saved, resume succeeds.""" diff --git a/tests/test_engine/test_script_workflow.py b/tests/test_engine/test_script_workflow.py index 8c48f44e..f3a8b8fa 100644 --- a/tests/test_engine/test_script_workflow.py +++ b/tests/test_engine/test_script_workflow.py @@ -28,6 +28,7 @@ ParallelGroup, RouteDef, RuntimeConfig, + ScriptStepDef, WorkflowConfig, WorkflowDef, ) @@ -52,9 +53,8 @@ async def test_script_step_runs_to_end(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="run_echo", - type="script", command=sys.executable, args=["-c", "print('hello world')"], routes=[RouteDef(to="$end")], @@ -83,9 +83,8 @@ async def test_script_output_in_context(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="checker", - type="script", command=sys.executable, args=["-c", "print('test output')"], routes=[RouteDef(to="processor")], @@ -131,9 +130,8 @@ async def test_route_on_exit_code_simpleeval_success(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="checker", - type="script", command=sys.executable, args=["-c", "import sys; sys.exit(0)"], routes=[ @@ -176,9 +174,8 @@ async def test_route_on_exit_code_simpleeval_failure(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="checker", - type="script", command=sys.executable, args=["-c", "import sys; sys.exit(1)"], routes=[ @@ -221,9 +218,8 @@ async def test_route_on_exit_code_jinja2(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="checker", - type="script", command=sys.executable, args=["-c", "import sys; sys.exit(0)"], routes=[ @@ -270,16 +266,14 @@ async def test_script_counts_toward_iteration_limit(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="step1", - type="script", command=sys.executable, args=["-c", "print('step1')"], routes=[RouteDef(to="step2")], ), - AgentDef( + ScriptStepDef( name="step2", - type="script", command=sys.executable, args=["-c", "print('step2')"], routes=[RouteDef(to="$end")], @@ -306,9 +300,8 @@ async def test_script_non_zero_exit_no_routes_ends(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="failing", - type="script", command=sys.executable, args=["-c", "import sys; sys.exit(1)"], ), @@ -341,9 +334,8 @@ async def test_mixed_agent_and_script(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="setup_script", - type="script", command=sys.executable, args=["-c", "print('setup complete')"], routes=[RouteDef(to="analyzer")], @@ -384,9 +376,8 @@ async def test_script_command_with_workflow_input(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="runner", - type="script", command=sys.executable, args=["-c", "import sys; print(sys.argv[1])", "{{ workflow.input.message }}"], routes=[RouteDef(to="$end")], @@ -418,9 +409,8 @@ def test_dry_run_includes_script_type(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="setup", - type="script", command="echo", args=["init"], routes=[RouteDef(to="$end")], @@ -459,7 +449,7 @@ def test_script_in_parallel_group_raises_configuration_error(self) -> None: ), agents=[ AgentDef(name="agent_a", prompt="do something", routes=[RouteDef(to="$end")]), - AgentDef(name="script_b", type="script", command="echo"), + ScriptStepDef(name="script_b", command="echo"), ], parallel=[ ParallelGroup( @@ -495,9 +485,8 @@ def _single_script_config(args: list[str]) -> WorkflowConfig: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="detector", - type="script", command=sys.executable, args=args, routes=[RouteDef(to="$end")], @@ -517,9 +506,8 @@ async def test_json_object_parsed_with_field_routing(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="detector", - type="script", command=sys.executable, args=[ "-c", @@ -531,9 +519,8 @@ async def test_json_object_parsed_with_field_routing(self) -> None: RouteDef(to="$end"), ], ), - AgentDef( + ScriptStepDef( name="planner", - type="script", command=sys.executable, args=["-c", "print('done')"], routes=[RouteDef(to="$end")], @@ -626,9 +613,8 @@ def _config_with_schema( limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="detector", - type="script", command=sys.executable, args=args, output=output, @@ -963,9 +949,8 @@ async def test_schema_field_drives_route(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="detector", - type="script", command=sys.executable, args=[ "-c", @@ -977,9 +962,8 @@ async def test_schema_field_drives_route(self) -> None: RouteDef(to="$end"), ], ), - AgentDef( + ScriptStepDef( name="planner", - type="script", command=sys.executable, args=["-c", "print('planning done')"], routes=[RouteDef(to="$end")], @@ -1017,9 +1001,8 @@ async def test_structured_payload_piped_via_stdin(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="consume", - type="script", command=sys.executable, args=["-c", reader], stdin="{{ workflow.input.payload | tojson }}", @@ -1060,9 +1043,8 @@ async def test_large_payload_via_stdin_reports_byte_count(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + ScriptStepDef( name="sizer", - type="script", command=sys.executable, args=["-c", reader], stdin="{{ workflow.input.blob }}", diff --git a/tests/test_engine/test_set_workflow.py b/tests/test_engine/test_set_workflow.py index e0779455..58ccd35e 100644 --- a/tests/test_engine/test_set_workflow.py +++ b/tests/test_engine/test_set_workflow.py @@ -24,7 +24,6 @@ import pytest from conductor.config.schema import ( - AgentDef, ContextConfig, ForEachDef, LimitsConfig, @@ -32,6 +31,8 @@ ParallelGroup, RouteDef, RuntimeConfig, + ScriptStepDef, + SetStepDef, WorkflowConfig, WorkflowDef, ) @@ -66,9 +67,8 @@ async def test_single_value_scalar_in_output(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="compute", - type="set", value="{{ workflow.input.org }}/{{ workflow.input.repo }}", routes=[RouteDef(to="$end")], ), @@ -90,9 +90,8 @@ async def test_multi_value_dict_field_access(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="derive", - type="set", values={ "is_breaking": ("{{ workflow.input.severity in ['high', 'critical'] }}"), "branch": "{{ workflow.input.branch or 'main' }}", @@ -126,25 +125,22 @@ async def test_route_on_boolean_set_output(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="flag", - type="set", values={"is_breaking": "{{ workflow.input.severity == 'high' }}"}, routes=[ RouteDef(to="breaking_path", when="{{ output.is_breaking }}"), RouteDef(to="safe_path"), ], ), - AgentDef( + ScriptStepDef( name="breaking_path", - type="script", command=sys.executable, args=["-c", "print('breaking')"], routes=[RouteDef(to="$end")], ), - AgentDef( + ScriptStepDef( name="safe_path", - type="script", command=sys.executable, args=["-c", "print('safe')"], routes=[RouteDef(to="$end")], @@ -177,25 +173,22 @@ async def test_route_on_scalar_set_output(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="flag", - type="set", value="{{ workflow.input.severity == 'high' }}", routes=[ RouteDef(to="hi", when="{{ output }}"), RouteDef(to="lo"), ], ), - AgentDef( + ScriptStepDef( name="hi", - type="script", command=sys.executable, args=["-c", "print('hi')"], routes=[RouteDef(to="$end")], ), - AgentDef( + ScriptStepDef( name="lo", - type="script", command=sys.executable, args=["-c", "print('lo')"], routes=[RouteDef(to="$end")], @@ -229,8 +222,8 @@ async def test_set_in_parallel_publishes_values(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef(name="left", type="set", value="{{ workflow.input.a }}"), - AgentDef(name="right", type="set", value="{{ workflow.input.b }}"), + SetStepDef(name="left", value="{{ workflow.input.a }}"), + SetStepDef(name="right", value="{{ workflow.input.b }}"), ], parallel=[ ParallelGroup( @@ -261,8 +254,8 @@ async def test_set_in_parallel_emits_set_events(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef(name="left", type="set", value="{{ workflow.input.a }}"), - AgentDef(name="right", type="set", value="{{ workflow.input.b }}"), + SetStepDef(name="left", value="{{ workflow.input.a }}"), + SetStepDef(name="right", value="{{ workflow.input.b }}"), ], parallel=[ ParallelGroup( @@ -294,10 +287,9 @@ async def test_set_in_parallel_output_schema_enforced(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef(name="ok", type="set", value="x"), - AgentDef( + SetStepDef(name="ok", value="x"), + SetStepDef( name="bad", - type="set", value="hello", output={"ok": OutputField(type="boolean")}, ), @@ -329,9 +321,8 @@ async def test_set_in_for_each_per_item(self) -> None: limits=LimitsConfig(max_iterations=20), ), agents=[ - AgentDef( + SetStepDef( name="setup", - type="set", values={"items": "{{ [1, 2, 3] }}"}, routes=[RouteDef(to="loop")], ), @@ -342,9 +333,8 @@ async def test_set_in_for_each_per_item(self) -> None: type="for_each", source="setup.output.items", **{"as": "item"}, - agent=AgentDef( + agent=SetStepDef( name="binder", - type="set", value="item-{{ item }}", ), routes=[RouteDef(to="$end")], @@ -368,9 +358,8 @@ async def test_set_in_for_each_emits_set_events_per_item(self) -> None: limits=LimitsConfig(max_iterations=20), ), agents=[ - AgentDef( + SetStepDef( name="setup", - type="set", values={"items": "{{ [1, 2, 3] }}"}, routes=[RouteDef(to="loop")], ), @@ -381,7 +370,7 @@ async def test_set_in_for_each_emits_set_events_per_item(self) -> None: type="for_each", source="setup.output.items", **{"as": "item"}, - agent=AgentDef(name="binder", type="set", value="item-{{ item }}"), + agent=SetStepDef(name="binder", value="item-{{ item }}"), routes=[RouteDef(to="$end")], ), ], @@ -411,9 +400,8 @@ async def test_scalar_value_with_output_schema_rejected(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="bind", - type="set", value="hello", output={"ok": OutputField(type="boolean")}, routes=[RouteDef(to="$end")], @@ -439,9 +427,8 @@ async def test_multi_values_with_output_schema_pass(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="bind", - type="set", values={"ok": "{{ true }}"}, output={"ok": OutputField(type="boolean")}, routes=[RouteDef(to="$end")], @@ -463,9 +450,8 @@ async def test_multi_values_with_output_schema_mismatch_fails(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="bind", - type="set", values={"ok": "{{ true }}"}, output={"missing": OutputField(type="boolean")}, routes=[RouteDef(to="$end")], @@ -493,9 +479,8 @@ async def test_set_step_extra_keys_no_warning(self, caplog: pytest.LogCaptureFix limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="bind", - type="set", values={ "ok": "{{ true }}", "extra": "{{ 42 }}", @@ -527,9 +512,8 @@ async def test_events_for_successful_set(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="bind", - type="set", values={"ok": "{{ true }}"}, routes=[RouteDef(to="$end")], ), @@ -559,9 +543,8 @@ async def test_events_for_failed_set(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="bind", - type="set", value="{{ does_not_exist }}", routes=[RouteDef(to="$end")], ), @@ -592,15 +575,13 @@ async def test_downstream_scalar_field_access_raises(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + SetStepDef( name="compute", - type="set", value="myorg/myrepo", routes=[RouteDef(to="consumer")], ), - AgentDef( + ScriptStepDef( name="consumer", - type="script", command=sys.executable, args=["-c", "import sys; print(sys.argv[1])", "{{ compute.output.field }}"], input=["compute.output.field"], @@ -628,15 +609,13 @@ async def test_set_counts_toward_max_iterations(self) -> None: limits=LimitsConfig(max_iterations=2), ), agents=[ - AgentDef( + SetStepDef( name="a", - type="set", value="x", routes=[RouteDef(to="b")], ), - AgentDef( + SetStepDef( name="b", - type="set", value="y", routes=[RouteDef(to="a")], ), diff --git a/tests/test_engine/test_step_def_dispatch.py b/tests/test_engine/test_step_def_dispatch.py new file mode 100644 index 00000000..df13b53c --- /dev/null +++ b/tests/test_engine/test_step_def_dispatch.py @@ -0,0 +1,170 @@ +"""Runtime defensive guards for the step dispatch (issue #517). + +The static validator rejects unsupported variants inside parallel and +for-each groups, but a directly-constructed engine never runs it — these +tests pin the engine-side guards so an unsupported variant fails with a +clear ``ExecutionError`` instead of an ``AttributeError`` deep in a +provider call. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from conductor.config.schema import ( + AgentDef, + ContextConfig, + ForEachDef, + LimitsConfig, + OutputField, + ParallelGroup, + RouteDef, + RuntimeConfig, + TerminateStepDef, + WorkflowConfig, + WorkflowDef, +) +from conductor.engine.workflow import WorkflowEngine +from conductor.exceptions import ExecutionError +from conductor.providers.base import AgentOutput + + +def _mock_provider() -> MagicMock: + provider = MagicMock() + provider.execute = AsyncMock( + return_value=AgentOutput(content={"result": "ok"}, raw_response={}, model="gpt-4") + ) + return provider + + +def _workflow_config(**overrides) -> WorkflowConfig: + return WorkflowConfig( + workflow=WorkflowDef( + name="defensive", + entry_point=overrides.pop("entry_point"), + runtime=RuntimeConfig(provider="copilot"), + context=ContextConfig(mode="accumulate"), + limits=LimitsConfig(max_iterations=10), + ), + **overrides, + ) + + +class TestParallelGroupGuards: + """Unsupported variants in a parallel group fail with a named error.""" + + @pytest.mark.asyncio + async def test_terminate_in_parallel_group_rejected(self) -> None: + # Requirement: a terminate step inside a parallel group raises + # ExecutionError naming the step, not an AttributeError on routes/provider. + config = _workflow_config( + entry_point="group", + agents=[ + TerminateStepDef(name="stop", status="success", reason="done"), + AgentDef( + name="worker", + model="gpt-4", + prompt="Work", + output={"result": OutputField(type="string")}, + routes=[RouteDef(to="$end")], + ), + ], + parallel=[ParallelGroup(name="group", agents=["stop", "worker"])], + ) + engine = WorkflowEngine(config, _mock_provider()) + + with pytest.raises(ExecutionError, match="cannot execute in a parallel group"): + await engine.run({}) + + +class TestForEachGuards: + """Unsupported inline for-each variants fail with a named error.""" + + @pytest.mark.asyncio + async def test_terminate_inline_agent_rejected(self) -> None: + # Requirement: a terminate inline agent raises ExecutionError naming + # the group, not an AttributeError on model_copy/provider access. + config = _workflow_config( + entry_point="seed", + agents=[ + AgentDef( + name="seed", + model="gpt-4", + prompt="Seed", + output={"items": OutputField(type="array")}, + routes=[RouteDef(to="loop")], + ), + TerminateStepDef(name="stop", status="success", reason="done"), + ], + for_each=[ + ForEachDef.model_validate( + { + "name": "loop", + "type": "for_each", + "source": "seed.output.items", + "as": "item", + "agent": { + "name": "stop", + "type": "terminate", + "status": "success", + "reason": "done", + }, + "routes": [{"to": "$end"}], + } + ) + ], + ) + provider = _mock_provider() + provider.execute = AsyncMock( + return_value=AgentOutput(content={"items": [1, 2]}, raw_response={}, model="gpt-4") + ) + engine = WorkflowEngine(config, provider) + + with pytest.raises(ExecutionError, match="cannot execute as the inline agent"): + await engine.run({}) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "inline_agent", + [ + {"name": "s", "type": "script", "command": "echo hi"}, + {"name": "w", "type": "wait", "duration": "1s"}, + {"name": "t", "type": "terminate", "status": "success", "reason": "done"}, + {"name": "q", "type": "questions", "questions": [{"id": "q1", "text": "Why?"}]}, + { + "name": "g", + "type": "human_gate", + "prompt": "Pick", + "options": [{"label": "Yes", "value": "yes", "route": "$end"}], + }, + ], + ids=["script", "wait", "terminate", "questions", "human_gate"], + ) + async def test_forbidden_inline_agent_rejected_even_with_empty_source( + self, inline_agent: dict + ) -> None: + # Requirement: the defensive guard fires before the empty-array early + # return — a forbidden inline variant must not pass silently just + # because the source resolved to zero items. + config = _workflow_config( + entry_point="loop", + agents=[], + for_each=[ + ForEachDef.model_validate( + { + "name": "loop", + "type": "for_each", + "source": "workflow.input.items", + "as": "item", + "agent": inline_agent, + "routes": [{"to": "$end"}], + } + ) + ], + ) + engine = WorkflowEngine(config, _mock_provider()) + + with pytest.raises(ExecutionError, match="cannot execute as the inline agent"): + await engine.run({"items": []}) diff --git a/tests/test_engine/test_subworkflow.py b/tests/test_engine/test_subworkflow.py index d84f55c7..af0fc742 100644 --- a/tests/test_engine/test_subworkflow.py +++ b/tests/test_engine/test_subworkflow.py @@ -29,6 +29,7 @@ RuntimeConfig, WorkflowConfig, WorkflowDef, + WorkflowStepDef, ) from conductor.engine.workflow import MAX_SUBWORKFLOW_DEPTH, WorkflowEngine from conductor.exceptions import ExecutionError @@ -87,9 +88,8 @@ async def test_subworkflow_runs_to_end(self, tmp_workflow_dir: Path) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="research", - type="workflow", workflow="sub.yaml", routes=[RouteDef(to="$end")], ), @@ -143,9 +143,8 @@ async def test_subworkflow_output_in_context(self, tmp_workflow_dir: Path) -> No limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="research", - type="workflow", workflow="sub.yaml", routes=[RouteDef(to="synthesizer")], ), @@ -215,9 +214,8 @@ async def test_depth_limit_exceeded(self, tmp_workflow_dir: Path) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="sub.yaml", routes=[RouteDef(to="$end")], ), @@ -254,9 +252,8 @@ async def test_subworkflow_file_not_found(self, tmp_workflow_dir: Path) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="nonexistent.yaml", routes=[RouteDef(to="$end")], ), @@ -303,9 +300,8 @@ async def test_self_referencing_workflow_hits_depth_limit(self, tmp_workflow_dir limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="parent.yaml", routes=[RouteDef(to="$end")], ), @@ -353,9 +349,8 @@ async def test_max_depth_per_agent(self, tmp_workflow_dir: Path) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="parent.yaml", max_depth=2, routes=[RouteDef(to="$end")], @@ -408,9 +403,8 @@ async def test_subworkflow_route_to_agent(self, tmp_workflow_dir: Path) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="sub.yaml", routes=[ RouteDef( @@ -486,9 +480,8 @@ async def test_mixed_agent_and_subworkflow(self, tmp_workflow_dir: Path) -> None prompt="Setup the work", routes=[RouteDef(to="sub_wf")], ), - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="sub.yaml", routes=[RouteDef(to="$end")], ), @@ -524,9 +517,8 @@ def test_dry_run_includes_workflow_type(self) -> None: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="./sub.yaml", routes=[RouteDef(to="$end")], ), @@ -580,9 +572,8 @@ async def test_subworkflow_counts_toward_iteration_limit(self, tmp_workflow_dir: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="sub.yaml", routes=[RouteDef(to="$end")], ), @@ -649,9 +640,8 @@ async def test_input_mapping_renders_expressions(self, tmp_workflow_dir: Path) - prompt="Setup", routes=[RouteDef(to="sub_wf")], ), - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="sub.yaml", input_mapping={ "item_id": "{{ setup.output.id }}", @@ -731,9 +721,8 @@ async def test_input_mapping_values_are_strings(self, tmp_workflow_dir: Path) -> prompt="Setup", routes=[RouteDef(to="sub_wf")], ), - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="sub.yaml", input_mapping={ "count": "{{ setup.output.num }}", @@ -803,9 +792,8 @@ async def test_no_input_mapping_forwards_parent_inputs(self, tmp_workflow_dir: P limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="sub.yaml", # No input_mapping — should forward parent's workflow.input.* routes=[RouteDef(to="$end")], @@ -864,9 +852,8 @@ async def test_empty_input_mapping_passes_nothing(self, tmp_workflow_dir: Path) limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="sub.yaml", input_mapping={}, # Explicitly empty — pass nothing routes=[RouteDef(to="$end")], @@ -924,9 +911,8 @@ async def test_input_mapping_error_includes_key_name(self, tmp_workflow_dir: Pat limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="sub.yaml", input_mapping={ "value": "{{ nonexistent_agent.output.missing }}", @@ -986,9 +972,8 @@ async def test_no_parent_context_leaks_to_child(self, tmp_workflow_dir: Path) -> prompt="Setup", routes=[RouteDef(to="sub_wf")], ), - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="sub.yaml", input_mapping={"data": "{{ setup.output.value }}"}, routes=[RouteDef(to="$end")], @@ -1074,9 +1059,8 @@ async def test_for_each_subworkflow_emits_distinct_slot_keys( source="finder.output.items", **{"as": "item"}, max_concurrent=1, - agent=AgentDef( + agent=WorkflowStepDef( name="runner", - type="workflow", workflow="sub.yaml", input_mapping={"item": "{{ item }}"}, ), @@ -1150,9 +1134,8 @@ async def test_sequential_subworkflow_emits_parent_path_and_slot_key( limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="research", - type="workflow", workflow="sub.yaml", routes=[RouteDef(to="$end")], ), @@ -1229,9 +1212,8 @@ async def test_subworkflow_failed_event_carries_parent_path_and_slot_key( limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="research", - type="workflow", workflow="sub.yaml", routes=[RouteDef(to="$end")], ), @@ -1316,9 +1298,8 @@ async def test_nested_subworkflow_path_accumulates(self, tmp_workflow_dir: Path) limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="mid", - type="workflow", workflow="mid.yaml", routes=[RouteDef(to="$end")], ), @@ -1425,9 +1406,8 @@ async def test_concurrent_for_each_subworkflow_emits_distinct_slot_keys( source="finder.output.items", **{"as": "item"}, max_concurrent=3, - agent=AgentDef( + agent=WorkflowStepDef( name="runner", - type="workflow", workflow="sub.yaml", input_mapping={"item": "{{ item }}"}, ), @@ -1523,9 +1503,8 @@ async def test_registry_ref_resolved_and_executed( limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="analysis@team-a#v1.0.0", routes=[RouteDef(to="$end")], ), @@ -1587,9 +1566,8 @@ async def test_registry_fetch_failure_raises_execution_error( limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="missing@unknown-registry#v1.0.0", routes=[RouteDef(to="$end")], ), @@ -1660,9 +1638,8 @@ async def test_local_file_takes_precedence_over_registry(self, tmp_workflow_dir: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="analysis", # extensionless — local file wins routes=[RouteDef(to="$end")], ), @@ -1702,9 +1679,8 @@ async def test_malformed_registry_ref_raises_execution_error( limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="a@b@c", # two '@' signs — malformed routes=[RouteDef(to="$end")], ), @@ -1780,9 +1756,8 @@ async def test_resume_re_resolves_registry_ref_to_same_path( prompt="plan", routes=[RouteDef(to="sub_wf")], ), - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="analysis@team-a#v1.0.0", routes=[RouteDef(to="$end")], ), @@ -1915,9 +1890,8 @@ async def test_adhoc_ref_resolved_and_executed( limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="analysis@myorg/workflows#v1.0.0", routes=[RouteDef(to="$end")], ), @@ -1968,9 +1942,8 @@ async def test_adhoc_fetch_failure_raises_execution_error(self, tmp_workflow_dir limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="missing@acme/tools#latest", routes=[RouteDef(to="$end")], ), @@ -2191,9 +2164,8 @@ async def test_child_success_terminate_returns_output(self, tmp_workflow_dir: Pa limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="research", - type="workflow", workflow="sub.yaml", routes=[RouteDef(to="$end")], ), @@ -2253,9 +2225,8 @@ async def test_child_failed_terminate_surfaces_as_execution_error( limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="research", - type="workflow", workflow="sub.yaml", routes=[RouteDef(to="$end")], ), @@ -2375,9 +2346,8 @@ async def test_child_failed_terminate_preserves_output_dict( limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="research", - type="workflow", workflow="sub.yaml", routes=[RouteDef(to="$end")], ), @@ -2476,9 +2446,8 @@ async def test_failed_terminate_in_for_each_workflow_iteration( "type": "for_each", "source": "finder.output.items", "as": "item", - "agent": AgentDef( + "agent": WorkflowStepDef( name="child", - type="workflow", workflow="sub.yaml", input_mapping={"item": "{{ item }}"}, ), @@ -2561,9 +2530,8 @@ async def test_subworkflow_no_working_dir_inheritance(self, tmp_workflow_dir: Pa limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="child_subdir/child.yaml", routes=[RouteDef(to="$end")], ), @@ -2624,9 +2592,8 @@ async def test_subworkflow_agent_includes_static_topology(self, tmp_workflow_dir limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="sub.yaml", routes=[RouteDef(to="$end")], ), @@ -2696,9 +2663,8 @@ async def test_nested_subworkflow_topology_recurses(self, tmp_workflow_dir: Path limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="child.yaml", routes=[RouteDef(to="$end")], ), @@ -2728,9 +2694,8 @@ async def test_missing_subworkflow_file_resolves_to_none(self, tmp_workflow_dir: limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="does-not-exist.yaml", routes=[RouteDef(to="$end")], ), @@ -2784,9 +2749,8 @@ async def test_self_referencing_subworkflow_resolves_to_none( limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="self.yaml", routes=[RouteDef(to="$end")], ), @@ -2860,9 +2824,8 @@ async def test_mutually_referencing_subworkflows_resolve_to_none( limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="a.yaml", routes=[RouteDef(to="$end")], ), @@ -2936,9 +2899,8 @@ async def test_eager_resolution_stops_at_max_subworkflow_depth( limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="level_0.yaml", routes=[RouteDef(to="$end")], ), @@ -3007,9 +2969,8 @@ async def test_eager_resolution_dedups_registry_fetch_with_real_execution( limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="sub_wf", - type="workflow", workflow="analysis@team-a#v1.0.0", routes=[RouteDef(to="$end")], ), @@ -3111,9 +3072,8 @@ async def test_nested_gate_inherits_bg_mode_and_skips_cli_prompt( limits=LimitsConfig(max_iterations=10), ), agents=[ - AgentDef( + WorkflowStepDef( name="nested", - type="workflow", workflow="sub.yaml", routes=[RouteDef(to="$end")], ), diff --git a/tests/test_engine/test_wait_workflow.py b/tests/test_engine/test_wait_workflow.py index 3bb2eac7..750a3dc8 100644 --- a/tests/test_engine/test_wait_workflow.py +++ b/tests/test_engine/test_wait_workflow.py @@ -25,6 +25,7 @@ LimitsConfig, RouteDef, RuntimeConfig, + WaitStepDef, WorkflowConfig, WorkflowDef, ) @@ -60,9 +61,8 @@ class TestWaitWorkflowLinear: async def test_wait_runs_to_end(self) -> None: config = _make_config( [ - AgentDef( + WaitStepDef( name="pause", - type="wait", duration="50ms", routes=[RouteDef(to="$end")], ), @@ -85,9 +85,8 @@ async def test_wait_output_only_has_waited_seconds(self) -> None: ``waited_seconds`` is exposed in workflow context.""" config = _make_config( [ - AgentDef( + WaitStepDef( name="pause", - type="wait", duration="20ms", reason="should not leak into context", routes=[RouteDef(to="$end")], @@ -109,9 +108,8 @@ async def test_workflow_timeout_cancels_wait(self) -> None: """A long wait must be cancelled by the workflow-level timeout.""" config = _make_config( [ - AgentDef( + WaitStepDef( name="long_pause", - type="wait", duration="60s", routes=[RouteDef(to="$end")], ), @@ -133,9 +131,8 @@ async def test_emits_wait_lifecycle(self) -> None: config = _make_config( [ - AgentDef( + WaitStepDef( name="pause", - type="wait", duration="20ms", reason="quick", routes=[RouteDef(to="$end")], @@ -194,9 +191,8 @@ async def test_emits_wait_failed_on_runtime_validation(self) -> None: }, ), agents=[ - AgentDef( + WaitStepDef( name="pause", - type="wait", duration="{{ workflow.input.hours }}h", routes=[RouteDef(to="$end")], ), @@ -233,9 +229,8 @@ async def test_templated_duration_from_workflow_input(self) -> None: }, ), agents=[ - AgentDef( + WaitStepDef( name="pause", - type="wait", duration="{{ workflow.input.interval_ms }}ms", routes=[RouteDef(to="$end")], ), @@ -266,9 +261,8 @@ async def test_interrupt_event_cuts_wait_short(self) -> None: interrupt_event = asyncio.Event() config = _make_config( [ - AgentDef( + WaitStepDef( name="long_pause", - type="wait", duration="30s", routes=[RouteDef(to="$end")], ), diff --git a/tests/test_engine/test_workflow.py b/tests/test_engine/test_workflow.py index ba90d782..c06858a2 100644 --- a/tests/test_engine/test_workflow.py +++ b/tests/test_engine/test_workflow.py @@ -20,12 +20,15 @@ ContextConfig, ForEachDef, GateOption, + HumanGateStepDef, InputDef, LimitsConfig, OutputField, ParallelGroup, RouteDef, RuntimeConfig, + ScriptStepDef, + TerminateStepDef, WorkflowConfig, WorkflowDef, ) @@ -356,9 +359,8 @@ async def test_explicit_mode_script_gets_workflow_inputs(self) -> None: context=ContextConfig(mode="explicit"), ), agents=[ - AgentDef( + ScriptStepDef( name="detector", - type="script", command=sys.executable, args=[ "-c", @@ -1181,9 +1183,8 @@ def human_gate_workflow_config(self) -> WorkflowConfig: output={"draft": OutputField(type="string")}, routes=[RouteDef(to="approval_gate")], ), - AgentDef( + HumanGateStepDef( name="approval_gate", - type="human_gate", prompt="Review the draft:\n\n{{ drafter.output.draft }}", options=[ GateOption( @@ -1275,9 +1276,8 @@ async def test_human_gate_with_end_route(self) -> None: entry_point="gate", ), agents=[ - AgentDef( + HumanGateStepDef( name="gate", - type="human_gate", prompt="Confirm action", options=[ GateOption( @@ -1346,9 +1346,8 @@ async def test_human_gate_stores_additional_input_nested_in_context(self) -> Non config = WorkflowConfig( workflow=WorkflowDef(name="gate-prompt-for", entry_point="ask_human"), agents=[ - AgentDef( + HumanGateStepDef( name="ask_human", - type="human_gate", prompt="Provide input:", options=[ GateOption( @@ -1424,9 +1423,8 @@ async def test_human_gate_prompt_for_named_selected_does_not_corrupt_selected( config = WorkflowConfig( workflow=WorkflowDef(name="gate-collision", entry_point="gate"), agents=[ - AgentDef( + HumanGateStepDef( name="gate", - type="human_gate", prompt="Go:", options=[ GateOption( @@ -1481,9 +1479,8 @@ async def test_human_gate_web_response_nests_additional_input(self) -> None: config = WorkflowConfig( workflow=WorkflowDef(name="gate-web", entry_point="approval_gate"), agents=[ - AgentDef( + HumanGateStepDef( name="approval_gate", - type="human_gate", prompt="Approve?", options=[ GateOption( @@ -1563,9 +1560,8 @@ async def test_human_gate_additional_input_readable_via_template(self) -> None: config = WorkflowConfig( workflow=WorkflowDef(name="gate-template-readthrough", entry_point="ask_human"), agents=[ - AgentDef( + HumanGateStepDef( name="ask_human", - type="human_gate", prompt="Provide input:", options=[ GateOption( @@ -1621,9 +1617,8 @@ async def test_human_gate_bg_mode_waits_web_only_and_skips_cli_prompt(self) -> N config = WorkflowConfig( workflow=WorkflowDef(name="gate-bg", entry_point="approval_gate"), agents=[ - AgentDef( + HumanGateStepDef( name="approval_gate", - type="human_gate", prompt="Approve?", options=[ GateOption(label="Approve", value="approved", route="next"), @@ -1679,9 +1674,8 @@ async def test_human_gate_foreground_tty_still_races_cli_and_web(self) -> None: config = WorkflowConfig( workflow=WorkflowDef(name="gate-tty", entry_point="approval_gate"), agents=[ - AgentDef( + HumanGateStepDef( name="approval_gate", - type="human_gate", prompt="Approve?", options=[ GateOption(label="Approve", value="approved", route="next"), @@ -1758,9 +1752,8 @@ async def test_human_gate_bg_mode_without_dashboard_raises_clear_error(self) -> config = WorkflowConfig( workflow=WorkflowDef(name="gate-bg-no-dashboard", entry_point="approval_gate"), agents=[ - AgentDef( + HumanGateStepDef( name="approval_gate", - type="human_gate", prompt="Approve?", options=[ GateOption(label="Approve", value="approved", route="$end"), @@ -3083,9 +3076,8 @@ def _config_with_terminate( output={"value": OutputField(type="string")}, routes=[RouteDef(to="finish")], ), - AgentDef( + TerminateStepDef( name="finish", - type="terminate", status=status, # type: ignore[arg-type] reason=reason, output_template=output_template, @@ -3276,9 +3268,8 @@ async def test_terminate_step_stored_in_context(self) -> None: output={"value": OutputField(type="string")}, routes=[RouteDef(to="finish")], ), - AgentDef( + TerminateStepDef( name="finish", - type="terminate", status="success", reason="all done", ), @@ -3336,9 +3327,8 @@ async def test_terminate_with_input_declared(self) -> None: output={"value": OutputField(type="string")}, routes=[RouteDef(to="finish")], ), - AgentDef( + TerminateStepDef( name="finish", - type="terminate", status="success", reason="ok", input=["upstream.output"], @@ -3384,9 +3374,8 @@ async def test_terminate_as_entry_point(self) -> None: limits=LimitsConfig(max_iterations=5), ), agents=[ - AgentDef( + TerminateStepDef( name="bye", - type="terminate", status="success", reason="nothing to do", output_template={"result": "no-op"}, @@ -3437,9 +3426,8 @@ async def test_terminate_routed_from_parallel_group(self) -> None: prompt="b", output={"y": OutputField(type="string")}, ), - AgentDef( + TerminateStepDef( name="finish", - type="terminate", status="success", reason="parallel branches done", output_template={"result": "from-parallel"}, @@ -3496,9 +3484,8 @@ async def test_terminate_routed_from_for_each_group(self) -> None: output={"items": OutputField(type="array")}, routes=[RouteDef(to="loop")], ), - AgentDef( + TerminateStepDef( name="finish", - type="terminate", status="success", reason="for_each done", output_template={"result": "from-for-each"}, @@ -3555,7 +3542,7 @@ async def test_lifecycle_event_ordering_failed_terminate(self) -> None: limits=LimitsConfig(max_iterations=5), ), agents=[ - AgentDef(name="abort", type="terminate", status="failed", reason="halt"), + TerminateStepDef(name="abort", status="failed", reason="halt"), ], output={}, ) diff --git a/tests/test_executor/test_mcp_step.py b/tests/test_executor/test_mcp_step.py index 326e7513..17222223 100644 --- a/tests/test_executor/test_mcp_step.py +++ b/tests/test_executor/test_mcp_step.py @@ -21,7 +21,7 @@ import pytest -from conductor.config.schema import AgentDef +from conductor.config.schema import MCPStepDef from conductor.exceptions import ExecutionError from conductor.executor.mcp_step import McpStepExecutor, mcp_result_bytes from conductor.file_string import FileString @@ -62,11 +62,11 @@ def executor() -> McpStepExecutor: return McpStepExecutor() -def make_agent(**overrides: Any) -> AgentDef: +def make_agent(**overrides: Any) -> MCPStepDef: """Build an mcp AgentDef with sensible defaults, overridden per test.""" kwargs: dict[str, Any] = {"name": "lookup", "type": "mcp", "server": "srv", "tool": "ping"} kwargs.update(overrides) - return AgentDef(**kwargs) + return MCPStepDef(**kwargs) class TestArgumentRendering: diff --git a/tests/test_executor/test_questions.py b/tests/test_executor/test_questions.py index 5b51e78f..7ba0dc0d 100644 --- a/tests/test_executor/test_questions.py +++ b/tests/test_executor/test_questions.py @@ -4,7 +4,7 @@ import pytest -from conductor.config.schema import AgentDef, QuestionDef +from conductor.config.schema import AgentDef, QuestionDef, QuestionsStepDef from conductor.exceptions import ExecutionError from conductor.executor.questions import ( FREE_TEXT, @@ -24,7 +24,7 @@ def _node(**kwargs) -> AgentDef: """Build a minimal questions node.""" kwargs.setdefault("questions", [QuestionDef(text="Why?")]) - return AgentDef(name="ask", type="questions", **kwargs) + return QuestionsStepDef(name="ask", **kwargs) class TestCoerceQuestions: diff --git a/tests/test_executor/test_script.py b/tests/test_executor/test_script.py index 0b16ea10..c30c5c07 100644 --- a/tests/test_executor/test_script.py +++ b/tests/test_executor/test_script.py @@ -23,7 +23,7 @@ import pytest -from conductor.config.schema import AgentDef +from conductor.config.schema import ScriptStepDef from conductor.exceptions import ExecutionError, TemplateError from conductor.executor.script import ScriptExecutor, ScriptOutput @@ -51,9 +51,8 @@ class TestScriptExecutorBasic: @pytest.mark.asyncio async def test_simple_echo(self, executor: ScriptExecutor) -> None: """Test simple command captures stdout.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_echo", - type="script", command=sys.executable, args=["-c", "print('hello')"], ) @@ -64,9 +63,8 @@ async def test_simple_echo(self, executor: ScriptExecutor) -> None: @pytest.mark.asyncio async def test_command_with_multiple_args(self, executor: ScriptExecutor) -> None: """Test command with multiple arguments.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_printf", - type="script", command=sys.executable, args=[ "-c", @@ -82,9 +80,8 @@ async def test_command_with_multiple_args(self, executor: ScriptExecutor) -> Non @pytest.mark.asyncio async def test_failing_command_exit_code(self, executor: ScriptExecutor) -> None: """Test that non-zero exit code is captured correctly (not 0).""" - agent = AgentDef( + agent = ScriptStepDef( name="test_false", - type="script", command=sys.executable, args=["-c", "import sys; sys.exit(1)"], ) @@ -95,9 +92,8 @@ async def test_failing_command_exit_code(self, executor: ScriptExecutor) -> None @pytest.mark.asyncio async def test_stderr_captured(self, executor: ScriptExecutor) -> None: """Test that stderr is captured separately from stdout.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_stderr", - type="script", command=sys.executable, args=["-c", "import sys; print('out'); print('err', file=sys.stderr)"], ) @@ -112,9 +108,8 @@ class TestScriptExecutorTimeout: @pytest.mark.asyncio async def test_timeout_kills_process(self, executor: ScriptExecutor) -> None: """Test that timeout kills process and raises ExecutionError.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_timeout", - type="script", command=sys.executable, args=["-c", "import time; time.sleep(10)"], timeout=1, @@ -125,9 +120,8 @@ async def test_timeout_kills_process(self, executor: ScriptExecutor) -> None: @pytest.mark.asyncio async def test_no_timeout_default(self, executor: ScriptExecutor) -> None: """Test that no timeout allows command to complete.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_quick", - type="script", command=sys.executable, args=["-c", "print('fast')"], ) @@ -141,9 +135,8 @@ class TestScriptExecutorEnvironment: @pytest.mark.asyncio async def test_custom_env_passed(self, executor: ScriptExecutor) -> None: """Test that custom environment variables are passed to subprocess.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_env", - type="script", command=sys.executable, args=["-c", "import os; print(os.environ['MY_TEST_VAR'])"], env={"MY_TEST_VAR": "custom_value"}, @@ -154,9 +147,8 @@ async def test_custom_env_passed(self, executor: ScriptExecutor) -> None: @pytest.mark.asyncio async def test_env_merges_with_os_environ(self, executor: ScriptExecutor) -> None: """Test that agent env merges with process environment.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_env_merge", - type="script", command=sys.executable, args=["-c", "import os; print(os.environ.get('PATH', ''))"], env={"MY_EXTRA": "val"}, @@ -173,9 +165,8 @@ async def test_env_values_not_jinja2_rendered(self, executor: ScriptExecutor) -> YAML loader's ${VAR:-default} pass, not by the Jinja2 template engine. Jinja2 syntax in env values is treated as a literal string. """ - agent = AgentDef( + agent = ScriptStepDef( name="test_env_no_render", - type="script", command=sys.executable, args=["-c", "import os; print(os.environ['MY_VAR'])"], env={"MY_VAR": "{{ literal_braces }}"}, @@ -192,9 +183,8 @@ class TestScriptExecutorWorkingDir: async def test_working_dir_respected(self, executor: ScriptExecutor) -> None: """Test that working_dir is used by subprocess.""" with tempfile.TemporaryDirectory() as tmpdir: - agent = AgentDef( + agent = ScriptStepDef( name="test_cwd", - type="script", command=sys.executable, args=["-c", "import os; print(os.getcwd())"], working_dir=tmpdir, @@ -207,9 +197,8 @@ async def test_working_dir_respected(self, executor: ScriptExecutor) -> None: async def test_working_dir_with_jinja2_template(self, executor: ScriptExecutor) -> None: """Test that working_dir supports Jinja2 template rendering.""" with tempfile.TemporaryDirectory() as tmpdir: - agent = AgentDef( + agent = ScriptStepDef( name="test_cwd_tpl", - type="script", command=sys.executable, args=["-c", "import os; print(os.getcwd())"], working_dir="{{ target_dir }}", @@ -224,9 +213,8 @@ class TestScriptExecutorTemplating: @pytest.mark.asyncio async def test_template_in_command(self, executor: ScriptExecutor) -> None: """Test Jinja2 template rendering in command field.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_cmd_tpl", - type="script", command="{{ cmd }}", args=["-c", "print('ok')"], ) @@ -236,9 +224,8 @@ async def test_template_in_command(self, executor: ScriptExecutor) -> None: @pytest.mark.asyncio async def test_template_in_args(self, executor: ScriptExecutor) -> None: """Test Jinja2 template rendering in args.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_args_tpl", - type="script", command=sys.executable, args=["-c", "print('{{ greeting }}')"], ) @@ -248,9 +235,8 @@ async def test_template_in_args(self, executor: ScriptExecutor) -> None: @pytest.mark.asyncio async def test_template_with_workflow_context(self, executor: ScriptExecutor) -> None: """Test template rendering with nested workflow context.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_ctx_tpl", - type="script", command=sys.executable, args=["-c", "print('{{ workflow.input.message }}')"], ) @@ -265,9 +251,8 @@ class TestScriptExecutorErrors: @pytest.mark.asyncio async def test_command_not_found(self, executor: ScriptExecutor) -> None: """Test that command not found raises ExecutionError.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_notfound", - type="script", command="definitely_not_a_real_command_xyz123", ) with pytest.raises(ExecutionError, match="command not found"): @@ -276,9 +261,8 @@ async def test_command_not_found(self, executor: ScriptExecutor) -> None: @pytest.mark.asyncio async def test_specific_exit_code(self, executor: ScriptExecutor) -> None: """Test that specific exit codes are captured correctly.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_exit42", - type="script", command=sys.executable, args=["-c", "import sys; sys.exit(42)"], ) @@ -299,9 +283,8 @@ class TestScriptExecutorCommandResolution: @pytest.mark.asyncio async def test_bare_name_resolved_via_which(self, executor: ScriptExecutor) -> None: """A bare command name is resolved to the executable ``which`` finds.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_bare", - type="script", command="python", args=["-c", "print('hello')"], ) @@ -325,9 +308,8 @@ async def test_bare_name_resolved_via_which(self, executor: ScriptExecutor) -> N @pytest.mark.asyncio async def test_absolute_path_resolved_via_which(self, executor: ScriptExecutor) -> None: """An absolute path (incl. forward slashes) is resolved via ``which``.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_abs", - type="script", command="C:/Python314/python", args=["-c", "print('hello')"], ) @@ -352,9 +334,8 @@ async def test_absolute_path_resolved_via_which(self, executor: ScriptExecutor) @pytest.mark.asyncio async def test_which_none_falls_back_to_rendered(self, executor: ScriptExecutor) -> None: """When ``which`` cannot resolve, the rendered command is used as-is.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_fallback", - type="script", command="python", args=["-c", "print('hello')"], ) @@ -375,9 +356,8 @@ async def test_relative_path_with_separator_not_resolved( self, executor: ScriptExecutor ) -> None: """A relative path with a separator is left untouched (working_dir semantics).""" - agent = AgentDef( + agent = ScriptStepDef( name="test_relative", - type="script", command="./scripts/run.sh", args=[], ) @@ -398,9 +378,8 @@ async def test_relative_path_with_separator_not_resolved( @pytest.mark.asyncio async def test_args_not_resolved(self, executor: ScriptExecutor) -> None: """Args are never passed through ``which`` (may contain URLs or flags with /).""" - agent = AgentDef( + agent = ScriptStepDef( name="test_args_preserve", - type="script", command="python", args=["-c", "print('hello')", "https://example.com/api/v1"], ) @@ -429,9 +408,8 @@ async def test_command_resolved_against_agent_env_path(self, executor: ScriptExe ``os.environ["PATH"]`` before ``env`` was built, so an ``agent.env`` PATH override silently ran a different binary than the subprocess would use. """ - agent = AgentDef( + agent = ScriptStepDef( name="test_env_path", - type="script", command="toolx", env={"PATH": "/childbin"}, ) @@ -455,9 +433,8 @@ async def test_command_resolved_against_agent_env_path(self, executor: ScriptExe @pytest.mark.asyncio async def test_file_not_found_includes_hint_on_windows(self, executor: ScriptExecutor) -> None: """FileNotFoundError on Windows includes a path-resolution hint.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_hint", - type="script", command="C:/nonexistent/python.exe", ) with ( @@ -480,9 +457,8 @@ async def test_file_not_found_includes_hint_on_windows(self, executor: ScriptExe @pytest.mark.asyncio async def test_file_not_found_no_hint_on_linux(self, executor: ScriptExecutor) -> None: """FileNotFoundError on Linux does not include the Windows hint.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_no_hint", - type="script", command="/usr/local/bin/nonexistent", ) with ( @@ -512,9 +488,8 @@ class TestScriptExecutorStdin: @pytest.mark.asyncio async def test_stdin_round_trip(self, executor: ScriptExecutor) -> None: """A rendered stdin payload is delivered verbatim to the child.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_stdin", - type="script", command=sys.executable, args=["-c", _ECHO_STDIN], stdin="hello from stdin", @@ -527,9 +502,8 @@ async def test_stdin_round_trip(self, executor: ScriptExecutor) -> None: @pytest.mark.asyncio async def test_stdin_jinja2_rendered_from_context(self, executor: ScriptExecutor) -> None: """The stdin field is a Jinja2 template rendered against the context.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_stdin_tpl", - type="script", command=sys.executable, args=["-c", _ECHO_STDIN], stdin="{{ workflow.input.message }}", @@ -541,9 +515,8 @@ async def test_stdin_jinja2_rendered_from_context(self, executor: ScriptExecutor @pytest.mark.asyncio async def test_stdin_json_via_tojson_filter(self, executor: ScriptExecutor) -> None: """Structured data is handed off as valid JSON via the ``tojson`` filter.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_stdin_json", - type="script", command=sys.executable, args=[ "-c", @@ -566,9 +539,8 @@ async def test_stdin_large_payload_bypasses_arg_limits(self, executor: ScriptExe is delivered intact. This is the core cross-platform fix for issue #18. """ payload = "x" * (2 * 1024 * 1024) # 2 MB, well beyond ARG_MAX - agent = AgentDef( + agent = ScriptStepDef( name="test_stdin_large", - type="script", command=sys.executable, args=["-c", _LEN_STDIN], stdin="{{ blob }}", @@ -581,9 +553,8 @@ async def test_stdin_large_payload_bypasses_arg_limits(self, executor: ScriptExe @pytest.mark.asyncio async def test_stdin_empty_string_pipes_immediate_eof(self, executor: ScriptExecutor) -> None: """An explicit empty string still pipes (sends immediate EOF), unlike omission.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_stdin_empty", - type="script", command=sys.executable, args=["-c", _LEN_STDIN], stdin="", @@ -597,9 +568,8 @@ async def test_stdin_empty_string_pipes_immediate_eof(self, executor: ScriptExec @pytest.mark.asyncio async def test_stdin_omitted_is_backwards_compatible(self, executor: ScriptExecutor) -> None: """Omitting stdin keeps legacy behavior: nothing piped, stdin_bytes is None.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_stdin_omitted", - type="script", command=sys.executable, args=["-c", "print('no stdin')"], ) @@ -611,9 +581,8 @@ async def test_stdin_omitted_is_backwards_compatible(self, executor: ScriptExecu @pytest.mark.asyncio async def test_stdin_coexists_with_args(self, executor: ScriptExecutor) -> None: """stdin and args are orthogonal — both reach the child when set.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_stdin_args", - type="script", command=sys.executable, args=[ "-c", @@ -632,9 +601,8 @@ async def test_stdin_coexists_with_args(self, executor: ScriptExecutor) -> None: async def test_stdin_utf8_payload_byte_count(self, executor: ScriptExecutor) -> None: """Non-ASCII payloads are UTF-8 encoded; stdin_bytes counts bytes, not chars.""" payload = "café ☕ 日本語" - agent = AgentDef( + agent = ScriptStepDef( name="test_stdin_utf8", - type="script", command=sys.executable, args=["-c", _ECHO_STDIN], stdin="{{ msg }}", @@ -656,9 +624,8 @@ async def test_stdin_large_bidirectional_no_deadlock(self, executor: ScriptExecu deadlock regression fails fast instead of hanging CI. """ payload = "m" * (4 * 1024 * 1024) # 4 MB each direction - agent = AgentDef( + agent = ScriptStepDef( name="test_stdin_bidi", - type="script", command=sys.executable, args=["-c", _ECHO_STDIN], stdin="{{ blob }}", @@ -675,9 +642,8 @@ async def test_stdin_child_exits_without_reading(self, executor: ScriptExecutor) ``communicate`` lets asyncio absorb the resulting BrokenPipeError, so the step completes with the child's real exit code rather than crashing. """ - agent = AgentDef( + agent = ScriptStepDef( name="test_stdin_early_exit", - type="script", command=sys.executable, args=["-c", "import sys; sys.exit(3)"], # never reads stdin stdin="y" * (2 * 1024 * 1024), @@ -690,9 +656,8 @@ async def test_stdin_child_exits_without_reading(self, executor: ScriptExecutor) @pytest.mark.asyncio async def test_stdin_timeout_while_writing(self, executor: ScriptExecutor) -> None: """Timeout fires cleanly even while a large stdin payload is mid-write.""" - agent = AgentDef( + agent = ScriptStepDef( name="test_stdin_timeout", - type="script", command=sys.executable, args=["-c", "import time; time.sleep(30)"], # sleeps, never drains stdin stdin="q" * (4 * 1024 * 1024), @@ -711,9 +676,8 @@ async def test_stdin_invalid_utf8_raises_execution_error( ``"\\ud800"``). The strict ``.encode`` must surface a named error, not a bare UnicodeEncodeError. """ - agent = AgentDef( + agent = ScriptStepDef( name="test_stdin_bad_utf8", - type="script", command=sys.executable, args=["-c", _ECHO_STDIN], stdin="{{ bad }}", @@ -726,9 +690,8 @@ async def test_stdin_render_failure_raises_template_error( self, executor: ScriptExecutor ) -> None: """An undefined variable in stdin fails like command/args (TemplateError).""" - agent = AgentDef( + agent = ScriptStepDef( name="test_stdin_bad_template", - type="script", command=sys.executable, args=["-c", _ECHO_STDIN], stdin="{{ does_not_exist }}", diff --git a/tests/test_executor/test_set_step.py b/tests/test_executor/test_set_step.py index 1b8b0368..a0806941 100644 --- a/tests/test_executor/test_set_step.py +++ b/tests/test_executor/test_set_step.py @@ -15,7 +15,7 @@ import pytest -from conductor.config.schema import AgentDef +from conductor.config.schema import SetStepDef from conductor.exceptions import ExecutionError, TemplateError from conductor.executor.set_step import ( SET_VALUE_REPR_MAX, @@ -35,67 +35,66 @@ class TestSetExecutorSingleValue: """Single ``value:`` step coverage.""" def test_string_auto(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="{{ a }}/{{ b }}") + agent = SetStepDef(name="x", value="{{ a }}/{{ b }}") out = executor.execute(agent, {"a": "myorg", "b": "myrepo"}) assert out.value == "myorg/myrepo" assert out.is_multi is False assert out.output_type == "auto" def test_integer_auto(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="{{ n + 1 }}") + agent = SetStepDef(name="x", value="{{ n + 1 }}") out = executor.execute(agent, {"n": 41}) assert out.value == 42 assert isinstance(out.value, int) def test_float_auto(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="{{ 3.14 }}") + agent = SetStepDef(name="x", value="{{ 3.14 }}") out = executor.execute(agent, {}) assert out.value == 3.14 def test_boolean_auto(self, executor: SetExecutor) -> None: - agent = AgentDef( + agent = SetStepDef( name="x", - type="set", value="{{ severity in ['high', 'critical'] }}", ) out = executor.execute(agent, {"severity": "high"}) assert out.value is True def test_list_auto(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="{{ [1, 2, 3] }}") + agent = SetStepDef(name="x", value="{{ [1, 2, 3] }}") out = executor.execute(agent, {}) assert out.value == [1, 2, 3] def test_dict_auto(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="{{ {'a': 1} }}") + agent = SetStepDef(name="x", value="{{ {'a': 1} }}") out = executor.execute(agent, {}) assert out.value == {"a": 1} def test_empty_string_becomes_empty_string_not_none(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="") + agent = SetStepDef(name="x", value="") out = executor.execute(agent, {}) assert out.value == "" def test_whitespace_only_becomes_empty_string(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value=" \n ") + agent = SetStepDef(name="x", value=" \n ") out = executor.execute(agent, {}) assert out.value == "" def test_explicit_null_keyword_returns_none(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="null") + agent = SetStepDef(name="x", value="null") out = executor.execute(agent, {}) assert out.value is None def test_empty_render_via_template_returns_empty_string(self, executor: SetExecutor) -> None: """Template rendering to empty hits the not-stripped short-circuit.""" - agent = AgentDef(name="x", type="set", value="{{ '' }}") + agent = SetStepDef(name="x", value="{{ '' }}") out = executor.execute(agent, {}) assert out.value == "" def test_date_like_render_normalised_to_iso(self, executor: SetExecutor) -> None: """End-to-end auto detection: YAML date → ISO 8601 string after ``_to_json_safe`` runs.""" - agent = AgentDef(name="x", type="set", value="2024-01-02") + agent = SetStepDef(name="x", value="2024-01-02") out = executor.execute(agent, {}) assert out.value == "2024-01-02" assert isinstance(out.value, str) @@ -105,9 +104,8 @@ class TestSetExecutorMultiValues: """Multi ``values:`` step coverage.""" def test_multi_binds_each_key(self, executor: SetExecutor) -> None: - agent = AgentDef( + agent = SetStepDef( name="d", - type="set", values={ "is_breaking": "{{ severity in ['high', 'critical'] }}", "target_branch": "{{ branch or 'main' }}", @@ -127,9 +125,8 @@ def test_multi_does_not_see_earlier_bindings(self, executor: SetExecutor) -> Non bindings within the same step. The second binding reads ``a`` from the *original* context (not the rendered ``first`` produced earlier in the same step).""" - agent = AgentDef( + agent = SetStepDef( name="d", - type="set", values={ "first": "{{ a }}-modified", "second": "{{ a }}", @@ -142,9 +139,8 @@ def test_multi_does_not_see_earlier_bindings(self, executor: SetExecutor) -> Non def test_date_like_render_in_multi_normalised_to_iso(self, executor: SetExecutor) -> None: """Per-binding ``_to_json_safe`` also runs for multi-values steps.""" - agent = AgentDef( + agent = SetStepDef( name="x", - type="set", values={"d": "2024-01-02", "t": "12:30:45"}, ) out = executor.execute(agent, {}) @@ -157,33 +153,33 @@ class TestSetExecutorExplicitOutputType: """Explicit ``output_type:`` overrides on single ``value:`` only.""" def test_string_keeps_raw(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="1.2.3", output_type="string") + agent = SetStepDef(name="x", value="1.2.3", output_type="string") out = executor.execute(agent, {}) assert out.value == "1.2.3" assert isinstance(out.value, str) def test_integer_success(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="42", output_type="integer") + agent = SetStepDef(name="x", value="42", output_type="integer") out = executor.execute(agent, {}) assert out.value == 42 def test_integer_failure(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="not-a-number", output_type="integer") + agent = SetStepDef(name="x", value="not-a-number", output_type="integer") with pytest.raises(ExecutionError, match="to integer"): executor.execute(agent, {}) def test_number_int(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="42", output_type="number") + agent = SetStepDef(name="x", value="42", output_type="number") out = executor.execute(agent, {}) assert out.value == 42 def test_number_float(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="3.14", output_type="number") + agent = SetStepDef(name="x", value="3.14", output_type="number") out = executor.execute(agent, {}) assert out.value == 3.14 def test_number_failure(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="not-a-number", output_type="number") + agent = SetStepDef(name="x", value="not-a-number", output_type="number") with pytest.raises(ExecutionError, match="to number"): executor.execute(agent, {}) @@ -206,32 +202,32 @@ def test_number_failure(self, executor: SetExecutor) -> None: ], ) def test_boolean_success(self, executor: SetExecutor, text: str, expected: bool) -> None: - agent = AgentDef(name="x", type="set", value=text, output_type="boolean") + agent = SetStepDef(name="x", value=text, output_type="boolean") out = executor.execute(agent, {}) assert out.value is expected def test_boolean_failure(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="maybe", output_type="boolean") + agent = SetStepDef(name="x", value="maybe", output_type="boolean") with pytest.raises(ExecutionError, match="to boolean"): executor.execute(agent, {}) def test_list_success(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="[1, 2, 3]", output_type="list") + agent = SetStepDef(name="x", value="[1, 2, 3]", output_type="list") out = executor.execute(agent, {}) assert out.value == [1, 2, 3] def test_list_failure_on_dict(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="{a: 1}", output_type="list") + agent = SetStepDef(name="x", value="{a: 1}", output_type="list") with pytest.raises(ExecutionError, match="output_type: list"): executor.execute(agent, {}) def test_dict_success(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="{a: 1, b: 2}", output_type="dict") + agent = SetStepDef(name="x", value="{a: 1, b: 2}", output_type="dict") out = executor.execute(agent, {}) assert out.value == {"a": 1, "b": 2} def test_dict_failure_on_scalar(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="42", output_type="dict") + agent = SetStepDef(name="x", value="42", output_type="dict") with pytest.raises(ExecutionError, match="output_type: dict"): executor.execute(agent, {}) @@ -240,12 +236,12 @@ class TestSetExecutorTemplateErrors: """Template rendering failures propagate as TemplateError.""" def test_undefined_variable_raises(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", value="{{ does_not_exist }}") + agent = SetStepDef(name="x", value="{{ does_not_exist }}") with pytest.raises(TemplateError): executor.execute(agent, {}) def test_undefined_in_multi_raises(self, executor: SetExecutor) -> None: - agent = AgentDef(name="x", type="set", values={"a": "{{ ok }}", "b": "{{ missing }}"}) + agent = SetStepDef(name="x", values={"a": "{{ ok }}", "b": "{{ missing }}"}) with pytest.raises(TemplateError): executor.execute(agent, {"ok": "hi"}) diff --git a/tests/test_executor/test_wait.py b/tests/test_executor/test_wait.py index 952d2951..fe0e39f9 100644 --- a/tests/test_executor/test_wait.py +++ b/tests/test_executor/test_wait.py @@ -14,7 +14,7 @@ import pytest -from conductor.config.schema import AgentDef +from conductor.config.schema import WaitStepDef from conductor.exceptions import ValidationError from conductor.executor.wait import WaitExecutor, WaitOutput @@ -34,7 +34,7 @@ def test_fields(self) -> None: class TestWaitExecutorBasic: @pytest.mark.asyncio async def test_short_sleep(self, executor: WaitExecutor) -> None: - agent = AgentDef(name="w", type="wait", duration="100ms") + agent = WaitStepDef(name="w", duration="100ms") out = await executor.execute(agent, {}) # Don't assert a tight lower bound — event-loop scheduling jitter # on loaded CI can make the monotonic elapsed slightly under the @@ -48,7 +48,7 @@ async def test_short_sleep(self, executor: WaitExecutor) -> None: @pytest.mark.asyncio async def test_numeric_duration(self, executor: WaitExecutor) -> None: - agent = AgentDef(name="w", type="wait", duration=0.05) + agent = WaitStepDef(name="w", duration=0.05) out = await executor.execute(agent, {}) assert out.requested_seconds == 0.05 assert out.waited_seconds < 1.0 @@ -56,15 +56,14 @@ async def test_numeric_duration(self, executor: WaitExecutor) -> None: @pytest.mark.asyncio async def test_reason_rendered(self, executor: WaitExecutor) -> None: - agent = AgentDef(name="w", type="wait", duration="50ms", reason="hi {{ name }}") + agent = WaitStepDef(name="w", duration="50ms", reason="hi {{ name }}") out = await executor.execute(agent, {"name": "there"}) assert out.reason == "hi there" @pytest.mark.asyncio async def test_templated_duration(self, executor: WaitExecutor) -> None: - agent = AgentDef( + agent = WaitStepDef( name="w", - type="wait", duration="{{ workflow.input.interval }}ms", ) out = await executor.execute(agent, {"workflow": {"input": {"interval": 50}}}) @@ -74,7 +73,7 @@ async def test_templated_duration(self, executor: WaitExecutor) -> None: class TestWaitExecutorInterrupt: @pytest.mark.asyncio async def test_interrupt_cancels_early(self, executor: WaitExecutor) -> None: - agent = AgentDef(name="w", type="wait", duration="10s") + agent = WaitStepDef(name="w", duration="10s") ev = asyncio.Event() task = asyncio.create_task(executor.execute(agent, {}, interrupt_event=ev)) await asyncio.sleep(0.05) @@ -88,7 +87,7 @@ async def test_interrupt_cancels_early(self, executor: WaitExecutor) -> None: @pytest.mark.asyncio async def test_no_interrupt_runs_to_completion(self, executor: WaitExecutor) -> None: - agent = AgentDef(name="w", type="wait", duration="50ms") + agent = WaitStepDef(name="w", duration="50ms") ev = asyncio.Event() out = await executor.execute(agent, {}, interrupt_event=ev) assert out.interrupted is False @@ -96,7 +95,7 @@ async def test_no_interrupt_runs_to_completion(self, executor: WaitExecutor) -> @pytest.mark.asyncio async def test_outer_cancellation_propagates(self, executor: WaitExecutor) -> None: - agent = AgentDef(name="w", type="wait", duration="10s") + agent = WaitStepDef(name="w", duration="10s") ev = asyncio.Event() task = asyncio.create_task(executor.execute(agent, {}, interrupt_event=ev)) await asyncio.sleep(0.05) @@ -112,15 +111,14 @@ class TestWaitExecutorRuntimeValidation: async def test_unparseable_duration(self, executor: WaitExecutor) -> None: # Bypass the schema by constructing via model_construct (skips # validation), then trip the runtime parser. - agent = AgentDef.model_construct(name="w", type="wait", duration="forever") + agent = WaitStepDef.model_construct(name="w", type="wait", duration="forever") with pytest.raises(ValidationError, match="Wait 'w'"): await executor.execute(agent, {}) @pytest.mark.asyncio async def test_zero_duration_via_template(self, executor: WaitExecutor) -> None: - agent = AgentDef( + agent = WaitStepDef( name="w", - type="wait", duration="{{ workflow.input.interval }}s", ) with pytest.raises(ValidationError, match="must be > 0"): @@ -128,9 +126,8 @@ async def test_zero_duration_via_template(self, executor: WaitExecutor) -> None: @pytest.mark.asyncio async def test_over_cap_via_template(self, executor: WaitExecutor) -> None: - agent = AgentDef( + agent = WaitStepDef( name="w", - type="wait", duration="{{ workflow.input.interval }}h", ) with pytest.raises(ValidationError, match="24h cap"): diff --git a/tests/test_gates/test_human.py b/tests/test_gates/test_human.py index baf7d968..c70f76f2 100644 --- a/tests/test_gates/test_human.py +++ b/tests/test_gates/test_human.py @@ -7,7 +7,7 @@ import pytest -from conductor.config.schema import AgentDef, GateOption +from conductor.config.schema import AgentDef, GateOption, HumanGateStepDef from conductor.exceptions import HumanGateError from conductor.gates.human import ( DIALOG_SUBMIT_SENTINEL, @@ -67,9 +67,8 @@ def sample_options_with_prompt_for() -> list[GateOption]: @pytest.fixture def human_gate_agent(sample_options: list[GateOption]) -> AgentDef: """Create a sample human_gate agent.""" - return AgentDef( + return HumanGateStepDef( name="approval_gate", - type="human_gate", prompt="Please review the following content:\n\n{{ agent1.output }}", options=sample_options, ) @@ -80,9 +79,8 @@ def human_gate_agent_with_prompt_for( sample_options_with_prompt_for: list[GateOption], ) -> AgentDef: """Create a sample human_gate agent with prompt_for option.""" - return AgentDef( + return HumanGateStepDef( name="feedback_gate", - type="human_gate", prompt="Please provide your feedback:", options=sample_options_with_prompt_for, ) @@ -535,9 +533,8 @@ async def test_prompt_wrapped_in_rich_markdown( """Verify Panel receives a RichMarkdown object, not a bare string.""" from rich.markdown import Markdown as RichMarkdown - agent = AgentDef( + agent = HumanGateStepDef( name="md_gate", - type="human_gate", prompt="## Review\n\n- [plan](./plan.md)\n- **bold** text", options=sample_options, ) @@ -564,9 +561,8 @@ async def test_skip_gates_auto_selects_without_panel( sample_options: list[GateOption], ) -> None: """Verify that skip_gates mode auto-selects without displaying the Panel.""" - agent = AgentDef( + agent = HumanGateStepDef( name="skip_md_gate", - type="human_gate", prompt="# Auto-review\nPlain text here.", options=sample_options, ) @@ -589,9 +585,8 @@ class TestMultilineAdditionalInput: @pytest.fixture def multiline_agent(self) -> AgentDef: """A gate whose only option collects multi-line feedback.""" - return AgentDef( + return HumanGateStepDef( name="review_gate", - type="human_gate", prompt="Review it", options=[ GateOption( diff --git a/tests/test_integration/test_examples.py b/tests/test_integration/test_examples.py index a6adcd16..b4ae69da 100644 --- a/tests/test_integration/test_examples.py +++ b/tests/test_integration/test_examples.py @@ -326,8 +326,10 @@ def test_passes_full_validation(self) -> None: assert config.workflow.runtime.provider.name == "claude-agent-sdk" def test_the_loop_back_and_the_hand_off_share_one_key(self) -> None: + from conductor.config.schema import AgentDef + config = load_config(self._workflow_file) - keys = {a.name: a.session_key for a in config.agents} + keys = {a.name: a.session_key if isinstance(a, AgentDef) else None for a in config.agents} assert keys["investigate"] == keys["summarize"] == "investigation" # A script step has no provider session; the schema rejects a key there. diff --git a/tests/test_integration/test_workflows.py b/tests/test_integration/test_workflows.py index eced19b0..d04fc9cf 100644 --- a/tests/test_integration/test_workflows.py +++ b/tests/test_integration/test_workflows.py @@ -293,6 +293,7 @@ def test_human_gate_with_skip_gates(self) -> None: from conductor.config.schema import ( AgentDef, GateOption, + HumanGateStepDef, OutputField, RouteDef, WorkflowConfig, @@ -312,9 +313,8 @@ def test_human_gate_with_skip_gates(self) -> None: output={"proposal": OutputField(type="string")}, routes=[RouteDef(to="approval")], ), - AgentDef( + HumanGateStepDef( name="approval", - type="human_gate", prompt="Review proposal: {{ prepare.output.proposal }}", options=[ GateOption(label="Approve", value="approved", route="execute"), @@ -359,6 +359,7 @@ def test_human_gate_routes_to_end(self) -> None: from conductor.config.schema import ( AgentDef, GateOption, + HumanGateStepDef, OutputField, WorkflowConfig, WorkflowDef, @@ -370,9 +371,8 @@ def test_human_gate_routes_to_end(self) -> None: entry_point="confirmation", ), agents=[ - AgentDef( + HumanGateStepDef( name="confirmation", - type="human_gate", prompt="Confirm action?", options=[ GateOption(label="Cancel", value="cancelled", route="$end"), diff --git a/tests/test_mcp/test_serve_introspect.py b/tests/test_mcp/test_serve_introspect.py index 6316bf6d..addae7c9 100644 --- a/tests/test_mcp/test_serve_introspect.py +++ b/tests/test_mcp/test_serve_introspect.py @@ -660,6 +660,41 @@ def test_plan_tree_matches_the_yaml(self, tmp_path: Path) -> None: assert by_name["analyzers"]["type"] == "for_each" assert by_name["analyzers"]["agent"] == "analyzer" + def test_terminate_step_node_has_no_routes(self, tmp_path: Path) -> None: + # Requirement: a terminate step appears in the plan tree with an empty + # routes list (it owns no ``routes`` field after the step-model split). + directory = tmp_path / "wfdir" + directory.mkdir() + (directory / "plan-terminate.yaml").write_text( + """\ +workflow: + name: plan-terminate + entry_point: first +agents: + - name: first + prompt: "Step one" + routes: + - to: stop + - name: stop + type: terminate + status: success + reason: done +""", + encoding="utf-8", + ) + options = ServeOptions(workflow_dirs=(directory,)) + catalogue = build_catalogue( + options, registries_config=RegistriesConfig(), allow_network=False + ) + + tree = conductor_plan_tree( + catalogue.entries[0].tool_name, catalogue=catalogue, options=options + ) + + by_name = {node["name"]: node for node in tree["nodes"]} + assert by_name["stop"]["type"] == "terminate" + assert by_name["stop"]["routes"] == [] + def test_unknown_tool_name_is_refused(self, tmp_path: Path) -> None: catalogue, options = self._catalogue(tmp_path) diff --git a/tests/test_per_agent_retry.py b/tests/test_per_agent_retry.py index a85700a9..677cb7fd 100644 --- a/tests/test_per_agent_retry.py +++ b/tests/test_per_agent_retry.py @@ -16,7 +16,12 @@ import pytest from pydantic import ValidationError -from conductor.config.schema import AgentDef, GateOption, RetryPolicy +from conductor.config.schema import ( + AgentDef, + HumanGateStepDef, + RetryPolicy, + ScriptStepDef, +) from conductor.exceptions import ProviderError from conductor.exceptions import TimeoutError as _ConductorTimeoutError from conductor.providers.copilot import CopilotProvider, RetryConfig @@ -140,28 +145,38 @@ def test_agent_retry_from_dict(self) -> None: def test_script_agent_cannot_have_retry(self) -> None: """Test that script agents cannot have a retry policy.""" - with pytest.raises(ValidationError, match="script agents cannot have 'retry'"): - AgentDef( - name="my_script", - type="script", - command="echo hello", - retry=RetryPolicy(max_attempts=3), + with pytest.raises(ValidationError) as exc_info: + ScriptStepDef.model_validate( + { + "name": "my_script", + "command": "echo hello", + "retry": {"max_attempts": 3}, + } ) + assert any( + e["loc"] == ("retry",) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) - def test_human_gate_can_have_retry_since_unused(self) -> None: - """Test that human_gate agents can technically have retry field. + def test_human_gate_rejects_retry(self) -> None: + """Test that human_gate agents cannot declare a retry policy. - The retry policy is only used by provider-backed agents, so - human_gate agents can have it without error (it simply won't be used). + Only provider-backed agents (AgentDef) own the retry field after the + step-model split; on every other step type it is an extra field. """ - agent = AgentDef( - name="gate", - type="human_gate", - prompt="Choose", - options=[GateOption(label="Yes", value="yes", route="next_agent")], - retry=RetryPolicy(max_attempts=2), + with pytest.raises(ValidationError) as exc_info: + HumanGateStepDef.model_validate( + { + "name": "gate", + "prompt": "Choose", + "options": [{"label": "Yes", "value": "yes", "route": "next_agent"}], + "retry": {"max_attempts": 2}, + } + ) + assert any( + e["loc"] == ("retry",) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() ) - assert agent.retry is not None # --------------------------------------------------------------------------- diff --git a/tests/test_plugins/test_engine_integration.py b/tests/test_plugins/test_engine_integration.py index e57dbaa6..603bbc23 100644 --- a/tests/test_plugins/test_engine_integration.py +++ b/tests/test_plugins/test_engine_integration.py @@ -25,6 +25,7 @@ RuntimeConfig, WorkflowConfig, WorkflowDef, + WorkflowStepDef, ) from conductor.engine.workflow import WorkflowEngine from conductor.plugins.errors import PluginNotFoundError @@ -444,9 +445,8 @@ def _parent(tmp_path: Path, sources: dict[str, Any]) -> WorkflowConfig: runtime=RuntimeConfig(provider="copilot", plugin_sources=sources), ), agents=[ - AgentDef( + WorkflowStepDef( name="delegate", - type="workflow", workflow="child.yaml", routes=[RouteDef(to="$end")], ) diff --git a/tests/test_plugins/test_schema.py b/tests/test_plugins/test_schema.py index abbba47e..daf341e9 100644 --- a/tests/test_plugins/test_schema.py +++ b/tests/test_plugins/test_schema.py @@ -9,7 +9,18 @@ import pytest from pydantic import ValidationError -from conductor.config.schema import AgentDef, PluginDef, RuntimeConfig +from conductor.config.schema import ( + AgentDef, + HumanGateStepDef, + PluginDef, + RuntimeConfig, + ScriptStepDef, + SetStepDef, + StepDef, + TerminateStepDef, + WaitStepDef, + WorkflowStepDef, +) class TestCoercion: @@ -68,22 +79,31 @@ def test_duplicate_entries_are_refused(self) -> None: RuntimeConfig.model_validate({"plugins": ["prs", {"name": "prs", "mcp": False}]}) @pytest.mark.parametrize( - ("kind", "extra"), + ("model", "extra"), [ - ("script", {"command": "ls"}), + (ScriptStepDef, {"command": "ls"}), ( - "human_gate", + HumanGateStepDef, {"prompt": "?", "options": [{"label": "a", "value": "a", "route": "next"}]}, ), - ("wait", {"duration": "1s"}), - ("set", {"value": "x"}), - ("terminate", {"status": "success", "reason": "done"}), - ("workflow", {"workflow": "child.yaml"}), + (WaitStepDef, {"duration": "1s"}), + (SetStepDef, {"value": "x"}), + (TerminateStepDef, {"status": "success", "reason": "done"}), + (WorkflowStepDef, {"workflow": "child.yaml"}), ], + ids=["script", "human_gate", "wait", "set", "terminate", "workflow"], ) - def test_non_provider_backed_steps_reject_plugins(self, kind: str, extra: dict) -> None: - with pytest.raises(ValidationError, match=f"{kind} agents cannot have 'plugins'"): - AgentDef.model_validate({"name": "s", "type": kind, "plugins": ["prs"], **extra}) + def test_non_provider_backed_steps_reject_plugins( + self, model: type[StepDef], extra: dict + ) -> None: + # Requirement: plugins are provider-backed-only; every other variant + # rejects the field with a plain extra_forbidden after the step-model split. + with pytest.raises(ValidationError) as exc_info: + model.model_validate({"name": "s", "plugins": ["prs"], **extra}) + assert any( + e["loc"] == ("plugins",) and e["type"] == "extra_forbidden" + for e in exc_info.value.errors() + ) class TestSerialization: diff --git a/tests/test_providers/test_pydantic_ai_compaction.py b/tests/test_providers/test_pydantic_ai_compaction.py index 0547426e..29dfa9f2 100644 --- a/tests/test_providers/test_pydantic_ai_compaction.py +++ b/tests/test_providers/test_pydantic_ai_compaction.py @@ -785,7 +785,6 @@ def test_build_agent_attaches_capability(self) -> None: agent_def = AgentDef( name="wired", prompt="hello", - max_depth=None, timeout_seconds=None, max_session_seconds=None, max_agent_iterations=None, @@ -860,7 +859,6 @@ def test_build_agent_without_compaction_has_no_capabilities(self) -> None: agent_def = AgentDef( name="plain", prompt="hello", - max_depth=None, timeout_seconds=None, max_session_seconds=None, max_agent_iterations=None, @@ -905,7 +903,6 @@ def fake_build_agent_fn( agent_def = AgentDef( name="callback-test", prompt="hello", - max_depth=None, timeout_seconds=None, max_session_seconds=None, max_agent_iterations=None, @@ -1424,7 +1421,6 @@ def callback(event_type: str, data: dict[str, Any]) -> None: name="cfg-test", prompt="hi", model="test", - max_depth=None, timeout_seconds=None, max_session_seconds=None, max_agent_iterations=None, @@ -1488,7 +1484,6 @@ def callback(event_type: str, data: dict[str, Any]) -> None: name="cfg-test", prompt="hi", model="test", - max_depth=None, timeout_seconds=None, max_session_seconds=None, max_agent_iterations=None, @@ -1549,7 +1544,6 @@ def callback(event_type: str, data: dict[str, Any]) -> None: name="multi", prompt="go", model="test", - max_depth=None, timeout_seconds=None, max_session_seconds=None, max_agent_iterations=None, @@ -1604,7 +1598,6 @@ async def test_interrupt_after_compaction(self) -> None: name="interrupt", prompt="go", model="test", - max_depth=None, timeout_seconds=None, max_session_seconds=None, max_agent_iterations=None, @@ -1666,7 +1659,6 @@ class AnswerModel(BaseModel): name="recovery", prompt="answer", model="test", - max_depth=None, timeout_seconds=None, max_session_seconds=None, max_agent_iterations=None, @@ -1745,7 +1737,6 @@ async def test_usage_limits_interaction(self) -> None: name="limits", prompt="go", model="test", - max_depth=None, timeout_seconds=None, max_session_seconds=None, max_agent_iterations=None, diff --git a/tests/test_skills/test_schema.py b/tests/test_skills/test_schema.py index eab22b28..46803e55 100644 --- a/tests/test_skills/test_schema.py +++ b/tests/test_skills/test_schema.py @@ -7,12 +7,26 @@ from conductor.config.schema import ( AgentDef, - GateOption, + HumanGateStepDef, RuntimeConfig, + ScriptStepDef, + SetStepDef, SkillDiscoveryConfig, + TerminateStepDef, + WaitStepDef, + WorkflowStepDef, ) +def _assert_extra_forbidden(exc_info: pytest.ExceptionInfo[ValidationError], field: str) -> None: + """Since issue #517 a sibling-variant field is rejected by the concrete + step model's ``extra="forbid"``, not by a custom message — so assert the + standard ``extra_forbidden`` error structurally.""" + assert any( + e["loc"] == (field,) and e["type"] == "extra_forbidden" for e in exc_info.value.errors() + ) + + class TestAgentDefSkills: def test_defaults_to_none(self) -> None: agent = AgentDef(name="a", model="gpt-4", prompt="Hello") @@ -35,45 +49,59 @@ def test_empty_string_rejected(self) -> None: AgentDef(name="a", model="gpt-4", prompt="Hello", skills=[""]) def test_forbidden_on_script_agent(self) -> None: - with pytest.raises(ValidationError, match="script agents cannot have 'skills'"): - AgentDef(name="s", type="script", command="echo hi", skills=["conductor"]) + # Issue #517: sibling-variant fields are rejected by extra="forbid", + # not by a custom " agents cannot have 'skills'" message. + with pytest.raises(ValidationError) as exc_info: + ScriptStepDef.model_validate( + {"name": "s", "command": "echo hi", "skills": ["conductor"]} + ) + _assert_extra_forbidden(exc_info, "skills") def test_forbidden_on_workflow_agent(self) -> None: - with pytest.raises(ValidationError, match="workflow agents cannot have 'skills'"): - AgentDef(name="w", type="workflow", workflow="sub.yaml", skills=["conductor"]) + with pytest.raises(ValidationError) as exc_info: + WorkflowStepDef.model_validate( + {"name": "w", "workflow": "sub.yaml", "skills": ["conductor"]} + ) + _assert_extra_forbidden(exc_info, "skills") def test_forbidden_on_human_gate(self) -> None: - with pytest.raises(ValidationError, match="human_gate agents cannot have 'skills'"): - AgentDef( - name="g", - type="human_gate", - prompt="Choose:", - options=[GateOption(label="Yes", value="y", route="next")], - skills=["conductor"], + with pytest.raises(ValidationError) as exc_info: + HumanGateStepDef.model_validate( + { + "name": "g", + "prompt": "Choose:", + "options": [{"label": "Yes", "value": "y", "route": "next"}], + "skills": ["conductor"], + } ) + _assert_extra_forbidden(exc_info, "skills") def test_forbidden_on_wait_agent(self) -> None: - with pytest.raises(ValidationError, match="wait agents cannot have 'skills'"): - AgentDef(name="w", type="wait", duration="1s", skills=["conductor"]) + with pytest.raises(ValidationError) as exc_info: + WaitStepDef.model_validate({"name": "w", "duration": "1s", "skills": ["conductor"]}) + _assert_extra_forbidden(exc_info, "skills") def test_forbidden_on_set_agent(self) -> None: - with pytest.raises(ValidationError, match="set agents cannot have 'skills'"): - AgentDef(name="s", type="set", value="hello", skills=["conductor"]) + with pytest.raises(ValidationError) as exc_info: + SetStepDef.model_validate({"name": "s", "value": "hello", "skills": ["conductor"]}) + _assert_extra_forbidden(exc_info, "skills") def test_forbidden_on_terminate_agent(self) -> None: - with pytest.raises(ValidationError, match="terminate agents cannot have 'skills'"): - AgentDef( - name="t", - type="terminate", - status="success", - reason="done", - skills=["conductor"], + with pytest.raises(ValidationError) as exc_info: + TerminateStepDef.model_validate( + { + "name": "t", + "status": "success", + "reason": "done", + "skills": ["conductor"], + } ) + _assert_extra_forbidden(exc_info, "skills") def test_allowed_on_default_type_agent(self) -> None: agent = AgentDef(name="r", model="gpt-4", prompt="p", skills=["conductor"]) assert agent.skills == ["conductor"] - assert agent.type is None + assert agent.type == "agent" def test_allowed_on_explicit_agent_type(self) -> None: agent = AgentDef(name="r", type="agent", model="gpt-4", prompt="p", skills=["conductor"]) @@ -134,8 +162,11 @@ def test_whitespace_only_entry_still_rejected(self) -> None: AgentDef(name="r", prompt="p", skills=[" "]) def test_path_entries_still_forbidden_on_non_provider_steps(self) -> None: - with pytest.raises(ValidationError, match="cannot have 'skills'"): - AgentDef(name="s", type="script", command="echo hi", skills=["./a/b"]) + # A path entry is still a skills entry: it cannot reach a script step + # either, and #517 routes that through extra="forbid". + with pytest.raises(ValidationError) as exc_info: + ScriptStepDef.model_validate({"name": "s", "command": "echo hi", "skills": ["./a/b"]}) + _assert_extra_forbidden(exc_info, "skills") class TestSkillDiscoveryConfig: diff --git a/tests/test_telemetry/test_integration.py b/tests/test_telemetry/test_integration.py index fa51eed6..438603e6 100644 --- a/tests/test_telemetry/test_integration.py +++ b/tests/test_telemetry/test_integration.py @@ -1229,12 +1229,12 @@ async def test_nested_subworkflow_agents_parent_under_child_workflow( monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") from conductor.config.schema import ( - AgentDef, LimitsConfig, RouteDef, RuntimeConfig, WorkflowConfig, WorkflowDef, + WorkflowStepDef, ) from conductor.engine.workflow import RunContext, WorkflowEngine from conductor.events import WorkflowEventEmitter @@ -1266,9 +1266,8 @@ async def test_nested_subworkflow_agents_parent_under_child_workflow( limits=LimitsConfig(max_iterations=5), ), agents=[ - AgentDef( + WorkflowStepDef( name="delegate", - type="workflow", workflow="child.yaml", routes=[RouteDef(to="$end")], ), @@ -1325,13 +1324,13 @@ async def test_nested_subworkflow_uses_inherited_registry_provider_identity( ): """Scenario 14: child events reflect the provider its inherited registry executes.""" from conductor.config.schema import ( - AgentDef, LimitsConfig, ProviderSettings, RouteDef, RuntimeConfig, WorkflowConfig, WorkflowDef, + WorkflowStepDef, ) from conductor.engine.workflow import RunContext, WorkflowEngine from conductor.events import WorkflowEventEmitter @@ -1374,9 +1373,8 @@ async def test_nested_subworkflow_uses_inherited_registry_provider_identity( limits=LimitsConfig(max_iterations=5), ), agents=[ - AgentDef( + WorkflowStepDef( name="delegate", - type="workflow", workflow="child-provider.yaml", routes=[RouteDef(to="$end")], ) diff --git a/tests/test_telemetry/test_provider_instrumentation.py b/tests/test_telemetry/test_provider_instrumentation.py index 094a5383..8f1abdb8 100644 --- a/tests/test_telemetry/test_provider_instrumentation.py +++ b/tests/test_telemetry/test_provider_instrumentation.py @@ -60,7 +60,6 @@ def native_tracing(monkeypatch: pytest.MonkeyPatch) -> Generator[NativeTracing, def _agent_definition(name: str) -> AgentDef: return AgentDef( name=name, - max_depth=None, timeout_seconds=None, max_session_seconds=None, max_agent_iterations=None, diff --git a/tests/test_web/test_server.py b/tests/test_web/test_server.py index 45551c88..d7746f16 100644 --- a/tests/test_web/test_server.py +++ b/tests/test_web/test_server.py @@ -1595,7 +1595,14 @@ class TestReplaySyntheticFromContext: def _build_config(self): """Build a minimal WorkflowConfig with one agent + one script + one wait for tests.""" - from conductor.config.schema import AgentDef, RuntimeConfig, WorkflowConfig, WorkflowDef + from conductor.config.schema import ( + AgentDef, + RuntimeConfig, + ScriptStepDef, + WaitStepDef, + WorkflowConfig, + WorkflowDef, + ) return WorkflowConfig( workflow=WorkflowDef( @@ -1605,8 +1612,8 @@ def _build_config(self): ), agents=[ AgentDef(name="a", prompt="x", routes=[]), - AgentDef(name="s", type="script", command="echo hi", routes=[]), - AgentDef(name="w", type="wait", duration="5s", reason="cooldown", routes=[]), + ScriptStepDef(name="s", command="echo hi", routes=[]), + WaitStepDef(name="w", duration="5s", reason="cooldown", routes=[]), ], )