2828from typing import TYPE_CHECKING , Any
2929
3030import jinja2
31+ from pydantic import ValidationError
3132
3233from ._stream import drive_backend_stream
3334from ._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
238246def _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+
259313def _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 (
0 commit comments