Skip to content
Draft
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
71 changes: 70 additions & 1 deletion docs/workflow-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,16 @@ agents:

output: # Optional: Output schema for validation
field_name:
type: string
type: string # string | number | boolean | array | object
description: "Field purpose"
enum: ["a", "b"] # Optional: Allowed scalar values (string/number/boolean)
pattern: "^[a-z]+$" # Optional: Regex pattern (string type only)
minimum: 0 # Optional: Inclusive minimum (number type only)
maximum: 100 # Optional: Inclusive maximum (number type only)
minLength: 1 # Optional: Minimum string length (string type only)
maxLength: 50 # Optional: Maximum string length (string type only)
nullable: true # Optional: Allow null value (default: false)
required: false # Optional: Only inside object properties (default: true)

output_mode: raw # Optional: raw | envelope (default: inferred)
# raw: skip JSON extraction, wrap response
Expand Down Expand Up @@ -206,6 +214,67 @@ A response that parses as JSON but isn't an object at all (a bare `42` or an arr
This is useful when you know an agent's output is simple and a single attempt should suffice, or when you want to fail fast instead of burning tokens on recovery loops.

When the budget runs out, a schema-shape failure raises the specific validation error naming the offending field and its expected type, while a syntax failure raises a provider error. Each recovery attempt emits an `agent_parse_recovery` event, visible in the dashboard activity stream and the structured event log.

### Field Constraints

Output field definitions support optional validation constraints to enforce value boundaries and formatting rules.

| Field | Applicable Type | Description | Semantics |
|-------|-----------------|-------------|-----------|
| `enum` | `string`, `number`, `boolean` | List of allowed scalar values | Uses exact value comparison. Cannot contain `null` (use `nullable: true` instead). |
| `pattern` | `string` | Regular expression pattern | Python `re.search` matching (unanchored by default; use `^` and `$` to anchor). Evaluated consistently on all providers. Matching is time-bounded (1 second); a pathological pattern fails validation instead of hanging the run. |
| `minimum` | `number` | Inclusive minimum numeric bound | Value must be greater than or equal to `minimum`. |
| `maximum` | `number` | Inclusive maximum numeric bound | Value must be less than or equal to `maximum`. |
| `minLength` | `string` | Inclusive minimum string length | String length must be greater than or equal to `minLength`. |
| `maxLength` | `string` | Inclusive maximum string length | String length must be less than or equal to `maxLength`. |
| `required` | Any (object property only) | Whether the object property must be present | Default: `true`. **Must be `true` for root-level fields**; setting `required: false` at the root level is rejected by `conductor validate`. |
| `nullable` | Any | Whether `null` is an acceptable value | Default: `false`. When `true`, renders as `type: [T, "null"]` in JSON Schema. |

#### JSON Schema and Validation Semantics

