Skip to content

Commit 84e5460

Browse files
anticomputerCopilot
andcommitted
feat(grammar): use JSON Schema for typed outputs instead of a bespoke DSL
Replace the hand-rolled `outputs` type mini-language with standard JSON Schema (Draft 2020-12), validated by the already-vendored `jsonschema` library. The `outputs` field is now an inline JSON Schema, giving enums, numeric/string constraints, unions, objects with dynamic keys, `$ref` reuse, and top-level non-object contracts, and it deletes the custom schema compiler and its reserved-key parsing footguns. Validation is strict and does not coerce: a produced value whose types do not already match the contract is a failure (a violation is a hard failure for a single-model task, and a failed branch under the completion policy for a multi-model task), which surfaces malformed model output rather than silently reshaping it. The schema is checked for well-formedness at load time. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ef57551 commit 84e5460

10 files changed

Lines changed: 243 additions & 244 deletions

File tree

doc/GRAMMAR.md

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -409,9 +409,11 @@ Three fields drive this:
409409

410410
- `id` names a task. Its produced value (its final tool result) is exposed to
411411
later tasks as `outputs.<id>`.
412-
- `outputs` declares an inline schema. When present, the task's value is
413-
validated and coerced against it before being stored. A malformed schema is
414-
rejected when the taskflow is loaded, before any model calls are made.
412+
- `outputs` declares an inline JSON Schema (Draft 2020-12). When present, the
413+
task's value is validated against it before being stored. Validation is strict
414+
and does not coerce, so a value whose types do not match the contract is a
415+
failure. A malformed schema is rejected when the taskflow is loaded, before
416+
any model calls are made.
415417
- `over` is an explicit iterable selector for `repeat_prompt`: a Jinja
416418
expression evaluated against the template context (so it yields a real list,
417419
not a re-parsed string).
@@ -425,11 +427,17 @@ Example: one task produces a typed list of functions, the next analyses each.
425427
user_prompt: |
426428
List all functions as JSON: {"functions": [{"name": ..., "body": ...}]}
427429
outputs:
428-
functions:
429-
type: list
430-
items:
431-
name: str
432-
body: str
430+
type: object
431+
properties:
432+
functions:
433+
type: array
434+
items:
435+
type: object
436+
properties:
437+
name: {type: string}
438+
body: {type: string}
439+
required: [name, body]
440+
required: [functions]
433441
- task:
434442
repeat_prompt: true
435443
over: "outputs.list_functions.functions"
@@ -439,17 +447,20 @@ Example: one task produces a typed list of functions, the next analyses each.
439447
{{ result.body }}
440448
```
441449

442-
The `outputs` schema is a mapping of field name to type spec:
450+
The `outputs` schema is a standard JSON Schema (Draft 2020-12), authored inline
451+
in YAML, so the full vocabulary is available:
443452

444-
- Scalars (as strings): `str`, `int`, `float`, `bool`, `any` (plus synonyms
445-
`string`/`integer`/`number`/`boolean`). A trailing `?` marks a field
446-
optional, e.g. `note: str?`.
447-
- Lists (as strings): `list` (list of any) or `list[T]` for a scalar `T`, e.g.
448-
`tags: list[str]`.
449-
- Nested objects: a mapping whose keys are sub-field specs, or the explicit
450-
`{type: object, fields: {...}}` form.
451-
- Lists of objects: `{type: list, items: <spec>}`, where `items` is any spec
452-
(a scalar, a nested object mapping, etc.).
453+
- Types via `type` (`object`, `array`, `string`, `integer`, `number`,
454+
`boolean`, `null`), with `properties`/`required` for objects and `items` for
455+
arrays.
456+
- `enum`/`const` for fixed value sets, and constraints such as `minimum`,
457+
`maximum`, `minLength`, and `pattern`.
458+
- Objects with dynamic keys via `additionalProperties`, and strictness via
459+
`additionalProperties: false`.
460+
- Unions (`anyOf`/`oneOf`), and `$ref`/`$defs` to reuse a shape across fields.
461+
462+
Because validation is strict (no coercion), have the task emit JSON whose types
463+
already match, e.g. the integer `7` rather than the string `"7"`.
453464

454465
Notes:
455466

examples/taskflows/example_pipeline.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@ taskflow:
2323
run: |
2424
echo '{"findings": ["use-after-free in parser", "overflow in loader"]}'
2525
outputs:
26-
findings: list[str]
26+
type: object
27+
properties:
28+
findings:
29+
type: array
30+
items:
31+
type: string
32+
required: [findings]
2733
- task:
2834
# only triage when the audit found enough findings
2935
if: "outputs.audit.findings | length >= globals.min_findings"

examples/taskflows/example_typed_outputs.yaml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,16 @@ taskflow:
1717
# a shell task produces the structured object deterministically
1818
run: |
1919
echo '{"items": ["apples", "bananas", "oranges"]}'
20-
# validate/coerce the produced value against this schema before it is
20+
# validate the produced value against this JSON Schema before it is
2121
# stored as outputs.fruit_list
2222
outputs:
23-
items: list[str]
23+
type: object
24+
properties:
25+
items:
26+
type: array
27+
items:
28+
type: string
29+
required: [items]
2430
- task:
2531
repeat_prompt: true
2632
# iterate the typed output by name (no double-JSON heuristic)

src/seclab_taskflow_agent/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,10 @@ def _validate_outputs(self) -> TaskDefinition:
201201
# Fail fast on a malformed outputs schema at load time, before any
202202
# model calls are made.
203203
if self.outputs:
204-
from .output_schema import OutputSchemaError, build_output_model
204+
from .output_schema import OutputSchemaError, validate_output_schema
205205

206206
try:
207-
build_output_model(f"{self.id or self.name or 'task'}_output", self.outputs)
207+
validate_output_schema(self.outputs)
208208
except OutputSchemaError as exc:
209209
raise ValueError(f"invalid 'outputs' schema: {exc}") from exc
210210
if self.over and not self.repeat_prompt:
Lines changed: 52 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,141 +1,77 @@
11
# SPDX-FileCopyrightText: GitHub, Inc.
22
# SPDX-License-Identifier: MIT
33

4-
"""Compile inline ``outputs`` schemas into Pydantic models.
4+
"""Validate task outputs against an inline JSON Schema.
55
6-
A task can declare a typed output contract inline:
6+
A task can declare a typed output contract inline as a JSON Schema (Draft
7+
2020-12), authored directly in YAML:
78
89
.. code-block:: yaml
910
1011
outputs:
11-
functions:
12-
type: list
13-
items:
14-
name: str
15-
body: str
16-
17-
This module turns such a schema into a generated Pydantic model so a task's
18-
produced value can be validated and coerced before it is passed to downstream
19-
tasks as ``outputs.<id>``.
20-
21-
Supported field type specs:
22-
23-
* Scalars (as strings): ``str``, ``int``, ``float``, ``bool``, ``any`` (plus
24-
the synonyms ``string``/``integer``/``number``/``boolean``). A trailing
25-
``?`` marks the field optional, e.g. ``str?``.
26-
* Lists (as strings): ``list`` (list of any) or ``list[T]`` where ``T`` is a
27-
scalar type name, e.g. ``list[str]``.
28-
* Nested objects (as a mapping): a mapping whose keys are sub-field specs, or
29-
the explicit ``{type: object, fields: {...}}`` form.
30-
* Lists of objects (as a mapping): ``{type: list, items: <spec>}`` where
31-
``items`` is any spec (a scalar string, a nested object mapping, etc.).
12+
type: object
13+
properties:
14+
findings:
15+
type: array
16+
items:
17+
type: object
18+
properties:
19+
file: {type: string}
20+
severity: {type: string, enum: [low, medium, high]}
21+
required: [file, severity]
22+
required: [findings]
23+
24+
The task's produced value is validated against the schema before it is passed
25+
to downstream tasks as ``outputs.<id>``. Using JSON Schema (validated with the
26+
``jsonschema`` library) rather than a bespoke type language gives enums,
27+
numeric/string constraints, unions, objects with dynamic keys, and ``$ref``
28+
reuse for free, and the schema itself is checked for well-formedness when the
29+
taskflow loads.
3230
"""
3331

3432
from __future__ import annotations
3533

36-
__all__ = ["OutputSchemaError", "build_output_model", "validate_output"]
34+
__all__ = ["OutputSchemaError", "validate_output", "validate_output_schema"]
3735

38-
from typing import Any, Optional
36+
from typing import Any
3937

40-
from pydantic import BaseModel, ConfigDict, create_model
41-
42-
_SCALARS: dict[str, Any] = {
43-
"str": str,
44-
"string": str,
45-
"int": int,
46-
"integer": int,
47-
"float": float,
48-
"number": float,
49-
"bool": bool,
50-
"boolean": bool,
51-
"any": Any,
52-
}
38+
from jsonschema import Draft202012Validator
39+
from jsonschema.exceptions import SchemaError
5340

5441

5542
class OutputSchemaError(ValueError):
56-
"""Raised when an ``outputs`` schema is malformed or unsupported."""
57-
58-
59-
def _scalar_type(name: str, field: str) -> Any:
60-
key = name.strip().lower()
61-
if key not in _SCALARS:
62-
raise OutputSchemaError(f"unsupported type {name!r} in field {field!r}")
63-
return _SCALARS[key]
64-
65-
66-
def _spec_to_type(field: str, spec: Any, model_name: str) -> tuple[Any, bool]:
67-
"""Resolve a field spec into ``(python_type, optional)``."""
68-
if isinstance(spec, str):
69-
s = spec.strip()
70-
optional = s.endswith("?")
71-
if optional:
72-
s = s[:-1].strip()
73-
low = s.lower()
74-
if low == "list":
75-
return list, optional
76-
if low.startswith("list[") and low.endswith("]"):
77-
inner = s[5:-1].strip()
78-
return list[_scalar_type(inner, field)], optional
79-
return _scalar_type(s, field), optional
80-
81-
if isinstance(spec, dict):
82-
optional = bool(spec.get("optional", False))
83-
declared = spec.get("type")
84-
if declared is None:
85-
# Nested object shorthand: the mapping's keys are sub-fields.
86-
return _build_model(f"{model_name}_{field}", spec), optional
87-
declared = str(declared).lower()
88-
if declared == "list":
89-
items = spec.get("items", "any")
90-
inner_type, _ = _spec_to_type(field, items, f"{model_name}_{field}")
91-
return list[inner_type], optional
92-
if declared == "object":
93-
fields = spec.get("fields")
94-
if not isinstance(fields, dict):
95-
raise OutputSchemaError(
96-
f"object field {field!r} requires a 'fields' mapping"
97-
)
98-
return _build_model(f"{model_name}_{field}", fields), optional
99-
# Scalar declared via the ``type`` key, e.g. {type: str}.
100-
return _scalar_type(declared, field), optional
101-
102-
raise OutputSchemaError(f"invalid type spec for field {field!r}: {spec!r}")
103-
104-
105-
def _build_model(name: str, fields: Any) -> type[BaseModel]:
106-
if not isinstance(fields, dict) or not fields:
107-
raise OutputSchemaError(f"schema {name!r} must be a non-empty mapping of fields")
108-
field_defs: dict[str, Any] = {}
109-
for fname, fspec in fields.items():
110-
if not isinstance(fname, str) or not fname.isidentifier():
111-
raise OutputSchemaError(f"invalid field name {fname!r} in schema {name!r}")
112-
py_type, optional = _spec_to_type(fname, fspec, name)
113-
if optional:
114-
field_defs[fname] = (Optional[py_type], None)
115-
else:
116-
field_defs[fname] = (py_type, ...)
117-
return create_model(name, __config__=ConfigDict(extra="allow"), **field_defs)
118-
119-
120-
def build_output_model(name: str, schema: dict[str, Any]) -> type[BaseModel]:
121-
"""Compile an ``outputs`` schema mapping into a Pydantic model class.
43+
"""Raised when an ``outputs`` schema is not a well-formed JSON Schema."""
44+
45+
46+
def validate_output_schema(schema: Any) -> None:
47+
"""Check that *schema* is a well-formed JSON Schema, raising on failure.
48+
49+
Called at taskflow load time so a malformed output contract fails fast,
50+
before any model calls are made.
12251
12352
Raises:
124-
OutputSchemaError: If the schema is malformed or uses an unsupported
125-
type. Callers can use this to validate schemas at load time.
53+
OutputSchemaError: If *schema* is not a non-empty, valid JSON Schema.
12654
"""
127-
return _build_model(name, schema)
55+
if not isinstance(schema, dict) or not schema:
56+
raise OutputSchemaError("'outputs' must be a non-empty JSON Schema object")
57+
try:
58+
Draft202012Validator.check_schema(schema)
59+
except SchemaError as exc:
60+
raise OutputSchemaError(f"invalid 'outputs' schema: {exc.message}") from exc
61+
12862

63+
def validate_output(schema: dict[str, Any], value: Any) -> Any:
64+
"""Validate *value* against the JSON Schema *schema* and return it unchanged.
12965
130-
def validate_output(
131-
schema: dict[str, Any], value: Any, model_name: str = "TaskOutput"
132-
) -> dict[str, Any]:
133-
"""Validate/coerce *value* against *schema*, returning a plain dict.
66+
JSON Schema validation does not coerce, so a value whose types do not
67+
already match the contract is a failure. This is deliberately stricter than
68+
permissive coercion: it surfaces malformed model output instead of silently
69+
reshaping it.
13470
13571
Raises:
136-
OutputSchemaError: If the schema itself is invalid.
137-
pydantic.ValidationError: If *value* does not satisfy the schema.
72+
OutputSchemaError: If *schema* itself is malformed.
73+
jsonschema.ValidationError: If *value* does not satisfy *schema*.
13874
"""
139-
model = build_output_model(model_name, schema)
140-
obj = model.model_validate(value)
141-
return obj.model_dump()
75+
validate_output_schema(schema)
76+
Draft202012Validator(schema).validate(value)
77+
return value

src/seclab_taskflow_agent/runner.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from typing import TYPE_CHECKING, Any
2929

3030
import jinja2
31-
from pydantic import ValidationError
31+
from jsonschema.exceptions import ValidationError
3232

3333
from ._stream import drive_backend_stream
3434
from ._watchdog import start_watchdog, watchdog_ping
@@ -272,13 +272,12 @@ def _aggregate_fanin(branches: list[_Branch]) -> list[dict[str, Any]]:
272272
def _capture_branch_output(
273273
branch: _Branch,
274274
schema: dict[str, Any],
275-
output_id: str,
276275
*,
277276
agent_ok: bool,
278277
) -> bool:
279278
"""Validate one multi-model branch's result against the task's outputs schema.
280279
281-
Stores the coerced value on ``branch.output`` (``None`` when the branch
280+
Stores the validated value on ``branch.output`` (``None`` when the branch
282281
failed to run or its result violates the contract) and returns whether the
283282
branch both ran and produced a schema-valid result. A contract violation is
284283
therefore surfaced as a failed branch, which the task's ``completion`` policy
@@ -299,7 +298,7 @@ def _capture_branch_output(
299298
except ValueError:
300299
value = branch.sink[-1].text
301300
try:
302-
branch.output = validate_output(schema, value, model_name=f"{output_id}_output")
301+
branch.output = validate_output(schema, value)
303302
return True
304303
except (ValidationError, ValueError) as exc:
305304
logging.error(
@@ -392,7 +391,7 @@ def _capture_task_output(
392391

393392
if schema:
394393
value = decode_tool_result(last)
395-
validated = validate_output(schema, value, model_name=f"{output_id}_output")
394+
validated = validate_output(schema, value)
396395
store.set_output(output_id, validated)
397396
else:
398397
try:
@@ -1158,12 +1157,11 @@ async def _branch_record(tr: ToolResult) -> None:
11581157
# result against the task's `outputs` schema. A missing
11591158
# or schema-violating result makes it a failed branch,
11601159
# folded into the completion policy alongside run
1161-
# failures; the coerced value feeds fan-in.
1160+
# failures; the validated value feeds fan-in.
11621161
if multi_model and task_output_schema:
11631162
return _capture_branch_output(
11641163
branch,
11651164
task_output_schema,
1166-
task_output_id or task_name,
11671165
agent_ok=branch_ok,
11681166
)
11691167
return branch_ok

0 commit comments

Comments
 (0)