@@ -243,6 +243,42 @@ class _Branch:
243243 output_set : bool = False
244244
245245
246+ # ---------------------------------------------------------------------------
247+ # Task result/output capture model (unified across repeat_prompt + multi-model)
248+ #
249+ # Every prompt task fans out into one or more branches: the cross product of
250+ # the prompts to run (one for a plain task, N for ``repeat_prompt``) and the
251+ # resolved models (one for a single-model task, M for a multi-model task). Each
252+ # branch captures its own tool results into an isolated ``_Branch.sink`` (never
253+ # the shared store), so concurrent branches never interleave and capture is the
254+ # same shape regardless of which axis (items, models, or both) fanned out.
255+ #
256+ # After the branches run, the runner does two things:
257+ #
258+ # 1. Carry-over projection. For single-model tasks it replays the branch sinks
259+ # into the shared run ``store`` in branch order, so the legacy implicit
260+ # last-tool-result carry-over, the run-level result list, and the session
261+ # snapshot behave exactly as before (and deterministically, even for async
262+ # ``repeat_prompt``). Multi-model tasks are deliberately excluded: there is
263+ # no well-defined "last" result across models, so their results are consumed
264+ # by name via ``id``/``over``, not the implicit channel.
265+ #
266+ # 2. Named-output capture (``outputs.<id>``), by whether the task fanned out:
267+ # - does NOT fan out (plain single-model task) -> the single decoded value
268+ # (validated against the ``outputs`` schema here; a violation is a hard
269+ # task failure). See :func:`_capture_task_output`.
270+ # - fans out (``repeat_prompt`` or multiple models) -> the per-branch
271+ # fan-in list ``[{model, item, result}]`` (each ``result`` validated
272+ # per branch during the run; a violation is a failed branch reduced by
273+ # the ``completion`` policy). See :func:`_aggregate_fanin` /
274+ # :func:`_capture_branch_output`.
275+ #
276+ # "Fans out" is structural (``repeat_prompt or multi_model``), not a runtime
277+ # count, so a ``repeat_prompt`` over one item is still a one-element list rather
278+ # than surprising the author with a scalar.
279+ # ---------------------------------------------------------------------------
280+
281+
246282def _aggregate_fanin (branches : list [_Branch ]) -> list [dict [str , Any ]]:
247283 """Aggregate each branch's final tool result into fan-in records.
248284
@@ -980,6 +1016,13 @@ async def on_handoff_hook(context: RunContextWrapper[TContext], agent: Agent[TCo
9801016 toolboxes_override = task .toolboxes or []
9811017 env = task .env or {}
9821018 repeat_prompt = task .repeat_prompt
1019+ # A task "fans out" when it runs more than one branch by intent:
1020+ # a repeat_prompt (over items) or a multi-model task (over models),
1021+ # or their cross product. This drives the unified output-capture
1022+ # model (see the capture design note above ``_aggregate_fanin``):
1023+ # a task that fans out yields a per-branch fan-in list, one that
1024+ # does not yields a single value.
1025+ fans_out = multi_model or repeat_prompt
9831026 exclude_from_context = task .exclude_from_context
9841027 async_task = task .async_task
9851028 max_concurrent_tasks = task .async_limit
@@ -1053,6 +1096,11 @@ async def on_handoff_hook(context: RunContextWrapper[TContext], agent: Agent[TCo
10531096 outputs = store .outputs , over = over ,
10541097 )
10551098
1099+ # run_prompts hands the branches it built back to the task loop
1100+ # (via this closure var) so projection + output capture happen
1101+ # once, after the retry loop, rather than per attempt.
1102+ captured_branches : list [_Branch ] = []
1103+
10561104 async def run_prompts (async_task : bool = False , max_concurrent_tasks : int = 5 ) -> bool :
10571105 if run :
10581106 await render_model_output ("** 🤖🐚 Executing Shell Task\n " )
@@ -1112,31 +1160,26 @@ async def run_prompts(async_task: bool = False, max_concurrent_tasks: int = 5) -
11121160 concurrency = model_concurrency if multi_model else max_concurrent_tasks
11131161
11141162 async def _deploy (branch : _Branch , rm : ResolvedModel ) -> bool :
1115- # Multi-model branches capture tool results into their
1116- # own private sink (never the shared store), keeping
1117- # concurrent branches isolated and enabling fan-in.
1118- # Single-model runs keep the shared sink for backwards
1119- # compatibility.
1120- if multi_model :
1121- async def _branch_tool_end (context , agent , tool , result ) -> None : # noqa: ANN001
1122- watchdog_ping ()
1123- branch .sink .append (
1124- normalize_openai_tool_output (result , tool_name = getattr (tool , "name" , "" ))
1125- )
1126-
1127- async def _branch_record (tr : ToolResult ) -> None :
1128- watchdog_ping ()
1129- branch .sink .append (tr )
1130-
1131- run_hooks = TaskRunHooks (
1132- on_tool_end = _branch_tool_end , on_tool_start = on_tool_start_hook
1133- )
1134- branch_record = _branch_record
1135- else :
1136- run_hooks = TaskRunHooks (
1137- on_tool_end = on_tool_end_hook , on_tool_start = on_tool_start_hook
1163+ # Every branch captures its tool results into its own
1164+ # isolated sink (never the shared store) so concurrent
1165+ # branches never interleave and capture is uniform across
1166+ # repeat_prompt and multi-model. Single-model results are
1167+ # projected back into the shared store after the fan-out
1168+ # (see the projection step in the task loop).
1169+ async def _branch_tool_end (context , agent , tool , result ) -> None : # noqa: ANN001
1170+ watchdog_ping ()
1171+ branch .sink .append (
1172+ normalize_openai_tool_output (result , tool_name = getattr (tool , "name" , "" ))
11381173 )
1139- branch_record = record_tool_result
1174+
1175+ async def _branch_record (tr : ToolResult ) -> None :
1176+ watchdog_ping ()
1177+ branch .sink .append (tr )
1178+
1179+ run_hooks = TaskRunHooks (
1180+ on_tool_end = _branch_tool_end , on_tool_start = on_tool_start_hook
1181+ )
1182+ branch_record = _branch_record
11401183 branch_ok = await deploy_task_agents (
11411184 available_tools ,
11421185 branch .agents ,
@@ -1159,12 +1202,15 @@ async def _branch_record(tr: ToolResult) -> None:
11591202 agent_hooks = TaskAgentHooks (on_handoff = on_handoff_hook ),
11601203 stream_label = branch .label if multi_model else None ,
11611204 )
1162- # Typed multi-model output: validate this branch's
1163- # result against the task's `outputs` schema. A missing
1164- # or schema-violating result makes it a failed branch,
1205+ # Typed fan-out output: when the task fans out (repeat
1206+ # and/or multi-model) and declares an `outputs` schema,
1207+ # validate this branch's result against it. A missing or
1208+ # schema-violating result makes it a failed branch,
11651209 # folded into the completion policy alongside run
1166- # failures; the validated value feeds fan-in.
1167- if multi_model and task_output_schema :
1210+ # failures; the validated value feeds fan-in. (A plain,
1211+ # non-fan-out task validates its single value later, in
1212+ # the capture step.)
1213+ if fans_out and task_output_schema :
11681214 return _capture_branch_output (
11691215 branch ,
11701216 task_output_schema ,
@@ -1181,11 +1227,10 @@ async def _branch_record(tr: ToolResult) -> None:
11811227 completion_policy = completion_policy ,
11821228 )
11831229
1184- # Fan-in: expose each branch's final result as a named list
1185- # for downstream tasks (multi-model / cross-product only;
1186- # single-model typed-output capture happens in run_main).
1187- if multi_model and task_output_id :
1188- store .set_output (task_output_id , _aggregate_fanin (branches ))
1230+ # Hand the branches back to the task loop for projection +
1231+ # output capture (done once, after the retry loop).
1232+ nonlocal captured_branches
1233+ captured_branches = branches
11891234
11901235 return complete
11911236
@@ -1252,27 +1297,43 @@ async def _branch_record(tr: ToolResult) -> None:
12521297 # --resume.
12531298 raise RuntimeError (f"Required task { task_name !r} did not complete" )
12541299
1255- # Capture this task's typed named output (M2). The task's
1256- # output is its final tool result; when an ``outputs`` schema
1257- # is declared it is validated before being stored under
1258- # ``outputs.<id>`` for downstream tasks to consume by name. A
1259- # declared output contract that the produced value violates is
1260- # a hard failure, surfaced with the same session-saved / resume
1261- # messaging as other task failures.
1262- if task_output_id and not multi_model :
1263- try :
1264- _capture_task_output (
1265- store , task_output_id , task_output_schema , task_name
1266- )
1267- except Exception as exc :
1268- logging .error ("Task %r output capture failed: %s" , task_name , exc )
1269- session .mark_failed (f"Task { task_name !r} output capture failed: { exc } " )
1270- await render_model_output (
1271- f"** 🤖❗ Output capture failed: { exc } \n "
1272- f"** 🤖💾 Session saved: { session .session_id } \n "
1273- f"** 🤖💡 Resume with: --resume { session .session_id } \n "
1274- )
1275- raise
1300+ # Projection: replay this task's per-branch tool results into the
1301+ # shared run store (single-model only) so the implicit
1302+ # last-tool-result carry-over, the run-level result list, and the
1303+ # session snapshot behave as before, and deterministically in
1304+ # branch order even for async repeat_prompt. Multi-model tasks
1305+ # are excluded on purpose (no well-defined "last" across models;
1306+ # consume them by name via id/over). Shell tasks have no branches
1307+ # and already wrote their result to the store directly.
1308+ if not multi_model :
1309+ for _branch in captured_branches :
1310+ for _tr in _branch .sink :
1311+ store .record (_tr )
1312+
1313+ # Capture this task's typed named output, uniform across single-
1314+ # and multi-model (see the capture design note above
1315+ # ``_aggregate_fanin``). A task that fans out (repeat_prompt or
1316+ # multiple models) publishes the per-branch fan-in list; a plain
1317+ # task publishes its single value, validated against the
1318+ # ``outputs`` schema here (a violation is a hard failure, with
1319+ # the same session-saved / resume messaging as other failures).
1320+ if task_output_id :
1321+ if fans_out :
1322+ store .set_output (task_output_id , _aggregate_fanin (captured_branches ))
1323+ else :
1324+ try :
1325+ _capture_task_output (
1326+ store , task_output_id , task_output_schema , task_name
1327+ )
1328+ except Exception as exc :
1329+ logging .error ("Task %r output capture failed: %s" , task_name , exc )
1330+ session .mark_failed (f"Task { task_name !r} output capture failed: { exc } " )
1331+ await render_model_output (
1332+ f"** 🤖❗ Output capture failed: { exc } \n "
1333+ f"** 🤖💾 Session saved: { session .session_id } \n "
1334+ f"** 🤖💡 Resume with: --resume { session .session_id } \n "
1335+ )
1336+ raise
12761337
12771338 # Checkpoint after task (must_complete failures break above
12781339 # without advancing the resume cursor)
0 commit comments