- **Inclusive Bounds**: `minimum`, `maximum`, `minLength`, and `maxLength` represent inclusive bounds.
- **Regex Pattern Matching**: `pattern` uses Python `re.search` semantics across all providers (including Claude). It matches anywhere in the target string unless explicitly anchored with `^` and `$`. Matching runs on a `re`-compatible engine with a 1-second wall-clock deadline per check: model output is untrusted input, so a pattern with catastrophic backtracking raises a validation error (which drives the provider's output-recovery loop) instead of stalling the workflow.
- **Nullable Fields**: Setting `nullable: true` renders the JSON Schema type as `type: [T, "null"]`, allowing the field to hold `null` or a value matching `type`.
- **Optional Object Properties**: The `required: false` constraint is permitted **only inside nested object properties** (e.g. `properties.details.required: false`). All root-level output fields must be required, so setting `required: false` on a root-level agent output field will be rejected during workflow validation (`conductor validate`).

#### Field Constraints Example

```yaml
agents:
- name: evaluator
prompt: "Evaluate the artifact and return structured metrics."
output:
status:
type: string
enum: ["passed", "failed", "pending"]
description: "Execution status"
score:
type: number
minimum: 0
maximum: 100
description: "Evaluation score between 0 and 100"
code:
type: string
pattern: "^ERR-[0-9]{3}$"
minLength: 7
maxLength: 7
description: "Error code in format ERR-123"
notes:
type: string
nullable: true
description: "Optional notes or null when absent"
metadata:
type: object
description: "Additional execution metadata"
properties:
reviewer:
type: string
description: "Reviewer identifier"
comments:
type: string
required: false
description: "Optional comments property inside object"
```
### Choosing whether to declare `output:`

Declaring `output:` does two things at once: it asks the model to return JSON matching the schema, and it parses the response as structured JSON. For some agents that's what you want. For others it produces parse-recovery loops and burns tokens.
Expand Down
81 changes: 81 additions & 0 deletions examples/output-constraints.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Output Field Constraints Workflow
#
# Demonstrates structured output schema constraints:
# - `enum`: allowed scalar values
# - `pattern`: regular expression matching (Python re.search semantics)
# - `minimum` and `maximum`: numeric range boundaries
# - `minLength` and `maxLength`: string length boundaries
# - `required`: optional object properties (`required: false` inside object)
# - `nullable`: fields that permit null values (`nullable: true`)
#
# Usage:
# conductor run examples/output-constraints.yaml
# conductor validate examples/output-constraints.yaml

workflow:
name: output-constraints
description: Workflow demonstrating all eight output field schema constraints
version: "1.0.0"
entry_point: audit_evaluator

runtime:
provider: copilot

input:
ticket_id:
type: string
required: false
default: "TICK-1234"
description: Ticket identifier to evaluate

agents:
- name: audit_evaluator
description: Evaluates a system audit ticket and returns structured metrics with schema constraints
model: gpt-5.5
prompt: |
Evaluate the audit ticket {{ workflow.input.ticket_id }}.
Return the verdict, score, reference ticket, summary, optional notes, and metadata object.
output:
verdict:
type: string
enum: ["passed", "failed", "inconclusive"]
description: Audit verdict (enum constraint)
ticket_ref:
type: string
pattern: "^TICK-[0-9]{4}$"
description: Formatted ticket reference matching pattern TICK-1234
score:
type: number
minimum: 0.0
maximum: 100.0
description: Numeric evaluation score between 0 and 100
summary:
type: string
minLength: 5
maxLength: 200
description: Brief summary string between 5 and 200 characters
notes:
type: string
nullable: true
description: Optional notes string or null when omitted
details:
type: object
description: Execution details object with an optional property
properties:
reviewer:
type: string
description: Identifier of the reviewer
comments:
type: string
required: false
description: Optional comments property inside object

routes:
- to: $end

output:
verdict: "{{ audit_evaluator.output.verdict }}"
ticket_ref: "{{ audit_evaluator.output.ticket_ref }}"
score: "{{ audit_evaluator.output.score }}"
summary: "{{ audit_evaluator.output.summary }}"
notes: "{{ audit_evaluator.output.notes }}"
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ dependencies = [
"websockets>=12.0",
"httpx>=0.27.0",
"packaging>=21.0",
# Timeout-bounded regex matching for output field constraints and provider
# response normalization. Ships typed stubs and supports per-match deadlines.
"regex>=2024.11.6",
]

[project.optional-dependencies]
Expand Down
138 changes: 137 additions & 1 deletion src/conductor/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@

from __future__ import annotations

import functools
from typing import Any, Literal, get_args
from urllib.parse import urlparse

import regex
from pydantic import (
BaseModel,
ConfigDict,
Expand Down Expand Up @@ -38,6 +40,12 @@
# certainly wants ``limits.timeout_seconds`` reconsidered first.
MAX_WAIT_DURATION_SECONDS = 24 * 60 * 60

# Wall-clock bound for a single pattern match. Model output is untrusted input
# and Python ``re`` has no timeout, so matching uses the third-party ``regex``
# engine which supports deadlines (and releases the GIL, so a pathological
# pattern cannot stall the event loop and neighboring parallel agents).
PATTERN_MATCH_TIMEOUT_SECONDS = 1.0


class InputDef(BaseModel):
"""Definition for a workflow input parameter."""
Expand Down Expand Up @@ -87,6 +95,8 @@ def validate_default_type(cls, v: Any, info) -> Any:
class OutputField(BaseModel):
"""Schema for a single output field from an agent."""

model_config = ConfigDict(extra="forbid")

type: Literal["string", "number", "boolean", "array", "object"]
"""The type of the output field."""

Expand All @@ -99,15 +109,113 @@ class OutputField(BaseModel):
properties: dict[str, OutputField] | None = None
"""For object types, the schema of object properties."""

enum: list[Any] | None = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OutputField has no model_config, so unknown keys are accepted and ignored. OutputField.model_validate({"type": "string", "minlength": 5}) loads happily and the field ships unconstrained, with conductor validate reporting success.

That was survivable with four well-known keys. With twelve, two of them camelCase in a schema whose every other YAML key is snake_case, a typo now quietly downgrades a declared contract to no contract. RouteDef, ParallelGroup and the other models in this file already set extra="forbid".

Suggested change
enum: list[Any] | None = None
model_config = ConfigDict(extra="forbid")
enum: list[Any] | None = None

Worth flagging that aca.py relies on the current tolerance for forward compatibility, so this needs checking against the runner before it lands.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The runner reconstructs through the same OutputField class and the wire only carries dumped known fields, so this is wire-safe today; the ACA and config suites are green. Side benefit for the version-skew concern in your other comment: once a runner carries this change, future unknown fields fail loudly at reconstruction instead of being silently dropped.

"""Allowed values for scalar types."""

pattern: str | None = None
"""Regular expression pattern for string types."""

minimum: int | float | None = None
"""Minimum value for number types."""

maximum: int | float | None = None
"""Maximum value for number types."""

minLength: int | None = None
"""Minimum length for string types."""

maxLength: int | None = None
"""Maximum length for string types."""

required: bool = True
"""Whether the field is required when used as an object property."""

nullable: bool = False
"""Whether the field value may be null."""

@functools.cached_property
def compiled_pattern(self) -> Any:
"""Return a compiled regex pattern, or ``None`` when no pattern is set.

Annotated as ``Any`` because the repo type checker (ty) does not yet
read the ``regex`` package stubs; the runtime object is always a
``regex.Pattern`` or ``None``.
"""

if self.pattern is None:
return None
return regex.compile(self.pattern)

@model_validator(mode="after")
def validate_type_specific_fields(self) -> OutputField:
"""Ensure type-specific fields are properly set."""
"""Ensure type-specific fields are properly set and consistent."""
if self.type == "array" and self.items is None:
# Items are optional but recommended for arrays
pass
if self.type == "object" and self.properties is None:
# Properties are optional but recommended for objects
pass

# String-only constraints.
if self.type != "string":
for field_name in ("pattern", "minLength", "maxLength"):
value = getattr(self, field_name)
if value is not None:
raise ValueError(f"{field_name} can only be set when type is 'string'")

# Number-only constraints.
if self.type != "number":
for field_name in ("minimum", "maximum"):
value = getattr(self, field_name)
if value is not None:
raise ValueError(f"{field_name} can only be set when type is 'number'")

# Enum validation.
if self.enum is not None:
if self.type in ("array", "object"):
raise ValueError("enum can only be set for scalar types")

if len(self.enum) == 0:
raise ValueError("enum must contain at least one value")

if any(value is None for value in self.enum):
raise ValueError(
"enum cannot contain null; use nullable: true to allow null values"
)
Comment on lines +181 to +184

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This message is good, but it currently routes authors into a combination that does not work. enum: ["a","b"], nullable: true produces a schema where null can never validate, while all three enforcement paths accept it. Worth resolving alongside the _schema.py change so the advice and the emitted schema agree.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved together with the _schema.py change: with null appended to the emitted enum when nullable: true, the advice in this message and the generated schema now agree.


type_checks = {
"string": lambda x: isinstance(x, str),
"number": lambda x: isinstance(x, int | float) and not isinstance(x, bool),
"boolean": lambda x: isinstance(x, bool),
}
check = type_checks.get(self.type)
if check is not None and not all(check(value) for value in self.enum):
raise ValueError(f"enum values must match the declared type '{self.type}'")

# String length validation.
if self.minLength is not None and self.minLength < 0:
raise ValueError("minLength must be non-negative")
if self.maxLength is not None and self.maxLength < 0:
raise ValueError("maxLength must be non-negative")
if (
self.minLength is not None
and self.maxLength is not None
and self.minLength > self.maxLength
):
raise ValueError("minLength cannot be greater than maxLength")

# Number range validation.
if self.minimum is not None and self.maximum is not None and self.minimum > self.maximum:
raise ValueError("minimum cannot be greater than maximum")

# Pattern compilation. ``regex`` is a strict superset of the stdlib
# ``re`` module, so every previously valid pattern still compiles.
if self.pattern is not None:
try:
regex.compile(self.pattern)
except regex.error as exc:
raise ValueError(f"pattern is not a valid regular expression: {exc}") from exc

return self


Expand Down Expand Up @@ -2928,3 +3036,31 @@ def validate_references(self) -> WorkflowConfig:
)

return self

@model_validator(mode="after")
def validate_root_level_output_required(self) -> WorkflowConfig:
"""Reject top-level optional output fields on agents and for-each agents.

Object properties may still be optional; the policy only applies to the
root output dict of an agent definition.
"""
for agent in self.agents:
if agent.output:
for field_name, field in agent.output.items():
if not field.required:
raise ValueError(
f"Agent '{agent.name}' output field '{field_name}': "
"root-level output fields cannot be optional "
"(required: false is only allowed inside object properties)"
)
for for_each_group in self.for_each:
agent = for_each_group.agent
if agent.output:
for field_name, field in agent.output.items():
if not field.required:
raise ValueError(
f"Agent '{agent.name}' output field '{field_name}': "
"root-level output fields cannot be optional "
"(required: false is only allowed inside object properties)"
)
return self
Loading