Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions examples/for-each-simple.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ workflow:
limits:
max_iterations: 20

# Optional input: if provided, items are used directly; otherwise generated
input:
items:
type: string
required: false
description: JSON array of items to process (e.g. '["apple", "banana"]')
# Optional input: if provided, items are used directly; otherwise generated
input:
items:
type: string
required: false
description: JSON array of items to process (e.g. '["apple", "banana"]')

# For-each group definition
for_each:
Expand Down
6 changes: 6 additions & 0 deletions src/conductor/cli/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ def display_validation_success(
# Build summary info
agent_count = len(config.agents)
human_gate_count = sum(1 for a in config.agents if a.type == "human_gate")
parallel_group_count = len(config.parallel)
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)
Expand Down Expand Up @@ -163,6 +165,10 @@ def display_validation_success(
table.add_row("Agents", str(agent_count))
if human_gate_count > 0:
table.add_row("Human Gates", str(human_gate_count))
if parallel_group_count > 0:
table.add_row("Parallel Groups", str(parallel_group_count))
if for_each_group_count > 0:
table.add_row("For-each Groups", str(for_each_group_count))
table.add_row("Max Iterations", str(config.workflow.limits.max_iterations))
timeout_val = config.workflow.limits.timeout_seconds
table.add_row("Timeout", f"{timeout_val}s" if timeout_val else "unlimited")
Expand Down
14 changes: 13 additions & 1 deletion src/conductor/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from typing import Any, Literal

from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

from conductor.providers.reasoning import ReasoningEffort

Expand Down Expand Up @@ -88,6 +88,8 @@ def validate_type_specific_fields(self) -> OutputField:
class RouteDef(BaseModel):
"""Definition for a routing rule."""

model_config = ConfigDict(extra="forbid")

to: str
"""Target agent name, '$end', or human gate name."""

Expand All @@ -109,6 +111,8 @@ def validate_target(cls, v: str) -> str:
class ParallelGroup(BaseModel):
"""Definition for a parallel agent execution group."""

model_config = ConfigDict(extra="forbid")

name: str
"""Unique identifier for this parallel group."""

Expand Down Expand Up @@ -160,6 +164,8 @@ class ForEachDef(BaseModel):
```
"""

model_config = ConfigDict(extra="forbid")

name: str
"""Unique identifier for this for-each group."""

Expand Down Expand Up @@ -444,6 +450,8 @@ class ReasoningConfig(BaseModel):
class AgentDef(BaseModel):
"""Definition for a single agent in the workflow."""

model_config = ConfigDict(extra="forbid")

name: str
"""Unique identifier for this agent."""

Expand Down Expand Up @@ -876,6 +884,8 @@ class RuntimeConfig(BaseModel):
class WorkflowDef(BaseModel):
"""Top-level workflow configuration."""

model_config = ConfigDict(extra="forbid")

name: str
"""Unique workflow identifier."""

Expand Down Expand Up @@ -939,6 +949,8 @@ class WorkflowDef(BaseModel):
class WorkflowConfig(BaseModel):
"""Complete workflow configuration file."""

model_config = ConfigDict(extra="forbid")

workflow: WorkflowDef
"""Workflow-level settings."""

Expand Down
126 changes: 126 additions & 0 deletions tests/test_config/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1396,3 +1396,129 @@ def test_rejects_invalid_effort(self, effort: object) -> None:
"""Test that invalid effort values raise ValidationError."""
with pytest.raises(ValidationError):
RuntimeConfig(default_reasoning_effort=effort) # type: ignore[arg-type]


class TestExtraFieldsForbidden:
"""Tests that workflow models reject unknown fields.

Regression tests for https://github.com/microsoft/conductor/issues/140 —
misnesting `parallel:` or `for_each:` inside an `agents:` item used to
silently drop the field, leaving a wrapper agent with no model/prompt
that failed obscurely at the provider.
"""

def test_agentdef_misnested_parallel_rejected(self) -> None:
"""An `agents:` item with a nested `parallel:` field is rejected."""
with pytest.raises(ValidationError) as exc_info:
AgentDef.model_validate(
{
"name": "review_group",
"parallel": ["technical_reviewer", "readability_reviewer"],
"failure_mode": "fail_fast",
"routes": [{"to": "$end"}],
}
)
errors = exc_info.value.errors()
# The unknown field must be reported by Pydantic's extra_forbidden
assert any(
err["type"] == "extra_forbidden" and "parallel" in err["loc"] for err in errors
), f"Expected extra_forbidden error for 'parallel', got: {errors}"

def test_agentdef_misnested_for_each_rejected(self) -> None:
"""An `agents:` item with a nested `for_each:` field is rejected."""
with pytest.raises(ValidationError) as exc_info:
AgentDef.model_validate(
{
"name": "fanout",
"for_each": [{"name": "x", "type": "for_each"}],
}
)
errors = exc_info.value.errors()
assert any(
err["type"] == "extra_forbidden" and "for_each" in err["loc"] for err in errors
), f"Expected extra_forbidden error for 'for_each', got: {errors}"

def test_agentdef_typo_field_rejected(self) -> None:
"""A typo'd field on an agent is rejected (e.g., `prmpt` instead of `prompt`)."""
with pytest.raises(ValidationError) as exc_info:
AgentDef.model_validate(
{
"name": "answerer",
"model": "claude-haiku-4.5",
"prmpt": "Answer the question.",
}
)
errors = exc_info.value.errors()
assert any(err["type"] == "extra_forbidden" and "prmpt" in err["loc"] for err in errors), (
f"Expected extra_forbidden error for 'prmpt', got: {errors}"
)

def test_parallel_group_extra_field_rejected(self) -> None:
"""An unknown field on a ParallelGroup is rejected."""
with pytest.raises(ValidationError) as exc_info:
from conductor.config.schema import ParallelGroup

ParallelGroup.model_validate(
{
"name": "g",
"agents": ["a", "b"],
"fail_fast": True, # typo: actually `failure_mode`
}
)
errors = exc_info.value.errors()
assert any(err["type"] == "extra_forbidden" for err in errors)

def test_workflow_config_top_level_extra_field_rejected(self) -> None:
"""An unknown top-level workflow field is rejected (catches `agent:` typo etc.)."""
from conductor.config.schema import ParallelGroup # noqa: F401

with pytest.raises(ValidationError) as exc_info:
WorkflowConfig.model_validate(
{
"workflow": {"name": "x", "version": "1", "entry_point": "a"},
"agents": [{"name": "a", "model": "m", "prompt": "p"}],
"agnts": [], # typo
}
)
errors = exc_info.value.errors()
assert any(err["type"] == "extra_forbidden" and "agnts" in err["loc"] for err in errors), (
f"Expected extra_forbidden error for 'agnts', got: {errors}"
)

def test_workflowdef_typo_field_rejected(self) -> None:
"""A typo'd field on the `workflow:` block is rejected.

Without `extra="forbid"` on WorkflowDef, typos like `entery_point:` or
`limts:` are silently dropped, leaving the user's intent ignored.
"""
with pytest.raises(ValidationError) as exc_info:
WorkflowDef.model_validate(
{
"name": "demo",
"entry_point": "a",
"entery_point": "b", # typo
}
)
errors = exc_info.value.errors()
assert any(
err["type"] == "extra_forbidden" and "entery_point" in err["loc"] for err in errors
), f"Expected extra_forbidden error for 'entery_point', got: {errors}"

def test_routedef_typo_when_rejected(self) -> None:
"""A typo'd `when:` on a route is rejected.

Without `extra="forbid"` on RouteDef, `whn:` was silently dropped and
`route.when` defaulted to `None`, turning a conditional route into an
unconditional one — a workflow-semantics bug nearly impossible to debug.
"""
with pytest.raises(ValidationError) as exc_info:
RouteDef.model_validate(
{
"to": "next_agent",
"whn": "output.score > 5", # typo for `when`
}
)
errors = exc_info.value.errors()
assert any(err["type"] == "extra_forbidden" and "whn" in err["loc"] for err in errors), (
f"Expected extra_forbidden error for 'whn', got: {errors}"
)
Loading