Skip to content

Commit ef57551

Browse files
anticomputerCopilot
andcommitted
feat(grammar): apply outputs schema per branch on multi-model tasks
Allow a typed `outputs` schema on multi-model tasks. Each branch's result is validated and coerced against the schema and stored as the `result` of its fan-in record. A branch whose result violates the schema is treated as a failed branch, so the task's `completion` policy (all/any) reduces it like any other branch failure. Removes the prior load-time restriction that rejected `outputs` on multi-model tasks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 42767f3 commit ef57551

5 files changed

Lines changed: 148 additions & 21 deletions

File tree

doc/GRAMMAR.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,11 @@ Notes and semantics:
177177
To consume multi-model results downstream, give the task an `id`: each
178178
branch's final result is aggregated into `outputs.<id>` as a list of records
179179
`{"model": ..., "item": ..., "result": ...}` (see "Typed named outputs").
180-
- The inline `outputs` schema is not yet supported on multi-model tasks (the
181-
aggregate is a list of records); use `id` for fan-in.
180+
- The inline `outputs` schema is applied per branch on multi-model tasks: each
181+
branch's result is validated/coerced against the schema and stored as the
182+
`result` of its fan-in record. A branch whose result violates the schema is
183+
treated as a failed branch, which the `completion` policy (`all`/`any`) then
184+
reduces like any other branch failure (see "Typed named outputs").
182185

183186
### Completion Requirement
184187

@@ -456,8 +459,10 @@ Notes:
456459
- Without `id`/`outputs`/`over`, the implicit last-tool-result `repeat_prompt`
457460
behaviour is unchanged.
458461
- On multi-model tasks, `id` fans in per-branch results as a list of
459-
`{model, item, result}` records (see "Multiple Models"); the inline `outputs`
460-
schema is not yet supported there.
462+
`{model, item, result}` records (see "Multiple Models"). When an `outputs`
463+
schema is present it is applied per branch: each `result` is the validated
464+
value, and a branch that violates the schema is treated as a failed branch
465+
under the task's `completion` policy.
461466

462467
### Toolboxes / MCP Servers
463468

src/seclab_taskflow_agent/models.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -207,11 +207,6 @@ def _validate_outputs(self) -> TaskDefinition:
207207
build_output_model(f"{self.id or self.name or 'task'}_output", self.outputs)
208208
except OutputSchemaError as exc:
209209
raise ValueError(f"invalid 'outputs' schema: {exc}") from exc
210-
if len(self.models) > 1:
211-
raise ValueError(
212-
"'outputs' schema is not yet supported on multi-model tasks; "
213-
"use 'id' to fan-in per-model results as a list"
214-
)
215210
if self.over and not self.repeat_prompt:
216211
raise ValueError("'over' only applies to repeat_prompt tasks")
217212
return self

src/seclab_taskflow_agent/runner.py

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

3030
import jinja2
31+
from pydantic import ValidationError
3132

3233
from ._stream import drive_backend_stream
3334
from ._watchdog import start_watchdog, watchdog_ping
@@ -233,6 +234,13 @@ class _Branch:
233234
item_index: int
234235
label: str
235236
sink: list[ToolResult] = field(default_factory=list)
237+
# Typed multi-model output: when the task declares an ``outputs`` schema,
238+
# each branch's result is validated and the coerced value stored here
239+
# (``None`` when the branch failed or violated the schema). ``output_set``
240+
# marks that validation ran so fan-in uses this value instead of decoding
241+
# the raw sink.
242+
output: Any = None
243+
output_set: bool = False
236244

237245

