-
Notifications
You must be signed in to change notification settings - Fork 46
feat: add output field constraints (enum, pattern, range, length, optional, nullable) #372
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2b6f1d5
f073c95
bc69065
98fc9c5
bf38c8a
f892256
cc3b39e
de4adee
d407dc0
c169c6e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 }}" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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.""" | ||
|
|
@@ -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.""" | ||
|
|
||
|
|
@@ -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 | ||
| """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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Resolved together with the |
||
|
|
||
| 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 | ||
|
|
||
|
|
||
|
|
@@ -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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OutputFieldhas nomodel_config, so unknown keys are accepted and ignored.OutputField.model_validate({"type": "string", "minlength": 5})loads happily and the field ships unconstrained, withconductor validatereporting 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,ParallelGroupand the other models in this file already setextra="forbid".Worth flagging that
aca.pyrelies on the current tolerance for forward compatibility, so this needs checking against the runner before it lands.There was a problem hiding this comment.
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
OutputFieldclass 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.