238246
def _aggregate_fanin(branches: list[_Branch]) -> list[dict[str, Any]]:
@@ -241,21 +249,67 @@ def _aggregate_fanin(branches: list[_Branch]) -> list[dict[str, Any]]:
241249
Produces one record per branch: ``{"model": <label>, "item": <index>,
242250
"result": <value>}``. ``result`` is the decoded final tool result of the
243251
branch (its raw text when not JSON, or ``None`` when the branch produced no
244-
tool result). Used to expose multi-model / cross-product outputs to later
245-
tasks as ``outputs.<id>``.
252+
tool result). When the task declares an ``outputs`` schema, ``result`` is
253+
the per-branch validated/coerced value instead (``None`` for a branch that
254+
failed or violated the schema). Used to expose multi-model / cross-product
255+
outputs to later tasks as ``outputs.<id>``.
246256
"""
247257
records: list[dict[str, Any]] = []
248258
for branch in branches:
249-
value: Any = None
250-
if branch.sink:
251-
try:
252-
value = decode_tool_result(branch.sink[-1])
253-
except ValueError:
254-
value = branch.sink[-1].text
259+
if branch.output_set:
260+
value: Any = branch.output
261+
else:
262+
value = None
263+
if branch.sink:
264+
try:
265+
value = decode_tool_result(branch.sink[-1])
266+
except ValueError:
267+
value = branch.sink[-1].text
255268
records.append({"model": branch.rm.label, "item": branch.item_index, "result": value})
256269
return records
257270

258271

272+
def _capture_branch_output(
273+
branch: _Branch,
274+
schema: dict[str, Any],
275+
output_id: str,
276+
*,
277+
agent_ok: bool,
278+
) -> bool:
279+
"""Validate one multi-model branch's result against the task's outputs schema.
280+
281+
Stores the coerced value on ``branch.output`` (``None`` when the branch
282+
failed to run or its result violates the contract) and returns whether the
283+
branch both ran and produced a schema-valid result. A contract violation is
284+
therefore surfaced as a failed branch, which the task's ``completion`` policy
285+
(``all`` / ``any``) reduces like any other branch failure.
286+
"""
287+
branch.output_set = True
288+
branch.output = None
289+
if not agent_ok:
290+
return False
291+
if not branch.sink:
292+
logging.error(
293+
"Multi-model branch %r produced no result to validate against 'outputs'",
294+
branch.label,
295+
)
296+
return False
297+
try:
298+
value = decode_tool_result(branch.sink[-1])
299+
except ValueError:
300+
value = branch.sink[-1].text
301+
try:
302+
branch.output = validate_output(schema, value, model_name=f"{output_id}_output")
303+
return True
304+
except (ValidationError, ValueError) as exc:
305+
logging.error(
306+
"Multi-model branch %r output violated 'outputs' schema: %s",
307+
branch.label,
308+
exc,
309+
)
310+
return False
311+
312+
259313
def _resolve_task_models(
260314
task: TaskDefinition,
261315
model_keys: list[str],
@@ -1078,7 +1132,7 @@ async def _branch_record(tr: ToolResult) -> None:
10781132
on_tool_end=on_tool_end_hook, on_tool_start=on_tool_start_hook
10791133
)
10801134
branch_record = record_tool_result
1081-
return await deploy_task_agents(
1135+
branch_ok = await deploy_task_agents(
10821136
available_tools,
10831137
branch.agents,
10841138
branch.prompt,
@@ -1100,6 +1154,19 @@ async def _branch_record(tr: ToolResult) -> None:
11001154
agent_hooks=TaskAgentHooks(on_handoff=on_handoff_hook),
11011155
stream_label=branch.label if multi_model else None,
11021156
)
1157+
# Typed multi-model output: validate this branch's
1158+
# result against the task's `outputs` schema. A missing
1159+
# or schema-violating result makes it a failed branch,
1160+
# folded into the completion policy alongside run
1161+
# failures; the coerced value feeds fan-in.
1162+
if multi_model and task_output_schema:
1163+
return _capture_branch_output(
1164+
branch,
1165+
task_output_schema,
1166+
task_output_id or task_name,
1167+
agent_ok=branch_ok,
1168+
)
1169+
return branch_ok
11031170

11041171
work_items = [(branch, branch.rm) for branch in branches]
11051172
complete = await _fan_out_deploys(

tests/test_models.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,12 @@ def test_over_allowed_on_multi_model_repeat(self):
220220
)
221221
assert t.over == "outputs.x.items"
222222

223-
def test_outputs_schema_rejected_on_multi_model(self):
224-
with pytest.raises(ValidationError, match="not yet supported on multi-model"):
225-
TaskDefinition(user_prompt="hi", models=["a", "b"], outputs={"f": "str"})
223+
def test_outputs_schema_allowed_on_multi_model(self):
224+
# Typed outputs are now supported on multi-model tasks: the schema is
225+
# applied per branch (see runner fan-in), so construction must succeed.
226+
t = TaskDefinition(user_prompt="hi", models=["a", "b"], outputs={"f": "str"})
227+
assert t.outputs == {"f": "str"}
228+
assert [e.model for e in t.models] == ["a", "b"]
226229

227230

228231
class TestTaskflowDocument:

tests/test_runner_integration.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,3 +354,60 @@ def test_upstream_skip_then_downstream_if_still_completes(self, monkeypatch, tmp
354354
data = _session_data(tmp_path)
355355
assert data["finished"] is True
356356
assert all(t["skipped"] for t in data["completed_tasks"])
357+
358+
359+
class TestTypedMultiModelOutputs:
360+
"""Typed `outputs` schema applied per branch on multi-model tasks."""
361+
362+
@staticmethod
363+
def _valid_deploy():
364+
async def _impl(available_tools, agents, prompt, *, model=None, record_tool_result=None, **kw):
365+
if record_tool_result is not None:
366+
await record_tool_result(ToolResult(text=json.dumps({"score": 7})))
367+
return True
368+
369+
return _impl
370+
371+
@staticmethod
372+
def _mixed_deploy():
373+
# m1 satisfies the schema; every other model violates it (missing `score`).
374+
async def _impl(available_tools, agents, prompt, *, model=None, record_tool_result=None, **kw):
375+
if record_tool_result is not None:
376+
payload = {"score": 1} if model == "m1" else {"wrong": "x"}
377+
await record_tool_result(ToolResult(text=json.dumps(payload)))
378+
return True
379+
380+
return _impl
381+
382+
def test_typed_fanin_validates_each_branch(self, monkeypatch, tmp_path):
383+
task = TaskDefinition(
384+
id="cmp", agents=["pkg.p"], user_prompt="score X",
385+
models=["m1", "m2"], outputs={"score": "int"},
386+
)
387+
_run(monkeypatch, tmp_path, _taskflow(task), deploy_impl=self._valid_deploy())
388+
records = _session_outputs(tmp_path)["cmp"]
389+
assert [r["model"] for r in records] == ["m1", "m2"]
390+
# Each branch result is the validated/coerced schema value, not the raw envelope.
391+
assert all(r["result"] == {"score": 7} for r in records)
392+
assert _session_data(tmp_path)["completed_tasks"][0]["result"] is True
393+
394+
def test_schema_violation_fails_branch_completion_all(self, monkeypatch, tmp_path):
395+
task = TaskDefinition(
396+
id="cmp", agents=["pkg.p"], user_prompt="score X",
397+
models=["m1", "m2"], outputs={"score": "int"}, completion="all",
398+
)
399+
_run(monkeypatch, tmp_path, _taskflow(task), deploy_impl=self._mixed_deploy())
400+
by_model = {r["model"]: r["result"] for r in _session_outputs(tmp_path)["cmp"]}
401+
assert by_model["m1"] == {"score": 1}
402+
assert by_model["m2"] is None # violating branch stored as None
403+
# completion=all: the invalid branch fails the whole task.
404+
assert _session_data(tmp_path)["completed_tasks"][0]["result"] is False
405+
406+
def test_schema_violation_tolerated_completion_any(self, monkeypatch, tmp_path):
407+
task = TaskDefinition(
408+
id="cmp", agents=["pkg.p"], user_prompt="score X",
409+
models=["m1", "m2"], outputs={"score": "int"}, completion="any",
410+
)
411+
_run(monkeypatch, tmp_path, _taskflow(task), deploy_impl=self._mixed_deploy())
412+
# completion=any: one schema-valid branch is enough for task success.
413+
assert _session_data(tmp_path)["completed_tasks"][0]["result"] is True

0 commit comments

Comments
 (0)